From c35e97f38862cf1d2f74d007c6d06fe74ac8239f Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 1 Feb 2026 19:19:32 +0100 Subject: [PATCH] feat(tv): persist downloaded opensubtitles across app restarts --- app/(auth)/player/direct-player.tsx | 46 +++++ app/(auth)/tv-subtitle-modal.tsx | 30 +++- components/ItemContent.tv.tsx | 96 ++++++++++- .../controls/contexts/VideoContext.tsx | 37 +++- components/video-player/controls/types.ts | 4 + hooks/useRemoteSubtitles.ts | 64 ++++++- utils/atoms/downloadedSubtitles.ts | 162 ++++++++++++++++++ utils/opensubtitles/api.ts | 107 +++++++----- 8 files changed, 489 insertions(+), 57 deletions(-) create mode 100644 utils/atoms/downloadedSubtitles.ts diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index 49fc16b6..7caeba24 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -10,6 +10,7 @@ import { getPlaystateApi, getUserLibraryApi, } from "@jellyfin/sdk/lib/utils/api"; +import { File } from "expo-file-system"; import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake"; import { useLocalSearchParams, useNavigation } from "expo-router"; import { useAtomValue } from "jotai"; @@ -49,6 +50,7 @@ import { useInactivity } from "@/providers/InactivityProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { OfflineModeProvider } from "@/providers/OfflineModeProvider"; +import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles"; import { useSettings } from "@/utils/atoms/settings"; import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; @@ -1075,6 +1077,28 @@ export default function page() { if (settings.mpvSubtitleAlignY !== undefined) { await videoRef.current?.setSubtitleAlignY?.(settings.mpvSubtitleAlignY); } + // Apply subtitle background (iOS only - doesn't work on tvOS due to composite OSD limitation) + // mpv uses #RRGGBBAA format (alpha last, same as CSS) + if (settings.mpvSubtitleBackgroundEnabled) { + const opacity = settings.mpvSubtitleBackgroundOpacity ?? 75; + const alphaHex = Math.round((opacity / 100) * 255) + .toString(16) + .padStart(2, "0") + .toUpperCase(); + // Enable background-box mode (required for sub-back-color to work) + await videoRef.current?.setSubtitleBorderStyle?.("background-box"); + await videoRef.current?.setSubtitleBackgroundColor?.( + `#000000${alphaHex}`, + ); + // Force override ASS subtitle styles so background shows on styled subtitles + await videoRef.current?.setSubtitleAssOverride?.("force"); + } else { + // Restore default outline-and-shadow style + await videoRef.current?.setSubtitleBorderStyle?.("outline-and-shadow"); + await videoRef.current?.setSubtitleBackgroundColor?.("#00000000"); + // Restore default ASS behavior (keep original styles) + await videoRef.current?.setSubtitleAssOverride?.("no"); + } }; applySubtitleSettings(); @@ -1094,6 +1118,28 @@ export default function page() { applyInitialPlaybackSpeed(); }, [isVideoLoaded, initialPlaybackSpeed]); + // TV only: Pre-load locally downloaded subtitles when video loads + // This adds them to MPV's track list without auto-selecting them + useEffect(() => { + if (!Platform.isTV || !isVideoLoaded || !videoRef.current || !itemId) + return; + + const preloadLocalSubtitles = async () => { + const localSubs = getSubtitlesForItem(itemId); + for (const sub of localSubs) { + // Verify file still exists (cache may have been cleared) + const subtitleFile = new File(sub.filePath); + if (!subtitleFile.exists) { + continue; + } + // Add subtitle file to MPV without selecting it (select: false) + await videoRef.current?.addSubtitleFile?.(sub.filePath, false); + } + }; + + preloadLocalSubtitles(); + }, [isVideoLoaded, itemId]); + // Show error UI first, before checking loading/missing‐data if (itemStatus.isError || streamStatus.isError) { return ( diff --git a/app/(auth)/tv-subtitle-modal.tsx b/app/(auth)/tv-subtitle-modal.tsx index 1745beed..e597a782 100644 --- a/app/(auth)/tv-subtitle-modal.tsx +++ b/app/(auth)/tv-subtitle-modal.tsx @@ -659,8 +659,30 @@ export default function TVSubtitleModal() { // Do NOT close modal - user can see and select the new track } else if (downloadResult.type === "local" && downloadResult.path) { + // Notify parent that a local subtitle was downloaded modalState?.onLocalSubtitleDownloaded?.(downloadResult.path); - handleClose(); // Only close for local downloads + + // Check if component is still mounted after callback + if (!isMountedRef.current) return; + + // Refresh tracks to include the newly downloaded subtitle + if (modalState?.refreshSubtitleTracks) { + const newTracks = await modalState.refreshSubtitleTracks(); + + // Check if component is still mounted after fetching tracks + if (!isMountedRef.current) return; + + // Update atom with new tracks + store.set(tvSubtitleModalAtom, { + ...modalState, + subtitleTracks: newTracks, + }); + // Switch to tracks tab to show the new subtitle + setActiveTab("tracks"); + } else { + // No refreshSubtitleTracks available (e.g., from player), just close + handleClose(); + } } } catch (error) { console.error("Failed to download subtitle:", error); @@ -685,13 +707,17 @@ export default function TVSubtitleModal() { value: -1, selected: currentSubtitleIndex === -1, setTrack: () => modalState?.onDisableSubtitles?.(), + isLocal: false, }; const options = subtitleTracks.map((track: Track) => ({ label: track.name, - sublabel: undefined as string | undefined, + sublabel: track.isLocal + ? t("player.downloaded") || "Downloaded" + : (undefined as string | undefined), value: track.index, selected: track.index === currentSubtitleIndex, setTrack: track.setTrack, + isLocal: track.isLocal ?? false, })); return [noneOption, ...options]; }, [subtitleTracks, currentSubtitleIndex, t, modalState]); diff --git a/components/ItemContent.tv.tsx b/components/ItemContent.tv.tsx index f9e4578b..02498d37 100644 --- a/components/ItemContent.tv.tsx +++ b/components/ItemContent.tv.tsx @@ -7,6 +7,7 @@ import type { import { getTvShowsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { BlurView } from "expo-blur"; +import { File } from "expo-file-system"; import { Image } from "expo-image"; import { useAtom } from "jotai"; import React, { @@ -50,6 +51,7 @@ import { useTVSubtitleModal } from "@/hooks/useTVSubtitleModal"; import { useTVThemeMusic } from "@/hooks/useTVThemeMusic"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useOfflineMode } from "@/providers/OfflineModeProvider"; +import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles"; import { useSettings } from "@/utils/atoms/settings"; import type { TVOptionItem } from "@/utils/atoms/tvOptionModal"; import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; @@ -243,9 +245,16 @@ export const ItemContentTV: React.FC = React.memo( null, ); + // State to trigger refresh of local subtitles list + const [localSubtitlesRefreshKey, setLocalSubtitlesRefreshKey] = useState(0); + + // Starting index for local (client-downloaded) subtitles + const LOCAL_SUBTITLE_INDEX_START = -100; + // Convert MediaStream[] to Track[] for the modal (with setTrack callbacks) + // Also includes locally downloaded subtitles from OpenSubtitles const subtitleTracksForModal = useMemo((): Track[] => { - return subtitleStreams.map((stream) => ({ + const tracks: Track[] = subtitleStreams.map((stream) => ({ name: stream.DisplayTitle || `${stream.Language || "Unknown"} (${stream.Codec})`, @@ -254,7 +263,37 @@ export const ItemContentTV: React.FC = React.memo( handleSubtitleChangeRef.current?.(stream.Index ?? -1); }, })); - }, [subtitleStreams]); + + // Add locally downloaded subtitles (from OpenSubtitles) + if (item?.Id) { + const localSubs = getSubtitlesForItem(item.Id); + let localIdx = 0; + for (const localSub of localSubs) { + // Verify file still exists (cache may have been cleared) + const subtitleFile = new File(localSub.filePath); + if (!subtitleFile.exists) { + continue; + } + + const localIndex = LOCAL_SUBTITLE_INDEX_START - localIdx; + tracks.push({ + name: localSub.name, + index: localIndex, + isLocal: true, + localPath: localSub.filePath, + setTrack: () => { + // For ItemContent (outside player), just update the selected index + // The actual subtitle will be loaded when playback starts + handleSubtitleChangeRef.current?.(localIndex); + }, + }); + localIdx++; + } + } + + return tracks; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [subtitleStreams, item?.Id, localSubtitlesRefreshKey]); // Get available media sources const mediaSources = useMemo(() => { @@ -346,6 +385,12 @@ export const ItemContentTV: React.FC = React.memo( } }, [item?.Id, queryClient]); + // Handle local subtitle download - trigger refresh of subtitle tracks + const handleLocalSubtitleDownloaded = useCallback((_path: string) => { + // Increment the refresh key to trigger re-computation of subtitleTracksForModal + setLocalSubtitlesRefreshKey((prev) => prev + 1); + }, []); + // Refresh subtitle tracks by fetching fresh item data from Jellyfin const refreshSubtitleTracks = useCallback(async (): Promise => { if (!api || !item?.Id) return []; @@ -373,7 +418,7 @@ export const ItemContentTV: React.FC = React.memo( ) ?? []; // Convert to Track[] with setTrack callbacks - return streams.map((stream) => ({ + const tracks: Track[] = streams.map((stream) => ({ name: stream.DisplayTitle || `${stream.Language || "Unknown"} (${stream.Codec})`, @@ -382,6 +427,30 @@ export const ItemContentTV: React.FC = React.memo( handleSubtitleChangeRef.current?.(stream.Index ?? -1); }, })); + + // Add locally downloaded subtitles + if (item?.Id) { + const localSubs = getSubtitlesForItem(item.Id); + let localIdx = 0; + for (const localSub of localSubs) { + const subtitleFile = new File(localSub.filePath); + if (!subtitleFile.exists) continue; + + const localIndex = LOCAL_SUBTITLE_INDEX_START - localIdx; + tracks.push({ + name: localSub.name, + index: localIndex, + isLocal: true, + localPath: localSub.filePath, + setTrack: () => { + handleSubtitleChangeRef.current?.(localIndex); + }, + }); + localIdx++; + } + } + + return tracks; } catch (error) { console.error("Failed to refresh subtitle tracks:", error); return []; @@ -399,13 +468,30 @@ export const ItemContentTV: React.FC = React.memo( const selectedSubtitleLabel = useMemo(() => { if (selectedOptions?.subtitleIndex === -1) return t("item_card.subtitles.none"); + + // Check if it's a local subtitle (negative index starting at -100) + if ( + selectedOptions?.subtitleIndex !== undefined && + selectedOptions.subtitleIndex <= LOCAL_SUBTITLE_INDEX_START + ) { + const localTrack = subtitleTracksForModal.find( + (t) => t.index === selectedOptions.subtitleIndex, + ); + return localTrack?.name || t("item_card.subtitles.label"); + } + const track = subtitleStreams.find( (t) => t.Index === selectedOptions?.subtitleIndex, ); return ( track?.DisplayTitle || track?.Language || t("item_card.subtitles.label") ); - }, [subtitleStreams, selectedOptions?.subtitleIndex, t]); + }, [ + subtitleStreams, + subtitleTracksForModal, + selectedOptions?.subtitleIndex, + t, + ]); const selectedMediaSourceLabel = useMemo(() => { const source = selectedOptions?.mediaSource; @@ -742,6 +828,8 @@ export const ItemContentTV: React.FC = React.memo( onDisableSubtitles: () => handleSubtitleChange(-1), onServerSubtitleDownloaded: handleServerSubtitleDownloaded, + onLocalSubtitleDownloaded: + handleLocalSubtitleDownloaded, refreshSubtitleTracks, }) } diff --git a/components/video-player/controls/contexts/VideoContext.tsx b/components/video-player/controls/contexts/VideoContext.tsx index ec9ca995..7c575084 100644 --- a/components/video-player/controls/contexts/VideoContext.tsx +++ b/components/video-player/controls/contexts/VideoContext.tsx @@ -47,6 +47,7 @@ */ import { SubtitleDeliveryMethod } from "@jellyfin/sdk/lib/generated-client"; +import { File } from "expo-file-system"; import { useLocalSearchParams } from "expo-router"; import type React from "react"; import { @@ -57,13 +58,19 @@ import { useMemo, useState, } from "react"; +import { Platform } from "react-native"; import useRouter from "@/hooks/useAppRouter"; import type { MpvAudioTrack } from "@/modules"; import { useOfflineMode } from "@/providers/OfflineModeProvider"; +import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles"; import { isImageBasedSubtitle } from "@/utils/jellyfin/subtitleUtils"; import type { Track } from "../types"; import { usePlayerContext, usePlayerControls } from "./PlayerContext"; +// Starting index for local (client-downloaded) subtitles +// Uses negative indices to avoid collision with Jellyfin indices +const LOCAL_SUBTITLE_INDEX_START = -100; + interface VideoContextProps { subtitleTracks: Track[] | null; audioTracks: Track[] | null; @@ -339,12 +346,40 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({ }; }); + // TV only: Merge locally downloaded subtitles (from OpenSubtitles) + if (Platform.isTV && itemId) { + const localSubs = getSubtitlesForItem(itemId); + let localIdx = 0; + for (const localSub of localSubs) { + // Verify file still exists (cache may have been cleared) + const subtitleFile = new File(localSub.filePath); + if (!subtitleFile.exists) { + continue; + } + + const localIndex = LOCAL_SUBTITLE_INDEX_START - localIdx; + subs.push({ + name: localSub.name, + index: localIndex, + mpvIndex: -1, // Will be loaded dynamically via addSubtitleFile + isLocal: true, + localPath: localSub.filePath, + setTrack: () => { + // Add the subtitle file to MPV and select it + playerControls.addSubtitleFile(localSub.filePath, true); + router.setParams({ subtitleIndex: String(localIndex) }); + }, + }); + localIdx++; + } + } + setSubtitleTracks(subs.sort((a, b) => a.index - b.index)); setAudioTracks(audio); }; fetchTracks(); - }, [tracksReady, mediaSource, offline, downloadedItem]); + }, [tracksReady, mediaSource, offline, downloadedItem, itemId]); return ( diff --git a/components/video-player/controls/types.ts b/components/video-player/controls/types.ts index 5ec03edd..30f277aa 100644 --- a/components/video-player/controls/types.ts +++ b/components/video-player/controls/types.ts @@ -22,6 +22,10 @@ type Track = { index: number; mpvIndex?: number; setTrack: () => void; + /** True for client-side downloaded subtitles (e.g., from OpenSubtitles) */ + isLocal?: boolean; + /** File path for local subtitles */ + localPath?: string; }; export type { EmbeddedSubtitle, ExternalSubtitle, TranscodedSubtitle, Track }; diff --git a/hooks/useRemoteSubtitles.ts b/hooks/useRemoteSubtitles.ts index bc5b83e7..b101aeee 100644 --- a/hooks/useRemoteSubtitles.ts +++ b/hooks/useRemoteSubtitles.ts @@ -7,7 +7,12 @@ import { useMutation } from "@tanstack/react-query"; import { Directory, File, Paths } from "expo-file-system"; import { useAtomValue } from "jotai"; import { useCallback, useMemo } from "react"; +import { Platform } from "react-native"; import { apiAtom } from "@/providers/JellyfinProvider"; +import { + addDownloadedSubtitle, + type DownloadedSubtitle, +} from "@/utils/atoms/downloadedSubtitles"; import { useSettings } from "@/utils/atoms/settings"; import { OpenSubtitlesApi, @@ -185,32 +190,70 @@ export function useRemoteSubtitles({ /** * Download subtitle via OpenSubtitles API (returns local file path) + * + * On TV: Downloads to cache directory and persists metadata in MMKV + * On mobile: Downloads to cache directory (ephemeral, no persistence) + * + * Uses a flat filename structure with itemId prefix to avoid tvOS permission issues */ const downloadOpenSubtitles = useCallback( - async (fileId: number): Promise => { + async ( + fileId: number, + result: SubtitleSearchResult, + ): Promise<{ path: string; subtitle?: DownloadedSubtitle }> => { if (!openSubtitlesApi) { throw new Error("OpenSubtitles API key not configured"); } // Get download link const response = await openSubtitlesApi.download(fileId); + const originalFileName = response.file_name || `subtitle_${fileId}.srt`; - // Download to cache directory - const fileName = response.file_name || `subtitle_${fileId}.srt`; - const subtitlesDir = new Directory(Paths.cache, "subtitles"); + // Use cache directory for both platforms (tvOS has permission issues with documents) + // TV: Uses itemId prefix for organization and persists metadata + // Mobile: Simple filename, no persistence + const subtitlesDir = new Directory(Paths.cache, "streamyfin-subtitles"); // Ensure directory exists if (!subtitlesDir.exists) { - subtitlesDir.create({ intermediates: true }); + subtitlesDir.create(); } + // TV: Prefix filename with itemId for organization + // Mobile: Use original filename + const fileName = Platform.isTV + ? `${itemId}_${originalFileName}` + : originalFileName; + // Create file and download const destination = new File(subtitlesDir, fileName); + + // Delete existing file if it exists (re-download) + if (destination.exists) { + destination.delete(); + } + await File.downloadFileAsync(response.link, destination); - return destination.uri; + // TV: Persist metadata for future sessions + if (Platform.isTV) { + const subtitleMetadata: DownloadedSubtitle = { + id: result.id, + itemId, + filePath: destination.uri, + name: result.name, + language: result.language, + format: result.format, + source: "opensubtitles", + downloadedAt: Date.now(), + }; + addDownloadedSubtitle(subtitleMetadata); + return { path: destination.uri, subtitle: subtitleMetadata }; + } + + return { path: destination.uri }; }, - [openSubtitlesApi], + [openSubtitlesApi, itemId], ); /** @@ -257,8 +300,11 @@ export function useRemoteSubtitles({ return { type: "server" as const }; } if (result.fileId) { - const localPath = await downloadOpenSubtitles(result.fileId); - return { type: "local" as const, path: localPath }; + const { path, subtitle } = await downloadOpenSubtitles( + result.fileId, + result, + ); + return { type: "local" as const, path, subtitle }; } throw new Error("Invalid subtitle result"); }, diff --git a/utils/atoms/downloadedSubtitles.ts b/utils/atoms/downloadedSubtitles.ts new file mode 100644 index 00000000..69d17a9f --- /dev/null +++ b/utils/atoms/downloadedSubtitles.ts @@ -0,0 +1,162 @@ +/** + * Downloaded Subtitles Storage + * + * Persists metadata about client-side downloaded subtitles (from OpenSubtitles). + * Subtitle files are stored in Paths.cache/streamyfin-subtitles/ directory. + * Filenames are prefixed with itemId for organization: {itemId}_{filename} + * + * While files are in cache, metadata is persisted in MMKV so subtitles survive + * app restarts (unless cache is manually cleared by the user). + * + * TV platform only. + */ + +import { storage } from "../mmkv"; + +// MMKV storage key +const DOWNLOADED_SUBTITLES_KEY = "downloadedSubtitles.json"; + +/** + * Metadata for a downloaded subtitle file + */ +export interface DownloadedSubtitle { + /** Unique identifier (uuid) */ + id: string; + /** Jellyfin item ID */ + itemId: string; + /** Local file path in documents directory */ + filePath: string; + /** Display name */ + name: string; + /** 3-letter language code */ + language: string; + /** File format (srt, ass, etc.) */ + format: string; + /** Source provider */ + source: "opensubtitles"; + /** Unix timestamp when downloaded */ + downloadedAt: number; +} + +/** + * Storage structure for downloaded subtitles + */ +interface DownloadedSubtitlesStorage { + /** Map of itemId to array of downloaded subtitles */ + byItemId: Record; +} + +/** + * Load the storage from MMKV + */ +function loadStorage(): DownloadedSubtitlesStorage { + try { + const data = storage.getString(DOWNLOADED_SUBTITLES_KEY); + if (data) { + return JSON.parse(data) as DownloadedSubtitlesStorage; + } + } catch { + // Ignore parse errors, return empty storage + } + return { byItemId: {} }; +} + +/** + * Save the storage to MMKV + */ +function saveStorage(data: DownloadedSubtitlesStorage): void { + try { + storage.set(DOWNLOADED_SUBTITLES_KEY, JSON.stringify(data)); + } catch (error) { + console.error("Failed to save downloaded subtitles:", error); + } +} + +/** + * Get all downloaded subtitles for a specific Jellyfin item + */ +export function getSubtitlesForItem(itemId: string): DownloadedSubtitle[] { + const data = loadStorage(); + return data.byItemId[itemId] ?? []; +} + +/** + * Add a downloaded subtitle to storage + */ +export function addDownloadedSubtitle(subtitle: DownloadedSubtitle): void { + const data = loadStorage(); + + // Initialize array for item if it doesn't exist + if (!data.byItemId[subtitle.itemId]) { + data.byItemId[subtitle.itemId] = []; + } + + // Check if subtitle with same id already exists and update it + const existingIndex = data.byItemId[subtitle.itemId].findIndex( + (s) => s.id === subtitle.id, + ); + + if (existingIndex !== -1) { + // Update existing entry + data.byItemId[subtitle.itemId][existingIndex] = subtitle; + } else { + // Add new entry + data.byItemId[subtitle.itemId].push(subtitle); + } + + saveStorage(data); +} + +/** + * Remove a downloaded subtitle from storage + */ +export function removeDownloadedSubtitle( + itemId: string, + subtitleId: string, +): void { + const data = loadStorage(); + + if (data.byItemId[itemId]) { + data.byItemId[itemId] = data.byItemId[itemId].filter( + (s) => s.id !== subtitleId, + ); + + // Clean up empty arrays + if (data.byItemId[itemId].length === 0) { + delete data.byItemId[itemId]; + } + + saveStorage(data); + } +} + +/** + * Remove all downloaded subtitles for a specific item + */ +export function removeAllSubtitlesForItem(itemId: string): void { + const data = loadStorage(); + + if (data.byItemId[itemId]) { + delete data.byItemId[itemId]; + saveStorage(data); + } +} + +/** + * Check if a subtitle file already exists for an item by language + */ +export function hasSubtitleForLanguage( + itemId: string, + language: string, +): boolean { + const subtitles = getSubtitlesForItem(itemId); + return subtitles.some((s) => s.language === language); +} + +/** + * Get all downloaded subtitles across all items + */ +export function getAllDownloadedSubtitles(): DownloadedSubtitle[] { + const data = loadStorage(); + return Object.values(data.byItemId).flat(); +} diff --git a/utils/opensubtitles/api.ts b/utils/opensubtitles/api.ts index d9101cf8..23059198 100644 --- a/utils/opensubtitles/api.ts +++ b/utils/opensubtitles/api.ts @@ -87,6 +87,58 @@ export class OpenSubtitlesApiError extends Error { } } +/** + * Mapping between ISO 639-1 (2-letter) and ISO 639-2B (3-letter) language codes + */ +const ISO_639_MAPPING: Record = { + en: "eng", + es: "spa", + fr: "fre", + de: "ger", + it: "ita", + pt: "por", + ru: "rus", + ja: "jpn", + ko: "kor", + zh: "chi", + ar: "ara", + pl: "pol", + nl: "dut", + sv: "swe", + no: "nor", + da: "dan", + fi: "fin", + tr: "tur", + cs: "cze", + el: "gre", + he: "heb", + hu: "hun", + ro: "rum", + th: "tha", + vi: "vie", + id: "ind", + ms: "may", + bg: "bul", + hr: "hrv", + sk: "slo", + sl: "slv", + uk: "ukr", +}; + +// Reverse mapping: 3-letter to 2-letter +const ISO_639_REVERSE: Record = Object.fromEntries( + Object.entries(ISO_639_MAPPING).map(([k, v]) => [v, k]), +); + +/** + * Convert ISO 639-2B (3-letter) to ISO 639-1 (2-letter) language code + * OpenSubtitles REST API uses 2-letter codes + */ +function toIso6391(code: string): string { + if (code.length === 2) return code; + return ISO_639_REVERSE[code.toLowerCase()] || code; +} + /** * OpenSubtitles API client for direct subtitle fetching */ @@ -138,7 +190,7 @@ export class OpenSubtitlesApi { const queryParams = new URLSearchParams(); if (params.imdbId) { - // Ensure IMDB ID has correct format (with "tt" prefix) + // Ensure IMDB ID has "tt" prefix const imdbId = params.imdbId.startsWith("tt") ? params.imdbId : `tt${params.imdbId}`; @@ -151,7 +203,12 @@ export class OpenSubtitlesApi { queryParams.set("year", params.year.toString()); } if (params.languages) { - queryParams.set("languages", params.languages); + // Convert 3-letter codes to 2-letter codes (API uses ISO 639-1) + const lang = + params.languages.length === 3 + ? toIso6391(params.languages) + : params.languages; + queryParams.set("languages", lang); } if (params.seasonNumber !== undefined) { queryParams.set("season_number", params.seasonNumber.toString()); @@ -179,50 +236,18 @@ export class OpenSubtitlesApi { } } +/** + * Convert ISO 639-2B (3-letter) to ISO 639-1 (2-letter) language code + * Exported for external use + */ +export { toIso6391 }; + /** * Convert ISO 639-1 (2-letter) to ISO 639-2B (3-letter) language code - * OpenSubtitles uses ISO 639-2B codes */ export function toIso6392B(code: string): string { - const mapping: Record = { - en: "eng", - es: "spa", - fr: "fre", - de: "ger", - it: "ita", - pt: "por", - ru: "rus", - ja: "jpn", - ko: "kor", - zh: "chi", - ar: "ara", - pl: "pol", - nl: "dut", - sv: "swe", - no: "nor", - da: "dan", - fi: "fin", - tr: "tur", - cs: "cze", - el: "gre", - he: "heb", - hu: "hun", - ro: "rum", - th: "tha", - vi: "vie", - id: "ind", - ms: "may", - bg: "bul", - hr: "hrv", - sk: "slo", - sl: "slv", - uk: "ukr", - }; - - // If already 3 letters, return as-is if (code.length === 3) return code; - - return mapping[code.toLowerCase()] || code; + return ISO_639_MAPPING[code.toLowerCase()] || code; } /**