diff options
43 files changed, 734 insertions, 78 deletions
diff --git a/Emby.Server.Implementations/Localization/Core/ar.json b/Emby.Server.Implementations/Localization/Core/ar.json index d09a7884e..7ce8baef5 100644 --- a/Emby.Server.Implementations/Localization/Core/ar.json +++ b/Emby.Server.Implementations/Localization/Core/ar.json @@ -73,7 +73,6 @@ "Shows": "العروض", "Songs": "الأغاني", "StartupEmbyServerIsLoading": "يتم تحميل خادم Jellyfin . الرجاء المحاولة بعد قليل.", - "SubtitleDownloadFailureForItem": "عملية إنزال الترجمة فشلت لـ{0}", "SubtitleDownloadFailureFromForItem": "فشل تحميل الترجمات من {0} ل {1}", "Sync": "مزامنة", "System": "النظام", diff --git a/Emby.Server.Implementations/Localization/Core/bg-BG.json b/Emby.Server.Implementations/Localization/Core/bg-BG.json index fd3666ef1..92b8e5d56 100644 --- a/Emby.Server.Implementations/Localization/Core/bg-BG.json +++ b/Emby.Server.Implementations/Localization/Core/bg-BG.json @@ -73,7 +73,6 @@ "Shows": "Сериали", "Songs": "Песни", "StartupEmbyServerIsLoading": "Сървърът зарежда. Моля, опитайте отново след малко.", - "SubtitleDownloadFailureForItem": "Неуспешно изтегляне на субтитри за {0}", "SubtitleDownloadFailureFromForItem": "Субтитрите за {1} от {0} не можаха да бъдат изтеглени", "Sync": "Синхронизиране", "System": "Система", diff --git a/Emby.Server.Implementations/Localization/Core/ca.json b/Emby.Server.Implementations/Localization/Core/ca.json index 596df6348..82cc1857b 100644 --- a/Emby.Server.Implementations/Localization/Core/ca.json +++ b/Emby.Server.Implementations/Localization/Core/ca.json @@ -73,7 +73,6 @@ "Shows": "Sèries", "Songs": "Cançons", "StartupEmbyServerIsLoading": "El servidor de Jellyfin s'està carregant. Proveu-ho de nou en una estona.", - "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", "SubtitleDownloadFailureFromForItem": "Els subtítols per a {1} no s'han pogut baixar de {0}", "Sync": "Sincronitza", "System": "Sistema", diff --git a/Emby.Server.Implementations/Localization/Core/cs.json b/Emby.Server.Implementations/Localization/Core/cs.json index e14edcffa..4d2477044 100644 --- a/Emby.Server.Implementations/Localization/Core/cs.json +++ b/Emby.Server.Implementations/Localization/Core/cs.json @@ -73,7 +73,6 @@ "Shows": "Seriály", "Songs": "Skladby", "StartupEmbyServerIsLoading": "Jellyfin Server je spouštěn. Zkuste to prosím v brzké době znovu.", - "SubtitleDownloadFailureForItem": "Stahování titulků selhalo pro {0}", "SubtitleDownloadFailureFromForItem": "Stažení titulků pro {1} z {0} selhalo", "Sync": "Synchronizace", "System": "Systém", diff --git a/Emby.Server.Implementations/Localization/Core/da.json b/Emby.Server.Implementations/Localization/Core/da.json index bbee38ba5..8b0d8745d 100644 --- a/Emby.Server.Implementations/Localization/Core/da.json +++ b/Emby.Server.Implementations/Localization/Core/da.json @@ -73,7 +73,6 @@ "Shows": "Serier", "Songs": "Sange", "StartupEmbyServerIsLoading": "Jellyfin er i gang med at starte. Prøv igen om et øjeblik.", - "SubtitleDownloadFailureForItem": "Fejlet i download af undertekster for {0}", "SubtitleDownloadFailureFromForItem": "Undertekster kunne ikke hentes fra {0} til {1}", "Sync": "Synkroniser", "System": "System", diff --git a/Emby.Server.Implementations/Localization/Core/de.json b/Emby.Server.Implementations/Localization/Core/de.json index 0b042c8fe..e9a1630d9 100644 --- a/Emby.Server.Implementations/Localization/Core/de.json +++ b/Emby.Server.Implementations/Localization/Core/de.json @@ -73,7 +73,6 @@ "Shows": "Serien", "Songs": "Lieder", "StartupEmbyServerIsLoading": "Jellyfin-Server lädt. Bitte versuche es gleich noch einmal.", - "SubtitleDownloadFailureForItem": "Download der Untertitel fehlgeschlagen für {0}", "SubtitleDownloadFailureFromForItem": "Untertitel von {0} für {1} konnten nicht heruntergeladen werden", "Sync": "Synchronisation", "System": "System", diff --git a/Emby.Server.Implementations/Localization/Core/el.json b/Emby.Server.Implementations/Localization/Core/el.json index 2ba2085da..87362ff8e 100644 --- a/Emby.Server.Implementations/Localization/Core/el.json +++ b/Emby.Server.Implementations/Localization/Core/el.json @@ -73,7 +73,6 @@ "Shows": "Σειρές", "Songs": "Τραγούδια", "StartupEmbyServerIsLoading": "Ο διακομιστής Jellyfin φορτώνει. Περιμένετε λίγο και δοκιμάστε ξανά.", - "SubtitleDownloadFailureForItem": "Οι υπότιτλοι απέτυχαν να κατέβουν για {0}", "SubtitleDownloadFailureFromForItem": "Αποτυχίες μεταφόρτωσης υποτίτλων από {0} για {1}", "Sync": "Συγχρονισμός", "System": "Σύστημα", diff --git a/Emby.Server.Implementations/Localization/Core/en-GB.json b/Emby.Server.Implementations/Localization/Core/en-GB.json index 720f550b3..bd5be0b1f 100644 --- a/Emby.Server.Implementations/Localization/Core/en-GB.json +++ b/Emby.Server.Implementations/Localization/Core/en-GB.json @@ -73,7 +73,6 @@ "Shows": "Shows", "Songs": "Songs", "StartupEmbyServerIsLoading": "Jellyfin Server is loading. Please try again shortly.", - "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", "SubtitleDownloadFailureFromForItem": "Subtitles failed to download from {0} for {1}", "Sync": "Sync", "System": "System", diff --git a/Emby.Server.Implementations/Localization/Core/es-AR.json b/Emby.Server.Implementations/Localization/Core/es-AR.json index 1f8af4c8a..ce0044f64 100644 --- a/Emby.Server.Implementations/Localization/Core/es-AR.json +++ b/Emby.Server.Implementations/Localization/Core/es-AR.json @@ -73,7 +73,6 @@ "Shows": "Series", "Songs": "Canciones", "StartupEmbyServerIsLoading": "El servidor Jellyfin se está cargando. Vuelve a intentarlo en breve.", - "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", "SubtitleDownloadFailureFromForItem": "Falló la descarga de subtitulos desde {0} para {1}", "Sync": "Sincronizar", "System": "Sistema", diff --git a/Emby.Server.Implementations/Localization/Core/es-MX.json b/Emby.Server.Implementations/Localization/Core/es-MX.json index 2830c657b..6748fff4c 100644 --- a/Emby.Server.Implementations/Localization/Core/es-MX.json +++ b/Emby.Server.Implementations/Localization/Core/es-MX.json @@ -73,7 +73,6 @@ "Shows": "Programas", "Songs": "Canciones", "StartupEmbyServerIsLoading": "El servidor Jellyfin está cargando. Por favor, intente de nuevo pronto.", - "SubtitleDownloadFailureForItem": "Falló la descarga de subtítulos para {0}", "SubtitleDownloadFailureFromForItem": "Falló la descarga de subtítulos desde {0} para {1}", "Sync": "Sincronizar", "System": "Sistema", diff --git a/Emby.Server.Implementations/Localization/Core/es.json b/Emby.Server.Implementations/Localization/Core/es.json index 1ec5eaa2a..b9c57afe6 100644 --- a/Emby.Server.Implementations/Localization/Core/es.json +++ b/Emby.Server.Implementations/Localization/Core/es.json @@ -73,7 +73,6 @@ "Shows": "Series", "Songs": "Canciones", "StartupEmbyServerIsLoading": "Jellyfin Server se está cargando. Vuelve a intentarlo en breve.", - "SubtitleDownloadFailureForItem": "Error al descargar subtítulos para {0}", "SubtitleDownloadFailureFromForItem": "Fallo en la descarga de subtítulos desde {0} para {1}", "Sync": "Sincronizar", "System": "Sistema", diff --git a/Emby.Server.Implementations/Localization/Core/fa.json b/Emby.Server.Implementations/Localization/Core/fa.json index ff14c1367..90cd3a58e 100644 --- a/Emby.Server.Implementations/Localization/Core/fa.json +++ b/Emby.Server.Implementations/Localization/Core/fa.json @@ -73,7 +73,6 @@ "Shows": "سریالها", "Songs": "موسیقیها", "StartupEmbyServerIsLoading": "سرور Jellyfin در حال بارگیری است. لطفا کمی بعد دوباره تلاش کنید.", - "SubtitleDownloadFailureForItem": "دانلود زیرنویس برای {0} ناموفق بود", "SubtitleDownloadFailureFromForItem": "بارگیری زیرنویس برای {1} از {0} شکست خورد", "Sync": "همگامسازی", "System": "سیستم", diff --git a/Emby.Server.Implementations/Localization/Core/fr-CA.json b/Emby.Server.Implementations/Localization/Core/fr-CA.json index 6d079d2f5..a8964e8b6 100644 --- a/Emby.Server.Implementations/Localization/Core/fr-CA.json +++ b/Emby.Server.Implementations/Localization/Core/fr-CA.json @@ -73,7 +73,6 @@ "Shows": "Séries", "Songs": "Chansons", "StartupEmbyServerIsLoading": "Serveur Jellyfin en cours de chargement. Réessayez dans quelques instants.", - "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", "SubtitleDownloadFailureFromForItem": "Échec du téléchargement des sous-titres depuis {0} pour {1}", "Sync": "Synchroniser", "System": "Système", diff --git a/Emby.Server.Implementations/Localization/Core/fr.json b/Emby.Server.Implementations/Localization/Core/fr.json index 8bf41c02a..b2a2e502a 100644 --- a/Emby.Server.Implementations/Localization/Core/fr.json +++ b/Emby.Server.Implementations/Localization/Core/fr.json @@ -73,7 +73,6 @@ "Shows": "Séries", "Songs": "Chansons", "StartupEmbyServerIsLoading": "Le serveur Jellyfin est en cours de chargement. Veuillez réessayer dans quelques instants.", - "SubtitleDownloadFailureForItem": "Le téléchargement des sous-titres pour {0} a échoué.", "SubtitleDownloadFailureFromForItem": "Échec du téléchargement des sous-titres depuis {0} pour {1}", "Sync": "Synchroniser", "System": "Système", diff --git a/Emby.Server.Implementations/Localization/Core/gsw.json b/Emby.Server.Implementations/Localization/Core/gsw.json index e1ee8cf7c..9be6f05ee 100644 --- a/Emby.Server.Implementations/Localization/Core/gsw.json +++ b/Emby.Server.Implementations/Localization/Core/gsw.json @@ -73,7 +73,6 @@ "Shows": "Serie", "Songs": "Lieder", "StartupEmbyServerIsLoading": "Jellyfin Server ladt. Bitte grad noeinisch probiere.", - "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", "SubtitleDownloadFailureFromForItem": "Ondertetle vo {0} för {1} hend ned chönne abeglade wärde", "Sync": "Synchronisation", "System": "System", diff --git a/Emby.Server.Implementations/Localization/Core/he.json b/Emby.Server.Implementations/Localization/Core/he.json index 90c921898..ef95a639f 100644 --- a/Emby.Server.Implementations/Localization/Core/he.json +++ b/Emby.Server.Implementations/Localization/Core/he.json @@ -73,7 +73,6 @@ "Shows": "סדרות", "Songs": "שירים", "StartupEmbyServerIsLoading": "שרת Jellyfin בתהליך טעינה. נא לנסות שוב בקרוב.", - "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", "SubtitleDownloadFailureFromForItem": "הורדת כתוביות מ־{0} עבור {1} נכשלה", "Sync": "סנכרון", "System": "מערכת", diff --git a/Emby.Server.Implementations/Localization/Core/hr.json b/Emby.Server.Implementations/Localization/Core/hr.json index 67263d3b2..eb75cfd49 100644 --- a/Emby.Server.Implementations/Localization/Core/hr.json +++ b/Emby.Server.Implementations/Localization/Core/hr.json @@ -73,7 +73,6 @@ "Shows": "Serije", "Songs": "Pjesme", "StartupEmbyServerIsLoading": "Jellyfin server se učitava. Pokušajte ponovo uskoro.", - "SubtitleDownloadFailureForItem": "Titlovi prijevoda nisu preuzeti za {0}", "SubtitleDownloadFailureFromForItem": "Prijevod nije uspješno preuzet od {0} za {1}", "Sync": "Sinkronizacija", "System": "Sustav", diff --git a/Emby.Server.Implementations/Localization/Core/hu.json b/Emby.Server.Implementations/Localization/Core/hu.json index 81a996330..813d79923 100644 --- a/Emby.Server.Implementations/Localization/Core/hu.json +++ b/Emby.Server.Implementations/Localization/Core/hu.json @@ -73,7 +73,6 @@ "Shows": "Sorozatok", "Songs": "Számok", "StartupEmbyServerIsLoading": "A Jellyfin kiszolgáló betöltődik. Próbálja újra hamarosan.", - "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", "SubtitleDownloadFailureFromForItem": "Nem sikerült a felirat letöltése innen: {0}, ehhez: {1}", "Sync": "Szinkronizálás", "System": "Rendszer", diff --git a/Emby.Server.Implementations/Localization/Core/it.json b/Emby.Server.Implementations/Localization/Core/it.json index 421c4ee30..c2974704b 100644 --- a/Emby.Server.Implementations/Localization/Core/it.json +++ b/Emby.Server.Implementations/Localization/Core/it.json @@ -73,7 +73,6 @@ "Shows": "Serie TV", "Songs": "Brani", "StartupEmbyServerIsLoading": "Jellyfin server si sta avviando. Per favore riprova più tardi.", - "SubtitleDownloadFailureForItem": "Impossibile scaricare i sottotitoli per {0}", "SubtitleDownloadFailureFromForItem": "Impossibile scaricare i sottotitoli da {0} per {1}", "Sync": "Sincronizza", "System": "Sistema", diff --git a/Emby.Server.Implementations/Localization/Core/kk.json b/Emby.Server.Implementations/Localization/Core/kk.json index e050196bc..fc5fcf3c4 100644 --- a/Emby.Server.Implementations/Localization/Core/kk.json +++ b/Emby.Server.Implementations/Localization/Core/kk.json @@ -73,7 +73,6 @@ "Shows": "Körsetımder", "Songs": "Äuender", "StartupEmbyServerIsLoading": "Jellyfin Server jüktelude. Ärekettı köp ūzamai qaitalañyz.", - "SubtitleDownloadFailureForItem": "Субтитрлер {0} үшін жүктеліп алынуы сәтсіз", "SubtitleDownloadFailureFromForItem": "{1} üşın subtitrlerdı {0} közınen jüktep alu sätsız", "Sync": "Ündestıru", "System": "Jüie", diff --git a/Emby.Server.Implementations/Localization/Core/ko.json b/Emby.Server.Implementations/Localization/Core/ko.json index 3d1b1ed27..2b24ea2c8 100644 --- a/Emby.Server.Implementations/Localization/Core/ko.json +++ b/Emby.Server.Implementations/Localization/Core/ko.json @@ -73,7 +73,6 @@ "Shows": "시리즈", "Songs": "노래", "StartupEmbyServerIsLoading": "Jellyfin 서버를 불러오고 있습니다. 잠시 후에 다시 시도하십시오.", - "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", "SubtitleDownloadFailureFromForItem": "{0}에서 {1} 자막 다운로드에 실패했습니다", "Sync": "동기화", "System": "시스템", diff --git a/Emby.Server.Implementations/Localization/Core/lt-LT.json b/Emby.Server.Implementations/Localization/Core/lt-LT.json index 3918ab81c..bdf63b4ca 100644 --- a/Emby.Server.Implementations/Localization/Core/lt-LT.json +++ b/Emby.Server.Implementations/Localization/Core/lt-LT.json @@ -73,7 +73,6 @@ "Shows": "Laidos", "Songs": "Kūriniai", "StartupEmbyServerIsLoading": "Jellyfin Server kraunasi. Netrukus pabandykite dar kartą.", - "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", "SubtitleDownloadFailureFromForItem": "{1} subtitrai buvo nesėkmingai parsiųsti iš {0}", "Sync": "Sinchronizuoti", "System": "Sistema", diff --git a/Emby.Server.Implementations/Localization/Core/ms.json b/Emby.Server.Implementations/Localization/Core/ms.json index 971f79c2c..2be04be80 100644 --- a/Emby.Server.Implementations/Localization/Core/ms.json +++ b/Emby.Server.Implementations/Localization/Core/ms.json @@ -73,7 +73,6 @@ "Shows": "Tayangan", "Songs": "Lagu-lagu", "StartupEmbyServerIsLoading": "Pelayan Jellyfin sedang dimuatkan. Sila cuba sebentar lagi.", - "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", "SubtitleDownloadFailureFromForItem": "Muat turun sarikata gagal dari {0} untuk {1}", "Sync": "Segerak", "System": "Sistem", diff --git a/Emby.Server.Implementations/Localization/Core/nb.json b/Emby.Server.Implementations/Localization/Core/nb.json index e73c56cb9..cd0315720 100644 --- a/Emby.Server.Implementations/Localization/Core/nb.json +++ b/Emby.Server.Implementations/Localization/Core/nb.json @@ -73,7 +73,6 @@ "Shows": "Serier", "Songs": "Sanger", "StartupEmbyServerIsLoading": "Jellyfin Server laster. Prøv igjen snart.", - "SubtitleDownloadFailureForItem": "En feil oppstå under nedlasting av undertekster for {0}", "SubtitleDownloadFailureFromForItem": "Kunne ikke laste ned undertekster fra {0} for {1}", "Sync": "Synkroniser", "System": "System", diff --git a/Emby.Server.Implementations/Localization/Core/nl.json b/Emby.Server.Implementations/Localization/Core/nl.json index 09246bd11..534c64e93 100644 --- a/Emby.Server.Implementations/Localization/Core/nl.json +++ b/Emby.Server.Implementations/Localization/Core/nl.json @@ -73,7 +73,6 @@ "Shows": "Series", "Songs": "Nummers", "StartupEmbyServerIsLoading": "Jellyfin Server is aan het laden. Probeer het later opnieuw.", - "SubtitleDownloadFailureForItem": "Downloaden van ondertiteling voor {0} is mislukt", "SubtitleDownloadFailureFromForItem": "Ondertiteling kon niet gedownload worden van {0} voor {1}", "Sync": "Synchronisatie", "System": "Systeem", diff --git a/Emby.Server.Implementations/Localization/Core/pl.json b/Emby.Server.Implementations/Localization/Core/pl.json index 8ca22ac04..f1c19ac1d 100644 --- a/Emby.Server.Implementations/Localization/Core/pl.json +++ b/Emby.Server.Implementations/Localization/Core/pl.json @@ -73,7 +73,6 @@ "Shows": "Seriale", "Songs": "Utwory", "StartupEmbyServerIsLoading": "Trwa wczytywanie serwera Jellyfin. Spróbuj ponownie za chwilę.", - "SubtitleDownloadFailureForItem": "Pobieranie napisów dla {0} zakończone niepowodzeniem", "SubtitleDownloadFailureFromForItem": "Nieudane pobieranie napisów z {0} dla {1}", "Sync": "Synchronizacja", "System": "System", diff --git a/Emby.Server.Implementations/Localization/Core/pt-BR.json b/Emby.Server.Implementations/Localization/Core/pt-BR.json index dc5bff161..8e76c6c63 100644 --- a/Emby.Server.Implementations/Localization/Core/pt-BR.json +++ b/Emby.Server.Implementations/Localization/Core/pt-BR.json @@ -73,7 +73,6 @@ "Shows": "Séries", "Songs": "Músicas", "StartupEmbyServerIsLoading": "O Servidor Jellyfin está carregando. Por favor, tente novamente mais tarde.", - "SubtitleDownloadFailureForItem": "Download de legendas falhou para {0}", "SubtitleDownloadFailureFromForItem": "Houve um problema ao baixar as legendas de {0} para {1}", "Sync": "Sincronizar", "System": "Sistema", diff --git a/Emby.Server.Implementations/Localization/Core/pt-PT.json b/Emby.Server.Implementations/Localization/Core/pt-PT.json index 17284854f..a27036493 100644 --- a/Emby.Server.Implementations/Localization/Core/pt-PT.json +++ b/Emby.Server.Implementations/Localization/Core/pt-PT.json @@ -73,7 +73,6 @@ "Shows": "Séries", "Songs": "Músicas", "StartupEmbyServerIsLoading": "O servidor Jellyfin está a iniciar. Tente novamente mais tarde.", - "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", "SubtitleDownloadFailureFromForItem": "Falha na transferência de legendas a partir de {0} para {1}", "Sync": "Sincronização", "System": "Sistema", diff --git a/Emby.Server.Implementations/Localization/Core/ru.json b/Emby.Server.Implementations/Localization/Core/ru.json index 1470a538c..03bce0ebd 100644 --- a/Emby.Server.Implementations/Localization/Core/ru.json +++ b/Emby.Server.Implementations/Localization/Core/ru.json @@ -73,7 +73,6 @@ "Shows": "Сериалы", "Songs": "Композиции", "StartupEmbyServerIsLoading": "Jellyfin Server загружается. Повторите попытку в ближайшее время.", - "SubtitleDownloadFailureForItem": "Субтитры к {0} не удалось загрузить", "SubtitleDownloadFailureFromForItem": "Субтитры к {1} не удалось загрузить с {0}", "Sync": "Синхронизация", "System": "Система", diff --git a/Emby.Server.Implementations/Localization/Core/sk.json b/Emby.Server.Implementations/Localization/Core/sk.json index 1de78eeae..7c8d86047 100644 --- a/Emby.Server.Implementations/Localization/Core/sk.json +++ b/Emby.Server.Implementations/Localization/Core/sk.json @@ -73,7 +73,6 @@ "Shows": "Seriály", "Songs": "Skladby", "StartupEmbyServerIsLoading": "Jellyfin Server sa spúšťa. Prosím, skúste to o chvíľu znova.", - "SubtitleDownloadFailureForItem": "Sťahovanie titulkov pre {0} zlyhalo", "SubtitleDownloadFailureFromForItem": "Sťahovanie titulkov z {0} pre {1} zlyhalo", "Sync": "Synchronizácia", "System": "Systém", diff --git a/Emby.Server.Implementations/Localization/Core/sl-SI.json b/Emby.Server.Implementations/Localization/Core/sl-SI.json index ff92db2f2..7c7c88e28 100644 --- a/Emby.Server.Implementations/Localization/Core/sl-SI.json +++ b/Emby.Server.Implementations/Localization/Core/sl-SI.json @@ -73,7 +73,6 @@ "Shows": "Serije", "Songs": "Pesmi", "StartupEmbyServerIsLoading": "Jellyfin strežnik se zaganja. Poskusite ponovno kasneje.", - "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", "SubtitleDownloadFailureFromForItem": "Neuspešen prenos podnapisov iz {0} za {1}", "Sync": "Sinhroniziraj", "System": "Sistem", diff --git a/Emby.Server.Implementations/Localization/Core/sv.json b/Emby.Server.Implementations/Localization/Core/sv.json index 1ee1a5366..23acd3c53 100644 --- a/Emby.Server.Implementations/Localization/Core/sv.json +++ b/Emby.Server.Implementations/Localization/Core/sv.json @@ -73,7 +73,6 @@ "Shows": "Serier", "Songs": "Låtar", "StartupEmbyServerIsLoading": "Jellyfin Server arbetar. Pröva igen snart.", - "SubtitleDownloadFailureForItem": "Nerladdning av undertexter för {0} misslyckades", "SubtitleDownloadFailureFromForItem": "Undertexter kunde inte laddas ner från {0} till {1}", "Sync": "Synk", "System": "System", diff --git a/Emby.Server.Implementations/Localization/Core/tr.json b/Emby.Server.Implementations/Localization/Core/tr.json index a07e6864e..d13f662e4 100644 --- a/Emby.Server.Implementations/Localization/Core/tr.json +++ b/Emby.Server.Implementations/Localization/Core/tr.json @@ -73,7 +73,6 @@ "Shows": "Diziler", "Songs": "Şarkılar", "StartupEmbyServerIsLoading": "Jellyfin Sunucusu yükleniyor. Lütfen kısa süre sonra tekrar deneyin.", - "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", "SubtitleDownloadFailureFromForItem": "{1} için altyazılar {0} sağlayıcısından indirilemedi", "Sync": "Eşzamanlama", "System": "Sistem", diff --git a/Emby.Server.Implementations/Localization/Core/zh-CN.json b/Emby.Server.Implementations/Localization/Core/zh-CN.json index b9635105a..0a0795d41 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-CN.json +++ b/Emby.Server.Implementations/Localization/Core/zh-CN.json @@ -73,7 +73,6 @@ "Shows": "节目", "Songs": "歌曲", "StartupEmbyServerIsLoading": "Jellyfin 服务器正在启动,请稍后再试。", - "SubtitleDownloadFailureForItem": "为 {0} 下载字幕失败", "SubtitleDownloadFailureFromForItem": "无法从 {0} 下载 {1} 的字幕", "Sync": "同步", "System": "系统", diff --git a/Emby.Server.Implementations/Localization/Core/zh-HK.json b/Emby.Server.Implementations/Localization/Core/zh-HK.json index c8800e256..e57a0c5b0 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-HK.json +++ b/Emby.Server.Implementations/Localization/Core/zh-HK.json @@ -73,7 +73,6 @@ "Shows": "節目", "Songs": "歌曲", "StartupEmbyServerIsLoading": "正在載入 Jellyfin,請稍後再試。", - "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", "SubtitleDownloadFailureFromForItem": "無法從 {0} 下載 {1} 的字幕", "Sync": "同步", "System": "系統", diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs index cf2ca047c..bbe23f8df 100644 --- a/Emby.Server.Implementations/Session/SessionManager.cs +++ b/Emby.Server.Implementations/Session/SessionManager.cs @@ -793,6 +793,16 @@ namespace Emby.Server.Implementations.Session PlaySessionId = info.PlaySessionId }; + if (info.Item is not null) + { + _logger.LogInformation( + "User {0} started playback of '{1}' ({2} {3})", + session.UserName, + info.Item.Name, + session.Client, + session.ApplicationVersion); + } + await _eventManager.PublishAsync(eventArgs).ConfigureAwait(false); // Nothing to save here @@ -1060,11 +1070,12 @@ namespace Emby.Server.Implementations.Session var msString = info.PositionTicks.HasValue ? (info.PositionTicks.Value / 10000).ToString(CultureInfo.InvariantCulture) : "unknown"; _logger.LogInformation( - "Playback stopped reported by app {0} {1} playing {2}. Stopped at {3} ms", - session.Client, - session.ApplicationVersion, + "User {0} stopped playback of '{1}' at {2}ms ({3} {4})", + session.UserName, info.Item.Name, - msString); + msString, + session.Client, + session.ApplicationVersion); } if (info.NowPlayingQueue is not null) diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs index 49ac0fa03..bf7ec05a9 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs @@ -172,23 +172,25 @@ namespace MediaBrowser.MediaEncoding.Subtitles private async Task<Stream> GetSubtitleStream(SubtitleInfo fileInfo, CancellationToken cancellationToken) { - if (fileInfo.IsExternal) + if (fileInfo.Protocol == MediaProtocol.Http) { - var stream = await GetStream(fileInfo.Path, fileInfo.Protocol, cancellationToken).ConfigureAwait(false); - await using (stream.ConfigureAwait(false)) + var result = await DetectCharset(fileInfo.Path, fileInfo.Protocol, cancellationToken).ConfigureAwait(false); + var detected = result.Detected; + + if (detected is not null) { - var result = await CharsetDetector.DetectFromStreamAsync(stream, cancellationToken).ConfigureAwait(false); - var detected = result.Detected; - stream.Position = 0; + _logger.LogDebug("charset {CharSet} detected for {Path}", detected.EncodingName, fileInfo.Path); - if (detected is not null) - { - _logger.LogDebug("charset {CharSet} detected for {Path}", detected.EncodingName, fileInfo.Path); + using var stream = await _httpClientFactory.CreateClient(NamedClient.Default) + .GetStreamAsync(new Uri(fileInfo.Path), cancellationToken) + .ConfigureAwait(false); - using var reader = new StreamReader(stream, detected.Encoding); - var text = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); + await using (stream.ConfigureAwait(false)) + { + using var reader = new StreamReader(stream, detected.Encoding); + var text = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); - return new MemoryStream(Encoding.UTF8.GetBytes(text)); + return new MemoryStream(Encoding.UTF8.GetBytes(text)); } } } @@ -218,7 +220,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles }; } - var currentFormat = (Path.GetExtension(subtitleStream.Path) ?? subtitleStream.Codec) + var currentFormat = subtitleStream.Codec ?? Path.GetExtension(subtitleStream.Path) .TrimStart('.'); // Handle PGS subtitles as raw streams for the client to render @@ -941,42 +943,44 @@ namespace MediaBrowser.MediaEncoding.Subtitles .ConfigureAwait(false); } - var stream = await GetStream(path, mediaSource.Protocol, cancellationToken).ConfigureAwait(false); - await using (stream.ConfigureAwait(false)) - { - var result = await CharsetDetector.DetectFromStreamAsync(stream, cancellationToken).ConfigureAwait(false); - var charset = result.Detected?.EncodingName ?? string.Empty; + var result = await DetectCharset(path, mediaSource.Protocol, cancellationToken).ConfigureAwait(false); + var charset = result.Detected?.EncodingName ?? string.Empty; - // UTF16 is automatically converted to UTF8 by FFmpeg, do not specify a character encoding - if ((path.EndsWith(".ass", StringComparison.Ordinal) || path.EndsWith(".ssa", StringComparison.Ordinal) || path.EndsWith(".srt", StringComparison.Ordinal)) - && (string.Equals(charset, "utf-16le", StringComparison.OrdinalIgnoreCase) - || string.Equals(charset, "utf-16be", StringComparison.OrdinalIgnoreCase))) - { - charset = string.Empty; - } + // UTF16 is automatically converted to UTF8 by FFmpeg, do not specify a character encoding + if ((path.EndsWith(".ass", StringComparison.Ordinal) || path.EndsWith(".ssa", StringComparison.Ordinal) || path.EndsWith(".srt", StringComparison.Ordinal)) + && (string.Equals(charset, "utf-16le", StringComparison.OrdinalIgnoreCase) + || string.Equals(charset, "utf-16be", StringComparison.OrdinalIgnoreCase))) + { + charset = string.Empty; + } - _logger.LogDebug("charset {0} detected for {Path}", charset, path); + _logger.LogDebug("charset {0} detected for {Path}", charset, path); - return charset; - } + return charset; } - private async Task<Stream> GetStream(string path, MediaProtocol protocol, CancellationToken cancellationToken) + private async Task<DetectionResult> DetectCharset(string path, MediaProtocol protocol, CancellationToken cancellationToken) { switch (protocol) { case MediaProtocol.Http: - { - using var response = await _httpClientFactory.CreateClient(NamedClient.Default) - .GetAsync(new Uri(path), cancellationToken) - .ConfigureAwait(false); - return await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - } + { + using var stream = await _httpClientFactory + .CreateClient(NamedClient.Default) + .GetStreamAsync(new Uri(path), cancellationToken) + .ConfigureAwait(false); + + return await CharsetDetector.DetectFromStreamAsync(stream, cancellationToken).ConfigureAwait(false); + } case MediaProtocol.File: - return AsyncFile.OpenRead(path); + { + return await CharsetDetector.DetectFromFileAsync(path, cancellationToken) + .ConfigureAwait(false); + } + default: - throw new ArgumentOutOfRangeException(nameof(protocol)); + throw new ArgumentOutOfRangeException(nameof(protocol), protocol, "Unsupported protocol"); } } diff --git a/MediaBrowser.Model/Dlna/StreamInfo.cs b/MediaBrowser.Model/Dlna/StreamInfo.cs index 9edb4115c..551bee89e 100644 --- a/MediaBrowser.Model/Dlna/StreamInfo.cs +++ b/MediaBrowser.Model/Dlna/StreamInfo.cs @@ -1252,11 +1252,11 @@ public class StreamInfo stream.Index.ToString(CultureInfo.InvariantCulture), startPositionTicks.ToString(CultureInfo.InvariantCulture), subtitleProfile.Format); - info.IsExternalUrl = false; // Default to API URL + info.IsExternalUrl = false; // Check conditions for potentially using the direct path if (stream.IsExternal // Must be external - && MediaSource?.Protocol != MediaProtocol.File // Main media must not be a local file + && stream.SupportsExternalStream && string.Equals(stream.Codec, subtitleProfile.Format, StringComparison.OrdinalIgnoreCase) // Format must match (no conversion needed) && !string.IsNullOrEmpty(stream.Path) // Path must exist && Uri.TryCreate(stream.Path, UriKind.Absolute, out Uri? uriResult) // Path must be an absolute URI diff --git a/MediaBrowser.Providers/Books/OpenPackagingFormat/EpubImageProvider.cs b/MediaBrowser.Providers/Books/OpenPackagingFormat/EpubImageProvider.cs new file mode 100644 index 000000000..33d2823de --- /dev/null +++ b/MediaBrowser.Providers/Books/OpenPackagingFormat/EpubImageProvider.cs @@ -0,0 +1,118 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Threading; +using System.Threading.Tasks; +using System.Xml; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Providers.Books.OpenPackagingFormat +{ + /// <summary> + /// Provides the primary image for EPUB items that have embedded covers. + /// </summary> + public class EpubImageProvider : IDynamicImageProvider + { + private readonly ILogger<EpubImageProvider> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="EpubImageProvider"/> class. + /// </summary> + /// <param name="logger">Instance of the <see cref="ILogger{EpubImageProvider}"/> interface.</param> + public EpubImageProvider(ILogger<EpubImageProvider> logger) + { + _logger = logger; + } + + /// <inheritdoc /> + public string Name => "EPUB Metadata"; + + /// <inheritdoc /> + public bool Supports(BaseItem item) + { + return item is Book; + } + + /// <inheritdoc /> + public IEnumerable<ImageType> GetSupportedImages(BaseItem item) + { + yield return ImageType.Primary; + } + + /// <inheritdoc /> + public Task<DynamicImageResponse> GetImage(BaseItem item, ImageType type, CancellationToken cancellationToken) + { + if (string.Equals(Path.GetExtension(item.Path), ".epub", StringComparison.OrdinalIgnoreCase)) + { + return GetFromZip(item); + } + + return Task.FromResult(new DynamicImageResponse { HasImage = false }); + } + + private async Task<DynamicImageResponse> LoadCover(ZipArchive epub, XmlDocument opf, string opfRootDirectory) + { + var utilities = new OpfReader<EpubImageProvider>(opf, _logger); + var coverReference = utilities.ReadCoverPath(opfRootDirectory); + if (coverReference == null) + { + return new DynamicImageResponse { HasImage = false }; + } + + var cover = coverReference.Value; + var coverFile = epub.GetEntry(cover.Path); + + if (coverFile == null) + { + return new DynamicImageResponse { HasImage = false }; + } + + var memoryStream = new MemoryStream(); + using (var coverStream = coverFile.Open()) + { + await coverStream.CopyToAsync(memoryStream).ConfigureAwait(false); + } + + memoryStream.Position = 0; + + var response = new DynamicImageResponse { HasImage = true, Stream = memoryStream }; + response.SetFormatFromMimeType(cover.MimeType); + + return response; + } + + private async Task<DynamicImageResponse> GetFromZip(BaseItem item) + { + using var epub = ZipFile.OpenRead(item.Path); + + var opfFilePath = EpubUtils.ReadContentFilePath(epub); + if (opfFilePath == null) + { + return new DynamicImageResponse { HasImage = false }; + } + + var opfRootDirectory = Path.GetDirectoryName(opfFilePath); + if (opfRootDirectory == null) + { + return new DynamicImageResponse { HasImage = false }; + } + + var opfFile = epub.GetEntry(opfFilePath); + if (opfFile == null) + { + return new DynamicImageResponse { HasImage = false }; + } + + using var opfStream = opfFile.Open(); + + var opfDocument = new XmlDocument(); + opfDocument.Load(opfStream); + + return await LoadCover(epub, opfDocument, opfRootDirectory).ConfigureAwait(false); + } + } +} diff --git a/MediaBrowser.Providers/Books/OpenPackagingFormat/EpubProvider.cs b/MediaBrowser.Providers/Books/OpenPackagingFormat/EpubProvider.cs new file mode 100644 index 000000000..bc77e5928 --- /dev/null +++ b/MediaBrowser.Providers/Books/OpenPackagingFormat/EpubProvider.cs @@ -0,0 +1,100 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Threading; +using System.Threading.Tasks; +using System.Xml; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Providers.Books.OpenPackagingFormat +{ + /// <summary> + /// Provides book metadata from OPF content in an EPUB item. + /// </summary> + public class EpubProvider : ILocalMetadataProvider<Book> + { + private readonly IFileSystem _fileSystem; + private readonly ILogger<EpubProvider> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="EpubProvider"/> class. + /// </summary> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="logger">Instance of the <see cref="ILogger{EpubProvider}"/> interface.</param> + public EpubProvider(IFileSystem fileSystem, ILogger<EpubProvider> logger) + { + _fileSystem = fileSystem; + _logger = logger; + } + + /// <inheritdoc /> + public string Name => "EPUB Metadata"; + + /// <inheritdoc /> + public Task<MetadataResult<Book>> GetMetadata(ItemInfo info, IDirectoryService directoryService, CancellationToken cancellationToken) + { + var path = GetEpubFile(info.Path)?.FullName; + + if (path is null) + { + return Task.FromResult(new MetadataResult<Book> { HasMetadata = false }); + } + + var result = ReadEpubAsZip(path, cancellationToken); + + if (result is null) + { + return Task.FromResult(new MetadataResult<Book> { HasMetadata = false }); + } + else + { + return Task.FromResult(result); + } + } + + private FileSystemMetadata? GetEpubFile(string path) + { + var fileInfo = _fileSystem.GetFileSystemInfo(path); + + if (fileInfo.IsDirectory) + { + return null; + } + + if (!string.Equals(Path.GetExtension(fileInfo.FullName), ".epub", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + return fileInfo; + } + + private MetadataResult<Book>? ReadEpubAsZip(string path, CancellationToken cancellationToken) + { + using var epub = ZipFile.OpenRead(path); + + var opfFilePath = EpubUtils.ReadContentFilePath(epub); + if (opfFilePath == null) + { + return null; + } + + var opf = epub.GetEntry(opfFilePath); + if (opf == null) + { + return null; + } + + using var opfStream = opf.Open(); + + var opfDocument = new XmlDocument(); + opfDocument.Load(opfStream); + + var utilities = new OpfReader<EpubProvider>(opfDocument, _logger); + return utilities.ReadOpfData(cancellationToken); + } + } +} diff --git a/MediaBrowser.Providers/Books/OpenPackagingFormat/EpubUtils.cs b/MediaBrowser.Providers/Books/OpenPackagingFormat/EpubUtils.cs new file mode 100644 index 000000000..e5d298731 --- /dev/null +++ b/MediaBrowser.Providers/Books/OpenPackagingFormat/EpubUtils.cs @@ -0,0 +1,35 @@ +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Xml.Linq; + +namespace MediaBrowser.Providers.Books.OpenPackagingFormat +{ + /// <summary> + /// Utilities for EPUB files. + /// </summary> + public static class EpubUtils + { + /// <summary> + /// Attempt to read content from ZIP archive. + /// </summary> + /// <param name="epub">The ZIP archive.</param> + /// <returns>The content file path.</returns> + public static string? ReadContentFilePath(ZipArchive epub) + { + var container = epub.GetEntry(Path.Combine("META-INF", "container.xml")); + if (container == null) + { + return null; + } + + using var containerStream = container.Open(); + + XNamespace containerNamespace = "urn:oasis:names:tc:opendocument:xmlns:container"; + var containerDocument = XDocument.Load(containerStream); + var element = containerDocument.Descendants(containerNamespace + "rootfile").FirstOrDefault(); + + return element?.Attribute("full-path")?.Value; + } + } +} diff --git a/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfProvider.cs b/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfProvider.cs new file mode 100644 index 000000000..6e678802c --- /dev/null +++ b/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfProvider.cs @@ -0,0 +1,94 @@ +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using System.Xml; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Providers.Books.OpenPackagingFormat +{ + /// <summary> + /// Provides metadata for book items that have an OPF file in the same directory. Supports the standard + /// content.opf filename, bespoke metadata.opf name from Calibre libraries, and OPF files that have the + /// same name as their respective books for directories with several books. + /// </summary> + public class OpfProvider : ILocalMetadataProvider<Book>, IHasItemChangeMonitor + { + private const string StandardOpfFile = "content.opf"; + private const string CalibreOpfFile = "metadata.opf"; + + private readonly IFileSystem _fileSystem; + + private readonly ILogger<OpfProvider> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="OpfProvider"/> class. + /// </summary> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="logger">Instance of the <see cref="ILogger{OpfProvider}"/> interface.</param> + public OpfProvider(IFileSystem fileSystem, ILogger<OpfProvider> logger) + { + _fileSystem = fileSystem; + _logger = logger; + } + + /// <inheritdoc /> + public string Name => "Open Packaging Format"; + + /// <inheritdoc /> + public bool HasChanged(BaseItem item, IDirectoryService directoryService) + { + var file = GetXmlFile(item.Path); + + return file.Exists && _fileSystem.GetLastWriteTimeUtc(file) > item.DateLastSaved; + } + + /// <inheritdoc /> + public Task<MetadataResult<Book>> GetMetadata(ItemInfo info, IDirectoryService directoryService, CancellationToken cancellationToken) + { + var path = GetXmlFile(info.Path).FullName; + + try + { + return Task.FromResult(ReadOpfData(path, cancellationToken)); + } + catch (FileNotFoundException) + { + return Task.FromResult(new MetadataResult<Book> { HasMetadata = false }); + } + } + + private FileSystemMetadata GetXmlFile(string path) + { + var fileInfo = _fileSystem.GetFileSystemInfo(path); + var directoryInfo = fileInfo.IsDirectory ? fileInfo : _fileSystem.GetDirectoryInfo(Path.GetDirectoryName(path)!); + + // check for OPF with matching name first since it's the most specific filename + var specificFile = Path.Combine(directoryInfo.FullName, Path.GetFileNameWithoutExtension(path) + ".opf"); + var file = _fileSystem.GetFileInfo(specificFile); + + if (file.Exists) + { + return file; + } + + file = _fileSystem.GetFileInfo(Path.Combine(directoryInfo.FullName, StandardOpfFile)); + + // check metadata.opf last since it's really only used by Calibre + return file.Exists ? file : _fileSystem.GetFileInfo(Path.Combine(directoryInfo.FullName, CalibreOpfFile)); + } + + private MetadataResult<Book> ReadOpfData(string file, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var doc = new XmlDocument(); + doc.Load(file); + + var utilities = new OpfReader<OpfProvider>(doc, _logger); + return utilities.ReadOpfData(cancellationToken); + } + } +} diff --git a/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs b/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs new file mode 100644 index 000000000..5d202c59e --- /dev/null +++ b/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs @@ -0,0 +1,329 @@ +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using System.Xml; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Net; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Providers.Books.OpenPackagingFormat +{ + /// <summary> + /// Methods used to pull metadata and other information from Open Packaging Format in XML objects. + /// </summary> + /// <typeparam name="TCategoryName">The type of category.</typeparam> + public class OpfReader<TCategoryName> + { + private const string DcNamespace = @"http://purl.org/dc/elements/1.1/"; + private const string OpfNamespace = @"http://www.idpf.org/2007/opf"; + + private readonly XmlNamespaceManager _namespaceManager; + private readonly XmlDocument _document; + + private readonly ILogger<TCategoryName> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="OpfReader{TCategoryName}"/> class. + /// </summary> + /// <param name="document">The XML document to parse.</param> + /// <param name="logger">Instance of the <see cref="ILogger{TCategoryName}"/> interface.</param> + public OpfReader(XmlDocument document, ILogger<TCategoryName> logger) + { + _document = document; + _logger = logger; + _namespaceManager = new XmlNamespaceManager(_document.NameTable); + + _namespaceManager.AddNamespace("dc", DcNamespace); + _namespaceManager.AddNamespace("opf", OpfNamespace); + } + + /// <summary> + /// Checks for the existence of a cover image. + /// </summary> + /// <param name="opfRootDirectory">The root directory in which the OPF file is located.</param> + /// <returns>Returns the found cover and its type or null.</returns> + public (string MimeType, string Path)? ReadCoverPath(string opfRootDirectory) + { + var coverImage = ReadEpubCoverInto(opfRootDirectory, "//opf:item[@properties='cover-image']"); + if (coverImage is not null) + { + return coverImage; + } + + var coverId = ReadEpubCoverInto(opfRootDirectory, "//opf:item[@id='cover' and @media-type='image/*']"); + if (coverId is not null) + { + return coverId; + } + + var coverImageId = ReadEpubCoverInto(opfRootDirectory, "//opf:item[@id='*cover-image']"); + if (coverImageId is not null) + { + return coverImageId; + } + + var metaCoverImage = _document.SelectSingleNode("//opf:meta[@name='cover']", _namespaceManager); + var content = metaCoverImage?.Attributes?["content"]?.Value; + if (string.IsNullOrEmpty(content) || metaCoverImage is null) + { + return null; + } + + var coverPath = Path.Combine("Images", content); + var coverFileManifest = _document.SelectSingleNode($"//opf:item[@href='{coverPath}']", _namespaceManager); + var mediaType = coverFileManifest?.Attributes?["media-type"]?.Value; + if (coverFileManifest?.Attributes is not null && !string.IsNullOrEmpty(mediaType) && IsValidImage(mediaType)) + { + return (mediaType, Path.Combine(opfRootDirectory, coverPath)); + } + + var coverFileIdManifest = _document.SelectSingleNode($"//opf:item[@id='{content}']", _namespaceManager); + if (coverFileIdManifest is not null) + { + return ReadManifestItem(coverFileIdManifest, opfRootDirectory); + } + + return null; + } + + /// <summary> + /// Read all supported OPF data from the file. + /// </summary> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>The metadata result to update.</returns> + public MetadataResult<Book> ReadOpfData(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var book = CreateBookFromOpf(); + var result = new MetadataResult<Book> { Item = book, HasMetadata = true }; + + FindAuthors(result); + ReadStringInto("//dc:language", language => result.ResultLanguage = language); + + return result; + } + + private Book CreateBookFromOpf() + { + var book = new Book + { + Name = FindMainTitle(), + ForcedSortName = FindSortTitle(), + }; + + ReadStringInto("//dc:description", summary => book.Overview = summary); + ReadStringInto("//dc:publisher", publisher => book.AddStudio(publisher)); + ReadStringInto("//dc:identifier[@opf:scheme='AMAZON']", amazon => book.SetProviderId("Amazon", amazon)); + ReadStringInto("//dc:identifier[@opf:scheme='GOOGLE']", google => book.SetProviderId("GoogleBooks", google)); + ReadStringInto("//dc:identifier[@opf:scheme='ISBN']", isbn => book.SetProviderId("ISBN", isbn)); + + ReadStringInto("//dc:date", date => + { + if (DateTime.TryParse(date, out var dateValue)) + { + book.PremiereDate = dateValue.Date; + book.ProductionYear = dateValue.Date.Year; + } + }); + + var genreNodes = _document.SelectNodes("//dc:subject", _namespaceManager); + + if (genreNodes?.Count > 0) + { + foreach (var node in genreNodes.Cast<XmlNode>().Where(node => !string.IsNullOrEmpty(node.InnerText) && !book.Genres.Contains(node.InnerText))) + { + // specification has no rules about content and some books combine every genre into a single element + foreach (var item in node.InnerText.Split(["/", "&", ",", ";", " - "], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + book.AddGenre(item); + } + } + } + + ReadInt32AttributeInto("//opf:meta[@name='calibre:series_index']", index => book.IndexNumber = index); + ReadInt32AttributeInto("//opf:meta[@name='calibre:rating']", rating => book.CommunityRating = rating); + + var seriesNameNode = _document.SelectSingleNode("//opf:meta[@name='calibre:series']", _namespaceManager); + + if (!string.IsNullOrEmpty(seriesNameNode?.Attributes?["content"]?.Value)) + { + try + { + book.SeriesName = seriesNameNode.Attributes["content"]?.Value; + } + catch (Exception) + { + _logger.LogError("error parsing Calibre series name"); + } + } + + return book; + } + + private string FindMainTitle() + { + var title = string.Empty; + var titleTypes = _document.SelectNodes("//opf:meta[@property='title-type']", _namespaceManager); + + if (titleTypes is not null && titleTypes.Count > 0) + { + foreach (XmlElement titleNode in titleTypes) + { + string refines = titleNode.GetAttribute("refines").TrimStart('#'); + string titleType = titleNode.InnerText; + + var titleElement = _document.SelectSingleNode($"//dc:title[@id='{refines}']", _namespaceManager); + if (titleElement is not null && string.Equals(titleType, "main", StringComparison.OrdinalIgnoreCase)) + { + title = titleElement.InnerText; + } + } + } + + // fallback in case there is no main title definition + if (string.IsNullOrEmpty(title)) + { + ReadStringInto("//dc:title", titleString => title = titleString); + } + + return title; + } + + private string? FindSortTitle() + { + var titleTypes = _document.SelectNodes("//opf:meta[@property='file-as']", _namespaceManager); + + if (titleTypes is not null && titleTypes.Count > 0) + { + foreach (XmlElement titleNode in titleTypes) + { + string refines = titleNode.GetAttribute("refines").TrimStart('#'); + string sortTitle = titleNode.InnerText; + + var titleElement = _document.SelectSingleNode($"//dc:title[@id='{refines}']", _namespaceManager); + if (titleElement is not null) + { + return sortTitle; + } + } + } + + // search for OPF 2.0 style title_sort node + var resultElement = _document.SelectSingleNode("//opf:meta[@name='calibre:title_sort']", _namespaceManager); + var titleSort = resultElement?.Attributes?["content"]?.Value; + + return titleSort; + } + + private void FindAuthors(MetadataResult<Book> book) + { + var resultElement = _document.SelectNodes("//dc:creator", _namespaceManager); + + if (resultElement != null && resultElement.Count > 0) + { + foreach (XmlElement creator in resultElement) + { + var creatorName = creator.InnerText; + var role = creator.GetAttribute("opf:role"); + var person = new PersonInfo { Name = creatorName, Type = GetRole(role) }; + + book.AddPerson(person); + } + } + } + + private PersonKind GetRole(string? role) + { + switch (role) + { + case "arr": + return PersonKind.Arranger; + case "art": + return PersonKind.Artist; + case "aut": + case "aqt": + case "aft": + case "aui": + default: + return PersonKind.Author; + case "edt": + return PersonKind.Editor; + case "ill": + return PersonKind.Illustrator; + case "lyr": + return PersonKind.Lyricist; + case "mus": + return PersonKind.AlbumArtist; + case "oth": + return PersonKind.Unknown; + case "trl": + return PersonKind.Translator; + } + } + + private void ReadStringInto(string xmlPath, Action<string> commitResult) + { + var resultElement = _document.SelectSingleNode(xmlPath, _namespaceManager); + if (resultElement is not null && !string.IsNullOrWhiteSpace(resultElement.InnerText)) + { + commitResult(resultElement.InnerText); + } + } + + private void ReadInt32AttributeInto(string xmlPath, Action<int> commitResult) + { + var resultElement = _document.SelectSingleNode(xmlPath, _namespaceManager); + var resultValue = resultElement?.Attributes?["content"]?.Value; + + if (!string.IsNullOrEmpty(resultValue)) + { + try + { + commitResult(Convert.ToInt32(Convert.ToDouble(resultValue, CultureInfo.InvariantCulture))); + } + catch (Exception e) + { + _logger.LogError(e, "error converting to Int32"); + } + } + } + + private (string MimeType, string Path)? ReadEpubCoverInto(string opfRootDirectory, string xmlPath) + { + var resultElement = _document.SelectSingleNode(xmlPath, _namespaceManager); + + if (resultElement is not null) + { + return ReadManifestItem(resultElement, opfRootDirectory); + } + + return null; + } + + private (string MimeType, string Path)? ReadManifestItem(XmlNode manifestNode, string opfRootDirectory) + { + var href = manifestNode.Attributes?["href"]?.Value; + var mediaType = manifestNode.Attributes?["media-type"]?.Value; + + if (string.IsNullOrEmpty(href) || string.IsNullOrEmpty(mediaType) || !IsValidImage(mediaType)) + { + return null; + } + + var coverPath = Path.Combine(opfRootDirectory, href); + + return (MimeType: mediaType, Path: coverPath); + } + + private static bool IsValidImage(string? mimeType) + { + return !string.IsNullOrEmpty(mimeType) && !string.IsNullOrWhiteSpace(MimeTypes.ToExtension(mimeType)); + } + } +} |
