mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-21 18:48:19 +00:00
287 lines
8.3 KiB
TypeScript
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();
|
|
},
|
|
};
|
|
}
|