mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-03-18 23:36:22 +00:00
wip
This commit is contained in:
286
hooks/useRemoteSubtitles.ts
Normal file
286
hooks/useRemoteSubtitles.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user