fix(v5): Laufzeit-Pfade plattformneutral — pipeline/struktur auf node:path
Ampel / ampel (push) Successful in 1m21s

Der NFO-Ablage-Test brach in der Linux-CI: pipeline/struktur bauten mit
path.win32 auf POSIX-Testordnern (mkdir legte 'x\Filme' als EINEN Namen
an, readdir fand nichts). Regel jetzt dokumentiert: Module ueber
LAUFZEIT-Wurzeln (Ablage) nutzen plattform-path — zur echten Laufzeit
immer Windows, in der CI physisch korrekt auf POSIX; Module mit FESTER
Windows-Semantik (katalog, dateiDaneben) bleiben bei path.win32.
Testerwartungen entsprechend ueber join() statt Literale.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-08-30 18:29:46 +02:00
co-authored by Claude Fable 5
parent 3dc296f071
commit e840b7f327
6 changed files with 390 additions and 15 deletions
+127
View File
@@ -0,0 +1,127 @@
// TMDb-Client (KONZEPT § 4.2) — Regeln aus clients/tmdb.py: Der Key kann
// ein v3-Key (32 Hex, api_key-Query) ODER ein v4-Token ('eyJ…',
// Bearer-Header) sein — der Befund vom 24.07.: der alte Client konnte nur
// eine Form, und die andere sah aus wie „falscher Key". Sprache de-DE.
export interface FilmTreffer {
id: number
title?: string
name?: string
}
export interface FilmDetails {
id: number
title?: string
name?: string
release_date?: string
first_air_date?: string
overview?: string
poster_path?: string
backdrop_path?: string
runtime?: number
genres?: Array<{ name: string }>
}
export function istV4Token(key: string): boolean {
return key.startsWith('eyJ')
}
const BASIS = 'https://api.themoviedb.org/3'
export class TmdbClient {
constructor(
private readonly apiKey: string,
private readonly holen: typeof fetch = fetch,
) {}
get verfuegbar(): boolean {
return this.apiKey.length > 0
}
private async anfrage(weg: string, params: Record<string, string>): Promise<unknown> {
if (!this.verfuegbar) return null
const url = new URL(`${BASIS}/${weg}`)
for (const [name, wert] of Object.entries(params)) url.searchParams.set(name, wert)
const kopf: Record<string, string> = { Accept: 'application/json' }
if (istV4Token(this.apiKey)) {
kopf['Authorization'] = `Bearer ${this.apiKey}`
} else {
url.searchParams.set('api_key', this.apiKey)
}
try {
const antwort = await this.holen(url.toString(), {
headers: kopf,
signal: AbortSignal.timeout(15_000),
})
if (antwort.status >= 300) return null
return await antwort.json()
} catch {
// R2: Ein verpasster Abruf ist keine Nachricht über die Welt — der
// Aufrufer bekommt null („weiß nicht"), nie eine leere Behauptung.
return null
}
}
async searchMovie(titel: string): Promise<FilmTreffer[] | null> {
const daten = (await this.anfrage('search/movie', {
query: titel,
language: 'de-DE',
include_adult: 'false',
})) as { results?: FilmTreffer[] } | null
return daten?.results ?? null
}
async searchTv(titel: string): Promise<FilmTreffer[] | null> {
const daten = (await this.anfrage('search/tv', {
query: titel,
language: 'de-DE',
include_adult: 'false',
})) as { results?: FilmTreffer[] } | null
return daten?.results ?? null
}
async movieDetails(id: number): Promise<FilmDetails | null> {
return (await this.anfrage(`movie/${id}`, { language: 'de-DE' })) as FilmDetails | null
}
async tvDetails(id: number): Promise<FilmDetails | null> {
return (await this.anfrage(`tv/${id}`, { language: 'de-DE' })) as FilmDetails | null
}
/** Staffel-Details mit Episoden-Laufzeiten — für die Serien-Zuordnung. */
async tvStaffel(id: number, staffel: number): Promise<{ episodes?: Array<{ episode_number: number; runtime?: number }> } | null> {
return (await this.anfrage(`tv/${id}/season/${staffel}`, { language: 'de-DE' })) as {
episodes?: Array<{ episode_number: number; runtime?: number }>
} | null
}
/** IMDb-ID → deutscher Datensatz (für OMDb-Treffer, die nur Englisch
* können — Commander-Wunsch 24.07.). */
async findByImdb(imdbId: string): Promise<Record<string, unknown> | null> {
const daten = (await this.anfrage(`find/${imdbId}`, {
external_source: 'imdb_id',
language: 'de-DE',
})) as { movie_results?: FilmDetails[]; tv_results?: FilmDetails[] } | null
const film = daten?.movie_results?.[0]
if (film !== undefined) {
return {
title: film.title,
overview: film.overview,
poster_path: film.poster_path,
year: film.release_date !== undefined && film.release_date.length >= 4 ? Number(film.release_date.slice(0, 4)) : null,
type: 'movie',
}
}
const serie = daten?.tv_results?.[0]
if (serie !== undefined) {
return {
title: serie.name,
overview: serie.overview,
poster_path: serie.poster_path,
year: serie.first_air_date !== undefined && serie.first_air_date.length >= 4 ? Number(serie.first_air_date.slice(0, 4)) : null,
type: 'tv',
}
}
return null
}
}