mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-19 05:16:27 -07:00
feat(stats): add TMDB metadata for live-action dramas in the Library
Anime covers come from AniList, which has no live-action titles, so dramas and movies showed blank cards with no description and split across season folders. Library entries now carry a media kind plus a TMDB link, and the cover-art fetcher falls back to TMDB when AniList has no match, accepting only a Japanese non-animated title whose TMDB names match the parsed title exactly. Entries that resolve to the same TMDB title merge into one card regardless of season, and a Link to TMDB action in the detail view covers anything the automatic match missed. Release builds bundle a project-owned TMDB key injected from the SUBMINER_TMDB_API_KEY secret at build time; tmdb.apiKey/apiKeyCommand override it and are required when running from source. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,9 @@ on:
|
||||
required: true
|
||||
APPLE_TEAM_ID:
|
||||
required: true
|
||||
# Project TMDB key baked into release artifacts; builds stay valid without it.
|
||||
SUBMINER_TMDB_API_KEY:
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -69,6 +72,8 @@ jobs:
|
||||
|
||||
- name: Build AppImage
|
||||
run: bun run build:appimage
|
||||
env:
|
||||
SUBMINER_TMDB_API_KEY: ${{ secrets.SUBMINER_TMDB_API_KEY }}
|
||||
|
||||
- name: Build unversioned AppImage
|
||||
run: |
|
||||
@@ -168,6 +173,7 @@ jobs:
|
||||
- name: Build signed + notarized macOS artifacts
|
||||
run: bun run build:mac
|
||||
env:
|
||||
SUBMINER_TMDB_API_KEY: ${{ secrets.SUBMINER_TMDB_API_KEY }}
|
||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
@@ -248,6 +254,8 @@ jobs:
|
||||
|
||||
- name: Build unsigned Windows artifacts
|
||||
run: bun run build:win:unsigned
|
||||
env:
|
||||
SUBMINER_TMDB_API_KEY: ${{ secrets.SUBMINER_TMDB_API_KEY }}
|
||||
|
||||
- name: Smoke packaged runtime assets
|
||||
shell: bash
|
||||
|
||||
@@ -27,6 +27,7 @@ jobs:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
SUBMINER_TMDB_API_KEY: ${{ secrets.SUBMINER_TMDB_API_KEY }}
|
||||
|
||||
release:
|
||||
needs: [package]
|
||||
|
||||
@@ -28,6 +28,7 @@ jobs:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
SUBMINER_TMDB_API_KEY: ${{ secrets.SUBMINER_TMDB_API_KEY }}
|
||||
|
||||
release:
|
||||
needs: [package]
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
type: added
|
||||
area: stats
|
||||
|
||||
- Live-action dramas and movies in the stats Library now get posters, synopses, and titles from TMDB. Release builds include a project key, so it works out of the box; `tmdb.apiKey` (or `tmdb.apiKeyCommand`) overrides it, and is required when running from source.
|
||||
- Titles that AniList cannot match are looked up on TMDB automatically when the parsed filename matches a Japanese live-action title exactly; otherwise use the new **Link to TMDB** action on a title to pick it by hand.
|
||||
- Entries linked to the same TMDB title are merged into one card even when they came from different season folders, and the Library gained an Anime / Live Action filter.
|
||||
@@ -658,6 +658,16 @@
|
||||
"maxSearchResults": 10 // Maximum TsukiHime search results returned.
|
||||
}, // TsukiHime subtitle search configuration for Japanese primary and configured secondary subtitles. No API key required.
|
||||
|
||||
// ==========================================
|
||||
// TMDB
|
||||
// TMDB (The Movie Database) metadata for live-action dramas and movies in the stats Library: posters, synopses, and grouping by show.
|
||||
// Hot-reload: TMDB changes apply to the next TMDB request.
|
||||
// ==========================================
|
||||
"tmdb": {
|
||||
"apiKey": "", // Your own TMDB API key or read access token for live-action posters and synopses in the stats Library. Release builds bundle a project key, so set this only to use your own quota or when running from source (free under Settings > API on themoviedb.org).
|
||||
"apiKeyCommand": "" // Shell command that prints the TMDB API key to stdout. Used instead of apiKey to avoid storing the key in plain text.
|
||||
}, // TMDB (The Movie Database) metadata for live-action dramas and movies in the stats Library: posters, synopses, and grouping by show.
|
||||
|
||||
// ==========================================
|
||||
// YouTube Playback Settings
|
||||
// Defaults for managed subtitle language preferences and YouTube subtitle loading.
|
||||
|
||||
@@ -71,6 +71,8 @@ SubMiner fetches cover art from AniList for display in the stats dashboard. When
|
||||
|
||||
A no-match result is cached for 5 minutes before SubMiner retries, preventing repeated API calls for unrecognized media.
|
||||
|
||||
When AniList has no match, SubMiner tries [TMDB](/configuration#tmdb) next so live-action dramas and movies get a poster and synopsis too. See [Immersion tracking](/immersion-tracking#library) for how live-action entries are grouped.
|
||||
|
||||
If the automatic match is wrong, use **Change AniList Entry** on a title in the stats Library. Relinking rewrites the cached art for every episode of that title, and both the detail view and the Library grid pick up the new cover right away: the grid refetches after a relink, and cover responses carry an ETag and are revalidated on each request instead of being cached for a day.
|
||||
|
||||
## Rate limiting
|
||||
|
||||
@@ -156,6 +156,7 @@ The configuration file includes several main sections:
|
||||
|
||||
- [**Jimaku**](#jimaku) - Jimaku API configuration and defaults
|
||||
- [**TsukiHime**](#tsukihime) - Multi-language subtitle search and download
|
||||
- [**TMDB**](#tmdb) - Posters and synopses for live-action dramas and movies in the stats Library
|
||||
- [**Subtitle Sync**](#subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync`
|
||||
- [**AniList**](#anilist) - Optional post-watch progress updates
|
||||
- [**Yomitan**](#yomitan) - Reuse an external read-only Yomitan profile
|
||||
@@ -975,7 +976,7 @@ This example is intentionally compact. The option table below documents availabl
|
||||
| `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). |
|
||||
| `ankiConnect.deck` | string | Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to use the Yomitan mining deck when available. In Settings, this dropdown auto-fills and persists Yomitan's current mining deck when available. |
|
||||
| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) |
|
||||
| `fields.audio` | string | Card field for the generated sentence audio clip (default: `ExpressionAudio`). Set this to a dedicated field such as `SentenceAudio` so it does not collide with the word audio Yomitan writes. |
|
||||
| `fields.audio` | string | Card field for the generated sentence audio clip (default: `ExpressionAudio`). Set this to a dedicated field such as `SentenceAudio` so it does not collide with the word audio Yomitan writes. |
|
||||
| `fields.image` | string | Card field for images (default: `Picture`) |
|
||||
| `fields.sentence` | string | Card field for sentences (default: `Sentence`) |
|
||||
| `fields.miscInfo` | string | Card field for metadata (default: `"MiscInfo"`, set to `null` to disable) |
|
||||
@@ -1157,6 +1158,30 @@ The keyboard shortcut lives under `shortcuts.openTsukihime` (default `Ctrl+Shift
|
||||
|
||||
See [TsukiHime Integration](/tsukihime-integration) for the modal workflow, language tabs, and troubleshooting.
|
||||
|
||||
### TMDB
|
||||
|
||||
TMDB (The Movie Database) supplies posters, synopses, and show grouping for live-action dramas and movies in the stats [Library](/immersion-tracking#library). AniList only covers anime, so TMDB is what gives live-action titles a cover and a description.
|
||||
|
||||
Release builds ship with a project TMDB key, so nothing needs to be configured. Set your own key to use your own quota, or when running SubMiner from source, where no key is bundled. Create one for free under **Settings > API** on [themoviedb.org](https://www.themoviedb.org/settings/api); either the short API key or the long "API Read Access Token" works.
|
||||
|
||||
```json
|
||||
{
|
||||
"tmdb": {
|
||||
"apiKey": "",
|
||||
"apiKeyCommand": "cat ~/.tmdb_key"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Values | Description |
|
||||
| -------------------- | ------ | -------------------------------------------------------------------------------------------------- |
|
||||
| `tmdb.apiKey` | string | Your own TMDB API key or read access token; overrides the bundled key (default: empty) |
|
||||
| `tmdb.apiKeyCommand` | string | Shell command that prints the key to stdout, used instead of `apiKey` to keep it out of the config |
|
||||
|
||||
Changes apply to the next TMDB request without a restart.
|
||||
|
||||
This product uses the TMDB API but is not endorsed or certified by TMDB.
|
||||
|
||||
### Japanese subtitle generation
|
||||
|
||||
Open the standalone modal with `Ctrl+Shift+G`, configurable through `shortcuts.openSubtitleGeneration`, or use the subtitle sidebar button. See [shortcuts](/shortcuts) for the shared mpv and overlay keybindings.
|
||||
|
||||
@@ -53,6 +53,8 @@ Local files and Jellyfin items with detected season numbers are split into seaso
|
||||
|
||||
When older stats already grouped multiple seasons under one series entry, SubMiner moves parsed episodes into the season-specific entries on startup and rebuilds the affected summaries.
|
||||
|
||||
**Live-action dramas and movies.** Anime covers come from AniList, which has no live-action titles. A title that AniList cannot match is looked up on [TMDB](/configuration#tmdb) instead (release builds bundle a key; source builds need your own): only a Japanese-language, non-animated result whose known titles match the parsed filename exactly is accepted, and it supplies the poster, synopsis, English and Japanese titles, and episode count. If nothing matches automatically, open the title and use **Link to TMDB** to search and pick it by hand. A TMDB show spans all of its seasons, so entries that resolve to the same TMDB title are merged into one card regardless of the season folder they came from, and the merged season titles are remembered so later episodes land on the same card. The **All Titles** filter above the grid narrows the Library to anime or live action, and a title's detail view shows whether it is a drama or a movie. Linking a title to AniList again turns it back into an anime entry.
|
||||
|
||||
Jellyfin stream URLs are normalized to stable item links before stats titles are shown, so playback query parameters are not displayed in the dashboard.
|
||||
|
||||
When YouTube channel metadata is available, the Library tab groups videos by creator/channel and treats each tracked video as an episode-like entry inside that channel section.
|
||||
@@ -327,14 +329,14 @@ LIMIT ?;
|
||||
- Large-table reads are index-backed for `sample_ms`, session time windows, frequency-ranked words/kanji, and cover-art identity lookups.
|
||||
- Workload-dependent tuning knobs remain at defaults unless you change them: `cache_size`, `mmap_size`, `temp_store`, `auto_vacuum`.
|
||||
|
||||
### Schema (v23)
|
||||
### Schema (v24)
|
||||
|
||||
The exact schema version lives in `SCHEMA_VERSION` (`src/core/services/immersion-tracker/types.ts`) and is recorded in the `imm_schema_version` table.
|
||||
|
||||
Core tables:
|
||||
|
||||
- `imm_videos` - video key/title/source metadata
|
||||
- `imm_anime` - anime/series metadata referenced by videos and lifetime tables
|
||||
- `imm_anime` - series metadata referenced by videos and lifetime tables, including the media kind (`anime` or `live_action`) and the AniList or TMDB link
|
||||
- `imm_anime_title_aliases` - alternate titles that resolve to the same anime row
|
||||
- `imm_anime_merge_recommendations` - candidate duplicate-series merges surfaced in the dashboard
|
||||
- `imm_sessions` - session UUID, video reference, timing/status, final denormalized totals
|
||||
|
||||
@@ -658,6 +658,16 @@
|
||||
"maxSearchResults": 10 // Maximum TsukiHime search results returned.
|
||||
}, // TsukiHime subtitle search configuration for Japanese primary and configured secondary subtitles. No API key required.
|
||||
|
||||
// ==========================================
|
||||
// TMDB
|
||||
// TMDB (The Movie Database) metadata for live-action dramas and movies in the stats Library: posters, synopses, and grouping by show.
|
||||
// Hot-reload: TMDB changes apply to the next TMDB request.
|
||||
// ==========================================
|
||||
"tmdb": {
|
||||
"apiKey": "", // Your own TMDB API key or read access token for live-action posters and synopses in the stats Library. Release builds bundle a project key, so set this only to use your own quota or when running from source (free under Settings > API on themoviedb.org).
|
||||
"apiKeyCommand": "" // Shell command that prints the TMDB API key to stdout. Used instead of apiKey to avoid storing the key in plain text.
|
||||
}, // TMDB (The Movie Database) metadata for live-action dramas and movies in the stats Library: posters, synopses, and grouping by show.
|
||||
|
||||
// ==========================================
|
||||
// YouTube Playback Settings
|
||||
// Defaults for managed subtitle language preferences and YouTube subtitle loading.
|
||||
|
||||
+4
-1
@@ -15,7 +15,10 @@
|
||||
|
||||
Stable and prerelease workflows share `.github/workflows/package-release.yml`.
|
||||
Both callers explicitly pass the five required macOS signing/notarization
|
||||
secrets; `GITHUB_TOKEN` remains automatically available to the reusable workflow.
|
||||
secrets plus the optional `SUBMINER_TMDB_API_KEY` (the project TMDB key that
|
||||
`scripts/prepare-build-assets.mjs` stages into `dist/bundled-integration-keys.json`;
|
||||
artifacts built without it simply require users to set `tmdb.apiKey`).
|
||||
`GITHUB_TOKEN` remains automatically available to the reusable workflow.
|
||||
Each platform verifies its ASAR and external resources before signing, then
|
||||
measures the signed app and installers before upload. Missing runtime assets,
|
||||
foreign SQLite/Koffi binaries, duplicate UI fonts, demo media, source maps,
|
||||
|
||||
@@ -30,6 +30,7 @@ Read when: you need to find the owner module for a behavior or test surface
|
||||
- Immersion sync: `src/core/services/stats-sync/`, bound by `src/main/sync-cli.ts`.
|
||||
`snapshot-transfer.ts` selects compressed rsync or scp. `transfer-cache.ts` atomically retains the last successfully received snapshot per hashed peer/database identity under the config directory's `sync-transfer-cache/`. Cache copies seed isolated transfer directories; rsync verifies reconstructed files before the existing merge engine runs. The `--make-temp` / `--remove-temp` helpers accept an internal `--transfer-cache` key, with a fallback for older peers that do not recognize it.
|
||||
- AniList tracking + character dictionary: `src/core/services/anilist/`, `src/main/runtime/composers/anilist-*`, `src/main/character-dictionary-runtime.ts`, `src/main/character-dictionary-runtime/`
|
||||
- TMDB live-action metadata: `src/core/services/tmdb/` (client + exact-title resolver), `src/core/services/immersion-tracker/live-action-link.ts` (links an entry to a TMDB title and merges other holders of the same title). The AniList cover-art fetcher calls the resolver as its fallback; `imm_anime.media_kind` marks the result and keeps the entry out of AniList season repair.
|
||||
- Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*`
|
||||
- Window trackers: `src/window-trackers/`
|
||||
- Stats HTTP app: `src/core/services/stats-server.ts`, with route groups and shared route support
|
||||
|
||||
@@ -20,6 +20,9 @@ export const IMMERSION_DB_FIXTURE_DDL = `
|
||||
title_native TEXT,
|
||||
episodes_total INTEGER,
|
||||
description TEXT,
|
||||
media_kind TEXT NOT NULL DEFAULT 'anime',
|
||||
tmdb_id INTEGER,
|
||||
tmdb_type TEXT,
|
||||
metadata_json TEXT,
|
||||
CREATED_DATE TEXT,
|
||||
LAST_UPDATE_DATE TEXT
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* Release builds carry a project-owned TMDB key so live-action lookups work
|
||||
* without user setup. The key is injected from the SUBMINER_TMDB_API_KEY
|
||||
* environment variable at build time (a GitHub Actions secret in CI) and never
|
||||
* lives in the repository. The runtime reader is
|
||||
* src/core/services/tmdb/bundled-api-key.ts; keep the file name in sync.
|
||||
*/
|
||||
export const BUNDLED_INTEGRATION_KEYS_FILENAME = 'bundled-integration-keys.json';
|
||||
export const TMDB_API_KEY_ENV = 'SUBMINER_TMDB_API_KEY';
|
||||
|
||||
/**
|
||||
* Write the bundled keys file into `distDir`, or remove a stale one when no
|
||||
* key is present so a keyless build never ships an older key by accident.
|
||||
* Returns the names of the keys staged.
|
||||
*/
|
||||
export function stageBundledIntegrationKeys(distDir, env = process.env) {
|
||||
const outputPath = path.join(distDir, BUNDLED_INTEGRATION_KEYS_FILENAME);
|
||||
const tmdbApiKey = env[TMDB_API_KEY_ENV]?.trim() ?? '';
|
||||
if (!tmdbApiKey) {
|
||||
fs.rmSync(outputPath, { force: true });
|
||||
return [];
|
||||
}
|
||||
fs.mkdirSync(distDir, { recursive: true });
|
||||
fs.writeFileSync(outputPath, `${JSON.stringify({ tmdbApiKey })}\n`, { mode: 0o644 });
|
||||
return ['tmdb'];
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
BUNDLED_INTEGRATION_KEYS_FILENAME,
|
||||
stageBundledIntegrationKeys,
|
||||
} from './bundled-integration-keys.mjs';
|
||||
|
||||
function withDistDir(work: (distDir: string) => void): void {
|
||||
const distDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-bundled-keys-'));
|
||||
try {
|
||||
work(distDir);
|
||||
} finally {
|
||||
fs.rmSync(distDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('stageBundledIntegrationKeys writes the TMDB key from the environment', () => {
|
||||
withDistDir((distDir) => {
|
||||
const staged = stageBundledIntegrationKeys(distDir, { SUBMINER_TMDB_API_KEY: ' abc123 ' });
|
||||
assert.deepEqual(staged, ['tmdb']);
|
||||
const written = JSON.parse(
|
||||
fs.readFileSync(path.join(distDir, BUNDLED_INTEGRATION_KEYS_FILENAME), 'utf8'),
|
||||
);
|
||||
assert.deepEqual(written, { tmdbApiKey: 'abc123' });
|
||||
});
|
||||
});
|
||||
|
||||
test('stageBundledIntegrationKeys removes a stale file when the variable is unset', () => {
|
||||
withDistDir((distDir) => {
|
||||
const outputPath = path.join(distDir, BUNDLED_INTEGRATION_KEYS_FILENAME);
|
||||
fs.writeFileSync(outputPath, '{"tmdbApiKey":"old"}');
|
||||
assert.deepEqual(stageBundledIntegrationKeys(distDir, {}), []);
|
||||
assert.equal(fs.existsSync(outputPath), false);
|
||||
assert.deepEqual(stageBundledIntegrationKeys(distDir, { SUBMINER_TMDB_API_KEY: ' ' }), []);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { stageBundledIntegrationKeys, TMDB_API_KEY_ENV } from './bundled-integration-keys.mjs';
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, '..');
|
||||
@@ -97,6 +98,17 @@ function buildMacosHelper() {
|
||||
}
|
||||
}
|
||||
|
||||
// Only the key names are logged, never the values: CI masks secrets, but a
|
||||
// stray echo would still leak them into local build logs.
|
||||
function stageIntegrationKeys() {
|
||||
const staged = stageBundledIntegrationKeys(path.join(repoRoot, 'dist'));
|
||||
process.stdout.write(
|
||||
staged.length > 0
|
||||
? `Staged bundled integration keys: ${staged.join(', ')}\n`
|
||||
: `No bundled integration keys (${TMDB_API_KEY_ENV} unset)\n`,
|
||||
);
|
||||
}
|
||||
|
||||
function main() {
|
||||
fs.cpSync(path.join(rendererSourceDir, 'fonts'), path.join(repoRoot, 'dist', 'fonts'), {
|
||||
recursive: true,
|
||||
@@ -106,6 +118,7 @@ function main() {
|
||||
copySettingsAssets();
|
||||
copySyncUiAssets();
|
||||
buildMacosHelper();
|
||||
stageIntegrationKeys();
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
@@ -42,6 +42,7 @@ const {
|
||||
ankiConnect,
|
||||
jimaku,
|
||||
tsukihime,
|
||||
tmdb,
|
||||
anilist,
|
||||
mpv,
|
||||
yomitan,
|
||||
@@ -76,6 +77,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
|
||||
auto_start_overlay,
|
||||
jimaku,
|
||||
tsukihime,
|
||||
tmdb,
|
||||
anilist,
|
||||
mpv,
|
||||
yomitan,
|
||||
|
||||
@@ -6,6 +6,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
| 'ankiConnect'
|
||||
| 'jimaku'
|
||||
| 'tsukihime'
|
||||
| 'tmdb'
|
||||
| 'anilist'
|
||||
| 'mpv'
|
||||
| 'yomitan'
|
||||
@@ -112,6 +113,10 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
apiBaseUrl: 'https://api.tsukihime.org/v1',
|
||||
maxSearchResults: 10,
|
||||
},
|
||||
tmdb: {
|
||||
apiKey: '',
|
||||
apiKeyCommand: '',
|
||||
},
|
||||
mpv: {
|
||||
executablePath: '',
|
||||
launchMode: 'normal',
|
||||
|
||||
@@ -472,6 +472,20 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.tsukihime.maxSearchResults,
|
||||
description: 'Maximum TsukiHime search results returned.',
|
||||
},
|
||||
{
|
||||
path: 'tmdb.apiKey',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.tmdb.apiKey,
|
||||
description:
|
||||
'Your own TMDB API key or read access token for live-action posters and synopses in the stats Library. Release builds bundle a project key, so set this only to use your own quota or when running from source (free under Settings > API on themoviedb.org).',
|
||||
},
|
||||
{
|
||||
path: 'tmdb.apiKeyCommand',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.tmdb.apiKeyCommand,
|
||||
description:
|
||||
'Shell command that prints the TMDB API key to stdout. Used instead of apiKey to avoid storing the key in plain text.',
|
||||
},
|
||||
{
|
||||
path: 'anilist.enabled',
|
||||
kind: 'boolean',
|
||||
|
||||
@@ -164,6 +164,14 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
notes: ['Hot-reload: TsukiHime changes apply to the next TsukiHime request.'],
|
||||
key: 'tsukihime',
|
||||
},
|
||||
{
|
||||
title: 'TMDB',
|
||||
description: [
|
||||
'TMDB (The Movie Database) metadata for live-action dramas and movies in the stats Library: posters, synopses, and grouping by show.',
|
||||
],
|
||||
notes: ['Hot-reload: TMDB changes apply to the next TMDB request.'],
|
||||
key: 'tmdb',
|
||||
},
|
||||
{
|
||||
title: 'YouTube Playback Settings',
|
||||
description: [
|
||||
|
||||
@@ -58,6 +58,19 @@ export function applyIntegrationConfig(context: ResolveContext): void {
|
||||
warn('ai', src.ai, resolved.ai, 'Expected object.');
|
||||
}
|
||||
|
||||
if (isObject(src.tmdb)) {
|
||||
for (const key of ['apiKey', 'apiKeyCommand'] as const) {
|
||||
const value = asString(src.tmdb[key]);
|
||||
if (value !== undefined) {
|
||||
resolved.tmdb[key] = value;
|
||||
} else if (src.tmdb[key] !== undefined) {
|
||||
warn(`tmdb.${key}`, src.tmdb[key], resolved.tmdb[key], 'Expected string.');
|
||||
}
|
||||
}
|
||||
} else if (src.tmdb !== undefined) {
|
||||
warn('tmdb', src.tmdb, resolved.tmdb, 'Expected object.');
|
||||
}
|
||||
|
||||
if (isObject(src.anilist)) {
|
||||
const enabled = asBoolean(src.anilist.enabled);
|
||||
if (enabled !== undefined) {
|
||||
|
||||
@@ -275,6 +275,9 @@ test('settings registry routes playback-related integrations into integrations',
|
||||
assert.equal(field('subsync.replace').section, 'Subtitle Sync');
|
||||
assert.equal(field('tsukihime.apiBaseUrl').category, 'integrations');
|
||||
assert.equal(field('tsukihime.apiBaseUrl').section, 'TsukiHime');
|
||||
assert.equal(field('tmdb.apiKey').category, 'integrations');
|
||||
assert.equal(field('tmdb.apiKey').section, 'TMDB');
|
||||
assert.equal(field('tmdb.apiKey').secret, true);
|
||||
});
|
||||
|
||||
test('settings registry puts feature toggles first, then other toggles alphabetically', () => {
|
||||
|
||||
@@ -93,7 +93,12 @@ const JSON_OBJECT_FIELDS = new Set([
|
||||
'subtitleSidebar.css',
|
||||
]);
|
||||
|
||||
export const SECRET_PATHS = new Set(['ai.apiKey', 'jimaku.apiKey', 'anilist.accessToken']);
|
||||
export const SECRET_PATHS = new Set([
|
||||
'ai.apiKey',
|
||||
'jimaku.apiKey',
|
||||
'tmdb.apiKey',
|
||||
'anilist.accessToken',
|
||||
]);
|
||||
|
||||
const COLOR_SUFFIXES = new Set(['Color', 'color', 'backgroundColor', 'singleColor']);
|
||||
const SUBTITLE_CSS_MANAGED_CONFIG_PATHS = new Set([
|
||||
@@ -135,6 +140,7 @@ const SECTION_ORDER = new Map<string, number>(
|
||||
'Anki AI',
|
||||
'AnkiConnect Proxy',
|
||||
'Jimaku',
|
||||
'TMDB',
|
||||
'Subtitle Sync',
|
||||
'MPV Keybindings',
|
||||
'Overlay Shortcuts',
|
||||
@@ -327,6 +333,7 @@ function humanizePath(path: string): string {
|
||||
.replace(/\bmpv\b/i, 'mpv')
|
||||
.replace(/\byomitan\b/i, 'Yomitan')
|
||||
.replace(/\bjimaku\b/i, 'Jimaku')
|
||||
.replace(/\btmdb\b/i, 'TMDB')
|
||||
.replace(/\banilist\b/i, 'AniList')
|
||||
.replace(/\banki\b/i, 'Anki');
|
||||
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
||||
@@ -442,7 +449,7 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
||||
if (path.startsWith('mpv.') || path.startsWith('youtube.')) {
|
||||
return { category: 'behavior', section: topSection(path) };
|
||||
}
|
||||
if (path.startsWith('jimaku.') || path.startsWith('tsukihime.')) {
|
||||
if (path.startsWith('jimaku.') || path.startsWith('tsukihime.') || path.startsWith('tmdb.')) {
|
||||
return { category: 'integrations', section: topSection(path) };
|
||||
}
|
||||
if (path.startsWith('subsync.')) {
|
||||
@@ -510,6 +517,7 @@ function topSection(path: string): string {
|
||||
subsync: 'Subtitle Sync',
|
||||
texthooker: 'Texthooker',
|
||||
tsukihime: 'TsukiHime',
|
||||
tmdb: 'TMDB',
|
||||
updates: 'Updates',
|
||||
websocket: 'WebSocket server',
|
||||
yomitan: 'Yomitan',
|
||||
@@ -732,6 +740,7 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
|
||||
pathStartsWith(path, 'notifications') ||
|
||||
path === 'youtube.primarySubLanguages' ||
|
||||
pathStartsWith(path, 'jimaku') ||
|
||||
pathStartsWith(path, 'tmdb') ||
|
||||
pathStartsWith(path, 'subsync') ||
|
||||
pathStartsWith(path, 'subtitleGeneration')
|
||||
) {
|
||||
|
||||
@@ -540,3 +540,186 @@ test('fetchIfMissing re-resolves an unresolved season once AniList publishes the
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('fetchIfMissing falls back to TMDB when AniList has no match and stores the poster', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
ensureSchema(db);
|
||||
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/hanzawa-01.mkv', {
|
||||
canonicalTitle: 'Hanzawa Naoki - 01.mkv',
|
||||
sourcePath: '/tmp/hanzawa-01.mkv',
|
||||
sourceUrl: null,
|
||||
sourceType: SOURCE_TYPE_LOCAL,
|
||||
});
|
||||
const animeId = getOrCreateAnimeRecord(db, {
|
||||
parsedTitle: 'Hanzawa Naoki',
|
||||
canonicalTitle: 'Hanzawa Naoki',
|
||||
anilistId: null,
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
titleNative: null,
|
||||
metadataJson: null,
|
||||
});
|
||||
linkVideoToAnimeRecord(db, videoId, {
|
||||
animeId,
|
||||
parsedBasename: null,
|
||||
parsedTitle: 'Hanzawa Naoki',
|
||||
parsedSeason: null,
|
||||
parsedEpisode: 1,
|
||||
parserSource: 'fallback',
|
||||
parserConfidence: 1,
|
||||
parseMetadataJson: null,
|
||||
});
|
||||
|
||||
const fetchCalls: string[] = [];
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
fetchCalls.push(url);
|
||||
if (url.startsWith('https://graphql.anilist.co')) {
|
||||
return createJsonResponse({ data: { Page: { media: [] } } });
|
||||
}
|
||||
assert.equal(url, 'https://image.tmdb.org/t/p/w500/hanzawa.jpg');
|
||||
return new Response(new Uint8Array([5, 6, 7]), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'image/jpeg' },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
const resolvedTitles: string[] = [];
|
||||
try {
|
||||
const fetcher = createCoverArtFetcher(
|
||||
{ acquire: async () => {}, recordResponse: () => {} },
|
||||
console,
|
||||
{
|
||||
runGuessit: async () => {
|
||||
throw new Error('guessit unavailable');
|
||||
},
|
||||
liveAction: {
|
||||
async resolveByTitle(title) {
|
||||
resolvedTitles.push(title);
|
||||
if (title !== 'Hanzawa Naoki') return null;
|
||||
return {
|
||||
tmdbId: 61222,
|
||||
tmdbType: 'tv',
|
||||
titleEnglish: 'Hanzawa Naoki',
|
||||
titleNative: '半沢直樹',
|
||||
description: 'A banker fights back.',
|
||||
posterUrl: 'https://image.tmdb.org/t/p/w500/hanzawa.jpg',
|
||||
episodesTotal: 10,
|
||||
year: 2013,
|
||||
originalLanguage: 'ja',
|
||||
isAnimation: false,
|
||||
allTitles: ['Hanzawa Naoki', '半沢直樹'],
|
||||
};
|
||||
},
|
||||
async resolveById() {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const fetched = await fetcher.fetchIfMissing(db, videoId, 'Hanzawa Naoki - 01.mkv');
|
||||
const stored = getCoverArt(db, videoId);
|
||||
const anime = db
|
||||
.prepare(
|
||||
'SELECT media_kind AS mediaKind, tmdb_id AS tmdbId, description FROM imm_anime WHERE anime_id = ?',
|
||||
)
|
||||
.get(animeId) as { mediaKind: string; tmdbId: number | null; description: string | null };
|
||||
|
||||
assert.equal(fetched, true);
|
||||
// The raw fallback-parser title is tried first, then the tag-stripped one.
|
||||
assert.deepEqual(resolvedTitles, ['Hanzawa Naoki - 01', 'Hanzawa Naoki']);
|
||||
assert.equal(stored?.anilistId, null);
|
||||
assert.equal(stored?.coverUrl, 'https://image.tmdb.org/t/p/w500/hanzawa.jpg');
|
||||
assert.equal(Buffer.from(stored?.coverBlob ?? []).toString('hex'), '050607');
|
||||
assert.equal(anime.mediaKind, 'live_action');
|
||||
assert.equal(anime.tmdbId, 61222);
|
||||
assert.equal(anime.description, 'A banker fights back.');
|
||||
assert.ok(fetchCalls.some((url) => url.startsWith('https://graphql.anilist.co')));
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('fetchIfMissing skips AniList for an entry already linked to TMDB', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
ensureSchema(db);
|
||||
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/hanzawa-02.mkv', {
|
||||
canonicalTitle: 'Hanzawa Naoki - 02.mkv',
|
||||
sourcePath: '/tmp/hanzawa-02.mkv',
|
||||
sourceUrl: null,
|
||||
sourceType: SOURCE_TYPE_LOCAL,
|
||||
});
|
||||
const animeId = getOrCreateAnimeRecord(db, {
|
||||
parsedTitle: 'Hanzawa Naoki',
|
||||
canonicalTitle: 'Hanzawa Naoki',
|
||||
anilistId: null,
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
titleNative: null,
|
||||
metadataJson: null,
|
||||
});
|
||||
linkVideoToAnimeRecord(db, videoId, {
|
||||
animeId,
|
||||
parsedBasename: null,
|
||||
parsedTitle: 'Hanzawa Naoki',
|
||||
parsedSeason: null,
|
||||
parsedEpisode: 2,
|
||||
parserSource: 'fallback',
|
||||
parserConfidence: 1,
|
||||
parseMetadataJson: null,
|
||||
});
|
||||
db.prepare(
|
||||
"UPDATE imm_anime SET media_kind = 'live_action', tmdb_id = 61222, tmdb_type = 'tv' WHERE anime_id = ?",
|
||||
).run(animeId);
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||
assert.equal(String(input), 'https://image.tmdb.org/t/p/w500/hanzawa.jpg');
|
||||
return new Response(new Uint8Array([1]), { status: 200 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const byIdCalls: Array<[string, number]> = [];
|
||||
try {
|
||||
const fetcher = createCoverArtFetcher(
|
||||
{ acquire: async () => {}, recordResponse: () => {} },
|
||||
console,
|
||||
{
|
||||
liveAction: {
|
||||
async resolveByTitle() {
|
||||
throw new Error('title search must not run for a linked entry');
|
||||
},
|
||||
async resolveById(tmdbType, tmdbId) {
|
||||
byIdCalls.push([tmdbType, tmdbId]);
|
||||
return {
|
||||
tmdbId,
|
||||
tmdbType,
|
||||
titleEnglish: 'Hanzawa Naoki',
|
||||
titleNative: null,
|
||||
description: null,
|
||||
posterUrl: 'https://image.tmdb.org/t/p/w500/hanzawa.jpg',
|
||||
episodesTotal: 10,
|
||||
year: null,
|
||||
originalLanguage: 'ja',
|
||||
isAnimation: false,
|
||||
allTitles: [],
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(await fetcher.fetchIfMissing(db, videoId, 'Hanzawa Naoki - 02.mkv'), true);
|
||||
assert.deepEqual(byIdCalls, [['tv', 61222]]);
|
||||
assert.equal(getCoverArt(db, videoId)?.coverBlob?.length, 1);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -16,6 +16,9 @@ import {
|
||||
type AnilistQueryExecutor,
|
||||
type AnilistSeasonResolution,
|
||||
} from './season-resolver';
|
||||
import { getVideoTmdbLink, linkAnimeToTmdbTitle } from '../immersion-tracker/live-action-link';
|
||||
import type { LiveActionMetadataResolver } from '../tmdb/live-action-resolver';
|
||||
import type { TmdbTitleDetails } from '../tmdb/tmdb-client';
|
||||
|
||||
const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
|
||||
const NO_MATCH_RETRY_MS = 5 * 60 * 1000;
|
||||
@@ -39,6 +42,8 @@ interface CoverArtCandidate {
|
||||
|
||||
interface CoverArtFetcherOptions {
|
||||
runGuessit?: GuessAnilistMediaInfoDeps['runGuessit'];
|
||||
/** Live-action fallback consulted when AniList has no match for a title. */
|
||||
liveAction?: LiveActionMetadataResolver;
|
||||
}
|
||||
|
||||
export function stripFilenameTags(raw: string): string {
|
||||
@@ -152,6 +157,55 @@ export function createCoverArtFetcher(
|
||||
return true;
|
||||
};
|
||||
|
||||
const cacheNoMatch = (db: DatabaseSync, videoId: number): void => {
|
||||
upsertCoverArt(db, videoId, {
|
||||
anilistId: null,
|
||||
coverUrl: null,
|
||||
coverBlob: null,
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
episodesTotal: null,
|
||||
});
|
||||
};
|
||||
|
||||
// Links the video's library entry to the TMDB title and stores its poster.
|
||||
const storeLiveActionArt = async (
|
||||
db: DatabaseSync,
|
||||
videoId: number,
|
||||
details: TmdbTitleDetails,
|
||||
): Promise<boolean> => {
|
||||
const row = db
|
||||
.prepare('SELECT anime_id AS animeId FROM imm_videos WHERE video_id = ?')
|
||||
.get(videoId) as { animeId: number | null } | undefined;
|
||||
if (row?.animeId) {
|
||||
const link = linkAnimeToTmdbTitle(db, row.animeId, details, { mode: 'auto' });
|
||||
if (link.mergedAnimeIds.length > 0) {
|
||||
logger.info(
|
||||
'cover-art: folded library entries %s into %d (same TMDB title)',
|
||||
link.mergedAnimeIds.join(','),
|
||||
link.animeId,
|
||||
);
|
||||
}
|
||||
}
|
||||
const coverBlob = details.posterUrl ? await downloadImage(details.posterUrl) : null;
|
||||
upsertCoverArt(db, videoId, {
|
||||
anilistId: null,
|
||||
coverUrl: details.posterUrl,
|
||||
coverBlob,
|
||||
titleRomaji: null,
|
||||
titleEnglish: details.titleEnglish,
|
||||
episodesTotal: details.episodesTotal,
|
||||
});
|
||||
logger.info(
|
||||
'cover-art: linked videoId=%d to TMDB %s/%d "%s"',
|
||||
videoId,
|
||||
details.tmdbType,
|
||||
details.tmdbId,
|
||||
details.titleEnglish ?? details.titleNative ?? '',
|
||||
);
|
||||
return coverBlob !== null;
|
||||
};
|
||||
|
||||
const resolveCanonicalTitle = (
|
||||
db: DatabaseSync,
|
||||
videoId: number,
|
||||
@@ -225,18 +279,23 @@ export function createCoverArtFetcher(
|
||||
return false;
|
||||
}
|
||||
|
||||
// A live-action entry already knows its TMDB title; AniList has nothing
|
||||
// to add and would only produce a spurious anime match.
|
||||
const tmdbLink = getVideoTmdbLink(db, videoId);
|
||||
if (tmdbLink) {
|
||||
const details = await options.liveAction?.resolveById(tmdbLink.tmdbType, tmdbLink.tmdbId);
|
||||
if (details) {
|
||||
return storeLiveActionArt(db, videoId, details);
|
||||
}
|
||||
cacheNoMatch(db, videoId);
|
||||
return false;
|
||||
}
|
||||
|
||||
const effectiveTitle = resolveCanonicalTitle(db, videoId, canonicalTitle);
|
||||
const cleaned = stripFilenameTags(effectiveTitle);
|
||||
if (!cleaned) {
|
||||
logger.warn('cover-art: empty title after stripping tags for videoId=%d', videoId);
|
||||
upsertCoverArt(db, videoId, {
|
||||
anilistId: null,
|
||||
coverUrl: null,
|
||||
coverBlob: null,
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
episodesTotal: null,
|
||||
});
|
||||
cacheNoMatch(db, videoId);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -294,15 +353,16 @@ export function createCoverArtFetcher(
|
||||
|
||||
const selected = resolution?.media ?? null;
|
||||
if (!selected) {
|
||||
logger.info('cover-art: no Anilist results for "%s", caching no-match', searchBase);
|
||||
upsertCoverArt(db, videoId, {
|
||||
anilistId: null,
|
||||
coverUrl: null,
|
||||
coverBlob: null,
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
episodesTotal: null,
|
||||
});
|
||||
if (options.liveAction) {
|
||||
for (const searchTitle of searchTitles) {
|
||||
const details = await options.liveAction.resolveByTitle(searchTitle);
|
||||
if (details) {
|
||||
return storeLiveActionArt(db, videoId, details);
|
||||
}
|
||||
}
|
||||
}
|
||||
logger.info('cover-art: no Anilist or TMDB results for "%s", caching no-match', searchBase);
|
||||
cacheNoMatch(db, videoId);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@ import {
|
||||
} from './immersion-tracker/query-library';
|
||||
import {
|
||||
cleanupVocabularyStats,
|
||||
clearAnimeCoverArt,
|
||||
getVideoDurationMs,
|
||||
markVideoWatched,
|
||||
upsertCoverArt,
|
||||
@@ -124,6 +125,11 @@ import {
|
||||
type AnimeMergeSummary,
|
||||
type VideoMoveSummary,
|
||||
} from './immersion-tracker/anime-merge';
|
||||
import {
|
||||
linkAnimeToTmdbTitle,
|
||||
type LiveActionLinkResult,
|
||||
type LiveActionTitleInput,
|
||||
} from './immersion-tracker/live-action-link';
|
||||
import {
|
||||
buildVideoKey,
|
||||
deriveCanonicalTitle,
|
||||
@@ -1029,6 +1035,9 @@ export class ImmersionTrackerService {
|
||||
`
|
||||
UPDATE imm_anime
|
||||
SET anilist_id = ?,
|
||||
media_kind = 'anime',
|
||||
tmdb_id = NULL,
|
||||
tmdb_type = NULL,
|
||||
title_romaji = COALESCE(?, title_romaji),
|
||||
title_english = COALESCE(?, title_english),
|
||||
title_native = COALESCE(?, title_native),
|
||||
@@ -1061,30 +1070,76 @@ export class ImmersionTrackerService {
|
||||
recomputeLifetimeGlobalFromSummaries(this.db);
|
||||
}
|
||||
|
||||
// Update cover art for all videos in this anime
|
||||
if (info.coverUrl) {
|
||||
const videos = this.db
|
||||
.prepare('SELECT video_id FROM imm_videos WHERE anime_id = ?')
|
||||
.all(animeId) as Array<{ video_id: number }>;
|
||||
let coverBlob: Buffer | null = null;
|
||||
try {
|
||||
const res = await fetch(info.coverUrl);
|
||||
if (res.ok) {
|
||||
coverBlob = Buffer.from(await res.arrayBuffer());
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
for (const v of videos) {
|
||||
upsertCoverArt(this.db, v.video_id, {
|
||||
anilistId: info.anilistId,
|
||||
coverUrl: info.coverUrl,
|
||||
coverBlob,
|
||||
titleRomaji: info.titleRomaji ?? null,
|
||||
titleEnglish: info.titleEnglish ?? null,
|
||||
episodesTotal: info.episodesTotal ?? null,
|
||||
});
|
||||
await this.applyCoverArtToAnimeVideos(animeId, {
|
||||
anilistId: info.anilistId,
|
||||
coverUrl: info.coverUrl,
|
||||
titleRomaji: info.titleRomaji ?? null,
|
||||
titleEnglish: info.titleEnglish ?? null,
|
||||
episodesTotal: info.episodesTotal ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Link a library entry to a TMDB title chosen in the dashboard. Every other
|
||||
* entry pointing at the same title is folded into this one, and its poster
|
||||
* replaces the art of every episode.
|
||||
*/
|
||||
async reassignAnimeTmdb(
|
||||
animeId: number,
|
||||
details: LiveActionTitleInput & { posterUrl: string | null },
|
||||
): Promise<LiveActionLinkResult> {
|
||||
this.requireWriteQueueDrained('linking a TMDB title');
|
||||
const result = linkAnimeToTmdbTitle(this.db, animeId, details, { mode: 'manual' });
|
||||
if (details.posterUrl) {
|
||||
await this.applyCoverArtToAnimeVideos(result.animeId, {
|
||||
anilistId: null,
|
||||
coverUrl: details.posterUrl,
|
||||
titleRomaji: null,
|
||||
titleEnglish: details.titleEnglish,
|
||||
episodesTotal: details.episodesTotal,
|
||||
});
|
||||
} else {
|
||||
// The user chose this title deliberately, so art from the previous link
|
||||
// must not keep standing in for it.
|
||||
clearAnimeCoverArt(this.db, result.animeId);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Downloads one cover and stores it against every episode of the entry. */
|
||||
private async applyCoverArtToAnimeVideos(
|
||||
animeId: number,
|
||||
art: {
|
||||
anilistId: number | null;
|
||||
coverUrl: string;
|
||||
titleRomaji: string | null;
|
||||
titleEnglish: string | null;
|
||||
episodesTotal: number | null;
|
||||
},
|
||||
): Promise<void> {
|
||||
const videos = this.db
|
||||
.prepare('SELECT video_id FROM imm_videos WHERE anime_id = ?')
|
||||
.all(animeId) as Array<{ video_id: number }>;
|
||||
let coverBlob: Buffer | null = null;
|
||||
try {
|
||||
const res = await fetch(art.coverUrl);
|
||||
if (res.ok) {
|
||||
coverBlob = Buffer.from(await res.arrayBuffer());
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
for (const v of videos) {
|
||||
upsertCoverArt(this.db, v.video_id, {
|
||||
anilistId: art.anilistId,
|
||||
coverUrl: art.coverUrl,
|
||||
coverBlob,
|
||||
titleRomaji: art.titleRomaji,
|
||||
titleEnglish: art.titleEnglish,
|
||||
episodesTotal: art.episodesTotal,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { Database } from '../sqlite.js';
|
||||
import type { DatabaseSync } from '../sqlite.js';
|
||||
import { applyPragmas, ensureSchema, getOrCreateAnimeRecord } from '../storage.js';
|
||||
import { repairLegacySeasonlessAnimeRows } from '../anime-season-repair.js';
|
||||
import { getVideoTmdbLink, linkAnimeToTmdbTitle } from '../live-action-link.js';
|
||||
import { getAnimeCoverArt, getCoverArt } from '../query-library.js';
|
||||
import { clearAnimeCoverArt, upsertCoverArt } from '../query-maintenance.js';
|
||||
|
||||
const BASE_MS = 1_700_000_000_000;
|
||||
|
||||
function withDb(work: (db: DatabaseSync) => void): void {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-live-action-link-'));
|
||||
const db = new Database(path.join(dir, 'immersion.sqlite'));
|
||||
try {
|
||||
applyPragmas(db);
|
||||
ensureSchema(db);
|
||||
work(db);
|
||||
} finally {
|
||||
db.close();
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function insertAnime(
|
||||
db: DatabaseSync,
|
||||
animeId: number,
|
||||
title: string,
|
||||
anilistId: number | null = null,
|
||||
) {
|
||||
db.prepare(
|
||||
`INSERT INTO imm_anime(anime_id, normalized_title_key, canonical_title, anilist_id, CREATED_DATE, LAST_UPDATE_DATE)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
).run(animeId, title.toLowerCase(), title, anilistId, BASE_MS, BASE_MS);
|
||||
}
|
||||
|
||||
function insertEpisode(db: DatabaseSync, videoId: number, animeId: number, season: number | null) {
|
||||
db.prepare(
|
||||
`INSERT INTO imm_videos(video_id, video_key, anime_id, canonical_title, source_type, parsed_title, parsed_season, parsed_episode, watched, duration_ms, CREATED_DATE, LAST_UPDATE_DATE)
|
||||
VALUES (?, ?, ?, ?, 1, 'Hanzawa Naoki', ?, ?, 1, 1440000, ?, ?)`,
|
||||
).run(
|
||||
videoId,
|
||||
`local:/tmp/${videoId}.mkv`,
|
||||
animeId,
|
||||
`Ep ${videoId}`,
|
||||
season,
|
||||
videoId,
|
||||
BASE_MS,
|
||||
BASE_MS,
|
||||
);
|
||||
db.prepare(
|
||||
`INSERT INTO imm_lifetime_media(video_id, total_sessions, total_active_ms, total_cards, completed, first_watched_ms, last_watched_ms, CREATED_DATE, LAST_UPDATE_DATE)
|
||||
VALUES (?, 1, 1000, 0, 1, ?, ?, ?, ?)`,
|
||||
).run(videoId, String(BASE_MS), String(BASE_MS + 1000), BASE_MS, BASE_MS);
|
||||
}
|
||||
|
||||
interface AnimeRowView {
|
||||
mediaKind: string;
|
||||
tmdbId: number | null;
|
||||
tmdbType: string | null;
|
||||
anilistId: number | null;
|
||||
titleEnglish: string | null;
|
||||
titleNative: string | null;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
// Copies the selected columns so the driver's row metadata does not leak into
|
||||
// deep-equality assertions.
|
||||
function animeRow(db: DatabaseSync, animeId: number): AnimeRowView | undefined {
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT media_kind AS mediaKind, tmdb_id AS tmdbId, tmdb_type AS tmdbType, anilist_id AS anilistId,
|
||||
title_english AS titleEnglish, title_native AS titleNative, description
|
||||
FROM imm_anime WHERE anime_id = ?`,
|
||||
)
|
||||
.get(animeId) as AnimeRowView | undefined;
|
||||
if (!row) return undefined;
|
||||
const { mediaKind, tmdbId, tmdbType, anilistId, titleEnglish, titleNative, description } = row;
|
||||
return { mediaKind, tmdbId, tmdbType, anilistId, titleEnglish, titleNative, description };
|
||||
}
|
||||
|
||||
function animeCount(db: DatabaseSync): number {
|
||||
return (db.prepare('SELECT COUNT(*) AS n FROM imm_anime').get() as { n: number }).n;
|
||||
}
|
||||
|
||||
function videoOwner(db: DatabaseSync, videoId: number): number | null {
|
||||
return (
|
||||
db.prepare('SELECT anime_id AS animeId FROM imm_videos WHERE video_id = ?').get(videoId) as {
|
||||
animeId: number | null;
|
||||
}
|
||||
).animeId;
|
||||
}
|
||||
|
||||
const HANZAWA = {
|
||||
tmdbId: 61222,
|
||||
tmdbType: 'tv' as const,
|
||||
titleEnglish: 'Hanzawa Naoki',
|
||||
titleNative: '半沢直樹',
|
||||
description: 'A banker fights back.',
|
||||
episodesTotal: 10,
|
||||
};
|
||||
|
||||
test('a manual link overwrites metadata, drops the AniList link, and folds other holders in', () => {
|
||||
withDb((db) => {
|
||||
insertAnime(db, 1, 'Hanzawa Naoki', 4242);
|
||||
insertAnime(db, 2, 'Hanzawa Naoki Season 2');
|
||||
insertEpisode(db, 1, 1, 1);
|
||||
insertEpisode(db, 2, 2, 2);
|
||||
db.prepare(
|
||||
`UPDATE imm_anime SET media_kind = 'live_action', tmdb_id = ?, tmdb_type = 'tv', description = 'old' WHERE anime_id = 2`,
|
||||
).run(HANZAWA.tmdbId);
|
||||
|
||||
const result = linkAnimeToTmdbTitle(db, 1, HANZAWA, { mode: 'manual' });
|
||||
|
||||
assert.deepEqual(result, { animeId: 1, mergedAnimeIds: [2] });
|
||||
assert.deepEqual(animeRow(db, 1), {
|
||||
mediaKind: 'live_action',
|
||||
tmdbId: 61222,
|
||||
tmdbType: 'tv',
|
||||
anilistId: null,
|
||||
titleEnglish: 'Hanzawa Naoki',
|
||||
titleNative: '半沢直樹',
|
||||
description: 'A banker fights back.',
|
||||
});
|
||||
assert.equal(animeRow(db, 2), undefined);
|
||||
assert.equal(animeCount(db), 1);
|
||||
assert.equal(videoOwner(db, 2), 1);
|
||||
assert.deepEqual(getVideoTmdbLink(db, 2), { animeId: 1, tmdbId: 61222, tmdbType: 'tv' });
|
||||
});
|
||||
});
|
||||
|
||||
test('an automatic link joins the entry that already owns the title and only fills gaps', () => {
|
||||
withDb((db) => {
|
||||
insertAnime(db, 1, 'Hanzawa Naoki');
|
||||
insertEpisode(db, 1, 1, 1);
|
||||
linkAnimeToTmdbTitle(db, 1, { ...HANZAWA, description: 'kept' }, { mode: 'manual' });
|
||||
const newcomer = getOrCreateAnimeRecord(db, {
|
||||
parsedTitle: 'Hanzawa Naoki',
|
||||
canonicalTitle: 'Hanzawa Naoki',
|
||||
seasonScope: 2,
|
||||
anilistId: null,
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
titleNative: null,
|
||||
metadataJson: null,
|
||||
});
|
||||
insertEpisode(db, 2, newcomer, 2);
|
||||
|
||||
const result = linkAnimeToTmdbTitle(db, newcomer, HANZAWA, { mode: 'auto' });
|
||||
|
||||
assert.deepEqual(result, { animeId: 1, mergedAnimeIds: [newcomer] });
|
||||
assert.equal(animeRow(db, 1)?.description, 'kept');
|
||||
assert.equal(animeCount(db), 1);
|
||||
assert.equal(videoOwner(db, 2), 1);
|
||||
// The merged-away season title is remembered, so the next episode of that
|
||||
// season lands on the survivor without a detour through a new row.
|
||||
const again = getOrCreateAnimeRecord(db, {
|
||||
parsedTitle: 'Hanzawa Naoki',
|
||||
canonicalTitle: 'Hanzawa Naoki',
|
||||
seasonScope: 2,
|
||||
anilistId: null,
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
titleNative: null,
|
||||
metadataJson: null,
|
||||
});
|
||||
assert.equal(again, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test('startup season repair leaves multi-season live-action entries alone', () => {
|
||||
withDb((db) => {
|
||||
insertAnime(db, 1, 'Hanzawa Naoki');
|
||||
insertEpisode(db, 1, 1, 1);
|
||||
insertEpisode(db, 2, 1, 2);
|
||||
linkAnimeToTmdbTitle(db, 1, HANZAWA, { mode: 'manual' });
|
||||
|
||||
repairLegacySeasonlessAnimeRows(db);
|
||||
|
||||
assert.equal(animeCount(db), 1);
|
||||
assert.equal(videoOwner(db, 1), 1);
|
||||
assert.equal(videoOwner(db, 2), 1);
|
||||
assert.equal(getVideoTmdbLink(db, 2)?.animeId, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test('getVideoTmdbLink is null for anime entries and unlinked videos', () => {
|
||||
withDb((db) => {
|
||||
insertAnime(db, 1, 'Some Anime', 77);
|
||||
insertEpisode(db, 1, 1, 1);
|
||||
assert.equal(getVideoTmdbLink(db, 1), null);
|
||||
assert.equal(getVideoTmdbLink(db, 99), null);
|
||||
});
|
||||
});
|
||||
|
||||
test('clearAnimeCoverArt drops every episode cover of the entry and its orphaned blob', () => {
|
||||
withDb((db) => {
|
||||
insertAnime(db, 1, 'Hanzawa Naoki');
|
||||
insertAnime(db, 2, 'Other Show');
|
||||
insertEpisode(db, 1, 1, 1);
|
||||
insertEpisode(db, 2, 1, 1);
|
||||
insertEpisode(db, 3, 2, 1);
|
||||
const shared = Buffer.from([1, 2, 3]);
|
||||
for (const videoId of [1, 2]) {
|
||||
upsertCoverArt(db, videoId, {
|
||||
anilistId: 4242,
|
||||
coverUrl: 'https://images.test/a.jpg',
|
||||
coverBlob: shared,
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
episodesTotal: null,
|
||||
});
|
||||
}
|
||||
upsertCoverArt(db, 3, {
|
||||
anilistId: 99,
|
||||
coverUrl: 'https://images.test/b.jpg',
|
||||
coverBlob: Buffer.from([9]),
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
episodesTotal: null,
|
||||
});
|
||||
|
||||
clearAnimeCoverArt(db, 1);
|
||||
|
||||
assert.equal(getAnimeCoverArt(db, 1), null);
|
||||
assert.equal(getCoverArt(db, 3)?.coverBlob?.length, 1);
|
||||
const blobs = (
|
||||
db.prepare('SELECT COUNT(*) AS n FROM imm_cover_art_blobs').get() as { n: number }
|
||||
).n;
|
||||
assert.equal(blobs, 1);
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,9 @@ interface AnimeMetadataRow {
|
||||
title_native: string | null;
|
||||
episodes_total: number | null;
|
||||
description: string | null;
|
||||
media_kind: string;
|
||||
tmdb_id: number | null;
|
||||
tmdb_type: string | null;
|
||||
}
|
||||
|
||||
function emptyMergeSummary(survivingAnimeId: number): AnimeMergeSummary {
|
||||
@@ -52,7 +55,8 @@ function readAnimeMetadata(db: DatabaseSync, animeId: number): AnimeMetadataRow
|
||||
return (db
|
||||
.prepare(
|
||||
`
|
||||
SELECT normalized_title_key, anilist_id, title_romaji, title_english, title_native, episodes_total, description
|
||||
SELECT normalized_title_key, anilist_id, title_romaji, title_english, title_native, episodes_total, description,
|
||||
media_kind, tmdb_id, tmdb_type
|
||||
FROM imm_anime
|
||||
WHERE anime_id = ?
|
||||
`,
|
||||
@@ -131,6 +135,12 @@ function absorbAnimeMetadata(
|
||||
title_native = COALESCE(title_native, ?),
|
||||
episodes_total = COALESCE(episodes_total, ?),
|
||||
description = COALESCE(description, ?),
|
||||
tmdb_id = COALESCE(tmdb_id, ?),
|
||||
tmdb_type = CASE WHEN tmdb_id IS NULL THEN ? ELSE tmdb_type END,
|
||||
media_kind = CASE
|
||||
WHEN anilist_id IS NULL AND tmdb_id IS NULL AND ? IS NOT NULL THEN ?
|
||||
ELSE media_kind
|
||||
END,
|
||||
LAST_UPDATE_DATE = ?
|
||||
WHERE anime_id = ?
|
||||
`,
|
||||
@@ -141,6 +151,10 @@ function absorbAnimeMetadata(
|
||||
source.title_native,
|
||||
source.episodes_total,
|
||||
source.description,
|
||||
source.tmdb_id,
|
||||
source.tmdb_type,
|
||||
source.tmdb_id,
|
||||
source.media_kind,
|
||||
updatedAt,
|
||||
targetAnimeId,
|
||||
);
|
||||
|
||||
@@ -335,7 +335,8 @@ export function repairLegacySeasonlessAnimeRows(db: DatabaseSync): AnimeSeasonRe
|
||||
SELECT a.anime_id AS animeId
|
||||
FROM imm_anime a
|
||||
JOIN imm_videos v ON v.anime_id = a.anime_id
|
||||
WHERE v.parsed_title IS NOT NULL
|
||||
WHERE a.media_kind != 'live_action'
|
||||
AND v.parsed_title IS NOT NULL
|
||||
AND TRIM(v.parsed_title) != ''
|
||||
AND v.parsed_season IS NOT NULL
|
||||
AND v.parsed_season > 0
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { DatabaseSync } from './sqlite';
|
||||
import type { TmdbMediaType } from '../../../shared/media-kind';
|
||||
import { mergeAnimeRecords } from './anime-merge';
|
||||
import { toDbTimestamp } from './query-shared';
|
||||
import { nowMs } from './time';
|
||||
|
||||
export interface LiveActionTitleInput {
|
||||
tmdbId: number;
|
||||
tmdbType: TmdbMediaType;
|
||||
titleEnglish: string | null;
|
||||
titleNative: string | null;
|
||||
description: string | null;
|
||||
episodesTotal: number | null;
|
||||
}
|
||||
|
||||
export interface LiveActionLinkResult {
|
||||
/** Library entry that carries the TMDB link once the call finishes. */
|
||||
animeId: number;
|
||||
/** Entries folded into `animeId` because they pointed at the same TMDB title. */
|
||||
mergedAnimeIds: number[];
|
||||
}
|
||||
|
||||
export interface LiveActionLinkOptions {
|
||||
/**
|
||||
* `manual`: the user picked this title, so stored titles are overwritten and
|
||||
* every other holder of the TMDB id is folded into this entry.
|
||||
* `auto`: an exact filename match, so gaps are filled and the entry joins an
|
||||
* existing holder rather than displacing it.
|
||||
*/
|
||||
mode: 'manual' | 'auto';
|
||||
}
|
||||
|
||||
export interface VideoTmdbLink {
|
||||
animeId: number;
|
||||
tmdbId: number;
|
||||
tmdbType: TmdbMediaType;
|
||||
}
|
||||
|
||||
function findOtherTmdbHolders(
|
||||
db: DatabaseSync,
|
||||
animeId: number,
|
||||
input: Pick<LiveActionTitleInput, 'tmdbId' | 'tmdbType'>,
|
||||
): number[] {
|
||||
return (
|
||||
db
|
||||
.prepare(
|
||||
`SELECT anime_id AS animeId
|
||||
FROM imm_anime
|
||||
WHERE tmdb_id = ? AND tmdb_type = ? AND anime_id != ?
|
||||
ORDER BY anime_id ASC`,
|
||||
)
|
||||
.all(input.tmdbId, input.tmdbType, animeId) as Array<{ animeId: number }>
|
||||
).map((row) => row.animeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Link a library entry to a TMDB title. Unlike AniList, a TMDB show spans all
|
||||
* of its seasons, so entries that resolve to the same title are one show and
|
||||
* are merged regardless of the season each was parsed with.
|
||||
*/
|
||||
export function linkAnimeToTmdbTitle(
|
||||
db: DatabaseSync,
|
||||
animeId: number,
|
||||
input: LiveActionTitleInput,
|
||||
options: LiveActionLinkOptions,
|
||||
): LiveActionLinkResult {
|
||||
const others = findOtherTmdbHolders(db, animeId, input);
|
||||
let survivor = animeId;
|
||||
let mergedAnimeIds: number[] = [];
|
||||
if (others.length > 0) {
|
||||
if (options.mode === 'manual') {
|
||||
mergedAnimeIds = mergeAnimeRecords(db, animeId, others).mergedAnimeIds;
|
||||
} else {
|
||||
// Keep the entry the user already sees; the newcomer is the transient
|
||||
// "Show Season 3" row that a fresh season folder just created.
|
||||
survivor = others[0]!;
|
||||
mergedAnimeIds = mergeAnimeRecords(db, survivor, [
|
||||
animeId,
|
||||
...others.slice(1),
|
||||
]).mergedAnimeIds;
|
||||
}
|
||||
}
|
||||
|
||||
const updatedAt = toDbTimestamp(nowMs());
|
||||
if (options.mode === 'manual') {
|
||||
db.prepare(
|
||||
`UPDATE imm_anime
|
||||
SET media_kind = 'live_action',
|
||||
tmdb_id = ?,
|
||||
tmdb_type = ?,
|
||||
anilist_id = NULL,
|
||||
title_romaji = NULL,
|
||||
title_english = ?,
|
||||
title_native = ?,
|
||||
episodes_total = ?,
|
||||
description = ?,
|
||||
LAST_UPDATE_DATE = ?
|
||||
WHERE anime_id = ?`,
|
||||
).run(
|
||||
input.tmdbId,
|
||||
input.tmdbType,
|
||||
input.titleEnglish,
|
||||
input.titleNative,
|
||||
input.episodesTotal,
|
||||
input.description,
|
||||
updatedAt,
|
||||
survivor,
|
||||
);
|
||||
} else {
|
||||
db.prepare(
|
||||
`UPDATE imm_anime
|
||||
SET media_kind = 'live_action',
|
||||
tmdb_id = ?,
|
||||
tmdb_type = ?,
|
||||
title_english = COALESCE(title_english, ?),
|
||||
title_native = COALESCE(title_native, ?),
|
||||
episodes_total = COALESCE(episodes_total, ?),
|
||||
description = COALESCE(description, ?),
|
||||
LAST_UPDATE_DATE = ?
|
||||
WHERE anime_id = ?`,
|
||||
).run(
|
||||
input.tmdbId,
|
||||
input.tmdbType,
|
||||
input.titleEnglish,
|
||||
input.titleNative,
|
||||
input.episodesTotal,
|
||||
input.description,
|
||||
updatedAt,
|
||||
survivor,
|
||||
);
|
||||
}
|
||||
return { animeId: survivor, mergedAnimeIds };
|
||||
}
|
||||
|
||||
/** The TMDB link of the live-action entry a video belongs to, if any. */
|
||||
export function getVideoTmdbLink(db: DatabaseSync, videoId: number): VideoTmdbLink | null {
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT a.anime_id AS animeId, a.tmdb_id AS tmdbId, a.tmdb_type AS tmdbType
|
||||
FROM imm_videos v
|
||||
JOIN imm_anime a ON a.anime_id = v.anime_id
|
||||
WHERE v.video_id = ?
|
||||
AND a.media_kind = 'live_action'
|
||||
AND a.tmdb_id IS NOT NULL
|
||||
AND a.tmdb_type IN ('tv', 'movie')`,
|
||||
)
|
||||
.get(videoId) as VideoTmdbLink | undefined;
|
||||
return row ? { animeId: row.animeId, tmdbId: row.tmdbId, tmdbType: row.tmdbType } : null;
|
||||
}
|
||||
@@ -34,6 +34,9 @@ export function getAnimeLibrary(db: DatabaseSync): AnimeLibraryRow[] {
|
||||
a.anime_id AS animeId,
|
||||
a.canonical_title AS canonicalTitle,
|
||||
a.anilist_id AS anilistId,
|
||||
a.media_kind AS mediaKind,
|
||||
a.tmdb_id AS tmdbId,
|
||||
a.tmdb_type AS tmdbType,
|
||||
COALESCE(lm.total_sessions, 0) AS totalSessions,
|
||||
COALESCE(lm.total_active_ms, 0) AS totalActiveMs,
|
||||
COALESCE(lm.total_cards, 0) AS totalCards,
|
||||
@@ -64,6 +67,9 @@ export function getAnimeDetail(db: DatabaseSync, animeId: number): AnimeDetailRo
|
||||
a.anime_id AS animeId,
|
||||
a.canonical_title AS canonicalTitle,
|
||||
a.anilist_id AS anilistId,
|
||||
a.media_kind AS mediaKind,
|
||||
a.tmdb_id AS tmdbId,
|
||||
a.tmdb_type AS tmdbType,
|
||||
a.title_romaji AS titleRomaji,
|
||||
a.title_english AS titleEnglish,
|
||||
a.title_native AS titleNative,
|
||||
|
||||
@@ -331,6 +331,29 @@ export async function cleanupVocabularyStats(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the cached art of every episode in a library entry. Used when a manual
|
||||
* relink points at a title with no artwork, so the previous link's cover does
|
||||
* not keep standing in for it.
|
||||
*/
|
||||
export function clearAnimeCoverArt(db: DatabaseSync, animeId: number): void {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT m.cover_blob_hash AS coverBlobHash
|
||||
FROM imm_media_art m
|
||||
JOIN imm_videos v ON v.video_id = m.video_id
|
||||
WHERE v.anime_id = ?`,
|
||||
)
|
||||
.all(animeId) as Array<{ coverBlobHash: string | null }>;
|
||||
if (rows.length === 0) return;
|
||||
db.prepare(
|
||||
'DELETE FROM imm_media_art WHERE video_id IN (SELECT video_id FROM imm_videos WHERE anime_id = ?)',
|
||||
).run(animeId);
|
||||
for (const hash of new Set(rows.map((row) => row.coverBlobHash))) {
|
||||
cleanupUnusedCoverArtBlobHash(db, hash);
|
||||
}
|
||||
}
|
||||
|
||||
export function upsertCoverArt(
|
||||
db: DatabaseSync,
|
||||
videoId: number,
|
||||
|
||||
@@ -916,11 +916,18 @@ export function ensureSchema(db: DatabaseSync): void {
|
||||
title_native TEXT,
|
||||
episodes_total INTEGER,
|
||||
description TEXT,
|
||||
media_kind TEXT NOT NULL DEFAULT 'anime',
|
||||
tmdb_id INTEGER,
|
||||
tmdb_type TEXT,
|
||||
metadata_json TEXT,
|
||||
CREATED_DATE TEXT,
|
||||
LAST_UPDATE_DATE TEXT
|
||||
);
|
||||
`);
|
||||
// Schema 24: live-action entries carry a TMDB link instead of an AniList id.
|
||||
addColumnIfMissing(db, 'imm_anime', 'media_kind', "TEXT NOT NULL DEFAULT 'anime'");
|
||||
addColumnIfMissing(db, 'imm_anime', 'tmdb_id', 'INTEGER');
|
||||
addColumnIfMissing(db, 'imm_anime', 'tmdb_type', 'TEXT');
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS imm_videos(
|
||||
video_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -1478,6 +1485,10 @@ export function ensureSchema(db: DatabaseSync): void {
|
||||
CREATE INDEX IF NOT EXISTS idx_anime_anilist_id
|
||||
ON imm_anime(anilist_id)
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_anime_tmdb_id
|
||||
ON imm_anime(tmdb_id, tmdb_type)
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_videos_anime_id
|
||||
ON imm_videos(anime_id)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export const SCHEMA_VERSION = 23;
|
||||
import type { MediaKind, TmdbMediaType } from '../../../shared/media-kind';
|
||||
|
||||
export const SCHEMA_VERSION = 24;
|
||||
export const DEFAULT_QUEUE_CAP = 1_000;
|
||||
export const DEFAULT_BATCH_SIZE = 25;
|
||||
export const DEFAULT_FLUSH_INTERVAL_MS = 500;
|
||||
@@ -521,6 +523,9 @@ export interface AnimeLibraryRow {
|
||||
animeId: number;
|
||||
canonicalTitle: string;
|
||||
anilistId: number | null;
|
||||
mediaKind: MediaKind;
|
||||
tmdbId: number | null;
|
||||
tmdbType: TmdbMediaType | null;
|
||||
totalSessions: number;
|
||||
totalActiveMs: number;
|
||||
totalCards: number;
|
||||
@@ -534,6 +539,9 @@ export interface AnimeDetailRow {
|
||||
animeId: number;
|
||||
canonicalTitle: string;
|
||||
anilistId: number | null;
|
||||
mediaKind: MediaKind;
|
||||
tmdbId: number | null;
|
||||
tmdbType: TmdbMediaType | null;
|
||||
titleRomaji: string | null;
|
||||
titleEnglish: string | null;
|
||||
titleNative: string | null;
|
||||
|
||||
@@ -3,6 +3,7 @@ import http, { type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import { Readable } from 'node:stream';
|
||||
import type { AnkiConnectConfig } from '../../types.js';
|
||||
import type { AnilistRateLimiter } from './anilist/rate-limiter.js';
|
||||
import type { TmdbClient } from './tmdb/tmdb-client.js';
|
||||
import type { ImmersionTrackerService } from './immersion-tracker-service.js';
|
||||
import type { RetimedSecondarySubtitleInput } from './secondary-subtitle-sidecar.js';
|
||||
import type { StatsServerMediaGenerator } from './stats-server/mining-support.js';
|
||||
@@ -86,6 +87,7 @@ export interface StatsServerConfig {
|
||||
input: RetimedSecondarySubtitleInput,
|
||||
) => Promise<string> | string;
|
||||
anilistRateLimiter?: AnilistRateLimiter;
|
||||
tmdbClient?: TmdbClient;
|
||||
addYomitanNote?: (word: string) => Promise<number | null>;
|
||||
resolveAnkiNoteId?: (noteId: number) => number;
|
||||
resolveSentenceSearchHeadwords?: (term: string) => Promise<string[]> | string[];
|
||||
@@ -108,6 +110,7 @@ export function createStatsApp(
|
||||
input: RetimedSecondarySubtitleInput,
|
||||
) => Promise<string> | string;
|
||||
anilistRateLimiter?: AnilistRateLimiter;
|
||||
tmdbClient?: TmdbClient;
|
||||
addYomitanNote?: (word: string) => Promise<number | null>;
|
||||
resolveAnkiNoteId?: (noteId: number) => number;
|
||||
resolveSentenceSearchHeadwords?: (term: string) => Promise<string[]> | string[];
|
||||
@@ -139,6 +142,7 @@ export function startStatsServer(config: StatsServerConfig): { close: () => void
|
||||
getStatsMiningAlassPath: config.getStatsMiningAlassPath,
|
||||
resolveRetimedSecondarySubtitleText: config.resolveRetimedSecondarySubtitleText,
|
||||
anilistRateLimiter: config.anilistRateLimiter,
|
||||
tmdbClient: config.tmdbClient,
|
||||
addYomitanNote: config.addYomitanNote,
|
||||
resolveAnkiNoteId: config.resolveAnkiNoteId,
|
||||
resolveSentenceSearchHeadwords: config.resolveSentenceSearchHeadwords,
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { Hono } from 'hono';
|
||||
import type { Context, Hono } from 'hono';
|
||||
import type { AnkiConnectConfig } from '../../../types.js';
|
||||
import {
|
||||
statsJson,
|
||||
type StatsAnilistSearchResult,
|
||||
type StatsAnkiBrowseResponse,
|
||||
} from '../../../types/stats-http-contract.js';
|
||||
import { isTmdbMediaType } from '../../../shared/media-kind.js';
|
||||
import type { AnilistRateLimiter } from '../anilist/rate-limiter.js';
|
||||
import { TmdbApiKeyMissingError, type TmdbClient } from '../tmdb/tmdb-client.js';
|
||||
import { registerStatsCoverRoutes } from '../stats-cover-routes.js';
|
||||
import type { ImmersionTrackerService } from '../immersion-tracker-service.js';
|
||||
import {
|
||||
@@ -27,6 +29,7 @@ export function registerStatsIntegrationRoutes(
|
||||
ankiConnectConfig?: AnkiConnectConfig;
|
||||
getAnkiConnectConfig?: () => AnkiConnectConfig | undefined;
|
||||
anilistRateLimiter?: AnilistRateLimiter;
|
||||
tmdbClient?: TmdbClient;
|
||||
resolveAnkiNoteId?: (noteId: number) => number;
|
||||
},
|
||||
): void {
|
||||
@@ -71,6 +74,50 @@ export function registerStatsIntegrationRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
const tmdbUnavailable = (c: Context, err: unknown) => {
|
||||
if (err instanceof TmdbApiKeyMissingError) {
|
||||
return c.json(statsJson('error', { error: err.message }), 503);
|
||||
}
|
||||
return c.json(statsJson('error', { error: 'TMDB request failed' }), 502);
|
||||
};
|
||||
|
||||
app.get('/api/stats/tmdb/search', async (c) => {
|
||||
const query = (c.req.query('q') ?? '').trim();
|
||||
if (!query) return c.json(statsJson('tmdbSearch', []));
|
||||
const tmdbClient = options?.tmdbClient;
|
||||
if (!tmdbClient) return c.json(statsJson('tmdbSearch', []));
|
||||
try {
|
||||
return c.json(statsJson('tmdbSearch', await tmdbClient.search(query)));
|
||||
} catch (err) {
|
||||
return tmdbUnavailable(c, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/api/stats/anime/:animeId/tmdb', async (c) => {
|
||||
const animeId = parseIntQuery(c.req.param('animeId'), 0);
|
||||
if (animeId <= 0) return c.body(null, 400);
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const tmdbId = body?.tmdbId;
|
||||
if (
|
||||
typeof tmdbId !== 'number' ||
|
||||
!Number.isInteger(tmdbId) ||
|
||||
tmdbId <= 0 ||
|
||||
!isTmdbMediaType(body?.tmdbType)
|
||||
) {
|
||||
return c.body(null, 400);
|
||||
}
|
||||
const tmdbClient = options?.tmdbClient;
|
||||
if (!tmdbClient) return c.json(statsJson('error', { error: 'TMDB is not available' }), 503);
|
||||
try {
|
||||
const details = await tmdbClient.getDetails(body.tmdbType, tmdbId);
|
||||
if (!details) return c.body(null, 404);
|
||||
await tracker.reassignAnimeTmdb(animeId, details);
|
||||
return c.json(statsJson('reassignAnimeTmdb', { ok: true }));
|
||||
} catch (err) {
|
||||
return tmdbUnavailable(c, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/stats/known-words', (c) => {
|
||||
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath);
|
||||
if (!knownWordsSet) return c.json(statsJson('knownWords', []));
|
||||
|
||||
@@ -10,6 +10,9 @@ const ANIME_COPY_COLUMNS = [
|
||||
'title_native',
|
||||
'episodes_total',
|
||||
'description',
|
||||
'media_kind',
|
||||
'tmdb_id',
|
||||
'tmdb_type',
|
||||
'metadata_json',
|
||||
'CREATED_DATE',
|
||||
'LAST_UPDATE_DATE',
|
||||
@@ -99,7 +102,13 @@ export function mergeAnime(
|
||||
): Map<number, number> {
|
||||
const map = new Map<number, number>();
|
||||
const byAnilist = local.query('SELECT anime_id FROM imm_anime WHERE anilist_id = ?');
|
||||
const byTmdb = local.query(
|
||||
'SELECT anime_id FROM imm_anime WHERE tmdb_id = ? AND tmdb_type = ? ORDER BY anime_id LIMIT 1',
|
||||
);
|
||||
const byTitleKey = local.query('SELECT anime_id FROM imm_anime WHERE normalized_title_key = ?');
|
||||
// A TMDB link only fills in when the local row is unlinked: a row already
|
||||
// pinned to AniList stays anime, and vice versa, so the two link kinds never
|
||||
// coexist on one entry.
|
||||
const fillMissing = local.query(
|
||||
`UPDATE imm_anime
|
||||
SET
|
||||
@@ -107,7 +116,13 @@ export function mergeAnime(
|
||||
title_english = COALESCE(title_english, ?),
|
||||
title_native = COALESCE(title_native, ?),
|
||||
episodes_total = COALESCE(episodes_total, ?),
|
||||
description = COALESCE(description, ?)
|
||||
description = COALESCE(description, ?),
|
||||
tmdb_id = CASE WHEN anilist_id IS NULL THEN COALESCE(tmdb_id, ?) ELSE tmdb_id END,
|
||||
tmdb_type = CASE WHEN anilist_id IS NULL AND tmdb_id IS NULL THEN ? ELSE tmdb_type END,
|
||||
media_kind = CASE
|
||||
WHEN anilist_id IS NULL AND tmdb_id IS NULL AND ? IS NOT NULL THEN ?
|
||||
ELSE media_kind
|
||||
END
|
||||
WHERE anime_id = ?`,
|
||||
);
|
||||
|
||||
@@ -117,6 +132,7 @@ export function mergeAnime(
|
||||
)) {
|
||||
const remoteId = Number(row.anime_id);
|
||||
const existing = ((row.anilist_id !== null ? byAnilist.get(row.anilist_id) : undefined) ??
|
||||
(row.tmdb_id !== null ? byTmdb.get(row.tmdb_id, row.tmdb_type) : undefined) ??
|
||||
byTitleKey.get(row.normalized_title_key)) as SqlRow | undefined;
|
||||
if (existing) {
|
||||
const localId = Number(existing.anime_id);
|
||||
@@ -127,6 +143,10 @@ export function mergeAnime(
|
||||
row.title_native,
|
||||
row.episodes_total,
|
||||
row.description,
|
||||
row.tmdb_id,
|
||||
row.tmdb_type,
|
||||
row.tmdb_id,
|
||||
row.media_kind,
|
||||
localId,
|
||||
);
|
||||
continue;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { BUNDLED_INTEGRATION_KEYS_FILENAME, readBundledTmdbApiKey } from './bundled-api-key.js';
|
||||
|
||||
test('readBundledTmdbApiKey reads the staged key and tolerates a missing or malformed file', () => {
|
||||
const distDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-bundled-key-'));
|
||||
const filePath = path.join(distDir, BUNDLED_INTEGRATION_KEYS_FILENAME);
|
||||
try {
|
||||
assert.equal(readBundledTmdbApiKey(distDir), null);
|
||||
fs.writeFileSync(filePath, '{"tmdbApiKey":" abc "}');
|
||||
assert.equal(readBundledTmdbApiKey(distDir), 'abc');
|
||||
fs.writeFileSync(filePath, '{"tmdbApiKey":""}');
|
||||
assert.equal(readBundledTmdbApiKey(distDir), null);
|
||||
fs.writeFileSync(filePath, 'not json');
|
||||
assert.equal(readBundledTmdbApiKey(distDir), null);
|
||||
} finally {
|
||||
fs.rmSync(distDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* Release builds stage a project-owned TMDB key into dist/ (see
|
||||
* scripts/bundled-integration-keys.mjs). Source checkouts and CI builds have no
|
||||
* such file, and TMDB lookups then depend on the user's own `tmdb.apiKey`.
|
||||
*/
|
||||
export const BUNDLED_INTEGRATION_KEYS_FILENAME = 'bundled-integration-keys.json';
|
||||
|
||||
export function readBundledTmdbApiKey(distDir: string): string | null {
|
||||
try {
|
||||
const raw = fs.readFileSync(path.join(distDir, BUNDLED_INTEGRATION_KEYS_FILENAME), 'utf8');
|
||||
const parsed = JSON.parse(raw) as { tmdbApiKey?: unknown };
|
||||
const key = typeof parsed.tmdbApiKey === 'string' ? parsed.tmdbApiKey.trim() : '';
|
||||
return key.length > 0 ? key : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createLiveActionMetadataResolver, titlesMatch } from './live-action-resolver.js';
|
||||
import {
|
||||
TmdbApiKeyMissingError,
|
||||
type TmdbClient,
|
||||
type TmdbSearchResult,
|
||||
type TmdbTitleDetails,
|
||||
} from './tmdb-client.js';
|
||||
|
||||
const silentLogger = { info: () => {}, warn: () => {} };
|
||||
|
||||
function searchResult(over: Partial<TmdbSearchResult> & { tmdbId: number }): TmdbSearchResult {
|
||||
return {
|
||||
tmdbType: 'tv',
|
||||
title: 'Title',
|
||||
originalTitle: 'Title',
|
||||
originalLanguage: 'ja',
|
||||
overview: null,
|
||||
posterUrl: null,
|
||||
year: null,
|
||||
isAnimation: false,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function details(over: Partial<TmdbTitleDetails> & { tmdbId: number }): TmdbTitleDetails {
|
||||
return {
|
||||
tmdbType: 'tv',
|
||||
titleEnglish: null,
|
||||
titleNative: null,
|
||||
description: null,
|
||||
posterUrl: null,
|
||||
episodesTotal: null,
|
||||
year: null,
|
||||
originalLanguage: 'ja',
|
||||
isAnimation: false,
|
||||
allTitles: [],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
test('titlesMatch ignores case, width, and punctuation but not extra words', () => {
|
||||
assert.equal(titlesMatch('Hanzawa Naoki', ['HANZAWA NAOKI!']), true);
|
||||
assert.equal(titlesMatch('半沢直樹', ['半沢直樹']), true);
|
||||
assert.equal(titlesMatch('Hanzawa Naoki', ['Hanzawa Naoki Season 2']), false);
|
||||
assert.equal(titlesMatch('', ['']), false);
|
||||
});
|
||||
|
||||
test('resolveByTitle only accepts a Japanese non-animated result whose known titles match exactly', async () => {
|
||||
const detailCalls: number[] = [];
|
||||
const client: TmdbClient = {
|
||||
async search() {
|
||||
return [
|
||||
searchResult({ tmdbId: 1, title: 'Hanzawa Naoki', originalLanguage: 'ko' }),
|
||||
searchResult({ tmdbId: 2, title: 'Hanzawa Naoki', isAnimation: true }),
|
||||
searchResult({ tmdbId: 3, title: 'Hanzawa Naoki: The Movie' }),
|
||||
searchResult({ tmdbId: 4, title: 'Hanzawa Naoki' }),
|
||||
];
|
||||
},
|
||||
async getDetails(_type, tmdbId) {
|
||||
detailCalls.push(tmdbId);
|
||||
if (tmdbId === 3) return details({ tmdbId: 3, allTitles: ['Hanzawa Naoki: The Movie'] });
|
||||
if (tmdbId === 4) return details({ tmdbId: 4, allTitles: ['Hanzawa Naoki', '半沢直樹'] });
|
||||
return null;
|
||||
},
|
||||
};
|
||||
const resolver = createLiveActionMetadataResolver(client, silentLogger);
|
||||
|
||||
const resolved = await resolver.resolveByTitle('hanzawa naoki');
|
||||
|
||||
assert.equal(resolved?.tmdbId, 4);
|
||||
assert.deepEqual(detailCalls, [3, 4]);
|
||||
});
|
||||
|
||||
test('resolveByTitle returns null when nothing matches or the key is missing', async () => {
|
||||
const noMatch: TmdbClient = {
|
||||
async search() {
|
||||
return [searchResult({ tmdbId: 1, title: 'Something Else' })];
|
||||
},
|
||||
async getDetails() {
|
||||
return details({ tmdbId: 1, allTitles: ['Something Else'] });
|
||||
},
|
||||
};
|
||||
assert.equal(
|
||||
await createLiveActionMetadataResolver(noMatch, silentLogger).resolveByTitle('Hanzawa Naoki'),
|
||||
null,
|
||||
);
|
||||
|
||||
let infoCount = 0;
|
||||
const noKey: TmdbClient = {
|
||||
async search() {
|
||||
throw new TmdbApiKeyMissingError();
|
||||
},
|
||||
async getDetails() {
|
||||
throw new TmdbApiKeyMissingError();
|
||||
},
|
||||
};
|
||||
const resolver = createLiveActionMetadataResolver(noKey, {
|
||||
info: () => {
|
||||
infoCount += 1;
|
||||
},
|
||||
warn: () => {},
|
||||
});
|
||||
assert.equal(await resolver.resolveByTitle('Hanzawa Naoki'), null);
|
||||
assert.equal(await resolver.resolveById('tv', 1), null);
|
||||
assert.equal(infoCount, 1);
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { normalizeTitleIdentity } from '../../utils/title-normalization';
|
||||
import type { TmdbMediaType } from '../../../shared/media-kind';
|
||||
import { TmdbApiKeyMissingError, type TmdbClient, type TmdbTitleDetails } from './tmdb-client';
|
||||
|
||||
const MAX_DETAIL_LOOKUPS = 3;
|
||||
|
||||
/**
|
||||
* Resolves live-action titles for the automatic cover-art path. Anime is
|
||||
* AniList's job, so only non-animated Japanese-language results qualify, and
|
||||
* a candidate must match the parsed title exactly under one of the names TMDB
|
||||
* knows for it. Fuzzy search hits are never trusted on their own: a stray
|
||||
* filename would otherwise pin the wrong show to a library entry.
|
||||
*/
|
||||
export interface LiveActionMetadataResolver {
|
||||
resolveByTitle(title: string): Promise<TmdbTitleDetails | null>;
|
||||
resolveById(tmdbType: TmdbMediaType, tmdbId: number): Promise<TmdbTitleDetails | null>;
|
||||
}
|
||||
|
||||
interface Logger {
|
||||
info(msg: string, ...args: unknown[]): void;
|
||||
warn(msg: string, ...args: unknown[]): void;
|
||||
}
|
||||
|
||||
export function titlesMatch(candidate: string, knownTitles: Iterable<string>): boolean {
|
||||
const key = normalizeTitleIdentity(candidate);
|
||||
if (!key) return false;
|
||||
for (const known of knownTitles) {
|
||||
if (normalizeTitleIdentity(known) === key) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function createLiveActionMetadataResolver(
|
||||
client: TmdbClient,
|
||||
logger: Logger,
|
||||
): LiveActionMetadataResolver {
|
||||
let warnedMissingKey = false;
|
||||
|
||||
const guard = async <T>(work: () => Promise<T>): Promise<T | null> => {
|
||||
try {
|
||||
return await work();
|
||||
} catch (err) {
|
||||
if (err instanceof TmdbApiKeyMissingError) {
|
||||
if (!warnedMissingKey) {
|
||||
warnedMissingKey = true;
|
||||
logger.info('tmdb: no API key configured, skipping live-action metadata lookups');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
logger.warn('tmdb: lookup failed: %s', err instanceof Error ? err.message : String(err));
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
resolveByTitle(title) {
|
||||
return guard(async () => {
|
||||
const results = await client.search(title);
|
||||
const candidates = results
|
||||
.filter((result) => result.originalLanguage === 'ja' && !result.isAnimation)
|
||||
.slice(0, MAX_DETAIL_LOOKUPS);
|
||||
for (const candidate of candidates) {
|
||||
const details = await client.getDetails(candidate.tmdbType, candidate.tmdbId);
|
||||
if (details && !details.isAnimation && titlesMatch(title, details.allTitles)) {
|
||||
return details;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
},
|
||||
resolveById(tmdbType, tmdbId) {
|
||||
return guard(() => client.getDetails(tmdbType, tmdbId));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { TmdbApiKeyMissingError, createTmdbClient, resolveTmdbApiKey } from './tmdb-client.js';
|
||||
|
||||
function jsonResponse(payload: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
function captureFetch(handler: (url: URL, init?: RequestInit) => Response) {
|
||||
const calls: Array<{ url: URL; init?: RequestInit }> = [];
|
||||
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = new URL(String(input));
|
||||
calls.push({ url, init });
|
||||
return handler(url, init);
|
||||
}) as typeof fetch;
|
||||
return { calls, fetchImpl };
|
||||
}
|
||||
|
||||
test('resolveTmdbApiKey prefers the literal key and trims it', async () => {
|
||||
assert.equal(await resolveTmdbApiKey({ apiKey: ' abc ', apiKeyCommand: 'echo nope' }), 'abc');
|
||||
assert.equal(await resolveTmdbApiKey({ apiKey: '', apiKeyCommand: '' }), null);
|
||||
assert.equal(await resolveTmdbApiKey(undefined), null);
|
||||
});
|
||||
|
||||
test('resolveTmdbApiKey runs apiKeyCommand when no literal key is set', async () => {
|
||||
assert.equal(await resolveTmdbApiKey({ apiKeyCommand: 'printf " from-cmd "' }), 'from-cmd');
|
||||
assert.equal(await resolveTmdbApiKey({ apiKeyCommand: 'exit 3' }), null);
|
||||
});
|
||||
|
||||
test('resolveTmdbApiKey falls back to the bundled key only when the user set nothing usable', async () => {
|
||||
assert.equal(await resolveTmdbApiKey({}, 'bundled'), 'bundled');
|
||||
assert.equal(await resolveTmdbApiKey({ apiKey: 'mine' }, 'bundled'), 'mine');
|
||||
assert.equal(await resolveTmdbApiKey({ apiKeyCommand: 'printf mine' }, 'bundled'), 'mine');
|
||||
assert.equal(await resolveTmdbApiKey({ apiKeyCommand: 'exit 3' }, 'bundled'), 'bundled');
|
||||
});
|
||||
|
||||
test('search rejects without a key and never touches the network', async () => {
|
||||
const { calls, fetchImpl } = captureFetch(() => jsonResponse({ results: [] }));
|
||||
const client = createTmdbClient({ resolveApiKey: async () => null, fetch: fetchImpl });
|
||||
await assert.rejects(client.search('半沢直樹'), TmdbApiKeyMissingError);
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test('search sends a v3 key as a query parameter and drops people from multi results', async () => {
|
||||
const { calls, fetchImpl } = captureFetch(() =>
|
||||
jsonResponse({
|
||||
results: [
|
||||
{ media_type: 'person', id: 1, name: 'Sakai Masato' },
|
||||
{
|
||||
media_type: 'tv',
|
||||
id: 61222,
|
||||
name: 'Hanzawa Naoki',
|
||||
original_name: '半沢直樹',
|
||||
original_language: 'ja',
|
||||
overview: 'A banker fights back.',
|
||||
poster_path: '/hanzawa.jpg',
|
||||
first_air_date: '2013-07-07',
|
||||
genre_ids: [18],
|
||||
},
|
||||
{
|
||||
media_type: 'movie',
|
||||
id: 9,
|
||||
title: 'Anime Film',
|
||||
original_title: 'アニメ映画',
|
||||
original_language: 'ja',
|
||||
release_date: '2020-01-01',
|
||||
genre_ids: [16],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const client = createTmdbClient({ resolveApiKey: async () => 'v3key', fetch: fetchImpl });
|
||||
|
||||
const results = await client.search(' 半沢直樹 ');
|
||||
|
||||
assert.equal(calls.length, 1);
|
||||
const url = calls[0]!.url;
|
||||
assert.equal(url.pathname, '/3/search/multi');
|
||||
assert.equal(url.searchParams.get('query'), '半沢直樹');
|
||||
assert.equal(url.searchParams.get('api_key'), 'v3key');
|
||||
assert.equal((calls[0]!.init?.headers as Record<string, string>).Authorization, undefined);
|
||||
assert.deepEqual(results, [
|
||||
{
|
||||
tmdbId: 61222,
|
||||
tmdbType: 'tv',
|
||||
title: 'Hanzawa Naoki',
|
||||
originalTitle: '半沢直樹',
|
||||
originalLanguage: 'ja',
|
||||
overview: 'A banker fights back.',
|
||||
posterUrl: 'https://image.tmdb.org/t/p/w500/hanzawa.jpg',
|
||||
year: 2013,
|
||||
isAnimation: false,
|
||||
},
|
||||
{
|
||||
tmdbId: 9,
|
||||
tmdbType: 'movie',
|
||||
title: 'Anime Film',
|
||||
originalTitle: 'アニメ映画',
|
||||
originalLanguage: 'ja',
|
||||
overview: null,
|
||||
posterUrl: null,
|
||||
year: 2020,
|
||||
isAnimation: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('a v4 read token travels as a bearer header instead of api_key', async () => {
|
||||
const { calls, fetchImpl } = captureFetch(() => jsonResponse({ results: [] }));
|
||||
const client = createTmdbClient({
|
||||
resolveApiKey: async () => 'eyJhbGciOiJIUzI1NiJ9.payload.sig',
|
||||
fetch: fetchImpl,
|
||||
});
|
||||
await client.search('x');
|
||||
assert.equal(calls[0]!.url.searchParams.has('api_key'), false);
|
||||
assert.equal(
|
||||
(calls[0]!.init?.headers as Record<string, string>).Authorization,
|
||||
'Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig',
|
||||
);
|
||||
});
|
||||
|
||||
test('getDetails folds translations and alternative titles into the normalized shape', async () => {
|
||||
const { calls, fetchImpl } = captureFetch(() =>
|
||||
jsonResponse({
|
||||
id: 61222,
|
||||
name: 'Hanzawa Naoki',
|
||||
original_name: '半沢直樹',
|
||||
original_language: 'ja',
|
||||
overview: 'A banker fights back.',
|
||||
poster_path: '/hanzawa.jpg',
|
||||
first_air_date: '2013-07-07',
|
||||
number_of_episodes: 10,
|
||||
genres: [{ id: 18, name: 'Drama' }],
|
||||
alternative_titles: { results: [{ iso_3166_1: 'JP', title: 'Hanzawa Naoki Season 1' }] },
|
||||
translations: {
|
||||
translations: [
|
||||
{ iso_639_1: 'en', data: { name: 'Hanzawa Naoki', overview: 'A banker fights back.' } },
|
||||
{ iso_639_1: 'ja', data: { name: '半沢直樹', overview: '銀行員の物語' } },
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
const client = createTmdbClient({ resolveApiKey: async () => 'k', fetch: fetchImpl });
|
||||
|
||||
const details = await client.getDetails('tv', 61222);
|
||||
|
||||
assert.equal(calls[0]!.url.pathname, '/3/tv/61222');
|
||||
assert.equal(
|
||||
calls[0]!.url.searchParams.get('append_to_response'),
|
||||
'alternative_titles,translations',
|
||||
);
|
||||
assert.deepEqual(details, {
|
||||
tmdbId: 61222,
|
||||
tmdbType: 'tv',
|
||||
titleEnglish: 'Hanzawa Naoki',
|
||||
titleNative: '半沢直樹',
|
||||
description: 'A banker fights back.',
|
||||
posterUrl: 'https://image.tmdb.org/t/p/w500/hanzawa.jpg',
|
||||
episodesTotal: 10,
|
||||
year: 2013,
|
||||
originalLanguage: 'ja',
|
||||
isAnimation: false,
|
||||
allTitles: ['Hanzawa Naoki', '半沢直樹', 'Hanzawa Naoki Season 1'],
|
||||
});
|
||||
});
|
||||
|
||||
test('getDetails falls back to the Japanese overview and counts a movie as one episode', async () => {
|
||||
const { fetchImpl } = captureFetch(() =>
|
||||
jsonResponse({
|
||||
id: 5,
|
||||
title: '半沢直樹',
|
||||
original_title: '半沢直樹',
|
||||
original_language: 'ja',
|
||||
overview: '',
|
||||
release_date: '2019-03-01',
|
||||
translations: {
|
||||
translations: [{ iso_639_1: 'ja', data: { title: '半沢直樹', overview: 'あらすじ' } }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
const client = createTmdbClient({ resolveApiKey: async () => 'k', fetch: fetchImpl });
|
||||
const details = await client.getDetails('movie', 5);
|
||||
assert.equal(details?.titleEnglish, null);
|
||||
assert.equal(details?.titleNative, '半沢直樹');
|
||||
assert.equal(details?.description, 'あらすじ');
|
||||
assert.equal(details?.episodesTotal, 1);
|
||||
});
|
||||
|
||||
test('getDetails returns null for an unknown id', async () => {
|
||||
const { fetchImpl } = captureFetch(() => jsonResponse({ status_message: 'nope' }, 404));
|
||||
const client = createTmdbClient({ resolveApiKey: async () => 'k', fetch: fetchImpl });
|
||||
assert.equal(await client.getDetails('tv', 1), null);
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
import * as childProcess from 'node:child_process';
|
||||
import type { TmdbMediaType } from '../../../shared/media-kind';
|
||||
import type { TmdbConfig } from '../../../types/integrations';
|
||||
import type { StatsTmdbSearchResult } from '../../../types/stats-http-contract';
|
||||
|
||||
export const TMDB_API_BASE_URL = 'https://api.themoviedb.org/3';
|
||||
const TMDB_POSTER_BASE_URL = 'https://image.tmdb.org/t/p/w500';
|
||||
const REQUEST_TIMEOUT_MS = 8_000;
|
||||
const ANIMATION_GENRE_ID = 16;
|
||||
|
||||
export type TmdbSearchResult = StatsTmdbSearchResult;
|
||||
|
||||
export interface TmdbTitleDetails {
|
||||
tmdbId: number;
|
||||
tmdbType: TmdbMediaType;
|
||||
titleEnglish: string | null;
|
||||
titleNative: string | null;
|
||||
/** English synopsis, falling back to the Japanese one. */
|
||||
description: string | null;
|
||||
posterUrl: string | null;
|
||||
episodesTotal: number | null;
|
||||
year: number | null;
|
||||
originalLanguage: string;
|
||||
isAnimation: boolean;
|
||||
/** Every name TMDB knows for the title, used for exact-title matching. */
|
||||
allTitles: string[];
|
||||
}
|
||||
|
||||
export interface TmdbClient {
|
||||
search(query: string): Promise<TmdbSearchResult[]>;
|
||||
getDetails(tmdbType: TmdbMediaType, tmdbId: number): Promise<TmdbTitleDetails | null>;
|
||||
}
|
||||
|
||||
export class TmdbApiKeyMissingError extends Error {
|
||||
constructor() {
|
||||
super('TMDB API key not configured. Set tmdb.apiKey or tmdb.apiKeyCommand.');
|
||||
this.name = 'TmdbApiKeyMissingError';
|
||||
}
|
||||
}
|
||||
|
||||
export class TmdbRequestError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'TmdbRequestError';
|
||||
}
|
||||
}
|
||||
|
||||
function execCommand(command: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
childProcess.exec(command, { timeout: 10_000 }, (err, stdout) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve(stdout);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the key in priority order: the user's literal `apiKey`, then the
|
||||
* output of `apiKeyCommand`, then the key bundled into release builds.
|
||||
*/
|
||||
export async function resolveTmdbApiKey(
|
||||
config: TmdbConfig | undefined,
|
||||
bundledKey: string | null = null,
|
||||
): Promise<string | null> {
|
||||
const literal = config?.apiKey?.trim();
|
||||
if (literal) return literal;
|
||||
const command = config?.apiKeyCommand?.trim();
|
||||
if (command) {
|
||||
try {
|
||||
const key = (await execCommand(command)).trim();
|
||||
if (key.length > 0) return key;
|
||||
} catch {
|
||||
/* fall through to the bundled key */
|
||||
}
|
||||
}
|
||||
return bundledKey;
|
||||
}
|
||||
|
||||
interface RawSearchItem {
|
||||
media_type?: string;
|
||||
id?: number;
|
||||
name?: string;
|
||||
original_name?: string;
|
||||
title?: string;
|
||||
original_title?: string;
|
||||
original_language?: string;
|
||||
overview?: string;
|
||||
poster_path?: string | null;
|
||||
first_air_date?: string;
|
||||
release_date?: string;
|
||||
genre_ids?: number[];
|
||||
}
|
||||
|
||||
interface RawTranslation {
|
||||
iso_639_1?: string;
|
||||
data?: { name?: string; title?: string; overview?: string };
|
||||
}
|
||||
|
||||
interface RawDetails extends RawSearchItem {
|
||||
number_of_episodes?: number;
|
||||
genres?: Array<{ id?: number }>;
|
||||
alternative_titles?: { results?: Array<{ title?: string }>; titles?: Array<{ title?: string }> };
|
||||
translations?: { translations?: RawTranslation[] };
|
||||
}
|
||||
|
||||
function nonEmpty(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function yearOf(date: string | undefined): number | null {
|
||||
const year = Number.parseInt(date?.slice(0, 4) ?? '', 10);
|
||||
return Number.isFinite(year) && year > 0 ? year : null;
|
||||
}
|
||||
|
||||
function posterUrlOf(path: string | null | undefined): string | null {
|
||||
return path ? `${TMDB_POSTER_BASE_URL}${path}` : null;
|
||||
}
|
||||
|
||||
function mediaTypeOf(value: unknown): TmdbMediaType | null {
|
||||
return value === 'tv' || value === 'movie' ? value : null;
|
||||
}
|
||||
|
||||
function normalizeSearchItem(item: RawSearchItem): TmdbSearchResult | null {
|
||||
const tmdbType = mediaTypeOf(item.media_type);
|
||||
if (!tmdbType || typeof item.id !== 'number') return null;
|
||||
const title = nonEmpty(item.name) ?? nonEmpty(item.title);
|
||||
const originalTitle = nonEmpty(item.original_name) ?? nonEmpty(item.original_title) ?? title;
|
||||
if (!title || !originalTitle) return null;
|
||||
return {
|
||||
tmdbId: item.id,
|
||||
tmdbType,
|
||||
title,
|
||||
originalTitle,
|
||||
originalLanguage: item.original_language ?? '',
|
||||
overview: nonEmpty(item.overview),
|
||||
posterUrl: posterUrlOf(item.poster_path),
|
||||
year: yearOf(item.first_air_date ?? item.release_date),
|
||||
isAnimation: (item.genre_ids ?? []).includes(ANIMATION_GENRE_ID),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDetails(tmdbType: TmdbMediaType, raw: RawDetails): TmdbTitleDetails | null {
|
||||
if (typeof raw.id !== 'number') return null;
|
||||
const localizedTitle = nonEmpty(raw.name) ?? nonEmpty(raw.title);
|
||||
const originalTitle = nonEmpty(raw.original_name) ?? nonEmpty(raw.original_title);
|
||||
const originalLanguage = raw.original_language ?? '';
|
||||
const translations = raw.translations?.translations ?? [];
|
||||
const translationFor = (language: string) =>
|
||||
translations.find((entry) => entry.iso_639_1 === language)?.data;
|
||||
const english = translationFor('en');
|
||||
const japanese = translationFor('ja');
|
||||
const englishTitle =
|
||||
nonEmpty(english?.name) ??
|
||||
nonEmpty(english?.title) ??
|
||||
(localizedTitle && localizedTitle !== originalTitle ? localizedTitle : null);
|
||||
const nativeTitle =
|
||||
originalLanguage === 'ja'
|
||||
? originalTitle
|
||||
: (nonEmpty(japanese?.name) ?? nonEmpty(japanese?.title));
|
||||
const alternativeTitles = [
|
||||
...(raw.alternative_titles?.results ?? []),
|
||||
...(raw.alternative_titles?.titles ?? []),
|
||||
].map((entry) => nonEmpty(entry.title));
|
||||
const translatedTitles = translations.map(
|
||||
(entry) => nonEmpty(entry.data?.name) ?? nonEmpty(entry.data?.title),
|
||||
);
|
||||
const allTitles = [
|
||||
...new Set(
|
||||
[
|
||||
localizedTitle,
|
||||
originalTitle,
|
||||
englishTitle,
|
||||
nativeTitle,
|
||||
...translatedTitles,
|
||||
...alternativeTitles,
|
||||
].filter((title): title is string => Boolean(title)),
|
||||
),
|
||||
];
|
||||
return {
|
||||
tmdbId: raw.id,
|
||||
tmdbType,
|
||||
titleEnglish: englishTitle,
|
||||
titleNative: nativeTitle,
|
||||
description:
|
||||
nonEmpty(raw.overview) ?? nonEmpty(english?.overview) ?? nonEmpty(japanese?.overview),
|
||||
posterUrl: posterUrlOf(raw.poster_path),
|
||||
episodesTotal:
|
||||
tmdbType === 'movie'
|
||||
? 1
|
||||
: typeof raw.number_of_episodes === 'number' && raw.number_of_episodes > 0
|
||||
? raw.number_of_episodes
|
||||
: null,
|
||||
year: yearOf(raw.first_air_date ?? raw.release_date),
|
||||
originalLanguage,
|
||||
isAnimation: (raw.genres ?? []).some((genre) => genre.id === ANIMATION_GENRE_ID),
|
||||
allTitles,
|
||||
};
|
||||
}
|
||||
|
||||
// TMDB issues two kinds of credential: a short v3 key that travels as a query
|
||||
// parameter and a long v4 read token (a JWT) that goes in the Authorization
|
||||
// header. Users paste whichever the settings page showed them.
|
||||
function isV4Token(apiKey: string): boolean {
|
||||
return apiKey.startsWith('eyJ');
|
||||
}
|
||||
|
||||
export function createTmdbClient(deps: {
|
||||
resolveApiKey: () => Promise<string | null>;
|
||||
fetch?: typeof fetch;
|
||||
baseUrl?: string;
|
||||
}): TmdbClient {
|
||||
const fetchImpl = deps.fetch ?? fetch;
|
||||
const baseUrl = (deps.baseUrl ?? TMDB_API_BASE_URL).replace(/\/+$/, '');
|
||||
|
||||
async function request<T>(path: string, params: Record<string, string>): Promise<T | null> {
|
||||
const apiKey = await deps.resolveApiKey();
|
||||
if (!apiKey) throw new TmdbApiKeyMissingError();
|
||||
const url = new URL(`${baseUrl}${path}`);
|
||||
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
if (isV4Token(apiKey)) {
|
||||
headers.Authorization = `Bearer ${apiKey}`;
|
||||
} else {
|
||||
url.searchParams.set('api_key', apiKey);
|
||||
}
|
||||
const res = await fetchImpl(url, { headers, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) {
|
||||
throw new TmdbRequestError(
|
||||
`TMDB request failed: ${res.status} ${res.statusText}`,
|
||||
res.status,
|
||||
);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
return {
|
||||
async search(query) {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) return [];
|
||||
const payload = await request<{ results?: RawSearchItem[] }>('/search/multi', {
|
||||
query: trimmed,
|
||||
include_adult: 'false',
|
||||
language: 'en-US',
|
||||
page: '1',
|
||||
});
|
||||
return (payload?.results ?? [])
|
||||
.map(normalizeSearchItem)
|
||||
.filter((item): item is TmdbSearchResult => item !== null);
|
||||
},
|
||||
async getDetails(tmdbType, tmdbId) {
|
||||
const raw = await request<RawDetails>(`/${tmdbType}/${tmdbId}`, {
|
||||
language: 'en-US',
|
||||
append_to_response: 'alternative_titles,translations',
|
||||
});
|
||||
return raw ? normalizeDetails(tmdbType, raw) : null;
|
||||
},
|
||||
};
|
||||
}
|
||||
+14
@@ -429,6 +429,9 @@ import {
|
||||
} from './core/services/anilist/anilist-updater';
|
||||
import { createCoverArtFetcher } from './core/services/anilist/cover-art-fetcher';
|
||||
import { createAnilistRateLimiter } from './core/services/anilist/rate-limiter';
|
||||
import { createLiveActionMetadataResolver } from './core/services/tmdb/live-action-resolver';
|
||||
import { createTmdbClient, resolveTmdbApiKey } from './core/services/tmdb/tmdb-client';
|
||||
import { readBundledTmdbApiKey } from './core/services/tmdb/bundled-api-key';
|
||||
import { createJellyfinTokenStore } from './core/services/jellyfin-token-store';
|
||||
import { applyRuntimeOptionResultRuntime } from './core/services/runtime-options-ipc';
|
||||
import { createAnilistTokenStore } from './core/services/anilist/anilist-token-store';
|
||||
@@ -962,6 +965,8 @@ const reportFatalError = createFatalErrorReporter({
|
||||
|
||||
let forceQuitTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const statsDistPath = path.join(__dirname, '..', 'stats', 'dist');
|
||||
// Release builds stage a project TMDB key next to the compiled main process.
|
||||
const bundledTmdbApiKey = readBundledTmdbApiKey(__dirname);
|
||||
const statsPreloadPath = path.join(__dirname, 'preload-stats.js');
|
||||
const statsServerRuntime = createStatsServerRuntime({
|
||||
userDataPath: USER_DATA_PATH,
|
||||
@@ -988,6 +993,7 @@ const statsServerRuntime = createStatsServerRuntime({
|
||||
},
|
||||
getYomitanAnkiDeckName: () => getCurrentYomitanAnkiDeckNameForRuntime(),
|
||||
getAnilistRateLimiter: () => anilistRateLimiter,
|
||||
getBundledTmdbApiKey: () => bundledTmdbApiKey,
|
||||
resolveAnkiNoteId: (noteId) => appState.ankiIntegration?.resolveCurrentNoteId(noteId) ?? noteId,
|
||||
trackDuplicateNoteIdsForNote: (noteId, duplicateNoteIds) => {
|
||||
appState.ankiIntegration?.trackDuplicateNoteIdsForNote(noteId, duplicateNoteIds);
|
||||
@@ -1680,6 +1686,14 @@ const anilistRateLimiter = createAnilistRateLimiter();
|
||||
const statsCoverArtFetcher = createCoverArtFetcher(
|
||||
anilistRateLimiter,
|
||||
createLogger('main:stats-cover-art'),
|
||||
{
|
||||
liveAction: createLiveActionMetadataResolver(
|
||||
createTmdbClient({
|
||||
resolveApiKey: () => resolveTmdbApiKey(configService.getConfig().tmdb, bundledTmdbApiKey),
|
||||
}),
|
||||
createLogger('main:tmdb'),
|
||||
),
|
||||
},
|
||||
);
|
||||
const anilistStateRuntime = createAnilistStateRuntime(buildAnilistStateRuntimeMainDepsHandler());
|
||||
const configDerivedRuntime = createConfigDerivedRuntime(buildConfigDerivedRuntimeMainDepsHandler());
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
syncYomitanDefaultAnkiServer as syncYomitanDefaultAnkiServerCore,
|
||||
} from '../../core/services';
|
||||
import { startStatsServer } from '../../core/services/stats-server';
|
||||
import { createTmdbClient, resolveTmdbApiKey } from '../../core/services/tmdb/tmdb-client';
|
||||
import { createLogger } from '../../logger';
|
||||
import type { ResolvedConfig } from '../../types/config';
|
||||
import type { AppState } from '../state';
|
||||
@@ -52,6 +53,8 @@ export interface StatsServerRuntimeDeps {
|
||||
getAnilistRateLimiter: () => NonNullable<
|
||||
Parameters<typeof startStatsServer>[0]['anilistRateLimiter']
|
||||
>;
|
||||
/** Project TMDB key staged into release builds; null for source builds. */
|
||||
getBundledTmdbApiKey?: () => string | null;
|
||||
resolveAnkiNoteId: (noteId: number) => number;
|
||||
trackDuplicateNoteIdsForNote: (noteId: number, duplicateNoteIds: number[]) => void;
|
||||
resolveSentenceSearchHeadwords: (term: string) => Promise<string[]>;
|
||||
@@ -165,6 +168,10 @@ export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): {
|
||||
deps.getResolvedConfig().secondarySub.secondarySubLanguages,
|
||||
getStatsMiningAlassPath: () => deps.getResolvedConfig().subsync.alass_path,
|
||||
anilistRateLimiter: deps.getAnilistRateLimiter(),
|
||||
tmdbClient: createTmdbClient({
|
||||
resolveApiKey: () =>
|
||||
resolveTmdbApiKey(deps.getResolvedConfig().tmdb, deps.getBundledTmdbApiKey?.() ?? null),
|
||||
}),
|
||||
resolveAnkiNoteId: (noteId: number) => deps.resolveAnkiNoteId(noteId),
|
||||
resolveSentenceSearchHeadwords: (term: string) => deps.resolveSentenceSearchHeadwords(term),
|
||||
addYomitanNote: async (word: string) => {
|
||||
|
||||
@@ -125,18 +125,21 @@ test('prerelease workflow builds and uploads all release platforms', () => {
|
||||
assert.ok(executableRunLines(publish).includes('release/package-size-*.json'));
|
||||
});
|
||||
|
||||
test('release callers pass only the declared macOS signing secrets to packaging', () => {
|
||||
const secrets = [
|
||||
test('release callers pass only the declared packaging secrets', () => {
|
||||
const signingSecrets = [
|
||||
'CSC_LINK',
|
||||
'CSC_KEY_PASSWORD',
|
||||
'APPLE_ID',
|
||||
'APPLE_APP_SPECIFIC_PASSWORD',
|
||||
'APPLE_TEAM_ID',
|
||||
];
|
||||
assert.deepEqual(
|
||||
parsedPackageWorkflow.on?.workflow_call?.secrets,
|
||||
Object.fromEntries(secrets.map((name) => [name, { required: true }])),
|
||||
);
|
||||
// The bundled TMDB key is optional: artifacts stay valid without it and
|
||||
// users fall back to their own tmdb.apiKey.
|
||||
const optionalSecrets = ['SUBMINER_TMDB_API_KEY'];
|
||||
assert.deepEqual(parsedPackageWorkflow.on?.workflow_call?.secrets, {
|
||||
...Object.fromEntries(signingSecrets.map((name) => [name, { required: true }])),
|
||||
...Object.fromEntries(optionalSecrets.map((name) => [name, { required: false }])),
|
||||
});
|
||||
for (const workflow of [
|
||||
parsedPrereleaseWorkflow,
|
||||
readWorkflow(resolve(__dirname, '../.github/workflows/release.yml')),
|
||||
@@ -144,7 +147,12 @@ test('release callers pass only the declared macOS signing secrets to packaging'
|
||||
assert.equal(workflow.jobs?.package?.uses, './.github/workflows/package-release.yml');
|
||||
assert.deepEqual(
|
||||
workflow.jobs?.package?.secrets,
|
||||
Object.fromEntries(secrets.map((name) => [name, '${{ secrets.' + name + ' }}'])),
|
||||
Object.fromEntries(
|
||||
[...signingSecrets, ...optionalSecrets].map((name) => [
|
||||
name,
|
||||
'${{ secrets.' + name + ' }}',
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Library entry classification shared by the tracker, the stats HTTP layer and
|
||||
* the stats SPA. Anime entries link to AniList; live-action entries link to TMDB.
|
||||
*/
|
||||
export const MEDIA_KINDS = ['anime', 'live_action'] as const;
|
||||
export type MediaKind = (typeof MEDIA_KINDS)[number];
|
||||
|
||||
export const TMDB_MEDIA_TYPES = ['tv', 'movie'] as const;
|
||||
export type TmdbMediaType = (typeof TMDB_MEDIA_TYPES)[number];
|
||||
|
||||
export function isMediaKind(value: unknown): value is MediaKind {
|
||||
return typeof value === 'string' && (MEDIA_KINDS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function isTmdbMediaType(value: unknown): value is TmdbMediaType {
|
||||
return typeof value === 'string' && (TMDB_MEDIA_TYPES as readonly string[]).includes(value);
|
||||
}
|
||||
@@ -7,6 +7,9 @@ import { createLogger, setLogLevel } from './logger';
|
||||
import { ImmersionTrackerService } from './core/services/immersion-tracker-service';
|
||||
import { createCoverArtFetcher } from './core/services/anilist/cover-art-fetcher';
|
||||
import { createAnilistRateLimiter } from './core/services/anilist/rate-limiter';
|
||||
import { createLiveActionMetadataResolver } from './core/services/tmdb/live-action-resolver';
|
||||
import { createTmdbClient, resolveTmdbApiKey } from './core/services/tmdb/tmdb-client';
|
||||
import { readBundledTmdbApiKey } from './core/services/tmdb/bundled-api-key';
|
||||
import { startStatsServer } from './core/services/stats-server';
|
||||
import {
|
||||
removeBackgroundStatsServerState,
|
||||
@@ -124,6 +127,7 @@ const daemonUserDataPath = userDataPath;
|
||||
const statePath = path.join(userDataPath, 'stats-daemon.json');
|
||||
const knownWordCachePath = path.join(userDataPath, 'known-words-cache.json');
|
||||
const statsDistPath = path.join(__dirname, '..', 'stats', 'dist');
|
||||
const bundledTmdbApiKey = readBundledTmdbApiKey(__dirname);
|
||||
const wordHelperScriptPath = path.join(__dirname, 'stats-word-helper.js');
|
||||
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
@@ -194,8 +198,13 @@ async function main(): Promise<void> {
|
||||
},
|
||||
},
|
||||
});
|
||||
const tmdbClient = createTmdbClient({
|
||||
resolveApiKey: () => resolveTmdbApiKey(configService.reloadConfig().tmdb, bundledTmdbApiKey),
|
||||
});
|
||||
tracker.setCoverArtFetcher(
|
||||
createCoverArtFetcher(createAnilistRateLimiter(), createLogger('stats-daemon:cover-art')),
|
||||
createCoverArtFetcher(createAnilistRateLimiter(), createLogger('stats-daemon:cover-art'), {
|
||||
liveAction: createLiveActionMetadataResolver(tmdbClient, createLogger('stats-daemon:tmdb')),
|
||||
}),
|
||||
);
|
||||
|
||||
statsServer = startStatsServer({
|
||||
@@ -204,6 +213,7 @@ async function main(): Promise<void> {
|
||||
tracker,
|
||||
knownWordCachePath,
|
||||
getAnkiConnectConfig: () => configService.reloadConfig().ankiConnect,
|
||||
tmdbClient,
|
||||
getYomitanAnkiDeckName: async () =>
|
||||
await readStatsYomitanDeckName({
|
||||
helperScriptPath: wordHelperScriptPath,
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
ImmersionTrackingRetentionMode,
|
||||
ImmersionTrackingRetentionPreset,
|
||||
TsukihimeConfig,
|
||||
TmdbConfig,
|
||||
JellyfinConfig,
|
||||
JimakuConfig,
|
||||
JimakuLanguagePreference,
|
||||
@@ -160,6 +161,7 @@ export interface Config {
|
||||
/** @deprecated Use tsukihime. */
|
||||
animetosho?: TsukihimeConfig;
|
||||
tsukihime?: TsukihimeConfig;
|
||||
tmdb?: TmdbConfig;
|
||||
anilist?: AnilistConfig;
|
||||
yomitan?: YomitanConfig;
|
||||
jellyfin?: JellyfinConfig;
|
||||
@@ -332,6 +334,10 @@ export interface ResolvedConfig {
|
||||
apiBaseUrl: string;
|
||||
maxSearchResults: number;
|
||||
};
|
||||
tmdb: {
|
||||
apiKey: string;
|
||||
apiKeyCommand: string;
|
||||
};
|
||||
anilist: {
|
||||
enabled: boolean;
|
||||
accessToken: string;
|
||||
|
||||
@@ -283,3 +283,9 @@ export interface TsukihimeConfig {
|
||||
apiBaseUrl?: string;
|
||||
maxSearchResults?: number;
|
||||
}
|
||||
|
||||
/** TMDB (The Movie Database) access for live-action drama and movie metadata. */
|
||||
export interface TmdbConfig {
|
||||
apiKey?: string;
|
||||
apiKeyCommand?: string;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import type {
|
||||
WatchTimePerAnime,
|
||||
WordDetailData,
|
||||
} from './stats-wire';
|
||||
import type { TmdbMediaType } from '../shared/media-kind';
|
||||
|
||||
export type StatsTrendRange = '7d' | '30d' | '90d' | '365d' | 'all';
|
||||
export type StatsTrendGroupBy = 'day' | 'month';
|
||||
@@ -78,6 +79,25 @@ export interface StatsAnilistSearchResult {
|
||||
title: { romaji: string | null; english: string | null; native: string | null } | null;
|
||||
}
|
||||
|
||||
export interface StatsTmdbSearchResult {
|
||||
tmdbId: number;
|
||||
tmdbType: TmdbMediaType;
|
||||
/** English title when TMDB has one, otherwise the original title. */
|
||||
title: string;
|
||||
originalTitle: string;
|
||||
originalLanguage: string;
|
||||
overview: string | null;
|
||||
posterUrl: string | null;
|
||||
year: number | null;
|
||||
/** True when TMDB tags the title with the Animation genre. */
|
||||
isAnimation: boolean;
|
||||
}
|
||||
|
||||
export interface StatsTmdbAssignment {
|
||||
tmdbId: number;
|
||||
tmdbType: TmdbMediaType;
|
||||
}
|
||||
|
||||
export interface StatsAnilistAssignment {
|
||||
anilistId: number;
|
||||
titleRomaji?: string | null;
|
||||
@@ -209,11 +229,13 @@ export interface StatsJsonResponseMap {
|
||||
moveVideoToAnime: StatsMoveVideoResponse;
|
||||
dismissAnimeMergeRecommendation: StatsOkResponse;
|
||||
anilistSearch: StatsAnilistSearchResult[];
|
||||
tmdbSearch: StatsTmdbSearchResult[];
|
||||
knownWords: string[];
|
||||
knownWordsSummary: StatsKnownWordsSummary;
|
||||
animeKnownWordsSummary: StatsKnownWordsSummary;
|
||||
mediaKnownWordsSummary: StatsKnownWordsSummary;
|
||||
reassignAnimeAnilist: StatsOkResponse;
|
||||
reassignAnimeTmdb: StatsOkResponse;
|
||||
coverImages: StatsCoverImagesData;
|
||||
episodeDetail: EpisodeDetailData;
|
||||
ankiBrowse: StatsAnkiBrowseResponse;
|
||||
@@ -302,6 +324,8 @@ export interface StatsHttpClient {
|
||||
getMediaKnownWordsSummary: (videoId: number) => Promise<StatsKnownWordsSummary>;
|
||||
searchAnilist: (query: string) => Promise<StatsAnilistSearchResult[]>;
|
||||
reassignAnimeAnilist: (animeId: number, info: StatsAnilistAssignment) => Promise<void>;
|
||||
searchTmdb: (query: string) => Promise<StatsTmdbSearchResult[]>;
|
||||
reassignAnimeTmdb: (animeId: number, info: StatsTmdbAssignment) => Promise<void>;
|
||||
mineCard: (params: StatsMineCardParams) => Promise<StatsMineCardResponse>;
|
||||
ankiBrowse: (noteId: number) => Promise<void>;
|
||||
ankiNotesInfo: (noteIds: number[]) => Promise<StatsAnkiNoteInfo[]>;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { MediaKind, TmdbMediaType } from '../shared/media-kind';
|
||||
|
||||
export interface SessionSummary {
|
||||
sessionId: number;
|
||||
canonicalTitle: string | null;
|
||||
@@ -243,6 +245,9 @@ export interface AnimeLibraryItem {
|
||||
animeId: number;
|
||||
canonicalTitle: string;
|
||||
anilistId: number | null;
|
||||
mediaKind: MediaKind;
|
||||
tmdbId: number | null;
|
||||
tmdbType: TmdbMediaType | null;
|
||||
totalSessions: number;
|
||||
totalActiveMs: number;
|
||||
totalCards: number;
|
||||
@@ -264,6 +269,9 @@ export interface AnimeDetailData {
|
||||
animeId: number;
|
||||
canonicalTitle: string;
|
||||
anilistId: number | null;
|
||||
mediaKind: MediaKind;
|
||||
tmdbId: number | null;
|
||||
tmdbType: TmdbMediaType | null;
|
||||
titleRomaji: string | null;
|
||||
titleEnglish: string | null;
|
||||
titleNative: string | null;
|
||||
|
||||
@@ -10,6 +10,9 @@ test('AnimeCard includes linked AniList id in cover URLs to avoid stale library
|
||||
animeId: 42,
|
||||
canonicalTitle: 'Test Anime',
|
||||
anilistId: 21699,
|
||||
mediaKind: 'anime',
|
||||
tmdbId: null,
|
||||
tmdbType: null,
|
||||
totalSessions: 1,
|
||||
totalActiveMs: 600_000,
|
||||
totalCards: 0,
|
||||
|
||||
@@ -29,7 +29,7 @@ export function AnimeCard({
|
||||
<AnimeCoverImage
|
||||
animeId={anime.animeId}
|
||||
title={anime.canonicalTitle}
|
||||
coverRetryToken={anime.anilistId ?? 0}
|
||||
coverRetryToken={anime.anilistId ?? anime.tmdbId ?? 0}
|
||||
className="w-full aspect-[3/4] rounded-t-lg transition-transform duration-200 group-hover:scale-105"
|
||||
/>
|
||||
{selectable && (
|
||||
|
||||
@@ -19,6 +19,9 @@ test('AnimeHeader uses the linked AniList id to avoid stale cached cover art', (
|
||||
animeId: 42,
|
||||
canonicalTitle: 'Test Anime',
|
||||
anilistId: 21699,
|
||||
mediaKind: 'anime',
|
||||
tmdbId: null,
|
||||
tmdbType: null,
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
titleNative: null,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { AnimeHeader } from './AnimeHeader';
|
||||
import { EpisodeList } from './EpisodeList';
|
||||
import { AnimeWordList } from './AnimeWordList';
|
||||
import { AnilistSelector } from './AnilistSelector';
|
||||
import { TmdbSelector } from './TmdbSelector';
|
||||
import { AnimeOverviewStats } from './AnimeOverviewStats';
|
||||
import { CHART_THEME } from '../../lib/chart-theme';
|
||||
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
@@ -156,6 +157,7 @@ export function AnimeDetailView({
|
||||
}: AnimeDetailViewProps) {
|
||||
const { data, loading, error, reload } = useAnimeDetail(animeId);
|
||||
const [showAnilistSelector, setShowAnilistSelector] = useState(false);
|
||||
const [showTmdbSelector, setShowTmdbSelector] = useState(false);
|
||||
const [coverRetryToken, setCoverRetryToken] = useState(0);
|
||||
const [isDeletingAnime, setIsDeletingAnime] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
@@ -219,6 +221,7 @@ export function AnimeDetailView({
|
||||
anilistEntries={anilistEntries ?? []}
|
||||
coverRetryToken={coverRetryToken}
|
||||
onChangeAnilist={() => setShowAnilistSelector(true)}
|
||||
onChangeTmdb={() => setShowTmdbSelector(true)}
|
||||
onDeleteAnime={() => void handleDeleteAnime()}
|
||||
isDeletingAnime={isDeletingAnime}
|
||||
/>
|
||||
@@ -250,6 +253,19 @@ export function AnimeDetailView({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showTmdbSelector && (
|
||||
<TmdbSelector
|
||||
animeId={animeId}
|
||||
initialQuery={detail.canonicalTitle}
|
||||
onClose={() => setShowTmdbSelector(false)}
|
||||
onLinked={() => {
|
||||
setShowTmdbSelector(false);
|
||||
setCoverRetryToken((value) => value + 1);
|
||||
reload();
|
||||
onAnilistRelinked?.();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,6 +49,9 @@ function libraryItem(animeId: number, title: string): AnimeLibraryItem {
|
||||
animeId,
|
||||
canonicalTitle: title,
|
||||
anilistId: null,
|
||||
mediaKind: 'anime',
|
||||
tmdbId: null,
|
||||
tmdbType: null,
|
||||
totalSessions: 1,
|
||||
totalActiveMs: 1000,
|
||||
totalCards: 0,
|
||||
|
||||
@@ -9,6 +9,9 @@ const DETAIL: AnimeDetailData['detail'] = {
|
||||
animeId: 3,
|
||||
canonicalTitle: 'Project Radio Noise Season 2',
|
||||
anilistId: 20661,
|
||||
mediaKind: 'anime',
|
||||
tmdbId: null,
|
||||
tmdbType: null,
|
||||
titleRomaji: 'Toaru Kagaku no Railgun S',
|
||||
titleEnglish: 'A Certain Scientific Railgun S',
|
||||
titleNative: null,
|
||||
@@ -69,3 +72,43 @@ test('confirmAnimeDelete spells out how much data the entry deletion removes', a
|
||||
assert.match(seen[1] ?? '', /3 episodes/);
|
||||
assert.match(seen[0] ?? '', /every session and stat/);
|
||||
});
|
||||
|
||||
test('AnimeHeader shows TMDB actions for a live-action entry and hides AniList links', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<AnimeHeader
|
||||
detail={{
|
||||
...DETAIL,
|
||||
anilistId: null,
|
||||
mediaKind: 'live_action',
|
||||
tmdbId: 61222,
|
||||
tmdbType: 'tv',
|
||||
titleRomaji: null,
|
||||
titleEnglish: 'Hanzawa Naoki',
|
||||
titleNative: '半沢直樹',
|
||||
description: 'A banker fights back.',
|
||||
}}
|
||||
anilistEntries={[]}
|
||||
onChangeAnilist={() => {}}
|
||||
onChangeTmdb={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /https:\/\/www\.themoviedb\.org\/tv\/61222/);
|
||||
assert.match(markup, /Change TMDB Title/);
|
||||
assert.match(markup, /Live action/);
|
||||
assert.match(markup, /A banker fights back\./);
|
||||
assert.doesNotMatch(markup, /anilist\.co/);
|
||||
});
|
||||
|
||||
test('AnimeHeader offers to link an unlinked anime entry to TMDB', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<AnimeHeader
|
||||
detail={{ ...DETAIL, anilistId: null }}
|
||||
anilistEntries={[]}
|
||||
onChangeTmdb={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /Link to TMDB/);
|
||||
assert.doesNotMatch(markup, /themoviedb\.org/);
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ interface AnimeHeaderProps {
|
||||
anilistEntries: AnilistEntry[];
|
||||
coverRetryToken?: number;
|
||||
onChangeAnilist?: () => void;
|
||||
onChangeTmdb?: () => void;
|
||||
onDeleteAnime?: () => void;
|
||||
isDeletingAnime?: boolean;
|
||||
}
|
||||
@@ -34,6 +35,7 @@ export function AnimeHeader({
|
||||
anilistEntries,
|
||||
coverRetryToken = 0,
|
||||
onChangeAnilist,
|
||||
onChangeTmdb,
|
||||
onDeleteAnime,
|
||||
isDeletingAnime = false,
|
||||
}: AnimeHeaderProps) {
|
||||
@@ -43,7 +45,12 @@ export function AnimeHeader({
|
||||
const uniqueAltTitles = [...new Set(altTitles)];
|
||||
|
||||
const hasMultipleEntries = anilistEntries.length > 1;
|
||||
const coverCacheToken = (detail.anilistId ?? 0) * 1_000_000 + coverRetryToken;
|
||||
const isLiveAction = detail.mediaKind === 'live_action';
|
||||
const tmdbUrl =
|
||||
detail.tmdbId && detail.tmdbType
|
||||
? `https://www.themoviedb.org/${detail.tmdbType}/${detail.tmdbId}`
|
||||
: null;
|
||||
const coverCacheToken = (detail.anilistId ?? detail.tmdbId ?? 0) * 1_000_000 + coverRetryToken;
|
||||
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
@@ -60,11 +67,28 @@ export function AnimeHeader({
|
||||
{uniqueAltTitles.join(' · ')}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm text-ctp-subtext0 mt-2">
|
||||
{detail.episodeCount} episode{detail.episodeCount !== 1 ? 's' : ''}
|
||||
<div className="text-sm text-ctp-subtext0 mt-2 flex items-center gap-2">
|
||||
<span>
|
||||
{detail.episodeCount} episode{detail.episodeCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
{isLiveAction && (
|
||||
<span className="px-1.5 py-0.5 rounded text-[10px] uppercase tracking-wide bg-ctp-peach/15 text-ctp-peach">
|
||||
{detail.tmdbType === 'movie' ? 'Movie' : 'Live action'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
{anilistEntries.length > 0 ? (
|
||||
{tmdbUrl && (
|
||||
<a
|
||||
href={tmdbUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded bg-ctp-surface1 text-ctp-blue hover:bg-ctp-surface2 hover:text-ctp-sapphire transition-colors"
|
||||
>
|
||||
View on TMDB <span className="text-[10px]">{'\u2197'}</span>
|
||||
</a>
|
||||
)}
|
||||
{isLiveAction ? null : anilistEntries.length > 0 ? (
|
||||
hasMultipleEntries ? (
|
||||
anilistEntries.map((entry) => <AnilistButton key={entry.anilistId} entry={entry} />)
|
||||
) : (
|
||||
@@ -99,6 +123,16 @@ export function AnimeHeader({
|
||||
: 'Link to AniList'}
|
||||
</button>
|
||||
)}
|
||||
{onChangeTmdb && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onChangeTmdb}
|
||||
title="Search TMDB and link this title to a live-action drama or movie"
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded bg-ctp-surface1 text-ctp-overlay2 hover:bg-ctp-surface2 hover:text-ctp-subtext0 transition-colors"
|
||||
>
|
||||
{tmdbUrl ? 'Change TMDB Title' : 'Link to TMDB'}
|
||||
</button>
|
||||
)}
|
||||
{onDeleteAnime && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -120,7 +120,7 @@ export function AnimeMergeDialog({ entries, onClose, onMerged }: AnimeMergeDialo
|
||||
<AnimeCoverImage
|
||||
animeId={entry.animeId}
|
||||
title={entry.canonicalTitle}
|
||||
coverRetryToken={entry.anilistId ?? 0}
|
||||
coverRetryToken={entry.anilistId ?? entry.tmdbId ?? 0}
|
||||
className="w-10 h-14 rounded shrink-0"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
|
||||
@@ -48,6 +48,9 @@ function libraryItem(animeId: number, title: string, episodeCount: number): Anim
|
||||
animeId,
|
||||
canonicalTitle: title,
|
||||
anilistId: null,
|
||||
mediaKind: 'anime',
|
||||
tmdbId: null,
|
||||
tmdbType: null,
|
||||
totalSessions: 1,
|
||||
totalActiveMs: 1000,
|
||||
totalCards: 1,
|
||||
|
||||
@@ -48,6 +48,9 @@ function libraryItem(anilistId: number | null): AnimeLibraryItem {
|
||||
animeId: 7,
|
||||
canonicalTitle: 'Test Anime Season 2',
|
||||
anilistId,
|
||||
mediaKind: 'anime',
|
||||
tmdbId: null,
|
||||
tmdbType: null,
|
||||
totalSessions: 1,
|
||||
totalActiveMs: 1000,
|
||||
totalCards: 0,
|
||||
@@ -64,6 +67,9 @@ function detailData(anilistId: number | null): AnimeDetailData {
|
||||
animeId: 7,
|
||||
canonicalTitle: 'Test Anime Season 2',
|
||||
anilistId,
|
||||
mediaKind: 'anime',
|
||||
tmdbId: null,
|
||||
tmdbType: null,
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
titleNative: null,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { AnimeMergeDialog } from './AnimeMergeDialog';
|
||||
import { DuplicateReviewStrip } from './DuplicateReviewStrip';
|
||||
|
||||
type SortKey = 'lastWatched' | 'watchTime' | 'cards' | 'episodes';
|
||||
type KindFilter = 'all' | 'anime' | 'live_action';
|
||||
|
||||
const GRID_CLASSES: Record<LibraryCardSize, string> = {
|
||||
sm: 'grid-cols-5 sm:grid-cols-7 md:grid-cols-9 lg:grid-cols-11',
|
||||
@@ -20,6 +21,12 @@ const GRID_CLASSES: Record<LibraryCardSize, string> = {
|
||||
lg: 'grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7',
|
||||
};
|
||||
|
||||
const KIND_OPTIONS: { key: KindFilter; label: string }[] = [
|
||||
{ key: 'all', label: 'All Titles' },
|
||||
{ key: 'anime', label: 'Anime' },
|
||||
{ key: 'live_action', label: 'Live Action' },
|
||||
];
|
||||
|
||||
const SORT_OPTIONS: { key: SortKey; label: string }[] = [
|
||||
{ key: 'lastWatched', label: 'Last Watched' },
|
||||
{ key: 'watchTime', label: 'Watch Time' },
|
||||
@@ -68,6 +75,7 @@ export function AnimeTab({
|
||||
} = useAnimeLibrary();
|
||||
const [search, setSearch] = useState('');
|
||||
const [sortKey, setSortKey] = useState<SortKey>('lastWatched');
|
||||
const [kindFilter, setKindFilter] = useState<KindFilter>('all');
|
||||
const [cardSize, setCardSize] = useState<LibraryCardSize>(() =>
|
||||
readLibraryCardSizePreference(
|
||||
getLibraryCardSizeStorage(typeof window === 'undefined' ? null : window),
|
||||
@@ -108,11 +116,12 @@ export function AnimeTab({
|
||||
}, [initialAnimeId, onClearInitialAnime]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const byKind = kindFilter === 'all' ? anime : anime.filter((a) => a.mediaKind === kindFilter);
|
||||
const base = search.trim()
|
||||
? anime.filter((a) => a.canonicalTitle.toLowerCase().includes(search.toLowerCase()))
|
||||
: anime;
|
||||
? byKind.filter((a) => a.canonicalTitle.toLowerCase().includes(search.toLowerCase()))
|
||||
: byKind;
|
||||
return sortAnime(base, sortKey);
|
||||
}, [anime, search, sortKey]);
|
||||
}, [anime, search, sortKey, kindFilter]);
|
||||
|
||||
const totalMs = anime.reduce((sum, a) => sum + a.totalActiveMs, 0);
|
||||
const checkedEntries = checkedAnimeIds
|
||||
@@ -163,6 +172,18 @@ export function AnimeTab({
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="flex-1 bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue"
|
||||
/>
|
||||
<select
|
||||
value={kindFilter}
|
||||
onChange={(e) => setKindFilter(e.target.value as KindFilter)}
|
||||
aria-label="Filter by media kind"
|
||||
className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-2 py-2 text-sm text-ctp-text focus:outline-none focus:border-ctp-blue"
|
||||
>
|
||||
{KIND_OPTIONS.map((opt) => (
|
||||
<option key={opt.key} value={opt.key}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={sortKey}
|
||||
onChange={(e) => setSortKey(e.target.value as SortKey)}
|
||||
@@ -258,6 +279,11 @@ export function AnimeTab({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-[11px] text-ctp-overlay2 pt-2">
|
||||
Cover art and synopses come from AniList and TMDB. This product uses the TMDB API but is not
|
||||
endorsed or certified by TMDB.
|
||||
</p>
|
||||
|
||||
{showMergeDialog && mergeEntries.length >= 2 && (
|
||||
<AnimeMergeDialog
|
||||
entries={mergeEntries}
|
||||
|
||||
@@ -66,6 +66,9 @@ function libraryItem(animeId: number, title: string): AnimeLibraryItem {
|
||||
animeId,
|
||||
canonicalTitle: title,
|
||||
anilistId: null,
|
||||
mediaKind: 'anime',
|
||||
tmdbId: null,
|
||||
tmdbType: null,
|
||||
totalSessions: 1,
|
||||
totalActiveMs: 1000,
|
||||
totalCards: 0,
|
||||
|
||||
@@ -144,7 +144,7 @@ export function LibraryEntryPicker({
|
||||
<AnimeCoverImage
|
||||
animeId={entry.animeId}
|
||||
title={entry.canonicalTitle}
|
||||
coverRetryToken={entry.anilistId ?? 0}
|
||||
coverRetryToken={entry.anilistId ?? entry.tmdbId ?? 0}
|
||||
className="w-10 h-14 rounded shrink-0"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { Window } from 'happy-dom';
|
||||
import { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { apiClient } from '../../lib/api-client';
|
||||
import type { StatsTmdbSearchResult } from '../../types/stats';
|
||||
import { TmdbSelector } from './TmdbSelector';
|
||||
|
||||
interface TestWindow extends Window {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
|
||||
function installDom(): () => void {
|
||||
const previousWindow = globalThis.window;
|
||||
const previousDocument = globalThis.document;
|
||||
const previousHTMLElement = globalThis.HTMLElement;
|
||||
const previousISReactActEnvironment = (
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT;
|
||||
const window = new Window() as TestWindow;
|
||||
|
||||
Object.defineProperty(globalThis, 'window', { value: window, configurable: true });
|
||||
Object.defineProperty(globalThis, 'document', { value: window.document, configurable: true });
|
||||
Object.defineProperty(globalThis, 'HTMLElement', {
|
||||
value: window.HTMLElement,
|
||||
configurable: true,
|
||||
});
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
return () => {
|
||||
Object.defineProperty(globalThis, 'window', { value: previousWindow, configurable: true });
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
value: previousDocument,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'HTMLElement', {
|
||||
value: previousHTMLElement,
|
||||
configurable: true,
|
||||
});
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = previousISReactActEnvironment;
|
||||
};
|
||||
}
|
||||
|
||||
const HANZAWA: StatsTmdbSearchResult = {
|
||||
tmdbId: 61222,
|
||||
tmdbType: 'tv',
|
||||
title: 'Hanzawa Naoki',
|
||||
originalTitle: '半沢直樹',
|
||||
originalLanguage: 'ja',
|
||||
overview: null,
|
||||
posterUrl: null,
|
||||
year: 2013,
|
||||
isAnimation: false,
|
||||
};
|
||||
|
||||
test('TmdbSelector searches the normalized title and links the picked result by id', async () => {
|
||||
const uninstallDom = installDom();
|
||||
const original = {
|
||||
searchTmdb: apiClient.searchTmdb,
|
||||
reassignAnimeTmdb: apiClient.reassignAnimeTmdb,
|
||||
};
|
||||
const searchCalls: string[] = [];
|
||||
const linkCalls: Array<[number, { tmdbId: number; tmdbType: string }]> = [];
|
||||
let linked = 0;
|
||||
apiClient.searchTmdb = (async (query: string) => {
|
||||
searchCalls.push(query);
|
||||
return [HANZAWA];
|
||||
}) as typeof apiClient.searchTmdb;
|
||||
apiClient.reassignAnimeTmdb = (async (animeId: number, info) => {
|
||||
linkCalls.push([animeId, info]);
|
||||
}) as typeof apiClient.reassignAnimeTmdb;
|
||||
|
||||
try {
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<TmdbSelector
|
||||
animeId={9}
|
||||
initialQuery="Hanzawa Naoki Season 2"
|
||||
onClose={() => {}}
|
||||
onLinked={() => {
|
||||
linked += 1;
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
assert.deepEqual(searchCalls, ['Hanzawa Naoki']);
|
||||
assert.match(container.textContent ?? '', /半沢直樹/);
|
||||
assert.match(container.textContent ?? '', /TV · 2013/);
|
||||
|
||||
const pick = [...container.querySelectorAll('button')].find((button) =>
|
||||
/Select/.test(button.textContent ?? ''),
|
||||
);
|
||||
assert.ok(pick);
|
||||
await act(async () => {
|
||||
pick.click();
|
||||
});
|
||||
|
||||
assert.deepEqual(linkCalls, [[9, { tmdbId: 61222, tmdbType: 'tv' }]]);
|
||||
assert.equal(linked, 1);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
} finally {
|
||||
apiClient.searchTmdb = original.searchTmdb;
|
||||
apiClient.reassignAnimeTmdb = original.reassignAnimeTmdb;
|
||||
uninstallDom();
|
||||
}
|
||||
});
|
||||
|
||||
test('TmdbSelector explains a missing API key instead of showing "No results"', async () => {
|
||||
const uninstallDom = installDom();
|
||||
const originalSearch = apiClient.searchTmdb;
|
||||
apiClient.searchTmdb = (async () => {
|
||||
throw new Error('Stats API error: 503 {"error":"TMDB API key not configured."}');
|
||||
}) as typeof apiClient.searchTmdb;
|
||||
|
||||
try {
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<TmdbSelector
|
||||
animeId={1}
|
||||
initialQuery="Hanzawa Naoki"
|
||||
onClose={() => {}}
|
||||
onLinked={() => {}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
assert.match(container.textContent ?? '', /tmdb\.apiKey/);
|
||||
assert.doesNotMatch(container.textContent ?? '', /No results/);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
} finally {
|
||||
apiClient.searchTmdb = originalSearch;
|
||||
uninstallDom();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { apiClient } from '../../lib/api-client';
|
||||
import { normalizeAnilistSearchQuery } from '../../lib/anilist-search-query';
|
||||
import type { StatsTmdbSearchResult } from '../../types/stats';
|
||||
|
||||
interface TmdbSelectorProps {
|
||||
animeId: number;
|
||||
initialQuery: string;
|
||||
onClose: () => void;
|
||||
onLinked: () => void;
|
||||
}
|
||||
|
||||
// The stats API answers a missing key with 503 and the message from config.
|
||||
function describeSearchError(err: unknown): string {
|
||||
const message = err instanceof Error ? err.message : '';
|
||||
if (/\b503\b/.test(message)) {
|
||||
return 'TMDB API key not configured. Set tmdb.apiKey or tmdb.apiKeyCommand in your config.';
|
||||
}
|
||||
return 'TMDB search failed. Check your connection and API key.';
|
||||
}
|
||||
|
||||
export function TmdbSelector({ animeId, initialQuery, onClose, onLinked }: TmdbSelectorProps) {
|
||||
const [query, setQuery] = useState(() => normalizeAnilistSearchQuery(initialQuery));
|
||||
const [results, setResults] = useState<StatsTmdbSearchResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [linking, setLinking] = useState<number | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
const normalizedInitialQuery = normalizeAnilistSearchQuery(initialQuery);
|
||||
setQuery(normalizedInitialQuery);
|
||||
setResults([]);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
setLinking(null);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
if (normalizedInitialQuery) void doSearch(normalizedInitialQuery);
|
||||
}, [initialQuery, animeId]);
|
||||
|
||||
const doSearch = async (q: string) => {
|
||||
const searchQuery = normalizeAnilistSearchQuery(q);
|
||||
if (!searchQuery) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setResults(await apiClient.searchTmdb(searchQuery));
|
||||
} catch (err) {
|
||||
setResults([]);
|
||||
setError(describeSearchError(err));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleInput = (value: string) => {
|
||||
setQuery(value);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => void doSearch(value), 400);
|
||||
};
|
||||
|
||||
const handleSelect = async (media: StatsTmdbSearchResult) => {
|
||||
setLinking(media.tmdbId);
|
||||
setError(null);
|
||||
try {
|
||||
await apiClient.reassignAnimeTmdb(animeId, {
|
||||
tmdbId: media.tmdbId,
|
||||
tmdbType: media.tmdbType,
|
||||
});
|
||||
onLinked();
|
||||
} catch (err) {
|
||||
setError(describeSearchError(err));
|
||||
setLinking(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[10vh]" onClick={onClose}>
|
||||
<div className="absolute inset-0 bg-ctp-crust/70 backdrop-blur-[2px]" />
|
||||
<div
|
||||
className="relative bg-ctp-base border border-ctp-surface1 rounded-xl shadow-2xl w-full max-w-lg max-h-[70vh] flex flex-col animate-fade-in"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-4 border-b border-ctp-surface1">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-ctp-text">Select TMDB Title</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-ctp-overlay2 hover:text-ctp-text text-lg leading-none"
|
||||
>
|
||||
{'✕'}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => handleInput(e.target.value)}
|
||||
placeholder="Search TMDB for a drama or movie..."
|
||||
className="w-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{loading && <div className="text-xs text-ctp-overlay2 p-3">Searching...</div>}
|
||||
{error && <div className="text-xs text-ctp-red p-3">{error}</div>}
|
||||
{!loading && !error && results.length === 0 && query.trim() && (
|
||||
<div className="text-xs text-ctp-overlay2 p-3">No results</div>
|
||||
)}
|
||||
{results.map((media) => (
|
||||
<button
|
||||
key={`${media.tmdbType}-${media.tmdbId}`}
|
||||
type="button"
|
||||
disabled={linking !== null}
|
||||
onClick={() => void handleSelect(media)}
|
||||
className="w-full flex items-center gap-3 p-2.5 rounded-lg hover:bg-ctp-surface0 transition-colors text-left disabled:opacity-50"
|
||||
>
|
||||
{media.posterUrl ? (
|
||||
<img
|
||||
src={media.posterUrl}
|
||||
alt=""
|
||||
className="w-10 h-14 rounded object-cover shrink-0 bg-ctp-surface1"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-14 rounded bg-ctp-surface1 shrink-0" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm text-ctp-text truncate">{media.title}</div>
|
||||
{media.originalTitle !== media.title && (
|
||||
<div className="text-xs text-ctp-subtext0 truncate">{media.originalTitle}</div>
|
||||
)}
|
||||
<div className="text-xs text-ctp-overlay2 mt-0.5">
|
||||
{media.tmdbType === 'movie' ? 'Movie' : 'TV'}
|
||||
{media.year ? ` · ${media.year}` : ''}
|
||||
{media.isAnimation ? ' · Animation' : ''}
|
||||
</div>
|
||||
</div>
|
||||
{linking === media.tmdbId ? (
|
||||
<span className="text-xs text-ctp-blue shrink-0">Linking...</span>
|
||||
) : (
|
||||
<span className="text-xs text-ctp-overlay2 shrink-0">Select</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
StatsMergeAnimeResponse,
|
||||
StatsMoveVideoRequest,
|
||||
StatsMoveVideoResponse,
|
||||
StatsTmdbAssignment,
|
||||
StatsTrendGroupBy,
|
||||
StatsTrendRange,
|
||||
StatsVideoWatchedRequest,
|
||||
@@ -256,6 +257,15 @@ export const apiClient = {
|
||||
body: JSON.stringify(info),
|
||||
});
|
||||
},
|
||||
searchTmdb: (query: string) =>
|
||||
fetchJson('tmdbSearch', `/api/stats/tmdb/search?q=${encodeURIComponent(query)}`),
|
||||
reassignAnimeTmdb: async (animeId: number, info: StatsTmdbAssignment): Promise<void> => {
|
||||
await fetchResponse(`/api/stats/anime/${animeId}/tmdb`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(info satisfies StatsTmdbAssignment),
|
||||
});
|
||||
},
|
||||
mineCard: async (params: StatsMineCardParams): Promise<StatsMineCardResponse> => {
|
||||
const res = await fetch(`${BASE_URL}/api/stats/mine-card?mode=${params.mode}`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -117,6 +117,9 @@ test('AnimeOverviewStats renders aggregate Yomitan lookup metrics', () => {
|
||||
animeId: 1,
|
||||
canonicalTitle: 'Anime',
|
||||
anilistId: null,
|
||||
mediaKind: 'anime',
|
||||
tmdbId: null,
|
||||
tmdbType: null,
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
titleNative: null,
|
||||
|
||||
Reference in New Issue
Block a user