mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-02-02 00:18:08 +00:00
feat(tv): persist downloaded opensubtitles across app restarts
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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<ItemContentTVProps> = 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<ItemContentTVProps> = 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<ItemContentTVProps> = 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<Track[]> => {
|
||||
if (!api || !item?.Id) return [];
|
||||
@@ -373,7 +418,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = 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<ItemContentTVProps> = 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<ItemContentTVProps> = 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<ItemContentTVProps> = React.memo(
|
||||
onDisableSubtitles: () => handleSubtitleChange(-1),
|
||||
onServerSubtitleDownloaded:
|
||||
handleServerSubtitleDownloaded,
|
||||
onLocalSubtitleDownloaded:
|
||||
handleLocalSubtitleDownloaded,
|
||||
refreshSubtitleTracks,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<VideoContext.Provider value={{ subtitleTracks, audioTracks }}>
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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<string> => {
|
||||
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");
|
||||
},
|
||||
|
||||
162
utils/atoms/downloadedSubtitles.ts
Normal file
162
utils/atoms/downloadedSubtitles.ts
Normal file
@@ -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<string, DownloadedSubtitle[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
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<string, string> = 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<string, string> = {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user