mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-17 23:42:04 +00:00
Merge branch 'NuvioMedia:main' into localization-patch
This commit is contained in:
commit
537b072924
11 changed files with 742 additions and 476 deletions
|
|
@ -206,7 +206,7 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
|
|||
}
|
||||
} else {
|
||||
if (item.type === 'movie') {
|
||||
watchedService.unmarkMovieAsWatched(item.id);
|
||||
watchedService.unmarkMovieAsWatched(item.id, item.imdb_id ?? undefined);
|
||||
} else {
|
||||
// Unmarking a series from the top level is tricky as we don't know the exact episodes.
|
||||
// For safety and consistency with old behavior, we just clear the legacy flag.
|
||||
|
|
@ -472,4 +472,4 @@ export default React.memo(ContentItem, (prev, next) => {
|
|||
if (prev.item.id !== next.item.id) return false;
|
||||
if (prev.item.poster !== next.item.poster) return false;
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -24,5 +24,6 @@ export const LOCALES = [
|
|||
{ code: 'ro', key: 'romanian' },
|
||||
{ code: 'sq', key: 'albanian' },
|
||||
{ code: 'ca', key: 'catalan' },
|
||||
{ code: 'vi', key: 'vietnamese' }
|
||||
{ code: 'vi', key: 'vietnamese' },
|
||||
{ code: 'ja', key: 'japanese' }
|
||||
];
|
||||
|
|
|
|||
|
|
@ -939,6 +939,22 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
|||
if (finalTmdbId) setTmdbId(finalTmdbId);
|
||||
}
|
||||
|
||||
// If the addon returned an imdb_id in its metadata (e.g. Kitsu addon), set it now.
|
||||
// This ensures imdbId state is populated for Trakt scrobbling even without TMDB enrichment.
|
||||
if (!imdbId && (finalMetadata as any).imdb_id) {
|
||||
const resolvedImdb = (finalMetadata as any).imdb_id as string;
|
||||
setImdbId(resolvedImdb);
|
||||
// Also resolve tmdbId from the imdb_id if we still don't have it
|
||||
if (!finalTmdbId) {
|
||||
const foundTmdbId = await tmdbSvc.findTMDBIdByIMDB(resolvedImdb);
|
||||
if (foundTmdbId) {
|
||||
finalTmdbId = foundTmdbId;
|
||||
setTmdbId(foundTmdbId);
|
||||
setMetadata(prev => prev ? { ...prev, tmdbId: foundTmdbId } : null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (finalTmdbId) {
|
||||
const lang = settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en';
|
||||
if (normalizedType === 'movie') {
|
||||
|
|
@ -2230,20 +2246,48 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
|||
// Fetch TMDB ID if needed and then recommendations
|
||||
useEffect(() => {
|
||||
const fetchTmdbIdAndRecommendations = async () => {
|
||||
if (!settings.enrichMetadataWithTMDB) {
|
||||
if (!metadata) return;
|
||||
|
||||
const isAnimeId = id.startsWith('kitsu:') || id.startsWith('mal:') || id.startsWith('anilist:');
|
||||
|
||||
// For anime IDs we always try to resolve tmdbId and imdbId regardless of enrichment setting,
|
||||
// because they're needed for Trakt scrobbling even when TMDB enrichment is disabled.
|
||||
if (!settings.enrichMetadataWithTMDB && !isAnimeId) {
|
||||
if (__DEV__) console.log('[useMetadata] enrichment disabled; skip TMDB id extraction (extract path)');
|
||||
return;
|
||||
}
|
||||
if (metadata && !tmdbId) {
|
||||
|
||||
if (!tmdbId) {
|
||||
try {
|
||||
const tmdbService = TMDBService.getInstance();
|
||||
const fetchedTmdbId = await tmdbService.extractTMDBIdFromStremioId(id);
|
||||
const tmdbSvc = TMDBService.getInstance();
|
||||
const fetchedTmdbId = await tmdbSvc.extractTMDBIdFromStremioId(id);
|
||||
if (fetchedTmdbId) {
|
||||
if (__DEV__) console.log('[useMetadata] extracted TMDB id from content id', { id, fetchedTmdbId });
|
||||
setTmdbId(fetchedTmdbId);
|
||||
|
||||
// For anime IDs, also resolve the IMDb ID from TMDB external IDs so Trakt can scrobble
|
||||
if (isAnimeId && !imdbId) {
|
||||
try {
|
||||
const externalIds = await tmdbSvc.getShowExternalIds(fetchedTmdbId);
|
||||
if (externalIds?.imdb_id) {
|
||||
if (__DEV__) console.log('[useMetadata] resolved imdbId for anime via TMDB', { id, imdbId: externalIds.imdb_id });
|
||||
setImdbId(externalIds.imdb_id);
|
||||
}
|
||||
} catch (e) {
|
||||
if (__DEV__) console.warn('[useMetadata] could not resolve imdbId from TMDB for anime ID', { id });
|
||||
}
|
||||
}
|
||||
|
||||
if (!settings.enrichMetadataWithTMDB) {
|
||||
// Enrichment is disabled but we still resolved tmdbId for Trakt scrobbling.
|
||||
// Set it on the metadata object so the player can read it via metadata.tmdbId.
|
||||
setMetadata(prev => prev ? { ...prev, tmdbId: fetchedTmdbId } : null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch certification only if granular setting is enabled
|
||||
if (settings.tmdbEnrichCertification) {
|
||||
const certification = await tmdbService.getCertification(normalizedType, fetchedTmdbId);
|
||||
const certification = await tmdbSvc.getCertification(normalizedType, fetchedTmdbId);
|
||||
if (certification) {
|
||||
if (__DEV__) console.log('[useMetadata] fetched certification via TMDB id (extract path)', { type, fetchedTmdbId, certification });
|
||||
setMetadata(prev => prev ? {
|
||||
|
|
|
|||
|
|
@ -53,9 +53,10 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
|||
|
||||
// Generate a unique session key for this content instance
|
||||
useEffect(() => {
|
||||
const resolvedId = (options.imdbId && options.imdbId.trim()) ? options.imdbId : (options.id || '');
|
||||
const contentKey = options.type === 'movie'
|
||||
? `movie:${options.imdbId}`
|
||||
: `episode:${options.showImdbId || options.imdbId}:${options.season}:${options.episode}`;
|
||||
? `movie:${resolvedId}`
|
||||
: `episode:${options.showImdbId || resolvedId}:${options.season}:${options.episode}`;
|
||||
sessionKey.current = `${contentKey}:${Date.now()}`;
|
||||
|
||||
// Reset all session state for new content
|
||||
|
|
@ -109,11 +110,22 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
|||
return null;
|
||||
}
|
||||
|
||||
if (!options.imdbId || options.imdbId.trim() === '') {
|
||||
logger.error('[TraktAutosync] Cannot build content data: missing or empty imdbId');
|
||||
// Resolve the best available ID: prefer a proper IMDb ID, fall back to the Stremio content ID.
|
||||
// This allows scrobbling for content with special IDs (e.g. "kitsu:123", "tmdb:456") where
|
||||
// the IMDb ID hasn't been resolved yet — Trakt will match by title + season/episode instead.
|
||||
const imdbIdRaw = options.imdbId && options.imdbId.trim() ? options.imdbId.trim() : '';
|
||||
const stremioIdRaw = options.id && options.id.trim() ? options.id.trim() : '';
|
||||
const resolvedImdbId = imdbIdRaw || stremioIdRaw;
|
||||
|
||||
if (!resolvedImdbId) {
|
||||
logger.error('[TraktAutosync] Cannot build content data: missing or empty imdbId and id');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!imdbIdRaw && stremioIdRaw) {
|
||||
logger.warn(`[TraktAutosync] imdbId is empty, falling back to stremio id "${stremioIdRaw}" — Trakt will match by title`);
|
||||
}
|
||||
|
||||
const numericYear = parseYear(options.year);
|
||||
const numericShowYear = parseYear(options.showYear);
|
||||
|
||||
|
|
@ -125,7 +137,7 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
|||
if (options.type === 'movie') {
|
||||
return {
|
||||
type: 'movie',
|
||||
imdbId: options.imdbId.trim(),
|
||||
imdbId: resolvedImdbId,
|
||||
title: options.title.trim(),
|
||||
year: numericYear // Can be undefined now
|
||||
};
|
||||
|
|
@ -140,26 +152,34 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
|||
return null;
|
||||
}
|
||||
|
||||
const resolvedShowImdbId = (options.showImdbId && options.showImdbId.trim())
|
||||
? options.showImdbId.trim()
|
||||
: resolvedImdbId;
|
||||
|
||||
return {
|
||||
type: 'episode',
|
||||
imdbId: options.imdbId.trim(),
|
||||
imdbId: resolvedImdbId,
|
||||
title: options.title.trim(),
|
||||
year: numericYear,
|
||||
season: options.season,
|
||||
episode: options.episode,
|
||||
showTitle: (options.showTitle || options.title).trim(),
|
||||
showYear: numericShowYear || numericYear,
|
||||
showImdbId: (options.showImdbId || options.imdbId).trim()
|
||||
showImdbId: resolvedShowImdbId
|
||||
};
|
||||
}
|
||||
}, [options]);
|
||||
|
||||
const buildSimklContentData = useCallback((): SimklContentData => {
|
||||
// Use the same fallback logic: prefer imdbId, fall back to stremio id
|
||||
const resolvedId = (options.imdbId && options.imdbId.trim())
|
||||
? options.imdbId.trim()
|
||||
: (options.id && options.id.trim()) ? options.id.trim() : '';
|
||||
return {
|
||||
type: options.type === 'series' ? 'episode' : 'movie',
|
||||
title: options.title,
|
||||
ids: {
|
||||
imdb: options.imdbId
|
||||
imdb: resolvedId
|
||||
},
|
||||
season: options.season,
|
||||
episode: options.episode
|
||||
|
|
@ -623,4 +643,4 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
|||
handlePlaybackEnd,
|
||||
resetState
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -669,6 +669,7 @@
|
|||
"romanian": "Romanian",
|
||||
"albanian": "Albanian",
|
||||
"catalan": "Catalan",
|
||||
"vietnamese": "Vietnamese",
|
||||
"account": "Account",
|
||||
"content_discovery": "Content & Discovery",
|
||||
"appearance": "Appearance",
|
||||
|
|
|
|||
|
|
@ -18,10 +18,10 @@
|
|||
"enable": "有効にする",
|
||||
"disable": "無効にする",
|
||||
"show_more": "もっと見る",
|
||||
"show_less": "少なく見る",
|
||||
"show_less": "表示を減らす",
|
||||
"load_more": "さらに読み込む",
|
||||
"unknown_date": "不明な日付",
|
||||
"anonymous_user": "匿名ユーザー",
|
||||
"unknown_date": "日付不明",
|
||||
"anonymous_user": "匿名",
|
||||
"time": {
|
||||
"now": "たった今",
|
||||
"minutes_ago": "{{count}}分前",
|
||||
|
|
@ -47,7 +47,7 @@
|
|||
"channels": "チャンネル"
|
||||
},
|
||||
"movies": "映画",
|
||||
"tv_shows": "TVシリーズ",
|
||||
"tv_shows": "シリーズ",
|
||||
"load_more_catalogs": "カタログをさらに読み込む",
|
||||
"no_content": "利用可能なコンテンツがありません",
|
||||
"add_catalogs": "カタログを追加",
|
||||
|
|
@ -56,7 +56,7 @@
|
|||
"view_all": "すべて表示",
|
||||
"this_week": "今週",
|
||||
"upcoming": "近日公開",
|
||||
"recently_released": "最近公開",
|
||||
"recently_released": "最近公開された作品",
|
||||
"no_scheduled_episodes": "予定されたエピソードがありません",
|
||||
"check_back_later": "後で確認してください",
|
||||
"continue_watching": "視聴を続ける",
|
||||
|
|
@ -72,15 +72,15 @@
|
|||
"episode": "エピソード{{episode}}",
|
||||
"movie": "映画",
|
||||
"series": "シリーズ",
|
||||
"tv_show": "TVシリーズ",
|
||||
"tv_show": "シリーズ",
|
||||
"percent_watched": "{{percent}}%視聴済み",
|
||||
"view_details": "詳細を見る",
|
||||
"remove": "削除",
|
||||
"play": "再生",
|
||||
"play_now": "今すぐ再生",
|
||||
"resume": "再開",
|
||||
"info": "情報",
|
||||
"more_info": "詳細情報",
|
||||
"info": "詳細",
|
||||
"more_info": "詳細",
|
||||
"my_list": "マイリスト",
|
||||
"save": "保存",
|
||||
"saved": "保存済み",
|
||||
|
|
@ -89,8 +89,8 @@
|
|||
"settings": "設定",
|
||||
"no_featured_content": "注目コンテンツがありません",
|
||||
"couldnt_load_featured": "注目コンテンツを読み込めませんでした",
|
||||
"no_featured_desc": "カタログアドオンをインストールするか、設定でコンテンツソースを変更してください。",
|
||||
"load_error_desc": "注目コンテンツの取得に問題が発生しました。接続を確認してもう一度お試しください。",
|
||||
"no_featured_desc": "カタログアドオンをインストールするか、設定でコンテンツソースを変更してください",
|
||||
"load_error_desc": "注目コンテンツの取得に問題が発生しました。接続を確認してもう一度お試しください",
|
||||
"no_featured_available": "利用可能な注目コンテンツがありません",
|
||||
"no_description": "説明なし"
|
||||
},
|
||||
|
|
@ -106,10 +106,10 @@
|
|||
"recent_searches": "最近の検索",
|
||||
"discover": "ディスカバー",
|
||||
"movies": "映画",
|
||||
"tv_shows": "TVシリーズ",
|
||||
"tv_shows": "シリーズ",
|
||||
"select_catalog": "カタログを選択",
|
||||
"all_genres": "すべてのジャンル",
|
||||
"discovering": "コンテンツを探しています...",
|
||||
"discovering": "コンテンツを検索中...",
|
||||
"show_more": "もっと見る({{count}})",
|
||||
"no_content_found": "コンテンツが見つかりません",
|
||||
"try_different": "別のジャンルやカタログを試してください",
|
||||
|
|
@ -122,9 +122,9 @@
|
|||
"try_keywords": "別のキーワードを試すか、スペルを確認してください",
|
||||
"select_type": "タイプを選択",
|
||||
"browse_movies": "映画カタログを閲覧",
|
||||
"browse_tv": "TVシリーズカタログを閲覧",
|
||||
"browse_tv": "シリーズカタログを閲覧",
|
||||
"select_genre": "ジャンルを選択",
|
||||
"show_all_content": "すべてのコンテンツを表示",
|
||||
"show_all_content": "すべて表示",
|
||||
"genres_count": "{{count}}ジャンル"
|
||||
},
|
||||
"library": {
|
||||
|
|
@ -182,28 +182,28 @@
|
|||
"unable_to_load": "コンテンツを読み込めません",
|
||||
"error_code": "エラーコード: {{code}}",
|
||||
"content_not_found": "コンテンツが見つかりません",
|
||||
"content_not_found_desc": "このコンテンツは存在しないか、削除された可能性があります。",
|
||||
"content_not_found_desc": "このコンテンツは存在しないか、削除された可能性があります",
|
||||
"server_error": "サーバーエラー",
|
||||
"server_error_desc": "サーバーが一時的に利用できません。後でもう一度お試しください。",
|
||||
"server_error_desc": "サーバーが一時的に利用できません。後でもう一度お試しください",
|
||||
"bad_gateway": "ゲートウェイエラー",
|
||||
"bad_gateway_desc": "サーバーで問題が発生しました。後でもう一度お試しください。",
|
||||
"bad_gateway_desc": "サーバーで問題が発生しました。後でもう一度お試しください",
|
||||
"service_unavailable": "サービス利用不可",
|
||||
"service_unavailable_desc": "サービスは現在メンテナンス中です。後でもう一度お試しください。",
|
||||
"service_unavailable_desc": "サービスは現在メンテナンス中です。後でもう一度お試しください",
|
||||
"too_many_requests": "リクエストが多すぎます",
|
||||
"too_many_requests_desc": "リクエストが多すぎます。少し待ってからもう一度お試しください。",
|
||||
"too_many_requests_desc": "リクエストが多すぎます。少し待ってからもう一度お試しください",
|
||||
"request_timeout": "リクエストタイムアウト",
|
||||
"request_timeout_desc": "リクエストに時間がかかりすぎました。もう一度お試しください。",
|
||||
"request_timeout_desc": "リクエストに時間がかかりすぎました。もう一度お試しください",
|
||||
"network_error": "ネットワークエラー",
|
||||
"network_error_desc": "インターネット接続を確認してもう一度お試しください。",
|
||||
"network_error_desc": "インターネット接続を確認してもう一度お試しください",
|
||||
"auth_error": "認証エラー",
|
||||
"auth_error_desc": "アカウント設定を確認してもう一度お試しください。",
|
||||
"auth_error_desc": "アカウント設定を確認してもう一度お試しください",
|
||||
"access_denied": "アクセス拒否",
|
||||
"access_denied_desc": "このコンテンツにアクセスする権限がありません。",
|
||||
"access_denied_desc": "このコンテンツにアクセスする権限がありません",
|
||||
"connection_error": "接続エラー",
|
||||
"streams_unavailable": "ストリームが利用できません",
|
||||
"streams_unavailable_desc": "ストリーミングソースは現在利用できません。後でもう一度お試しください。",
|
||||
"streams_unavailable_desc": "ストリーミングソースは現在利用できません。後でもう一度お試しください",
|
||||
"unknown_error": "不明なエラー",
|
||||
"something_went_wrong": "問題が発生しました。もう一度お試しください。",
|
||||
"something_went_wrong": "問題が発生しました。もう一度お試しください",
|
||||
"cast": "キャスト",
|
||||
"more_like_this": "関連作品",
|
||||
"collection": "コレクション",
|
||||
|
|
@ -217,7 +217,7 @@
|
|||
"episode_count_plural": "{{count}}エピソード",
|
||||
"no_episodes": "利用可能なエピソードがありません",
|
||||
"no_episodes_for_season": "シーズン{{season}}の利用可能なエピソードがありません",
|
||||
"episodes_not_released": "エピソードはまだ公開されていない可能性があります",
|
||||
"episodes_not_released": "まだ公開されていない可能性があります",
|
||||
"no_description": "説明なし",
|
||||
"episode_label": "エピソード{{number}}",
|
||||
"watch_again": "もう一度見る",
|
||||
|
|
@ -232,8 +232,8 @@
|
|||
"directors": "監督",
|
||||
"creator": "制作者",
|
||||
"creators": "制作者",
|
||||
"production": "制作",
|
||||
"network": "ネットワーク",
|
||||
"production": "制作会社",
|
||||
"network": "放送局",
|
||||
"mark_watched": "視聴済みにする",
|
||||
"mark_unwatched": "未視聴にする",
|
||||
"marking": "マーク中...",
|
||||
|
|
@ -258,7 +258,7 @@
|
|||
"first_air_date": "初回放送日",
|
||||
"last_air_date": "最終放送日",
|
||||
"total_episodes": "エピソード総数",
|
||||
"episode_runtime": "エピソード時間",
|
||||
"episode_runtime": "エピソードの長さ",
|
||||
"created_by": "制作",
|
||||
"backdrop_gallery": "バックドロップギャラリー",
|
||||
"loading_episodes": "エピソードを読み込み中...",
|
||||
|
|
@ -280,11 +280,11 @@
|
|||
"cast": {
|
||||
"biography": "経歴",
|
||||
"known_for": "代表作",
|
||||
"personal_info": "個人情報",
|
||||
"born_in": "{{place}}生まれ",
|
||||
"filmography": "フィルモグラフィー",
|
||||
"personal_info": "基本情報",
|
||||
"born_in": "出生地: {{place}}",
|
||||
"filmography": "出演作品",
|
||||
"also_known_as": "別名",
|
||||
"no_info_available": "追加情報なし",
|
||||
"no_info_available": "情報がありません",
|
||||
"as_character": "{{character}}役",
|
||||
"loading_details": "詳細を読み込み中...",
|
||||
"years_old": "{{age}}歳",
|
||||
|
|
@ -300,16 +300,16 @@
|
|||
"loading_filmography": "フィルモグラフィーを読み込み中...",
|
||||
"load_more_remaining": "さらに読み込む(残り{{count}}件)",
|
||||
"alert_error_title": "エラー",
|
||||
"alert_error_message": "「{{title}}」を読み込めませんでした。後でもう一度お試しください。",
|
||||
"alert_error_message": "「{{title}}」を読み込めませんでした。後でもう一度お試しください",
|
||||
"alert_ok": "OK",
|
||||
"no_upcoming": "この俳優の近日公開作品はありません",
|
||||
"no_content": "この俳優のコンテンツがありません",
|
||||
"no_movies": "この俳優の映画がありません",
|
||||
"no_tv": "この俳優のTVシリーズがありません"
|
||||
"no_tv": "この俳優のシリーズがありません"
|
||||
},
|
||||
"comments": {
|
||||
"title": "Traktコメント",
|
||||
"spoiler_warning": "⚠️ このコメントにはネタバレが含まれています。タップして表示。",
|
||||
"spoiler_warning": "⚠️ このコメントにはネタバレが含まれています。タップして表示",
|
||||
"spoiler": "ネタバレ",
|
||||
"contains_spoilers": "ネタバレあり",
|
||||
"reveal": "表示",
|
||||
|
|
@ -325,15 +325,15 @@
|
|||
"official_trailer": "公式予告編",
|
||||
"teasers": "ティーザー",
|
||||
"teaser": "ティーザー",
|
||||
"clips_scenes": "クリップとシーン",
|
||||
"clip": "クリップ",
|
||||
"featurettes": "特典映像",
|
||||
"clips_scenes": "シーン",
|
||||
"clip": "シーン",
|
||||
"featurettes": "メイキング",
|
||||
"featurette": "特典映像",
|
||||
"behind_the_scenes": "メイキング",
|
||||
"no_trailers": "利用可能な予告編がありません",
|
||||
"unavailable": "予告編は利用できません",
|
||||
"unavailable_desc": "予告編を読み込めませんでした。後でもう一度お試しください。",
|
||||
"unable_to_play": "予告編を再生できません。もう一度お試しください。",
|
||||
"unavailable_desc": "予告編を読み込めませんでした。後でもう一度お試しください",
|
||||
"unable_to_play": "予告編を再生できません。もう一度お試しください",
|
||||
"watch_on_youtube": "YouTubeで見る"
|
||||
},
|
||||
"catalog": {
|
||||
|
|
@ -345,13 +345,13 @@
|
|||
"all": "すべて",
|
||||
"failed_tmdb": "TMDBからコンテンツを読み込めませんでした",
|
||||
"movies": "映画",
|
||||
"tv_shows": "TVシリーズ",
|
||||
"tv_shows": "シリーズ",
|
||||
"channels": "チャンネル"
|
||||
},
|
||||
"streams": {
|
||||
"back_to_episodes": "エピソードに戻る",
|
||||
"back_to_info": "情報に戻る",
|
||||
"fetching_from": "取得元:",
|
||||
"fetching_from": "ソース:",
|
||||
"no_sources_available": "利用可能なストリーミングソースがありません",
|
||||
"add_sources_desc": "設定でストリーミングソースを追加してください",
|
||||
"add_sources": "ソースを追加",
|
||||
|
|
@ -369,7 +369,7 @@
|
|||
"playback_speed": "再生速度",
|
||||
"on_hold": "保留中",
|
||||
"playback_error": "再生エラー",
|
||||
"unknown_error": "再生中に不明なエラーが発生しました。",
|
||||
"unknown_error": "再生中に不明なエラーが発生しました",
|
||||
"copy_error": "エラー詳細をコピー",
|
||||
"copied_to_clipboard": "クリップボードにコピーしました",
|
||||
"dismiss": "閉じる",
|
||||
|
|
@ -382,7 +382,7 @@
|
|||
"sources": "ソース",
|
||||
"finding_sources": "ソースを検索中...",
|
||||
"unknown_source": "不明なソース",
|
||||
"sources_limited": "プロバイダーエラーによりソース数が制限される場合があります。",
|
||||
"sources_limited": "プロバイダーエラーによりソース数が制限される場合があります",
|
||||
"episodes": "エピソード",
|
||||
"specials": "スペシャル",
|
||||
"season": "シーズン{{season}}",
|
||||
|
|
@ -418,7 +418,7 @@
|
|||
"line_height": "行の高さ",
|
||||
"timing_offset": "タイミングオフセット(秒)",
|
||||
"visual_sync": "ビジュアル同期",
|
||||
"timing_hint": "字幕を早く(-)または遅く(+)ずらして同期させます。",
|
||||
"timing_hint": "字幕を早く(-)または遅く(+)ずらして同期させます",
|
||||
"reset_defaults": "デフォルトに戻す",
|
||||
"mark_intro_start": "イントロ開始をマーク",
|
||||
"mark_intro_end": "イントロ終了をマーク",
|
||||
|
|
@ -437,7 +437,7 @@
|
|||
"incomplete": "ダウンロード未完了",
|
||||
"incomplete_desc": "ダウンロードはまだ完了していません",
|
||||
"not_available": "利用不可",
|
||||
"not_available_desc": "ローカルファイルパスはダウンロード完了後に利用可能になります。",
|
||||
"not_available_desc": "ローカルファイルパスはダウンロード完了後に利用可能になります",
|
||||
"status_downloading": "ダウンロード中",
|
||||
"status_completed": "完了",
|
||||
"status_paused": "一時停止",
|
||||
|
|
@ -448,7 +448,7 @@
|
|||
"streaming_playlist_warning": "動作しない可能性があります - ストリーミングプレイリスト",
|
||||
"remaining": "残り",
|
||||
"not_ready": "ダウンロードの準備ができていません",
|
||||
"not_ready_desc": "ダウンロードが完了するまでお待ちください。",
|
||||
"not_ready_desc": "ダウンロードが完了するまでお待ちください",
|
||||
"filter_all": "すべて",
|
||||
"filter_active": "アクティブ",
|
||||
"filter_done": "完了",
|
||||
|
|
@ -456,12 +456,27 @@
|
|||
"no_filter_results": "フィルター「{{filter}}」に該当するダウンロードがありません",
|
||||
"try_different_filter": "別のフィルターをお試しください",
|
||||
"limitations_title": "ダウンロードの制限",
|
||||
"limitations_msg": "• 1MB未満のファイルは通常M3U8プレイリストであり、オフライン視聴用にダウンロードできません。これらはオンラインストリーミングのみで動作します。",
|
||||
"limitations_msg": "• 1MB未満のファイルは通常M3U8プレイリストであり、オフライン視聴用にダウンロードできません。これらはオンラインストリーミングのみで動作します",
|
||||
"remove_title": "ダウンロードを削除",
|
||||
"remove_confirm": "「{{title}}」{{season_episode}}を削除しますか?",
|
||||
"cancel": "キャンセル",
|
||||
"remove": "削除"
|
||||
},
|
||||
"parentalGuide": {
|
||||
"labels": {
|
||||
"nudity": "ヌード",
|
||||
"violence": "暴力",
|
||||
"profanity": "不適切な言葉",
|
||||
"alcohol": "飲酒・薬物",
|
||||
"frightening": "恐怖表現"
|
||||
},
|
||||
"severity": {
|
||||
"severe": "激しい",
|
||||
"moderate": "中程度",
|
||||
"mild": "軽い",
|
||||
"none": "なし"
|
||||
}
|
||||
},
|
||||
"addons": {
|
||||
"title": "アドオン",
|
||||
"reorder_mode": "並び替えモード",
|
||||
|
|
@ -485,9 +500,9 @@
|
|||
"reorder_drag_title": "ドラッグして並び替え",
|
||||
"install": "インストール",
|
||||
"config_unavailable_title": "設定不可",
|
||||
"config_unavailable_msg": "このアドオンの設定URLを特定できません。",
|
||||
"config_unavailable_msg": "このアドオンの設定URLを特定できません",
|
||||
"cannot_open_config_title": "設定を開けません",
|
||||
"cannot_open_config_msg": "設定URL({{url}})を開けません。アドオンに設定ページがない可能性があります。",
|
||||
"cannot_open_config_msg": "設定URL({{url}})を開けません。アドオンに設定ページがない可能性があります",
|
||||
"description": "説明",
|
||||
"supported_types": "対応タイプ",
|
||||
"catalogs": "カタログ",
|
||||
|
|
@ -506,7 +521,7 @@
|
|||
"sign_out_confirm": "Traktアカウントからサインアウトしますか?",
|
||||
"joined": "{{date}}に参加",
|
||||
"sync_settings_title": "同期設定",
|
||||
"sync_info": "Traktに接続すると、完全な履歴がAPIから直接同期され、ローカルには保存されません。",
|
||||
"sync_info": "Traktに接続すると、完全な履歴がAPIから直接同期され、ローカルには保存されません",
|
||||
"auto_sync_label": "視聴進捗を自動同期",
|
||||
"auto_sync_desc": "視聴進捗をTraktに自動的にアップロード",
|
||||
"import_history_label": "視聴履歴をインポート",
|
||||
|
|
@ -517,17 +532,17 @@
|
|||
"show_comments_desc": "利用可能な場合、コンテンツ詳細にTraktコメントを表示",
|
||||
"maintenance_title": "メンテナンス中",
|
||||
"maintenance_unavailable": "Traktが利用できません",
|
||||
"maintenance_desc": "Trakt連携はメンテナンス作業のため一時停止中です。",
|
||||
"maintenance_desc": "Trakt連携はメンテナンス作業のため一時停止中です",
|
||||
"maintenance_button": "サービスメンテナンス中",
|
||||
"auth_success_title": "接続に成功しました",
|
||||
"auth_success_msg": "Traktアカウントが正常に接続されました。",
|
||||
"auth_success_msg": "Traktアカウントが正常に接続されました",
|
||||
"auth_error_title": "認証エラー",
|
||||
"auth_error_msg": "Traktの認証を完了できませんでした。",
|
||||
"auth_error_generic": "認証中にエラーが発生しました。",
|
||||
"sign_out_error": "Traktからサインアウトできませんでした。",
|
||||
"auth_error_msg": "Traktの認証を完了できませんでした",
|
||||
"auth_error_generic": "認証中にエラーが発生しました",
|
||||
"sign_out_error": "Traktからサインアウトできませんでした",
|
||||
"sync_complete_title": "同期完了",
|
||||
"sync_success_msg": "Traktと視聴進捗を正常に同期しました。",
|
||||
"sync_error_msg": "同期に失敗しました。もう一度お試しください。"
|
||||
"sync_success_msg": "Traktと視聴進捗を正常に同期しました",
|
||||
"sync_error_msg": "同期に失敗しました。もう一度お試しください"
|
||||
},
|
||||
"simkl": {
|
||||
"title": "Simkl設定",
|
||||
|
|
@ -537,35 +552,35 @@
|
|||
"sign_in": "Simklでサインイン",
|
||||
"sign_out": "切断",
|
||||
"sign_out_confirm": "Simklアカウントを切断しますか?",
|
||||
"syncing_desc": "視聴済みのアイテムがSimklと同期されます。",
|
||||
"syncing_desc": "視聴済みのアイテムがSimklと同期されます",
|
||||
"auth_success_title": "接続に成功しました",
|
||||
"auth_success_msg": "Simklアカウントが正常に接続されました。",
|
||||
"auth_success_msg": "Simklアカウントが正常に接続されました",
|
||||
"auth_error_title": "認証エラー",
|
||||
"auth_error_msg": "Simklの認証を完了できませんでした。",
|
||||
"auth_error_generic": "認証中にエラーが発生しました。",
|
||||
"sign_out_error": "Simklから切断できませんでした。",
|
||||
"auth_error_msg": "Simklの認証を完了できませんでした",
|
||||
"auth_error_generic": "認証中にエラーが発生しました",
|
||||
"sign_out_error": "Simklから切断できませんでした",
|
||||
"config_error_title": "設定エラー",
|
||||
"config_error_msg": "環境変数にSimkl Client IDがありません。",
|
||||
"config_error_msg": "環境変数にSimkl Client IDがありません",
|
||||
"conflict_title": "競合",
|
||||
"conflict_msg": "Traktが接続されている場合はSimklに接続できません。先にTraktを切断してください。",
|
||||
"disclaimer": "NuvioはSimklと提携していません。"
|
||||
"conflict_msg": "Traktが接続されている場合はSimklに接続できません。先にTraktを切断してください",
|
||||
"disclaimer": "NuvioはSimklと提携していません"
|
||||
},
|
||||
"tmdb_settings": {
|
||||
"title": "TMDb設定",
|
||||
"metadata_enrichment": "メタデータ補完",
|
||||
"metadata_enrichment_desc": "TMDbのデータでコンテンツのメタデータを強化します。",
|
||||
"metadata_enrichment_desc": "TMDbのデータでコンテンツのメタデータを強化します",
|
||||
"enable_enrichment": "補完を有効にする",
|
||||
"enable_enrichment_desc": "キャスト、年齢制限、ロゴ/ポスター、制作情報についてTMDbのデータでアドオンのメタデータを補完します。",
|
||||
"enable_enrichment_desc": "キャスト、年齢制限、ロゴ/ポスター、製作情報についてTMDbのデータでアドオンのメタデータを補完します",
|
||||
"localized_text": "ローカライズされたテキスト",
|
||||
"localized_text_desc": "TMDbから希望言語でタイトルと説明を取得します。",
|
||||
"localized_text_desc": "TMDbから希望言語でタイトルと説明を取得します",
|
||||
"language": "言語",
|
||||
"change": "変更",
|
||||
"logo_preview": "ロゴプレビュー",
|
||||
"logo_preview_desc": "選択した言語でローカライズされたロゴのプレビューです。",
|
||||
"logo_preview_desc": "選択した言語でローカライズされたロゴのプレビューです",
|
||||
"example": "例:",
|
||||
"no_logo": "利用可能なロゴがありません",
|
||||
"enrichment_options": "補完オプション",
|
||||
"enrichment_options_desc": "TMDbから取得するデータを選択します。",
|
||||
"enrichment_options_desc": "TMDbから取得するデータを選択します",
|
||||
"cast_crew": "キャストとスタッフ",
|
||||
"cast_crew_desc": "プロフィール写真付きの俳優、監督、脚本家",
|
||||
"title_description": "タイトルと説明",
|
||||
|
|
@ -579,26 +594,26 @@
|
|||
"recommendations": "おすすめ",
|
||||
"recommendations_desc": "類似コンテンツの提案",
|
||||
"episode_data": "エピソードデータ",
|
||||
"episode_data_desc": "TVシリーズのエピソードサムネイル、情報、フォールバックデータ",
|
||||
"episode_data_desc": "シリーズのエピソードサムネイル、情報、フォールバックデータ",
|
||||
"season_posters": "シーズンポスター",
|
||||
"season_posters_desc": "特定のシーズンに割り当てられたポスター画像",
|
||||
"production_info": "制作情報",
|
||||
"production_info": "製作情報",
|
||||
"production_info_desc": "ロゴ付きのテレビ局と制作会社",
|
||||
"movie_details": "映画の詳細",
|
||||
"movie_details_desc": "製作費、興行収入、上映時間、キャッチコピー",
|
||||
"tv_details": "TVシリーズの詳細",
|
||||
"tv_details": "シリーズの詳細",
|
||||
"tv_details_desc": "ステータス、シーズン数、ネットワーク、制作者",
|
||||
"movie_collections": "映画コレクション",
|
||||
"movie_collections_desc": "映画シリーズ(マーベル、スターウォーズなど)",
|
||||
"api_configuration": "API設定",
|
||||
"api_configuration_desc": "拡張機能のためにTMDB APIアクセスを設定します。",
|
||||
"api_configuration_desc": "拡張機能のためにTMDB APIアクセスを設定します",
|
||||
"custom_api_key": "カスタムAPIキー",
|
||||
"custom_api_key_desc": "パフォーマンス向上のために独自のTMDB 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キーを使用しています。パフォーマンス向上のために独自のキーの使用をご検討ください。",
|
||||
"built_in_key_msg": "現在、組み込みAPIキーを使用しています。パフォーマンス向上のために独自のキーの使用をご検討ください",
|
||||
"cache_size": "キャッシュサイズ",
|
||||
"clear_cache": "キャッシュをクリア",
|
||||
"cache_days": "TMDBデータはパフォーマンス向上のため7日間保存されます",
|
||||
|
|
@ -607,23 +622,23 @@
|
|||
"popular": "人気",
|
||||
"all_languages": "すべての言語",
|
||||
"search_results": "検索結果",
|
||||
"no_languages_found": "「{{query}}」の言語が見つかりません",
|
||||
"no_languages_found": "「{{query}}」に一致する言語が見つかりません",
|
||||
"clear_search": "検索をクリア",
|
||||
"clear_cache_title": "TMDBキャッシュをクリア",
|
||||
"clear_cache_msg": "保存されているすべてのTMDBデータ({{size}})が削除されます。",
|
||||
"clear_cache_success": "TMDBキャッシュをクリアしました。",
|
||||
"clear_cache_error": "キャッシュのクリアに失敗しました。",
|
||||
"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キーが正常に確認・保存されました。"
|
||||
"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": "言語",
|
||||
|
|
@ -654,6 +669,7 @@
|
|||
"romanian": "ルーマニア語",
|
||||
"albanian": "アルバニア語",
|
||||
"catalan": "カタルーニャ語",
|
||||
"vietnamese": "ベトナム語",
|
||||
"japanese": "日本語",
|
||||
"account": "アカウント",
|
||||
"content_discovery": "コンテンツとディスカバー",
|
||||
|
|
@ -661,6 +677,7 @@
|
|||
"integrations": "連携",
|
||||
"playback": "再生",
|
||||
"backup_restore": "バックアップと復元",
|
||||
"backup_restore_desc": "バックアップと復元を作成する",
|
||||
"updates": "アップデート",
|
||||
"about": "アプリについて",
|
||||
"developer": "開発者",
|
||||
|
|
@ -771,7 +788,7 @@
|
|||
"title": "Nuvio Sync",
|
||||
"description": "Nuvioデバイス間でデータを同期",
|
||||
"hero_title": "クラウド同期",
|
||||
"hero_subtitle": "すべてのデバイスでアドオン、進捗、ライブラリを同期します。",
|
||||
"hero_subtitle": "すべてのデバイスでアドオン、進捗、ライブラリを同期します",
|
||||
"auth": {
|
||||
"account": "アカウント",
|
||||
"not_configured": "Supabaseが設定されていません",
|
||||
|
|
@ -788,11 +805,11 @@
|
|||
"watch_progress": "視聴進捗",
|
||||
"library_items": "ライブラリアイテム",
|
||||
"watched_items": "視聴済みアイテム",
|
||||
"signin_required": "リモートデータ数を読み込むにはサインインしてください。"
|
||||
"signin_required": "リモートデータ数を読み込むにはサインインしてください"
|
||||
},
|
||||
"actions": {
|
||||
"title": "アクション",
|
||||
"description": "クラウドからこのデバイスに取得するか、このデバイスからプッシュします。",
|
||||
"description": "クラウドからこのデバイスに取得するか、このデバイスからプッシュします",
|
||||
"pull_btn": "クラウドから取得",
|
||||
"push_btn": "デバイスからプッシュ",
|
||||
"manage_account": "アカウントを管理",
|
||||
|
|
@ -801,11 +818,11 @@
|
|||
},
|
||||
"alerts": {
|
||||
"pull_success_title": "クラウドデータを取得しました",
|
||||
"pull_success_msg": "最新のクラウドデータがこのデバイスにダウンロードされました。",
|
||||
"pull_success_msg": "最新のクラウドデータがこのデバイスにダウンロードされました",
|
||||
"pull_failed_title": "取得に失敗しました",
|
||||
"pull_failed_msg": "クラウドからデータをダウンロードできませんでした",
|
||||
"push_success_title": "プッシュ完了",
|
||||
"push_success_msg": "デバイスデータがクラウドにアップロードされました。",
|
||||
"push_success_msg": "デバイスデータがクラウドにアップロードされました",
|
||||
"push_failed_title": "プッシュに失敗しました",
|
||||
"push_failed_msg": "ローカルデータのアップロードに失敗しました",
|
||||
"sign_out_failed": "サインアウトに失敗しました",
|
||||
|
|
@ -813,15 +830,15 @@
|
|||
},
|
||||
"external_sync": {
|
||||
"title": "外部同期の優先度",
|
||||
"active_msg": "{{services}}がアクティブです。視聴進捗とライブラリの更新はNuvioクラウドの代わりにこれらのサービスによって管理されます。",
|
||||
"inactive_msg": "TraktまたはSimklの同期が有効な場合、視聴進捗とライブラリの更新はNuvioクラウドの代わりにそれらのサービスを使用します。"
|
||||
"active_msg": "{{services}}がアクティブです。視聴進捗とライブラリの更新はNuvioクラウドの代わりにこれらのサービスによって管理されます",
|
||||
"inactive_msg": "TraktまたはSimklの同期が有効な場合、視聴進捗とライブラリの更新はNuvioクラウドの代わりにそれらのサービスを使用します"
|
||||
},
|
||||
"pre_auth": {
|
||||
"title": "同期前に",
|
||||
"description": "クラウド同期を開始するにはサインインしてください。",
|
||||
"description": "クラウド同期を開始するにはサインインしてください",
|
||||
"point_1": "• アドオンとプラグインの設定",
|
||||
"point_2": "• 視聴進捗とライブラリ",
|
||||
"env_warning": "同期を有効にするにはEXPO_PUBLIC_SUPABASE_URLとEXPO_PUBLIC_SUPABASE_ANON_KEYを設定してください。"
|
||||
"env_warning": "同期を有効にするにはEXPO_PUBLIC_SUPABASE_URLとEXPO_PUBLIC_SUPABASE_ANON_KEYを設定してください"
|
||||
},
|
||||
"connection": "接続"
|
||||
}
|
||||
|
|
@ -830,22 +847,22 @@
|
|||
"title": "プライバシーとデータ",
|
||||
"settings_desc": "テレメトリーとデータ収集の管理",
|
||||
"info_title": "あなたのプライバシーは重要です",
|
||||
"info_description": "収集・共有されるデータを管理してください。分析はデフォルトで無効、エラーレポートはデフォルトで匿名です。",
|
||||
"info_description": "収集・共有されるデータを管理してください。分析はデフォルトで無効、エラーレポートはデフォルトで匿名です",
|
||||
"analytics_enabled_title": "分析が有効になりました",
|
||||
"analytics_enabled_message": "アプリ改善のために使用データが収集されます。いつでも無効にできます。",
|
||||
"analytics_enabled_message": "アプリ改善のために使用データが収集されます。いつでも無効にできます",
|
||||
"disable_error_reporting_title": "エラーレポートを無効にしますか?",
|
||||
"disable_error_reporting_message": "エラーレポートを無効にすると、クラッシュや問題について通知されなくなります。",
|
||||
"disable_error_reporting_message": "エラーレポートを無効にすると、クラッシュや問題について通知されなくなります",
|
||||
"enable_session_replay_title": "セッションリプレイを有効にしますか?",
|
||||
"enable_session_replay_message": "セッションリプレイはエラー発生時に画面を記録します。画面上に表示されたコンテンツが記録される可能性があります。",
|
||||
"enable_session_replay_message": "セッションリプレイはエラー発生時に画面を記録します。画面上に表示されたコンテンツが記録される可能性があります",
|
||||
"enable_pii_title": "PII収集を有効にしますか?",
|
||||
"enable_pii_message": "IPアドレスやデバイスの詳細などの個人情報の収集が可能になります。",
|
||||
"enable_pii_message": "IPアドレスやデバイスの詳細などの個人情報の収集が可能になります",
|
||||
"disable_all_title": "すべてのテレメトリーを無効にしますか?",
|
||||
"disable_all_message": "分析、エラーレポート、セッションリプレイがすべて無効になります。",
|
||||
"disable_all_message": "分析、エラーレポート、セッションリプレイがすべて無効になります",
|
||||
"disable_all_button": "すべて無効にする",
|
||||
"all_disabled_title": "テレメトリーが無効になりました",
|
||||
"all_disabled_message": "すべてのデータ収集が無効になりました。変更はアプリの再起動後に有効になります。",
|
||||
"all_disabled_message": "すべてのデータ収集が無効になりました。変更はアプリの再起動後に有効になります",
|
||||
"reset_title": "推奨設定に戻す",
|
||||
"reset_message": "プライバシー設定が推奨デフォルト値にリセットされました。",
|
||||
"reset_message": "プライバシー設定が推奨デフォルト値にリセットされました",
|
||||
"section_analytics": "分析",
|
||||
"analytics_title": "使用状況統計",
|
||||
"analytics_description": "匿名の使用パターンと画面ビューを収集",
|
||||
|
|
@ -868,12 +885,12 @@
|
|||
"summary_errors": "エラーレポート",
|
||||
"summary_replay": "セッションリプレイ",
|
||||
"summary_pii": "デバイス情報",
|
||||
"restart_note_detailed": "* 分析とエラーレポートの変更は即座に有効になります。セッションリプレイとPII設定はアプリの再起動が必要です。"
|
||||
"restart_note_detailed": "* 分析とエラーレポートの変更は即座に有効になります。セッションリプレイとPII設定はアプリの再起動が必要です"
|
||||
},
|
||||
"ai_settings": {
|
||||
"title": "AIアシスタント",
|
||||
"info_title": "AIを使ったチャット",
|
||||
"info_desc": "高度なAIを使って映画やエピソードについて質問できます。",
|
||||
"info_desc": "高度なAIを使って映画やエピソードについて質問できます",
|
||||
"feature_1": "エピソード固有の分析とコンテキスト",
|
||||
"feature_2": "ストーリーの説明とキャラクターの考察",
|
||||
"feature_3": "トリビアと舞台裏の事実",
|
||||
|
|
@ -887,9 +904,9 @@
|
|||
"remove": "削除",
|
||||
"get_free_key": "OpenRouterから無料のAPIキーを取得",
|
||||
"enable_chat": "AIチャットを有効にする",
|
||||
"enable_chat_desc": "有効にすると、コンテンツページに「AIに聞く」ボタンが表示されます。",
|
||||
"enable_chat_desc": "有効にすると、コンテンツページに「AIに聞く」ボタンが表示されます",
|
||||
"chat_enabled": "AIチャットが有効になりました",
|
||||
"chat_enabled_desc": "映画やシリーズについて質問できるようになりました。",
|
||||
"chat_enabled_desc": "映画やシリーズについて質問できるようになりました",
|
||||
"how_it_works": "仕組み",
|
||||
"how_it_works_desc": "• OpenRouterは複数のAIモデルへのアクセスを提供します\n• APIキーはプライベートで安全に保たれます\n• 無料プランには十分な使用制限があります\n• 特定のエピソード/映画のコンテキストでチャットできます\n• 詳細な分析と説明を受け取れます",
|
||||
"error_invalid_key": "有効なAPIキーを入力してください",
|
||||
|
|
@ -897,7 +914,7 @@
|
|||
"success_saved": "OpenRouter APIキーを正常に保存しました!",
|
||||
"error_save": "APIキーの保存に失敗しました",
|
||||
"confirm_remove_title": "APIキーを削除",
|
||||
"confirm_remove_msg": "OpenRouter APIキーを削除しますか?これによりAIチャット機能が無効になります。",
|
||||
"confirm_remove_msg": "OpenRouter APIキーを削除しますか?これによりAIチャット機能が無効になります",
|
||||
"success_removed": "APIキーを削除しました",
|
||||
"error_remove": "APIキーの削除に失敗しました"
|
||||
},
|
||||
|
|
@ -908,21 +925,21 @@
|
|||
"auto": "自動",
|
||||
"show_titles": "ポスターにタイトルを表示",
|
||||
"show_titles_desc": "各ポスターの下にタイトルテキストを表示",
|
||||
"phone_only_hint": "電話のみ対象。タブレットはアダプティブレイアウトを維持します。",
|
||||
"phone_only_hint": "電話のみ対象。タブレットはアダプティブレイアウトを維持します",
|
||||
"catalogs_group": "カタログ",
|
||||
"enabled_count": "{{total}}中{{enabled}}つが有効",
|
||||
"rename_hint": "カタログを長押しして名前を変更",
|
||||
"rename_modal_title": "カタログ名を変更",
|
||||
"rename_placeholder": "新しいカタログ名を入力",
|
||||
"error_save_name": "カスタム名の保存に失敗しました。"
|
||||
"error_save_name": "カスタム名の保存に失敗しました"
|
||||
},
|
||||
"continue_watching_settings": {
|
||||
"title": "視聴を続ける",
|
||||
"playback_behavior": "再生の動作",
|
||||
"use_cached": "保存されたストリームを使用",
|
||||
"use_cached_desc": "有効にすると、以前再生したストリームを使って直接プレーヤーが開きます。",
|
||||
"use_cached_desc": "有効にすると、以前再生したストリームを使って直接プレーヤーが開きます",
|
||||
"open_metadata": "詳細画面を開く",
|
||||
"open_metadata_desc": "保存されたストリームが無効の場合、メタデータ画面を開きます。",
|
||||
"open_metadata_desc": "保存されたストリームが無効の場合、メタデータ画面を開きます",
|
||||
"card_appearance": "カードの外観",
|
||||
"card_style": "カードスタイル",
|
||||
"card_style_desc": "ホーム画面での「視聴を続ける」アイテムの表示方法を選択",
|
||||
|
|
@ -932,7 +949,7 @@
|
|||
"cache_duration": "ストリームキャッシュ期間",
|
||||
"cache_duration_desc": "ストリームリンクの有効期限が切れるまでの保存期間",
|
||||
"important_note": "重要なお知らせ",
|
||||
"important_note_text": "すべてのストリームリンクが保存期間中ずっとアクティブとは限りません。",
|
||||
"important_note_text": "すべてのストリームリンクが保存期間中ずっとアクティブとは限りません",
|
||||
"how_it_works": "仕組み",
|
||||
"how_it_works_cached": "• ストリームは再生後に選択した期間保存されます\n• 保存されたストリームは使用前に検証されます\n• キャッシュが期限切れの場合はコンテンツ画面に戻ります",
|
||||
"how_it_works_uncached": "• 保存されたストリームが無効の場合、アイテムをタップするとコンテンツ画面が開きます\n• メタデータ画面は詳細を表示して手動選択ができます",
|
||||
|
|
@ -960,21 +977,21 @@
|
|||
"gratitude_desc": "コードの1行1行、バグ報告、提案がすべての人のためにNuvioを改善します",
|
||||
"special_thanks_title": "特別な感謝",
|
||||
"special_thanks_desc": "これらの素晴らしい人たちがNuvioコミュニティとサーバーの維持を支援しています",
|
||||
"donors_desc": "私たちが構築しているものを信じてくれてありがとうございます。",
|
||||
"donors_desc": "私たちが構築しているものを信じてくれてありがとうございます",
|
||||
"latest_donations": "最新",
|
||||
"leaderboard": "ランキング",
|
||||
"loading_donors": "寄付者を読み込み中...",
|
||||
"no_donors": "寄付者がいません",
|
||||
"error_rate_limit": "GitHub APIレート制限を超えました。後でもう一度試してください。",
|
||||
"error_failed": "貢献者の読み込みに失敗しました。インターネット接続を確認してください。",
|
||||
"error_rate_limit": "GitHub APIレート制限を超えました。後でもう一度試してください",
|
||||
"error_failed": "貢献者の読み込みに失敗しました。インターネット接続を確認してください",
|
||||
"retry": "もう一度試す",
|
||||
"no_contributors": "貢献者が見つかりません",
|
||||
"loading_contributors": "貢献者を読み込み中..."
|
||||
},
|
||||
"debrid": {
|
||||
"title": "Debrid連携",
|
||||
"description_torbox": "Torbox連携で4K品質のストリームと超高速スピードを解放。",
|
||||
"description_torrentio": "映画やシリーズのトレントストリームを受け取るようにTorrentioを設定します。",
|
||||
"description_torbox": "Torbox連携で4K品質のストリームと超高速スピードを解放",
|
||||
"description_torrentio": "映画やシリーズのトレントストリームを受け取るようにTorrentioを設定します",
|
||||
"tab_torbox": "TorBox",
|
||||
"tab_torrentio": "Torrentio",
|
||||
"status_connected": "接続済み",
|
||||
|
|
@ -993,23 +1010,23 @@
|
|||
"downloaded": "ダウンロード済み",
|
||||
"status_active": "アクティブ",
|
||||
"connected_title": "✓ TorBoxに接続済み",
|
||||
"connected_desc": "TorBoxアドオンがアクティブでプレミアムストリームを提供しています。",
|
||||
"connected_desc": "TorBoxアドオンがアクティブでプレミアムストリームを提供しています",
|
||||
"configure_title": "アドオンを設定",
|
||||
"configure_desc": "体験をカスタマイズしてください。",
|
||||
"configure_desc": "体験をカスタマイズしてください",
|
||||
"open_settings": "設定を開く",
|
||||
"what_is_debrid": "Debridサービスとは?",
|
||||
"enter_api_key": "APIキーを入力",
|
||||
"connect_button": "接続してインストール",
|
||||
"connecting": "接続中...",
|
||||
"unlock_speeds_title": "プレミアム速度を解放",
|
||||
"unlock_speeds_desc": "Torboxサブスクリプションを購入して、バッファリングなしの高品質ストリームにアクセスしましょう。",
|
||||
"unlock_speeds_desc": "Torboxサブスクリプションを購入して、バッファリングなしの高品質ストリームにアクセスしましょう",
|
||||
"get_subscription": "サブスクリプションを取得",
|
||||
"powered_by": "提供元",
|
||||
"disclaimer_torbox": "NuvioはTorboxと一切関係ありません。",
|
||||
"disclaimer_torrentio": "NuvioはTorrentioと一切関係ありません。",
|
||||
"disclaimer_torbox": "NuvioはTorboxと一切関係ありません",
|
||||
"disclaimer_torrentio": "NuvioはTorrentioと一切関係ありません",
|
||||
"installed_badge": "✓ インストール済み",
|
||||
"promo_title": "⚡ Debridサービスが必要ですか?",
|
||||
"promo_desc": "バッファリングなしの超高速4Kストリーミング。プレミアムトレントと即時ダウンロード。",
|
||||
"promo_desc": "バッファリングなしの超高速4Kストリーミング。プレミアムトレントと即時ダウンロード",
|
||||
"promo_button": "TorBoxサブスクリプションを購入",
|
||||
"service_label": "Debridサービス *",
|
||||
"api_key_label": "APIキー *",
|
||||
|
|
@ -1026,11 +1043,11 @@
|
|||
"updating": "更新中...",
|
||||
"remove_button": "Torrentioを削除",
|
||||
"error_api_required": "APIキーが必要",
|
||||
"error_api_required_desc": "TorrentioをインストールするにはDebridサービスのAPIキーを入力してください。",
|
||||
"error_api_required_desc": "TorrentioをインストールするにはDebridサービスのAPIキーを入力してください",
|
||||
"success_installed": "Torrentioアドオンを正常にインストールしました!",
|
||||
"success_removed": "Torrentioアドオンを削除しました",
|
||||
"alert_disconnect_title": "Torboxを切断",
|
||||
"alert_disconnect_msg": "Torboxを切断しますか?これによりアドオンが削除されAPIキーがクリアされます。"
|
||||
"alert_disconnect_msg": "Torboxを切断しますか?これによりアドオンが削除されAPIキーがクリアされます"
|
||||
},
|
||||
"home_screen": {
|
||||
"title": "ホーム画面設定",
|
||||
|
|
@ -1055,7 +1072,7 @@
|
|||
"manage_selected_catalogs": "選択したカタログを管理",
|
||||
"dynamic_bg": "ダイナミックヒーロー背景",
|
||||
"dynamic_bg_desc": "カルーセルの後ろにぼかしたバナー",
|
||||
"performance_note": "低スペックデバイスではパフォーマンスに影響する可能性があります。",
|
||||
"performance_note": "低スペックデバイスではパフォーマンスに影響する可能性があります",
|
||||
"posters": "ポスター",
|
||||
"show_titles": "タイトルを表示",
|
||||
"poster_size": "ポスターサイズ",
|
||||
|
|
@ -1067,23 +1084,23 @@
|
|||
"corners_rounded": "丸め",
|
||||
"corners_pill": "ピル",
|
||||
"about_these_settings": "これらの設定について",
|
||||
"about_desc": "これらの設定はホーム画面でのコンテンツ表示方法を制御します。変更はすぐに適用されます。",
|
||||
"about_desc": "これらの設定はホーム画面でのコンテンツ表示方法を制御します。変更はすぐに適用されます",
|
||||
"hero_catalogs": {
|
||||
"title": "ヒーローカタログ",
|
||||
"select_all": "すべて選択",
|
||||
"clear_all": "すべてクリア",
|
||||
"info": "ヒーローセクションに表示するカタログを選択してください。変更を保存することを忘れずに。",
|
||||
"info": "ヒーローセクションに表示するカタログを選択してください。変更を保存することを忘れずに",
|
||||
"settings_saved": "設定を保存しました",
|
||||
"error_load": "カタログの読み込みに失敗しました",
|
||||
"movies": "映画",
|
||||
"tv_shows": "TVシリーズ"
|
||||
"tv_shows": "シリーズ"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
"title": "カレンダー",
|
||||
"loading": "カレンダーを読み込み中...",
|
||||
"no_scheduled_episodes": "予定されたエピソードがありません",
|
||||
"check_back_later": "後で確認してください",
|
||||
"check_back_later": "後でもう一度確認してください",
|
||||
"showing_episodes_for": "{{date}}のエピソード",
|
||||
"show_all_episodes": "すべてのエピソードを表示",
|
||||
"no_episodes_for": "{{date}}のエピソードがありません",
|
||||
|
|
@ -1095,9 +1112,9 @@
|
|||
"status_disabled": "MDBListが無効",
|
||||
"status_active": "APIキーがアクティブ",
|
||||
"status_required": "APIキーが必要",
|
||||
"status_disabled_desc": "MDBList機能は現在無効です。",
|
||||
"status_active_desc": "MDBListの評価が有効です。",
|
||||
"status_required_desc": "評価を有効にするには以下にキーを追加してください。",
|
||||
"status_disabled_desc": "MDBList機能は現在無効です",
|
||||
"status_active_desc": "MDBListの評価が有効です",
|
||||
"status_required_desc": "評価を有効にするには以下にキーを追加してください",
|
||||
"enable_toggle": "MDBListを有効にする",
|
||||
"enable_toggle_desc": "すべてのMDBList機能を有効/無効にする",
|
||||
"api_section": "APIキー",
|
||||
|
|
@ -1113,14 +1130,14 @@
|
|||
"step_2_settings": "設定",
|
||||
"step_2_api": "API",
|
||||
"step_2_end": "。",
|
||||
"step_3": "新しいキーを生成してコピーしてください。",
|
||||
"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_saved": "APIキーを正常に保存しました",
|
||||
"error_empty": "APIキーを空にすることはできません",
|
||||
"error_save": "保存中にエラーが発生しました。もう一度お試しください",
|
||||
"api_key_empty_error": "APIキーを空にすることはできません",
|
||||
"success_cleared": "APIキーを削除しました",
|
||||
"error_clear": "APIキーの削除に失敗しました"
|
||||
},
|
||||
|
|
@ -1142,7 +1159,7 @@
|
|||
"stats_total": "合計",
|
||||
"sync_button": "ライブラリとTraktを同期",
|
||||
"syncing": "同期中...",
|
||||
"sync_desc": "ライブラリとTraktのウォッチリスト/コレクション内のすべてのシリーズの通知を自動的に同期します。",
|
||||
"sync_desc": "ライブラリとTraktのウォッチリスト/コレクション内のすべてのシリーズの通知を自動的に同期します",
|
||||
"section_advanced": "詳細",
|
||||
"reset_button": "すべての通知をリセット",
|
||||
"test_button": "通知をテスト(5秒)",
|
||||
|
|
@ -1185,18 +1202,18 @@
|
|||
"section_info": "バックアップについて",
|
||||
"info_text": "• 上のトグルでバックアップの範囲をカスタマイズ\n• バックアップファイルはデバイスにローカル保存\n• バックアップを共有してデバイス間でデータを移動\n• 復元すると現在のデータが上書きされます",
|
||||
"alert_create_title": "バックアップを作成",
|
||||
"alert_no_content": "バックアップするコンテンツが選択されていません。\n\n上のオプションを少なくとも1つ有効にしてください。",
|
||||
"alert_no_content": "バックアップするコンテンツが選択されていません。\n\n上のオプションを少なくとも1つ有効にしてください",
|
||||
"alert_backup_created_title": "バックアップを作成しました",
|
||||
"alert_backup_created_msg": "バックアップが作成され共有の準備ができました。",
|
||||
"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_complete_msg": "データが正常に復元されました。変更を確認するにはアプリを再起動してください",
|
||||
"alert_restore_failed_title": "復元に失敗しました",
|
||||
"restart_app": "アプリを再起動",
|
||||
"alert_restart_failed_title": "再起動に失敗しました",
|
||||
"alert_restart_failed_msg": "アプリの再起動に失敗しました。手動で閉じて再度開いてください。"
|
||||
"alert_restart_failed_msg": "アプリの再起動に失敗しました。手動で閉じて再度開いてください"
|
||||
},
|
||||
"updates": {
|
||||
"title": "アプリのアップデート",
|
||||
|
|
@ -1225,10 +1242,10 @@
|
|||
"major_alerts_label": "メジャーアップデートアラート",
|
||||
"major_alerts_desc": "GitHubの新バージョンの通知を表示",
|
||||
"alert_disable_ota_title": "OTAアップデートアラートを無効にしますか?",
|
||||
"alert_disable_ota_msg": "OTAアップデートの自動通知を受け取らなくなります。",
|
||||
"alert_disable_ota_msg": "OTAアップデートの自動通知を受け取らなくなります",
|
||||
"alert_disable_major_title": "メジャーアップデートアラートを無効にしますか?",
|
||||
"alert_disable_major_msg": "再インストールが必要なメジャーアップデートの通知を受け取らなくなります。",
|
||||
"warning_note": "有効なアラートにより、バグ修正を受け取りクラッシュレポートを提出できます。",
|
||||
"alert_disable_major_msg": "再インストールが必要なメジャーアップデートの通知を受け取らなくなります",
|
||||
"warning_note": "有効なアラートにより、バグ修正を受け取りクラッシュレポートを提出できます",
|
||||
"disable": "無効にする",
|
||||
"alert_no_update_to_install": "インストールするアップデートがありません",
|
||||
"alert_install_failed": "アップデートのインストールエラー",
|
||||
|
|
@ -1256,20 +1273,20 @@
|
|||
"skip_intro_settings_title": "イントロをスキップ",
|
||||
"powered_by_introdb": "IntroDBを使用",
|
||||
"autoplay_title": "最初のストリームを自動再生",
|
||||
"autoplay_desc": "リストに表示されている最初のストリームを自動的に起動します。",
|
||||
"autoplay_desc": "リストに表示されている最初のストリームを自動的に起動します",
|
||||
"resume_title": "常に再開",
|
||||
"resume_desc": "再開の確認をスキップして中断した場所から続ける(85%未満視聴の場合)。",
|
||||
"resume_desc": "再開の確認をスキップして中断した場所から続ける(85%未満視聴の場合)",
|
||||
"engine_title": "プレーヤーエンジン",
|
||||
"engine_desc": "AutoモードはExoPlayerとMPVフォールバックを使用します。Autoモードを推奨します。",
|
||||
"engine_desc": "AutoモードはExoPlayerとMPVフォールバックを使用します。Autoモードを推奨します",
|
||||
"decoder_title": "デコーダーモード",
|
||||
"decoder_desc": "動画のデコード方法。最適なバランスのためAutoモードを推奨します。",
|
||||
"decoder_desc": "動画のデコード方法。最適なバランスのためAutoモードを推奨します",
|
||||
"gpu_title": "GPUレンダリング",
|
||||
"gpu_desc": "GPU-NextはHDRとカラー管理が改善されています。",
|
||||
"gpu_desc": "GPU-NextはHDRとカラー管理が改善されています",
|
||||
"external_downloads_title": "ダウンロード用外部プレーヤー",
|
||||
"external_downloads_desc": "好みの外部プレーヤーでダウンロードしたコンテンツを再生します。",
|
||||
"external_downloads_desc": "好みの外部プレーヤーでダウンロードしたコンテンツを再生します",
|
||||
"restart_required": "再起動が必要",
|
||||
"restart_msg_decoder": "デコーダーの変更を有効にするにはアプリを再起動してください。",
|
||||
"restart_msg_gpu": "GPUモードの変更を有効にするにはアプリを再起動してください。",
|
||||
"restart_msg_decoder": "デコーダーの変更を有効にするにはアプリを再起動してください",
|
||||
"restart_msg_gpu": "GPUモードの変更を有効にするにはアプリを再起動してください",
|
||||
"option_auto": "Auto",
|
||||
"option_auto_desc_engine": "ExoPlayer + MPVフォールバック",
|
||||
"option_mpv": "MPV",
|
||||
|
|
@ -1289,9 +1306,9 @@
|
|||
"enable_title": "プラグインを有効にする",
|
||||
"enable_desc": "外部メディアソースを取得するためにプラグインエンジンを有効にする",
|
||||
"repo_config_title": "リポジトリ設定",
|
||||
"repo_config_desc": "外部プラグインリポジトリを管理します。",
|
||||
"repo_config_desc": "外部プラグインリポジトリを管理します",
|
||||
"your_repos": "リポジトリ",
|
||||
"your_repos_desc": "外部プラグインソースを設定してください。",
|
||||
"your_repos_desc": "外部プラグインソースを設定してください",
|
||||
"add_repo_button": "リポジトリを追加",
|
||||
"refresh": "更新",
|
||||
"remove": "削除",
|
||||
|
|
@ -1312,22 +1329,22 @@
|
|||
"platform_disabled": "プラットフォーム無効",
|
||||
"limited": "制限付き",
|
||||
"clear_all": "すべてのプラグインを削除",
|
||||
"clear_all_desc": "インストールされたすべてのプラグインを削除しますか?この操作は元に戻せません。",
|
||||
"clear_all_desc": "インストールされたすべてのプラグインを削除しますか?この操作は元に戻せません",
|
||||
"clear_cache": "リポジトリキャッシュをクリア",
|
||||
"clear_cache_desc": "これによりリポジトリURLが削除されキャッシュデータがクリアされます。",
|
||||
"clear_cache_desc": "これによりリポジトリURLが削除されキャッシュデータがクリアされます",
|
||||
"add_new_repo": "新しいリポジトリを追加",
|
||||
"available_plugins": "利用可能なプラグイン({{count}})",
|
||||
"placeholder": "プラグインを検索...",
|
||||
"all": "すべて",
|
||||
"filter_all": "すべてのタイプ",
|
||||
"filter_movies": "映画",
|
||||
"filter_tv": "TVシリーズ",
|
||||
"filter_tv": "シリーズ",
|
||||
"enable_all": "すべて有効",
|
||||
"disable_all": "すべて無効",
|
||||
"no_plugins_found": "プラグインが見つかりません",
|
||||
"no_plugins_available": "利用可能なプラグインがありません",
|
||||
"no_match_desc": "「{{query}}」に一致するプラグインがありません。別のキーワードをお試しください。",
|
||||
"configure_repo_desc": "利用可能なプラグインを確認するには上でリポジトリを設定してください。",
|
||||
"no_match_desc": "「{{query}}」に一致するプラグインがありません。別のキーワードをお試しください",
|
||||
"configure_repo_desc": "利用可能なプラグインを確認するには上でリポジトリを設定してください",
|
||||
"clear_search": "検索をクリア",
|
||||
"no_external_player": "外部プレーヤーなし",
|
||||
"showbox_token": "ShowBox UIトークン",
|
||||
|
|
@ -1338,22 +1355,22 @@
|
|||
"enable_url_validation": "URL検証を有効にする",
|
||||
"url_validation_desc": "メディアURLを返す前に検証します",
|
||||
"group_streams": "プラグインソースをグループ化",
|
||||
"group_streams_desc": "有効にするとソースはリポジトリ別にグループ化されます。",
|
||||
"group_streams_desc": "有効にするとソースはリポジトリ別にグループ化されます",
|
||||
"sort_quality": "まず品質でソート",
|
||||
"sort_quality_desc": "有効にするとソースは品質でソートされます。グループ化が有効な場合のみ動作します。",
|
||||
"sort_quality_desc": "有効にするとソースは品質でソートされます。グループ化が有効な場合のみ動作します",
|
||||
"show_logos": "プラグインロゴを表示",
|
||||
"show_logos_desc": "ソース画面のメディアリンクの横にプラグインロゴを表示します。",
|
||||
"show_logos_desc": "ソース画面のメディアリンクの横にプラグインロゴを表示します",
|
||||
"quality_filtering": "品質フィルタリング",
|
||||
"quality_filtering_desc": "検索結果から特定の解像度を除外します。品質をタップして除外します。",
|
||||
"quality_filtering_desc": "検索結果から特定の解像度を除外します。品質をタップして除外します",
|
||||
"excluded_qualities": "除外された品質:",
|
||||
"language_filtering": "言語フィルタリング",
|
||||
"language_filtering_desc": "検索結果から特定の言語を除外します。言語をタップして除外します。",
|
||||
"language_filtering_desc": "検索結果から特定の言語を除外します。言語をタップして除外します",
|
||||
"note": "注意:",
|
||||
"language_filtering_note": "このフィルターは言語情報を提供するプロバイダーにのみ適用されます。",
|
||||
"language_filtering_note": "このフィルターは言語情報を提供するプロバイダーにのみ適用されます",
|
||||
"excluded_languages": "除外された言語:",
|
||||
"about_title": "プラグインについて",
|
||||
"about_desc_1": "プラグインはさまざまなプロトコルからコンテンツを取得するモジュール式コンポーネントです。デバイス上でローカルに動作します。",
|
||||
"about_desc_2": "「制限付き」としてマークされたプラグインは特定の外部設定が必要な場合があります。",
|
||||
"about_desc_1": "プラグインはさまざまなプロトコルからコンテンツを取得するモジュール式コンポーネントです。デバイス上でローカルに動作します",
|
||||
"about_desc_2": "「制限付き」としてマークされたプラグインは特定の外部設定が必要な場合があります",
|
||||
"help_title": "プラグインの設定",
|
||||
"help_step_1": "1. **プラグインを有効にする** - メインスイッチをオンにする",
|
||||
"help_step_2": "2. **リポジトリを追加する** - 有効なリポジトリURLを入力する",
|
||||
|
|
@ -1397,15 +1414,15 @@
|
|||
"legal": {
|
||||
"title": "法的情報",
|
||||
"intro_title": "アプリの性質",
|
||||
"intro_text": "Nuvioはメディアプレーヤーおよびメタデータ管理アプリケーションです。Nuvioはいかなるメディアコンテンツもホスト、保存、配布、またはインデックス化しません。",
|
||||
"intro_text": "Nuvioはメディアプレーヤーおよびメタデータ管理アプリケーションです。Nuvioはいかなるメディアコンテンツもホスト、保存、配布、またはインデックス化しません",
|
||||
"extensions_title": "サードパーティ拡張機能",
|
||||
"extensions_text": "Nuvioはユーザーがサードパーティプラグインをインストールできる拡張可能なアーキテクチャを使用しています。サードパーティ拡張機能のコンテンツ、合法性、機能について責任を負いません。",
|
||||
"extensions_text": "Nuvioはユーザーがサードパーティプラグインをインストールできる拡張可能なアーキテクチャを使用しています。サードパーティ拡張機能のコンテンツ、合法性、機能について責任を負いません",
|
||||
"user_resp_title": "ユーザーの責任",
|
||||
"user_resp_text": "ユーザーはインストールするプラグインとアクセスするコンテンツに対して単独で責任を負います。Nuvioの制作者は著作権侵害を奨励しません。",
|
||||
"user_resp_text": "ユーザーはインストールするプラグインとアクセスするコンテンツに対して単独で責任を負います。Nuvioの制作者は著作権侵害を奨励しません",
|
||||
"dmca_title": "著作権とDMCA",
|
||||
"dmca_text": "私たちは他者の知的財産権を尊重します。アプリのインターフェース自体があなたの権利を侵害していると思われる場合はお問い合わせください。",
|
||||
"dmca_text": "私たちは他者の知的財産権を尊重します。アプリのインターフェース自体があなたの権利を侵害していると思われる場合はお問い合わせください",
|
||||
"warranty_title": "無保証",
|
||||
"warranty_text": "このソフトウェアは「現状のまま」で提供され、いかなる保証もありません。"
|
||||
"warranty_text": "このソフトウェアは「現状のまま」で提供され、いかなる保証もありません"
|
||||
},
|
||||
"plugin_tester": {
|
||||
"title": "プラグインテスター",
|
||||
|
|
@ -1421,7 +1438,7 @@
|
|||
"error": "エラー",
|
||||
"success": "成功",
|
||||
"movie": "映画",
|
||||
"tv": "TVシリーズ",
|
||||
"tv": "シリーズ",
|
||||
"tmdb_id": "TMDB ID",
|
||||
"season": "シーズン",
|
||||
"episode": "エピソード",
|
||||
|
|
@ -1434,7 +1451,7 @@
|
|||
},
|
||||
"individual": {
|
||||
"load_from_url": "URLから読み込む",
|
||||
"load_from_url_desc": "GitHubのrawURLまたはローカルIPを貼り付けて取得をタップ。",
|
||||
"load_from_url_desc": "GitHubのrawURLまたはローカルIPを貼り付けて取得をタップ",
|
||||
"enter_url_error": "URLを入力してください",
|
||||
"code_loaded": "URLからコードを読み込みました",
|
||||
"fetch_error": "取得エラー: {{message}}",
|
||||
|
|
@ -1443,11 +1460,11 @@
|
|||
"focus_editor": "コードエディターをアクティブにする",
|
||||
"code_placeholder": "// プラグインコードをここに貼り付け...",
|
||||
"test_parameters": "テストパラメーター",
|
||||
"no_logs": "ログなし。テストを実行して出力を確認してください。",
|
||||
"no_streams": "まだストリームが見つかりません。",
|
||||
"no_logs": "ログなし。テストを実行して出力を確認してください",
|
||||
"no_streams": "まだストリームが見つかりません",
|
||||
"streams_found": "{{count}}件のストリームが見つかりました",
|
||||
"streams_found_plural": "{{count}}件のストリームが見つかりました",
|
||||
"tap_play_hint": "再生をタップしてネイティブプレーヤーでストリームをテストします。",
|
||||
"tap_play_hint": "再生をタップしてネイティブプレーヤーでストリームをテストします",
|
||||
"unnamed_stream": "無名のストリーム",
|
||||
"quality": "品質: {{quality}}",
|
||||
"size": "サイズ: {{size}}",
|
||||
|
|
@ -1459,10 +1476,10 @@
|
|||
},
|
||||
"repo": {
|
||||
"title": "リポジトリテスター",
|
||||
"description": "リポジトリを取得して各プロバイダーをテストします。",
|
||||
"description": "リポジトリを取得して各プロバイダーをテストします",
|
||||
"enter_repo_url_error": "リポジトリURLを入力してください",
|
||||
"invalid_url_title": "無効なURL",
|
||||
"invalid_url_msg": "GitHub rawURLまたはローカルhttp(s)アドレスを使用してください。",
|
||||
"invalid_url_msg": "GitHub rawURLまたはローカルhttp(s)アドレスを使用してください",
|
||||
"manifest_build_error": "マニフェストURLを構築できません",
|
||||
"manifest_fetch_error": "マニフェストの取得エラー",
|
||||
"repo_manifest_fetch_error": "リポジトリマニフェストの取得エラー",
|
||||
|
|
@ -1471,13 +1488,13 @@
|
|||
"download_scraper_error": "スクレイパーのダウンロードエラー",
|
||||
"test_failed": "テストに失敗しました",
|
||||
"test_parameters": "リポジトリテストパラメーター",
|
||||
"test_parameters_desc": "これらのパラメーターはリポジトリテスターのみに使用されます。",
|
||||
"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": "プロバイダーを表示するにはリポジトリを取得してください。",
|
||||
"fetch_hint": "プロバイダーを表示するにはリポジトリを取得してください",
|
||||
"test_all": "すべてテスト",
|
||||
"status_running": "実行中",
|
||||
"status_ok": "OK({{count}})",
|
||||
|
|
@ -1486,7 +1503,7 @@
|
|||
"status_idle": "待機中",
|
||||
"tried_url": "試したURL: {{url}}",
|
||||
"provider_logs": "プロバイダーログ",
|
||||
"no_logs_captured": "ログが記録されていません。"
|
||||
"no_logs_captured": "ログが記録されていません"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
"ok": "OK",
|
||||
"unknown": "Không xác định",
|
||||
"retry": "Thử lại",
|
||||
"try_again": "Thử lại",
|
||||
"try_again": "Vui lòng thử lại",
|
||||
"go_back": "Quay lại",
|
||||
"settings": "Cài đặt",
|
||||
"close": "Đóng",
|
||||
|
|
@ -21,48 +21,48 @@
|
|||
"show_less": "Thu gọn",
|
||||
"load_more": "Tải thêm",
|
||||
"unknown_date": "Ngày không xác định",
|
||||
"anonymous_user": "Người dùng khách",
|
||||
"anonymous_user": "Khách",
|
||||
"time": {
|
||||
"now": "Vừa xong",
|
||||
"minutes_ago": "{{count}} phút trước",
|
||||
"hours_ago": "{{count}} giờ trước",
|
||||
"days_ago": "{{count}} ngày trước"
|
||||
"minutes_ago": "{{count}} phút",
|
||||
"hours_ago": "{{count}} giờ",
|
||||
"days_ago": "{{count}} ngày"
|
||||
},
|
||||
"days_short": {
|
||||
"sun": "Chủ nhật",
|
||||
"mon": "Thứ 2",
|
||||
"tue": "Thứ 3",
|
||||
"wed": "Thứ 4",
|
||||
"thu": "Thứ 5",
|
||||
"fri": "Thứ 6",
|
||||
"sat": "Thứ 7"
|
||||
"sun": "CN",
|
||||
"mon": "Th 2",
|
||||
"tue": "Th 3",
|
||||
"wed": "Th 4",
|
||||
"thu": "Th 5",
|
||||
"fri": "Th 6",
|
||||
"sat": "Th 7"
|
||||
},
|
||||
"email": "Email",
|
||||
"status": "Trạng thái"
|
||||
},
|
||||
"home": {
|
||||
"categories": {
|
||||
"movies": "Phim lẻ",
|
||||
"series": "Phim bộ",
|
||||
"channels": "Truyền hình"
|
||||
"movies": "Phim",
|
||||
"series": "Chương trình TV",
|
||||
"channels": "Kênh truyền hình"
|
||||
},
|
||||
"movies": "Phim lẻ",
|
||||
"tv_shows": "Chương trình truyền hình",
|
||||
"movies": "Phim",
|
||||
"tv_shows": "Chương trình TV",
|
||||
"load_more_catalogs": "Tải thêm danh mục",
|
||||
"no_content": "Không có nội dung",
|
||||
"add_catalogs": "Thêm danh mục",
|
||||
"sign_in_available": "Đăng nhập ngay",
|
||||
"sign_in_desc": "Bạn có thể đăng nhập bất cứ lúc nào trong Cài đặt → Tài khoản",
|
||||
"view_all": "Xem tất cả",
|
||||
"view_all": "Xem thêm",
|
||||
"this_week": "Tuần này",
|
||||
"upcoming": "Sắp chiếu",
|
||||
"recently_released": "Vừa phát hành",
|
||||
"no_scheduled_episodes": "Phim bộ chưa có lịch chiếu",
|
||||
"recently_released": "Mới ra mắt",
|
||||
"no_scheduled_episodes": "Phim chưa có lịch chiếu",
|
||||
"check_back_later": "Quay lại sau nhé",
|
||||
"continue_watching": "Xem tiếp",
|
||||
"up_next": "Tiếp theo",
|
||||
"up_next_caps": "TIẾP THEO",
|
||||
"released": "Đã phát hành",
|
||||
"released": "Đang chiếu",
|
||||
"new": "Mới",
|
||||
"tba": "Chưa xác định",
|
||||
"new_episodes": "{{count}} tập mới",
|
||||
|
|
@ -70,17 +70,17 @@
|
|||
"episode_short": "T{{episode}}",
|
||||
"season": "Phần {{season}}",
|
||||
"episode": "Tập {{episode}}",
|
||||
"movie": "Phim lẻ",
|
||||
"series": "Phim bộ",
|
||||
"tv_show": "Chương trình truyền hình",
|
||||
"movie": "Phim",
|
||||
"series": "Chương trình TV",
|
||||
"tv_show": "Chương trình TV",
|
||||
"percent_watched": "Đã xem {{percent}}%",
|
||||
"view_details": "Xem chi tiết",
|
||||
"remove": "Xoá",
|
||||
"play": "Phát",
|
||||
"play": "Xem",
|
||||
"play_now": "Xem ngay",
|
||||
"resume": "Xem tiếp",
|
||||
"info": "Thông tin",
|
||||
"more_info": "Thêm thông tin",
|
||||
"more_info": "Thêm",
|
||||
"my_list": "Danh sách của tôi",
|
||||
"save": "Lưu",
|
||||
"saved": "Đã lưu",
|
||||
|
|
@ -105,8 +105,8 @@
|
|||
"title": "Tìm kiếm",
|
||||
"recent_searches": "Tìm kiếm gần đây",
|
||||
"discover": "Khám phá",
|
||||
"movies": "Phim lẻ",
|
||||
"tv_shows": "Phim bộ",
|
||||
"movies": "Phim",
|
||||
"tv_shows": "Chương trình TV",
|
||||
"select_catalog": "Chọn danh mục",
|
||||
"all_genres": "Tất cả thể loại",
|
||||
"discovering": "Đang tải nội dung...",
|
||||
|
|
@ -115,14 +115,14 @@
|
|||
"try_different": "Thử thể loại hoặc danh mục khác",
|
||||
"select_catalog_desc": "Chọn một danh mục để khám phá",
|
||||
"tap_catalog_desc": "Nhấn vào danh mục bên trên để bắt đầu",
|
||||
"placeholder": "Tìm phim, chương trình...",
|
||||
"keep_typing": "Tiếp tục nhập...",
|
||||
"type_characters": "Nhập ít nhất 2 ký tự để tìm kiếm",
|
||||
"placeholder": "Chương trình, Phim, v.v.",
|
||||
"keep_typing": "Vui lòng nhập tiếp...",
|
||||
"type_characters": "Nhập ít nhất từ 2 ký tự để tìm kiếm",
|
||||
"no_results": "Không có kết quả",
|
||||
"try_keywords": "Thử từ khoá khác hoặc kiểm tra chính tả",
|
||||
"select_type": "Chọn loại",
|
||||
"browse_movies": "Duyệt danh mục phim lẻ",
|
||||
"browse_tv": "Duyệt danh mục phim bộ",
|
||||
"select_type": "Chọn loại danh mục",
|
||||
"browse_movies": "Duyệt danh mục phim",
|
||||
"browse_tv": "Duyệt danh mục chương trình TV",
|
||||
"select_genre": "Chọn thể loại",
|
||||
"show_all_content": "Hiển thị tất cả nội dung",
|
||||
"genres_count": "{{count}} thể loại"
|
||||
|
|
@ -143,8 +143,8 @@
|
|||
"empty_folder": "Không có nội dung trong {{folder}}",
|
||||
"empty_folder_desc": "Bộ sưu tập này đang trống",
|
||||
"refresh": "Làm mới",
|
||||
"no_movies": "Chưa có phim lẻ",
|
||||
"no_series": "Chưa có phim bộ",
|
||||
"no_movies": "Chưa có phim",
|
||||
"no_series": "Chưa có chương trình TV",
|
||||
"no_content": "Chưa có nội dung",
|
||||
"add_content_desc": "Thêm nội dung vào thư viện để xem ở đây",
|
||||
"find_something": "Tìm gì đó để xem",
|
||||
|
|
@ -200,7 +200,7 @@
|
|||
"access_denied": "Truy cập bị từ chối",
|
||||
"access_denied_desc": "Bạn không có quyền truy cập nội dung này.",
|
||||
"connection_error": "Lỗi kết nối",
|
||||
"streams_unavailable": "Không có luồng phát",
|
||||
"streams_unavailable": "Không có nguồn phát",
|
||||
"streams_unavailable_desc": "Nguồn phát hiện tại không khả dụng. Vui lòng thử lại sau.",
|
||||
"unknown_error": "Lỗi không xác định",
|
||||
"something_went_wrong": "Đã xảy ra sự cố. Vui lòng thử lại.",
|
||||
|
|
@ -222,16 +222,16 @@
|
|||
"episode_label": "TẬP {{number}}",
|
||||
"watch_again": "Xem lại",
|
||||
"completed": "Đã xem xong",
|
||||
"play_episode": "Phát P{{season}}T{{episode}}",
|
||||
"play": "Phát",
|
||||
"play_episode": "Xem P{{season}}T{{episode}}",
|
||||
"play": "Xem",
|
||||
"watched": "Đã xem",
|
||||
"watched_on_trakt": "Đã xem trên Trakt",
|
||||
"synced_with_trakt": "Đồng bộ với Trakt",
|
||||
"saved": "Đã lưu",
|
||||
"director": "Đạo diễn",
|
||||
"directors": "Đạo diễn",
|
||||
"creator": "Người tạo",
|
||||
"creators": "Những người tạo",
|
||||
"creator": "Tác giả",
|
||||
"creators": "Tác giả",
|
||||
"production": "Sản xuất",
|
||||
"network": "Kênh phát sóng",
|
||||
"mark_watched": "Đánh dấu đã xem",
|
||||
|
|
@ -242,12 +242,12 @@
|
|||
"mark_season": "Đánh dấu Phần {{season}}",
|
||||
"resume": "Xem tiếp",
|
||||
"spoiler_warning": "Cảnh báo tiết lộ nội dung",
|
||||
"spoiler_warning_desc": "Bình luận này chứa nội dung tiết lộ. Bạn có muốn hiển thị không?",
|
||||
"spoiler_warning_desc": "Bình luận này có tiết lộ nội dung phim. Bạn có muốn hiển thị không?",
|
||||
"cancel": "Huỷ",
|
||||
"reveal_spoilers": "Hiện nội dung tiết lộ",
|
||||
"movie_details": "Thông tin phim",
|
||||
"show_details": "Thông tin chương trình",
|
||||
"tagline": "Khẩu hiệu",
|
||||
"tagline": "Slogan",
|
||||
"status": "Trạng thái",
|
||||
"release_date": "Ngày phát hành",
|
||||
"runtime": "Thời lượng",
|
||||
|
|
@ -260,7 +260,7 @@
|
|||
"total_episodes": "Tổng số tập",
|
||||
"episode_runtime": "Thời lượng mỗi tập",
|
||||
"created_by": "Tạo bởi",
|
||||
"backdrop_gallery": "Thư viện ảnh nền",
|
||||
"backdrop_gallery": "Thư viện ảnh",
|
||||
"loading_episodes": "Đang tải tập phim...",
|
||||
"no_episodes_available": "Không có tập phim",
|
||||
"play_next": "Phát P{{season}}T{{episode}}",
|
||||
|
|
@ -304,12 +304,12 @@
|
|||
"alert_ok": "OK",
|
||||
"no_upcoming": "Không có tác phẩm sắp tới của diễn viên này",
|
||||
"no_content": "Không có nội dung của diễn viên này",
|
||||
"no_movies": "Không có phim lẻ của diễn viên này",
|
||||
"no_tv": "Không có phim bộ của diễn viên này"
|
||||
"no_movies": "Không có phim của diễn viên này",
|
||||
"no_tv": "Không có chương trình TV của diễn viên này"
|
||||
},
|
||||
"comments": {
|
||||
"title": "Bình luận Trakt",
|
||||
"spoiler_warning": "⚠️ Bình luận này chứa nội dung tiết lộ. Nhấn để hiện.",
|
||||
"spoiler_warning": "⚠️ Bình luận này có tiết lộ nội dung phim. Nhấn để hiện.",
|
||||
"spoiler": "Tiết lộ nội dung",
|
||||
"contains_spoilers": "Chứa nội dung tiết lộ",
|
||||
"reveal": "Hiện",
|
||||
|
|
@ -327,8 +327,8 @@
|
|||
"teaser": "Teaser",
|
||||
"clips_scenes": "Clip & Cảnh phim",
|
||||
"clip": "Clip",
|
||||
"featurettes": "Hậu trường ngắn",
|
||||
"featurette": "Hậu trường ngắn",
|
||||
"featurettes": "Hậu trường",
|
||||
"featurette": "Hậu trường",
|
||||
"behind_the_scenes": "Hậu trường",
|
||||
"no_trailers": "Không có trailer",
|
||||
"unavailable": "Trailer không khả dụng",
|
||||
|
|
@ -341,11 +341,11 @@
|
|||
"no_content_filters": "Không có nội dung cho bộ lọc đã chọn",
|
||||
"loading_content": "Đang tải nội dung...",
|
||||
"back": "Quay lại",
|
||||
"in_theaters": "Đang chiếu",
|
||||
"in_theaters": "Đang chiếu rạp",
|
||||
"all": "Tất cả",
|
||||
"failed_tmdb": "Không thể tải nội dung từ TMDB",
|
||||
"movies": "Phim lẻ",
|
||||
"tv_shows": "Phim bộ",
|
||||
"movies": "Phim",
|
||||
"tv_shows": "Chương trình TV",
|
||||
"channels": "Kênh truyền hình"
|
||||
},
|
||||
"streams": {
|
||||
|
|
@ -364,8 +364,8 @@
|
|||
},
|
||||
"player_ui": {
|
||||
"via": "từ {{name}}",
|
||||
"audio_tracks": "Rãnh âm thanh",
|
||||
"no_audio_tracks": "Không có rãnh âm thanh",
|
||||
"audio_tracks": "Âm thanh",
|
||||
"no_audio_tracks": "Không có âm thanh",
|
||||
"playback_speed": "Tốc độ phát",
|
||||
"on_hold": "Tạm dừng",
|
||||
"playback_error": "Lỗi phát",
|
||||
|
|
@ -374,7 +374,7 @@
|
|||
"copied_to_clipboard": "Đã sao chép",
|
||||
"dismiss": "Bỏ qua",
|
||||
"continue_watching": "Xem tiếp",
|
||||
"start_over": "Xem từ đầu",
|
||||
"start_over": "Xem lại từ đầu",
|
||||
"resume": "Tiếp tục",
|
||||
"change_source": "Đổi nguồn",
|
||||
"switching_source": "Đang chuyển nguồn...",
|
||||
|
|
@ -386,7 +386,7 @@
|
|||
"episodes": "Tập phim",
|
||||
"specials": "Tập đặc biệt",
|
||||
"season": "Phần {{season}}",
|
||||
"stream": "Luồng {{number}}",
|
||||
"stream": "{{number}} nguồn",
|
||||
"subtitles": "Phụ đề",
|
||||
"built_in": "Tích hợp sẵn",
|
||||
"addons": "Addon",
|
||||
|
|
@ -462,6 +462,21 @@
|
|||
"cancel": "Huỷ",
|
||||
"remove": "Xoá"
|
||||
},
|
||||
"parentalGuide": {
|
||||
"labels": {
|
||||
"nudity": "Cảnh khoả thân",
|
||||
"violence": "Bạo lực",
|
||||
"profanity": "Ngôn từ thô tục",
|
||||
"alcohol": "Chất kích thích",
|
||||
"frightening": "Cảnh đáng sợ"
|
||||
},
|
||||
"severity": {
|
||||
"severe": "Nặng",
|
||||
"moderate": "Vừa phải",
|
||||
"mild": "Nhẹ",
|
||||
"none": "Không có"
|
||||
}
|
||||
},
|
||||
"addons": {
|
||||
"title": "Addon",
|
||||
"reorder_mode": "Sắp xếp lại",
|
||||
|
|
@ -579,14 +594,14 @@
|
|||
"recommendations": "Gợi ý",
|
||||
"recommendations_desc": "Nội dung tương tự",
|
||||
"episode_data": "Dữ liệu tập phim",
|
||||
"episode_data_desc": "Thumbnail tập, thông tin & dự phòng cho phim bộ",
|
||||
"episode_data_desc": "Thumbnail tập, thông tin & dự phòng cho chương trình TV",
|
||||
"season_posters": "Poster phần",
|
||||
"season_posters_desc": "Poster riêng cho từng phần",
|
||||
"production_info": "Thông tin sản xuất",
|
||||
"production_info_desc": "Kênh truyền hình & công ty sản xuất kèm logo",
|
||||
"movie_details": "Chi tiết phim lẻ",
|
||||
"movie_details": "Chi tiết phim",
|
||||
"movie_details_desc": "Ngân sách, doanh thu, thời lượng, khẩu hiệu",
|
||||
"tv_details": "Chi tiết phim bộ",
|
||||
"tv_details": "Chi tiết chương trình TV",
|
||||
"tv_details_desc": "Trạng thái, số phần, kênh, người tạo",
|
||||
"movie_collections": "Bộ phim series",
|
||||
"movie_collections_desc": "Phim trong cùng vũ trụ (Marvel, Star Wars...)",
|
||||
|
|
@ -654,11 +669,12 @@
|
|||
"romanian": "Tiếng Romania",
|
||||
"albanian": "Tiếng Albania",
|
||||
"catalan": "Tiếng Catalan",
|
||||
"vietnamese": "Tiếng Việt",
|
||||
"account": "Tài khoản",
|
||||
"content_discovery": "Nội dung & Khám phá",
|
||||
"appearance": "Giao diện",
|
||||
"integrations": "Tích hợp",
|
||||
"playback": "Phát",
|
||||
"playback": "Trình phát",
|
||||
"backup_restore": "Sao lưu & Khôi phục",
|
||||
"backup_restore_desc": "Tạo và khôi phục bản sao lưu ứng dụng",
|
||||
"updates": "Cập nhật",
|
||||
|
|
@ -675,7 +691,7 @@
|
|||
"clear_mdblist_cache": "Xoá bộ nhớ đệm MDBList",
|
||||
"cache_management": "QUẢN LÝ BỘ NHỚ ĐỆM",
|
||||
"downloads_counter": "lượt tải và đang tăng",
|
||||
"made_with_love": "Được tạo với ❤️ bởi Tapframe và cộng sự",
|
||||
"made_with_love": "Được tạo với ❤️ bởi Tapframe và cộng đồng",
|
||||
"sections": {
|
||||
"information": "THÔNG TIN",
|
||||
"account": "TÀI KHOẢN",
|
||||
|
|
@ -694,12 +710,12 @@
|
|||
"danger_zone": "KHU VỰC NGUY HIỂM"
|
||||
},
|
||||
"items": {
|
||||
"legal": "Pháp lý & Tuyên bố miễn trách",
|
||||
"privacy_policy": "Chính sách quyền riêng tư",
|
||||
"legal": "Pháp lý & Quy định",
|
||||
"privacy_policy": "Chính sách bảo mật",
|
||||
"report_issue": "Báo cáo sự cố",
|
||||
"version": "Phiên bản",
|
||||
"contributors": "Đóng góp viên",
|
||||
"view_contributors": "Xem tất cả đóng góp viên",
|
||||
"contributors": "Người đóng góp",
|
||||
"view_contributors": "Xem tất cả người đóng góp",
|
||||
"theme": "Giao diện",
|
||||
"episode_layout": "Bố cục tập phim",
|
||||
"streams_backdrop": "Ảnh nền nguồn phát",
|
||||
|
|
@ -715,7 +731,7 @@
|
|||
"home_screen": "Màn hình chính",
|
||||
"home_screen_desc": "Bố cục và nội dung",
|
||||
"continue_watching": "Xem tiếp",
|
||||
"continue_watching_desc": "Bộ nhớ đệm và hành vi phát",
|
||||
"continue_watching_desc": "Bộ nhớ đệm và chế độ phát",
|
||||
"show_discover": "Hiện phần Khám phá",
|
||||
"show_discover_desc": "Hiển thị nội dung khám phá trong Tìm kiếm",
|
||||
"mdblist": "MDBList",
|
||||
|
|
@ -738,7 +754,7 @@
|
|||
"auto_select_subs": "Tự động chọn phụ đề",
|
||||
"auto_select_subs_desc": "Tự động chọn phụ đề theo tuỳ chọn của bạn",
|
||||
"show_trailers": "Hiện trailer",
|
||||
"show_trailers_desc": "Hiển thị trailer trong phần hero",
|
||||
"show_trailers_desc": "Hiển thị trailer trong banner nổi bật",
|
||||
"enable_downloads": "Bật tải về",
|
||||
"enable_downloads_desc": "Hiện tab Tải về và cho phép lưu luồng phát",
|
||||
"notifications": "Thông báo",
|
||||
|
|
@ -760,9 +776,9 @@
|
|||
"internal_first": "Ưu tiên tích hợp",
|
||||
"internal_first_desc": "Ưu tiên phụ đề nhúng sẵn, rồi đến bên ngoài",
|
||||
"external_first": "Ưu tiên bên ngoài",
|
||||
"external_first_desc": "Ưu tiên phụ đề addon, rồi đến nhúng sẵn",
|
||||
"any_available": "Tuỳ có",
|
||||
"any_available_desc": "Dùng rãnh phụ đề đầu tiên có sẵn"
|
||||
"external_first_desc": "Ưu tiên phụ đề từ addon, rồi đến nhúng sẵn",
|
||||
"any_available": "Nguồn bất kỳ",
|
||||
"any_available_desc": "Tự động chọn phụ đề đầu tiên"
|
||||
},
|
||||
"clear_data_desc": "Thao tác này sẽ đặt lại tất cả cài đặt và xoá dữ liệu đệm. Bạn có chắc không?",
|
||||
"app_updates": "Cập nhật ứng dụng",
|
||||
|
|
@ -873,7 +889,7 @@
|
|||
"ai_settings": {
|
||||
"title": "Trợ lý AI",
|
||||
"info_title": "Chat hỗ trợ bởi AI",
|
||||
"info_desc": "Đặt câu hỏi về bất kỳ phim hay tập phim bộ nào bằng AI. Nhận thông tin về cốt truyện, nhân vật, chủ đề, kiến thức thú vị và nhiều hơn nữa - hỗ trợ bởi dữ liệu TMDB toàn diện.",
|
||||
"info_desc": "Đặt câu hỏi về bất kỳ phim hay tập chương trình TV nào bằng AI. Nhận thông tin về cốt truyện, nhân vật, chủ đề, kiến thức thú vị và nhiều hơn nữa - hỗ trợ bởi dữ liệu TMDB toàn diện.",
|
||||
"feature_1": "Ngữ cảnh và phân tích từng tập",
|
||||
"feature_2": "Giải thích cốt truyện và nhân vật",
|
||||
"feature_3": "Kiến thức hậu trường thú vị",
|
||||
|
|
@ -889,7 +905,7 @@
|
|||
"enable_chat": "Bật chat AI",
|
||||
"enable_chat_desc": "Khi bật, nút Hỏi AI sẽ xuất hiện trên trang nội dung.",
|
||||
"chat_enabled": "Đã bật chat AI",
|
||||
"chat_enabled_desc": "Bạn có thể đặt câu hỏi về phim và phim bộ. Tìm nút \"Hỏi AI\" trên trang nội dung!",
|
||||
"chat_enabled_desc": "Bạn có thể đặt câu hỏi về phim và chương trình TV. Tìm nút \"Hỏi AI\" trên trang nội dung!",
|
||||
"how_it_works": "Cách hoạt động",
|
||||
"how_it_works_desc": "• OpenRouter cung cấp quyền truy cập nhiều mô hình AI\n• API key của bạn được giữ bí mật và bảo mật\n• Gói miễn phí bao gồm giới hạn sử dụng hào phóng\n• Chat với ngữ cảnh về tập/phim cụ thể\n• Nhận phân tích và giải thích chi tiết",
|
||||
"error_invalid_key": "Vui lòng nhập API key hợp lệ",
|
||||
|
|
@ -918,7 +934,7 @@
|
|||
},
|
||||
"continue_watching_settings": {
|
||||
"title": "Xem tiếp",
|
||||
"playback_behavior": "HÀNH VI PHÁT",
|
||||
"playback_behavior": "CHẾ ĐỘ PHÁT",
|
||||
"use_cached": "Dùng luồng đã lưu đệm",
|
||||
"use_cached_desc": "Khi bật, nhấn mục Xem tiếp sẽ mở trình phát trực tiếp dùng luồng đã phát trước đó. Khi tắt, sẽ mở màn hình nội dung.",
|
||||
"open_metadata": "Mở màn hình thông tin",
|
||||
|
|
@ -942,9 +958,9 @@
|
|||
"hours": "giờ"
|
||||
},
|
||||
"contributors": {
|
||||
"title": "Đóng góp viên",
|
||||
"title": "Người đóng góp",
|
||||
"special_mentions": "Nhắc đến đặc biệt",
|
||||
"tab_contributors": "Đóng góp viên",
|
||||
"tab_contributors": "Người đóng góp",
|
||||
"tab_special": "Nhắc đến đặc biệt",
|
||||
"tab_donors": "Nhà tài trợ",
|
||||
"manager_role": "Quản lý cộng đồng",
|
||||
|
|
@ -966,10 +982,10 @@
|
|||
"loading_donors": "Đang tải nhà tài trợ...",
|
||||
"no_donors": "Chưa có nhà tài trợ",
|
||||
"error_rate_limit": "Đã vượt giới hạn tốc độ GitHub API. Vui lòng thử lại sau hoặc kéo để làm mới.",
|
||||
"error_failed": "Không thể tải đóng góp viên. Vui lòng kiểm tra kết nối internet.",
|
||||
"error_failed": "Không thể tải người đóng góp. Vui lòng kiểm tra kết nối internet.",
|
||||
"retry": "Thử lại",
|
||||
"no_contributors": "Không tìm thấy đóng góp viên",
|
||||
"loading_contributors": "Đang tải đóng góp viên..."
|
||||
"no_contributors": "Không tìm thấy người đóng góp",
|
||||
"loading_contributors": "Đang tải người đóng góp..."
|
||||
},
|
||||
"debrid": {
|
||||
"title": "Tích hợp Debrid",
|
||||
|
|
@ -1036,7 +1052,7 @@
|
|||
"title": "Cài đặt màn hình chính",
|
||||
"changes_applied": "Đã áp dụng thay đổi",
|
||||
"display_options": "TUỲ CHỌN HIỂN THỊ",
|
||||
"show_hero": "Hiện phần hero",
|
||||
"show_hero": "Hiện banner nổi bật",
|
||||
"show_hero_desc": "Nội dung nổi bật ở trên cùng",
|
||||
"show_this_week": "Hiện phần Tuần này",
|
||||
"show_this_week_desc": "Tập mới trong tuần hiện tại",
|
||||
|
|
@ -1045,16 +1061,16 @@
|
|||
"selected": "đã chọn",
|
||||
"prefer_external_meta": "Ưu tiên Addon metadata bên ngoài",
|
||||
"prefer_external_meta_desc": "Dùng metadata bên ngoài trên trang chi tiết",
|
||||
"hero_layout": "Bố cục hero",
|
||||
"hero_layout": "Bố cục banner nổi bật",
|
||||
"layout_legacy": "Cổ điển",
|
||||
"layout_carousel": "Băng chuyền",
|
||||
"layout_carousel": "Danh sách",
|
||||
"layout_appletv": "Apple TV",
|
||||
"layout_desc": "Banner toàn màn hình, thẻ vuốt, hoặc kiểu Apple TV",
|
||||
"featured_source": "Nguồn nổi bật",
|
||||
"using_catalogs": "Dùng danh mục",
|
||||
"manage_selected_catalogs": "Quản lý danh mục đã chọn",
|
||||
"dynamic_bg": "Nền hero động",
|
||||
"dynamic_bg_desc": "Ảnh nền mờ phía sau băng chuyền",
|
||||
"dynamic_bg": "Nền banner nổi bật động",
|
||||
"dynamic_bg_desc": "Ảnh nền mờ phía sau danh sách",
|
||||
"performance_note": "Có thể ảnh hưởng hiệu suất trên thiết bị cấu hình thấp.",
|
||||
"posters": "Poster",
|
||||
"show_titles": "Hiện tiêu đề",
|
||||
|
|
@ -1069,14 +1085,14 @@
|
|||
"about_these_settings": "VỀ CÁC CÀI ĐẶT NÀY",
|
||||
"about_desc": "Các cài đặt này kiểm soát cách hiển thị nội dung trên màn hình chính. Thay đổi được áp dụng ngay mà không cần khởi động lại ứng dụng.",
|
||||
"hero_catalogs": {
|
||||
"title": "Danh mục phần hero",
|
||||
"title": "Danh mục banner nổi bật",
|
||||
"select_all": "Chọn tất cả",
|
||||
"clear_all": "Bỏ chọn tất cả",
|
||||
"info": "Chọn danh mục hiển thị trong phần hero. Nếu không chọn, tất cả danh mục sẽ được dùng. Đừng quên nhấn Lưu khi xong.",
|
||||
"info": "Chọn danh mục hiển thị trong banner nổi bật. Nếu không chọn, tất cả danh mục sẽ được dùng. Đừng quên nhấn Lưu khi xong.",
|
||||
"settings_saved": "Đã lưu cài đặt",
|
||||
"error_load": "Tải danh mục thất bại",
|
||||
"movies": "Phim lẻ",
|
||||
"tv_shows": "Phim bộ"
|
||||
"movies": "Phim",
|
||||
"tv_shows": "Chương trình TV"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
|
|
@ -1088,7 +1104,7 @@
|
|||
"show_all_episodes": "Hiện tất cả tập",
|
||||
"no_episodes_for": "Không có tập cho {{date}}",
|
||||
"no_upcoming_found": "Không tìm thấy tập sắp tới",
|
||||
"add_series_desc": "Thêm phim bộ vào thư viện để xem các tập sắp tới ở đây"
|
||||
"add_series_desc": "Thêm chương trình TV vào thư viện để xem các tập sắp tới ở đây"
|
||||
},
|
||||
"mdblist": {
|
||||
"title": "Nguồn xếp hạng",
|
||||
|
|
@ -1142,7 +1158,7 @@
|
|||
"stats_total": "Tổng cộng",
|
||||
"sync_button": "Đồng bộ Thư viện & Trakt",
|
||||
"syncing": "Đang đồng bộ...",
|
||||
"sync_desc": "Tự động đồng bộ thông báo cho tất cả phim bộ trong thư viện và danh sách/bộ sưu tập Trakt.",
|
||||
"sync_desc": "Tự động đồng bộ thông báo cho tất cả chương trình TV trong thư viện và danh sách/bộ sưu tập Trakt.",
|
||||
"section_advanced": "Nâng cao",
|
||||
"reset_button": "Đặt lại tất cả thông báo",
|
||||
"test_button": "Kiểm tra thông báo (5 giây)",
|
||||
|
|
@ -1163,7 +1179,7 @@
|
|||
"section_addons": "Addon & Tích hợp",
|
||||
"section_settings": "Cài đặt & Tuỳ chọn",
|
||||
"library_label": "Thư viện",
|
||||
"library_desc": "Phim lẻ và phim bộ đã lưu",
|
||||
"library_desc": "Phim và chương trình TV đã lưu",
|
||||
"watch_progress_label": "Tiến độ xem",
|
||||
"watch_progress_desc": "Vị trí xem tiếp",
|
||||
"addons_label": "Addon",
|
||||
|
|
@ -1208,8 +1224,8 @@
|
|||
"status_error": "Cập nhật thất bại",
|
||||
"status_ready": "Sẵn sàng kiểm tra cập nhật",
|
||||
"action_check": "Kiểm tra cập nhật",
|
||||
"action_install": "Cài cập nhật",
|
||||
"release_notes": "Ghi chú phát hành:",
|
||||
"action_install": "Cài bản cập nhật",
|
||||
"release_notes": "Chi tiết bản cập nhật:",
|
||||
"version": "Phiên bản:",
|
||||
"last_checked": "Kiểm tra lần cuối:",
|
||||
"current_version": "Phiên bản hiện tại:",
|
||||
|
|
@ -1320,8 +1336,8 @@
|
|||
"placeholder": "Tìm plugin...",
|
||||
"all": "Tất cả",
|
||||
"filter_all": "Tất cả loại",
|
||||
"filter_movies": "Phim lẻ",
|
||||
"filter_tv": "Phim bộ",
|
||||
"filter_movies": "Phim",
|
||||
"filter_tv": "Chương trình TV",
|
||||
"enable_all": "Bật tất cả",
|
||||
"disable_all": "Tắt tất cả",
|
||||
"no_plugins_found": "Không tìm thấy plugin",
|
||||
|
|
@ -1395,9 +1411,9 @@
|
|||
}
|
||||
},
|
||||
"legal": {
|
||||
"title": "Pháp lý & Tuyên bố miễn trách",
|
||||
"title": "Pháp lý & Quy định",
|
||||
"intro_title": "Bản chất ứng dụng",
|
||||
"intro_text": "Nuvio là ứng dụng trình phát phương tiện và quản lý metadata. Nó chỉ hoạt động như giao diện phía máy khách để duyệt metadata công khai (phim, phim bộ...) và phát file phương tiện do người dùng hoặc tiện ích mở rộng bên thứ ba cung cấp. Nuvio không lưu trữ, phân phối hay lập chỉ mục nội dung phương tiện.",
|
||||
"intro_text": "Nuvio là ứng dụng trình phát phương tiện và quản lý metadata. Nó chỉ hoạt động như giao diện phía máy khách để duyệt metadata công khai (phim, chương trình TV...) và phát file phương tiện do người dùng hoặc tiện ích mở rộng bên thứ ba cung cấp. Nuvio không lưu trữ, phân phối hay lập chỉ mục nội dung phương tiện.",
|
||||
"extensions_title": "Plugin bên thứ ba",
|
||||
"extensions_text": "Nuvio sử dụng kiến trúc mở rộng cho phép người dùng cài plugin bên thứ ba. Các plugin này được phát triển và duy trì bởi các nhà phát triển độc lập không liên kết với Nuvio. Chúng tôi không kiểm soát và không chịu trách nhiệm về nội dung, tính hợp pháp hay chức năng của bất kỳ plugin bên thứ ba nào.",
|
||||
"user_resp_title": "Trách nhiệm người dùng",
|
||||
|
|
@ -1420,8 +1436,8 @@
|
|||
"common": {
|
||||
"error": "Lỗi",
|
||||
"success": "Thành công",
|
||||
"movie": "Phim lẻ",
|
||||
"tv": "Phim bộ",
|
||||
"movie": "Phim",
|
||||
"tv": "Chương trình TV",
|
||||
"tmdb_id": "TMDB ID",
|
||||
"season": "Phần",
|
||||
"episode": "Tập",
|
||||
|
|
@ -1444,18 +1460,18 @@
|
|||
"code_placeholder": "// Dán code plugin vào đây...",
|
||||
"test_parameters": "Tham số kiểm tra",
|
||||
"no_logs": "Chưa có log. Chạy kiểm tra để xem đầu ra.",
|
||||
"no_streams": "Chưa tìm thấy luồng nào.",
|
||||
"streams_found": "Tìm thấy {{count}} luồng",
|
||||
"streams_found_plural": "Tìm thấy {{count}} luồng",
|
||||
"tap_play_hint": "Nhấn Phát để kiểm tra luồng trong trình phát.",
|
||||
"unnamed_stream": "Luồng không tên",
|
||||
"no_streams": "Chưa tìm thấy nguồn nào.",
|
||||
"streams_found": "Tìm thấy {{count}} nguồn",
|
||||
"streams_found_plural": "Tìm thấy {{count}} nguồn",
|
||||
"tap_play_hint": "Nhấn Xem để kiểm tra nguồn trong trình phát.",
|
||||
"unnamed_stream": "Nguồn không tên",
|
||||
"quality": "Chất lượng: {{quality}}",
|
||||
"size": "Kích thước: {{size}}",
|
||||
"url_label": "URL: {{url}}",
|
||||
"headers_info": "Header: {{count}} header tuỳ chỉnh",
|
||||
"find_placeholder": "Tìm trong code…",
|
||||
"edit_code_title": "Sửa Code",
|
||||
"no_url_stream_error": "Không tìm thấy URL cho luồng này"
|
||||
"no_url_stream_error": "Không tìm thấy URL cho nguồn này"
|
||||
},
|
||||
"repo": {
|
||||
"title": "Kiểm tra kho",
|
||||
|
|
@ -1489,4 +1505,4 @@
|
|||
"no_logs_captured": "Chưa có log nào."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1536,8 +1536,29 @@ class CatalogService {
|
|||
const addonOrderRef: Record<string, number> = {};
|
||||
searchableAddons.forEach((addon, i) => { addonOrderRef[addon.id] = i; });
|
||||
|
||||
// Global dedupe across emitted results
|
||||
const globalSeen = new Set<string>();
|
||||
// Human-readable labels for known content types
|
||||
const CATALOG_TYPE_LABELS: Record<string, string> = {
|
||||
'movie': 'Movies',
|
||||
'series': 'TV Shows',
|
||||
'anime.series': 'Anime Series',
|
||||
'anime.movie': 'Anime Movies',
|
||||
'other': 'Other',
|
||||
'tv': 'TV',
|
||||
'channel': 'Channels',
|
||||
};
|
||||
const GENERIC_CATALOG_NAMES = new Set(['search', 'Search']);
|
||||
|
||||
// Collect all sections from all addons first, then sort and dedup before emitting.
|
||||
// This avoids race conditions where concurrent addon workers steal each other's IDs
|
||||
// from a shared globalSeen set before they get a chance to emit.
|
||||
type PendingSection = {
|
||||
addonId: string;
|
||||
addonName: string;
|
||||
sectionName: string;
|
||||
catalogIndex: number;
|
||||
results: StreamingContent[];
|
||||
};
|
||||
const allPendingSections: PendingSection[] = [];
|
||||
|
||||
await Promise.all(
|
||||
searchableAddons.map(async (addon) => {
|
||||
|
|
@ -1552,47 +1573,24 @@ class CatalogService {
|
|||
const searchableCatalogs = (addon.catalogs || []).filter(catalog => this.canSearchCatalog(catalog));
|
||||
logger.log(`Searching ${addon.name} (${addon.id}) with ${searchableCatalogs.length} searchable catalogs`);
|
||||
|
||||
// Fetch all catalogs for this addon in parallel
|
||||
const settled = await Promise.allSettled(
|
||||
searchableCatalogs.map(c => this.searchAddonCatalog(manifest, c.type, c.id, trimmedQuery))
|
||||
);
|
||||
if (controller.cancelled) return;
|
||||
|
||||
// If addon has multiple search catalogs, emit each as its own section.
|
||||
// If only one, emit as a single addon section (original behaviour).
|
||||
const hasMultipleCatalogs = searchableCatalogs.length > 1;
|
||||
|
||||
const catalogResultsList: { catalog: any; results: StreamingContent[] }[] = [];
|
||||
for (let i = 0; i < searchableCatalogs.length; i++) {
|
||||
const s = settled[i];
|
||||
if (s.status === 'fulfilled' && Array.isArray(s.value) && s.value.length > 0) {
|
||||
catalogResultsList.push({ catalog: searchableCatalogs[i], results: s.value });
|
||||
} else if (s.status === 'rejected') {
|
||||
logger.warn(`Search failed for catalog ${searchableCatalogs[i].id} in ${addon.name}:`, s.reason);
|
||||
}
|
||||
}
|
||||
|
||||
if (catalogResultsList.length === 0) {
|
||||
logger.log(`No results from ${addon.name}`);
|
||||
return;
|
||||
}
|
||||
const addonRank = addonOrderRef[addon.id] ?? Number.MAX_SAFE_INTEGER;
|
||||
|
||||
if (hasMultipleCatalogs) {
|
||||
// Human-readable labels for known content types used as fallback section names
|
||||
const CATALOG_TYPE_LABELS: Record<string, string> = {
|
||||
'movie': 'Movies',
|
||||
'series': 'TV Shows',
|
||||
'anime.series': 'Anime Series',
|
||||
'anime.movie': 'Anime Movies',
|
||||
'other': 'Other',
|
||||
'tv': 'TV',
|
||||
'channel': 'Channels',
|
||||
};
|
||||
for (let ci = 0; ci < searchableCatalogs.length; ci++) {
|
||||
const s = settled[ci];
|
||||
const catalog = searchableCatalogs[ci];
|
||||
if (s.status === 'rejected' || !(s as PromiseFulfilledResult<StreamingContent[]>).value?.length) {
|
||||
if (s.status === 'rejected') logger.warn(`Search failed for ${catalog.id} in ${addon.name}:`, s.reason);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Emit each catalog as its own section, in manifest order
|
||||
for (let ci = 0; ci < catalogResultsList.length; ci++) {
|
||||
const { catalog, results } = catalogResultsList[ci];
|
||||
if (controller.cancelled) return;
|
||||
const results = (s as PromiseFulfilledResult<StreamingContent[]>).value;
|
||||
|
||||
// Within-catalog dedup: prefer dot-type over generic for same ID
|
||||
const bestById = new Map<string, StreamingContent>();
|
||||
|
|
@ -1604,74 +1602,63 @@ class CatalogService {
|
|||
}
|
||||
|
||||
// Stamp catalog type onto results
|
||||
const stamped = Array.from(bestById.values()).map(item => {
|
||||
if (catalog.type && item.type !== catalog.type) {
|
||||
return { ...item, type: catalog.type };
|
||||
}
|
||||
return item;
|
||||
});
|
||||
const stamped = Array.from(bestById.values()).map(item =>
|
||||
catalog.type && item.type !== catalog.type ? { ...item, type: catalog.type } : item
|
||||
);
|
||||
|
||||
// Dedupe against global seen
|
||||
const unique = stamped.filter(item => {
|
||||
const key = `${item.type}:${item.id}`;
|
||||
if (globalSeen.has(key)) return false;
|
||||
globalSeen.add(key);
|
||||
return true;
|
||||
});
|
||||
// Build section name — use type label if catalog name is generic
|
||||
const typeLabel = CATALOG_TYPE_LABELS[catalog.type]
|
||||
|| catalog.type.replace(/[._]/g, ' ').replace(/\w/g, (c: string) => c.toUpperCase());
|
||||
const catalogLabel = (!catalog.name || GENERIC_CATALOG_NAMES.has(catalog.name) || catalog.name === addon.name)
|
||||
? typeLabel
|
||||
: catalog.name;
|
||||
const sectionName = `${addon.name} - ${catalogLabel}`;
|
||||
const catalogIndex = addonRank * 1000 + ci;
|
||||
|
||||
if (unique.length > 0 && !controller.cancelled) {
|
||||
// Build section name:
|
||||
// - If catalog.name is generic ("Search") or same as addon name, use type label instead
|
||||
// - Otherwise use catalog.name as-is
|
||||
const GENERIC_NAMES = new Set(['search', 'Search']);
|
||||
const typeLabel = CATALOG_TYPE_LABELS[catalog.type]
|
||||
|| catalog.type.replace(/[._]/g, ' ').replace(/\w/g, (c: string) => c.toUpperCase());
|
||||
const catalogLabel = (!catalog.name || GENERIC_NAMES.has(catalog.name) || catalog.name === addon.name)
|
||||
? typeLabel
|
||||
: catalog.name;
|
||||
const sectionName = `${addon.name} - ${catalogLabel}`;
|
||||
|
||||
// catalogIndex encodes addon rank + position within addon for deterministic ordering
|
||||
const addonRank = addonOrderRef[addon.id] ?? Number.MAX_SAFE_INTEGER;
|
||||
const catalogIndex = addonRank * 1000 + ci;
|
||||
|
||||
logger.log(`Emitting ${unique.length} results from ${sectionName}`);
|
||||
onAddonResults({ addonId: `${addon.id}||${catalog.id}`, addonName: addon.name, sectionName, catalogIndex, results: unique });
|
||||
}
|
||||
allPendingSections.push({ addonId: `${addon.id}||${catalog.id}`, addonName: addon.name, sectionName, catalogIndex, results: stamped });
|
||||
}
|
||||
} else {
|
||||
// Single catalog — one section per addon
|
||||
const allResults = catalogResultsList.flatMap(c => c.results);
|
||||
const s = settled[0];
|
||||
const catalog = searchableCatalogs[0];
|
||||
if (!s || s.status === 'rejected' || !(s as PromiseFulfilledResult<StreamingContent[]>).value?.length) {
|
||||
if (s?.status === 'rejected') logger.warn(`Search failed for ${addon.name}:`, s.reason);
|
||||
return;
|
||||
}
|
||||
|
||||
const bestByIdWithinAddon = new Map<string, StreamingContent>();
|
||||
for (const item of allResults) {
|
||||
const existing = bestByIdWithinAddon.get(item.id);
|
||||
const results = (s as PromiseFulfilledResult<StreamingContent[]>).value;
|
||||
const bestById = new Map<string, StreamingContent>();
|
||||
for (const item of results) {
|
||||
const existing = bestById.get(item.id);
|
||||
if (!existing || (!existing.type.includes('.') && item.type.includes('.'))) {
|
||||
bestByIdWithinAddon.set(item.id, item);
|
||||
bestById.set(item.id, item);
|
||||
}
|
||||
}
|
||||
const deduped = Array.from(bestByIdWithinAddon.values());
|
||||
const stamped = Array.from(bestById.values()).map(item =>
|
||||
catalog.type && item.type !== catalog.type ? { ...item, type: catalog.type } : item
|
||||
);
|
||||
|
||||
const localSeen = new Set<string>();
|
||||
const unique = deduped.filter(item => {
|
||||
const key = `${item.type}:${item.id}`;
|
||||
if (localSeen.has(key) || globalSeen.has(key)) return false;
|
||||
localSeen.add(key);
|
||||
globalSeen.add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
if (unique.length > 0 && !controller.cancelled) {
|
||||
const addonRank = addonOrderRef[addon.id] ?? Number.MAX_SAFE_INTEGER;
|
||||
logger.log(`Emitting ${unique.length} results from ${addon.name}`);
|
||||
onAddonResults({ addonId: addon.id, addonName: addon.name, sectionName: addon.name, catalogIndex: addonRank * 1000, results: unique });
|
||||
}
|
||||
allPendingSections.push({ addonId: addon.id, addonName: addon.name, sectionName: addon.name, catalogIndex: addonRank * 1000, results: stamped });
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(`Error searching addon ${addon.name} (${addon.id}):`, e);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (controller.cancelled) return;
|
||||
|
||||
// Sort by catalogIndex (addon manifest order + position within addon) then emit.
|
||||
// No cross-section dedup — each section is shown separately so duplicates across
|
||||
// sections are intentional (e.g. same movie in Cinemeta and People Search).
|
||||
allPendingSections.sort((a, b) => a.catalogIndex - b.catalogIndex);
|
||||
|
||||
for (const section of allPendingSections) {
|
||||
if (controller.cancelled) return;
|
||||
if (section.results.length > 0) {
|
||||
logger.log(`Emitting ${section.results.length} results from ${section.sectionName}`);
|
||||
onAddonResults({ addonId: section.addonId, addonName: section.addonName, sectionName: section.sectionName, catalogIndex: section.catalogIndex, results: section.results });
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -523,24 +523,58 @@ export class TMDBService {
|
|||
}
|
||||
|
||||
/**
|
||||
* Extract TMDB ID from Stremio ID
|
||||
* Stremio IDs for series are typically in the format: tt1234567:1:1 (imdbId:season:episode)
|
||||
* or just tt1234567 for the series itself
|
||||
* Extract TMDB ID from Stremio ID.
|
||||
* Handles standard IMDb IDs (tt1234567) as well as anime provider IDs:
|
||||
* - kitsu:12345 → looks up via ARM (arm.haglund.dev)
|
||||
* - mal:12345 → looks up via ARM
|
||||
* - anilist:12345 → looks up via ARM
|
||||
*/
|
||||
async extractTMDBIdFromStremioId(stremioId: string): Promise<number | null> {
|
||||
try {
|
||||
// Extract the base ID (remove season/episode info if present)
|
||||
const baseId = stremioId.split(':')[0];
|
||||
// Strip season/episode suffix — e.g. "kitsu:7936:5" → "kitsu:7936"
|
||||
const parts = stremioId.split(':');
|
||||
const prefix = parts[0];
|
||||
const numericId = parts[1];
|
||||
|
||||
// Only try to convert if it's an IMDb ID (starts with 'tt')
|
||||
if (!baseId.startsWith('tt')) {
|
||||
// Standard IMDb ID
|
||||
if (prefix.startsWith('tt') || /^\d{7,}$/.test(prefix)) {
|
||||
const baseId = prefix.startsWith('tt') ? prefix : `tt${prefix}`;
|
||||
return await this.findTMDBIdByIMDB(baseId);
|
||||
}
|
||||
|
||||
// Anime provider IDs — resolve via ARM (https://arm.haglund.dev/api/v2)
|
||||
const ARM_SOURCES: Record<string, string> = {
|
||||
kitsu: 'kitsu',
|
||||
mal: 'myanimelist',
|
||||
anilist: 'anilist',
|
||||
};
|
||||
|
||||
const armSource = ARM_SOURCES[prefix];
|
||||
if (armSource && numericId && /^\d+$/.test(numericId)) {
|
||||
const cacheKey = this.generateCacheKey('arm_tmdb', { source: armSource, id: numericId });
|
||||
const cached = this.getCachedData<number>(cacheKey);
|
||||
if (cached !== null) return cached;
|
||||
|
||||
logger.log(`[TMDB] Resolving TMDB ID for ${prefix}:${numericId} via ARM`);
|
||||
const response = await axios.get('https://arm.haglund.dev/api/v2/ids', {
|
||||
params: { source: armSource, id: numericId },
|
||||
timeout: 8000,
|
||||
});
|
||||
|
||||
const tmdbId: number | undefined = response.data?.themoviedb;
|
||||
if (tmdbId) {
|
||||
this.setCachedData(cacheKey, tmdbId);
|
||||
logger.log(`[TMDB] ARM resolved ${prefix}:${numericId} → TMDB ${tmdbId}`);
|
||||
return tmdbId;
|
||||
}
|
||||
|
||||
logger.warn(`[TMDB] ARM did not return a TMDB ID for ${prefix}:${numericId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use the existing findTMDBIdByIMDB function to get the TMDB ID
|
||||
const tmdbId = await this.findTMDBIdByIMDB(baseId);
|
||||
return tmdbId;
|
||||
return null;
|
||||
} catch (error) {
|
||||
logger.warn('[TMDB] extractTMDBIdFromStremioId failed:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1155,17 +1155,19 @@ export class TraktService {
|
|||
}
|
||||
|
||||
|
||||
public async isMovieWatchedAccurate(imdbId: string): Promise<boolean> {
|
||||
public async isMovieWatchedAccurate(imdbId: string, fallbackImdbId?: string): Promise<boolean> {
|
||||
try {
|
||||
const imdb = imdbId.startsWith('tt')
|
||||
? imdbId
|
||||
: `tt${imdbId}`;
|
||||
const normalise = (id: string) => id.startsWith('tt') ? id : `tt${id}`;
|
||||
const imdb = normalise(imdbId);
|
||||
const fallback = fallbackImdbId && fallbackImdbId !== imdbId && fallbackImdbId.trim()
|
||||
? normalise(fallbackImdbId)
|
||||
: null;
|
||||
|
||||
const movies = await this.apiRequest<any[]>('/sync/watched/movies');
|
||||
const moviesArray = Array.isArray(movies) ? movies : [];
|
||||
|
||||
return moviesArray.some(
|
||||
(m: any) => m.movie?.ids?.imdb === imdb
|
||||
(m: any) => m.movie?.ids?.imdb === imdb || (fallback && m.movie?.ids?.imdb === fallback)
|
||||
);
|
||||
} catch (err) {
|
||||
logger.warn('[TraktService] Movie watched check failed', err);
|
||||
|
|
@ -1176,14 +1178,20 @@ export class TraktService {
|
|||
public async isEpisodeWatchedAccurate(
|
||||
showImdbId: string,
|
||||
season: number,
|
||||
episode: number
|
||||
episode: number,
|
||||
fallbackImdbId?: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
if (season === 0) return false;
|
||||
|
||||
const imdb = showImdbId.startsWith('tt')
|
||||
? showImdbId
|
||||
: `tt${showImdbId}`;
|
||||
const normalise = (id: string) => id.startsWith('tt') ? id : `tt${id}`;
|
||||
const isRealImdbId = (id: string) => /^tt\d+$/.test(id) || /^\d{7,}$/.test(id);
|
||||
|
||||
// Use fallback if primary isn't a real IMDb ID
|
||||
const resolvedId = isRealImdbId(showImdbId) ? showImdbId
|
||||
: (fallbackImdbId && isRealImdbId(fallbackImdbId) ? fallbackImdbId : showImdbId);
|
||||
|
||||
const imdb = normalise(resolvedId);
|
||||
|
||||
const watchedShows = await this.apiRequest<any[]>(
|
||||
'/sync/watched/shows'
|
||||
|
|
@ -1521,16 +1529,25 @@ export class TraktService {
|
|||
imdbId: string,
|
||||
season: number,
|
||||
episode: number,
|
||||
watchedAt: Date = new Date()
|
||||
watchedAt: Date = new Date(),
|
||||
fallbackImdbId?: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const traktId = await this.getTraktIdFromImdbId(imdbId, 'show');
|
||||
const isImdbFormat = (id: string) => /^tt\d+$/.test(id) || /^\d{7,}$/.test(id);
|
||||
const resolvedId = isImdbFormat(imdbId) ? imdbId
|
||||
: (fallbackImdbId && isImdbFormat(fallbackImdbId) ? fallbackImdbId : imdbId);
|
||||
|
||||
if (resolvedId !== imdbId) {
|
||||
logger.log(`[TraktService] addToWatchedEpisodes: falling back from "${imdbId}" to "${resolvedId}"`);
|
||||
}
|
||||
|
||||
const traktId = await this.getTraktIdFromImdbId(resolvedId, 'show');
|
||||
if (!traktId) {
|
||||
logger.warn(`[TraktService] Could not find Trakt ID for show: ${imdbId}`);
|
||||
logger.warn(`[TraktService] Could not find Trakt ID for show: ${resolvedId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.log(`[TraktService] Marking S${season}E${episode} as watched for show ${imdbId} (trakt: ${traktId})`);
|
||||
logger.log(`[TraktService] Marking S${season}E${episode} as watched for show ${resolvedId} (trakt: ${traktId})`);
|
||||
|
||||
// Use shows array with seasons/episodes structure per Trakt API docs
|
||||
await this.apiRequest('/sync/history', 'POST', {
|
||||
|
|
@ -1570,16 +1587,22 @@ export class TraktService {
|
|||
public async markSeasonAsWatched(
|
||||
imdbId: string,
|
||||
season: number,
|
||||
watchedAt: Date = new Date()
|
||||
watchedAt: Date = new Date(),
|
||||
fallbackImdbId?: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const traktId = await this.getTraktIdFromImdbId(imdbId, 'show');
|
||||
const isImdbFormat = (id: string) => /^tt\d+$/.test(id) || /^\d{7,}$/.test(id);
|
||||
const resolvedId = isImdbFormat(imdbId) ? imdbId
|
||||
: (fallbackImdbId && isImdbFormat(fallbackImdbId) ? fallbackImdbId : imdbId);
|
||||
if (resolvedId !== imdbId) logger.log(`[TraktService] markSeasonAsWatched: falling back from "${imdbId}" to "${resolvedId}"`);
|
||||
|
||||
const traktId = await this.getTraktIdFromImdbId(resolvedId, 'show');
|
||||
if (!traktId) {
|
||||
logger.warn(`[TraktService] Could not find Trakt ID for show: ${imdbId}`);
|
||||
logger.warn(`[TraktService] Could not find Trakt ID for show: ${resolvedId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.log(`[TraktService] Marking entire season ${season} as watched for show ${imdbId} (trakt: ${traktId})`);
|
||||
logger.log(`[TraktService] Marking entire season ${season} as watched for show ${resolvedId} (trakt: ${traktId})`);
|
||||
|
||||
// Mark entire season - Trakt will mark all episodes in the season
|
||||
await this.apiRequest('/sync/history', 'POST', {
|
||||
|
|
@ -1614,7 +1637,8 @@ export class TraktService {
|
|||
public async markEpisodesAsWatched(
|
||||
imdbId: string,
|
||||
episodes: Array<{ season: number; episode: number }>,
|
||||
watchedAt: Date = new Date()
|
||||
watchedAt: Date = new Date(),
|
||||
fallbackImdbId?: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
if (episodes.length === 0) {
|
||||
|
|
@ -1622,13 +1646,18 @@ export class TraktService {
|
|||
return false;
|
||||
}
|
||||
|
||||
const traktId = await this.getTraktIdFromImdbId(imdbId, 'show');
|
||||
const isImdbFormat = (id: string) => /^tt\d+$/.test(id) || /^\d{7,}$/.test(id);
|
||||
const resolvedId = isImdbFormat(imdbId) ? imdbId
|
||||
: (fallbackImdbId && isImdbFormat(fallbackImdbId) ? fallbackImdbId : imdbId);
|
||||
if (resolvedId !== imdbId) logger.log(`[TraktService] markEpisodesAsWatched: falling back from "${imdbId}" to "${resolvedId}"`);
|
||||
|
||||
const traktId = await this.getTraktIdFromImdbId(resolvedId, 'show');
|
||||
if (!traktId) {
|
||||
logger.warn(`[TraktService] Could not find Trakt ID for show: ${imdbId}`);
|
||||
logger.warn(`[TraktService] Could not find Trakt ID for show: ${resolvedId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.log(`[TraktService] Marking ${episodes.length} episodes as watched for show ${imdbId}`);
|
||||
logger.log(`[TraktService] Marking ${episodes.length} episodes as watched for show ${resolvedId}`);
|
||||
|
||||
// Group episodes by season for the API call
|
||||
const seasonMap = new Map<number, Array<{ number: number; watched_at: string }>>();
|
||||
|
|
@ -1709,12 +1738,18 @@ export class TraktService {
|
|||
*/
|
||||
public async removeSeasonFromHistory(
|
||||
imdbId: string,
|
||||
season: number
|
||||
season: number,
|
||||
fallbackImdbId?: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
logger.log(`[TraktService] Removing season ${season} from history for show: ${imdbId}`);
|
||||
|
||||
const fullImdbId = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`;
|
||||
const isImdbFormat = (id: string) => /^tt\d+$/.test(id) || /^\d{7,}$/.test(id);
|
||||
const resolvedId = (!isImdbFormat(imdbId) && fallbackImdbId && isImdbFormat(fallbackImdbId))
|
||||
? fallbackImdbId
|
||||
: imdbId;
|
||||
|
||||
const fullImdbId = resolvedId.startsWith('tt') ? resolvedId : `tt${resolvedId}`;
|
||||
|
||||
const payload: TraktHistoryRemovePayload = {
|
||||
shows: [
|
||||
|
|
@ -1735,6 +1770,19 @@ export class TraktService {
|
|||
|
||||
const result = await this.removeFromHistory(payload);
|
||||
|
||||
if ((result === null || result.deleted.episodes === 0) && fallbackImdbId && fallbackImdbId !== resolvedId && isImdbFormat(fallbackImdbId)) {
|
||||
logger.log(`[TraktService] removeSeasonFromHistory: retrying with fallback ID "${fallbackImdbId}"`);
|
||||
const fb = fallbackImdbId.startsWith('tt') ? fallbackImdbId : `tt${fallbackImdbId}`;
|
||||
const fallbackResult = await this.removeFromHistory({
|
||||
shows: [{ ids: { imdb: fb }, seasons: [{ number: season }] }]
|
||||
});
|
||||
if (fallbackResult) {
|
||||
logger.log(`[TraktService] Season removal success via fallback: ${fallbackResult.deleted.episodes} episodes deleted`);
|
||||
return fallbackResult.deleted.episodes > 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (result) {
|
||||
const success = result.deleted.episodes > 0;
|
||||
logger.log(`[TraktService] Season removal success: ${success} (${result.deleted.episodes} episodes deleted)`);
|
||||
|
|
@ -1902,7 +1950,11 @@ export class TraktService {
|
|||
}
|
||||
|
||||
/**
|
||||
* Validate content data before making API calls
|
||||
* Validate content data before making API calls.
|
||||
*
|
||||
* IMDb ID validation is intentionally lenient: a non-IMDb provider ID (e.g. "kitsu:123")
|
||||
* is allowed through with a warning. Trakt can still scrobble via title + season/episode.
|
||||
* A truly empty ID is still blocked.
|
||||
*/
|
||||
private validateContentData(contentData: TraktContentData): { isValid: boolean; errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
|
|
@ -1915,8 +1967,11 @@ export class TraktService {
|
|||
errors.push('Missing or empty title');
|
||||
}
|
||||
|
||||
// Block only truly empty IDs — non-IMDb provider IDs are allowed (warn, don't fail)
|
||||
if (!contentData.imdbId || contentData.imdbId.trim() === '') {
|
||||
errors.push('Missing or empty IMDb ID');
|
||||
} else if (!/^tt\d+$/.test(contentData.imdbId) && !/^\d{7,}$/.test(contentData.imdbId)) {
|
||||
logger.warn(`[TraktService] imdbId "${contentData.imdbId}" is not a standard IMDb ID — Trakt will match by title/season/episode`);
|
||||
}
|
||||
|
||||
if (contentData.type === 'episode') {
|
||||
|
|
@ -1929,8 +1984,11 @@ export class TraktService {
|
|||
if (!contentData.showTitle || contentData.showTitle.trim() === '') {
|
||||
errors.push('Missing or empty show title');
|
||||
}
|
||||
if (!contentData.showYear || contentData.showYear < 1900) {
|
||||
errors.push('Invalid show year');
|
||||
// showYear is intentionally not required — Trakt can match episodes by
|
||||
// show title + season + episode number alone. Anime and many non-Western
|
||||
// shows often have year=0 or missing; blocking scrobble for them is wrong.
|
||||
if (contentData.showYear !== undefined && contentData.showYear > 0 && contentData.showYear < 1900) {
|
||||
logger.warn(`[TraktService] showYear ${contentData.showYear} looks invalid, omitting from payload`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1991,19 +2049,25 @@ export class TraktService {
|
|||
return null;
|
||||
}
|
||||
|
||||
// Ensure IMDb ID includes the 'tt' prefix for Trakt scrobble payloads
|
||||
const imdbIdWithPrefix = contentData.imdbId.startsWith('tt')
|
||||
? contentData.imdbId
|
||||
: `tt${contentData.imdbId}`;
|
||||
const isRealImdbId = /^tt\d+$/.test(contentData.imdbId) || /^\d{7,}$/.test(contentData.imdbId);
|
||||
|
||||
// Build movie payload - only include year if valid
|
||||
const movieData: { title: string; year?: number; ids: { imdb: string } } = {
|
||||
const movieData: { title: string; year?: number; ids: { imdb?: string } } = {
|
||||
title: contentData.title.trim(),
|
||||
ids: {
|
||||
imdb: imdbIdWithPrefix
|
||||
}
|
||||
ids: {}
|
||||
};
|
||||
|
||||
// Only add IMDb ID to payload when it's a real IMDb format — sending a provider ID
|
||||
// (e.g. "kitsu:123") causes Trakt to fail the lookup. Without it, Trakt matches by title.
|
||||
if (isRealImdbId) {
|
||||
const imdbIdWithPrefix = contentData.imdbId.startsWith('tt')
|
||||
? contentData.imdbId
|
||||
: `tt${contentData.imdbId}`;
|
||||
(movieData.ids as any).imdb = imdbIdWithPrefix;
|
||||
} else {
|
||||
logger.warn(`[TraktService] Movie imdbId "${contentData.imdbId}" is not IMDb format — omitting from scrobble payload, Trakt will match by title`);
|
||||
}
|
||||
|
||||
// Only add year if it's valid (prevents year: 0 or invalid years)
|
||||
if (isValidYear(contentData.year)) {
|
||||
movieData.year = contentData.year;
|
||||
|
|
@ -2067,12 +2131,17 @@ export class TraktService {
|
|||
progress: clampedProgress
|
||||
};
|
||||
|
||||
// Add show IMDB ID if available
|
||||
// Add show IMDB ID if available and valid IMDb format
|
||||
if (contentData.showImdbId && contentData.showImdbId.trim() !== '') {
|
||||
const showImdbWithPrefix = contentData.showImdbId.startsWith('tt')
|
||||
? contentData.showImdbId
|
||||
: `tt${contentData.showImdbId}`;
|
||||
payload.show.ids.imdb = showImdbWithPrefix;
|
||||
const isRealShowImdbId = /^tt\d+$/.test(contentData.showImdbId) || /^\d{7,}$/.test(contentData.showImdbId);
|
||||
if (isRealShowImdbId) {
|
||||
const showImdbWithPrefix = contentData.showImdbId.startsWith('tt')
|
||||
? contentData.showImdbId
|
||||
: `tt${contentData.showImdbId}`;
|
||||
payload.show.ids.imdb = showImdbWithPrefix;
|
||||
} else {
|
||||
logger.warn(`[TraktService] showImdbId "${contentData.showImdbId}" is not IMDb format — omitting from scrobble payload, Trakt will match by title`);
|
||||
}
|
||||
}
|
||||
|
||||
// Add episode IMDB ID if available (for specific episode IDs)
|
||||
|
|
@ -2679,21 +2748,43 @@ export class TraktService {
|
|||
}
|
||||
|
||||
/**
|
||||
* Remove a movie from watched history by IMDB ID
|
||||
* Remove a movie from watched history by IMDB ID.
|
||||
* If the primary imdbId is not a valid IMDb ID (e.g. a provider ID like "kitsu:123"),
|
||||
* falls back to fallbackImdbId (typically the resolved IMDb ID from metadata).
|
||||
*/
|
||||
public async removeMovieFromHistory(imdbId: string): Promise<boolean> {
|
||||
public async removeMovieFromHistory(imdbId: string, fallbackImdbId?: string): Promise<boolean> {
|
||||
try {
|
||||
const isImdbFormat = (id: string) => /^tt\d+$/.test(id) || /^\d{7,}$/.test(id);
|
||||
|
||||
// Resolve which ID to use: prefer a proper IMDb-format ID
|
||||
let resolvedId = imdbId;
|
||||
if (!isImdbFormat(imdbId) && fallbackImdbId && isImdbFormat(fallbackImdbId) && fallbackImdbId !== imdbId) {
|
||||
logger.log(`[TraktService] removeMovieFromHistory: "${imdbId}" is not IMDb format, falling back to "${fallbackImdbId}"`);
|
||||
resolvedId = fallbackImdbId;
|
||||
}
|
||||
|
||||
const payload: TraktHistoryRemovePayload = {
|
||||
movies: [
|
||||
{
|
||||
ids: {
|
||||
imdb: imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`
|
||||
imdb: resolvedId.startsWith('tt') ? resolvedId : `tt${resolvedId}`
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const result = await this.removeFromHistory(payload);
|
||||
|
||||
// If primary attempt deleted nothing and we haven't tried the fallback yet, try it
|
||||
if ((result === null || result.deleted.movies === 0) && fallbackImdbId && fallbackImdbId !== imdbId && fallbackImdbId !== resolvedId && isImdbFormat(fallbackImdbId)) {
|
||||
logger.log(`[TraktService] removeMovieFromHistory: primary attempt found nothing, retrying with fallback ID "${fallbackImdbId}"`);
|
||||
const fallbackPayload: TraktHistoryRemovePayload = {
|
||||
movies: [{ ids: { imdb: fallbackImdbId.startsWith('tt') ? fallbackImdbId : `tt${fallbackImdbId}` } }]
|
||||
};
|
||||
const fallbackResult = await this.removeFromHistory(fallbackPayload);
|
||||
return fallbackResult !== null && fallbackResult.deleted.movies > 0;
|
||||
}
|
||||
|
||||
return result !== null && result.deleted.movies > 0;
|
||||
} catch (error) {
|
||||
logger.error('[TraktService] Failed to remove movie from history:', error);
|
||||
|
|
@ -2704,14 +2795,24 @@ export class TraktService {
|
|||
/**
|
||||
* Remove an episode from watched history by IMDB IDs
|
||||
*/
|
||||
public async removeEpisodeFromHistory(showImdbId: string, season: number, episode: number): Promise<boolean> {
|
||||
public async removeEpisodeFromHistory(showImdbId: string, season: number, episode: number, fallbackImdbId?: string): Promise<boolean> {
|
||||
try {
|
||||
logger.log(`🔍 [TraktService] removeEpisodeFromHistory called for ${showImdbId} S${season}E${episode}`);
|
||||
|
||||
const isImdbFormat = (id: string) => /^tt\d+$/.test(id) || /^\d{7,}$/.test(id);
|
||||
const resolvedId = (!isImdbFormat(showImdbId) && fallbackImdbId && isImdbFormat(fallbackImdbId))
|
||||
? fallbackImdbId
|
||||
: showImdbId;
|
||||
|
||||
if (resolvedId !== showImdbId) {
|
||||
logger.log(`[TraktService] removeEpisodeFromHistory: "${showImdbId}" is not IMDb format, falling back to "${resolvedId}"`);
|
||||
}
|
||||
|
||||
const payload: TraktHistoryRemovePayload = {
|
||||
shows: [
|
||||
{
|
||||
ids: {
|
||||
imdb: showImdbId.startsWith('tt') ? showImdbId : `tt${showImdbId}`
|
||||
imdb: resolvedId.startsWith('tt') ? resolvedId : `tt${resolvedId}`
|
||||
},
|
||||
seasons: [
|
||||
{
|
||||
|
|
@ -2731,6 +2832,23 @@ export class TraktService {
|
|||
|
||||
const result = await this.removeFromHistory(payload);
|
||||
|
||||
// If nothing was deleted and we haven't tried the fallback yet, retry with it
|
||||
if ((result === null || result.deleted.episodes === 0) && fallbackImdbId && fallbackImdbId !== resolvedId && isImdbFormat(fallbackImdbId)) {
|
||||
logger.log(`[TraktService] removeEpisodeFromHistory: retrying with fallback ID "${fallbackImdbId}"`);
|
||||
const fallbackPayload: TraktHistoryRemovePayload = {
|
||||
shows: [{
|
||||
ids: { imdb: fallbackImdbId.startsWith('tt') ? fallbackImdbId : `tt${fallbackImdbId}` },
|
||||
seasons: [{ number: season, episodes: [{ number: episode }] }]
|
||||
}]
|
||||
};
|
||||
const fallbackResult = await this.removeFromHistory(fallbackPayload);
|
||||
if (fallbackResult) {
|
||||
logger.log(`✅ [TraktService] Episode removal success via fallback: ${fallbackResult.deleted.episodes} episodes deleted`);
|
||||
return fallbackResult.deleted.episodes > 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (result) {
|
||||
const success = result.deleted.episodes > 0;
|
||||
logger.log(`✅ [TraktService] Episode removal success: ${success} (${result.deleted.episodes} episodes deleted)`);
|
||||
|
|
|
|||
|
|
@ -316,12 +316,15 @@ class WatchedService {
|
|||
let syncedToTrakt = false;
|
||||
|
||||
// Sync to Trakt
|
||||
// showId is the Stremio content ID — pass it as fallback so Trakt can resolve
|
||||
// anime/provider IDs (e.g. kitsu:123) that aren't valid IMDb IDs
|
||||
if (isTraktAuth) {
|
||||
syncedToTrakt = await this.traktService.addToWatchedEpisodes(
|
||||
showImdbId,
|
||||
season,
|
||||
episode,
|
||||
watchedAt
|
||||
watchedAt,
|
||||
showId !== showImdbId ? showId : undefined
|
||||
);
|
||||
logger.log(`[WatchedService] Trakt sync result for episode: ${syncedToTrakt}`);
|
||||
}
|
||||
|
|
@ -445,7 +448,8 @@ class WatchedService {
|
|||
syncedToTrakt = await this.traktService.markEpisodesAsWatched(
|
||||
showImdbId,
|
||||
episodes,
|
||||
watchedAt
|
||||
watchedAt,
|
||||
showId !== showImdbId ? showId : undefined
|
||||
);
|
||||
logger.log(`[WatchedService] Trakt batch sync result: ${syncedToTrakt}`);
|
||||
}
|
||||
|
|
@ -523,7 +527,8 @@ class WatchedService {
|
|||
syncedToTrakt = await this.traktService.markSeasonAsWatched(
|
||||
showImdbId,
|
||||
season,
|
||||
watchedAt
|
||||
watchedAt,
|
||||
showId !== showImdbId ? showId : undefined
|
||||
);
|
||||
logger.log(`[WatchedService] Trakt season sync result: ${syncedToTrakt}`);
|
||||
}
|
||||
|
|
@ -570,32 +575,40 @@ class WatchedService {
|
|||
}
|
||||
|
||||
/**
|
||||
* Unmark a movie as watched (remove from history)
|
||||
* Unmark a movie as watched (remove from history).
|
||||
* @param imdbId - The primary content ID (may be a provider ID like "kitsu:123")
|
||||
* @param fallbackImdbId - The resolved IMDb ID from metadata (used when imdbId isn't IMDb format)
|
||||
*/
|
||||
public async unmarkMovieAsWatched(
|
||||
imdbId: string
|
||||
imdbId: string,
|
||||
fallbackImdbId?: string
|
||||
): Promise<{ success: boolean; syncedToTrakt: boolean }> {
|
||||
try {
|
||||
logger.log(`[WatchedService] Unmarking movie as watched: ${imdbId}`);
|
||||
logger.log(`[WatchedService] Unmarking movie as watched: ${imdbId}${fallbackImdbId && fallbackImdbId !== imdbId ? ` (fallback: ${fallbackImdbId})` : ''}`);
|
||||
|
||||
const isTraktAuth = await this.traktService.isAuthenticated();
|
||||
let syncedToTrakt = false;
|
||||
|
||||
if (isTraktAuth) {
|
||||
syncedToTrakt = await this.traktService.removeMovieFromHistory(imdbId);
|
||||
syncedToTrakt = await this.traktService.removeMovieFromHistory(imdbId, fallbackImdbId);
|
||||
logger.log(`[WatchedService] Trakt remove result for movie: ${syncedToTrakt}`);
|
||||
}
|
||||
|
||||
// Simkl Unmark
|
||||
// Simkl Unmark — try both IDs
|
||||
const isSimklAuth = await this.simklService.isAuthenticated();
|
||||
if (isSimklAuth) {
|
||||
await this.simklService.removeFromHistory({ movies: [{ ids: { imdb: imdbId } }] });
|
||||
const simklId = (fallbackImdbId && fallbackImdbId !== imdbId) ? fallbackImdbId : imdbId;
|
||||
await this.simklService.removeFromHistory({ movies: [{ ids: { imdb: simklId } }] });
|
||||
logger.log(`[WatchedService] Simkl remove request sent for movie`);
|
||||
}
|
||||
|
||||
// Remove local progress
|
||||
// Remove local progress — clear both IDs to be safe
|
||||
await storageService.removeWatchProgress(imdbId, 'movie');
|
||||
await mmkvStorage.removeItem(`watched:movie:${imdbId}`);
|
||||
if (fallbackImdbId && fallbackImdbId !== imdbId) {
|
||||
await storageService.removeWatchProgress(fallbackImdbId, 'movie');
|
||||
await mmkvStorage.removeItem(`watched:movie:${fallbackImdbId}`);
|
||||
}
|
||||
await this.removeLocalWatchedItems([
|
||||
{ content_id: imdbId, season: null, episode: null },
|
||||
]);
|
||||
|
|
@ -622,21 +635,25 @@ class WatchedService {
|
|||
const isTraktAuth = await this.traktService.isAuthenticated();
|
||||
let syncedToTrakt = false;
|
||||
|
||||
const fallback = showId !== showImdbId ? showId : undefined;
|
||||
|
||||
if (isTraktAuth) {
|
||||
syncedToTrakt = await this.traktService.removeEpisodeFromHistory(
|
||||
showImdbId,
|
||||
season,
|
||||
episode
|
||||
episode,
|
||||
fallback
|
||||
);
|
||||
logger.log(`[WatchedService] Trakt remove result for episode: ${syncedToTrakt}`);
|
||||
}
|
||||
|
||||
// Simkl Unmark
|
||||
// Simkl Unmark — use best available ID
|
||||
const isSimklAuth = await this.simklService.isAuthenticated();
|
||||
if (isSimklAuth) {
|
||||
const simklId = showImdbId || showId;
|
||||
await this.simklService.removeFromHistory({
|
||||
shows: [{
|
||||
ids: { imdb: showImdbId },
|
||||
ids: { imdb: simklId },
|
||||
seasons: [{
|
||||
number: season,
|
||||
episodes: [{ number: episode }]
|
||||
|
|
@ -679,26 +696,27 @@ class WatchedService {
|
|||
const isTraktAuth = await this.traktService.isAuthenticated();
|
||||
let syncedToTrakt = false;
|
||||
|
||||
const fallback = showId !== showImdbId ? showId : undefined;
|
||||
|
||||
if (isTraktAuth) {
|
||||
// Remove entire season from Trakt
|
||||
syncedToTrakt = await this.traktService.removeSeasonFromHistory(
|
||||
showImdbId,
|
||||
season
|
||||
season,
|
||||
fallback
|
||||
);
|
||||
logger.log(`[WatchedService] Trakt season removal result: ${syncedToTrakt}`);
|
||||
}
|
||||
|
||||
// Sync to Simkl
|
||||
// Sync to Simkl — use best available ID
|
||||
const isSimklAuth = await this.simklService.isAuthenticated();
|
||||
if (isSimklAuth) {
|
||||
const simklId = showImdbId || showId;
|
||||
const episodes = episodeNumbers.map(num => ({ number: num }));
|
||||
await this.simklService.removeFromHistory({
|
||||
shows: [{
|
||||
ids: { imdb: showImdbId },
|
||||
seasons: [{
|
||||
number: season,
|
||||
episodes: episodes
|
||||
}]
|
||||
ids: { imdb: simklId },
|
||||
seasons: [{ number: season, episodes: episodes }]
|
||||
}]
|
||||
});
|
||||
logger.log(`[WatchedService] Simkl season removal request sent`);
|
||||
|
|
@ -728,18 +746,26 @@ class WatchedService {
|
|||
/**
|
||||
* Check if a movie is marked as watched (locally)
|
||||
*/
|
||||
public async isMovieWatched(imdbId: string): Promise<boolean> {
|
||||
public async isMovieWatched(imdbId: string, fallbackImdbId?: string): Promise<boolean> {
|
||||
try {
|
||||
const isAuthed = await this.traktService.isAuthenticated();
|
||||
|
||||
if (isAuthed) {
|
||||
const traktWatched =
|
||||
await this.traktService.isMovieWatchedAccurate(imdbId);
|
||||
await this.traktService.isMovieWatchedAccurate(imdbId, fallbackImdbId);
|
||||
if (traktWatched) return true;
|
||||
}
|
||||
|
||||
const local = await mmkvStorage.getItem(`watched:movie:${imdbId}`);
|
||||
return local === 'true';
|
||||
if (local === 'true') return true;
|
||||
|
||||
// Also check under fallback ID locally
|
||||
if (fallbackImdbId && fallbackImdbId !== imdbId) {
|
||||
const localFallback = await mmkvStorage.getItem(`watched:movie:${fallbackImdbId}`);
|
||||
if (localFallback === 'true') return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -752,7 +778,8 @@ class WatchedService {
|
|||
public async isEpisodeWatched(
|
||||
showId: string,
|
||||
season: number,
|
||||
episode: number
|
||||
episode: number,
|
||||
fallbackImdbId?: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const isAuthed = await this.traktService.isAuthenticated();
|
||||
|
|
@ -762,7 +789,8 @@ class WatchedService {
|
|||
await this.traktService.isEpisodeWatchedAccurate(
|
||||
showId,
|
||||
season,
|
||||
episode
|
||||
episode,
|
||||
fallbackImdbId
|
||||
);
|
||||
if (traktWatched) return true;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue