From b152619c0374c4b5d5c8acef276ee89396047538 Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Sat, 28 Feb 2026 06:11:01 +0530 Subject: [PATCH 01/81] reduce trakt api calls and fix sort order for trakt continue watching --- .../home/ContinueWatchingSection.tsx | 83 +++++++++++++------ 1 file changed, 58 insertions(+), 25 deletions(-) diff --git a/src/components/home/ContinueWatchingSection.tsx b/src/components/home/ContinueWatchingSection.tsx index 586d66e1..0d4617a5 100644 --- a/src/components/home/ContinueWatchingSection.tsx +++ b/src/components/home/ContinueWatchingSection.tsx @@ -855,6 +855,36 @@ const ContinueWatchingSection = React.forwardRef((props, re const traktBatch: ContinueWatchingItem[] = []; + // Pre-fetch watched shows so both Step 1 and Step 2 can use the watched episode sets + // This fixes "Up Next" suggesting already-watched episodes when the watched set is missing + let watchedShowsData: Awaited> = []; + // Map from showImdb -> Set of "imdb:season:episode" strings + const watchedEpisodeSetByShow = new Map>(); + try { + watchedShowsData = await traktService.getWatchedShows(); + for (const ws of watchedShowsData) { + if (!ws.show?.ids?.imdb) continue; + const imdb = ws.show.ids.imdb.startsWith('tt') ? ws.show.ids.imdb : `tt${ws.show.ids.imdb}`; + const resetAt = ws.reset_at ? new Date(ws.reset_at).getTime() : 0; + const episodeSet = new Set(); + if (ws.seasons) { + for (const season of ws.seasons) { + for (const episode of season.episodes) { + // Respect reset_at: skip episodes watched before the reset + if (resetAt > 0) { + const watchedAt = new Date(episode.last_watched_at).getTime(); + if (watchedAt < resetAt) continue; + } + episodeSet.add(`${imdb}:${season.number}:${episode.number}`); + } + } + } + watchedEpisodeSetByShow.set(imdb, episodeSet); + } + } catch { + // Non-fatal — fall back to no watched set + } + // STEP 1: Process playback progress items (in-progress, paused) // These have actual progress percentage from Trakt const thirtyDaysAgo = Date.now() - (30 * 24 * 60 * 60 * 1000); @@ -918,11 +948,13 @@ const ContinueWatchingSection = React.forwardRef((props, re if (item.progress >= 85) { const metadata = cachedData.metadata; if (metadata?.videos) { + // Use pre-fetched watched set so already-watched episodes are skipped + const watchedSetForShow = watchedEpisodeSetByShow.get(showImdb); const nextEpisode = findNextEpisode( item.episode.season, item.episode.number, metadata.videos, - undefined, // No watched set needed, findNextEpisode handles it + watchedSetForShow, showImdb ); @@ -965,13 +997,13 @@ const ContinueWatchingSection = React.forwardRef((props, re } } - // STEP 2: Get watched shows and find "Up Next" episodes - // This handles cases where episodes are fully completed and removed from playback progress + // STEP 2: Find "Up Next" episodes using pre-fetched watched shows data + // Reuses watchedShowsData fetched before Step 1 — no extra API call + // Also respects reset_at (Bug 4 fix) and uses pre-built watched episode sets (Bug 3 fix) try { - const watchedShows = await traktService.getWatchedShows(); const thirtyDaysAgoForShows = Date.now() - (30 * 24 * 60 * 60 * 1000); - for (const watchedShow of watchedShows) { + for (const watchedShow of watchedShowsData) { try { if (!watchedShow.show?.ids?.imdb) continue; @@ -987,7 +1019,9 @@ const ContinueWatchingSection = React.forwardRef((props, re const showKey = `series:${showImdb}`; if (recentlyRemovedRef.current.has(showKey)) continue; - // Find the last watched episode + const resetAt = watchedShow.reset_at ? new Date(watchedShow.reset_at).getTime() : 0; + + // Find the last watched episode (respecting reset_at) let lastWatchedSeason = 0; let lastWatchedEpisode = 0; let latestEpisodeTimestamp = 0; @@ -996,6 +1030,8 @@ const ContinueWatchingSection = React.forwardRef((props, re for (const season of watchedShow.seasons) { for (const episode of season.episodes) { const episodeTimestamp = new Date(episode.last_watched_at).getTime(); + // Skip episodes watched before the user reset their progress + if (resetAt > 0 && episodeTimestamp < resetAt) continue; if (episodeTimestamp > latestEpisodeTimestamp) { latestEpisodeTimestamp = episodeTimestamp; lastWatchedSeason = season.number; @@ -1011,15 +1047,8 @@ const ContinueWatchingSection = React.forwardRef((props, re const cachedData = await getCachedMetadata('series', showImdb); if (!cachedData?.basicContent || !cachedData?.metadata?.videos) continue; - // Build a set of watched episodes for this show - const watchedEpisodeSet = new Set(); - if (watchedShow.seasons) { - for (const season of watchedShow.seasons) { - for (const episode of season.episodes) { - watchedEpisodeSet.add(`${showImdb}:${season.number}:${episode.number}`); - } - } - } + // Use pre-built watched episode set (already respects reset_at) + const watchedEpisodeSet = watchedEpisodeSetByShow.get(showImdb) ?? new Set(); // Find the next unwatched episode const nextEpisode = findNextEpisode( @@ -1054,13 +1083,24 @@ const ContinueWatchingSection = React.forwardRef((props, re // Trakt mode: show ONLY Trakt items, but override progress with local if local is higher. if (traktBatch.length > 0) { - // Dedupe (keep most recent per show/movie) + // Dedupe (keep in-progress over "Up Next"; then prefer most recent) const deduped = new Map(); for (const item of traktBatch) { const key = `${item.type}:${item.id}`; const existing = deduped.get(key); - if (!existing || (item.lastUpdated ?? 0) > (existing.lastUpdated ?? 0)) { + if (!existing) { deduped.set(key, item); + } else { + const existingHasProgress = (existing.progress ?? 0) > 0; + const candidateHasProgress = (item.progress ?? 0) > 0; + if (candidateHasProgress && !existingHasProgress) { + // Always prefer actual in-progress over "Up Next" placeholder + deduped.set(key, item); + } else if (!candidateHasProgress && existingHasProgress) { + // Keep existing in-progress item + } else if ((item.lastUpdated ?? 0) > (existing.lastUpdated ?? 0)) { + deduped.set(key, item); + } } } @@ -2223,14 +2263,7 @@ const ContinueWatchingSection = React.forwardRef((props, re { - const aProgress = a.progress ?? 0; - const bProgress = b.progress ?? 0; - const aIsUpNext = a.type === 'series' && aProgress <= 0; - const bIsUpNext = b.type === 'series' && bProgress <= 0; - if (aIsUpNext !== bIsUpNext) return aIsUpNext ? 1 : -1; - return (b.lastUpdated ?? 0) - (a.lastUpdated ?? 0); - })} + data={continueWatchingItems} renderItem={renderContinueWatchingItem} keyExtractor={keyExtractor} horizontal From 6bc287d2b34d66790230ea7ac45f9b429eb99baf Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Sat, 28 Feb 2026 14:13:21 +0530 Subject: [PATCH 02/81] remove watched epis from this week section --- src/hooks/useCalendarData.ts | 70 ++++++++++++++++++++++++++++++++++-- 1 file changed, 67 insertions(+), 3 deletions(-) diff --git a/src/hooks/useCalendarData.ts b/src/hooks/useCalendarData.ts index c3748fd6..e2884cd3 100644 --- a/src/hooks/useCalendarData.ts +++ b/src/hooks/useCalendarData.ts @@ -68,7 +68,38 @@ export const useCalendarData = (): UseCalendarDataReturn => { ); if (cachedData) { - setCalendarData(cachedData); + // Apply watched filter even on cached data + if (traktAuthenticated && watchedShows && watchedShows.length > 0) { + const cachedWatchedSet = new Set(); + for (const ws of watchedShows) { + const imdb = ws.show?.ids?.imdb; + if (!imdb) continue; + const showId = imdb.startsWith('tt') ? imdb : `tt${imdb}`; + const resetAt = ws.reset_at ? new Date(ws.reset_at).getTime() : 0; + if (ws.seasons) { + for (const season of ws.seasons) { + for (const episode of season.episodes) { + if (resetAt > 0 && new Date(episode.last_watched_at).getTime() < resetAt) continue; + cachedWatchedSet.add(`${showId}:${season.number}:${episode.number}`); + } + } + } + } + const filtered = cachedData.map(section => { + if (section.title !== 'This Week') return section; + return { + ...section, + data: section.data.filter((ep: any) => { + const showId = ep.seriesId?.startsWith('tt') ? ep.seriesId : `tt${ep.seriesId}`; + return !cachedWatchedSet.has(`${showId}:${ep.season}:${ep.episode}`) && + !cachedWatchedSet.has(`${ep.seriesId}:${ep.season}:${ep.episode}`); + }) + }; + }); + setCalendarData(filtered); + } else { + setCalendarData(cachedData); + } setLoading(false); return; } @@ -314,6 +345,29 @@ export const useCalendarData = (): UseCalendarDataReturn => { logger.log(`[CalendarData] Total episodes fetched: ${allEpisodes.length}`); + // Build a set of watched episodes from Trakt so we can filter them out of This Week + const watchedEpisodeSet = new Set(); + if (traktAuthenticated && watchedShows) { + for (const ws of watchedShows) { + const imdb = ws.show?.ids?.imdb; + if (!imdb) continue; + const showId = imdb.startsWith('tt') ? imdb : `tt${imdb}`; + const resetAt = ws.reset_at ? new Date(ws.reset_at).getTime() : 0; + if (ws.seasons) { + for (const season of ws.seasons) { + for (const episode of season.episodes) { + // Respect reset_at + if (resetAt > 0) { + const watchedAt = new Date(episode.last_watched_at).getTime(); + if (watchedAt < resetAt) continue; + } + watchedEpisodeSet.add(`${showId}:${season.number}:${episode.number}`); + } + } + } + } + } + // Use memory-efficient filtering with error handling const thisWeekEpisodes = await memoryManager.filterLargeArray( allEpisodes, @@ -321,8 +375,18 @@ export const useCalendarData = (): UseCalendarDataReturn => { try { if (!ep.releaseDate) return false; const parsed = parseISO(ep.releaseDate); - // Show all episodes for this week, including released ones - return isThisWeek(parsed); + if (!isThisWeek(parsed)) return false; + // Filter out already-watched episodes when Trakt is authenticated + if (traktAuthenticated && watchedEpisodeSet.size > 0) { + const showId = ep.seriesId?.startsWith('tt') ? ep.seriesId : `tt${ep.seriesId}`; + if ( + watchedEpisodeSet.has(`${showId}:${ep.season}:${ep.episode}`) || + watchedEpisodeSet.has(`${ep.seriesId}:${ep.season}:${ep.episode}`) + ) { + return false; + } + } + return true; } catch (error) { logger.warn(`[CalendarData] Error parsing date for this week filtering: ${ep.releaseDate}`, error); return false; From db7cd130b8ed72e719667c120dec82a7c6229a1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A1i=20Ho=C3=A0ng=20T=C3=A2m?= Date: Sat, 28 Feb 2026 11:44:34 +0100 Subject: [PATCH 03/81] Update legal information link in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index dca575ef..9ae35957 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ Nuvio functions solely as a client-side interface for browsing metadata and play Nuvio is not affiliated with any third-party extensions, catalogs, sources, or content providers. It does not host, store, or distribute any media content. -For comprehensive legal information, including our full disclaimer, third-party extension policy, and DMCA/Copyright information, please visit our **[Legal & Disclaimer Page](https://tapframe.github.io/NuvioStreaming/#legal)**. +For comprehensive legal information, including our full disclaimer, third-party extension policy, and DMCA/Copyright information, please visit our **[Legal & Disclaimer Page](https://nuvioapp.space/legal)**. ## Built With From 6b566cf69d683fdfc140652d479593f9f35c7db0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Du=C5=A1an=20Milivojevi=C4=87?= Date: Sat, 28 Feb 2026 16:26:05 +0100 Subject: [PATCH 04/81] fix(auth): disable auto-capitalize on password input --- src/screens/AuthScreen.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/screens/AuthScreen.tsx b/src/screens/AuthScreen.tsx index 4033429f..7c42cfb0 100644 --- a/src/screens/AuthScreen.tsx +++ b/src/screens/AuthScreen.tsx @@ -406,6 +406,7 @@ const AuthScreen: React.FC = () => { placeholder="Password (min 6 characters)" placeholderTextColor="rgba(255,255,255,0.4)" style={[styles.input, { color: currentTheme.colors.white }]} + autoCapitalize="none" secureTextEntry={!showPassword} value={password} onChangeText={setPassword} @@ -454,6 +455,7 @@ const AuthScreen: React.FC = () => { placeholder="Confirm password" placeholderTextColor="rgba(255,255,255,0.4)" style={[styles.input, { color: currentTheme.colors.white }]} + autoCapitalize="none" secureTextEntry={!showConfirm} value={confirmPassword} onChangeText={setConfirmPassword} From 3e83be2bf07de0fc9006ee236bea09da5e707226 Mon Sep 17 00:00:00 2001 From: Joe00011 <152079313+Joe00011@users.noreply.github.com> Date: Sun, 1 Mar 2026 02:41:24 +0900 Subject: [PATCH 05/81] Add Japanese (ja) translation --- src/i18n/locales/ja.json | 1492 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 1492 insertions(+) create mode 100644 src/i18n/locales/ja.json diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json new file mode 100644 index 00000000..1448d135 --- /dev/null +++ b/src/i18n/locales/ja.json @@ -0,0 +1,1492 @@ +{ + "common": { + "loading": "読み込み中...", + "cancel": "キャンセル", + "save": "保存", + "delete": "削除", + "edit": "編集", + "search": "検索", + "error": "エラー", + "success": "成功", + "ok": "OK", + "unknown": "不明", + "retry": "再試行", + "try_again": "もう一度試す", + "go_back": "戻る", + "settings": "設定", + "close": "閉じる", + "enable": "有効にする", + "disable": "無効にする", + "show_more": "もっと見る", + "show_less": "少なく見る", + "load_more": "さらに読み込む", + "unknown_date": "不明な日付", + "anonymous_user": "匿名ユーザー", + "time": { + "now": "たった今", + "minutes_ago": "{{count}}分前", + "hours_ago": "{{count}}時間前", + "days_ago": "{{count}}日前" + }, + "days_short": { + "sun": "日", + "mon": "月", + "tue": "火", + "wed": "水", + "thu": "木", + "fri": "金", + "sat": "土" + }, + "email": "メール", + "status": "ステータス" + }, + "home": { + "categories": { + "movies": "映画", + "series": "シリーズ", + "channels": "チャンネル" + }, + "movies": "映画", + "tv_shows": "TVシリーズ", + "load_more_catalogs": "カタログをさらに読み込む", + "no_content": "利用可能なコンテンツがありません", + "add_catalogs": "カタログを追加", + "sign_in_available": "サインイン可能", + "sign_in_desc": "設定 → アカウントからいつでもサインインできます", + "view_all": "すべて表示", + "this_week": "今週", + "upcoming": "近日公開", + "recently_released": "最近公開", + "no_scheduled_episodes": "予定されたエピソードがありません", + "check_back_later": "後で確認してください", + "continue_watching": "視聴を続ける", + "up_next": "次のエピソード", + "up_next_caps": "次のエピソード", + "released": "公開日", + "new": "新着", + "tba": "未定", + "new_episodes": "新着{{count}}エピソード", + "season_short": "S{{season}}", + "episode_short": "E{{episode}}", + "season": "シーズン{{season}}", + "episode": "エピソード{{episode}}", + "movie": "映画", + "series": "シリーズ", + "tv_show": "TVシリーズ", + "percent_watched": "{{percent}}%視聴済み", + "view_details": "詳細を見る", + "remove": "削除", + "play": "再生", + "play_now": "今すぐ再生", + "resume": "再開", + "info": "情報", + "more_info": "詳細情報", + "my_list": "マイリスト", + "save": "保存", + "saved": "保存済み", + "retry": "再試行", + "install_addons": "アドオンをインストール", + "settings": "設定", + "no_featured_content": "注目コンテンツがありません", + "couldnt_load_featured": "注目コンテンツを読み込めませんでした", + "no_featured_desc": "カタログアドオンをインストールするか、設定でコンテンツソースを変更してください。", + "load_error_desc": "注目コンテンツの取得に問題が発生しました。接続を確認してもう一度お試しください。", + "no_featured_available": "利用可能な注目コンテンツがありません", + "no_description": "説明なし" + }, + "navigation": { + "home": "ホーム", + "library": "ライブラリ", + "search": "検索", + "downloads": "ダウンロード", + "settings": "設定" + }, + "search": { + "title": "検索", + "recent_searches": "最近の検索", + "discover": "ディスカバー", + "movies": "映画", + "tv_shows": "TVシリーズ", + "select_catalog": "カタログを選択", + "all_genres": "すべてのジャンル", + "discovering": "コンテンツを探しています...", + "show_more": "もっと見る({{count}})", + "no_content_found": "コンテンツが見つかりません", + "try_different": "別のジャンルやカタログを試してください", + "select_catalog_desc": "カタログを選択してディスカバーを始める", + "tap_catalog_desc": "上のカタログをタップして始める", + "placeholder": "映画、シリーズを検索...", + "keep_typing": "入力を続けてください...", + "type_characters": "検索するには2文字以上入力してください", + "no_results": "結果なし", + "try_keywords": "別のキーワードを試すか、スペルを確認してください", + "select_type": "タイプを選択", + "browse_movies": "映画カタログを閲覧", + "browse_tv": "TVシリーズカタログを閲覧", + "select_genre": "ジャンルを選択", + "show_all_content": "すべてのコンテンツを表示", + "genres_count": "{{count}}ジャンル" + }, + "library": { + "title": "ライブラリ", + "watched": "視聴済み", + "continue": "続きを見る", + "watchlist": "ウォッチリスト", + "collection": "コレクション", + "rated": "評価済み", + "items": "アイテム", + "trakt_collections": "Traktコレクション", + "trakt_collection": "Traktコレクション", + "no_trakt": "Traktコレクションなし", + "no_trakt_desc": "Traktにログイン後、コレクションがここに表示されます", + "load_collections": "コレクションを読み込む", + "empty_folder": "{{folder}}にコンテンツがありません", + "empty_folder_desc": "このコレクションは空です", + "refresh": "更新", + "no_movies": "映画がありません", + "no_series": "シリーズがありません", + "no_content": "コンテンツがありません", + "add_content_desc": "ライブラリにコンテンツを追加するとここに表示されます", + "find_something": "視聴するものを見つける", + "removed_from_library": "ライブラリから削除しました", + "item_removed": "アイテムをライブラリから削除しました", + "failed_update_library": "ライブラリの更新に失敗しました", + "unable_remove": "ライブラリからアイテムを削除できません", + "marked_watched": "視聴済みにしました", + "marked_unwatched": "未視聴にしました", + "item_marked_watched": "アイテムを視聴済みにしました", + "item_marked_unwatched": "アイテムを未視聴にしました", + "failed_update_watched": "視聴状態の更新に失敗しました", + "unable_update_watched": "視聴状態を更新できません", + "added_to_library": "ライブラリに追加しました", + "item_added": "ローカルライブラリに追加しました", + "add_to_library": "ライブラリに追加", + "remove_from_library": "ライブラリから削除", + "mark_watched": "視聴済みにする", + "mark_unwatched": "未視聴にする", + "share": "共有", + "add_to_watchlist": "Traktリストに追加", + "remove_from_watchlist": "Traktリストから削除", + "added_to_watchlist": "リストに追加しました", + "added_to_watchlist_desc": "Traktのウォッチリストに追加しました", + "removed_from_watchlist": "リストから削除しました", + "removed_from_watchlist_desc": "Traktのウォッチリストから削除しました", + "add_to_collection": "Traktコレクションに追加", + "remove_from_collection": "Traktコレクションから削除", + "added_to_collection": "コレクションに追加しました", + "added_to_collection_desc": "Traktコレクションに追加しました", + "removed_from_collection": "コレクションから削除しました", + "removed_from_collection_desc": "Traktコレクションから削除しました" + }, + "metadata": { + "unable_to_load": "コンテンツを読み込めません", + "error_code": "エラーコード: {{code}}", + "content_not_found": "コンテンツが見つかりません", + "content_not_found_desc": "このコンテンツは存在しないか、削除された可能性があります。", + "server_error": "サーバーエラー", + "server_error_desc": "サーバーが一時的に利用できません。後でもう一度お試しください。", + "bad_gateway": "ゲートウェイエラー", + "bad_gateway_desc": "サーバーで問題が発生しました。後でもう一度お試しください。", + "service_unavailable": "サービス利用不可", + "service_unavailable_desc": "サービスは現在メンテナンス中です。後でもう一度お試しください。", + "too_many_requests": "リクエストが多すぎます", + "too_many_requests_desc": "リクエストが多すぎます。少し待ってからもう一度お試しください。", + "request_timeout": "リクエストタイムアウト", + "request_timeout_desc": "リクエストに時間がかかりすぎました。もう一度お試しください。", + "network_error": "ネットワークエラー", + "network_error_desc": "インターネット接続を確認してもう一度お試しください。", + "auth_error": "認証エラー", + "auth_error_desc": "アカウント設定を確認してもう一度お試しください。", + "access_denied": "アクセス拒否", + "access_denied_desc": "このコンテンツにアクセスする権限がありません。", + "connection_error": "接続エラー", + "streams_unavailable": "ストリームが利用できません", + "streams_unavailable_desc": "ストリーミングソースは現在利用できません。後でもう一度お試しください。", + "unknown_error": "不明なエラー", + "something_went_wrong": "問題が発生しました。もう一度お試しください。", + "cast": "キャスト", + "more_like_this": "関連作品", + "collection": "コレクション", + "episodes": "エピソード", + "seasons": "シーズン", + "posters": "ポスター", + "banners": "バナー", + "specials": "スペシャル", + "season_number": "シーズン{{number}}", + "episode_count": "{{count}}エピソード", + "episode_count_plural": "{{count}}エピソード", + "no_episodes": "利用可能なエピソードがありません", + "no_episodes_for_season": "シーズン{{season}}の利用可能なエピソードがありません", + "episodes_not_released": "エピソードはまだ公開されていない可能性があります", + "no_description": "説明なし", + "episode_label": "エピソード{{number}}", + "watch_again": "もう一度見る", + "completed": "完了", + "play_episode": "S{{season}}E{{episode}}を再生", + "play": "再生", + "watched": "視聴済み", + "watched_on_trakt": "Traktで視聴済み", + "synced_with_trakt": "Traktと同期済み", + "saved": "保存済み", + "director": "監督", + "directors": "監督", + "creator": "制作者", + "creators": "制作者", + "production": "制作", + "network": "ネットワーク", + "mark_watched": "視聴済みにする", + "mark_unwatched": "未視聴にする", + "marking": "マーク中...", + "removing": "削除中...", + "unmark_season": "シーズン{{season}}のマークを外す", + "mark_season": "シーズン{{season}}をマーク", + "resume": "再開", + "spoiler_warning": "ネタバレ警告", + "spoiler_warning_desc": "このコメントにはネタバレが含まれています。本当に表示しますか?", + "cancel": "キャンセル", + "reveal_spoilers": "ネタバレを表示", + "movie_details": "映画の詳細", + "show_details": "詳細を表示", + "tagline": "キャッチコピー", + "status": "ステータス", + "release_date": "公開日", + "runtime": "上映時間", + "budget": "製作費", + "revenue": "興行収入", + "origin_country": "制作国", + "original_language": "原語", + "first_air_date": "初回放送日", + "last_air_date": "最終放送日", + "total_episodes": "エピソード総数", + "episode_runtime": "エピソード時間", + "created_by": "制作", + "backdrop_gallery": "バックドロップギャラリー", + "loading_episodes": "エピソードを読み込み中...", + "no_episodes_available": "利用可能なエピソードがありません", + "play_next": "S{{season}}E{{episode}}を再生", + "play_next_episode": "次のエピソードを再生", + "save": "保存", + "percent_watched": "{{percent}}%視聴済み", + "percent_watched_trakt": "{{percent}}%視聴済み(Traktでは{{traktPercent}}%)", + "synced_with_trakt_progress": "Traktと進捗を同期しました", + "using_trakt_progress": "Traktの進捗を使用中", + "added_to_collection_hero": "コレクションに追加しました", + "added_to_collection_desc_hero": "Traktコレクションに追加しました", + "removed_from_collection_hero": "コレクションから削除しました", + "removed_from_collection_desc_hero": "Traktコレクションから削除しました", + "mark_as_watched": "視聴済みにする", + "mark_as_unwatched": "未視聴にする" + }, + "cast": { + "biography": "経歴", + "known_for": "代表作", + "personal_info": "個人情報", + "born_in": "{{place}}生まれ", + "filmography": "フィルモグラフィー", + "also_known_as": "別名", + "no_info_available": "追加情報なし", + "as_character": "{{character}}役", + "loading_details": "詳細を読み込み中...", + "years_old": "{{age}}歳", + "view_filmography": "フィルモグラフィーを見る", + "filter": "フィルター", + "sort_by": "並び替え", + "sort_popular": "人気順", + "sort_latest": "新着順", + "sort_upcoming": "近日公開順", + "upcoming_badge": "近日公開", + "coming_soon": "近日公開", + "filmography_count": "フィルモグラフィー・{{count}}件", + "loading_filmography": "フィルモグラフィーを読み込み中...", + "load_more_remaining": "さらに読み込む(残り{{count}}件)", + "alert_error_title": "エラー", + "alert_error_message": "「{{title}}」を読み込めませんでした。後でもう一度お試しください。", + "alert_ok": "OK", + "no_upcoming": "この俳優の近日公開作品はありません", + "no_content": "この俳優のコンテンツがありません", + "no_movies": "この俳優の映画がありません", + "no_tv": "この俳優のTVシリーズがありません" + }, + "comments": { + "title": "Traktコメント", + "spoiler_warning": "⚠️ このコメントにはネタバレが含まれています。タップして表示。", + "spoiler": "ネタバレ", + "contains_spoilers": "ネタバレあり", + "reveal": "表示", + "vip": "VIP", + "unavailable": "コメントは利用できません", + "no_comments": "Traktにコメントがありません", + "not_in_database": "このコンテンツはまだTraktデータベースにない可能性があります", + "check_trakt": "Traktを確認" + }, + "trailers": { + "title": "予告編", + "official_trailers": "公式予告編", + "official_trailer": "公式予告編", + "teasers": "ティーザー", + "teaser": "ティーザー", + "clips_scenes": "クリップとシーン", + "clip": "クリップ", + "featurettes": "特典映像", + "featurette": "特典映像", + "behind_the_scenes": "メイキング", + "no_trailers": "利用可能な予告編がありません", + "unavailable": "予告編は利用できません", + "unavailable_desc": "予告編を読み込めませんでした。後でもう一度お試しください。", + "unable_to_play": "予告編を再生できません。もう一度お試しください。", + "watch_on_youtube": "YouTubeで見る" + }, + "catalog": { + "no_content_found": "コンテンツが見つかりません", + "no_content_filters": "選択したフィルターのコンテンツが見つかりません", + "loading_content": "コンテンツを読み込み中...", + "back": "戻る", + "in_theaters": "上映中", + "all": "すべて", + "failed_tmdb": "TMDBからコンテンツを読み込めませんでした", + "movies": "映画", + "tv_shows": "TVシリーズ", + "channels": "チャンネル" + }, + "streams": { + "back_to_episodes": "エピソードに戻る", + "back_to_info": "情報に戻る", + "fetching_from": "取得元:", + "no_sources_available": "利用可能なストリーミングソースがありません", + "add_sources_desc": "設定でストリーミングソースを追加してください", + "add_sources": "ソースを追加", + "finding_streams": "利用可能なストリームを検索中...", + "finding_best_stream": "自動再生用の最適なストリームを検索中...", + "still_fetching": "ストリームを取得中...", + "no_streams_available": "利用可能なストリームがありません", + "starting_best_stream": "最適なストリームを開始中...", + "loading_more_sources": "さらにソースを読み込み中..." + }, + "player_ui": { + "via": "{{name}}経由", + "audio_tracks": "音声トラック", + "no_audio_tracks": "利用可能な音声トラックがありません", + "playback_speed": "再生速度", + "on_hold": "保留中", + "playback_error": "再生エラー", + "unknown_error": "再生中に不明なエラーが発生しました。", + "copy_error": "エラー詳細をコピー", + "copied_to_clipboard": "クリップボードにコピーしました", + "dismiss": "閉じる", + "continue_watching": "視聴を続ける", + "start_over": "最初から", + "resume": "再開", + "change_source": "ソースを変更", + "switching_source": "ソースを切り替え中...", + "no_sources_found": "ソースが見つかりません", + "sources": "ソース", + "finding_sources": "ソースを検索中...", + "unknown_source": "不明なソース", + "sources_limited": "プロバイダーエラーによりソース数が制限される場合があります。", + "episodes": "エピソード", + "specials": "スペシャル", + "season": "シーズン{{season}}", + "stream": "ストリーム{{number}}", + "subtitles": "字幕", + "built_in": "内蔵", + "addons": "アドオン", + "style": "スタイル", + "none": "なし", + "search_online_subtitles": "オンライン字幕を検索", + "preview": "プレビュー", + "quick_presets": "クイックプリセット", + "default": "デフォルト", + "yellow": "黄色", + "high_contrast": "高コントラスト", + "large": "大", + "core": "基本", + "font_size": "フォントサイズ", + "show_background": "背景を表示", + "advanced": "詳細", + "position": "位置", + "text_color": "文字色", + "align": "配置", + "bottom_offset": "下部オフセット", + "background_opacity": "背景の透明度", + "text_shadow": "テキストシャドウ", + "on": "オン", + "off": "オフ", + "outline_color": "アウトラインの色", + "outline": "アウトライン", + "outline_width": "アウトラインの幅", + "letter_spacing": "文字間隔", + "line_height": "行の高さ", + "timing_offset": "タイミングオフセット(秒)", + "visual_sync": "ビジュアル同期", + "timing_hint": "字幕を早く(-)または遅く(+)ずらして同期させます。", + "reset_defaults": "デフォルトに戻す", + "mark_intro_start": "イントロ開始をマーク", + "mark_intro_end": "イントロ終了をマーク", + "intro_start_marked": "イントロ開始をマークしました", + "intro_submitted": "イントロを正常に送信しました", + "intro_submit_failed": "イントロの送信に失敗しました" + }, + "downloads": { + "title": "ダウンロード", + "no_downloads": "ダウンロードがありません", + "no_downloads_desc": "ダウンロードしたコンテンツはここに表示されオフラインで視聴できます", + "explore": "コンテンツを探す", + "path_copied": "パスをコピーしました", + "path_copied_desc": "ローカルファイルパスをクリップボードにコピーしました", + "copied": "コピーしました", + "incomplete": "ダウンロード未完了", + "incomplete_desc": "ダウンロードはまだ完了していません", + "not_available": "利用不可", + "not_available_desc": "ローカルファイルパスはダウンロード完了後に利用可能になります。", + "status_downloading": "ダウンロード中", + "status_completed": "完了", + "status_paused": "一時停止", + "status_error": "エラー", + "status_queued": "キュー待ち", + "status_unknown": "不明", + "provider": "プロバイダー", + "streaming_playlist_warning": "動作しない可能性があります - ストリーミングプレイリスト", + "remaining": "残り", + "not_ready": "ダウンロードの準備ができていません", + "not_ready_desc": "ダウンロードが完了するまでお待ちください。", + "filter_all": "すべて", + "filter_active": "アクティブ", + "filter_done": "完了", + "filter_paused": "一時停止", + "no_filter_results": "フィルター「{{filter}}」に該当するダウンロードがありません", + "try_different_filter": "別のフィルターをお試しください", + "limitations_title": "ダウンロードの制限", + "limitations_msg": "• 1MB未満のファイルは通常M3U8プレイリストであり、オフライン視聴用にダウンロードできません。これらはオンラインストリーミングのみで動作します。", + "remove_title": "ダウンロードを削除", + "remove_confirm": "「{{title}}」{{season_episode}}を削除しますか?", + "cancel": "キャンセル", + "remove": "削除" + }, + "addons": { + "title": "アドオン", + "reorder_mode": "並び替えモード", + "reorder_info": "上部のアドオンはコンテンツ読み込み時に優先されます", + "add_addon_placeholder": "アドオンのURL", + "add_button": "アドオンを追加", + "my_addons": "マイアドオン", + "community_addons": "コミュニティアドオン", + "no_addons": "インストールされたアドオンがありません", + "uninstall_title": "アドオンをアンインストール", + "uninstall_message": "{{name}}をアンインストールしますか?", + "uninstall_button": "アンインストール", + "install_success": "アドオンを正常にインストールしました", + "install_error": "アドオンのインストールに失敗しました", + "load_error": "アドオンの読み込みに失敗しました", + "fetch_error": "アドオンの詳細取得に失敗しました", + "invalid_url": "有効なアドオンURLを入力してください", + "configure": "設定", + "version": "バージョン: {{version}}", + "installed_addons": "インストール済みアドオン", + "reorder_drag_title": "ドラッグして並び替え", + "install": "インストール", + "config_unavailable_title": "設定不可", + "config_unavailable_msg": "このアドオンの設定URLを特定できません。", + "cannot_open_config_title": "設定を開けません", + "cannot_open_config_msg": "設定URL({{url}})を開けません。アドオンに設定ページがない可能性があります。", + "description": "説明", + "supported_types": "対応タイプ", + "catalogs": "カタログ", + "no_description": "説明がありません", + "overview": "概要", + "no_categories": "カテゴリなし", + "pre_installed": "プリインストール済み" + }, + "trakt": { + "title": "Trakt設定", + "settings_title": "Trakt設定", + "connect_title": "Traktに接続", + "connect_desc": "Trakt.tvで視聴履歴、ウォッチリスト、コレクションを同期", + "sign_in": "Traktでサインイン", + "sign_out": "サインアウト", + "sign_out_confirm": "Traktアカウントからサインアウトしますか?", + "joined": "{{date}}に参加", + "sync_settings_title": "同期設定", + "sync_info": "Traktに接続すると、完全な履歴がAPIから直接同期され、ローカルには保存されません。", + "auto_sync_label": "視聴進捗を自動同期", + "auto_sync_desc": "視聴進捗をTraktに自動的にアップロード", + "import_history_label": "視聴履歴をインポート", + "import_history_desc": "「今すぐ同期」でTraktから履歴と進捗をインポート", + "sync_now_button": "今すぐ同期", + "display_settings_title": "表示設定", + "show_comments_label": "Traktコメントを表示", + "show_comments_desc": "利用可能な場合、コンテンツ詳細にTraktコメントを表示", + "maintenance_title": "メンテナンス中", + "maintenance_unavailable": "Traktが利用できません", + "maintenance_desc": "Trakt連携はメンテナンス作業のため一時停止中です。", + "maintenance_button": "サービスメンテナンス中", + "auth_success_title": "接続に成功しました", + "auth_success_msg": "Traktアカウントが正常に接続されました。", + "auth_error_title": "認証エラー", + "auth_error_msg": "Traktの認証を完了できませんでした。", + "auth_error_generic": "認証中にエラーが発生しました。", + "sign_out_error": "Traktからサインアウトできませんでした。", + "sync_complete_title": "同期完了", + "sync_success_msg": "Traktと視聴進捗を正常に同期しました。", + "sync_error_msg": "同期に失敗しました。もう一度お試しください。" + }, + "simkl": { + "title": "Simkl設定", + "settings_title": "Simkl設定", + "connect_title": "Simklに接続", + "connect_desc": "視聴履歴を同期して視聴中のものを追跡", + "sign_in": "Simklでサインイン", + "sign_out": "切断", + "sign_out_confirm": "Simklアカウントを切断しますか?", + "syncing_desc": "視聴済みのアイテムがSimklと同期されます。", + "auth_success_title": "接続に成功しました", + "auth_success_msg": "Simklアカウントが正常に接続されました。", + "auth_error_title": "認証エラー", + "auth_error_msg": "Simklの認証を完了できませんでした。", + "auth_error_generic": "認証中にエラーが発生しました。", + "sign_out_error": "Simklから切断できませんでした。", + "config_error_title": "設定エラー", + "config_error_msg": "環境変数にSimkl Client IDがありません。", + "conflict_title": "競合", + "conflict_msg": "Traktが接続されている場合はSimklに接続できません。先にTraktを切断してください。", + "disclaimer": "NuvioはSimklと提携していません。" + }, + "tmdb_settings": { + "title": "TMDb設定", + "metadata_enrichment": "メタデータ補完", + "metadata_enrichment_desc": "TMDbのデータでコンテンツのメタデータを強化します。", + "enable_enrichment": "補完を有効にする", + "enable_enrichment_desc": "キャスト、年齢制限、ロゴ/ポスター、制作情報についてTMDbのデータでアドオンのメタデータを補完します。", + "localized_text": "ローカライズされたテキスト", + "localized_text_desc": "TMDbから希望言語でタイトルと説明を取得します。", + "language": "言語", + "change": "変更", + "logo_preview": "ロゴプレビュー", + "logo_preview_desc": "選択した言語でローカライズされたロゴのプレビューです。", + "example": "例:", + "no_logo": "利用可能なロゴがありません", + "enrichment_options": "補完オプション", + "enrichment_options_desc": "TMDbから取得するデータを選択します。", + "cast_crew": "キャストとスタッフ", + "cast_crew_desc": "プロフィール写真付きの俳優、監督、脚本家", + "title_description": "タイトルと説明", + "title_description_desc": "TMDbのローカライズされたタイトルと説明を使用", + "title_logos": "タイトルロゴ", + "title_logos_desc": "高品質なタイトルグラフィック", + "banners_backdrops": "バナーとバックドロップ", + "banners_backdrops_desc": "高解像度の背景画像", + "certification": "年齢制限", + "certification_desc": "年齢制限(PG-13、R、TV-MAなど)", + "recommendations": "おすすめ", + "recommendations_desc": "類似コンテンツの提案", + "episode_data": "エピソードデータ", + "episode_data_desc": "TVシリーズのエピソードサムネイル、情報、フォールバックデータ", + "season_posters": "シーズンポスター", + "season_posters_desc": "特定のシーズンに割り当てられたポスター画像", + "production_info": "制作情報", + "production_info_desc": "ロゴ付きのテレビ局と制作会社", + "movie_details": "映画の詳細", + "movie_details_desc": "製作費、興行収入、上映時間、キャッチコピー", + "tv_details": "TVシリーズの詳細", + "tv_details_desc": "ステータス、シーズン数、ネットワーク、制作者", + "movie_collections": "映画コレクション", + "movie_collections_desc": "映画シリーズ(マーベル、スターウォーズなど)", + "api_configuration": "API設定", + "api_configuration_desc": "拡張機能のためにTMDB APIアクセスを設定します。", + "custom_api_key": "カスタムAPIキー", + "custom_api_key_desc": "パフォーマンス向上のために独自のTMDB APIキーを使用します。", + "custom_key_active": "カスタムAPIキーが有効", + "api_key_required": "APIキーが必要", + "api_key_placeholder": "TMDB APIキー(v3)を貼り付け", + "how_to_get_key": "TMDB APIキーの取得方法", + "built_in_key_msg": "現在、組み込みAPIキーを使用しています。パフォーマンス向上のために独自のキーの使用をご検討ください。", + "cache_size": "キャッシュサイズ", + "clear_cache": "キャッシュをクリア", + "cache_days": "TMDBデータはパフォーマンス向上のため7日間保存されます", + "choose_language": "言語を選択", + "choose_language_desc": "TMDBコンテンツの希望言語を選択してください", + "popular": "人気", + "all_languages": "すべての言語", + "search_results": "検索結果", + "no_languages_found": "「{{query}}」の言語が見つかりません", + "clear_search": "検索をクリア", + "clear_cache_title": "TMDBキャッシュをクリア", + "clear_cache_msg": "保存されているすべてのTMDBデータ({{size}})が削除されます。", + "clear_cache_success": "TMDBキャッシュをクリアしました。", + "clear_cache_error": "キャッシュのクリアに失敗しました。", + "clear_api_key_title": "APIキーを削除", + "clear_api_key_msg": "カスタムAPIキーを削除してデフォルトに戻しますか?", + "clear_api_key_success": "APIキーを削除しました", + "clear_api_key_error": "APIキーの削除に失敗しました", + "empty_api_key": "APIキーを空にすることはできません。", + "invalid_api_key": "無効なAPIキーです。確認してもう一度お試しください。", + "save_error": "保存中にエラーが発生しました。もう一度お試しください。", + "using_builtin_key": "組み込みTMDB APIキーを使用しています。", + "using_custom_key": "カスタムTMDB APIキーを使用しています。", + "enter_custom_key": "カスタムTMDB APIキーを入力して保存してください。", + "key_verified": "APIキーが正常に確認・保存されました。" + }, + "settings": { + "language": "言語", + "select_language": "言語を選択", + "english": "英語", + "portuguese": "ポルトガル語", + "portuguese_br": "ポルトガル語(ブラジル)", + "portuguese_pt": "ポルトガル語(ポルトガル)", + "german": "ドイツ語", + "arabic": "アラビア語", + "spanish": "スペイン語", + "french": "フランス語", + "italian": "イタリア語", + "croatian": "クロアチア語", + "chinese": "中国語(簡体字)", + "hindi": "ヒンディー語", + "serbian": "セルビア語", + "hebrew": "ヘブライ語", + "bulgarian": "ブルガリア語", + "polish": "ポーランド語", + "czech": "チェコ語", + "turkish": "トルコ語", + "slovenian": "スロベニア語", + "macedonian": "マケドニア語", + "russian": "ロシア語", + "filipino": "フィリピン語", + "dutch_nl": "オランダ語(オランダ)", + "romanian": "ルーマニア語", + "albanian": "アルバニア語", + "catalan": "カタルーニャ語", + "japanese": "日本語", + "account": "アカウント", + "content_discovery": "コンテンツとディスカバー", + "appearance": "外観", + "integrations": "連携", + "playback": "再生", + "backup_restore": "バックアップと復元", + "updates": "アップデート", + "about": "アプリについて", + "developer": "開発者", + "cache": "キャッシュ", + "title": "設定", + "settings_title": "設定", + "sign_in_sync": "サインインして同期", + "add_catalogs_sources": "アドオン、カタログ、ソース", + "player_trailers_downloads": "プレーヤー、予告編、ダウンロード", + "mdblist_tmdb_ai": "MDBList、TMDB、AI", + "check_updates": "アップデートを確認", + "clear_mdblist_cache": "MDBListキャッシュをクリア", + "cache_management": "キャッシュ管理", + "downloads_counter": "ダウンロード数と増加中", + "made_with_love": "Tapframeとフレンドたちが❤️を込めて制作", + "sections": { + "information": "情報", + "account": "アカウント", + "theme": "テーマ", + "layout": "レイアウト", + "sources": "ソース", + "catalogs": "カタログ", + "discovery": "ディスカバー", + "metadata": "メタデータ", + "ai_assistant": "AIアシスタント", + "video_player": "ビデオプレーヤー", + "audio_subtitles": "音声と字幕", + "media": "メディア", + "notifications": "通知", + "testing": "テスト", + "danger_zone": "危険ゾーン" + }, + "items": { + "legal": "法的情報と免責事項", + "privacy_policy": "プライバシーポリシー", + "report_issue": "問題を報告", + "version": "バージョン", + "contributors": "貢献者", + "view_contributors": "すべての貢献者を見る", + "theme": "テーマ", + "episode_layout": "エピソードレイアウト", + "streams_backdrop": "ストリームバックドロップ", + "streams_backdrop_desc": "モバイルでストリーミング中にぼかした背景を表示", + "addons": "アドオン", + "installed": "インストール済み", + "debrid_integration": "Debrid連携", + "debrid_desc": "Torboxを接続", + "plugins": "プラグイン", + "plugins_desc": "プラグインとリポジトリを管理", + "catalogs": "カタログ", + "active": "アクティブ", + "home_screen": "ホーム画面", + "home_screen_desc": "レイアウトとコンテンツ", + "continue_watching": "視聴を続ける", + "continue_watching_desc": "キャッシュと再生の動作", + "show_discover": "ディスカバーセクションを表示", + "show_discover_desc": "検索でディスカバーコンテンツを表示", + "mdblist": "MDBList", + "mdblist_connected": "接続済み", + "mdblist_desc": "評価とレビューを追加するために有効にする", + "simkl": "Simkl", + "simkl_connected": "接続済み", + "simkl_desc": "視聴中のものを追跡", + "tmdb": "TMDB", + "tmdb_desc": "メタデータとロゴのプロバイダー", + "openrouter": "OpenRouter API", + "openrouter_connected": "接続済み", + "openrouter_desc": "AIチャットを有効にするにはAPIキーを追加", + "video_player": "ビデオプレーヤー", + "built_in": "内蔵", + "external": "外部", + "preferred_audio": "優先音声言語", + "preferred_subtitle": "優先字幕言語", + "subtitle_source": "字幕ソースの優先度", + "auto_select_subs": "字幕を自動選択", + "auto_select_subs_desc": "設定に合った字幕を自動的に選択", + "show_trailers": "予告編を表示", + "show_trailers_desc": "ヒーローセクションに予告編を表示", + "enable_downloads": "ダウンロードを有効にする", + "enable_downloads_desc": "ダウンロードタブを表示してストリームの保存を有効にする", + "notifications": "通知", + "notifications_desc": "エピソードのリマインダー", + "developer_tools": "開発者ツール", + "developer_tools_desc": "テストとデバッグのオプション", + "test_onboarding": "オンボーディングをテスト", + "reset_onboarding": "オンボーディングをリセット", + "test_announcement": "お知らせをテスト", + "test_announcement_desc": "「新着情報」オーバーレイを表示", + "reset_campaigns": "キャンペーンをリセット", + "reset_campaigns_desc": "キャンペーン表示をクリア", + "clear_all_data": "すべてのデータをクリア", + "clear_all_data_desc": "すべての設定とキャッシュデータをリセット" + }, + "options": { + "horizontal": "横", + "vertical": "縦", + "internal_first": "内蔵を優先", + "internal_first_desc": "内蔵字幕を優先し、次に外部字幕", + "external_first": "外部を優先", + "external_first_desc": "アドオンの字幕を優先し、次に内蔵字幕", + "any_available": "利用可能なもの", + "any_available_desc": "最初に利用可能な字幕トラックを使用" + }, + "clear_data_desc": "すべての設定がリセットされキャッシュがクリアされます。よろしいですか?", + "app_updates": "アプリのアップデート", + "about_nuvio": "Nuvioについて", + "cloud_sync": { + "title": "Nuvio Sync", + "description": "Nuvioデバイス間でデータを同期", + "hero_title": "クラウド同期", + "hero_subtitle": "すべてのデバイスでアドオン、進捗、ライブラリを同期します。", + "auth": { + "account": "アカウント", + "not_configured": "Supabaseが設定されていません", + "not_authenticated": "認証されていません", + "email_session": "メールセッション", + "signed_in_as": "{{email}}としてサインイン中", + "not_signed_in": "サインインしていません", + "effective_owner": "有効なオーナー: {{id}}" + }, + "stats": { + "title": "データベース統計", + "plugins": "プラグイン", + "addons": "アドオン", + "watch_progress": "視聴進捗", + "library_items": "ライブラリアイテム", + "watched_items": "視聴済みアイテム", + "signin_required": "リモートデータ数を読み込むにはサインインしてください。" + }, + "actions": { + "title": "アクション", + "description": "クラウドからこのデバイスに取得するか、このデバイスからプッシュします。", + "pull_btn": "クラウドから取得", + "push_btn": "デバイスからプッシュ", + "manage_account": "アカウントを管理", + "sign_out": "サインアウト", + "sign_in_up": "サインイン / 登録" + }, + "alerts": { + "pull_success_title": "クラウドデータを取得しました", + "pull_success_msg": "最新のクラウドデータがこのデバイスにダウンロードされました。", + "pull_failed_title": "取得に失敗しました", + "pull_failed_msg": "クラウドからデータをダウンロードできませんでした", + "push_success_title": "プッシュ完了", + "push_success_msg": "デバイスデータがクラウドにアップロードされました。", + "push_failed_title": "プッシュに失敗しました", + "push_failed_msg": "ローカルデータのアップロードに失敗しました", + "sign_out_failed": "サインアウトに失敗しました", + "sign_out_failed_title": "ログアウトエラー" + }, + "external_sync": { + "title": "外部同期の優先度", + "active_msg": "{{services}}がアクティブです。視聴進捗とライブラリの更新はNuvioクラウドの代わりにこれらのサービスによって管理されます。", + "inactive_msg": "TraktまたはSimklの同期が有効な場合、視聴進捗とライブラリの更新はNuvioクラウドの代わりにそれらのサービスを使用します。" + }, + "pre_auth": { + "title": "同期前に", + "description": "クラウド同期を開始するにはサインインしてください。", + "point_1": "• アドオンとプラグインの設定", + "point_2": "• 視聴進捗とライブラリ", + "env_warning": "同期を有効にするにはEXPO_PUBLIC_SUPABASE_URLとEXPO_PUBLIC_SUPABASE_ANON_KEYを設定してください。" + }, + "connection": "接続" + } + }, + "privacy": { + "title": "プライバシーとデータ", + "settings_desc": "テレメトリーとデータ収集の管理", + "info_title": "あなたのプライバシーは重要です", + "info_description": "収集・共有されるデータを管理してください。分析はデフォルトで無効、エラーレポートはデフォルトで匿名です。", + "analytics_enabled_title": "分析が有効になりました", + "analytics_enabled_message": "アプリ改善のために使用データが収集されます。いつでも無効にできます。", + "disable_error_reporting_title": "エラーレポートを無効にしますか?", + "disable_error_reporting_message": "エラーレポートを無効にすると、クラッシュや問題について通知されなくなります。", + "enable_session_replay_title": "セッションリプレイを有効にしますか?", + "enable_session_replay_message": "セッションリプレイはエラー発生時に画面を記録します。画面上に表示されたコンテンツが記録される可能性があります。", + "enable_pii_title": "PII収集を有効にしますか?", + "enable_pii_message": "IPアドレスやデバイスの詳細などの個人情報の収集が可能になります。", + "disable_all_title": "すべてのテレメトリーを無効にしますか?", + "disable_all_message": "分析、エラーレポート、セッションリプレイがすべて無効になります。", + "disable_all_button": "すべて無効にする", + "all_disabled_title": "テレメトリーが無効になりました", + "all_disabled_message": "すべてのデータ収集が無効になりました。変更はアプリの再起動後に有効になります。", + "reset_title": "推奨設定に戻す", + "reset_message": "プライバシー設定が推奨デフォルト値にリセットされました。", + "section_analytics": "分析", + "analytics_title": "使用状況統計", + "analytics_description": "匿名の使用パターンと画面ビューを収集", + "section_error_reporting": "エラーレポート", + "error_reporting_title": "クラッシュレポート", + "error_reporting_description": "安定性向上のため匿名のクラッシュレポートを送信", + "session_replay_title": "セッションリプレイ", + "session_replay_description": "エラー発生時に画面を記録", + "pii_title": "デバイス情報を含める", + "pii_description": "レポートとともにIPアドレスとデバイスの詳細を送信", + "section_quick_actions": "クイックアクション", + "disable_all": "すべてのテレメトリーを無効にする", + "disable_all_desc": "データ収集を完全に無効にする", + "reset_recommended": "推奨設定に戻す", + "reset_recommended_desc": "エラーレポートありのプライバシー設定", + "section_learn_more": "詳細", + "privacy_policy": "プライバシーポリシー", + "current_settings": "現在の設定の概要", + "summary_analytics": "分析", + "summary_errors": "エラーレポート", + "summary_replay": "セッションリプレイ", + "summary_pii": "デバイス情報", + "restart_note_detailed": "* 分析とエラーレポートの変更は即座に有効になります。セッションリプレイとPII設定はアプリの再起動が必要です。" + }, + "ai_settings": { + "title": "AIアシスタント", + "info_title": "AIを使ったチャット", + "info_desc": "高度なAIを使って映画やエピソードについて質問できます。", + "feature_1": "エピソード固有の分析とコンテキスト", + "feature_2": "ストーリーの説明とキャラクターの考察", + "feature_3": "トリビアと舞台裏の事実", + "feature_4": "無料のOpenRouter APIキー", + "api_key_section": "OPENROUTER APIキー", + "api_key_label": "APIキー", + "api_key_desc": "AIチャット機能を有効にするにはOpenRouter APIキーを入力してください", + "save_api_key": "APIキーを保存", + "saving": "保存中...", + "update": "更新", + "remove": "削除", + "get_free_key": "OpenRouterから無料のAPIキーを取得", + "enable_chat": "AIチャットを有効にする", + "enable_chat_desc": "有効にすると、コンテンツページに「AIに聞く」ボタンが表示されます。", + "chat_enabled": "AIチャットが有効になりました", + "chat_enabled_desc": "映画やシリーズについて質問できるようになりました。", + "how_it_works": "仕組み", + "how_it_works_desc": "• OpenRouterは複数のAIモデルへのアクセスを提供します\n• APIキーはプライベートで安全に保たれます\n• 無料プランには十分な使用制限があります\n• 特定のエピソード/映画のコンテキストでチャットできます\n• 詳細な分析と説明を受け取れます", + "error_invalid_key": "有効なAPIキーを入力してください", + "error_key_format": "OpenRouter APIキーは「sk-or-」で始まる必要があります", + "success_saved": "OpenRouter APIキーを正常に保存しました!", + "error_save": "APIキーの保存に失敗しました", + "confirm_remove_title": "APIキーを削除", + "confirm_remove_msg": "OpenRouter APIキーを削除しますか?これによりAIチャット機能が無効になります。", + "success_removed": "APIキーを削除しました", + "error_remove": "APIキーの削除に失敗しました" + }, + "catalog_settings": { + "title": "カタログ", + "layout_phone": "カタログ画面のレイアウト(電話)", + "posters_per_row": "1行あたりのポスター数", + "auto": "自動", + "show_titles": "ポスターにタイトルを表示", + "show_titles_desc": "各ポスターの下にタイトルテキストを表示", + "phone_only_hint": "電話のみ対象。タブレットはアダプティブレイアウトを維持します。", + "catalogs_group": "カタログ", + "enabled_count": "{{total}}中{{enabled}}つが有効", + "rename_hint": "カタログを長押しして名前を変更", + "rename_modal_title": "カタログ名を変更", + "rename_placeholder": "新しいカタログ名を入力", + "error_save_name": "カスタム名の保存に失敗しました。" + }, + "continue_watching_settings": { + "title": "視聴を続ける", + "playback_behavior": "再生の動作", + "use_cached": "保存されたストリームを使用", + "use_cached_desc": "有効にすると、以前再生したストリームを使って直接プレーヤーが開きます。", + "open_metadata": "詳細画面を開く", + "open_metadata_desc": "保存されたストリームが無効の場合、メタデータ画面を開きます。", + "card_appearance": "カードの外観", + "card_style": "カードスタイル", + "card_style_desc": "ホーム画面での「視聴を続ける」アイテムの表示方法を選択", + "wide": "ワイド", + "poster": "ポスター", + "cache_settings": "キャッシュ設定", + "cache_duration": "ストリームキャッシュ期間", + "cache_duration_desc": "ストリームリンクの有効期限が切れるまでの保存期間", + "important_note": "重要なお知らせ", + "important_note_text": "すべてのストリームリンクが保存期間中ずっとアクティブとは限りません。", + "how_it_works": "仕組み", + "how_it_works_cached": "• ストリームは再生後に選択した期間保存されます\n• 保存されたストリームは使用前に検証されます\n• キャッシュが期限切れの場合はコンテンツ画面に戻ります", + "how_it_works_uncached": "• 保存されたストリームが無効の場合、アイテムをタップするとコンテンツ画面が開きます\n• メタデータ画面は詳細を表示して手動選択ができます", + "changes_saved": "変更を保存しました", + "min": "分", + "hour": "時間", + "hours": "時間" + }, + "contributors": { + "title": "貢献者", + "special_mentions": "特別な感謝", + "tab_contributors": "貢献者", + "tab_special": "特別", + "tab_donors": "寄付者", + "manager_role": "コミュニティマネージャー", + "manager_desc": "NuvioのDiscordとRedditコミュニティを管理", + "sponsor_role": "サーバースポンサー", + "sponsor_desc": "Nuvioのサーバーインフラをスポンサー", + "mod_role": "Discordモデレーター", + "mod_desc": "NuvioのDiscordコミュニティのモデレートを支援", + "loading": "読み込み中...", + "discord_user": "Discordユーザー", + "contributions": "貢献", + "gratitude_title": "すべての貢献に感謝しています", + "gratitude_desc": "コードの1行1行、バグ報告、提案がすべての人のためにNuvioを改善します", + "special_thanks_title": "特別な感謝", + "special_thanks_desc": "これらの素晴らしい人たちがNuvioコミュニティとサーバーの維持を支援しています", + "donors_desc": "私たちが構築しているものを信じてくれてありがとうございます。", + "latest_donations": "最新", + "leaderboard": "ランキング", + "loading_donors": "寄付者を読み込み中...", + "no_donors": "寄付者がいません", + "error_rate_limit": "GitHub APIレート制限を超えました。後でもう一度試してください。", + "error_failed": "貢献者の読み込みに失敗しました。インターネット接続を確認してください。", + "retry": "もう一度試す", + "no_contributors": "貢献者が見つかりません", + "loading_contributors": "貢献者を読み込み中..." + }, + "debrid": { + "title": "Debrid連携", + "description_torbox": "Torbox連携で4K品質のストリームと超高速スピードを解放。", + "description_torrentio": "映画やシリーズのトレントストリームを受け取るようにTorrentioを設定します。", + "tab_torbox": "TorBox", + "tab_torrentio": "Torrentio", + "status_connected": "接続済み", + "status_disconnected": "切断済み", + "enable_addon": "アドオンを有効にする", + "disconnect_button": "切断して削除", + "disconnect_loading": "切断中...", + "account_info": "アカウント情報", + "plan": "プラン", + "plan_free": "無料", + "plan_essential": "Essential($3/月)", + "plan_pro": "Pro($10/月)", + "plan_standard": "Standard($5/月)", + "plan_unknown": "不明", + "expires": "有効期限", + "downloaded": "ダウンロード済み", + "status_active": "アクティブ", + "connected_title": "✓ TorBoxに接続済み", + "connected_desc": "TorBoxアドオンがアクティブでプレミアムストリームを提供しています。", + "configure_title": "アドオンを設定", + "configure_desc": "体験をカスタマイズしてください。", + "open_settings": "設定を開く", + "what_is_debrid": "Debridサービスとは?", + "enter_api_key": "APIキーを入力", + "connect_button": "接続してインストール", + "connecting": "接続中...", + "unlock_speeds_title": "プレミアム速度を解放", + "unlock_speeds_desc": "Torboxサブスクリプションを購入して、バッファリングなしの高品質ストリームにアクセスしましょう。", + "get_subscription": "サブスクリプションを取得", + "powered_by": "提供元", + "disclaimer_torbox": "NuvioはTorboxと一切関係ありません。", + "disclaimer_torrentio": "NuvioはTorrentioと一切関係ありません。", + "installed_badge": "✓ インストール済み", + "promo_title": "⚡ Debridサービスが必要ですか?", + "promo_desc": "バッファリングなしの超高速4Kストリーミング。プレミアムトレントと即時ダウンロード。", + "promo_button": "TorBoxサブスクリプションを購入", + "service_label": "Debridサービス *", + "api_key_label": "APIキー *", + "sorting_label": "並び替え", + "exclude_qualities": "品質を除外", + "priority_languages": "優先言語", + "max_results": "最大結果数", + "additional_options": "追加オプション", + "no_download_links": "ダウンロードリンクを表示しない", + "no_debrid_catalog": "Debridカタログを表示しない", + "install_button": "Torrentioをインストール", + "installing": "インストール中...", + "update_button": "設定を更新", + "updating": "更新中...", + "remove_button": "Torrentioを削除", + "error_api_required": "APIキーが必要", + "error_api_required_desc": "TorrentioをインストールするにはDebridサービスのAPIキーを入力してください。", + "success_installed": "Torrentioアドオンを正常にインストールしました!", + "success_removed": "Torrentioアドオンを削除しました", + "alert_disconnect_title": "Torboxを切断", + "alert_disconnect_msg": "Torboxを切断しますか?これによりアドオンが削除されAPIキーがクリアされます。" + }, + "home_screen": { + "title": "ホーム画面設定", + "changes_applied": "変更が適用されました", + "display_options": "表示オプション", + "show_hero": "ヒーローセクションを表示", + "show_hero_desc": "上部に注目コンテンツ", + "show_this_week": "「今週」セクションを表示", + "show_this_week_desc": "今週の新着エピソード", + "select_catalogs": "カタログを選択", + "all_catalogs": "すべてのカタログ", + "selected": "選択済み", + "prefer_external_meta": "外部メタアドオンを優先", + "prefer_external_meta_desc": "詳細ページで外部メタデータを使用", + "hero_layout": "ヒーローセクションのレイアウト", + "layout_legacy": "クラシック", + "layout_carousel": "カルーセル", + "layout_appletv": "Apple TV", + "layout_desc": "全幅バナー、スライドカード、またはApple TVスタイル", + "featured_source": "注目コンテンツのソース", + "using_catalogs": "カタログを使用中", + "manage_selected_catalogs": "選択したカタログを管理", + "dynamic_bg": "ダイナミックヒーロー背景", + "dynamic_bg_desc": "カルーセルの後ろにぼかしたバナー", + "performance_note": "低スペックデバイスではパフォーマンスに影響する可能性があります。", + "posters": "ポスター", + "show_titles": "タイトルを表示", + "poster_size": "ポスターサイズ", + "poster_corners": "ポスターの角", + "size_small": "小", + "size_medium": "中", + "size_large": "大", + "corners_square": "四角", + "corners_rounded": "丸め", + "corners_pill": "ピル", + "about_these_settings": "これらの設定について", + "about_desc": "これらの設定はホーム画面でのコンテンツ表示方法を制御します。変更はすぐに適用されます。", + "hero_catalogs": { + "title": "ヒーローカタログ", + "select_all": "すべて選択", + "clear_all": "すべてクリア", + "info": "ヒーローセクションに表示するカタログを選択してください。変更を保存することを忘れずに。", + "settings_saved": "設定を保存しました", + "error_load": "カタログの読み込みに失敗しました", + "movies": "映画", + "tv_shows": "TVシリーズ" + } + }, + "calendar": { + "title": "カレンダー", + "loading": "カレンダーを読み込み中...", + "no_scheduled_episodes": "予定されたエピソードがありません", + "check_back_later": "後で確認してください", + "showing_episodes_for": "{{date}}のエピソード", + "show_all_episodes": "すべてのエピソードを表示", + "no_episodes_for": "{{date}}のエピソードがありません", + "no_upcoming_found": "近日公開のエピソードが見つかりません", + "add_series_desc": "ライブラリにシリーズを追加すると、近日公開のエピソードがここに表示されます" + }, + "mdblist": { + "title": "評価ソース", + "status_disabled": "MDBListが無効", + "status_active": "APIキーがアクティブ", + "status_required": "APIキーが必要", + "status_disabled_desc": "MDBList機能は現在無効です。", + "status_active_desc": "MDBListの評価が有効です。", + "status_required_desc": "評価を有効にするには以下にキーを追加してください。", + "enable_toggle": "MDBListを有効にする", + "enable_toggle_desc": "すべてのMDBList機能を有効/無効にする", + "api_section": "APIキー", + "placeholder": "MDBList APIキーを貼り付け", + "save": "保存", + "clear": "キーをクリア", + "rating_providers": "評価プロバイダー", + "rating_providers_desc": "アプリに表示する評価を選択", + "how_to": "APIキーの取得方法", + "step_1": "にサインイン", + "step_1_link": "MDBListのサイト", + "step_2": "に移動", + "step_2_settings": "設定", + "step_2_api": "API", + "step_2_end": "。", + "step_3": "新しいキーを生成してコピーしてください。", + "go_to_website": "MDBListに移動", + "alert_clear_title": "APIキーを削除", + "alert_clear_msg": "保存されたAPIキーを削除しますか?", + "success_saved": "APIキーを正常に保存しました。", + "error_empty": "APIキーを空にすることはできません。", + "error_save": "保存中にエラーが発生しました。もう一度お試しください。", + "api_key_empty_error": "APIキーを空にすることはできません。", + "success_cleared": "APIキーを削除しました", + "error_clear": "APIキーの削除に失敗しました" + }, + "notification": { + "title": "通知設定", + "section_general": "一般", + "enable_notifications": "通知を有効にする", + "section_types": "通知タイプ", + "new_episodes": "新着エピソード", + "upcoming_shows": "近日公開番組", + "reminders": "リマインダー", + "section_timing": "通知タイミング", + "timing_desc": "エピソード放送前何時間で通知しますか?", + "hours_1": "1時間", + "hours_suffix": "時間", + "section_status": "通知ステータス", + "stats_upcoming": "近日公開", + "stats_this_week": "今週", + "stats_total": "合計", + "sync_button": "ライブラリとTraktを同期", + "syncing": "同期中...", + "sync_desc": "ライブラリとTraktのウォッチリスト/コレクション内のすべてのシリーズの通知を自動的に同期します。", + "section_advanced": "詳細", + "reset_button": "すべての通知をリセット", + "test_button": "通知をテスト(5秒)", + "test_notification_in": "{{seconds}}秒後に通知...", + "test_notification_text": "{{seconds}}秒後に通知が表示されます", + "alert_reset_title": "通知をリセット", + "alert_reset_msg": "これはすべてのスケジュールされた通知をキャンセルします。続けますか?", + "alert_reset_success": "すべての通知をリセットしました", + "alert_sync_complete": "同期完了", + "alert_sync_msg": "ライブラリとTraktのアイテムの通知を正常に同期しました。\n\nスケジュール済み: {{upcoming}}件の近日公開エピソード\n今週: {{thisWeek}}件のエピソード", + "alert_test_scheduled": "テスト通知をスケジュールしました" + }, + "backup": { + "title": "バックアップと復元", + "options_title": "バックアップオプション", + "options_desc": "バックアップに含めるものを選択", + "section_core": "コアデータ", + "section_addons": "アドオンと連携", + "section_settings": "設定と設定値", + "library_label": "ライブラリ", + "library_desc": "保存した映画とシリーズ", + "watch_progress_label": "視聴進捗", + "watch_progress_desc": "視聴継続アイテム", + "addons_label": "アドオン", + "addons_desc": "インストールされたStremioアドオン", + "plugins_label": "プラグイン", + "plugins_desc": "カスタムスクレイパー設定", + "trakt_label": "Trakt連携", + "trakt_desc": "同期データと認証トークン", + "app_settings_label": "アプリ設定", + "app_settings_desc": "テーマ、設定値、設定", + "user_prefs_label": "ユーザー設定値", + "user_prefs_desc": "アドオンの順序とUI設定", + "catalog_settings_label": "カタログ設定", + "catalog_settings_desc": "カタログフィルターと設定値", + "api_keys_label": "APIキー", + "api_keys_desc": "MDBListとOpenRouterのキー", + "action_create": "バックアップを作成", + "action_restore": "バックアップから復元", + "section_info": "バックアップについて", + "info_text": "• 上のトグルでバックアップの範囲をカスタマイズ\n• バックアップファイルはデバイスにローカル保存\n• バックアップを共有してデバイス間でデータを移動\n• 復元すると現在のデータが上書きされます", + "alert_create_title": "バックアップを作成", + "alert_no_content": "バックアップするコンテンツが選択されていません。\n\n上のオプションを少なくとも1つ有効にしてください。", + "alert_backup_created_title": "バックアップを作成しました", + "alert_backup_created_msg": "バックアップが作成され共有の準備ができました。", + "alert_backup_failed_title": "バックアップに失敗しました", + "alert_restore_confirm_title": "復元を確認", + "alert_restore_confirm_msg": "{{date}}に作成されたバックアップからデータを復元します。\n\nこのアクションにより現在のデータが上書きされます。続けますか?", + "alert_restore_complete_title": "復元完了", + "alert_restore_complete_msg": "データが正常に復元されました。変更を確認するにはアプリを再起動してください。", + "alert_restore_failed_title": "復元に失敗しました", + "restart_app": "アプリを再起動", + "alert_restart_failed_title": "再起動に失敗しました", + "alert_restart_failed_msg": "アプリの再起動に失敗しました。手動で閉じて再度開いてください。" + }, + "updates": { + "title": "アプリのアップデート", + "status_checking": "アップデートを確認中...", + "status_available": "アップデートが利用可能!", + "status_downloading": "アップデートをダウンロード中...", + "status_installing": "アップデートをインストール中...", + "status_success": "アップデートが正常にインストールされました!", + "status_error": "アップデートに失敗しました", + "status_ready": "アップデートを確認する準備ができています", + "action_check": "アップデートを確認", + "action_install": "アップデートをインストール", + "release_notes": "リリースノート:", + "version": "バージョン:", + "last_checked": "最終確認:", + "current_version": "現在のバージョン:", + "current_release_notes": "現在のリリースノート:", + "github_release": "GitHubリリース", + "current": "現在:", + "latest": "最新:", + "notes": "メモ:", + "view_release": "リリースを見る", + "notification_settings": "通知設定", + "ota_alerts_label": "OTAアップデートアラート", + "ota_alerts_desc": "無線(OTA)アップデートの通知を表示", + "major_alerts_label": "メジャーアップデートアラート", + "major_alerts_desc": "GitHubの新バージョンの通知を表示", + "alert_disable_ota_title": "OTAアップデートアラートを無効にしますか?", + "alert_disable_ota_msg": "OTAアップデートの自動通知を受け取らなくなります。", + "alert_disable_major_title": "メジャーアップデートアラートを無効にしますか?", + "alert_disable_major_msg": "再インストールが必要なメジャーアップデートの通知を受け取らなくなります。", + "warning_note": "有効なアラートにより、バグ修正を受け取りクラッシュレポートを提出できます。", + "disable": "無効にする", + "alert_no_update_to_install": "インストールするアップデートがありません", + "alert_install_failed": "アップデートのインストールエラー", + "alert_no_update_title": "アップデートなし", + "alert_update_applied_msg": "次回アプリ起動時にアップデートが適用されます" + }, + "player": { + "title": "ビデオプレーヤー", + "section_selection": "プレーヤーの選択", + "internal_title": "内蔵プレーヤー", + "internal_desc": "アプリのデフォルトプレーヤーを使用", + "vlc_title": "VLC", + "vlc_desc": "VLCプレーヤーでストリームを開く", + "infuse_title": "Infuse", + "infuse_desc": "Infuseプレーヤーでストリームを開く", + "outplayer_title": "OutPlayer", + "outplayer_desc": "OutPlayerでストリームを開く", + "vidhub_title": "VidHub", + "vidhub_desc": "VidHubプレーヤーでストリームを開く", + "infuse_live_title": "Infuse LiveContainer", + "infuse_live_desc": "Infuse LiveContainerでストリームを開く", + "external_title": "外部プレーヤー", + "external_desc": "好みのビデオプレーヤーでストリームを開く", + "section_playback": "再生オプション", + "skip_intro_settings_title": "イントロをスキップ", + "powered_by_introdb": "IntroDBを使用", + "autoplay_title": "最初のストリームを自動再生", + "autoplay_desc": "リストに表示されている最初のストリームを自動的に起動します。", + "resume_title": "常に再開", + "resume_desc": "再開の確認をスキップして中断した場所から続ける(85%未満視聴の場合)。", + "engine_title": "プレーヤーエンジン", + "engine_desc": "AutoモードはExoPlayerとMPVフォールバックを使用します。Autoモードを推奨します。", + "decoder_title": "デコーダーモード", + "decoder_desc": "動画のデコード方法。最適なバランスのためAutoモードを推奨します。", + "gpu_title": "GPUレンダリング", + "gpu_desc": "GPU-NextはHDRとカラー管理が改善されています。", + "external_downloads_title": "ダウンロード用外部プレーヤー", + "external_downloads_desc": "好みの外部プレーヤーでダウンロードしたコンテンツを再生します。", + "restart_required": "再起動が必要", + "restart_msg_decoder": "デコーダーの変更を有効にするにはアプリを再起動してください。", + "restart_msg_gpu": "GPUモードの変更を有効にするにはアプリを再起動してください。", + "option_auto": "Auto", + "option_auto_desc_engine": "ExoPlayer + MPVフォールバック", + "option_mpv": "MPV", + "option_mpv_desc": "MPVのみ", + "option_auto_desc_decoder": "最適なバランス", + "option_sw": "SW", + "option_sw_desc": "ソフトウェア", + "option_hw": "HW", + "option_hw_desc": "ハードウェア", + "option_hw_plus": "HW+", + "option_hw_plus_desc": "フルハードウェア", + "option_gpu_desc": "標準", + "option_gpu_next_desc": "高度" + }, + "plugins": { + "title": "プラグイン", + "enable_title": "プラグインを有効にする", + "enable_desc": "外部メディアソースを取得するためにプラグインエンジンを有効にする", + "repo_config_title": "リポジトリ設定", + "repo_config_desc": "外部プラグインリポジトリを管理します。", + "your_repos": "リポジトリ", + "your_repos_desc": "外部プラグインソースを設定してください。", + "add_repo_button": "リポジトリを追加", + "refresh": "更新", + "remove": "削除", + "enabled": "有効", + "disabled": "無効", + "updating": "更新中...", + "success": "成功", + "error": "エラー", + "alert_repo_added": "リポジトリが追加されプラグインが正常に読み込まれました", + "alert_repo_saved": "リポジトリURLが正常に保存されました", + "alert_repo_refreshed": "リポジトリが正常に更新されました", + "alert_invalid_url": "無効なURL形式", + "alert_plugins_cleared": "すべてのプラグインが削除されました", + "alert_cache_cleared": "リポジトリキャッシュをクリアしました", + "unknown": "不明", + "active": "アクティブ", + "available": "利用可能", + "platform_disabled": "プラットフォーム無効", + "limited": "制限付き", + "clear_all": "すべてのプラグインを削除", + "clear_all_desc": "インストールされたすべてのプラグインを削除しますか?この操作は元に戻せません。", + "clear_cache": "リポジトリキャッシュをクリア", + "clear_cache_desc": "これによりリポジトリURLが削除されキャッシュデータがクリアされます。", + "add_new_repo": "新しいリポジトリを追加", + "available_plugins": "利用可能なプラグイン({{count}})", + "placeholder": "プラグインを検索...", + "all": "すべて", + "filter_all": "すべてのタイプ", + "filter_movies": "映画", + "filter_tv": "TVシリーズ", + "enable_all": "すべて有効", + "disable_all": "すべて無効", + "no_plugins_found": "プラグインが見つかりません", + "no_plugins_available": "利用可能なプラグインがありません", + "no_match_desc": "「{{query}}」に一致するプラグインがありません。別のキーワードをお試しください。", + "configure_repo_desc": "利用可能なプラグインを確認するには上でリポジトリを設定してください。", + "clear_search": "検索をクリア", + "no_external_player": "外部プレーヤーなし", + "showbox_token": "ShowBox UIトークン", + "showbox_placeholder": "ShowBox UIトークンを貼り付け", + "save": "保存", + "clear": "クリア", + "additional_settings": "追加設定", + "enable_url_validation": "URL検証を有効にする", + "url_validation_desc": "メディアURLを返す前に検証します", + "group_streams": "プラグインソースをグループ化", + "group_streams_desc": "有効にするとソースはリポジトリ別にグループ化されます。", + "sort_quality": "まず品質でソート", + "sort_quality_desc": "有効にするとソースは品質でソートされます。グループ化が有効な場合のみ動作します。", + "show_logos": "プラグインロゴを表示", + "show_logos_desc": "ソース画面のメディアリンクの横にプラグインロゴを表示します。", + "quality_filtering": "品質フィルタリング", + "quality_filtering_desc": "検索結果から特定の解像度を除外します。品質をタップして除外します。", + "excluded_qualities": "除外された品質:", + "language_filtering": "言語フィルタリング", + "language_filtering_desc": "検索結果から特定の言語を除外します。言語をタップして除外します。", + "note": "注意:", + "language_filtering_note": "このフィルターは言語情報を提供するプロバイダーにのみ適用されます。", + "excluded_languages": "除外された言語:", + "about_title": "プラグインについて", + "about_desc_1": "プラグインはさまざまなプロトコルからコンテンツを取得するモジュール式コンポーネントです。デバイス上でローカルに動作します。", + "about_desc_2": "「制限付き」としてマークされたプラグインは特定の外部設定が必要な場合があります。", + "help_title": "プラグインの設定", + "help_step_1": "1. **プラグインを有効にする** - メインスイッチをオンにする", + "help_step_2": "2. **リポジトリを追加する** - 有効なリポジトリURLを入力する", + "help_step_3": "3. **リポジトリを更新する** - 利用可能なプラグインを取得する", + "help_step_4": "4. **アクティブにする** - 使用したいプラグインを有効にする", + "got_it": "わかりました!", + "repo_format_hint": "形式: https://raw.githubusercontent.com/ユーザー/リポジトリ/ブランチ", + "cancel": "キャンセル", + "add": "追加" + }, + "theme": { + "title": "アプリテーマ", + "select_theme": "テーマを選択", + "create_custom": "カスタムテーマを作成", + "options": "オプション", + "use_dominant_color": "カバーのドミナントカラーを使用", + "categories": { + "all": "すべてのテーマ", + "dark": "ダークテーマ", + "colorful": "カラフル", + "custom": "マイテーマ" + }, + "editor": { + "theme_name_placeholder": "テーマ名", + "save": "保存", + "primary": "プライマリ", + "secondary": "セカンダリ", + "background": "背景", + "invalid_name_title": "無効な名前", + "invalid_name_msg": "有効なテーマ名を入力してください" + }, + "alerts": { + "delete_title": "テーマを削除", + "delete_msg": "「{{name}}」を削除しますか?", + "ok": "OK", + "delete": "削除", + "cancel": "キャンセル", + "back": "設定" + } + }, + "legal": { + "title": "法的情報", + "intro_title": "アプリの性質", + "intro_text": "Nuvioはメディアプレーヤーおよびメタデータ管理アプリケーションです。Nuvioはいかなるメディアコンテンツもホスト、保存、配布、またはインデックス化しません。", + "extensions_title": "サードパーティ拡張機能", + "extensions_text": "Nuvioはユーザーがサードパーティプラグインをインストールできる拡張可能なアーキテクチャを使用しています。サードパーティ拡張機能のコンテンツ、合法性、機能について責任を負いません。", + "user_resp_title": "ユーザーの責任", + "user_resp_text": "ユーザーはインストールするプラグインとアクセスするコンテンツに対して単独で責任を負います。Nuvioの制作者は著作権侵害を奨励しません。", + "dmca_title": "著作権とDMCA", + "dmca_text": "私たちは他者の知的財産権を尊重します。アプリのインターフェース自体があなたの権利を侵害していると思われる場合はお問い合わせください。", + "warranty_title": "無保証", + "warranty_text": "このソフトウェアは「現状のまま」で提供され、いかなる保証もありません。" + }, + "plugin_tester": { + "title": "プラグインテスター", + "subtitle": "スクレイパーを実行してリアルタイムでログを確認", + "tabs": { + "individual": "個別", + "repo": "リポジトリテスター", + "code": "コード", + "logs": "ログ", + "results": "結果" + }, + "common": { + "error": "エラー", + "success": "成功", + "movie": "映画", + "tv": "TVシリーズ", + "tmdb_id": "TMDB ID", + "season": "シーズン", + "episode": "エピソード", + "running": "実行中...", + "run_test": "テストを実行", + "play": "再生", + "done": "完了", + "test": "テスト", + "testing": "テスト中..." + }, + "individual": { + "load_from_url": "URLから読み込む", + "load_from_url_desc": "GitHubのrawURLまたはローカルIPを貼り付けて取得をタップ。", + "enter_url_error": "URLを入力してください", + "code_loaded": "URLからコードを読み込みました", + "fetch_error": "取得エラー: {{message}}", + "no_code_error": "実行するコードがありません", + "plugin_code": "プラグインコード", + "focus_editor": "コードエディターをアクティブにする", + "code_placeholder": "// プラグインコードをここに貼り付け...", + "test_parameters": "テストパラメーター", + "no_logs": "ログなし。テストを実行して出力を確認してください。", + "no_streams": "まだストリームが見つかりません。", + "streams_found": "{{count}}件のストリームが見つかりました", + "streams_found_plural": "{{count}}件のストリームが見つかりました", + "tap_play_hint": "再生をタップしてネイティブプレーヤーでストリームをテストします。", + "unnamed_stream": "無名のストリーム", + "quality": "品質: {{quality}}", + "size": "サイズ: {{size}}", + "url_label": "URL: {{url}}", + "headers_info": "ヘッダー: {{count}}件のカスタム", + "find_placeholder": "コードを検索...", + "edit_code_title": "コードを編集", + "no_url_stream_error": "このストリームのURLが見つかりませんでした" + }, + "repo": { + "title": "リポジトリテスター", + "description": "リポジトリを取得して各プロバイダーをテストします。", + "enter_repo_url_error": "リポジトリURLを入力してください", + "invalid_url_title": "無効なURL", + "invalid_url_msg": "GitHub rawURLまたはローカルhttp(s)アドレスを使用してください。", + "manifest_build_error": "マニフェストURLを構築できません", + "manifest_fetch_error": "マニフェストの取得エラー", + "repo_manifest_fetch_error": "リポジトリマニフェストの取得エラー", + "missing_filename": "マニフェストにファイル名がありません", + "scraper_build_error": "スクレイパーURLを構築できません", + "download_scraper_error": "スクレイパーのダウンロードエラー", + "test_failed": "テストに失敗しました", + "test_parameters": "リポジトリテストパラメーター", + "test_parameters_desc": "これらのパラメーターはリポジトリテスターのみに使用されます。", + "using_info": "使用中: {{mediaType}} • TMDB {{tmdbId}}", + "using_info_tv": "使用中: {{mediaType}} • TMDB {{tmdbId}} • S{{season}}E{{episode}}", + "providers_title": "プロバイダー", + "repository_default": "リポジトリ", + "providers_count": "{{count}}件のプロバイダー", + "fetch_hint": "プロバイダーを表示するにはリポジトリを取得してください。", + "test_all": "すべてテスト", + "status_running": "実行中", + "status_ok": "OK({{count}})", + "status_ok_empty": "OK(0)", + "status_failed": "失敗", + "status_idle": "待機中", + "tried_url": "試したURL: {{url}}", + "provider_logs": "プロバイダーログ", + "no_logs_captured": "ログが記録されていません。" + } + } +} From 2888ceb710bc4a986138266e7648f4d92b1f008b Mon Sep 17 00:00:00 2001 From: Joe00011 <152079313+Joe00011@users.noreply.github.com> Date: Sun, 1 Mar 2026 02:47:04 +0900 Subject: [PATCH 06/81] Add Japanese (ja) to resources --- src/i18n/resources.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/i18n/resources.ts b/src/i18n/resources.ts index 52c1a6f1..8309d7b6 100644 --- a/src/i18n/resources.ts +++ b/src/i18n/resources.ts @@ -24,6 +24,7 @@ import nlNL from './locales/nl-NL.json'; import ro from './locales/ro.json'; import sq from './locales/sq.json'; import ca from './locales/ca.json'; +import ja from './locales/ja.json'; export const resources = { en: { translation: en }, @@ -51,4 +52,5 @@ export const resources = { ro: { translation: ro }, sq: { translation: sq }, ca: { translation: ca }, + ja: { translation: ja }, }; From d732fa2016ab0f01497dc9b271c1d497b0c3b775 Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Sun, 1 Mar 2026 00:53:06 +0530 Subject: [PATCH 07/81] fix edge cases where items sync back to trakt with "now" timestamp --- src/services/storageService.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/services/storageService.ts b/src/services/storageService.ts index 74402527..51d13605 100644 --- a/src/services/storageService.ts +++ b/src/services/storageService.ts @@ -500,7 +500,10 @@ class StorageService { traktProgress: highestTraktProgress, currentTime: highestCurrentTime, }; - await this.setWatchProgress(id, type, updatedProgress, episodeId); + // preserveTimestamp: true prevents lastUpdated from being bumped to Date.now(), + // which would make getUnsyncedProgress() think the entry needs re-syncing and + // re-add already-watched movies/episodes back to Trakt history. + await this.setWatchProgress(id, type, updatedProgress, episodeId, { preserveTimestamp: true }); } } catch (error) { logger.error('Error updating Trakt sync status:', error); @@ -540,7 +543,9 @@ class StorageService { simklProgress: highestSimklProgress, currentTime: highestCurrentTime, }; - await this.setWatchProgress(id, type, updatedProgress, episodeId); + // preserveTimestamp: true prevents lastUpdated from being bumped to Date.now(), + // which would make getUnsyncedProgress() treat synced entries as needing re-sync. + await this.setWatchProgress(id, type, updatedProgress, episodeId, { preserveTimestamp: true }); } } catch (error) { logger.error('Error updating Simkl sync status:', error); From 9d5bbaf3b1ddfa828ec613b9094828a0f7bd1e3b Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Sun, 1 Mar 2026 13:19:31 +0530 Subject: [PATCH 08/81] added local extractor --- src/services/youtubeExtractor.ts | 423 +++++++++++++++++++++++++++++++ 1 file changed, 423 insertions(+) create mode 100644 src/services/youtubeExtractor.ts diff --git a/src/services/youtubeExtractor.ts b/src/services/youtubeExtractor.ts new file mode 100644 index 00000000..c340bc0f --- /dev/null +++ b/src/services/youtubeExtractor.ts @@ -0,0 +1,423 @@ +import { logger } from '../utils/logger'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface InnertubeFormat { + itag: number; + url?: string; + signatureCipher?: string; + mimeType: string; + bitrate: number; + width?: number; + height?: number; + contentLength?: string; + quality: string; + qualityLabel?: string; + audioQuality?: string; + audioSampleRate?: string; + audioChannels?: number; + approxDurationMs?: string; + lastModified?: string; + projectionType?: string; +} + +interface InnertubeStreamingData { + formats: InnertubeFormat[]; + adaptiveFormats: InnertubeFormat[]; + expiresInSeconds?: string; +} + +interface InnertubePlayerResponse { + streamingData?: InnertubeStreamingData; + videoDetails?: { + videoId: string; + title: string; + lengthSeconds: string; + isLive?: boolean; + isLiveDvr?: boolean; + }; + playabilityStatus?: { + status: string; + reason?: string; + }; +} + +export interface ExtractedStream { + url: string; + quality: string; // e.g. "720p", "480p" + mimeType: string; // e.g. "video/mp4" + itag: number; + hasAudio: boolean; + hasVideo: boolean; + bitrate: number; +} + +export interface YouTubeExtractionResult { + streams: ExtractedStream[]; + bestStream: ExtractedStream | null; + videoId: string; + title?: string; + durationSeconds?: number; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +// Innertube client configs — we use Android (no cipher, direct URLs) +// and web as fallback (may need cipher decode) +const INNERTUBE_API_KEY = 'AIzaSyA8ggJvXiQHQFN-YMEoM30s0s3RlxEYJuA'; +const INNERTUBE_URL = 'https://www.youtube.com/youtubei/v1/player'; + +// Android client gives direct URLs without cipher obfuscation +const ANDROID_CLIENT_CONTEXT = { + client: { + clientName: 'ANDROID', + clientVersion: '19.09.37', + androidSdkVersion: 30, + userAgent: + 'com.google.android.youtube/19.09.37 (Linux; U; Android 11) gzip', + hl: 'en', + gl: 'US', + }, +}; + +// iOS client as secondary fallback +const IOS_CLIENT_CONTEXT = { + client: { + clientName: 'IOS', + clientVersion: '19.09.3', + deviceModel: 'iPhone14,3', + userAgent: + 'com.google.ios.youtube/19.09.3 (iPhone14,3; U; CPU iPhone OS 15_6 like Mac OS X)', + hl: 'en', + gl: 'US', + }, +}; + +// TV Embedded client — works for age-restricted / embed-allowed content +const TVHTML5_EMBEDDED_CONTEXT = { + client: { + clientName: 'TVHTML5_SIMPLY_EMBEDDED_PLAYER', + clientVersion: '2.0', + hl: 'en', + gl: 'US', + }, +}; + +// Preferred itags: muxed (video+audio) formats, best quality first +// These are single-file MP4s ExoPlayer can play directly +const PREFERRED_MUXED_ITAGS = [ + 22, // 720p MP4 (video+audio) + 18, // 360p MP4 (video+audio) + 59, // 480p MP4 (video+audio) — rare + 78, // 480p MP4 (video+audio) — rare + 135, // 480p video-only (fallback) + 134, // 360p video-only (fallback) +]; + +const REQUEST_TIMEOUT_MS = 12000; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function extractVideoId(input: string): string | null { + if (!input) return null; + + // Already a bare video ID (11 chars, alphanumeric + _ -) + if (/^[A-Za-z0-9_-]{11}$/.test(input.trim())) { + return input.trim(); + } + + try { + const url = new URL(input); + + // youtu.be/VIDEO_ID + if (url.hostname === 'youtu.be') { + const id = url.pathname.slice(1).split('/')[0]; + if (id && /^[A-Za-z0-9_-]{11}$/.test(id)) return id; + } + + // youtube.com/watch?v=VIDEO_ID + const v = url.searchParams.get('v'); + if (v && /^[A-Za-z0-9_-]{11}$/.test(v)) return v; + + // youtube.com/embed/VIDEO_ID or /shorts/VIDEO_ID + const pathMatch = url.pathname.match(/\/(embed|shorts|v)\/([A-Za-z0-9_-]{11})/); + if (pathMatch) return pathMatch[2]; + } catch { + // Not a valid URL — try regex fallback + const match = input.match(/[?&]v=([A-Za-z0-9_-]{11})/); + if (match) return match[1]; + } + + return null; +} + +function parseMimeType(mimeType: string): { container: string; codecs: string } { + // e.g. 'video/mp4; codecs="avc1.64001F, mp4a.40.2"' + const [base, codecsPart] = mimeType.split(';'); + const container = base.trim(); + const codecs = codecsPart ? codecsPart.replace(/codecs=["']?/i, '').replace(/["']$/, '').trim() : ''; + return { container, codecs }; +} + +function isMuxedFormat(format: InnertubeFormat): boolean { + // A muxed format has both video and audio codecs in its mimeType + const { codecs } = parseMimeType(format.mimeType); + // MP4 muxed: "avc1.xxx, mp4a.xxx" + // WebM muxed: "vp8, vorbis" etc. + return codecs.includes(',') || (!!format.audioQuality && !!format.qualityLabel); +} + +function isVideoMp4(format: InnertubeFormat): boolean { + return format.mimeType.startsWith('video/mp4'); +} + +function formatQualityLabel(format: InnertubeFormat): string { + return format.qualityLabel || format.quality || 'unknown'; +} + +function scoreFormat(format: InnertubeFormat): number { + // Prioritise: + // 1. Preferred itags (pre-muxed MP4 with audio) + // 2. Height (higher = better, but cap at 720 for stability) + // 3. Bitrate + const preferredIndex = PREFERRED_MUXED_ITAGS.indexOf(format.itag); + const itagBonus = preferredIndex !== -1 ? (PREFERRED_MUXED_ITAGS.length - preferredIndex) * 10000 : 0; + const height = format.height ?? 0; + // Don't prefer > 720p because those are usually adaptive-only + const heightScore = Math.min(height, 720) * 10; + const bitrateScore = Math.min(format.bitrate ?? 0, 3_000_000) / 1000; + return itagBonus + heightScore + bitrateScore; +} + +// --------------------------------------------------------------------------- +// Core extractor +// --------------------------------------------------------------------------- + +async function fetchPlayerResponse( + videoId: string, + context: object, + userAgent: string +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + + try { + const body = { + videoId, + context, + contentCheckOk: true, + racyCheckOk: true, + }; + + const response = await fetch( + `${INNERTUBE_URL}?key=${INNERTUBE_API_KEY}&prettyPrint=false`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': userAgent, + 'X-YouTube-Client-Name': '3', + 'Origin': 'https://www.youtube.com', + 'Referer': `https://www.youtube.com/watch?v=${videoId}`, + }, + body: JSON.stringify(body), + signal: controller.signal, + } + ); + + clearTimeout(timer); + + if (!response.ok) { + logger.warn('YouTubeExtractor', `Innertube HTTP ${response.status} for videoId=${videoId}`); + return null; + } + + const data: InnertubePlayerResponse = await response.json(); + return data; + } catch (err) { + clearTimeout(timer); + if (err instanceof Error && err.name === 'AbortError') { + logger.warn('YouTubeExtractor', `Request timed out for videoId=${videoId}`); + } else { + logger.warn('YouTubeExtractor', `Fetch error for videoId=${videoId}:`, err); + } + return null; + } +} + +function parseFormats(playerResponse: InnertubePlayerResponse): InnertubeFormat[] { + const sd = playerResponse.streamingData; + if (!sd) return []; + + const formats: InnertubeFormat[] = []; + + // Include muxed formats (video+audio in one file) + for (const f of sd.formats ?? []) { + if (f.url) formats.push(f); + } + + // Also scan adaptiveFormats for any that happen to have a direct URL + // and look muxed (edge case but occasionally seen) + for (const f of sd.adaptiveFormats ?? []) { + if (f.url && isMuxedFormat(f)) formats.push(f); + } + + return formats; +} + +function pickBestStream(formats: InnertubeFormat[]): ExtractedStream | null { + if (formats.length === 0) return null; + + // Filter to MP4 only for maximum ExoPlayer compatibility + const mp4Formats = formats.filter(isVideoMp4); + const pool = mp4Formats.length > 0 ? mp4Formats : formats; + + // Sort by score descending + const sorted = [...pool].sort((a, b) => scoreFormat(b) - scoreFormat(a)); + const best = sorted[0]; + + return { + url: best.url!, + quality: formatQualityLabel(best), + mimeType: best.mimeType, + itag: best.itag, + hasAudio: !!best.audioQuality || isMuxedFormat(best), + hasVideo: !!best.qualityLabel || best.mimeType.startsWith('video/'), + bitrate: best.bitrate ?? 0, + }; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export class YouTubeExtractor { + /** + * Extract a playable stream URL from a YouTube video ID or URL. + * Tries Android client first (no cipher), then iOS, then TV embedded. + * Returns null if all attempts fail. + */ + static async extract(videoIdOrUrl: string): Promise { + const videoId = extractVideoId(videoIdOrUrl); + if (!videoId) { + logger.warn('YouTubeExtractor', `Could not parse video ID from: ${videoIdOrUrl}`); + return null; + } + + logger.info('YouTubeExtractor', `Extracting streams for videoId=${videoId}`); + + // Try each client in order until we get usable formats + const clients: Array<{ context: object; userAgent: string; name: string }> = [ + { + name: 'ANDROID', + context: ANDROID_CLIENT_CONTEXT, + userAgent: + 'com.google.android.youtube/19.09.37 (Linux; U; Android 11) gzip', + }, + { + name: 'IOS', + context: IOS_CLIENT_CONTEXT, + userAgent: + 'com.google.ios.youtube/19.09.3 (iPhone14,3; U; CPU iPhone OS 15_6 like Mac OS X)', + }, + { + name: 'TVHTML5_EMBEDDED', + context: TVHTML5_EMBEDDED_CONTEXT, + userAgent: 'Mozilla/5.0 (SMART-TV; Linux; Tizen 6.0)', + }, + ]; + + let bestFormats: InnertubeFormat[] = []; + let playerResponse: InnertubePlayerResponse | null = null; + + for (const client of clients) { + logger.info('YouTubeExtractor', `Trying ${client.name} client...`); + const resp = await fetchPlayerResponse(videoId, client.context, client.userAgent); + + if (!resp) continue; + + const status = resp.playabilityStatus?.status; + if (status === 'UNPLAYABLE' || status === 'LOGIN_REQUIRED') { + logger.warn( + 'YouTubeExtractor', + `${client.name} got playabilityStatus=${status} (${resp.playabilityStatus?.reason ?? ''})` + ); + continue; + } + + const formats = parseFormats(resp); + if (formats.length > 0) { + logger.info( + 'YouTubeExtractor', + `${client.name} returned ${formats.length} usable formats` + ); + bestFormats = formats; + playerResponse = resp; + break; + } + + logger.warn('YouTubeExtractor', `${client.name} returned no direct-URL formats`); + } + + if (bestFormats.length === 0) { + logger.warn('YouTubeExtractor', `All clients failed for videoId=${videoId}`); + return null; + } + + const streams: ExtractedStream[] = bestFormats.map((f) => ({ + url: f.url!, + quality: formatQualityLabel(f), + mimeType: f.mimeType, + itag: f.itag, + hasAudio: !!f.audioQuality || isMuxedFormat(f), + hasVideo: !!f.qualityLabel || f.mimeType.startsWith('video/'), + bitrate: f.bitrate ?? 0, + })); + + const bestStream = pickBestStream(bestFormats); + + const details = playerResponse?.videoDetails; + const result: YouTubeExtractionResult = { + streams, + bestStream, + videoId, + title: details?.title, + durationSeconds: details?.lengthSeconds + ? parseInt(details.lengthSeconds, 10) + : undefined, + }; + + if (bestStream) { + logger.info( + 'YouTubeExtractor', + `Best stream: itag=${bestStream.itag} quality=${bestStream.quality} mimeType=${bestStream.mimeType}` + ); + } + + return result; + } + + /** + * Convenience method — returns just the best playable URL or null. + */ + static async getBestStreamUrl(videoIdOrUrl: string): Promise { + const result = await this.extract(videoIdOrUrl); + return result?.bestStream?.url ?? null; + } + + /** + * Parse a video ID from any YouTube URL format or bare ID. + * Exposed so callers can validate IDs before calling extract(). + */ + static parseVideoId(input: string): string | null { + return extractVideoId(input); + } +} + +export default YouTubeExtractor; From 9a4af754f04034a1a581d9b00523147bca215c87 Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Sun, 1 Mar 2026 13:21:02 +0530 Subject: [PATCH 09/81] local extractor --- src/services/trailerService.ts | 434 +++++++++------------------------ 1 file changed, 110 insertions(+), 324 deletions(-) diff --git a/src/services/trailerService.ts b/src/services/trailerService.ts index 1b7f4bbf..5044a93b 100644 --- a/src/services/trailerService.ts +++ b/src/services/trailerService.ts @@ -1,4 +1,5 @@ import { logger } from '../utils/logger'; +import { YouTubeExtractor } from './youtubeExtractor'; export interface TrailerData { url: string; @@ -6,373 +7,158 @@ export interface TrailerData { year: number; } -export class TrailerService { - // Environment-configurable values (Expo public env) - private static readonly ENV_LOCAL_BASE = process.env.EXPO_PUBLIC_TRAILER_LOCAL_BASE || 'http://46.62.173.157:3001'; - private static readonly ENV_LOCAL_TRAILER_PATH = process.env.EXPO_PUBLIC_TRAILER_LOCAL_TRAILER_PATH || '/trailer'; - private static readonly ENV_LOCAL_SEARCH_PATH = process.env.EXPO_PUBLIC_TRAILER_LOCAL_SEARCH_PATH || '/search-trailer'; +interface CacheEntry { + url: string; + expiresAt: number; +} - private static readonly LOCAL_SERVER_URL = `${TrailerService.ENV_LOCAL_BASE}${TrailerService.ENV_LOCAL_TRAILER_PATH}`; - private static readonly AUTO_SEARCH_URL = `${TrailerService.ENV_LOCAL_BASE}${TrailerService.ENV_LOCAL_SEARCH_PATH}`; - private static readonly TIMEOUT = 20000; // 20 seconds +export class TrailerService { + // YouTube CDN URLs expire ~6h; cache for 5h + private static readonly CACHE_TTL_MS = 5 * 60 * 60 * 1000; + private static urlCache = new Map(); + + // --------------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------------- /** - * Fetches trailer URL for a given title and year - * @param title - The movie/series title - * @param year - The release year - * @param tmdbId - Optional TMDB ID for more accurate results - * @param type - Optional content type ('movie' or 'tv') - * @returns Promise - The trailer URL or null if not found + * Get a playable stream URL from a raw YouTube video ID (e.g. from TMDB). + * Pure on-device extraction via Innertube. No server involved. */ - static async getTrailerUrl(title: string, year: number, tmdbId?: string, type?: 'movie' | 'tv'): Promise { - logger.info('TrailerService', `getTrailerUrl requested: title="${title}", year=${year}, tmdbId=${tmdbId || 'n/a'}, type=${type || 'n/a'}`); - return this.getTrailerFromLocalServer(title, year, tmdbId, type); + static async getTrailerFromVideoId( + youtubeVideoId: string, + title?: string, + year?: number + ): Promise { + if (!youtubeVideoId) return null; + + logger.info('TrailerService', `getTrailerFromVideoId: ${youtubeVideoId} (${title ?? '?'} ${year ?? ''})`); + + const cached = this.getCached(youtubeVideoId); + if (cached) { + logger.info('TrailerService', `Cache hit for videoId=${youtubeVideoId}`); + return cached; + } + + try { + const url = await YouTubeExtractor.getBestStreamUrl(youtubeVideoId); + if (url) { + logger.info('TrailerService', `On-device extraction succeeded for ${youtubeVideoId}`); + this.setCache(youtubeVideoId, url); + return url; + } + } catch (err) { + logger.warn('TrailerService', `On-device extraction threw for ${youtubeVideoId}:`, err); + } + + logger.warn('TrailerService', `Extraction failed for ${youtubeVideoId}`); + return null; } /** - * Fetches trailer from local server using TMDB API or auto-search - * @param title - The movie/series title - * @param year - The release year - * @param tmdbId - Optional TMDB ID for more accurate results - * @param type - Optional content type ('movie' or 'tv') - * @returns Promise - The trailer URL or null if not found + * Called by TrailerModal which has the full YouTube URL from TMDB. + * Parses the video ID then delegates to getTrailerFromVideoId. */ - private static async getTrailerFromLocalServer(title: string, year: number, tmdbId?: string, type?: 'movie' | 'tv'): Promise { - const startTime = Date.now(); - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), this.TIMEOUT); + static async getTrailerFromYouTubeUrl( + youtubeUrl: string, + title?: string, + year?: string + ): Promise { + logger.info('TrailerService', `getTrailerFromYouTubeUrl: ${youtubeUrl}`); - // Build URL with parameters - const params = new URLSearchParams(); - - // Always send title and year for logging and fallback - params.append('title', title); - params.append('year', year.toString()); - - if (tmdbId) { - params.append('tmdbId', tmdbId); - params.append('type', type || 'movie'); - logger.info('TrailerService', `Using TMDB API for: ${title} (TMDB ID: ${tmdbId})`); - } else { - logger.info('TrailerService', `Auto-searching trailer for: ${title} (${year})`); - } - - const url = `${this.AUTO_SEARCH_URL}?${params.toString()}`; - logger.info('TrailerService', `Local server request URL: ${url}`); - logger.info('TrailerService', `Local server timeout set to ${this.TIMEOUT}ms`); - logger.info('TrailerService', `Making fetch request to: ${url}`); - - try { - - const response = await fetch(url, { - method: 'GET', - headers: { - 'Accept': 'application/json', - 'User-Agent': 'Nuvio/1.0', - }, - signal: controller.signal, - }); - - // logger.info('TrailerService', `Fetch request completed. Response status: ${response.status}`); - - clearTimeout(timeoutId); - - const elapsed = Date.now() - startTime; - const contentType = response.headers.get('content-type') || 'unknown'; - // logger.info('TrailerService', `Local server response: status=${response.status} ok=${response.ok} content-type=${contentType} elapsedMs=${elapsed}`); - - // Read body as text first so we can log it even on non-200s - let rawText = ''; - try { - rawText = await response.text(); - if (rawText) { - /* - const preview = rawText.length > 200 ? `${rawText.slice(0, 200)}...` : rawText; - logger.info('TrailerService', `Local server body preview: ${preview}`); - */ - } else { - // logger.info('TrailerService', 'Local server body is empty'); - } - } catch (e) { - const msg = e instanceof Error ? `${e.name}: ${e.message}` : String(e); - logger.warn('TrailerService', `Failed reading local server body text: ${msg}`); - } - - if (!response.ok) { - logger.warn('TrailerService', `Auto-search failed: ${response.status} ${response.statusText}`); - return null; - } - - // Attempt to parse JSON from the raw text - let data: any = null; - try { - data = rawText ? JSON.parse(rawText) : null; - // const keys = typeof data === 'object' && data !== null ? Object.keys(data).join(',') : typeof data; - // logger.info('TrailerService', `Local server JSON parsed. Keys/Type: ${keys}`); - } catch (e) { - const msg = e instanceof Error ? `${e.name}: ${e.message}` : String(e); - logger.warn('TrailerService', `Failed to parse local server JSON: ${msg}`); - return null; - } - - if (!data.url || !this.isValidTrailerUrl(data.url)) { - logger.warn('TrailerService', `Invalid trailer URL from auto-search: ${data.url}`); - return null; - } - - // logger.info('TrailerService', `Successfully found trailer: ${String(data.url).substring(0, 80)}...`); - return data.url; - } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { - logger.warn('TrailerService', `Auto-search request timed out after ${this.TIMEOUT}ms`); - } else { - const msg = error instanceof Error ? `${error.name}: ${error.message}` : String(error); - logger.error('TrailerService', `Error in auto-search: ${msg}`); - logger.error('TrailerService', `Error details:`, { - name: (error as any)?.name, - message: (error as any)?.message, - stack: (error as any)?.stack, - url: url - }); - } + const videoId = YouTubeExtractor.parseVideoId(youtubeUrl); + if (!videoId) { + logger.warn('TrailerService', `Could not parse video ID from: ${youtubeUrl}`); return null; } + + return this.getTrailerFromVideoId( + videoId, + title, + year ? parseInt(year, 10) : undefined + ); } /** - * Validates if the provided string is a valid trailer URL - * @param url - The URL to validate - * @returns boolean - True if valid, false otherwise + * Called by AppleTVHero and HeroSection which only have title/year/tmdbId. + * These callers need to be updated to pass the YouTube video ID from TMDB + * instead and call getTrailerFromVideoId directly. Until then this returns null. */ - private static isValidTrailerUrl(url: string): boolean { - try { - const urlObj = new URL(url); - - // Check if it's a valid HTTP/HTTPS URL - if (!['http:', 'https:'].includes(urlObj.protocol)) { - return false; - } - - // Check for common video streaming domains/patterns - const validDomains = [ - 'theplatform.com', - 'youtube.com', - 'youtu.be', - 'vimeo.com', - 'dailymotion.com', - 'twitch.tv', - 'amazonaws.com', - 'cloudfront.net', - 'googlevideo.com', // Google's CDN for YouTube videos - 'sn-aigl6nzr.googlevideo.com', // Specific Google CDN servers - 'sn-aigl6nze.googlevideo.com', - 'sn-aigl6nsk.googlevideo.com', - 'sn-aigl6ns6.googlevideo.com' - ]; - - const hostname = urlObj.hostname.toLowerCase(); - const isValidDomain = validDomains.some(domain => - hostname.includes(domain) || hostname.endsWith(domain) - ); - - // Special check for Google Video CDN (YouTube direct streaming URLs) - const isGoogleVideoCDN = hostname.includes('googlevideo.com') || - hostname.includes('sn-') && hostname.includes('.googlevideo.com'); - - // Check for video file extensions or streaming formats - const hasVideoFormat = /\.(mp4|m3u8|mpd|webm|mov|avi|mkv)$/i.test(urlObj.pathname) || - url.includes('formats=') || - url.includes('manifest') || - url.includes('playlist'); - - return isValidDomain || hasVideoFormat || isGoogleVideoCDN; - } catch { - return false; - } + static async getTrailerUrl( + title: string, + year: number, + _tmdbId?: string, + _type?: 'movie' | 'tv' + ): Promise { + logger.warn( + 'TrailerService', + `getTrailerUrl called for "${title}" but no YouTube video ID was provided. ` + + `Update caller to fetch the YouTube video ID from TMDB and call getTrailerFromVideoId instead.` + ); + return null; } - /** - * Extracts the best video format URL from a multi-format URL - * @param url - The trailer URL that may contain multiple formats - * @returns string - The best format URL for mobile playback - */ + // --------------------------------------------------------------------------- + // Unchanged public helpers (API compatibility) + // --------------------------------------------------------------------------- + + /** Legacy format URL helper kept for API compatibility. */ static getBestFormatUrl(url: string): string { - // If the URL contains format parameters, try to get the best one for mobile if (url.includes('formats=')) { - // Prefer M3U (HLS) for better mobile compatibility if (url.includes('M3U')) { - // Try to get M3U without encryption first, then with encryption - const baseUrl = url.split('?')[0]; - const best = `${baseUrl}?formats=M3U+none,M3U+appleHlsEncryption`; - logger.info('TrailerService', `Optimized format URL from M3U: ${best.substring(0, 80)}...`); - return best; + return `${url.split('?')[0]}?formats=M3U+none,M3U+appleHlsEncryption`; } - // Fallback to MP4 if available if (url.includes('MPEG4')) { - const baseUrl = url.split('?')[0]; - const best = `${baseUrl}?formats=MPEG4`; - logger.info('TrailerService', `Optimized format URL from MPEG4: ${best.substring(0, 80)}...`); - return best; + return `${url.split('?')[0]}?formats=MPEG4`; } } - - // Return the original URL if no format optimization is needed - // logger.info('TrailerService', 'No format optimization applied'); return url; } - /** - * Checks if a trailer is available for the given title and year - * @param title - The movie/series title - * @param year - The release year - * @returns Promise - True if trailer is available - */ - static async isTrailerAvailable(title: string, year: number): Promise { - logger.info('TrailerService', `Checking trailer availability for: ${title} (${year})`); - const trailerUrl = await this.getTrailerUrl(title, year); - logger.info('TrailerService', `Trailer availability for ${title} (${year}): ${trailerUrl ? 'available' : 'not available'}`); - return trailerUrl !== null; + static async isTrailerAvailable(videoId: string): Promise { + return (await this.getTrailerFromVideoId(videoId)) !== null; } - /** - * Gets trailer data with additional metadata - * @param title - The movie/series title - * @param year - The release year - * @returns Promise - Trailer data or null if not found - */ static async getTrailerData(title: string, year: number): Promise { - logger.info('TrailerService', `getTrailerData for: ${title} (${year})`); - const url = await this.getTrailerUrl(title, year); - - if (!url) { - logger.info('TrailerService', 'No trailer URL found for getTrailerData'); - return null; - } - - return { - url: this.getBestFormatUrl(url), - title, - year - }; + logger.warn('TrailerService', `getTrailerData: no video ID available for "${title}"`); + return null; } - /** - * Fetches trailer directly from a known YouTube URL - * @param youtubeUrl - The YouTube URL to process - * @param title - Optional title for logging/caching - * @param year - Optional year for logging/caching - * @returns Promise - The direct streaming URL or null if failed - */ - static async getTrailerFromYouTubeUrl(youtubeUrl: string, title?: string, year?: string): Promise { - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), this.TIMEOUT); - - const params = new URLSearchParams(); - params.append('youtube_url', youtubeUrl); - if (title) params.append('title', title); - if (year) params.append('year', year.toString()); - - const url = `${this.ENV_LOCAL_BASE}${this.ENV_LOCAL_TRAILER_PATH}?${params.toString()}`; - logger.info('TrailerService', `Fetching trailer directly from YouTube URL: ${youtubeUrl}`); - logger.info('TrailerService', `Direct trailer request URL: ${url}`); - - const response = await fetch(url, { - method: 'GET', - headers: { - 'Accept': 'application/json', - 'User-Agent': 'Nuvio/1.0', - }, - signal: controller.signal, - }); - - clearTimeout(timeoutId); - - logger.info('TrailerService', `Direct trailer response: status=${response.status} ok=${response.ok}`); - - if (!response.ok) { - logger.warn('TrailerService', `Direct trailer failed: ${response.status} ${response.statusText}`); - return null; - } - - const data = await response.json(); - - if (!data.url || !this.isValidTrailerUrl(data.url)) { - logger.warn('TrailerService', `Invalid trailer URL from direct fetch: ${data.url}`); - return null; - } - - logger.info('TrailerService', `Successfully got direct trailer: ${String(data.url).substring(0, 80)}...`); - return data.url; - } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { - logger.warn('TrailerService', `Direct trailer request timed out after ${this.TIMEOUT}ms`); - } else { - const msg = error instanceof Error ? `${error.name}: ${error.message}` : String(error); - logger.error('TrailerService', `Error in direct trailer fetch: ${msg}`); - } - return null; - } + static setUseLocalServer(_useLocal: boolean): void { + logger.info('TrailerService', 'setUseLocalServer: no server used, on-device only'); } - /** - * Switch between local server (deprecated - always uses local server now) - * @param useLocal - true for local server (always true now) - */ - static setUseLocalServer(useLocal: boolean): void { - if (!useLocal) { - logger.warn('TrailerService', 'XPrime API is no longer supported. Always using local server.'); - } - logger.info('TrailerService', 'Using local server'); - } - - /** - * Get current server status - * @returns object with server information - */ static getServerStatus(): { usingLocal: boolean; localUrl: string } { - return { - usingLocal: true, - localUrl: this.LOCAL_SERVER_URL, - }; + return { usingLocal: false, localUrl: '' }; } - /** - * Test local server and return its status - * @returns Promise with server status information - */ static async testServers(): Promise<{ localServer: { status: 'online' | 'offline'; responseTime?: number }; }> { - logger.info('TrailerService', 'Testing local server'); - const results: { - localServer: { status: 'online' | 'offline'; responseTime?: number }; - } = { - localServer: { status: 'offline' } - }; + return { localServer: { status: 'offline' } }; + } - // Test local server - try { - const startTime = Date.now(); - const response = await fetch(`${this.AUTO_SEARCH_URL}?title=test&year=2023`, { - method: 'GET', - signal: AbortSignal.timeout(5000) // 5 second timeout - }); - if (response.ok || response.status === 404) { // 404 is ok, means server is running - results.localServer = { - status: 'online', - responseTime: Date.now() - startTime - }; - logger.info('TrailerService', `Local server online. Response time: ${results.localServer.responseTime}ms`); - } - } catch (error) { - const msg = error instanceof Error ? `${error.name}: ${error.message}` : String(error); - logger.warn('TrailerService', `Local server test failed: ${msg}`); + // --------------------------------------------------------------------------- + // Private cache + // --------------------------------------------------------------------------- + + private static getCached(key: string): string | null { + const entry = this.urlCache.get(key); + if (!entry) return null; + if (Date.now() > entry.expiresAt) { + this.urlCache.delete(key); + return null; } + return entry.url; + } - logger.info('TrailerService', `Server test results -> local: ${results.localServer.status}`); - return results; + private static setCache(key: string, url: string): void { + this.urlCache.set(key, { url, expiresAt: Date.now() + this.CACHE_TTL_MS }); + if (this.urlCache.size > 100) { + const oldest = this.urlCache.keys().next().value; + if (oldest) this.urlCache.delete(oldest); + } } } -export default TrailerService; \ No newline at end of file +export default TrailerService; From 9134c3eb95ec29bb2a23c3583cdd098bd283b3d5 Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Sun, 1 Mar 2026 13:23:31 +0530 Subject: [PATCH 10/81] local trailers for herosection --- src/components/home/AppleTVHero.tsx | 63 ++++++++++++++++++++++------- 1 file changed, 48 insertions(+), 15 deletions(-) diff --git a/src/components/home/AppleTVHero.tsx b/src/components/home/AppleTVHero.tsx index 7de8e8b0..5192ac34 100644 --- a/src/components/home/AppleTVHero.tsx +++ b/src/components/home/AppleTVHero.tsx @@ -440,35 +440,68 @@ const AppleTVHero: React.FC = ({ thumbnailOpacity.value = withTiming(1, { duration: 300 }); try { - // Extract year from metadata - const year = currentItem.releaseInfo - ? parseInt(currentItem.releaseInfo.split('-')[0], 10) - : new Date().getFullYear(); - // Extract TMDB ID if available const tmdbId = currentItem.id?.startsWith('tmdb:') ? currentItem.id.replace('tmdb:', '') : undefined; + if (!tmdbId) { + logger.info('[AppleTVHero] No TMDB ID for:', currentItem.name, '- skipping trailer'); + setTrailerUrl(null); + setTrailerLoading(false); + return; + } + const contentType = currentItem.type === 'series' ? 'tv' : 'movie'; - logger.info('[AppleTVHero] Fetching trailer for:', currentItem.name, year, tmdbId); + logger.info('[AppleTVHero] Fetching TMDB videos for:', currentItem.name, 'tmdbId:', tmdbId); - const url = await TrailerService.getTrailerUrl( - currentItem.name, - year, - tmdbId, - contentType + // Fetch video list from TMDB to get the YouTube video ID + const videosRes = await fetch( + `https://api.themoviedb.org/3/${contentType}/${tmdbId}/videos?api_key=d131017ccc6e5462a81c9304d21476de&language=en-US` + ); + + if (!alive) return; + + if (!videosRes.ok) { + logger.warn('[AppleTVHero] TMDB videos fetch failed:', videosRes.status); + setTrailerUrl(null); + setTrailerLoading(false); + return; + } + + const videosData = await videosRes.json(); + const results: any[] = videosData.results ?? []; + + // Pick best YouTube trailer: official trailer > any trailer > teaser > any YouTube video + const pick = + results.find((v) => v.site === 'YouTube' && v.type === 'Trailer' && v.official) ?? + results.find((v) => v.site === 'YouTube' && v.type === 'Trailer') ?? + results.find((v) => v.site === 'YouTube' && v.type === 'Teaser') ?? + results.find((v) => v.site === 'YouTube'); + + if (!alive) return; + + if (!pick) { + logger.info('[AppleTVHero] No YouTube video found for:', currentItem.name); + setTrailerUrl(null); + setTrailerLoading(false); + return; + } + + logger.info('[AppleTVHero] Extracting stream for videoId:', pick.key, currentItem.name); + + const url = await TrailerService.getTrailerFromVideoId( + pick.key, + currentItem.name ); if (!alive) return; if (url) { - const bestUrl = TrailerService.getBestFormatUrl(url); - setTrailerUrl(bestUrl); - // logger.info('[AppleTVHero] Trailer URL loaded:', bestUrl); + setTrailerUrl(url); } else { - logger.info('[AppleTVHero] No trailer found for:', currentItem.name); + logger.info('[AppleTVHero] No stream extracted for:', currentItem.name); setTrailerUrl(null); } } catch (error) { From 47a484fe3d454d03761d70d1b249474da94a3a31 Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Sun, 1 Mar 2026 13:24:33 +0530 Subject: [PATCH 11/81] local trailers --- src/components/metadata/HeroSection.tsx | 109 ++++++++++++++---------- 1 file changed, 63 insertions(+), 46 deletions(-) diff --git a/src/components/metadata/HeroSection.tsx b/src/components/metadata/HeroSection.tsx index 911986c1..67f6221c 100644 --- a/src/components/metadata/HeroSection.tsx +++ b/src/components/metadata/HeroSection.tsx @@ -1129,12 +1129,14 @@ const HeroSection: React.FC = memo(({ useEffect(() => { let alive = true as boolean; let timerId: any = null; - const fetchTrailer = async () => { - if (!metadata?.name || !metadata?.year || !settings?.showTrailers || !isFocused) return; - // If we expect TMDB ID but don't have it yet, wait a bit more - if (!metadata?.tmdbId && metadata?.id?.startsWith('tmdb:')) { - logger.info('HeroSection', `Waiting for TMDB ID for ${metadata.name}`); + const fetchTrailer = async () => { + if (!metadata?.name || !settings?.showTrailers || !isFocused) return; + + // Need a TMDB ID to look up the YouTube video ID + const resolvedTmdbId = tmdbId ? String(tmdbId) : undefined; + if (!resolvedTmdbId) { + logger.info('HeroSection', `No TMDB ID for ${metadata.name} - skipping trailer`); return; } @@ -1143,51 +1145,66 @@ const HeroSection: React.FC = memo(({ setTrailerReady(false); setTrailerPreloaded(false); - try { - // Use requestIdleCallback or setTimeout to prevent blocking main thread - const fetchWithDelay = () => { - // Extract TMDB ID if available - const tmdbIdString = tmdbId ? String(tmdbId) : undefined; + // Small delay to avoid blocking the UI render + timerId = setTimeout(async () => { + if (!alive) return; + + try { const contentType = type === 'series' ? 'tv' : 'movie'; - // Debug logging to see what we have - logger.info('HeroSection', `Trailer request for ${metadata.name}:`, { - hasTmdbId: !!tmdbId, - tmdbId: tmdbId, - contentType, - metadataKeys: Object.keys(metadata || {}), - metadataId: metadata?.id - }); + logger.info('HeroSection', `Fetching TMDB videos for ${metadata.name} (tmdbId: ${resolvedTmdbId})`); - TrailerService.getTrailerUrl(metadata.name, metadata.year, tmdbIdString, contentType) - .then(url => { - if (url) { - const bestUrl = TrailerService.getBestFormatUrl(url); - setTrailerUrl(bestUrl); - logger.info('HeroSection', `Trailer URL loaded for ${metadata.name}${tmdbId ? ` (TMDB: ${tmdbId})` : ''}`); - } else { - logger.info('HeroSection', `No trailer found for ${metadata.name}`); - } - }) - .catch(error => { - logger.error('HeroSection', 'Error fetching trailer:', error); - setTrailerError(true); - }) - .finally(() => { - setTrailerLoading(false); - }); - }; + // Fetch video list from TMDB to get the YouTube video ID + const videosRes = await fetch( + `https://api.themoviedb.org/3/${contentType}/${resolvedTmdbId}/videos?api_key=d131017ccc6e5462a81c9304d21476de&language=en-US` + ); - // Delay trailer fetch to prevent blocking UI - timerId = setTimeout(() => { if (!alive) return; - fetchWithDelay(); - }, 100); - } catch (error) { - logger.error('HeroSection', 'Error in trailer fetch setup:', error); - setTrailerError(true); - setTrailerLoading(false); - } + + if (!videosRes.ok) { + logger.warn('HeroSection', `TMDB videos fetch failed: ${videosRes.status} for ${metadata.name}`); + setTrailerLoading(false); + return; + } + + const videosData = await videosRes.json(); + const results: any[] = videosData.results ?? []; + + // Pick best YouTube trailer: official trailer > any trailer > teaser > any YouTube video + const pick = + results.find((v) => v.site === 'YouTube' && v.type === 'Trailer' && v.official) ?? + results.find((v) => v.site === 'YouTube' && v.type === 'Trailer') ?? + results.find((v) => v.site === 'YouTube' && v.type === 'Teaser') ?? + results.find((v) => v.site === 'YouTube'); + + if (!alive) return; + + if (!pick) { + logger.info('HeroSection', `No YouTube video found for ${metadata.name}`); + setTrailerLoading(false); + return; + } + + logger.info('HeroSection', `Extracting stream for videoId: ${pick.key} (${metadata.name})`); + + const url = await TrailerService.getTrailerFromVideoId(pick.key, metadata.name); + + if (!alive) return; + + if (url) { + setTrailerUrl(url); + logger.info('HeroSection', `Trailer loaded for ${metadata.name}`); + } else { + logger.info('HeroSection', `No stream extracted for ${metadata.name}`); + } + } catch (error) { + if (!alive) return; + logger.error('HeroSection', 'Error fetching trailer:', error); + setTrailerError(true); + } finally { + if (alive) setTrailerLoading(false); + } + }, 100); }; fetchTrailer(); @@ -1195,7 +1212,7 @@ const HeroSection: React.FC = memo(({ alive = false; try { if (timerId) clearTimeout(timerId); } catch (_e) { } }; - }, [metadata?.name, metadata?.year, tmdbId, settings?.showTrailers, isFocused]); + }, [metadata?.name, tmdbId, settings?.showTrailers, isFocused]); // Shimmer animation removed From 5b6554ff37d789669bb8627221641aeb2db9e2a3 Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Sun, 1 Mar 2026 15:05:10 +0530 Subject: [PATCH 12/81] fix trailers in homescreen hero --- src/components/home/AppleTVHero.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/components/home/AppleTVHero.tsx b/src/components/home/AppleTVHero.tsx index 5192ac34..8b3e7226 100644 --- a/src/components/home/AppleTVHero.tsx +++ b/src/components/home/AppleTVHero.tsx @@ -524,10 +524,17 @@ const AppleTVHero: React.FC = ({ }, [currentItem, currentIndex]); // Removed settings?.showTrailers from dependencies // Handle trailer preloaded + // FIX: Set global trailer playing to true HERE — before the visible player mounts — + // so that when the visible player's autoPlay prop is evaluated it is already true, + // eliminating the race condition that previously caused the global state effect in + // TrailerPlayer to immediately pause the video on first render. const handleTrailerPreloaded = useCallback(() => { + if (isFocused && !isOutOfView && !trailerShouldBePaused) { + setTrailerPlaying(true); + } setTrailerPreloaded(true); logger.info('[AppleTVHero] Trailer preloaded successfully'); - }, []); + }, [isFocused, isOutOfView, trailerShouldBePaused, setTrailerPlaying]); // Handle trailer ready to play const handleTrailerReady = useCallback(() => { @@ -1111,7 +1118,7 @@ const AppleTVHero: React.FC = ({ key={`visible-${trailerUrl}`} ref={trailerVideoRef} trailerUrl={trailerUrl} - autoPlay={globalTrailerPlaying} + autoPlay={!trailerShouldBePaused} muted={trailerMuted} style={StyleSheet.absoluteFillObject} hideLoadingSpinner={true} From c764faf2a74847a300f09152b463df0ea8a3b003 Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Sun, 1 Mar 2026 15:06:50 +0530 Subject: [PATCH 13/81] fix race conditions --- src/components/video/TrailerPlayer.tsx | 29 ++++++++++++++++++-------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/src/components/video/TrailerPlayer.tsx b/src/components/video/TrailerPlayer.tsx index de8a7b91..cd5d9e75 100644 --- a/src/components/video/TrailerPlayer.tsx +++ b/src/components/video/TrailerPlayer.tsx @@ -75,6 +75,11 @@ const TrailerPlayer = React.forwardRef(({ const [isFullscreen, setIsFullscreen] = useState(false); const [isComponentMounted, setIsComponentMounted] = useState(true); + // FIX: Track whether this player has ever been in a playing state. + // This prevents the globalTrailerPlaying effect from suppressing the + // very first play attempt before the global state has been set to true. + const hasBeenPlayingRef = useRef(false); + // Animated values const controlsOpacity = useSharedValue(0); const loadingOpacity = useSharedValue(1); @@ -150,6 +155,7 @@ const TrailerPlayer = React.forwardRef(({ useEffect(() => { if (isComponentMounted && paused === undefined) { setIsPlaying(autoPlay); + if (autoPlay) hasBeenPlayingRef.current = true; } }, [autoPlay, isComponentMounted, paused]); @@ -163,18 +169,23 @@ const TrailerPlayer = React.forwardRef(({ // Handle external paused prop to override playing state (highest priority) useEffect(() => { if (paused !== undefined) { - setIsPlaying(!paused); - logger.info('TrailerPlayer', `External paused prop changed: ${paused}, setting isPlaying to ${!paused}`); + const shouldPlay = !paused; + setIsPlaying(shouldPlay); + if (shouldPlay) hasBeenPlayingRef.current = true; + logger.info('TrailerPlayer', `External paused prop changed: ${paused}, setting isPlaying to ${shouldPlay}`); } }, [paused]); // Respond to global trailer state changes (e.g., when modal opens) - // Only apply if no external paused prop is controlling this + // Only apply if no external paused prop is controlling this. + // FIX: Only pause if this player has previously been in a playing state. + // This avoids the race condition where globalTrailerPlaying is still false + // at mount time (before the parent has called setTrailerPlaying(true)), + // which was causing the trailer to be immediately paused on every load. useEffect(() => { if (isComponentMounted && paused === undefined) { - // Always sync with global trailer state when pausing - // This ensures all trailers pause when one screen loses focus - if (!globalTrailerPlaying) { + if (!globalTrailerPlaying && hasBeenPlayingRef.current) { + // Only suppress if the player was previously playing — not on initial mount logger.info('TrailerPlayer', 'Global trailer paused - pausing this trailer'); setIsPlaying(false); } @@ -364,10 +375,10 @@ const TrailerPlayer = React.forwardRef(({ ref={videoRef} source={(() => { const androidHeaders = Platform.OS === 'android' ? { 'User-Agent': 'Nuvio/1.0 (Android)' } : {} as any; - // Help ExoPlayer select proper MediaSource const lower = (trailerUrl || '').toLowerCase(); const looksLikeHls = /\.m3u8(\b|$)/.test(lower) || /hls|applehlsencryption|playlist|m3u/.test(lower); - const looksLikeDash = /\.mpd(\b|$)/.test(lower) || /dash|manifest/.test(lower); + // Detect both .mpd URLs and inline data: DASH manifests + const looksLikeDash = /\.mpd(\b|$)/.test(lower) || /dash|manifest/.test(lower) || lower.startsWith('data:application/dash'); if (Platform.OS === 'android') { if (looksLikeHls) { return { uri: trailerUrl, type: 'm3u8', headers: androidHeaders } as any; @@ -595,4 +606,4 @@ const styles = StyleSheet.create({ }, }); -export default TrailerPlayer; \ No newline at end of file +export default TrailerPlayer; From f653aa9aa7b7e362f2c9f8acbbd061a1033a9b3a Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Sun, 1 Mar 2026 15:09:49 +0530 Subject: [PATCH 14/81] fix for low quality trailers --- src/services/youtubeExtractor.ts | 269 +++++++++++++++++++++++-------- 1 file changed, 206 insertions(+), 63 deletions(-) diff --git a/src/services/youtubeExtractor.ts b/src/services/youtubeExtractor.ts index c340bc0f..928cc8ec 100644 --- a/src/services/youtubeExtractor.ts +++ b/src/services/youtubeExtractor.ts @@ -107,15 +107,39 @@ const TVHTML5_EMBEDDED_CONTEXT = { }, }; -// Preferred itags: muxed (video+audio) formats, best quality first -// These are single-file MP4s ExoPlayer can play directly +// --------------------------------------------------------------------------- +// Itag reference tables +// --------------------------------------------------------------------------- + +// Muxed (video+audio in one file) — these are the ONLY formats iOS AVPlayer +// can play without a DASH bridge. Max quality is 720p (itag 22), often absent. const PREFERRED_MUXED_ITAGS = [ 22, // 720p MP4 (video+audio) 18, // 360p MP4 (video+audio) 59, // 480p MP4 (video+audio) — rare 78, // 480p MP4 (video+audio) — rare - 135, // 480p video-only (fallback) - 134, // 360p video-only (fallback) +]; + +// Adaptive video-only itags in descending quality order. +// ExoPlayer on Android can combine these with an audio stream via DASH. +const ADAPTIVE_VIDEO_ITAGS_RANKED = [ + 137, // 1080p MP4 video-only + 248, // 1080p WebM video-only + 136, // 720p MP4 video-only + 247, // 720p WebM video-only + 135, // 480p MP4 video-only + 244, // 480p WebM video-only + 134, // 360p MP4 video-only + 243, // 360p WebM video-only +]; + +// Adaptive audio-only itags in descending quality order. +const ADAPTIVE_AUDIO_ITAGS_RANKED = [ + 141, // 256kbps AAC + 140, // 128kbps AAC ← most common + 251, // 160kbps Opus + 250, // 70kbps Opus + 249, // 50kbps Opus ]; const REQUEST_TIMEOUT_MS = 12000; @@ -182,19 +206,103 @@ function formatQualityLabel(format: InnertubeFormat): string { } function scoreFormat(format: InnertubeFormat): number { - // Prioritise: - // 1. Preferred itags (pre-muxed MP4 with audio) - // 2. Height (higher = better, but cap at 720 for stability) - // 3. Bitrate const preferredIndex = PREFERRED_MUXED_ITAGS.indexOf(format.itag); const itagBonus = preferredIndex !== -1 ? (PREFERRED_MUXED_ITAGS.length - preferredIndex) * 10000 : 0; const height = format.height ?? 0; - // Don't prefer > 720p because those are usually adaptive-only const heightScore = Math.min(height, 720) * 10; const bitrateScore = Math.min(format.bitrate ?? 0, 3_000_000) / 1000; return itagBonus + heightScore + bitrateScore; } +// --------------------------------------------------------------------------- +// Adaptive stream selection helpers +// --------------------------------------------------------------------------- + +/** Pick the best video-only adaptive format available (MP4 preferred). */ +function pickBestAdaptiveVideo(adaptiveFormats: InnertubeFormat[]): InnertubeFormat | null { + const videoOnly = adaptiveFormats.filter( + (f) => f.url && f.qualityLabel && !f.audioQuality && f.mimeType.startsWith('video/') + ); + if (videoOnly.length === 0) return null; + + for (const itag of ADAPTIVE_VIDEO_ITAGS_RANKED) { + const match = videoOnly.find((f) => f.itag === itag); + if (match) return match; + } + return videoOnly.sort((a, b) => (b.bitrate ?? 0) - (a.bitrate ?? 0))[0] ?? null; +} + +/** Pick the best audio-only adaptive format available (AAC preferred). */ +function pickBestAdaptiveAudio(adaptiveFormats: InnertubeFormat[]): InnertubeFormat | null { + const audioOnly = adaptiveFormats.filter( + (f) => f.url && f.audioQuality && !f.qualityLabel && f.mimeType.startsWith('audio/') + ); + if (audioOnly.length === 0) return null; + + for (const itag of ADAPTIVE_AUDIO_ITAGS_RANKED) { + const match = audioOnly.find((f) => f.itag === itag); + if (match) return match; + } + return audioOnly.sort((a, b) => (b.bitrate ?? 0) - (a.bitrate ?? 0))[0] ?? null; +} + +/** + * Build an in-memory DASH MPD XML that references separate video + audio streams. + * ExoPlayer (Android) can parse a data:application/dash+xml;base64,... URI directly. + * iOS AVPlayer does NOT support DASH — this path is Android-only. + */ +function buildDashManifest( + videoFormat: InnertubeFormat, + audioFormat: InnertubeFormat, + durationSeconds?: number +): string | null { + try { + const duration = durationSeconds ?? 300; + const mediaDurationISO = `PT${duration}S`; + + const videoCodec = parseMimeType(videoFormat.mimeType).codecs.replace(/"/g, '').trim(); + const audioCodec = parseMimeType(audioFormat.mimeType).codecs.replace(/"/g, '').trim(); + const videoMime = videoFormat.mimeType.split(';')[0].trim(); + const audioMime = audioFormat.mimeType.split(';')[0].trim(); + + const width = videoFormat.width ?? 1920; + const height = videoFormat.height ?? 1080; + const videoBandwidth = videoFormat.bitrate ?? 2_000_000; + const audioBandwidth = audioFormat.bitrate ?? 128_000; + const audioSampleRate = audioFormat.audioSampleRate ?? '44100'; + + const escapeXml = (s: string) => + s.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); + + const videoUrl = escapeXml(videoFormat.url!); + const audioUrl = escapeXml(audioFormat.url!); + + const mpd = ` + + + + + ${videoUrl} + + + + + + ${audioUrl} + + + + +`; + + const b64 = Buffer.from(mpd, 'utf8').toString('base64'); + return `data:application/dash+xml;base64,${b64}`; + } catch (err) { + logger.warn('YouTubeExtractor', 'Failed to build DASH manifest:', err); + return null; + } +} + // --------------------------------------------------------------------------- // Core extractor // --------------------------------------------------------------------------- @@ -251,34 +359,40 @@ async function fetchPlayerResponse( } } -function parseFormats(playerResponse: InnertubePlayerResponse): InnertubeFormat[] { +/** + * Returns muxed formats (video+audio) from sd.formats, plus any muxed adaptive formats. + * Used as the iOS fallback and the basis for the muxed bestStream. + */ +function parseMuxedFormats(playerResponse: InnertubePlayerResponse): InnertubeFormat[] { const sd = playerResponse.streamingData; if (!sd) return []; const formats: InnertubeFormat[] = []; - - // Include muxed formats (video+audio in one file) for (const f of sd.formats ?? []) { if (f.url) formats.push(f); } - - // Also scan adaptiveFormats for any that happen to have a direct URL - // and look muxed (edge case but occasionally seen) + // Edge case: some adaptive formats are actually muxed for (const f of sd.adaptiveFormats ?? []) { if (f.url && isMuxedFormat(f)) formats.push(f); } - return formats; } -function pickBestStream(formats: InnertubeFormat[]): ExtractedStream | null { +/** + * Returns all adaptive formats (video-only + audio-only) that have direct URLs. + * Used for DASH manifest building on Android. + */ +function parseAdaptiveFormats(playerResponse: InnertubePlayerResponse): InnertubeFormat[] { + const sd = playerResponse.streamingData; + if (!sd) return []; + return (sd.adaptiveFormats ?? []).filter((f) => !!f.url); +} + +function pickBestMuxedStream(formats: InnertubeFormat[]): ExtractedStream | null { if (formats.length === 0) return null; - // Filter to MP4 only for maximum ExoPlayer compatibility const mp4Formats = formats.filter(isVideoMp4); const pool = mp4Formats.length > 0 ? mp4Formats : formats; - - // Sort by score descending const sorted = [...pool].sort((a, b) => scoreFormat(b) - scoreFormat(a)); const best = sorted[0]; @@ -300,31 +414,33 @@ function pickBestStream(formats: InnertubeFormat[]): ExtractedStream | null { export class YouTubeExtractor { /** * Extract a playable stream URL from a YouTube video ID or URL. - * Tries Android client first (no cipher), then iOS, then TV embedded. - * Returns null if all attempts fail. + * + * Strategy: + * - Android: Try to build a DASH manifest from the best adaptive video + + * audio streams (up to 1080p). Falls back to best muxed stream (≤720p). + * - iOS: Use best muxed stream only (AVPlayer has no DASH support). + * + * Tries Android Innertube client first, then iOS, then TV Embedded. */ - static async extract(videoIdOrUrl: string): Promise { + static async extract(videoIdOrUrl: string, platform?: 'android' | 'ios'): Promise { const videoId = extractVideoId(videoIdOrUrl); if (!videoId) { logger.warn('YouTubeExtractor', `Could not parse video ID from: ${videoIdOrUrl}`); return null; } - logger.info('YouTubeExtractor', `Extracting streams for videoId=${videoId}`); + logger.info('YouTubeExtractor', `Extracting streams for videoId=${videoId} platform=${platform ?? 'unknown'}`); - // Try each client in order until we get usable formats const clients: Array<{ context: object; userAgent: string; name: string }> = [ { name: 'ANDROID', context: ANDROID_CLIENT_CONTEXT, - userAgent: - 'com.google.android.youtube/19.09.37 (Linux; U; Android 11) gzip', + userAgent: 'com.google.android.youtube/19.09.37 (Linux; U; Android 11) gzip', }, { name: 'IOS', context: IOS_CLIENT_CONTEXT, - userAgent: - 'com.google.ios.youtube/19.09.3 (iPhone14,3; U; CPU iPhone OS 15_6 like Mac OS X)', + userAgent: 'com.google.ios.youtube/19.09.3 (iPhone14,3; U; CPU iPhone OS 15_6 like Mac OS X)', }, { name: 'TVHTML5_EMBEDDED', @@ -333,7 +449,8 @@ export class YouTubeExtractor { }, ]; - let bestFormats: InnertubeFormat[] = []; + let muxedFormats: InnertubeFormat[] = []; + let adaptiveFormats: InnertubeFormat[] = []; let playerResponse: InnertubePlayerResponse | null = null; for (const client of clients) { @@ -344,33 +461,73 @@ export class YouTubeExtractor { const status = resp.playabilityStatus?.status; if (status === 'UNPLAYABLE' || status === 'LOGIN_REQUIRED') { - logger.warn( - 'YouTubeExtractor', - `${client.name} got playabilityStatus=${status} (${resp.playabilityStatus?.reason ?? ''})` - ); + logger.warn('YouTubeExtractor', `${client.name}: playabilityStatus=${status}`); continue; } - const formats = parseFormats(resp); - if (formats.length > 0) { - logger.info( - 'YouTubeExtractor', - `${client.name} returned ${formats.length} usable formats` - ); - bestFormats = formats; + const muxed = parseMuxedFormats(resp); + const adaptive = parseAdaptiveFormats(resp); + + if (muxed.length > 0 || adaptive.length > 0) { + logger.info('YouTubeExtractor', `${client.name}: ${muxed.length} muxed, ${adaptive.length} adaptive formats`); + muxedFormats = muxed; + adaptiveFormats = adaptive; playerResponse = resp; break; } - logger.warn('YouTubeExtractor', `${client.name} returned no direct-URL formats`); + logger.warn('YouTubeExtractor', `${client.name} returned no usable formats`); } - if (bestFormats.length === 0) { + if (muxedFormats.length === 0 && adaptiveFormats.length === 0) { logger.warn('YouTubeExtractor', `All clients failed for videoId=${videoId}`); return null; } - const streams: ExtractedStream[] = bestFormats.map((f) => ({ + const details = playerResponse?.videoDetails; + const durationSeconds = details?.lengthSeconds ? parseInt(details.lengthSeconds, 10) : undefined; + + // --- Android: attempt high-quality DASH manifest --- + let bestStream: ExtractedStream | null = null; + + if (platform === 'android' && adaptiveFormats.length > 0) { + const bestVideo = pickBestAdaptiveVideo(adaptiveFormats); + const bestAudio = pickBestAdaptiveAudio(adaptiveFormats); + + if (bestVideo && bestAudio) { + const dashUri = buildDashManifest(bestVideo, bestAudio, durationSeconds); + if (dashUri) { + logger.info( + 'YouTubeExtractor', + `DASH manifest built: video itag=${bestVideo.itag} (${formatQualityLabel(bestVideo)}), audio itag=${bestAudio.itag}` + ); + bestStream = { + url: dashUri, + quality: formatQualityLabel(bestVideo), + mimeType: 'application/dash+xml', + itag: bestVideo.itag, + hasAudio: true, + hasVideo: true, + bitrate: (bestVideo.bitrate ?? 0) + (bestAudio.bitrate ?? 0), + }; + } else { + logger.warn('YouTubeExtractor', 'DASH manifest build failed, falling back to muxed'); + } + } else { + logger.info('YouTubeExtractor', `Adaptive: bestVideo=${bestVideo?.itag ?? 'none'}, bestAudio=${bestAudio?.itag ?? 'none'} — falling back to muxed`); + } + } + + // --- iOS or DASH fallback: use best muxed stream --- + if (!bestStream) { + bestStream = pickBestMuxedStream(muxedFormats); + if (bestStream) { + logger.info('YouTubeExtractor', `Muxed fallback: itag=${bestStream.itag} quality=${bestStream.quality}`); + } + } + + // Build the full streams list from muxed formats for the result object + const streams: ExtractedStream[] = muxedFormats.map((f) => ({ url: f.url!, quality: formatQualityLabel(f), mimeType: f.mimeType, @@ -380,40 +537,26 @@ export class YouTubeExtractor { bitrate: f.bitrate ?? 0, })); - const bestStream = pickBestStream(bestFormats); - - const details = playerResponse?.videoDetails; - const result: YouTubeExtractionResult = { + return { streams, bestStream, videoId, title: details?.title, - durationSeconds: details?.lengthSeconds - ? parseInt(details.lengthSeconds, 10) - : undefined, + durationSeconds, }; - - if (bestStream) { - logger.info( - 'YouTubeExtractor', - `Best stream: itag=${bestStream.itag} quality=${bestStream.quality} mimeType=${bestStream.mimeType}` - ); - } - - return result; } /** * Convenience method — returns just the best playable URL or null. + * Pass platform so the extractor can choose DASH vs muxed appropriately. */ - static async getBestStreamUrl(videoIdOrUrl: string): Promise { - const result = await this.extract(videoIdOrUrl); + static async getBestStreamUrl(videoIdOrUrl: string, platform?: 'android' | 'ios'): Promise { + const result = await this.extract(videoIdOrUrl, platform); return result?.bestStream?.url ?? null; } /** * Parse a video ID from any YouTube URL format or bare ID. - * Exposed so callers can validate IDs before calling extract(). */ static parseVideoId(input: string): string | null { return extractVideoId(input); From e475feae84535706cfa5ba311fb360d624354e3d Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Sun, 1 Mar 2026 15:10:59 +0530 Subject: [PATCH 15/81] fix for low quality trailers --- src/services/trailerService.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/services/trailerService.ts b/src/services/trailerService.ts index 5044a93b..2cff19d3 100644 --- a/src/services/trailerService.ts +++ b/src/services/trailerService.ts @@ -1,4 +1,5 @@ import { logger } from '../utils/logger'; +import { Platform } from 'react-native'; import { YouTubeExtractor } from './youtubeExtractor'; export interface TrailerData { @@ -41,7 +42,8 @@ export class TrailerService { } try { - const url = await YouTubeExtractor.getBestStreamUrl(youtubeVideoId); + const platform = Platform.OS === 'android' ? 'android' : 'ios'; + const url = await YouTubeExtractor.getBestStreamUrl(youtubeVideoId, platform); if (url) { logger.info('TrailerService', `On-device extraction succeeded for ${youtubeVideoId}`); this.setCache(youtubeVideoId, url); From ca8c503a47bc56e02f82ec0d45393d1006200b2b Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Sun, 1 Mar 2026 15:12:26 +0530 Subject: [PATCH 16/81] fix for low quality trailers From 005e4b7ea901f6e7e898874e5b846bdf3a59409b Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Sun, 1 Mar 2026 15:20:09 +0530 Subject: [PATCH 17/81] fix From 8cd1ec8188465e977cca5964d82bb4eac6d74b38 Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Sun, 1 Mar 2026 15:21:37 +0530 Subject: [PATCH 18/81] fix From cadcda27df209939cdce0f838b121409232900fe Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Sun, 1 Mar 2026 15:22:14 +0530 Subject: [PATCH 19/81] fix --- src/services/youtubeExtractor.ts | 120 +++++++++++++++++-------------- 1 file changed, 65 insertions(+), 55 deletions(-) diff --git a/src/services/youtubeExtractor.ts b/src/services/youtubeExtractor.ts index 928cc8ec..393876cf 100644 --- a/src/services/youtubeExtractor.ts +++ b/src/services/youtubeExtractor.ts @@ -111,8 +111,9 @@ const TVHTML5_EMBEDDED_CONTEXT = { // Itag reference tables // --------------------------------------------------------------------------- -// Muxed (video+audio in one file) — these are the ONLY formats iOS AVPlayer -// can play without a DASH bridge. Max quality is 720p (itag 22), often absent. +// Muxed (video+audio in one file). +// iOS AVPlayer can ONLY use these. Max quality YouTube provides is 720p (itag 22), +// but it is often absent on modern videos, leaving 360p (itag 18) as the fallback. const PREFERRED_MUXED_ITAGS = [ 22, // 720p MP4 (video+audio) 18, // 360p MP4 (video+audio) @@ -120,8 +121,8 @@ const PREFERRED_MUXED_ITAGS = [ 78, // 480p MP4 (video+audio) — rare ]; -// Adaptive video-only itags in descending quality order. -// ExoPlayer on Android can combine these with an audio stream via DASH. +// Adaptive video-only itags, best quality first (MP4 preferred over WebM). +// Used for DASH on Android only. const ADAPTIVE_VIDEO_ITAGS_RANKED = [ 137, // 1080p MP4 video-only 248, // 1080p WebM video-only @@ -133,7 +134,8 @@ const ADAPTIVE_VIDEO_ITAGS_RANKED = [ 243, // 360p WebM video-only ]; -// Adaptive audio-only itags in descending quality order. +// Adaptive audio-only itags, best quality first (AAC preferred over Opus). +// Used for DASH on Android only. const ADAPTIVE_AUDIO_ITAGS_RANKED = [ 141, // 256kbps AAC 140, // 128kbps AAC ← most common @@ -215,16 +217,15 @@ function scoreFormat(format: InnertubeFormat): number { } // --------------------------------------------------------------------------- -// Adaptive stream selection helpers +// Adaptive stream helpers (Android/DASH only) // --------------------------------------------------------------------------- -/** Pick the best video-only adaptive format available (MP4 preferred). */ function pickBestAdaptiveVideo(adaptiveFormats: InnertubeFormat[]): InnertubeFormat | null { + // Video-only: has qualityLabel, no audioQuality, has direct URL const videoOnly = adaptiveFormats.filter( (f) => f.url && f.qualityLabel && !f.audioQuality && f.mimeType.startsWith('video/') ); if (videoOnly.length === 0) return null; - for (const itag of ADAPTIVE_VIDEO_ITAGS_RANKED) { const match = videoOnly.find((f) => f.itag === itag); if (match) return match; @@ -232,13 +233,12 @@ function pickBestAdaptiveVideo(adaptiveFormats: InnertubeFormat[]): InnertubeFor return videoOnly.sort((a, b) => (b.bitrate ?? 0) - (a.bitrate ?? 0))[0] ?? null; } -/** Pick the best audio-only adaptive format available (AAC preferred). */ function pickBestAdaptiveAudio(adaptiveFormats: InnertubeFormat[]): InnertubeFormat | null { + // Audio-only: has audioQuality, no qualityLabel, has direct URL const audioOnly = adaptiveFormats.filter( (f) => f.url && f.audioQuality && !f.qualityLabel && f.mimeType.startsWith('audio/') ); if (audioOnly.length === 0) return null; - for (const itag of ADAPTIVE_AUDIO_ITAGS_RANKED) { const match = audioOnly.find((f) => f.itag === itag); if (match) return match; @@ -247,16 +247,29 @@ function pickBestAdaptiveAudio(adaptiveFormats: InnertubeFormat[]): InnertubeFor } /** - * Build an in-memory DASH MPD XML that references separate video + audio streams. - * ExoPlayer (Android) can parse a data:application/dash+xml;base64,... URI directly. - * iOS AVPlayer does NOT support DASH — this path is Android-only. + * Write a DASH MPD manifest to a temp file and return its file:// URI. + * + * We use a file URI rather than a data: URI because: + * - ExoPlayer's DefaultDataSource handles file:// URIs natively via FileDataSource. + * - The .mpd file extension lets ExoPlayer auto-detect the type even without an + * explicit 'type' hint — meaning TrailerModal's bare