mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-27 04:49:49 -07:00
feat(animetosho): swap backend to TsukiHime API (animetosho successor)
Animetosho stops updating May 2026; TsukiHime mirrors its index and attachment storage. Internal identifiers, IPC channels, settings keys, and the openAnimetosho shortcut keep the legacy name so user configs stay valid; user-facing labels now say TsukiHime. - API base https://api.tsukihime.org/v1 (/search/torrents, /torrents/{id}) - attachment type is numeric (1 = subtitle); sizes are no longer reported - storage URLs split: native /attach/ vs /tosho/attach/ for imported entries (flagged animetosho: true or id >= 1e9) - download redirect allowlist covers *.tsukihime.org and *.animetosho.org (the /tosho/ mirror currently 302s to storage.animetosho.org) - search results now expose sublangs; modal shows them per release
This commit is contained in:
@@ -238,6 +238,7 @@ SubMiner builds on the work of these open-source projects:
|
|||||||
| [jellyfin-mpv-shim](https://github.com/jellyfin/jellyfin-mpv-shim) | Jellyfin integration |
|
| [jellyfin-mpv-shim](https://github.com/jellyfin/jellyfin-mpv-shim) | Jellyfin integration |
|
||||||
| [Jimaku.cc](https://jimaku.cc) | Japanese subtitle search and downloads |
|
| [Jimaku.cc](https://jimaku.cc) | Japanese subtitle search and downloads |
|
||||||
| [Renji's Texthooker Page](https://github.com/Renji-XD/texthooker-ui) | Base for the WebSocket texthooker integration |
|
| [Renji's Texthooker Page](https://github.com/Renji-XD/texthooker-ui) | Base for the WebSocket texthooker integration |
|
||||||
|
| [TsukiHime](https://tsukihime.org) | Release-track subtitle search and downloads (Animetosho successor) |
|
||||||
| [Yomitan](https://github.com/yomidevs/yomitan) | Dictionary engine powering all lookups and the morphological parser |
|
| [Yomitan](https://github.com/yomidevs/yomitan) | Dictionary engine powering all lookups and the morphological parser |
|
||||||
| [yomitan-jlpt-vocab](https://github.com/stephenmk/yomitan-jlpt-vocab) | JLPT level tags for vocabulary |
|
| [yomitan-jlpt-vocab](https://github.com/stephenmk/yomitan-jlpt-vocab) | JLPT level tags for vocabulary |
|
||||||
|
|
||||||
|
|||||||
+135
-65
@@ -10,13 +10,21 @@ import {
|
|||||||
buildAnimetoshoAttachmentUrl,
|
buildAnimetoshoAttachmentUrl,
|
||||||
decompressXzFile,
|
decompressXzFile,
|
||||||
extractAnimetoshoSubtitleFiles,
|
extractAnimetoshoSubtitleFiles,
|
||||||
|
isAnimetoshoDownloadUrl,
|
||||||
mapAnimetoshoSearchResults,
|
mapAnimetoshoSearchResults,
|
||||||
} from './utils.js';
|
} from './utils.js';
|
||||||
|
|
||||||
test('buildAnimetoshoAttachmentUrl pads the attachment id to 8 hex digits', () => {
|
test('buildAnimetoshoAttachmentUrl pads the attachment id to 8 hex digits', () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
buildAnimetoshoAttachmentUrl(1955356),
|
buildAnimetoshoAttachmentUrl(96412),
|
||||||
'https://animetosho.org/storage/attach/001dd61c/1955356.xz',
|
'https://storage.tsukihime.org/attach/0001789c/96412.xz',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildAnimetoshoAttachmentUrl uses the tosho mirror path for imported entries', () => {
|
||||||
|
assert.equal(
|
||||||
|
buildAnimetoshoAttachmentUrl(2826332, { imported: true }),
|
||||||
|
'https://storage.tsukihime.org/tosho/attach/002b205c/2826332.xz',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -28,6 +36,13 @@ test('animetoshoLangToFilenameSuffix maps common ISO 639-2 codes to two-letter s
|
|||||||
assert.equal(animetoshoLangToFilenameSuffix('POR'), 'pt');
|
assert.equal(animetoshoLangToFilenameSuffix('POR'), 'pt');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('animetoshoLangToFilenameSuffix handles TsukiHime BCP-47 style codes', () => {
|
||||||
|
assert.equal(animetoshoLangToFilenameSuffix('en-US'), 'en');
|
||||||
|
assert.equal(animetoshoLangToFilenameSuffix('es-419'), 'es');
|
||||||
|
assert.equal(animetoshoLangToFilenameSuffix('zh-Hant'), 'zh');
|
||||||
|
assert.equal(animetoshoLangToFilenameSuffix('ja'), 'ja');
|
||||||
|
});
|
||||||
|
|
||||||
test('animetoshoLangToFilenameSuffix falls back to the raw code, and to en when unknown', () => {
|
test('animetoshoLangToFilenameSuffix falls back to the raw code, and to en when unknown', () => {
|
||||||
assert.equal(animetoshoLangToFilenameSuffix('vie'), 'vie');
|
assert.equal(animetoshoLangToFilenameSuffix('vie'), 'vie');
|
||||||
assert.equal(animetoshoLangToFilenameSuffix(''), 'en');
|
assert.equal(animetoshoLangToFilenameSuffix(''), 'en');
|
||||||
@@ -42,59 +57,88 @@ test('buildAnimetoshoAttachmentUrl rejects non-positive and non-integer ids', ()
|
|||||||
assert.equal(buildAnimetoshoAttachmentUrl(Number.NaN), null);
|
assert.equal(buildAnimetoshoAttachmentUrl(Number.NaN), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('mapAnimetoshoSearchResults maps valid entries and caps to maxResults', () => {
|
test('isAnimetoshoDownloadUrl accepts tsukihime.org and animetosho.org hosts only', () => {
|
||||||
const payload = [
|
assert.equal(isAnimetoshoDownloadUrl('https://storage.tsukihime.org/attach/0001789c/1.xz'), true);
|
||||||
{
|
assert.equal(isAnimetoshoDownloadUrl('https://tsukihime.org/view/1'), true);
|
||||||
id: 606713,
|
// The tosho mirror currently 302s to storage.animetosho.org; the hop must stay allowed.
|
||||||
title: '[SubsPlease] Sousou no Frieren - 28 (1080p) [8BBBC28C].mkv',
|
assert.equal(
|
||||||
timestamp: 1710000000,
|
isAnimetoshoDownloadUrl('https://storage.animetosho.org/attach/002b205c/2826332.xz'),
|
||||||
total_size: 1490354395,
|
true,
|
||||||
num_files: 1,
|
);
|
||||||
},
|
assert.equal(isAnimetoshoDownloadUrl('http://storage.tsukihime.org/attach/1/1.xz'), false);
|
||||||
{ id: 'bogus', title: 'missing numeric id' },
|
assert.equal(isAnimetoshoDownloadUrl('https://eviltsukihime.org/attach/1/1.xz'), false);
|
||||||
{ id: 606714, title: '[Erai-raws] Sousou no Frieren - 28 [1080p].mkv' },
|
assert.equal(isAnimetoshoDownloadUrl('https://example.com/1.xz'), false);
|
||||||
{ id: 606715, title: 'capped away' },
|
assert.equal(isAnimetoshoDownloadUrl('not a url'), false);
|
||||||
];
|
});
|
||||||
|
|
||||||
|
test('mapAnimetoshoSearchResults maps valid results and caps to maxResults', () => {
|
||||||
|
const payload = {
|
||||||
|
total: 4,
|
||||||
|
start: 0,
|
||||||
|
limit: 50,
|
||||||
|
error: false,
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
id: 1000752022,
|
||||||
|
state: 'completed',
|
||||||
|
name: '[SubsPlease] Sousou no Frieren S2 - 10 (1080p) [7D35515E].mkv',
|
||||||
|
totalsize: 1457009569,
|
||||||
|
filecount: 1,
|
||||||
|
sublangs: ['en'],
|
||||||
|
audiolangs: ['ja'],
|
||||||
|
source_date: 1774623761,
|
||||||
|
added_date: 1774624861,
|
||||||
|
animetosho: true,
|
||||||
|
},
|
||||||
|
{ id: 'bogus', name: 'missing numeric id' },
|
||||||
|
{ id: 12255, name: '[DKB] Futsutsuka na Akujo - S01E01 [Multi-Subs]' },
|
||||||
|
{ id: 12256, name: 'capped away' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
const entries = mapAnimetoshoSearchResults(payload, 2);
|
const entries = mapAnimetoshoSearchResults(payload, 2);
|
||||||
assert.equal(entries.length, 2);
|
assert.equal(entries.length, 2);
|
||||||
assert.deepEqual(entries[0], {
|
assert.deepEqual(entries[0], {
|
||||||
id: 606713,
|
id: 1000752022,
|
||||||
title: '[SubsPlease] Sousou no Frieren - 28 (1080p) [8BBBC28C].mkv',
|
title: '[SubsPlease] Sousou no Frieren S2 - 10 (1080p) [7D35515E].mkv',
|
||||||
timestamp: 1710000000,
|
timestamp: 1774624861,
|
||||||
totalSize: 1490354395,
|
totalSize: 1457009569,
|
||||||
numFiles: 1,
|
numFiles: 1,
|
||||||
|
sublangs: ['en'],
|
||||||
});
|
});
|
||||||
assert.equal(entries[1]!.id, 606714);
|
assert.equal(entries[1]!.id, 12255);
|
||||||
assert.equal(entries[1]!.totalSize, null);
|
assert.equal(entries[1]!.totalSize, null);
|
||||||
assert.equal(entries[1]!.numFiles, null);
|
assert.equal(entries[1]!.numFiles, null);
|
||||||
|
assert.deepEqual(entries[1]!.sublangs, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('mapAnimetoshoSearchResults returns empty list for non-array payloads', () => {
|
test('mapAnimetoshoSearchResults returns empty list for malformed payloads', () => {
|
||||||
assert.deepEqual(mapAnimetoshoSearchResults({ error: 'nope' }, 10), []);
|
assert.deepEqual(mapAnimetoshoSearchResults({ error: 'nope' }, 10), []);
|
||||||
|
assert.deepEqual(mapAnimetoshoSearchResults({ results: 'nope' }, 10), []);
|
||||||
assert.deepEqual(mapAnimetoshoSearchResults(null, 10), []);
|
assert.deepEqual(mapAnimetoshoSearchResults(null, 10), []);
|
||||||
|
assert.deepEqual(mapAnimetoshoSearchResults([], 10), []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Trimmed from a real /v1/torrents/{id} response (native entry).
|
||||||
const DETAIL_PAYLOAD = {
|
const DETAIL_PAYLOAD = {
|
||||||
id: 606713,
|
id: 12255,
|
||||||
title: '[SubsPlease] Sousou no Frieren - 28 (1080p) [8BBBC28C].mkv',
|
name: '[DKB] Futsutsuka na Akujo dewa Gozaimasu ga - S01E01 [Multi-Subs]',
|
||||||
files: [
|
files: [
|
||||||
{
|
{
|
||||||
id: 1151711,
|
id: 16179,
|
||||||
filename: '[SubsPlease] Sousou no Frieren - 28 (1080p) [8BBBC28C].mkv',
|
filename: '[DKB] Futsutsuka na Akujo dewa Gozaimasu ga - S01E01 [Multi-Subs].mkv',
|
||||||
attachments: [
|
attachments: [
|
||||||
{
|
{
|
||||||
id: 1955355,
|
id: 96400,
|
||||||
type: 'font',
|
type: 0,
|
||||||
info: { name: 'arial.ttf' },
|
info: { name: 'arial.ttf', mime: 'application/x-truetype-font' },
|
||||||
size: 300000,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 1955356,
|
id: 96412,
|
||||||
type: 'subtitle',
|
type: 1,
|
||||||
info: { codec: 'ASS', lang: 'eng', name: 'English subs', trackid: 2 },
|
info: { codec: 'ASS', lang: 'en-US', name: 'English subs', trackid: 2, tracknum: 3 },
|
||||||
size: 33075,
|
|
||||||
},
|
},
|
||||||
|
{ id: 96499, type: 3 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -104,25 +148,64 @@ test('extractAnimetoshoSubtitleFiles keeps only text subtitle attachments with d
|
|||||||
const files = extractAnimetoshoSubtitleFiles(DETAIL_PAYLOAD);
|
const files = extractAnimetoshoSubtitleFiles(DETAIL_PAYLOAD);
|
||||||
assert.equal(files.length, 1);
|
assert.equal(files.length, 1);
|
||||||
const file = files[0]!;
|
const file = files[0]!;
|
||||||
assert.equal(file.attachmentId, 1955356);
|
assert.equal(file.attachmentId, 96412);
|
||||||
assert.equal(file.lang, 'eng');
|
assert.equal(file.lang, 'en-us');
|
||||||
assert.equal(file.trackName, 'English subs');
|
assert.equal(file.trackName, 'English subs');
|
||||||
assert.equal(file.size, 33075);
|
assert.equal(file.size, 0);
|
||||||
assert.equal(file.url, 'https://animetosho.org/storage/attach/001dd61c/1955356.xz');
|
assert.equal(file.url, 'https://storage.tsukihime.org/attach/0001789c/96412.xz');
|
||||||
assert.equal(file.sourceFilename, '[SubsPlease] Sousou no Frieren - 28 (1080p) [8BBBC28C].mkv');
|
assert.equal(
|
||||||
assert.equal(file.filename, '[SubsPlease] Sousou no Frieren - 28 (1080p) [8BBBC28C].eng.ass');
|
file.sourceFilename,
|
||||||
|
'[DKB] Futsutsuka na Akujo dewa Gozaimasu ga - S01E01 [Multi-Subs].mkv',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
file.filename,
|
||||||
|
'[DKB] Futsutsuka na Akujo dewa Gozaimasu ga - S01E01 [Multi-Subs].en-us.ass',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extractAnimetoshoSubtitleFiles uses the tosho mirror for animetosho-imported entries', () => {
|
||||||
|
const files = extractAnimetoshoSubtitleFiles({
|
||||||
|
id: 1000752022,
|
||||||
|
animetosho: true,
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
id: 1001361385,
|
||||||
|
filename: '[SubsPlease] Sousou no Frieren S2 - 10 (1080p) [7D35515E].mkv',
|
||||||
|
attachments: [
|
||||||
|
{ id: 2826332, type: 1, info: { codec: 'ASS', lang: 'en', name: 'English subs' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
assert.equal(files.length, 1);
|
||||||
|
assert.equal(files[0]!.url, 'https://storage.tsukihime.org/tosho/attach/002b205c/2826332.xz');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extractAnimetoshoSubtitleFiles detects imported entries by id when the flag is absent', () => {
|
||||||
|
const files = extractAnimetoshoSubtitleFiles({
|
||||||
|
id: 1000752022,
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
id: 1001361385,
|
||||||
|
filename: 'episode.mkv',
|
||||||
|
attachments: [{ id: 2826332, type: 1, info: { codec: 'ASS', lang: 'en' } }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
assert.equal(files[0]!.url, 'https://storage.tsukihime.org/tosho/attach/002b205c/2826332.xz');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('extractAnimetoshoSubtitleFiles skips image-based subtitle codecs', () => {
|
test('extractAnimetoshoSubtitleFiles skips image-based subtitle codecs', () => {
|
||||||
const files = extractAnimetoshoSubtitleFiles({
|
const files = extractAnimetoshoSubtitleFiles({
|
||||||
|
id: 1,
|
||||||
files: [
|
files: [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
filename: 'movie.mkv',
|
filename: 'movie.mkv',
|
||||||
attachments: [
|
attachments: [
|
||||||
{ id: 10, type: 'subtitle', info: { codec: 'PGS', lang: 'eng' }, size: 100 },
|
{ id: 10, type: 1, info: { codec: 'PGS', lang: 'en' } },
|
||||||
{ id: 11, type: 'subtitle', info: { codec: 'VobSub', lang: 'eng' }, size: 100 },
|
{ id: 11, type: 1, info: { codec: 'VobSub', lang: 'en' } },
|
||||||
{ id: 12, type: 'subtitle', info: { codec: 'SRT', lang: 'eng' }, size: 100 },
|
{ id: 12, type: 1, info: { codec: 'SRT', lang: 'en' } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -131,34 +214,20 @@ test('extractAnimetoshoSubtitleFiles skips image-based subtitle codecs', () => {
|
|||||||
files.map((f) => f.attachmentId),
|
files.map((f) => f.attachmentId),
|
||||||
[12],
|
[12],
|
||||||
);
|
);
|
||||||
assert.equal(files[0]!.filename, 'movie.eng.srt');
|
assert.equal(files[0]!.filename, 'movie.en.srt');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('extractAnimetoshoSubtitleFiles sorts English tracks first and disambiguates duplicates', () => {
|
test('extractAnimetoshoSubtitleFiles sorts English tracks first and disambiguates duplicates', () => {
|
||||||
const files = extractAnimetoshoSubtitleFiles({
|
const files = extractAnimetoshoSubtitleFiles({
|
||||||
|
id: 1,
|
||||||
files: [
|
files: [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
filename: 'episode.mkv',
|
filename: 'episode.mkv',
|
||||||
attachments: [
|
attachments: [
|
||||||
{
|
{ id: 21, type: 1, info: { codec: 'ASS', lang: 'de', name: 'Deutsch' } },
|
||||||
id: 21,
|
{ id: 22, type: 1, info: { codec: 'ASS', lang: 'en', name: 'Signs & Songs' } },
|
||||||
type: 'subtitle',
|
{ id: 23, type: 1, info: { codec: 'ASS', lang: 'en', name: 'Full Subtitles' } },
|
||||||
info: { codec: 'ASS', lang: 'ger', name: 'Deutsch' },
|
|
||||||
size: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 22,
|
|
||||||
type: 'subtitle',
|
|
||||||
info: { codec: 'ASS', lang: 'eng', name: 'Signs & Songs' },
|
|
||||||
size: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 23,
|
|
||||||
type: 'subtitle',
|
|
||||||
info: { codec: 'ASS', lang: 'eng', name: 'Full Subtitles' },
|
|
||||||
size: 3,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -168,17 +237,18 @@ test('extractAnimetoshoSubtitleFiles sorts English tracks first and disambiguate
|
|||||||
files.map((f) => f.attachmentId),
|
files.map((f) => f.attachmentId),
|
||||||
[22, 23, 21],
|
[22, 23, 21],
|
||||||
);
|
);
|
||||||
assert.equal(files[0]!.filename, 'episode.eng.signs-songs.ass');
|
assert.equal(files[0]!.filename, 'episode.en.signs-songs.ass');
|
||||||
assert.equal(files[1]!.filename, 'episode.eng.full-subtitles.ass');
|
assert.equal(files[1]!.filename, 'episode.en.full-subtitles.ass');
|
||||||
assert.equal(files[2]!.filename, 'episode.ger.ass');
|
assert.equal(files[2]!.filename, 'episode.de.ass');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('extractAnimetoshoSubtitleFiles tolerates missing info fields', () => {
|
test('extractAnimetoshoSubtitleFiles tolerates missing info fields', () => {
|
||||||
const files = extractAnimetoshoSubtitleFiles({
|
const files = extractAnimetoshoSubtitleFiles({
|
||||||
|
id: 1,
|
||||||
files: [
|
files: [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
attachments: [{ id: 31, type: 'subtitle', info: { codec: 'ASS' }, size: 5 }],
|
attachments: [{ id: 31, type: 1, info: { codec: 'ASS' } }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
Binary file not shown.
@@ -98,7 +98,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
|||||||
maxEntryResults: 10,
|
maxEntryResults: 10,
|
||||||
},
|
},
|
||||||
animetosho: {
|
animetosho: {
|
||||||
apiBaseUrl: 'https://feed.animetosho.org',
|
apiBaseUrl: 'https://api.tsukihime.org/v1',
|
||||||
maxSearchResults: 10,
|
maxSearchResults: 10,
|
||||||
},
|
},
|
||||||
mpv: {
|
mpv: {
|
||||||
|
|||||||
@@ -620,7 +620,7 @@ export function buildCoreConfigOptionRegistry(
|
|||||||
kind: 'string',
|
kind: 'string',
|
||||||
defaultValue: defaultConfig.shortcuts.openAnimetosho,
|
defaultValue: defaultConfig.shortcuts.openAnimetosho,
|
||||||
description:
|
description:
|
||||||
'Accelerator that opens the Animetosho subtitle search modal (English/Japanese tabs).',
|
'Accelerator that opens the TsukiHime subtitle search modal (English/Japanese tabs).',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'shortcuts.openSessionHelp',
|
path: 'shortcuts.openSessionHelp',
|
||||||
|
|||||||
@@ -404,13 +404,14 @@ export function buildIntegrationConfigOptionRegistry(
|
|||||||
path: 'animetosho.apiBaseUrl',
|
path: 'animetosho.apiBaseUrl',
|
||||||
kind: 'string',
|
kind: 'string',
|
||||||
defaultValue: defaultConfig.animetosho.apiBaseUrl,
|
defaultValue: defaultConfig.animetosho.apiBaseUrl,
|
||||||
description: 'Base URL of the Animetosho JSON feed API. No API key required.',
|
description:
|
||||||
|
'Base URL of the TsukiHime API (Animetosho successor). No API key required.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'animetosho.maxSearchResults',
|
path: 'animetosho.maxSearchResults',
|
||||||
kind: 'number',
|
kind: 'number',
|
||||||
defaultValue: defaultConfig.animetosho.maxSearchResults,
|
defaultValue: defaultConfig.animetosho.maxSearchResults,
|
||||||
description: 'Maximum Animetosho search results returned.',
|
description: 'Maximum TsukiHime search results returned.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'anilist.enabled',
|
path: 'anilist.enabled',
|
||||||
|
|||||||
@@ -148,11 +148,12 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
|||||||
key: 'jimaku',
|
key: 'jimaku',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Animetosho',
|
title: 'TsukiHime',
|
||||||
description: [
|
description: [
|
||||||
'Animetosho subtitle search configuration (English and Japanese). No API key required.',
|
'TsukiHime subtitle search configuration (English and Japanese). No API key required.',
|
||||||
|
'TsukiHime is the successor to Animetosho; the config key keeps the legacy name.',
|
||||||
],
|
],
|
||||||
notes: ['Hot-reload: Animetosho changes apply to the next Animetosho request.'],
|
notes: ['Hot-reload: TsukiHime changes apply to the next TsukiHime request.'],
|
||||||
key: 'animetosho',
|
key: 'animetosho',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ test('anki/jimaku IPC handlers reject malformed invoke payloads', async () => {
|
|||||||
const invalidAnimetoshoSearch = await animetoshoSearchHandler!({}, { query: 12 });
|
const invalidAnimetoshoSearch = await animetoshoSearchHandler!({}, { query: 12 });
|
||||||
assert.deepEqual(invalidAnimetoshoSearch, {
|
assert.deepEqual(invalidAnimetoshoSearch, {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: { error: 'Invalid Animetosho search query payload', code: 400 },
|
error: { error: 'Invalid TsukiHime search query payload', code: 400 },
|
||||||
});
|
});
|
||||||
|
|
||||||
const animetoshoFilesHandler = handleHandlers.get(IPC_CHANNELS.request.animetoshoListFiles);
|
const animetoshoFilesHandler = handleHandlers.get(IPC_CHANNELS.request.animetoshoListFiles);
|
||||||
@@ -111,7 +111,7 @@ test('anki/jimaku IPC handlers reject malformed invoke payloads', async () => {
|
|||||||
const invalidAnimetoshoFiles = await animetoshoFilesHandler!({}, { entryId: 'x' });
|
const invalidAnimetoshoFiles = await animetoshoFilesHandler!({}, { entryId: 'x' });
|
||||||
assert.deepEqual(invalidAnimetoshoFiles, {
|
assert.deepEqual(invalidAnimetoshoFiles, {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: { error: 'Invalid Animetosho files query payload', code: 400 },
|
error: { error: 'Invalid TsukiHime files query payload', code: 400 },
|
||||||
});
|
});
|
||||||
|
|
||||||
const animetoshoDownloadHandler = handleHandlers.get(IPC_CHANNELS.request.animetoshoDownloadFile);
|
const animetoshoDownloadHandler = handleHandlers.get(IPC_CHANNELS.request.animetoshoDownloadFile);
|
||||||
@@ -119,7 +119,7 @@ test('anki/jimaku IPC handlers reject malformed invoke payloads', async () => {
|
|||||||
const invalidAnimetoshoDownload = await animetoshoDownloadHandler!({}, { entryId: 1, url: '/x' });
|
const invalidAnimetoshoDownload = await animetoshoDownloadHandler!({}, { entryId: 1, url: '/x' });
|
||||||
assert.deepEqual(invalidAnimetoshoDownload, {
|
assert.deepEqual(invalidAnimetoshoDownload, {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: { error: 'Invalid Animetosho download query payload', code: 400 },
|
error: { error: 'Invalid TsukiHime download query payload', code: 400 },
|
||||||
});
|
});
|
||||||
|
|
||||||
const foreignUrlDownload = await animetoshoDownloadHandler!(
|
const foreignUrlDownload = await animetoshoDownloadHandler!(
|
||||||
@@ -128,7 +128,7 @@ test('anki/jimaku IPC handlers reject malformed invoke payloads', async () => {
|
|||||||
);
|
);
|
||||||
assert.deepEqual(foreignUrlDownload, {
|
assert.deepEqual(foreignUrlDownload, {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: { error: 'Refusing to download subtitle from a non-Animetosho URL.', code: 400 },
|
error: { error: 'Refusing to download subtitle from a non-TsukiHime URL.', code: 400 },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -177,7 +177,7 @@ test('animetosho downloads route by language: secondary for eng, primary for jpn
|
|||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
entryId: 1,
|
entryId: 1,
|
||||||
url: 'https://animetosho.org/storage/attach/00000001/1.xz',
|
url: 'https://storage.tsukihime.org/attach/00000001/1.xz',
|
||||||
name: 'episode.eng.ass',
|
name: 'episode.eng.ass',
|
||||||
lang: 'eng',
|
lang: 'eng',
|
||||||
},
|
},
|
||||||
@@ -191,7 +191,7 @@ test('animetosho downloads route by language: secondary for eng, primary for jpn
|
|||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
entryId: 1,
|
entryId: 1,
|
||||||
url: 'https://animetosho.org/storage/attach/00000002/2.xz',
|
url: 'https://storage.tsukihime.org/attach/00000002/2.xz',
|
||||||
name: 'episode.jpn.ass',
|
name: 'episode.jpn.ass',
|
||||||
lang: 'jpn',
|
lang: 'jpn',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ export function registerAnkiJimakuIpcHandlers(
|
|||||||
if (!parsedQuery) {
|
if (!parsedQuery) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: { error: 'Invalid Animetosho search query payload', code: 400 },
|
error: { error: 'Invalid TsukiHime search query payload', code: 400 },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return deps.searchAnimetoshoEntries(parsedQuery);
|
return deps.searchAnimetoshoEntries(parsedQuery);
|
||||||
@@ -229,7 +229,7 @@ export function registerAnkiJimakuIpcHandlers(
|
|||||||
async (_event, query: unknown): Promise<AnimetoshoApiResponse<AnimetoshoSubtitleFile[]>> => {
|
async (_event, query: unknown): Promise<AnimetoshoApiResponse<AnimetoshoSubtitleFile[]>> => {
|
||||||
const parsedQuery = parseAnimetoshoFilesQuery(query);
|
const parsedQuery = parseAnimetoshoFilesQuery(query);
|
||||||
if (!parsedQuery) {
|
if (!parsedQuery) {
|
||||||
return { ok: false, error: { error: 'Invalid Animetosho files query payload', code: 400 } };
|
return { ok: false, error: { error: 'Invalid TsukiHime files query payload', code: 400 } };
|
||||||
}
|
}
|
||||||
return deps.listAnimetoshoFiles(parsedQuery);
|
return deps.listAnimetoshoFiles(parsedQuery);
|
||||||
},
|
},
|
||||||
@@ -242,14 +242,14 @@ export function registerAnkiJimakuIpcHandlers(
|
|||||||
if (!parsedQuery) {
|
if (!parsedQuery) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: { error: 'Invalid Animetosho download query payload', code: 400 },
|
error: { error: 'Invalid TsukiHime download query payload', code: 400 },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAnimetoshoDownloadUrl(parsedQuery.url)) {
|
if (!isAnimetoshoDownloadUrl(parsedQuery.url)) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: { error: 'Refusing to download subtitle from a non-Animetosho URL.', code: 400 },
|
error: { error: 'Refusing to download subtitle from a non-TsukiHime URL.', code: 400 },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,10 +44,11 @@ function createHarness(): RuntimeHarness {
|
|||||||
endpoint,
|
endpoint,
|
||||||
query: query as Record<string, unknown>,
|
query: query as Record<string, unknown>,
|
||||||
});
|
});
|
||||||
if ((query as Record<string, unknown>)?.show === 'torrent') {
|
if (endpoint.startsWith('/torrents/')) {
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
data: {
|
data: {
|
||||||
|
id: 606713,
|
||||||
files: [
|
files: [
|
||||||
{
|
{
|
||||||
id: 9,
|
id: 9,
|
||||||
@@ -55,9 +56,8 @@ function createHarness(): RuntimeHarness {
|
|||||||
attachments: [
|
attachments: [
|
||||||
{
|
{
|
||||||
id: 1955356,
|
id: 1955356,
|
||||||
type: 'subtitle',
|
type: 1,
|
||||||
info: { codec: 'ASS', lang: 'eng', name: 'English subs' },
|
info: { codec: 'ASS', lang: 'en', name: 'English subs' },
|
||||||
size: 33075,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -67,11 +67,13 @@ function createHarness(): RuntimeHarness {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
data: [
|
data: {
|
||||||
{ id: 1, title: 'release a' },
|
results: [
|
||||||
{ id: 2, title: 'release b' },
|
{ id: 1, name: 'release a' },
|
||||||
{ id: 3, title: 'release c' },
|
{ id: 2, name: 'release b' },
|
||||||
] as never,
|
{ id: 3, name: 'release c' },
|
||||||
|
],
|
||||||
|
} as never,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
getSubtitleTimingTracker: () => null,
|
getSubtitleTimingTracker: () => null,
|
||||||
@@ -371,8 +373,8 @@ test('searchAnimetoshoEntries caps results using animetosho.maxSearchResults', a
|
|||||||
const searchResult = await registered.searchAnimetoshoEntries!({ query: 'frieren 28' });
|
const searchResult = await registered.searchAnimetoshoEntries!({ query: 'frieren 28' });
|
||||||
assert.deepEqual(state.animetoshoFetchCalls, [
|
assert.deepEqual(state.animetoshoFetchCalls, [
|
||||||
{
|
{
|
||||||
endpoint: '/json',
|
endpoint: '/search/torrents',
|
||||||
query: { q: 'frieren 28', qx: 1 },
|
query: { q: 'frieren 28', limit: 2 },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
assert.equal((searchResult as { ok: boolean }).ok, true);
|
assert.equal((searchResult as { ok: boolean }).ok, true);
|
||||||
@@ -390,14 +392,14 @@ test('listAnimetoshoFiles extracts subtitle attachments from torrent detail', as
|
|||||||
const filesResult = await registered.listAnimetoshoFiles!({ entryId: 606713 });
|
const filesResult = await registered.listAnimetoshoFiles!({ entryId: 606713 });
|
||||||
assert.deepEqual(state.animetoshoFetchCalls, [
|
assert.deepEqual(state.animetoshoFetchCalls, [
|
||||||
{
|
{
|
||||||
endpoint: '/json',
|
endpoint: '/torrents/606713',
|
||||||
query: { show: 'torrent', id: 606713 },
|
query: {},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
assert.equal((filesResult as { ok: boolean }).ok, true);
|
assert.equal((filesResult as { ok: boolean }).ok, true);
|
||||||
const files = (filesResult as { data: Array<Record<string, unknown>> }).data;
|
const files = (filesResult as { data: Array<Record<string, unknown>> }).data;
|
||||||
assert.equal(files.length, 1);
|
assert.equal(files.length, 1);
|
||||||
assert.equal(files[0]!.attachmentId, 1955356);
|
assert.equal(files[0]!.attachmentId, 1955356);
|
||||||
assert.equal(files[0]!.filename, 'episode.eng.ass');
|
assert.equal(files[0]!.filename, 'episode.en.ass');
|
||||||
assert.equal(files[0]!.url, 'https://animetosho.org/storage/attach/001dd61c/1955356.xz');
|
assert.equal(files[0]!.url, 'https://storage.tsukihime.org/attach/001dd61c/1955356.xz');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
} from '../../types';
|
} from '../../types';
|
||||||
import { sortJimakuFiles } from '../../jimaku/utils';
|
import { sortJimakuFiles } from '../../jimaku/utils';
|
||||||
import {
|
import {
|
||||||
ANIMETOSHO_FEED_BASE_URL,
|
TSUKIHIME_API_BASE_URL,
|
||||||
animetoshoFetchJson as animetoshoFetchJsonRequest,
|
animetoshoFetchJson as animetoshoFetchJsonRequest,
|
||||||
decompressXzFile,
|
decompressXzFile,
|
||||||
extractAnimetoshoSubtitleFiles,
|
extractAnimetoshoSubtitleFiles,
|
||||||
@@ -125,7 +125,7 @@ function animetoshoFetch<T>(
|
|||||||
if (options.animetoshoFetchJson) {
|
if (options.animetoshoFetchJson) {
|
||||||
return options.animetoshoFetchJson<T>(endpoint, query);
|
return options.animetoshoFetchJson<T>(endpoint, query);
|
||||||
}
|
}
|
||||||
const baseUrl = options.getResolvedConfig().animetosho?.apiBaseUrl || ANIMETOSHO_FEED_BASE_URL;
|
const baseUrl = options.getResolvedConfig().animetosho?.apiBaseUrl || TSUKIHIME_API_BASE_URL;
|
||||||
return animetoshoFetchJsonRequest<T>(endpoint, query, { baseUrl });
|
return animetoshoFetchJsonRequest<T>(endpoint, query, { baseUrl });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,22 +244,24 @@ export function registerAnkiJimakuIpcRuntime(
|
|||||||
|
|
||||||
searchAnimetoshoEntries: async (query) => {
|
searchAnimetoshoEntries: async (query) => {
|
||||||
logger.info(`[animetosho] search-entries query: "${query.query}"`);
|
logger.info(`[animetosho] search-entries query: "${query.query}"`);
|
||||||
const response = await animetoshoFetch<unknown>(options, '/json', {
|
const maxResults = getAnimetoshoMaxSearchResults(options);
|
||||||
|
const response = await animetoshoFetch<unknown>(options, '/search/torrents', {
|
||||||
q: query.query,
|
q: query.query,
|
||||||
qx: 1,
|
// The API caps limit at 100.
|
||||||
|
limit: Math.min(maxResults, 100),
|
||||||
});
|
});
|
||||||
if (!response.ok) return response;
|
if (!response.ok) return response;
|
||||||
const maxResults = getAnimetoshoMaxSearchResults(options);
|
|
||||||
const entries = mapAnimetoshoSearchResults(response.data, maxResults);
|
const entries = mapAnimetoshoSearchResults(response.data, maxResults);
|
||||||
logger.info(`[animetosho] search-entries returned ${entries.length} results`);
|
logger.info(`[animetosho] search-entries returned ${entries.length} results`);
|
||||||
return { ok: true, data: entries };
|
return { ok: true, data: entries };
|
||||||
},
|
},
|
||||||
listAnimetoshoFiles: async (query) => {
|
listAnimetoshoFiles: async (query) => {
|
||||||
logger.info(`[animetosho] list-files entryId=${query.entryId}`);
|
logger.info(`[animetosho] list-files entryId=${query.entryId}`);
|
||||||
const response = await animetoshoFetch<unknown>(options, '/json', {
|
const response = await animetoshoFetch<unknown>(
|
||||||
show: 'torrent',
|
options,
|
||||||
id: query.entryId,
|
`/torrents/${encodeURIComponent(query.entryId)}`,
|
||||||
});
|
{},
|
||||||
|
);
|
||||||
if (!response.ok) return response;
|
if (!response.ok) return response;
|
||||||
const files = extractAnimetoshoSubtitleFiles(response.data);
|
const files = extractAnimetoshoSubtitleFiles(response.data);
|
||||||
logger.info(`[animetosho] list-files returned ${files.length} subtitle attachments`);
|
logger.info(`[animetosho] list-files returned ${files.length} subtitle attachments`);
|
||||||
|
|||||||
+2
-2
@@ -2928,8 +2928,8 @@ function openJimakuOverlay(): void {
|
|||||||
function openAnimetoshoOverlay(): void {
|
function openAnimetoshoOverlay(): void {
|
||||||
openOverlayHostedModalWithOsd(
|
openOverlayHostedModalWithOsd(
|
||||||
openAnimetoshoModalRuntime,
|
openAnimetoshoModalRuntime,
|
||||||
'Animetosho overlay unavailable.',
|
'TsukiHime overlay unavailable.',
|
||||||
'Failed to open Animetosho overlay.',
|
'Failed to open TsukiHime overlay.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -119,7 +119,7 @@
|
|||||||
<div id="animetoshoModal" class="modal hidden" aria-hidden="true">
|
<div id="animetoshoModal" class="modal hidden" aria-hidden="true">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<div class="modal-title">Animetosho Subtitles</div>
|
<div class="modal-title">TsukiHime Subtitles</div>
|
||||||
<button id="animetoshoClose" class="modal-close" type="button">Close</button>
|
<button id="animetoshoClose" class="modal-close" type="button">Close</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
|
|||||||
@@ -90,6 +90,10 @@ interface ModalHarness {
|
|||||||
modalCloseNotifications: string[];
|
modalCloseNotifications: string[];
|
||||||
overlayClassList: ReturnType<typeof createClassList>;
|
overlayClassList: ReturnType<typeof createClassList>;
|
||||||
animetoshoModalClassList: ReturnType<typeof createClassList>;
|
animetoshoModalClassList: ReturnType<typeof createClassList>;
|
||||||
|
titleInput: { value: string };
|
||||||
|
status: { textContent: string; style: { color: string } };
|
||||||
|
entriesList: ReturnType<typeof createListStub>;
|
||||||
|
filesList: ReturnType<typeof createListStub>;
|
||||||
restoreGlobals: () => void;
|
restoreGlobals: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +102,7 @@ function createModalHarness(
|
|||||||
options: {
|
options: {
|
||||||
secondaryLanguages?: string[];
|
secondaryLanguages?: string[];
|
||||||
listFiles?: (entryId: number) => Promise<unknown>;
|
listFiles?: (entryId: number) => Promise<unknown>;
|
||||||
|
searchEntries?: (query: unknown) => Promise<unknown>;
|
||||||
} = {},
|
} = {},
|
||||||
): ModalHarness {
|
): ModalHarness {
|
||||||
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
||||||
@@ -117,6 +122,8 @@ function createModalHarness(
|
|||||||
animetoshoGetSecondaryLanguages: async () => options.secondaryLanguages ?? ['en', 'eng'],
|
animetoshoGetSecondaryLanguages: async () => options.secondaryLanguages ?? ['en', 'eng'],
|
||||||
animetoshoListFiles: async ({ entryId }: { entryId: number }) =>
|
animetoshoListFiles: async ({ entryId }: { entryId: number }) =>
|
||||||
options.listFiles ? options.listFiles(entryId) : { ok: true, data: [] },
|
options.listFiles ? options.listFiles(entryId) : { ok: true, data: [] },
|
||||||
|
animetoshoSearchEntries: async (query: unknown) =>
|
||||||
|
options.searchEntries ? options.searchEntries(query) : { ok: true, data: [] },
|
||||||
getJimakuMediaInfo: async () => ({
|
getJimakuMediaInfo: async () => ({
|
||||||
title: '',
|
title: '',
|
||||||
season: null,
|
season: null,
|
||||||
@@ -192,6 +199,10 @@ function createModalHarness(
|
|||||||
modalCloseNotifications,
|
modalCloseNotifications,
|
||||||
overlayClassList,
|
overlayClassList,
|
||||||
animetoshoModalClassList,
|
animetoshoModalClassList,
|
||||||
|
titleInput: ctx.dom.animetoshoTitleInput,
|
||||||
|
status: ctx.dom.animetoshoStatus,
|
||||||
|
entriesList: ctx.dom.animetoshoEntriesList,
|
||||||
|
filesList: ctx.dom.animetoshoFilesList,
|
||||||
restoreGlobals: () => {
|
restoreGlobals: () => {
|
||||||
const target = globalThis as unknown as Record<string, unknown>;
|
const target = globalThis as unknown as Record<string, unknown>;
|
||||||
if (hadWindow) {
|
if (hadWindow) {
|
||||||
@@ -348,8 +359,8 @@ test('a slow release response does not overwrite the newly selected release', as
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
harness.state.animetoshoEntries = [
|
harness.state.animetoshoEntries = [
|
||||||
{ id: 1, title: 'slow release', timestamp: null, totalSize: null, numFiles: 1 },
|
{ id: 1, title: 'slow release', timestamp: null, totalSize: null, numFiles: 1, sublangs: [] },
|
||||||
{ id: 2, title: 'fast release', timestamp: null, totalSize: null, numFiles: 1 },
|
{ id: 2, title: 'fast release', timestamp: null, totalSize: null, numFiles: 1, sublangs: [] },
|
||||||
];
|
];
|
||||||
|
|
||||||
harness.modal.selectAnimetoshoEntry(0);
|
harness.modal.selectAnimetoshoEntry(0);
|
||||||
@@ -386,3 +397,83 @@ test('ArrowLeft switches back to the English tab', () => {
|
|||||||
harness.restoreGlobals();
|
harness.restoreGlobals();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('searching reports TsukiHime as the backend and lists sublangs per release', async () => {
|
||||||
|
const harness = createModalHarness([], {
|
||||||
|
searchEntries: async () => ({
|
||||||
|
ok: true,
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 12255,
|
||||||
|
title: '[DKB] Futsutsuka na Akujo - S01E01 [Multi-Subs]',
|
||||||
|
timestamp: null,
|
||||||
|
totalSize: 423868898,
|
||||||
|
numFiles: 1,
|
||||||
|
sublangs: ['en-US', 'ja'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 12256,
|
||||||
|
title: 'release without langs',
|
||||||
|
timestamp: null,
|
||||||
|
totalSize: null,
|
||||||
|
numFiles: null,
|
||||||
|
sublangs: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
harness.state.animetoshoFiles = [];
|
||||||
|
harness.state.currentAnimetoshoEntryId = null;
|
||||||
|
harness.titleInput.value = 'Futsutsuka na Akujo';
|
||||||
|
|
||||||
|
const seenStatuses: string[] = [];
|
||||||
|
const status = harness.status;
|
||||||
|
Object.defineProperty(status, 'textContent', {
|
||||||
|
get: () => seenStatuses.at(-1) ?? '',
|
||||||
|
set: (value: string) => {
|
||||||
|
seenStatuses.push(value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pressKey(harness, 'Enter');
|
||||||
|
await flushAsyncWork();
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
seenStatuses.some((message) => message.includes('TsukiHime')),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
seenStatuses.some((message) => message.includes('Animetosho')),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
const firstEntry = harness.entriesList.children[0] as {
|
||||||
|
children: Array<{ textContent: string }>;
|
||||||
|
};
|
||||||
|
assert.equal(firstEntry.children.length, 1);
|
||||||
|
assert.match(firstEntry.children[0]!.textContent, /en-US, ja/);
|
||||||
|
} finally {
|
||||||
|
harness.restoreGlobals();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderFiles omits the size detail when the API does not report one', () => {
|
||||||
|
const zeroSizeTrack: AnimetoshoSubtitleFile = {
|
||||||
|
...ENGLISH_TRACK,
|
||||||
|
size: 0,
|
||||||
|
};
|
||||||
|
const harness = createModalHarness([zeroSizeTrack, JAPANESE_TRACK]);
|
||||||
|
try {
|
||||||
|
pressKey(harness, 'ArrowDown');
|
||||||
|
|
||||||
|
const firstFile = harness.filesList.children[0] as {
|
||||||
|
children: Array<{ textContent: string }>;
|
||||||
|
};
|
||||||
|
assert.equal(firstFile.children.length, 1);
|
||||||
|
assert.equal(firstFile.children[0]!.textContent.includes('0 B'), false);
|
||||||
|
assert.match(firstFile.children[0]!.textContent, /English subs/);
|
||||||
|
} finally {
|
||||||
|
harness.restoreGlobals();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ export function createAnimetoshoModal(
|
|||||||
if (entry.numFiles !== null) {
|
if (entry.numFiles !== null) {
|
||||||
details.push(`${entry.numFiles} file${entry.numFiles === 1 ? '' : 's'}`);
|
details.push(`${entry.numFiles} file${entry.numFiles === 1 ? '' : 's'}`);
|
||||||
}
|
}
|
||||||
|
if (entry.sublangs?.length) details.push(`subs: ${entry.sublangs.join(', ')}`);
|
||||||
if (details.length > 0) {
|
if (details.length > 0) {
|
||||||
const sub = document.createElement('div');
|
const sub = document.createElement('div');
|
||||||
sub.className = 'jimaku-subtext';
|
sub.className = 'jimaku-subtext';
|
||||||
@@ -161,7 +162,8 @@ export function createAnimetoshoModal(
|
|||||||
const details: string[] = [];
|
const details: string[] = [];
|
||||||
if (file.lang) details.push(file.lang);
|
if (file.lang) details.push(file.lang);
|
||||||
if (file.trackName) details.push(file.trackName);
|
if (file.trackName) details.push(file.trackName);
|
||||||
details.push(formatBytes(file.size));
|
// TsukiHime does not report attachment sizes; skip a meaningless "0 B".
|
||||||
|
if (file.size > 0) details.push(formatBytes(file.size));
|
||||||
const sub = document.createElement('div');
|
const sub = document.createElement('div');
|
||||||
sub.className = 'jimaku-subtext';
|
sub.className = 'jimaku-subtext';
|
||||||
sub.textContent = details.filter(Boolean).join(' • ');
|
sub.textContent = details.filter(Boolean).join(' • ');
|
||||||
@@ -199,7 +201,7 @@ export function createAnimetoshoModal(
|
|||||||
}
|
}
|
||||||
|
|
||||||
resetAnimetoshoLists();
|
resetAnimetoshoLists();
|
||||||
setAnimetoshoStatus('Searching Animetosho...');
|
setAnimetoshoStatus('Searching TsukiHime...');
|
||||||
|
|
||||||
const response: AnimetoshoApiResponse<AnimetoshoEntry[]> =
|
const response: AnimetoshoApiResponse<AnimetoshoEntry[]> =
|
||||||
await window.electronAPI.animetoshoSearchEntries({ query });
|
await window.electronAPI.animetoshoSearchEntries({ query });
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ function describeCommand(command: (string | number)[]): string {
|
|||||||
if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) return 'Open subtitle sync controls';
|
if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) return 'Open subtitle sync controls';
|
||||||
if (first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN) return 'Open runtime options';
|
if (first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN) return 'Open runtime options';
|
||||||
if (first === SPECIAL_COMMANDS.JIMAKU_OPEN) return 'Open jimaku';
|
if (first === SPECIAL_COMMANDS.JIMAKU_OPEN) return 'Open jimaku';
|
||||||
if (first === SPECIAL_COMMANDS.ANIMETOSHO_OPEN) return 'Open animetosho';
|
if (first === SPECIAL_COMMANDS.ANIMETOSHO_OPEN) return 'Open TsukiHime';
|
||||||
if (first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN) return 'Open playlist browser';
|
if (first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN) return 'Open playlist browser';
|
||||||
if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE) return 'Replay current subtitle';
|
if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE) return 'Replay current subtitle';
|
||||||
if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE) return 'Play next subtitle';
|
if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE) return 'Play next subtitle';
|
||||||
@@ -224,7 +224,7 @@ function describeSessionAction(
|
|||||||
case 'openJimaku':
|
case 'openJimaku':
|
||||||
return 'Open jimaku';
|
return 'Open jimaku';
|
||||||
case 'openAnimetosho':
|
case 'openAnimetosho':
|
||||||
return 'Open animetosho';
|
return 'Open TsukiHime';
|
||||||
case 'openYoutubePicker':
|
case 'openYoutubePicker':
|
||||||
return 'Open YouTube subtitle picker';
|
return 'Open YouTube subtitle picker';
|
||||||
case 'openPlaylistBrowser':
|
case 'openPlaylistBrowser':
|
||||||
|
|||||||
@@ -251,6 +251,7 @@ export interface AnimetoshoEntry {
|
|||||||
timestamp: number | null;
|
timestamp: number | null;
|
||||||
totalSize: number | null;
|
totalSize: number | null;
|
||||||
numFiles: number | null;
|
numFiles: number | null;
|
||||||
|
sublangs: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AnimetoshoFilesQuery {
|
export interface AnimetoshoFilesQuery {
|
||||||
|
|||||||
Reference in New Issue
Block a user