Files
streamyfin/hooks/useRemoteSubtitles.ts
Fredrik Burmester ee3a288fa0 wip
2026-01-18 10:38:06 +01:00

287 lines
8.3 KiB
TypeScript

import type {
BaseItemDto,
RemoteSubtitleInfo,
} from "@jellyfin/sdk/lib/generated-client";
import { getSubtitleApi } from "@jellyfin/sdk/lib/utils/api";
import { useMutation } from "@tanstack/react-query";
import { Directory, File, Paths } from "expo-file-system";
import { useAtomValue } from "jotai";
import { useCallback, useMemo } from "react";
import { apiAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import {
OpenSubtitlesApi,
type OpenSubtitlesResult,
} from "@/utils/opensubtitles/api";
export interface SubtitleSearchResult {
id: string;
name: string;
providerName: string;
format: string;
language: string;
communityRating?: number;
downloadCount?: number;
isHashMatch?: boolean;
hearingImpaired?: boolean;
aiTranslated?: boolean;
machineTranslated?: boolean;
/** For OpenSubtitles: file ID to download */
fileId?: number;
/** Source: 'jellyfin' or 'opensubtitles' */
source: "jellyfin" | "opensubtitles";
}
interface UseRemoteSubtitlesOptions {
itemId: string;
item: BaseItemDto;
mediaSourceId?: string | null;
}
/**
* Convert Jellyfin RemoteSubtitleInfo to unified SubtitleSearchResult
*/
function jellyfinToResult(sub: RemoteSubtitleInfo): SubtitleSearchResult {
return {
id: sub.Id ?? "",
name: sub.Name ?? "Unknown",
providerName: sub.ProviderName ?? "Unknown",
format: sub.Format ?? "srt",
language: sub.ThreeLetterISOLanguageName ?? "",
communityRating: sub.CommunityRating ?? undefined,
downloadCount: sub.DownloadCount ?? undefined,
isHashMatch: sub.IsHashMatch ?? undefined,
hearingImpaired: sub.HearingImpaired ?? undefined,
aiTranslated: sub.AiTranslated ?? undefined,
machineTranslated: sub.MachineTranslated ?? undefined,
source: "jellyfin",
};
}
/**
* Convert OpenSubtitles result to unified SubtitleSearchResult
*/
function openSubtitlesToResult(
sub: OpenSubtitlesResult,
): SubtitleSearchResult | null {
const firstFile = sub.attributes.files[0];
if (!firstFile) return null;
return {
id: sub.id,
name:
sub.attributes.release || sub.attributes.files[0]?.file_name || "Unknown",
providerName: "OpenSubtitles",
format: sub.attributes.format || "srt",
language: sub.attributes.language,
communityRating: sub.attributes.ratings,
downloadCount: sub.attributes.download_count,
isHashMatch: false,
hearingImpaired: sub.attributes.hearing_impaired,
aiTranslated: sub.attributes.ai_translated,
machineTranslated: sub.attributes.machine_translated,
fileId: firstFile.file_id,
source: "opensubtitles",
};
}
/**
* Hook for searching and downloading remote subtitles
*
* Primary: Uses Jellyfin's subtitle API (server-side OpenSubtitles plugin)
* Fallback: Direct OpenSubtitles API when server has no provider
*/
export function useRemoteSubtitles({
itemId,
item,
mediaSourceId: _mediaSourceId,
}: UseRemoteSubtitlesOptions) {
const api = useAtomValue(apiAtom);
const { settings } = useSettings();
const openSubtitlesApiKey = settings.openSubtitlesApiKey;
// Check if we can use OpenSubtitles fallback
const hasOpenSubtitlesApiKey = Boolean(openSubtitlesApiKey);
// Create OpenSubtitles API client when API key is available
const openSubtitlesApi = useMemo(() => {
if (!openSubtitlesApiKey) return null;
return new OpenSubtitlesApi(openSubtitlesApiKey);
}, [openSubtitlesApiKey]);
/**
* Search for subtitles via Jellyfin API
*/
const searchJellyfin = useCallback(
async (language: string): Promise<SubtitleSearchResult[]> => {
if (!api) throw new Error("API not available");
const subtitleApi = getSubtitleApi(api);
const response = await subtitleApi.searchRemoteSubtitles({
itemId,
language,
});
return (response.data || []).map(jellyfinToResult);
},
[api, itemId],
);
/**
* Search for subtitles via OpenSubtitles direct API
*/
const searchOpenSubtitles = useCallback(
async (language: string): Promise<SubtitleSearchResult[]> => {
if (!openSubtitlesApi) {
throw new Error("OpenSubtitles API key not configured");
}
// Get IMDB ID from item if available
const imdbId = item.ProviderIds?.Imdb;
// Build search params
const params: Parameters<OpenSubtitlesApi["search"]>[0] = {
languages: language,
};
if (imdbId) {
params.imdbId = imdbId;
} else {
// Fall back to title search
params.query = item.Name || "";
params.year = item.ProductionYear || undefined;
}
// For TV episodes, add season/episode info
if (item.Type === "Episode") {
params.seasonNumber = item.ParentIndexNumber || undefined;
params.episodeNumber = item.IndexNumber || undefined;
}
const response = await openSubtitlesApi.search(params);
return response.data
.map(openSubtitlesToResult)
.filter((r): r is SubtitleSearchResult => r !== null);
},
[openSubtitlesApi, item],
);
/**
* Download subtitle via Jellyfin API (saves to server library)
*/
const downloadJellyfin = useCallback(
async (subtitleId: string): Promise<void> => {
if (!api) throw new Error("API not available");
const subtitleApi = getSubtitleApi(api);
await subtitleApi.downloadRemoteSubtitles({
itemId,
subtitleId,
});
},
[api, itemId],
);
/**
* Download subtitle via OpenSubtitles API (returns local file path)
*/
const downloadOpenSubtitles = useCallback(
async (fileId: number): Promise<string> => {
if (!openSubtitlesApi) {
throw new Error("OpenSubtitles API key not configured");
}
// Get download link
const response = await openSubtitlesApi.download(fileId);
// Download to cache directory
const fileName = response.file_name || `subtitle_${fileId}.srt`;
const subtitlesDir = new Directory(Paths.cache, "subtitles");
// Ensure directory exists
if (!subtitlesDir.exists) {
subtitlesDir.create({ intermediates: true });
}
// Create file and download
const destination = new File(subtitlesDir, fileName);
await File.downloadFileAsync(response.link, destination);
return destination.uri;
},
[openSubtitlesApi],
);
/**
* Search mutation - tries Jellyfin first, falls back to OpenSubtitles
*/
const searchMutation = useMutation({
mutationFn: async ({
language,
preferOpenSubtitles = false,
}: {
language: string;
preferOpenSubtitles?: boolean;
}) => {
// If user prefers OpenSubtitles and has API key, use it
if (preferOpenSubtitles && hasOpenSubtitlesApiKey) {
return searchOpenSubtitles(language);
}
// Try Jellyfin first
try {
const results = await searchJellyfin(language);
// If no results and we have OpenSubtitles fallback, try it
if (results.length === 0 && hasOpenSubtitlesApiKey) {
return searchOpenSubtitles(language);
}
return results;
} catch (error) {
// If Jellyfin fails (no provider configured) and we have fallback, use it
if (hasOpenSubtitlesApiKey) {
return searchOpenSubtitles(language);
}
throw error;
}
},
});
/**
* Download mutation
*/
const downloadMutation = useMutation({
mutationFn: async (result: SubtitleSearchResult) => {
if (result.source === "jellyfin") {
await downloadJellyfin(result.id);
return { type: "server" as const };
}
if (result.fileId) {
const localPath = await downloadOpenSubtitles(result.fileId);
return { type: "local" as const, path: localPath };
}
throw new Error("Invalid subtitle result");
},
});
return {
// State
hasOpenSubtitlesApiKey,
isSearching: searchMutation.isPending,
isDownloading: downloadMutation.isPending,
searchError: searchMutation.error,
downloadError: downloadMutation.error,
searchResults: searchMutation.data,
// Actions
search: searchMutation.mutate,
searchAsync: searchMutation.mutateAsync,
download: downloadMutation.mutate,
downloadAsync: downloadMutation.mutateAsync,
reset: () => {
searchMutation.reset();
downloadMutation.reset();
},
};
}