aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Emby.Server.Implementations/Localization/Core/ar.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/bg-BG.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/ca.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/cs.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/da.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/de.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/el.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/en-GB.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/es-AR.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/es-MX.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/es.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/fa.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/fr-CA.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/fr.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/gsw.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/he.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/hr.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/hu.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/it.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/kk.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/ko.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/lt-LT.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/ms.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/nb.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/nl.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/pl.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/pt-BR.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/pt-PT.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/ru.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/sk.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/sl-SI.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/sv.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/tr.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/zh-CN.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/zh-HK.json1
-rw-r--r--Emby.Server.Implementations/Session/SessionManager.cs19
-rw-r--r--MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs78
-rw-r--r--MediaBrowser.Model/Dlna/StreamInfo.cs4
-rw-r--r--MediaBrowser.Providers/Books/OpenPackagingFormat/EpubImageProvider.cs118
-rw-r--r--MediaBrowser.Providers/Books/OpenPackagingFormat/EpubProvider.cs100
-rw-r--r--MediaBrowser.Providers/Books/OpenPackagingFormat/EpubUtils.cs35
-rw-r--r--MediaBrowser.Providers/Books/OpenPackagingFormat/OpfProvider.cs94
-rw-r--r--MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs329
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));
+ }
+ }
+}