feat(tv): persist downloaded opensubtitles across app restarts

This commit is contained in:
Fredrik Burmester
2026-02-01 19:19:32 +01:00
parent bc575c26c1
commit c35e97f388
8 changed files with 489 additions and 57 deletions

View File

@@ -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/missingdata
if (itemStatus.isError || streamStatus.isError) {
return (

View File

@@ -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]);

View File

@@ -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,
})
}

View File

@@ -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 }}>

View File

@@ -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 };

View File

@@ -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");
},

View 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();
}

View File

@@ -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;
}
/**