diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index bc574d7054..e7d991d94a 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -3,7 +3,7 @@
"isRoot": true,
"tools": {
"dotnet-ef": {
- "version": "10.0.10",
+ "version": "10.0.11",
"commands": [
"dotnet-ef"
]
diff --git a/.github/workflows/ci-codeql-analysis.yml b/.github/workflows/ci-codeql-analysis.yml
index 3aea4abaaa..2b51105652 100644
--- a/.github/workflows/ci-codeql-analysis.yml
+++ b/.github/workflows/ci-codeql-analysis.yml
@@ -32,13 +32,13 @@ jobs:
dotnet-version: '10.0.x'
- name: Initialize CodeQL
- uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
+ uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
languages: ${{ matrix.language }}
queries: +security-extended
- name: Autobuild
- uses: github/codeql-action/autobuild@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
+ uses: github/codeql-action/autobuild@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
+ uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md
index d61f1703b2..99f24e3a14 100644
--- a/CONTRIBUTORS.md
+++ b/CONTRIBUTORS.md
@@ -143,6 +143,7 @@
- [Smith00101010](https://github.com/Smith00101010)
- [sorinyo2004](https://github.com/sorinyo2004)
- [Soumyadip Auddy](https://github.com/SoumyadipAuddy)
+ - [st7105](https://github.com/st7105)
- [sparky8251](https://github.com/sparky8251)
- [spookbits](https://github.com/spookbits)
- [ssenart](https://github.com/ssenart)
@@ -237,6 +238,8 @@
- [elio42](https://github.com/elio42)
- [rwebster85](https://github.com/rwebster85)
- [Florin-Popescu](https://github.com/Florin-Popescu)
+ - [m0g3r](https://github.com/m0g3r)
+ - [martin-77](https://github.com/martin-77)
# Emby Contributors
@@ -247,7 +250,7 @@
- [Mark2xv](https://github.com/Mark2xv)
- [ScottRapsey](https://github.com/ScottRapsey)
- [skynet600](https://github.com/skynet600)
- - [Cheesegeezer](https://githum.com/Cheesegeezer)
+ - [Cheesegeezer](https://github.com/Cheesegeezer)
- [Radeon](https://github.com/radeonorama)
- [gcw07](https://github.com/gcw07)
- [SivaramAdhiappan](https://github.com/shivaram1190)
diff --git a/Directory.Packages.props b/Directory.Packages.props
index ff3b3948b3..ac9830fc7e 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -26,28 +26,28 @@
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -78,7 +78,7 @@
-
+
diff --git a/Emby.Naming/Common/NamingOptions.cs b/Emby.Naming/Common/NamingOptions.cs
index 0d93403758..9ecefb4c7b 100644
--- a/Emby.Naming/Common/NamingOptions.cs
+++ b/Emby.Naming/Common/NamingOptions.cs
@@ -151,7 +151,7 @@ namespace Emby.Naming.Common
CleanStrings =
[
- @"^\s*(?.+?)[ _\,\.\(\)\[\]\-](3d|sbs|tab|hsbs|htab|mvc|HDR|HDC|UHD|UltraHD|4k|ac3|dts|custom|dc|divx|divx5|dsr|dsrip|dutch|dvd|dvdrip|dvdscr|dvdscreener|screener|dvdivx|cam|fragment|fs|hdtv|hdrip|hdtvrip|internal|limited|multi|subs|ntsc|ogg|ogm|pal|pdtv|proper|repack|rerip|retail|cd[1-9]|r5|bd5|bd|se|svcd|swedish|german|read.nfo|nfofix|unrated|ws|telesync|ts|telecine|tc|brrip|bdrip|480p|480i|576p|576i|720p|720i|1080p|1080i|2160p|hrhd|hrhdtv|hddvd|bluray|blu-ray|x264|x265|h264|h265|xvid|xvidvd|xxx|www.www|AAC|DTS)(?=[ _\,\.\(\)\[\]\-]|$)",
+ @"^\s*(?.+?)[ _\,\.\(\)\[\]\-](3d|sbs|tab|hsbs|htab|mvc|HDR|HDC|UHD|UltraHD|4k|ac3|dts|custom|dc|divx|divx5|dsr|dsrip|dutch|dvd|dvdrip|dvdscr|dvdscreener|screener|dvdivx|cam|fragment|fs|hdtv|hdrip|hdtvrip|internal|limited|multi|subs|ntsc|ogg|ogm|pal|pdtv|proper|repack|rerip|retail|cd[1-9]|r5|bd5|bd|se|svcd|swedish|german|read.nfo|nfofix|unrated|ws|web-dl|telesync|ts|telecine|tc|brrip|bdrip|480p|480i|576p|576i|720p|720i|1080p|1080i|2160p|hrhd|hrhdtv|hddvd|bluray|blu-ray|x264|x265|h264|h265|xvid|xvidvd|xxx|www.www|AAC|DTS)(?=[ _\,\.\(\)\[\]\-]|$)",
@"^\s*(?.+?)((\s*\[[^\]]+\]\s*)+)(\.[^\s]+)?$",
@"^\s*(?.+?)\WE[0-9]+(-|~)E?[0-9]+(\W|$)",
@"^\s*\[[^\]]+\](?!\.\w+$)\s*(?.+)",
diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs
index 6a39b2177d..2bba659a23 100644
--- a/Emby.Server.Implementations/Library/LibraryManager.cs
+++ b/Emby.Server.Implementations/Library/LibraryManager.cs
@@ -1914,14 +1914,14 @@ namespace Emby.Server.Implementations.Library
}
// Optimize by querying against top level views
- query.TopParentIds = parents.SelectMany(i => GetTopParentIdsForQuery(i, query.User)).ToArray();
- query.AncestorIds = [];
-
- // Prevent searching in all libraries due to empty filter
- if (query.TopParentIds.Length == 0)
+ var topParentIds = parents.SelectMany(i => GetTopParentIdsForQuery(i, query.User)).ToArray();
+ if (topParentIds.Length == 0)
{
- query.TopParentIds = [Guid.NewGuid()];
+ return;
}
+
+ query.TopParentIds = topParentIds;
+ query.AncestorIds = [];
}
public QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetAlbumArtists(InternalItemsQuery query)
@@ -1967,12 +1967,15 @@ namespace Emby.Server.Implementations.Library
if (parents.All(i => i is ICollectionFolder || i is UserView))
{
// Optimize by querying against top level views
- query.TopParentIds = parents.SelectMany(i => GetTopParentIdsForQuery(i, query.User)).ToArray();
+ var topParentIds = parents.SelectMany(i => GetTopParentIdsForQuery(i, query.User)).ToArray();
- // Prevent searching in all libraries due to empty filter
- if (query.TopParentIds.Length == 0)
+ if (topParentIds.Length > 0)
{
- query.TopParentIds = [Guid.NewGuid()];
+ query.TopParentIds = topParentIds;
+ }
+ else
+ {
+ SetAncestorIds(query, parents);
}
}
else if (parents.Count == 1 && parents.First() is Folder folder
@@ -1996,19 +1999,24 @@ namespace Emby.Server.Implementations.Library
}
else
{
- // We need to be able to query from any arbitrary ancestor up the tree
- query.AncestorIds = parents.SelectMany(i => i.GetIdsForAncestorQuery()).ToArray();
-
- // Prevent searching in all libraries due to empty filter
- if (query.AncestorIds.Length == 0)
- {
- query.AncestorIds = [Guid.NewGuid()];
- }
+ SetAncestorIds(query, parents);
}
query.Parent = null;
}
+ private static void SetAncestorIds(InternalItemsQuery query, IReadOnlyCollection parents)
+ {
+ // We need to be able to query from any arbitrary ancestor up the tree
+ query.AncestorIds = parents.SelectMany(i => i.GetIdsForAncestorQuery()).ToArray();
+
+ // Prevent searching in all libraries due to empty filter
+ if (query.AncestorIds.Length == 0)
+ {
+ query.AncestorIds = [Guid.NewGuid()];
+ }
+ }
+
private void AddUserToQuery(InternalItemsQuery query, User user, bool allowExternalContent = true)
{
if (query.User is null)
diff --git a/Emby.Server.Implementations/Library/UserViewManager.cs b/Emby.Server.Implementations/Library/UserViewManager.cs
index 9512b0ffd7..49d76e195d 100644
--- a/Emby.Server.Implementations/Library/UserViewManager.cs
+++ b/Emby.Server.Implementations/Library/UserViewManager.cs
@@ -396,6 +396,12 @@ namespace Emby.Server.Implementations.Library
query.Limit = limit;
return _libraryManager.GetLatestItemList(query, parents, CollectionType.movies);
}
+
+ if (collectionType is null)
+ {
+ query.Limit = limit;
+ return _libraryManager.GetLatestItemList(query, parents, CollectionType.unknown);
+ }
}
return _libraryManager.GetItemList(query, parents);
diff --git a/Emby.Server.Implementations/Localization/Core/da.json b/Emby.Server.Implementations/Localization/Core/da.json
index 5f5bc1b214..6e5532ead9 100644
--- a/Emby.Server.Implementations/Localization/Core/da.json
+++ b/Emby.Server.Implementations/Localization/Core/da.json
@@ -51,7 +51,7 @@
"Shows": "Serier",
"StartupEmbyServerIsLoading": "Jellyfin er i gang med at starte. Prøv igen om et øjeblik.",
"SubtitleDownloadFailureFromForItem": "Undertekster kunne ikke hentes fra {0} til {1}",
- "TvShows": "TV-serier",
+ "TvShows": "Serier",
"UserCreatedWithName": "Bruger {0} er blevet oprettet",
"UserDeletedWithName": "Brugeren {0} er nu slettet",
"UserDownloadingItemWithValues": "{0} henter {1}",
@@ -80,8 +80,8 @@
"TaskRefreshChapterImagesDescription": "Laver miniaturebilleder for videoer, der har kapitler.",
"TaskRefreshChannelsDescription": "Opdaterer information for internetkanaler.",
"TaskRefreshChannels": "Opdatér kanaler",
- "TaskCleanTranscodeDescription": "Fjerner omkodningsfiler, som er mere end 1 dag gamle.",
- "TaskCleanTranscode": "Tøm omkodningsmappen",
+ "TaskCleanTranscodeDescription": "Fjerner transkodningsfiler, som er mere end 1 dag gamle.",
+ "TaskCleanTranscode": "Tøm transkodningsmappen",
"TaskRefreshPeople": "Opdatér personer",
"TaskRefreshPeopleDescription": "Opdaterer metadata for skuespillere og instruktører i dit mediebibliotek.",
"TaskCleanActivityLogDescription": "Sletter linjer i aktivitetsloggen ældre end den konfigurerede alder.",
@@ -109,17 +109,17 @@
"CleanupUserDataTaskDescription": "Rydder alle brugerdata (eks. visning- og favoritstatus) fra medier, der har været utilgængelige i mindst 90 dage.",
"LyricDownloadFailureFromForItem": "Sangtekster kunne ikke downloades fra {0} til {1}",
"Original": "Original",
- "NameExtraBehindTheScenes": "Bag Scenerne",
+ "NameExtraBehindTheScenes": "Bag scenerne",
"NameExtraClip": "Klip",
- "NameExtraDeletedScene": "Slettet Scene",
+ "NameExtraDeletedScene": "Slettet scene",
"NameExtraFeaturette": "Featurette",
"NameExtraInterview": "Interview",
"NameExtraNumbered": "{0} {1}",
"NameExtraSample": "Smagsprøve",
"NameExtraScene": "Scene",
"NameExtraShort": "Kort",
- "NameExtraThemeSong": "Tema Sang",
- "NameExtraThemeVideo": "Tema Video",
+ "NameExtraThemeSong": "Temasang",
+ "NameExtraThemeVideo": "Temavideo",
"NameExtraTrailer": "Trailer",
"NameExtraUnknown": "Ekstra"
}
diff --git a/Emby.Server.Implementations/Localization/Core/de.json b/Emby.Server.Implementations/Localization/Core/de.json
index e812e303d0..ce5a4c0a6c 100644
--- a/Emby.Server.Implementations/Localization/Core/de.json
+++ b/Emby.Server.Implementations/Localization/Core/de.json
@@ -1,6 +1,6 @@
{
"AppDeviceValues": "App: {0}, Gerät: {1}",
- "Artists": "Interpreten",
+ "Artists": "Künstler",
"AuthenticationSucceededWithUserName": "{0} erfolgreich authentifiziert",
"Books": "Bücher",
"ChapterNameValue": "Kapitel {0}",
diff --git a/Emby.Server.Implementations/Localization/Core/en-GB.json b/Emby.Server.Implementations/Localization/Core/en-GB.json
index 298d60d277..5f1759e9d0 100644
--- a/Emby.Server.Implementations/Localization/Core/en-GB.json
+++ b/Emby.Server.Implementations/Localization/Core/en-GB.json
@@ -108,5 +108,17 @@
"CleanupUserDataTask": "User data cleanup task",
"CleanupUserDataTaskDescription": "Cleans all user data (Watch state, favourite status etc) from media that is no longer present for at least 90 days.",
"LyricDownloadFailureFromForItem": "Lyrics failed to download from {0} for {1}",
- "Original": "Original"
+ "Original": "Original",
+ "NameExtraBehindTheScenes": "Behind The Scenes",
+ "NameExtraClip": "Clip",
+ "NameExtraDeletedScene": "Deleted Scene",
+ "NameExtraFeaturette": "Featurette",
+ "NameExtraInterview": "Interview",
+ "NameExtraSample": "Sample",
+ "NameExtraScene": "Scene",
+ "NameExtraShort": "Short",
+ "NameExtraThemeSong": "Theme Song",
+ "NameExtraThemeVideo": "Theme Video",
+ "NameExtraTrailer": "Trailer",
+ "NameExtraUnknown": "Extra"
}
diff --git a/Emby.Server.Implementations/Localization/Core/eo.json b/Emby.Server.Implementations/Localization/Core/eo.json
index 133a2755a8..e59f089459 100644
--- a/Emby.Server.Implementations/Localization/Core/eo.json
+++ b/Emby.Server.Implementations/Localization/Core/eo.json
@@ -97,5 +97,9 @@
"TaskAudioNormalizationDescription": "Skanas dosierojn por sonnivelaj normaligaj datumoj.",
"TaskRefreshTrickplayImages": "Generi la bildojn por TrickPlay (Antaŭrigardo rapida antaŭen)",
"TaskAudioNormalization": "Normaligo Sonnivela",
- "HearingImpaired": "Surda"
+ "HearingImpaired": "Surda",
+ "NameExtraDeletedScene": "Forigita sceno",
+ "NameExtraScene": "Sceno",
+ "NameExtraThemeSong": "Tema Kanto",
+ "Original": "Originala"
}
diff --git a/Emby.Server.Implementations/Localization/Core/eu.json b/Emby.Server.Implementations/Localization/Core/eu.json
index 643c919843..df6855d6e3 100644
--- a/Emby.Server.Implementations/Localization/Core/eu.json
+++ b/Emby.Server.Implementations/Localization/Core/eu.json
@@ -114,5 +114,12 @@
"NameExtraScene": "Eszena",
"NameExtraShort": "Laburra",
"NameExtraThemeSong": "Gai-abestia",
- "NameExtraThemeVideo": "Gai-bideoa"
+ "NameExtraThemeVideo": "Gai-bideoa",
+ "NameExtraBehindTheScenes": "Eszenen atzean",
+ "NameExtraClip": "Klipa",
+ "NameExtraDeletedScene": "Ezabatutako eszena",
+ "NameExtraFeaturette": "Erreportajea",
+ "NameExtraInterview": "Elkarrizketa",
+ "NameExtraTrailer": "Trailerra",
+ "NameExtraUnknown": "Extra"
}
diff --git a/Emby.Server.Implementations/Localization/Core/fi.json b/Emby.Server.Implementations/Localization/Core/fi.json
index d080eb0236..36a248a1d1 100644
--- a/Emby.Server.Implementations/Localization/Core/fi.json
+++ b/Emby.Server.Implementations/Localization/Core/fi.json
@@ -108,5 +108,10 @@
"CleanupUserDataTask": "Käyttäjätietojen puhdistustehtävä",
"CleanupUserDataTaskDescription": "Puhdistaa kaikki käyttäjätiedot (katselutila, suosikit ym.) medioista, joita ei ole ollut saatavilla yli 90 päivään.",
"LyricDownloadFailureFromForItem": "Sanoitusten lataus kohteesta {0} kappaleelle {1} epäonnistui",
- "Original": "Alkuperäinen"
+ "Original": "Alkuperäinen",
+ "NameExtraBehindTheScenes": "Kulissien Takana",
+ "NameExtraClip": "Klippi",
+ "NameExtraDeletedScene": "Poistettu Kohtaus",
+ "NameExtraFeaturette": "Lyhytelokuva",
+ "NameExtraInterview": "Haastattelu"
}
diff --git a/Emby.Server.Implementations/Localization/Core/fo.json b/Emby.Server.Implementations/Localization/Core/fo.json
index 29e007866f..d1e9065d97 100644
--- a/Emby.Server.Implementations/Localization/Core/fo.json
+++ b/Emby.Server.Implementations/Localization/Core/fo.json
@@ -1,6 +1,6 @@
{
"Artists": "Tónlistafólk",
- "Collections": "Søvn",
+ "Collections": "Samlingar",
"Default": "Forsett",
"External": "Ytri",
"Genres": "Greinar",
@@ -55,8 +55,8 @@
"TaskUpdatePluginsDescription": "Niðurtekur og innleggur dagføringar til ískoytisforrit ið eru stillaði til at dagførast sjálvvirkandi.",
"TaskCleanTranscodeDescription": "Strikar umkotaðar fílar ið eru eldri enn 1 dag.",
"TaskOptimizeDatabase": "Albøt dátugrunn",
- "NameSeasonNumber": "Sesong {0}",
- "NameSeasonUnknown": "Ókend sesong",
+ "NameSeasonNumber": "Umfar {0}",
+ "NameSeasonUnknown": "Ókent umfar",
"ScheduledTaskFailedWithName": "{0} miseydnaðist",
"Undefined": "Óskilmarkað",
"TasksMaintenanceCategory": "Viðlíkahald",
diff --git a/Emby.Server.Implementations/Localization/Core/gl.json b/Emby.Server.Implementations/Localization/Core/gl.json
index a68db0076a..9eb9786b8a 100644
--- a/Emby.Server.Implementations/Localization/Core/gl.json
+++ b/Emby.Server.Implementations/Localization/Core/gl.json
@@ -107,5 +107,19 @@
"TaskAudioNormalizationDescription": "Escanea ficheiros á procura de datos de normalización de volume.",
"CleanupUserDataTask": "Tarefa de limpeza de datos dos usuarios",
"CleanupUserDataTaskDescription": "Limpa todos os datos do usuario (estado de visualización, de favorito etc.) dos medios ausentes polo menos 90 días.",
- "Original": "Orixinal"
+ "Original": "Orixinal",
+ "LyricDownloadFailureFromForItem": "Non se puideron descargar as letras desde {0} para {1}",
+ "NameExtraBehindTheScenes": "Detrás das Cámaras",
+ "NameExtraClip": "Clip",
+ "NameExtraDeletedScene": "Escena Eliminada",
+ "NameExtraFeaturette": "Reportaxe especial",
+ "NameExtraInterview": "Entrevista",
+ "NameExtraNumbered": "{0} {1}",
+ "NameExtraSample": "Mostra",
+ "NameExtraScene": "Escena",
+ "NameExtraShort": "Curtametraxe",
+ "NameExtraThemeSong": "Canción principal",
+ "NameExtraThemeVideo": "Vídeo da canción principal",
+ "NameExtraTrailer": "Tráiler",
+ "NameExtraUnknown": "Extra"
}
diff --git a/Emby.Server.Implementations/Localization/Core/he.json b/Emby.Server.Implementations/Localization/Core/he.json
index 48056b0bb8..0ca7c6aa08 100644
--- a/Emby.Server.Implementations/Localization/Core/he.json
+++ b/Emby.Server.Implementations/Localization/Core/he.json
@@ -108,5 +108,12 @@
"CleanupUserDataTaskDescription": "ניקוי כל המידע של המשתמש (מצב צפייה, מועדפים וכו) ממדיה שאינה קיימת מעל 90 יום.",
"CleanupUserDataTask": "משימת ניקוי מידע משתמש",
"LyricDownloadFailureFromForItem": "הורדת המילים מ-{0} עבור {1} נכשלה",
- "Original": "מקור"
+ "Original": "מקור",
+ "NameExtraBehindTheScenes": "מאחורי הקלעים",
+ "NameExtraClip": "קליפ",
+ "NameExtraDeletedScene": "סצנה שנמחקה",
+ "NameExtraFeaturette": "סרט קצר",
+ "NameExtraInterview": "ריאיון",
+ "NameExtraSample": "דגימה",
+ "NameExtraScene": "סצנה"
}
diff --git a/Emby.Server.Implementations/Localization/Core/hu.json b/Emby.Server.Implementations/Localization/Core/hu.json
index f7982352ee..4a327e7b02 100644
--- a/Emby.Server.Implementations/Localization/Core/hu.json
+++ b/Emby.Server.Implementations/Localization/Core/hu.json
@@ -120,5 +120,6 @@
"NameExtraThemeSong": "Főcímdal",
"NameExtraThemeVideo": "Főcímvideó",
"NameExtraTrailer": "Előzetes",
- "NameExtraUnknown": "Extra"
+ "NameExtraUnknown": "Extra",
+ "NameExtraNumbered": "{0} {1}"
}
diff --git a/Emby.Server.Implementations/Localization/Core/hy.json b/Emby.Server.Implementations/Localization/Core/hy.json
index b79b540bf4..80be71ed70 100644
--- a/Emby.Server.Implementations/Localization/Core/hy.json
+++ b/Emby.Server.Implementations/Localization/Core/hy.json
@@ -24,5 +24,10 @@
"TaskDownloadMissingSubtitles": "Ներբեռնել պակասող ենթագրերը",
"AppDeviceValues": "Հավելված` {0}, Սարք `{1}",
"ChapterNameValue": "Գլուխ {0}",
- "Collections": "Հավաքածուներ"
+ "Collections": "Հավաքածուներ",
+ "Artists": "Երաժիշտներ",
+ "Default": "Լռելյայն",
+ "Favorites": "Ընտրյալ",
+ "Forced": "Ստիպուած",
+ "Genres": "Ոճ"
}
diff --git a/Emby.Server.Implementations/Localization/Core/it.json b/Emby.Server.Implementations/Localization/Core/it.json
index 3f4a6e3e54..41097ed92e 100644
--- a/Emby.Server.Implementations/Localization/Core/it.json
+++ b/Emby.Server.Implementations/Localization/Core/it.json
@@ -118,5 +118,8 @@
"NameExtraSample": "Campione",
"NameExtraShort": "Corto",
"NameExtraThemeSong": "Sigla musicale",
- "NameExtraTrailer": "Trailer"
+ "NameExtraTrailer": "Trailer",
+ "NameExtraFeaturette": "Caratteristica",
+ "NameExtraThemeVideo": "Video tematico",
+ "NameExtraUnknown": "Extra"
}
diff --git a/Emby.Server.Implementations/Localization/Core/lt-LT.json b/Emby.Server.Implementations/Localization/Core/lt-LT.json
index 137aa15ad5..b0fb6c52ba 100644
--- a/Emby.Server.Implementations/Localization/Core/lt-LT.json
+++ b/Emby.Server.Implementations/Localization/Core/lt-LT.json
@@ -59,8 +59,8 @@
"UserOfflineFromDevice": "{0} buvo atjungtas nuo {1}",
"UserOnlineFromDevice": "{0} prisijungęs iš {1}",
"UserPasswordChangedWithName": "Slaptažodis pakeistas naudotojui {0}",
- "UserStartedPlayingItemWithValues": "{0} leidžia {1} į {2}",
- "UserStoppedPlayingItemWithValues": "{0} baigė leisti {1} į {2}",
+ "UserStartedPlayingItemWithValues": "{0} atkuriama {1} į {2}",
+ "UserStoppedPlayingItemWithValues": "{0} baigė atkūrimą {1} į {2}",
"VersionNumber": "Versija {0}",
"TaskUpdatePluginsDescription": "Atsisiunčia ir įdiegia įskiepių, kurie sukonfigūruoti atnaujinti automatiškai, naujinius.",
"TaskUpdatePlugins": "Atnaujinti įskieius",
diff --git a/Emby.Server.Implementations/Localization/Core/ms.json b/Emby.Server.Implementations/Localization/Core/ms.json
index 9fc61fadd5..bb4469ad9c 100644
--- a/Emby.Server.Implementations/Localization/Core/ms.json
+++ b/Emby.Server.Implementations/Localization/Core/ms.json
@@ -106,5 +106,11 @@
"TaskAudioNormalization": "Normalisasi Audio",
"TaskAudioNormalizationDescription": "Mengimbas fail-fail untuk data normalisasi audio.",
"CleanupUserDataTaskDescription": "Membersihkan semua data pengguna (keadaan tontonan, status kegemaran, dan sebagainya) daripada media yang tidak lagi wujud sekurang-kurangnya selama 90 hari.",
- "CleanupUserDataTask": "Tugas pembersihan data pengguna"
+ "CleanupUserDataTask": "Tugas pembersihan data pengguna",
+ "LyricDownloadFailureFromForItem": "Lirik gagal dimuat turun dari {0} untuk {1}",
+ "NameExtraBehindTheScenes": "Di Sebalik Takbir",
+ "NameExtraClip": "Klip",
+ "NameExtraInterview": "Temu bual",
+ "NameExtraThemeSong": "Lagu Tema",
+ "NameExtraThemeVideo": "Video Tema"
}
diff --git a/Emby.Server.Implementations/Localization/Core/pt-PT.json b/Emby.Server.Implementations/Localization/Core/pt-PT.json
index ce7f6d120e..e71475afd8 100644
--- a/Emby.Server.Implementations/Localization/Core/pt-PT.json
+++ b/Emby.Server.Implementations/Localization/Core/pt-PT.json
@@ -5,108 +5,121 @@
"Books": "Livros",
"ChapterNameValue": "Capítulo {0}",
"Collections": "Coleções",
- "FailedLoginAttemptWithUserName": "Tentativa de login falhada a partir de {0}",
+ "FailedLoginAttemptWithUserName": "Tentativa falhada de início de sessão do utilizador {0}",
"Favorites": "Favoritos",
"Folders": "Pastas",
"Genres": "Géneros",
"HeaderContinueWatching": "Continuar a ver",
- "HeaderFavoriteEpisodes": "Episódios Favoritos",
- "HeaderFavoriteShows": "Séries Favoritas",
- "HeaderLiveTV": "TV em Direto",
- "HeaderNextUp": "A Seguir",
- "HomeVideos": "Vídeos Caseiros",
+ "HeaderFavoriteEpisodes": "Episódios favoritos",
+ "HeaderFavoriteShows": "Séries favoritas",
+ "HeaderLiveTV": "TV em direto",
+ "HeaderNextUp": "A seguir",
+ "HomeVideos": "Vídeos caseiros",
"Inherit": "Herdar",
"LabelIpAddressValue": "Endereço IP: {0}",
"LabelRunningTimeValue": "Duração: {0}",
- "Latest": "Mais Recente",
- "MixedContent": "Conteúdo Misto",
+ "Latest": "Mais recente",
+ "MixedContent": "Conteúdo misto",
"Movies": "Filmes",
"Music": "Música",
"MusicVideos": "Videoclipes",
- "NameInstallFailed": "{0} falha na instalação",
+ "NameInstallFailed": "Falha na instalação de {0}",
"NameSeasonNumber": "Temporada {0}",
- "NameSeasonUnknown": "Temporada Desconhecida",
+ "NameSeasonUnknown": "Temporada desconhecida",
"NewVersionIsAvailable": "Está disponível para transferência uma nova versão do servidor Jellyfin.",
"NotificationOptionApplicationUpdateAvailable": "Atualização de aplicação disponível",
"NotificationOptionApplicationUpdateInstalled": "Atualização de aplicação instalada",
- "NotificationOptionAudioPlayback": "Reprodução Iniciada",
- "NotificationOptionAudioPlaybackStopped": "Reprodução Parada",
+ "NotificationOptionAudioPlayback": "Reprodução de áudio iniciada",
+ "NotificationOptionAudioPlaybackStopped": "Reprodução de áudio interrompida",
"NotificationOptionCameraImageUploaded": "Imagem da câmara enviada",
"NotificationOptionInstallationFailed": "Falha na instalação",
"NotificationOptionNewLibraryContent": "Novo conteúdo adicionado",
- "NotificationOptionPluginError": "Falha na extensão",
- "NotificationOptionPluginInstalled": "Extensão instalada",
- "NotificationOptionPluginUninstalled": "Extensão desinstalada",
- "NotificationOptionPluginUpdateInstalled": "Extensão atualizada",
+ "NotificationOptionPluginError": "Falha no plugin",
+ "NotificationOptionPluginInstalled": "Plugin instalado",
+ "NotificationOptionPluginUninstalled": "Plugin desinstalado",
+ "NotificationOptionPluginUpdateInstalled": "Atualização de plugin instalada",
"NotificationOptionServerRestartRequired": "Necessário reiniciar o servidor",
"NotificationOptionTaskFailed": "Falha em tarefa agendada",
"NotificationOptionUserLockedOut": "Utilizador bloqueado",
- "NotificationOptionVideoPlayback": "Reprodução do vídeo iniciada",
- "NotificationOptionVideoPlaybackStopped": "Reprodução do vídeo parada",
+ "NotificationOptionVideoPlayback": "Reprodução de vídeo iniciada",
+ "NotificationOptionVideoPlaybackStopped": "Reprodução de vídeo interrompida",
"Photos": "Fotografias",
"PluginInstalledWithName": "{0} foi instalado",
"PluginUninstalledWithName": "{0} foi desinstalado",
"PluginUpdatedWithName": "{0} foi atualizado",
"ScheduledTaskFailedWithName": "{0} falhou",
"Shows": "Séries",
- "StartupEmbyServerIsLoading": "O servidor Jellyfin está a iniciar. Tente novamente mais tarde.",
- "SubtitleDownloadFailureFromForItem": "Falha na transferência de legendas a partir de {0} para {1}",
- "TvShows": "Séries",
+ "StartupEmbyServerIsLoading": "O servidor Jellyfin está a iniciar. Tenta novamente dentro de instantes.",
+ "SubtitleDownloadFailureFromForItem": "Falha ao transferir legendas de {0} para {1}",
+ "TvShows": "Séries de TV",
"UserCreatedWithName": "Utilizador {0} criado",
- "UserDeletedWithName": "Utilizador {0} apagado",
+ "UserDeletedWithName": "Utilizador {0} eliminado",
"UserDownloadingItemWithValues": "{0} está a transferir {1}",
"UserLockedOutWithName": "Utilizador {0} bloqueado",
- "UserOfflineFromDevice": "{0} desligou-se a partir de {1}",
- "UserOnlineFromDevice": "{0} ligou-se a partir de {1}",
+ "UserOfflineFromDevice": "{0} desligou-se de {1}",
+ "UserOnlineFromDevice": "{0} está online em {1}",
"UserPasswordChangedWithName": "Palavra-passe alterada para o utilizador {0}",
"UserStartedPlayingItemWithValues": "{0} está a reproduzir {1} em {2}",
"UserStoppedPlayingItemWithValues": "{0} terminou a reprodução de {1} em {2}",
"VersionNumber": "Versão {0}",
- "TaskDownloadMissingSubtitlesDescription": "Procurar na internet por legendas em falta baseado na configuração de metadados.",
+ "TaskDownloadMissingSubtitlesDescription": "Procura na Internet legendas em falta com base na configuração dos metadados.",
"TaskDownloadMissingSubtitles": "Transferir legendas em falta",
- "TaskRefreshChannelsDescription": "Atualizar informação sobre canais da Internet.",
- "TaskRefreshChannels": "Atualizar Canais",
- "TaskCleanTranscodeDescription": "Apagar ficheiros de transcode com mais de um dia.",
- "TaskCleanTranscode": "Limpar a Diretoria de Transcode",
- "TaskUpdatePluginsDescription": "Faz o download e instala updates para os plugins que estão configurados para atualizar automaticamente.",
- "TaskUpdatePlugins": "Atualizar Plugins",
- "TaskRefreshPeopleDescription": "Atualizar metadados para elenco e equipa técnica da tua mediateca.",
- "TaskRefreshPeople": "Atualizar Pessoas",
- "TaskCleanLogsDescription": "Apagar ficheiros de log que têm mais de {0} dias.",
- "TaskCleanLogs": "Limpar a Diretoria de Logs",
- "TaskRefreshLibraryDescription": "Analisar a mediateca para novos ficheiros e atualizar os metadados.",
- "TaskRefreshLibrary": "Analisar mediateca",
- "TaskRefreshChapterImagesDescription": "Criar thumbnails para os vídeos que têm capítulos.",
- "TaskRefreshChapterImages": "Extrair Imagens dos Capítulos",
- "TaskCleanCacheDescription": "Apagar ficheiros em cache que já não são necessários.",
- "TaskCleanCache": "Limpar Cache",
+ "TaskRefreshChannelsDescription": "Atualiza as informações dos canais da Internet.",
+ "TaskRefreshChannels": "Atualizar canais",
+ "TaskCleanTranscodeDescription": "Elimina ficheiros de transcodificação com mais de um dia.",
+ "TaskCleanTranscode": "Limpar pasta de transcodificação",
+ "TaskUpdatePluginsDescription": "Transfere e instala atualizações dos plugins configurados para atualização automática.",
+ "TaskUpdatePlugins": "Atualizar plugins",
+ "TaskRefreshPeopleDescription": "Atualiza os metadados de atores e realizadores na tua biblioteca multimédia.",
+ "TaskRefreshPeople": "Atualizar pessoas",
+ "TaskCleanLogsDescription": "Elimina ficheiros de registo com mais de {0} dias.",
+ "TaskCleanLogs": "Limpar pasta de registos",
+ "TaskRefreshLibraryDescription": "Analisa a biblioteca multimédia à procura de novos ficheiros e atualiza os metadados.",
+ "TaskRefreshLibrary": "Analisar biblioteca multimédia",
+ "TaskRefreshChapterImagesDescription": "Cria miniaturas para vídeos que têm capítulos.",
+ "TaskRefreshChapterImages": "Extrair imagens dos capítulos",
+ "TaskCleanCacheDescription": "Elimina ficheiros de cache que já não são necessários ao sistema.",
+ "TaskCleanCache": "Limpar pasta de cache",
"TasksChannelsCategory": "Canais da Internet",
"TasksApplicationCategory": "Aplicação",
- "TasksLibraryCategory": "Mediateca",
+ "TasksLibraryCategory": "Biblioteca",
"TasksMaintenanceCategory": "Manutenção",
- "TaskCleanActivityLogDescription": "Apaga as entradas do registo de atividade anteriores à data configurada.",
+ "TaskCleanActivityLogDescription": "Elimina as entradas do registo de atividade mais antigas do que o período configurado.",
"TaskCleanActivityLog": "Limpar registo de atividade",
"Undefined": "Indefinido",
"Forced": "Forçado",
"Default": "Predefinição",
- "TaskOptimizeDatabaseDescription": "Otimiza e liberta espaço livre na base de dados. A execução desta tarefa depois de analisar a mediateca ou efetuar outras alterações que impliquem modificações na base de dados pode melhorar o desempenho.",
+ "TaskOptimizeDatabaseDescription": "Compacta a base de dados e liberta espaço não utilizado. A execução desta tarefa depois de analisar a biblioteca ou de outras alterações que modifiquem a base de dados pode melhorar o desempenho.",
"TaskOptimizeDatabase": "Otimizar base de dados",
- "TaskKeyframeExtractorDescription": "Extrai quadros-chave de ficheiros de video para criar listas de reprodução HLS mais precisas. Esta tarefa pode demorar algum tempo.",
- "TaskKeyframeExtractor": "Extrator de Quadros-chave",
+ "TaskKeyframeExtractorDescription": "Extrai fotogramas-chave de ficheiros de vídeo para criar playlists HLS mais precisas. Esta tarefa pode demorar algum tempo.",
+ "TaskKeyframeExtractor": "Extrator de fotogramas-chave",
"External": "Externo",
- "HearingImpaired": "Surdo",
+ "HearingImpaired": "Deficiência auditiva",
"TaskRefreshTrickplayImages": "Gerar imagens de trickplay",
"TaskRefreshTrickplayImagesDescription": "Cria pré-visualizações de trickplay para vídeos nas bibliotecas ativadas.",
"TaskAudioNormalizationDescription": "Analisa os ficheiros para obter dados de normalização de áudio.",
"TaskAudioNormalization": "Normalização de áudio",
- "TaskExtractMediaSegments": "Analisar segmentos de multimédia",
- "TaskDownloadMissingLyrics": "Transferir letra em falta",
- "TaskMoveTrickplayImages": "Migrar a localização da imagem do Trickplay",
- "TaskDownloadMissingLyricsDescription": "Transferir letra para músicas",
- "TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos de multimédia a partir de plugins com suporte para MediaSegment.",
- "TaskMoveTrickplayImagesDescription": "Move os ficheiros trickplay existentes de acordo com as definições da mediateca.",
- "CleanupUserDataTaskDescription": "Apaga todos os dados de utilizador (estados de reprodução, favoritos, etc) de arquivos média não presentes há 90 dias ou mais.",
+ "TaskExtractMediaSegments": "Analisar segmentos multimédia",
+ "TaskDownloadMissingLyrics": "Transferir letras em falta",
+ "TaskMoveTrickplayImages": "Migrar localização das imagens de trickplay",
+ "TaskDownloadMissingLyricsDescription": "Transfere letras de músicas",
+ "TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos multimédia de plugins com MediaSegment ativado.",
+ "TaskMoveTrickplayImagesDescription": "Move os ficheiros de trickplay existentes de acordo com as definições da biblioteca.",
+ "CleanupUserDataTaskDescription": "Remove todos os dados de utilizador (estado de reprodução, estado de favorito, etc.) de conteúdos multimédia que já não estejam presentes há pelo menos 90 dias.",
"CleanupUserDataTask": "Limpeza de dados de utilizador",
"Original": "Original",
- "LyricDownloadFailureFromForItem": "Erro ao descarregar letras de {0} para {1}"
+ "LyricDownloadFailureFromForItem": "Falha ao transferir letras de {0} para {1}",
+ "NameExtraDeletedScene": "Cena eliminada",
+ "NameExtraInterview": "Entrevista",
+ "NameExtraUnknown": "Extra",
+ "NameExtraBehindTheScenes": "Bastidores",
+ "NameExtraSample": "Amostra",
+ "NameExtraScene": "Cena",
+ "NameExtraClip": "Excerto",
+ "NameExtraFeaturette": "Minidocumentário",
+ "NameExtraThemeSong": "Tema musical",
+ "NameExtraThemeVideo": "Vídeo temático",
+ "NameExtraShort": "Curta-metragem",
+ "NameExtraNumbered": "{0} {1}",
+ "NameExtraTrailer": "Trailer"
}
diff --git a/Emby.Server.Implementations/Localization/Core/pt.json b/Emby.Server.Implementations/Localization/Core/pt.json
index 9a6e213d9a..8299a25992 100644
--- a/Emby.Server.Implementations/Localization/Core/pt.json
+++ b/Emby.Server.Implementations/Localization/Core/pt.json
@@ -1,124 +1,125 @@
{
- "HeaderLiveTV": "TV Em Direto",
+ "HeaderLiveTV": "TV em direto",
"Collections": "Coleções",
"Books": "Livros",
"Artists": "Artistas",
- "HeaderNextUp": "A Seguir",
- "HeaderFavoriteEpisodes": "Episódios Favoritos",
- "HeaderFavoriteShows": "Séries Favoritas",
+ "HeaderNextUp": "A seguir",
+ "HeaderFavoriteEpisodes": "Episódios favoritos",
+ "HeaderFavoriteShows": "Séries favoritas",
"HeaderContinueWatching": "Continuar a ver",
"Genres": "Géneros",
"Folders": "Pastas",
"Favorites": "Favoritos",
- "UserDownloadingItemWithValues": "{0} está sendo baixado {1}",
+ "UserDownloadingItemWithValues": "{0} está a transferir {1}",
"VersionNumber": "Versão {0}",
"UserStoppedPlayingItemWithValues": "{0} terminou a reprodução de {1} em {2}",
- "UserStartedPlayingItemWithValues": "{0} está reproduzindo {1} em {2}",
- "UserPasswordChangedWithName": "A senha do usuário {0} foi alterada",
- "UserOnlineFromDevice": "{0} está online a partir de {1}",
- "UserOfflineFromDevice": "{0} desconectou-se a partir de {1}",
- "UserLockedOutWithName": "O usuário {0} foi bloqueado",
- "UserDeletedWithName": "O usuário {0} foi removido",
- "UserCreatedWithName": "O usuário {0} foi criado",
- "TvShows": "Séries",
- "SubtitleDownloadFailureFromForItem": "Falha na transferência de legendas de {0} para {1}",
- "StartupEmbyServerIsLoading": "O servidor Jellyfin está iniciando. Tente novamente dentro de momentos.",
+ "UserStartedPlayingItemWithValues": "{0} está a reproduzir {1} em {2}",
+ "UserPasswordChangedWithName": "Palavra-passe alterada para o utilizador {0}",
+ "UserOnlineFromDevice": "{0} está online em {1}",
+ "UserOfflineFromDevice": "{0} desligou-se de {1}",
+ "UserLockedOutWithName": "Utilizador {0} bloqueado",
+ "UserDeletedWithName": "Utilizador {0} eliminado",
+ "UserCreatedWithName": "Utilizador {0} criado",
+ "TvShows": "Séries de TV",
+ "SubtitleDownloadFailureFromForItem": "Falha ao transferir legendas de {0} para {1}",
+ "StartupEmbyServerIsLoading": "O servidor Jellyfin está a iniciar. Tenta novamente dentro de instantes.",
"ScheduledTaskFailedWithName": "{0} falhou",
"PluginUpdatedWithName": "{0} foi atualizado",
"PluginUninstalledWithName": "{0} foi desinstalado",
"PluginInstalledWithName": "{0} foi instalado",
- "NotificationOptionVideoPlaybackStopped": "Reprodução de vídeo parada",
+ "NotificationOptionVideoPlaybackStopped": "Reprodução de vídeo interrompida",
"NotificationOptionVideoPlayback": "Reprodução de vídeo iniciada",
- "NotificationOptionUserLockedOut": "Usuário bloqueado",
- "NotificationOptionTaskFailed": "Falha na tarefa agendada",
- "NotificationOptionServerRestartRequired": "É necessário reiniciar o servidor",
- "NotificationOptionPluginUpdateInstalled": "Plugin atualizado",
+ "NotificationOptionUserLockedOut": "Utilizador bloqueado",
+ "NotificationOptionTaskFailed": "Falha em tarefa agendada",
+ "NotificationOptionServerRestartRequired": "Necessário reiniciar o servidor",
+ "NotificationOptionPluginUpdateInstalled": "Atualização de plugin instalada",
"NotificationOptionPluginUninstalled": "Plugin desinstalado",
"NotificationOptionPluginInstalled": "Plugin instalado",
"NotificationOptionPluginError": "Falha no plugin",
"NotificationOptionNewLibraryContent": "Novo conteúdo adicionado",
- "NotificationOptionInstallationFailed": "Falha de instalação",
- "NotificationOptionCameraImageUploaded": "Imagem de câmera enviada",
- "NotificationOptionAudioPlaybackStopped": "Reprodução Parada",
- "NotificationOptionAudioPlayback": "Reprodução Iniciada",
- "NotificationOptionApplicationUpdateInstalled": "A atualização do aplicativo foi instalada",
- "NotificationOptionApplicationUpdateAvailable": "Uma atualização do aplicativo está disponível",
- "NewVersionIsAvailable": "Uma nova versão do servidor Jellyfin está disponível para download.",
- "NameSeasonUnknown": "Temporada Desconhecida",
+ "NotificationOptionInstallationFailed": "Falha na instalação",
+ "NotificationOptionCameraImageUploaded": "Imagem da câmara enviada",
+ "NotificationOptionAudioPlaybackStopped": "Reprodução de áudio interrompida",
+ "NotificationOptionAudioPlayback": "Reprodução de áudio iniciada",
+ "NotificationOptionApplicationUpdateInstalled": "Atualização de aplicação instalada",
+ "NotificationOptionApplicationUpdateAvailable": "Atualização de aplicação disponível",
+ "NewVersionIsAvailable": "Está disponível para transferência uma nova versão do servidor Jellyfin.",
+ "NameSeasonUnknown": "Temporada desconhecida",
"NameSeasonNumber": "Temporada {0}",
"NameInstallFailed": "Falha na instalação de {0}",
"MusicVideos": "Videoclipes",
"Music": "Música",
- "MixedContent": "Conteúdo diverso",
- "Latest": "Mais Recente",
+ "MixedContent": "Conteúdo misto",
+ "Latest": "Mais recente",
"LabelRunningTimeValue": "Duração: {0}",
- "LabelIpAddressValue": "Endereço de IP: {0}",
+ "LabelIpAddressValue": "Endereço IP: {0}",
"Inherit": "Herdar",
- "HomeVideos": "Vídeos Caseiros",
+ "HomeVideos": "Vídeos caseiros",
"Shows": "Séries",
"Photos": "Fotografias",
"Movies": "Filmes",
- "FailedLoginAttemptWithUserName": "Tentativa de início de sessão falhada a partir de {0}",
+ "FailedLoginAttemptWithUserName": "Tentativa falhada de início de sessão do utilizador {0}",
"ChapterNameValue": "Capítulo {0}",
"AuthenticationSucceededWithUserName": "{0} autenticado com sucesso",
"AppDeviceValues": "Aplicação: {0}, Dispositivo: {1}",
- "TaskCleanCache": "Limpar Diretório de Cache",
+ "TaskCleanCache": "Limpar pasta de cache",
"TasksApplicationCategory": "Aplicação",
- "TasksLibraryCategory": "Mediateca",
+ "TasksLibraryCategory": "Biblioteca",
"TasksMaintenanceCategory": "Manutenção",
- "TaskRefreshChannels": "Atualizar Canais",
- "TaskUpdatePlugins": "Atualizar Plugins",
- "TaskCleanLogsDescription": "Deletar arquivos de log que existe a mais de {0} dias.",
- "TaskCleanLogs": "Limpar diretório de logs",
- "TaskRefreshLibrary": "Analisar mediateca",
+ "TaskRefreshChannels": "Atualizar canais",
+ "TaskUpdatePlugins": "Atualizar plugins",
+ "TaskCleanLogsDescription": "Elimina ficheiros de registo com mais de {0} dias.",
+ "TaskCleanLogs": "Limpar pasta de registos",
+ "TaskRefreshLibrary": "Analisar biblioteca multimédia",
"TaskRefreshChapterImagesDescription": "Cria miniaturas para vídeos que têm capítulos.",
- "TaskCleanCacheDescription": "Apaga ficheiros em cache que já não são usados pelo sistema.",
- "TasksChannelsCategory": "Canais de Internet",
- "TaskRefreshChapterImages": "Extrair Imagens do Capítulo",
- "TaskDownloadMissingSubtitlesDescription": "Pesquisa na Internet as legendas em falta com base na configuração de metadados.",
+ "TaskCleanCacheDescription": "Elimina ficheiros de cache que já não são necessários ao sistema.",
+ "TasksChannelsCategory": "Canais da Internet",
+ "TaskRefreshChapterImages": "Extrair imagens dos capítulos",
+ "TaskDownloadMissingSubtitlesDescription": "Procura na Internet legendas em falta com base na configuração dos metadados.",
"TaskDownloadMissingSubtitles": "Transferir legendas em falta",
- "TaskRefreshChannelsDescription": "Atualiza as informações do canal da Internet.",
- "TaskCleanTranscodeDescription": "Apagar os ficheiros com mais de um dia, de Transcode.",
- "TaskCleanTranscode": "Limpar o diretório de Transcode",
- "TaskUpdatePluginsDescription": "Baixa e instala as atualizações para plug-ins configurados para atualização automática.",
- "TaskRefreshPeopleDescription": "Atualizar metadados para elenco e equipa técnica da tua mediateca.",
+ "TaskRefreshChannelsDescription": "Atualiza as informações dos canais da Internet.",
+ "TaskCleanTranscodeDescription": "Elimina ficheiros de transcodificação com mais de um dia.",
+ "TaskCleanTranscode": "Limpar pasta de transcodificação",
+ "TaskUpdatePluginsDescription": "Transfere e instala atualizações dos plugins configurados para atualização automática.",
+ "TaskRefreshPeopleDescription": "Atualiza os metadados de atores e realizadores na tua biblioteca multimédia.",
"TaskRefreshPeople": "Atualizar pessoas",
- "TaskRefreshLibraryDescription": "Analisar a mediateca para novos ficheiros e atualizar os metadados.",
- "TaskCleanActivityLog": "Limpar registro de atividade",
+ "TaskRefreshLibraryDescription": "Analisa a biblioteca multimédia à procura de novos ficheiros e atualiza os metadados.",
+ "TaskCleanActivityLog": "Limpar registo de atividade",
"Undefined": "Indefinido",
"Forced": "Forçado",
"Default": "Predefinição",
- "TaskCleanActivityLogDescription": "Apaga itens no registro com idade acima do que é configurado.",
+ "TaskCleanActivityLogDescription": "Elimina as entradas do registo de atividade mais antigas do que o período configurado.",
"TaskOptimizeDatabase": "Otimizar base de dados",
- "TaskOptimizeDatabaseDescription": "Otimiza e liberta espaço livre na base de dados. A execução desta tarefa depois de analisar a mediateca ou efetuar outras alterações que impliquem modificações na base de dados pode melhorar o desempenho.",
+ "TaskOptimizeDatabaseDescription": "Compacta a base de dados e liberta espaço não utilizado. A execução desta tarefa depois de analisar a biblioteca ou de outras alterações que modifiquem a base de dados pode melhorar o desempenho.",
"External": "Externo",
- "HearingImpaired": "Problemas auditivos",
- "TaskKeyframeExtractor": "Extrator de quadro-chave",
- "TaskKeyframeExtractorDescription": "Retira frames chave do video para criar listas HLS precisas. Esta tarefa pode correr durante algum tempo.",
- "TaskRefreshTrickplayImages": "Gerar imagens de Trickplay",
- "TaskRefreshTrickplayImagesDescription": "Cria miniaturas de pré-visualização (Trickplay) para vídeos nas bibliotecas ativadas.",
+ "HearingImpaired": "Deficiência auditiva",
+ "TaskKeyframeExtractor": "Extrator de fotogramas-chave",
+ "TaskKeyframeExtractorDescription": "Extrai fotogramas-chave de ficheiros de vídeo para criar playlists HLS mais precisas. Esta tarefa pode demorar algum tempo.",
+ "TaskRefreshTrickplayImages": "Gerar imagens de trickplay",
+ "TaskRefreshTrickplayImagesDescription": "Cria pré-visualizações de trickplay para vídeos nas bibliotecas ativadas.",
"TaskAudioNormalizationDescription": "Analisa os ficheiros para obter dados de normalização de áudio.",
"TaskAudioNormalization": "Normalização de áudio",
- "TaskDownloadMissingLyrics": "Transferir letra em falta",
- "TaskDownloadMissingLyricsDescription": "Transferir letra para músicas",
- "TaskMoveTrickplayImagesDescription": "Move os ficheiros Trickplay existentes de acordo com as definições da mediateca.",
- "TaskExtractMediaSegments": "Analisar segmentos de multimédia",
- "TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos de multimédia a partir de plugins com suporte para MediaSegment.",
- "TaskMoveTrickplayImages": "Migrar a localização das imagens de Trickplay",
- "CleanupUserDataTask": "Task de limpeza de dados do usuário",
- "CleanupUserDataTaskDescription": "Remove todos os dados do usuário (progresso, favoritos etc) de mídias que não estão presentes há pelo menos 90 dias.",
+ "TaskDownloadMissingLyrics": "Transferir letras em falta",
+ "TaskDownloadMissingLyricsDescription": "Transfere letras de músicas",
+ "TaskMoveTrickplayImagesDescription": "Move os ficheiros de trickplay existentes de acordo com as definições da biblioteca.",
+ "TaskExtractMediaSegments": "Analisar segmentos multimédia",
+ "TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos multimédia de plugins com MediaSegment ativado.",
+ "TaskMoveTrickplayImages": "Migrar localização das imagens de trickplay",
+ "CleanupUserDataTask": "Limpeza de dados de utilizador",
+ "CleanupUserDataTaskDescription": "Remove todos os dados de utilizador (estado de reprodução, estado de favorito, etc.) de conteúdos multimédia que já não estejam presentes há pelo menos 90 dias.",
"Original": "Original",
- "LyricDownloadFailureFromForItem": "Erro ao descarregar letras de {0} para {1}",
+ "LyricDownloadFailureFromForItem": "Falha ao transferir letras de {0} para {1}",
"NameExtraBehindTheScenes": "Bastidores",
- "NameExtraClip": "Clip",
- "NameExtraDeletedScene": "Cena Eliminada",
- "NameExtraFeaturette": "Média-metragem",
+ "NameExtraClip": "Excerto",
+ "NameExtraDeletedScene": "Cena eliminada",
+ "NameExtraFeaturette": "Minidocumentário",
"NameExtraInterview": "Entrevista",
"NameExtraSample": "Amostra",
"NameExtraShort": "Curta-metragem",
- "NameExtraThemeSong": "Tema Principal",
- "NameExtraThemeVideo": "Vídeo de Abertura",
+ "NameExtraThemeSong": "Tema musical",
+ "NameExtraThemeVideo": "Vídeo temático",
"NameExtraScene": "Cena",
"NameExtraUnknown": "Extra",
- "NameExtraTrailer": "Trailer"
+ "NameExtraTrailer": "Trailer",
+ "NameExtraNumbered": "{0} {1}"
}
diff --git a/Emby.Server.Implementations/Localization/Core/ro.json b/Emby.Server.Implementations/Localization/Core/ro.json
index ea83b88951..358c19881f 100644
--- a/Emby.Server.Implementations/Localization/Core/ro.json
+++ b/Emby.Server.Implementations/Localization/Core/ro.json
@@ -108,5 +108,8 @@
"CleanupUserDataTask": "Sarcina de curatare a datelor utilizatorului",
"CleanupUserDataTaskDescription": "Sterge toate datele utilizatorului (starea vizionarii, starea favoritelor etc.) de pe suporturile media care nu mai sunt prezente timp de cel puțin 90 de zile.",
"LyricDownloadFailureFromForItem": "Versurile nu au putut fi descărcate din {0} pentru {1}",
- "Original": "Original"
+ "Original": "Original",
+ "NameExtraBehindTheScenes": "În culise",
+ "NameExtraClip": "Clip",
+ "NameExtraDeletedScene": "Scenă ștearsă"
}
diff --git a/Emby.Server.Implementations/Localization/Core/ru.json b/Emby.Server.Implementations/Localization/Core/ru.json
index 6382fc083f..c13fd3ebdc 100644
--- a/Emby.Server.Implementations/Localization/Core/ru.json
+++ b/Emby.Server.Implementations/Localization/Core/ru.json
@@ -109,7 +109,7 @@
"CleanupUserDataTaskDescription": "Очищает все пользовательские данные (состояние просмотра, статус избранного и т.д.) с медиа, отсутствующих по меньшей мере в течение 90 дней.",
"Original": "Оригинальный",
"LyricDownloadFailureFromForItem": "Не получилось скачать текст песни с {0} для {1}",
- "NameExtraBehindTheScenes": "За кулисами",
+ "NameExtraBehindTheScenes": "За кадром",
"NameExtraClip": "Отрывок",
"NameExtraDeletedScene": "Удалённая сцена",
"NameExtraFeaturette": "Короткометражка",
@@ -119,5 +119,7 @@
"NameExtraThemeSong": "Заглавная песня",
"NameExtraThemeVideo": "Заглавное видео",
"NameExtraTrailer": "Трейлер",
- "NameExtraUnknown": "Дополнительный материал"
+ "NameExtraUnknown": "Дополнительный материал",
+ "NameExtraNumbered": "{0} {1}",
+ "NameExtraShort": "Короткометражка"
}
diff --git a/Emby.Server.Implementations/Localization/Core/sk.json b/Emby.Server.Implementations/Localization/Core/sk.json
index babacc31a9..9573eeefe4 100644
--- a/Emby.Server.Implementations/Localization/Core/sk.json
+++ b/Emby.Server.Implementations/Localization/Core/sk.json
@@ -91,7 +91,7 @@
"Default": "Predvolené",
"TaskOptimizeDatabaseDescription": "Zmenší databázu a odstráni prázdne miesto. Spustenie tejto úlohy po skenovaní knižnice alebo po iných zmenách zahŕňajúcich úpravy databáze môže zlepšiť výkon.",
"TaskOptimizeDatabase": "Optimalizovať databázu",
- "TaskKeyframeExtractorDescription": "Extrahuje kľúčové snímky z video súborov na vytvorenie presnejších HLS playlistov. Táto úloha môže trvať dlhšiu dobu.",
+ "TaskKeyframeExtractorDescription": "Extrahuje kľúčové snímky z video súborov na vytvorenie presnejších HLS zoznamov prehrávania. Táto úloha môže trvať dlhšiu dobu.",
"TaskKeyframeExtractor": "Extraktor kľúčových snímkov",
"External": "Externé",
"HearingImpaired": "Sluchovo postihnutí",
diff --git a/Emby.Server.Implementations/Localization/Core/sq.json b/Emby.Server.Implementations/Localization/Core/sq.json
index e13f7b09e1..11b25e9795 100644
--- a/Emby.Server.Implementations/Localization/Core/sq.json
+++ b/Emby.Server.Implementations/Localization/Core/sq.json
@@ -108,5 +108,18 @@
"CleanupUserDataTaskDescription": "Pastron të gjitha të dhënat e përdorueseve (gjendja e shikimit, statusi i të preferuarave etj.) nga mediat që nuk janë më të pranishme për të paktën 90 ditë.",
"CleanupUserDataTask": "Veprim për pastrimin të dhënave të përdorueseve",
"LyricDownloadFailureFromForItem": "Teksti i këngës nuk arriti të shkarkohej nga {0} për {1}",
- "Original": "Origjinal"
+ "Original": "Origjinal",
+ "NameExtraBehindTheScenes": "Pamje nga prapaskenat",
+ "NameExtraClip": "Pjesë",
+ "NameExtraDeletedScene": "Skenë e fshirë",
+ "NameExtraFeaturette": "Film i shkurtër",
+ "NameExtraInterview": "Intervistë",
+ "NameExtraNumbered": "{0} {1}",
+ "NameExtraSample": "Shembull",
+ "NameExtraScene": "Skenë",
+ "NameExtraShort": "Film i shkurtër",
+ "NameExtraThemeSong": "Kënga e temës",
+ "NameExtraThemeVideo": "Videoja e temës",
+ "NameExtraTrailer": "Parapamje",
+ "NameExtraUnknown": "Shtesë"
}
diff --git a/Emby.Server.Implementations/Localization/Core/sr.json b/Emby.Server.Implementations/Localization/Core/sr.json
index 92f309c80c..de0e425098 100644
--- a/Emby.Server.Implementations/Localization/Core/sr.json
+++ b/Emby.Server.Implementations/Localization/Core/sr.json
@@ -108,5 +108,18 @@
"TaskMoveTrickplayImages": "Промени локацију сличица за визуелно премотавање",
"TaskDownloadMissingLyricsDescription": "Преузми стихове песама",
"LyricDownloadFailureFromForItem": "Није успело преузимање стихова са {0} за {1}",
- "Original": "Изворно"
+ "Original": "Изворно",
+ "NameExtraBehindTheScenes": "Иза кулиса",
+ "NameExtraClip": "Исечак",
+ "NameExtraDeletedScene": "Обрисана сцена",
+ "NameExtraFeaturette": "Кратки прилог",
+ "NameExtraInterview": "Интервју",
+ "NameExtraNumbered": "{0} {1}",
+ "NameExtraSample": "Узорак",
+ "NameExtraScene": "Сцена",
+ "NameExtraShort": "Кратки филм",
+ "NameExtraThemeSong": "Музичка тема",
+ "NameExtraThemeVideo": "Спот музичке теме",
+ "NameExtraTrailer": "Реклама",
+ "NameExtraUnknown": "Додатак"
}
diff --git a/Emby.Server.Implementations/Localization/Core/sv.json b/Emby.Server.Implementations/Localization/Core/sv.json
index 30c85baaba..7d741bca36 100644
--- a/Emby.Server.Implementations/Localization/Core/sv.json
+++ b/Emby.Server.Implementations/Localization/Core/sv.json
@@ -116,5 +116,10 @@
"NameExtraScene": "Scen",
"NameExtraShort": "Kortfilm",
"NameExtraThemeSong": "Signaturmelodi",
- "NameExtraTrailer": "Trailer"
+ "NameExtraTrailer": "Trailer",
+ "NameExtraClip": "Klipp",
+ "NameExtraFeaturette": "Kortfilm",
+ "NameExtraSample": "Prov",
+ "NameExtraThemeVideo": "Signaturvideo",
+ "NameExtraUnknown": "Extra"
}
diff --git a/Emby.Server.Implementations/Localization/Core/ta.json b/Emby.Server.Implementations/Localization/Core/ta.json
index f613b973db..256f349926 100644
--- a/Emby.Server.Implementations/Localization/Core/ta.json
+++ b/Emby.Server.Implementations/Localization/Core/ta.json
@@ -106,5 +106,20 @@
"TaskExtractMediaSegments": "மீடியா பிரிவு ஸ்கேன்",
"TaskExtractMediaSegmentsDescription": "மீடியாசெக்மென்ட் இயக்கப்பட்ட செருகுநிரல்களிலிருந்து மீடியா பிரிவுகளைப் பிரித்தெடுக்கிறது அல்லது பெறுகிறது.",
"CleanupUserDataTaskDescription": "குறைந்தது 90 நாட்களுக்கு இல்லாத மீடியாவிலிருந்து அனைத்து பயனர் தரவையும் (கண்காணிப்பு நிலை, பிடித்த நிலை போன்றவை) சுத்தம் செய்கிறது.",
- "CleanupUserDataTask": "பயனர் தரவை சுத்தம் செய்யும் பணி"
+ "CleanupUserDataTask": "பயனர் தரவை சுத்தம் செய்யும் பணி",
+ "LyricDownloadFailureFromForItem": "{0} இலிருந்து {1} க்கு பாடல் வரிகளைப் பதிவிறக்க முடியவில்லை",
+ "NameExtraBehindTheScenes": "திரைக்குப் பின்னால்",
+ "NameExtraClip": "துண்டுக்காட்சி",
+ "NameExtraDeletedScene": "நீக்கப்பட்ட காட்சிகள்",
+ "NameExtraFeaturette": "திரைப்பின்னணித் தொகுப்பு",
+ "NameExtraInterview": "நேர்காணல்",
+ "NameExtraSample": "மாதிரி",
+ "NameExtraScene": "காட்சி",
+ "NameExtraShort": "குறுகிய",
+ "NameExtraThemeSong": "மையப் பாடல்",
+ "NameExtraThemeVideo": "மையக் காணொளி",
+ "NameExtraTrailer": "முன்னோட்டம்",
+ "NameExtraUnknown": "கூடுதல்",
+ "Original": "அசல்",
+ "NameExtraNumbered": "{0} {1}"
}
diff --git a/Emby.Server.Implementations/Localization/Core/tr.json b/Emby.Server.Implementations/Localization/Core/tr.json
index 1aa4b6a4b6..67f4ecc4d5 100644
--- a/Emby.Server.Implementations/Localization/Core/tr.json
+++ b/Emby.Server.Implementations/Localization/Core/tr.json
@@ -1,5 +1,5 @@
{
- "AppDeviceValues": "Uygulama: {0}, Aygıt: {1}",
+ "AppDeviceValues": "Uygulama: {0}, Cihaz: {1}",
"Artists": "Sanatçılar",
"AuthenticationSucceededWithUserName": "{0} kimliği başarıyla doğrulandı",
"Books": "Kitaplar",
diff --git a/Emby.Server.Implementations/Localization/Core/zh-CN.json b/Emby.Server.Implementations/Localization/Core/zh-CN.json
index d6e4be01e8..9590d9c1c9 100644
--- a/Emby.Server.Implementations/Localization/Core/zh-CN.json
+++ b/Emby.Server.Implementations/Localization/Core/zh-CN.json
@@ -115,11 +115,11 @@
"NameExtraFeaturette": "花絮",
"NameExtraInterview": "采访",
"NameExtraNumbered": "{0} {1}",
- "NameExtraSample": "样本",
- "NameExtraScene": "场景",
- "NameExtraShort": "短片",
+ "NameExtraSample": "试看",
+ "NameExtraScene": "精选片段",
+ "NameExtraShort": "短篇",
"NameExtraThemeSong": "主题曲",
"NameExtraThemeVideo": "主题视频",
"NameExtraTrailer": "预告片",
- "NameExtraUnknown": "额外"
+ "NameExtraUnknown": "额外内容"
}
diff --git a/Emby.Server.Implementations/Localization/Core/zh-HK.json b/Emby.Server.Implementations/Localization/Core/zh-HK.json
index b2fcbc93a2..9557742d39 100644
--- a/Emby.Server.Implementations/Localization/Core/zh-HK.json
+++ b/Emby.Server.Implementations/Localization/Core/zh-HK.json
@@ -10,8 +10,8 @@
"Folders": "資料夾",
"Genres": "風格",
"HeaderContinueWatching": "繼續睇返",
- "HeaderFavoriteEpisodes": "心水嘅劇集",
- "HeaderFavoriteShows": "心水嘅節目",
+ "HeaderFavoriteEpisodes": "心水劇集",
+ "HeaderFavoriteShows": "心水節目",
"HeaderLiveTV": "電視直播",
"HeaderNextUp": "跟住落嚟",
"HomeVideos": "家庭影片",
@@ -108,5 +108,18 @@
"CleanupUserDataTask": "清理使用者資料嘅任務",
"CleanupUserDataTaskDescription": "清理已消失至少 90 日嘅媒體用家數據(包括觀看狀態、心水狀態等)。",
"LyricDownloadFailureFromForItem": "冇辦法從 {0} 下載 {1} 嘅歌詞",
- "Original": "原始"
+ "Original": "原始",
+ "NameExtraBehindTheScenes": "幕後花絮",
+ "NameExtraClip": "片段",
+ "NameExtraDeletedScene": "刪減場景",
+ "NameExtraFeaturette": "花絮",
+ "NameExtraInterview": "采訪",
+ "NameExtraNumbered": "{0} {1}",
+ "NameExtraSample": "試看",
+ "NameExtraScene": "精選片段",
+ "NameExtraShort": "短篇",
+ "NameExtraThemeSong": "主題曲",
+ "NameExtraThemeVideo": "主題影片",
+ "NameExtraTrailer": "預告片",
+ "NameExtraUnknown": "額外內容"
}
diff --git a/Emby.Server.Implementations/Localization/Core/zh-TW.json b/Emby.Server.Implementations/Localization/Core/zh-TW.json
index 4e0dff87b9..bcc5b0b44f 100644
--- a/Emby.Server.Implementations/Localization/Core/zh-TW.json
+++ b/Emby.Server.Implementations/Localization/Core/zh-TW.json
@@ -108,5 +108,18 @@
"CleanupUserDataTask": "用戶資料清理工作",
"CleanupUserDataTaskDescription": "從用戶資料中清除已被刪除超過 90 天的媒體的相關資料。",
"Original": "原作",
- "LyricDownloadFailureFromForItem": "無法從 {0} 下載 {1} 的歌詞"
+ "LyricDownloadFailureFromForItem": "無法從 {0} 下載 {1} 的歌詞",
+ "NameExtraBehindTheScenes": "幕後花絮",
+ "NameExtraClip": "片段",
+ "NameExtraDeletedScene": "刪減場景",
+ "NameExtraFeaturette": "花絮",
+ "NameExtraInterview": "采訪",
+ "NameExtraNumbered": "{0} {1}",
+ "NameExtraSample": "試看",
+ "NameExtraScene": "精選片段",
+ "NameExtraShort": "短篇",
+ "NameExtraThemeSong": "主題曲",
+ "NameExtraThemeVideo": "主題影片",
+ "NameExtraTrailer": "預告片",
+ "NameExtraUnknown": "額外內容"
}
diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs
index e4939205c9..29b633530f 100644
--- a/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs
@@ -174,7 +174,7 @@ public partial class AudioNormalizationTask : IScheduledTask
if (!t.NormalizationGain.HasValue && !t.LUFS.HasValue && t.IsFileProtocol)
{
t.LUFS = await CalculateLUFSAsync(
- string.Format(CultureInfo.InvariantCulture, "-i \"{0}\"", t.Path.Replace("\"", "\\\"", StringComparison.Ordinal)),
+ string.Format(CultureInfo.InvariantCulture, "-i \"{0}\"", t.Path.EscapeProcessArgument()),
false,
cancellationToken).ConfigureAwait(false);
toSaveDbItems.Add(t);
diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs
index 5d62332552..f4aa0ad03a 100644
--- a/Emby.Server.Implementations/Session/SessionManager.cs
+++ b/Emby.Server.Implementations/Session/SessionManager.cs
@@ -655,7 +655,7 @@ namespace Emby.Server.Implementations.Session
ItemId = session.NowPlayingItem is null ? Guid.Empty : session.NowPlayingItem.Id,
SessionId = session.Id,
MediaSourceId = session.PlayState?.MediaSourceId,
- PositionTicks = session.PlayState?.PositionTicks
+ PositionTicks = session.LastPlaybackCheckInPositionTicks
}).ConfigureAwait(false);
}
catch (Exception ex)
diff --git a/Jellyfin.Api/Controllers/DynamicHlsController.cs b/Jellyfin.Api/Controllers/DynamicHlsController.cs
index a6555a2beb..034a9dea55 100644
--- a/Jellyfin.Api/Controllers/DynamicHlsController.cs
+++ b/Jellyfin.Api/Controllers/DynamicHlsController.cs
@@ -20,7 +20,6 @@ using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Controller.Streaming;
-using MediaBrowser.MediaEncoding.Encoder;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Dlna;
using MediaBrowser.Model.Entities;
@@ -1652,9 +1651,9 @@ public class DynamicHlsController : BaseJellyfinApiController
segmentFormat,
startNumber.ToString(CultureInfo.InvariantCulture),
baseUrlParam,
- EncodingUtils.NormalizePath(outputTsArg),
+ outputTsArg.EscapeProcessArgument(),
hlsArguments,
- EncodingUtils.NormalizePath(outputPath)).Trim();
+ outputPath.EscapeProcessArgument()).Trim();
}
///
diff --git a/Jellyfin.Api/Controllers/ItemLookupController.cs b/Jellyfin.Api/Controllers/ItemLookupController.cs
index d009f80a96..39ba5ab186 100644
--- a/Jellyfin.Api/Controllers/ItemLookupController.cs
+++ b/Jellyfin.Api/Controllers/ItemLookupController.cs
@@ -13,6 +13,7 @@ using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.Providers;
using Microsoft.AspNetCore.Authorization;
@@ -263,7 +264,7 @@ public class ItemLookupController : BaseJellyfinApiController
searchResult.ProviderIds);
// Since the refresh process won't erase provider Ids, we need to set this explicitly now.
- item.ProviderIds = searchResult.ProviderIds;
+ item.SetProviderIds(searchResult.ProviderIds);
await _providerManager.RefreshFullItem(
item,
new MetadataRefreshOptions(new DirectoryService(_fileSystem))
diff --git a/Jellyfin.Api/Controllers/ItemUpdateController.cs b/Jellyfin.Api/Controllers/ItemUpdateController.cs
index 36c82cf461..65fffc4181 100644
--- a/Jellyfin.Api/Controllers/ItemUpdateController.cs
+++ b/Jellyfin.Api/Controllers/ItemUpdateController.cs
@@ -428,15 +428,7 @@ public class ItemUpdateController : BaseJellyfinApiController
if (request.ProviderIds is not null)
{
- foreach (var pair in request.ProviderIds.ToList())
- {
- if (string.IsNullOrEmpty(pair.Value))
- {
- request.ProviderIds.Remove(pair.Key);
- }
- }
-
- item.ProviderIds = request.ProviderIds;
+ item.SetProviderIds(request.ProviderIds);
}
if (item is Video video)
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs
index c7acf72043..c9e08b1b5d 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs
@@ -110,7 +110,7 @@ public sealed partial class BaseItemRepository
PrepareFilterQuery(filter);
// Early exit if collection type is not supported
- if (collectionType is not CollectionType.movies and not CollectionType.tvshows and not CollectionType.music)
+ if (collectionType is not CollectionType.movies and not CollectionType.tvshows and not CollectionType.music and not CollectionType.unknown)
{
return [];
}
@@ -121,30 +121,27 @@ public sealed partial class BaseItemRepository
var baseQuery = PrepareItemQuery(context, filter);
baseQuery = TranslateQuery(baseQuery, context, filter);
- if (collectionType == CollectionType.tvshows)
+ if (collectionType is CollectionType.tvshows)
{
return GetLatestTvShowItems(context, baseQuery, filter, limit);
}
if (collectionType is CollectionType.movies)
{
- // Pick, per PresentationUniqueKey, the newest item; return the newest `limit` of those.
- // Build up until limit by streaming through results and deduplicating on the fly.
- var orderedIds = baseQuery
- .Where(e => e.PresentationUniqueKey != null)
- .OrderByDescending(e => e.DateCreated)
- .ThenByDescending(e => e.Id)
- .Select(e => new { e.Id, e.PresentationUniqueKey });
+ return GetLatestMovieItems(context, baseQuery, filter, limit);
+ }
- // DistinctBy and Take are lazy, so enumeration stops as soon as limit distinct keys are read.
- var firstIds = orderedIds
- .AsEnumerable()
- .DistinctBy(row => row.PresentationUniqueKey)
- .Select(row => row.Id)
+ if (collectionType is CollectionType.unknown)
+ {
+ var moviesQuery = baseQuery.Where(e => e.SeriesName == null);
+ var latestMovies = GetLatestMovieItems(context, moviesQuery, filter, limit);
+ var latestShows = GetLatestTvShowItems(context, baseQuery, filter, limit);
+
+ return latestMovies.Concat(latestShows)
+ .OrderByDescending(dto => dto.DateCreated)
+ .ThenByDescending(dto => dto.Id)
.Take(limit ?? int.MaxValue)
.ToList();
-
- return LoadLatestByIds(context, firstIds, filter);
}
var musicAlbumTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicAlbum]!;
@@ -225,6 +222,39 @@ public sealed partial class BaseItemRepository
.ToArray()!;
}
+ ///
+ /// Gets the latest movies, deduplicated so each movie only appears once.
+ ///
+ /// The database context.
+ /// The query to pull movies from, with filters already applied.
+ /// The original query filter, used when loading the final items.
+ /// How many items to return.
+ /// The latest movies, newest first.
+ private IReadOnlyList GetLatestMovieItems(
+ JellyfinDbContext context,
+ IQueryable baseQuery,
+ InternalItemsQuery filter,
+ int? limit)
+ {
+ // Pick, per PresentationUniqueKey, the newest item; return the newest `limit` of those.
+ // Build up until limit by streaming through results and deduplicating on the fly.
+ var orderedIds = baseQuery
+ .Where(e => e.PresentationUniqueKey != null)
+ .OrderByDescending(e => e.DateCreated)
+ .ThenByDescending(e => e.Id)
+ .Select(e => new { e.Id, e.PresentationUniqueKey });
+
+ // DistinctBy and Take are lazy, so enumeration stops as soon as limit distinct keys are read.
+ var firstIds = orderedIds
+ .AsEnumerable()
+ .DistinctBy(row => row.PresentationUniqueKey)
+ .Select(row => row.Id)
+ .Take(limit ?? int.MaxValue)
+ .ToList();
+
+ return LoadLatestByIds(context, firstIds, filter);
+ }
+
///
/// Gets the latest TV show items with smart Season/Series container selection.
///
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
index 4be9b04baa..623c1ea0ab 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
@@ -35,6 +35,18 @@ public sealed partial class BaseItemRepository
// instance across several lambdas, and this filter is combined into a tree more than once.
private static Expression> IsFolderFilter => e => e.IsFolder;
+ // "und" is the language filters' stand-in for a track that declares no language at all.
+ private static string NormalizeLanguage(string language)
+ => string.Equals(language, "und", StringComparison.OrdinalIgnoreCase) ? "und" : language;
+
+ // The primary versions whose alternate version satisfies a dimension bound. Anchored on
+ // PrimaryVersionId so the filtered index carries it rather than a scan of every item.
+ private static IQueryable VersionsMatchingDimension(JellyfinDbContext context, Expression> bound)
+ => context.BaseItems
+ .Where(v => v.PrimaryVersionId != null)
+ .Where(bound)
+ .Select(v => v.PrimaryVersionId!.Value);
+
///
public IQueryable TranslateQuery(
IQueryable baseQuery,
@@ -70,47 +82,86 @@ public sealed partial class BaseItemRepository
include4K = true;
}
- // Non-folders: check own resolution directly (no subquery).
- // Folders (Series, BoxSets): EXISTS check on descendants/linked children.
- // Using navigation properties (a.Item, lc.Child) produces efficient
- // EXISTS + JOIN instead of nested IN (SELECT ...) subqueries.
+ // A 4K remux of an SD primary is a version of the same item, so the bucket a caller filters
+ // on is the best any of the item's versions offers, not just the primary file's. Three sets,
+ // because a bucket is as much about what the version group does not have as what it does, and
+ // because an unprobed primary can still be placed by a version that does carry dimensions.
+ // The filtered PrimaryVersionId index keeps all three to the few items that have versions.
+ var versionsSd = VersionsMatchingDimension(context, v => v.Width > 0 && v.Width < HDWidth);
+ var versionsHd = VersionsMatchingDimension(context, v => v.Width >= HDWidth);
+ var versions4K = VersionsMatchingDimension(context, v => v.Width >= UHDWidth || v.Height >= UHDHeight);
+
+ // Only the SD test needs the Width > 0 guard against a row with no dimensions: such a row
+ // cannot reach the HD or 4K bound anyway, and EF lowers the HD bucket's negated "not itself
+ // 4K" guard to CASE WHEN ... THEN 0 ELSE 1, which already reads unknown as not 4K rather
+ // than propagating a null. Folders (Series, BoxSets) answer on their descendants, bucketed
+ // exactly as a top-level item is so that the two cannot disagree; the navigation properties
+ // (a.Item, lc.Child) give EXISTS + JOIN rather than nested IN (SELECT ...).
baseQuery = baseQuery.Where(e =>
- (!e.IsFolder && e.Width > 0
- && ((includeSD && e.Width < HDWidth)
- || (includeHD && e.Width >= HDWidth && !(e.Width >= UHDWidth || e.Height >= UHDHeight))
- || (include4K && (e.Width >= UHDWidth || e.Height >= UHDHeight))))
+ (!e.IsFolder
+ && ((includeSD
+ && ((e.Width > 0 && e.Width < HDWidth) || versionsSd.Contains(e.Id))
+ && !versionsHd.Contains(e.Id)
+ && !versions4K.Contains(e.Id))
+ || (includeHD
+ && (e.Width >= HDWidth || versionsHd.Contains(e.Id))
+ && !(e.Width >= UHDWidth || e.Height >= UHDHeight)
+ && !versions4K.Contains(e.Id))
+ || (include4K
+ && (e.Width >= UHDWidth || e.Height >= UHDHeight || versions4K.Contains(e.Id)))))
|| (e.IsFolder
&& (e.Children!.Any(a =>
- a.Item.Width > 0
- && ((includeSD && a.Item.Width < HDWidth)
- || (includeHD && a.Item.Width >= HDWidth && !(a.Item.Width >= UHDWidth || a.Item.Height >= UHDHeight))
- || (include4K && (a.Item.Width >= UHDWidth || a.Item.Height >= UHDHeight))))
+ (includeSD
+ && ((a.Item.Width > 0 && a.Item.Width < HDWidth) || versionsSd.Contains(a.ItemId))
+ && !versionsHd.Contains(a.ItemId)
+ && !versions4K.Contains(a.ItemId))
+ || (includeHD
+ && (a.Item.Width >= HDWidth || versionsHd.Contains(a.ItemId))
+ && !(a.Item.Width >= UHDWidth || a.Item.Height >= UHDHeight)
+ && !versions4K.Contains(a.ItemId))
+ || (include4K
+ && (a.Item.Width >= UHDWidth || a.Item.Height >= UHDHeight || versions4K.Contains(a.ItemId))))
|| context.LinkedChildren.Any(lc =>
lc.ParentId == e.Id
- && lc.Child!.Width > 0
- && ((includeSD && lc.Child.Width < HDWidth)
- || (includeHD && lc.Child.Width >= HDWidth && !(lc.Child.Width >= UHDWidth || lc.Child.Height >= UHDHeight))
- || (include4K && (lc.Child.Width >= UHDWidth || lc.Child.Height >= UHDHeight)))))));
+ && ((includeSD
+ && ((lc.Child!.Width > 0 && lc.Child!.Width < HDWidth) || versionsSd.Contains(lc.ChildId))
+ && !versionsHd.Contains(lc.ChildId)
+ && !versions4K.Contains(lc.ChildId))
+ || (includeHD
+ && (lc.Child!.Width >= HDWidth || versionsHd.Contains(lc.ChildId))
+ && !(lc.Child!.Width >= UHDWidth || lc.Child!.Height >= UHDHeight)
+ && !versions4K.Contains(lc.ChildId))
+ || (include4K
+ && (lc.Child!.Width >= UHDWidth || lc.Child!.Height >= UHDHeight || versions4K.Contains(lc.ChildId))))))));
}
+ // Same reasoning as the resolution filter: a dimension bound is met if any version meets it.
if (minWidth.HasValue)
{
- baseQuery = baseQuery.Where(e => e.Width >= minWidth);
+ var versionsWideEnough = VersionsMatchingDimension(context, v => v.Width >= minWidth);
+ baseQuery = baseQuery.Where(e => e.Width >= minWidth || versionsWideEnough.Contains(e.Id));
}
if (filter.MinHeight.HasValue)
{
- baseQuery = baseQuery.Where(e => e.Height >= filter.MinHeight);
+ var minHeight = filter.MinHeight;
+ var versionsTallEnough = VersionsMatchingDimension(context, v => v.Height >= minHeight);
+ baseQuery = baseQuery.Where(e => e.Height >= minHeight || versionsTallEnough.Contains(e.Id));
}
+ // An upper bound inverts that: it is met only if no version breaches it, since the item's
+ // resolution is the best its version group offers.
if (maxWidth.HasValue)
{
- baseQuery = baseQuery.Where(e => e.Width <= maxWidth);
+ var versionsTooWide = VersionsMatchingDimension(context, v => v.Width > maxWidth);
+ baseQuery = baseQuery.Where(e => e.Width <= maxWidth && !versionsTooWide.Contains(e.Id));
}
if (filter.MaxHeight.HasValue)
{
- baseQuery = baseQuery.Where(e => e.Height <= filter.MaxHeight);
+ var maxHeight = filter.MaxHeight;
+ var versionsTooTall = VersionsMatchingDimension(context, v => v.Height > maxHeight);
+ baseQuery = baseQuery.Where(e => e.Height <= maxHeight && !versionsTooTall.Contains(e.Id));
}
if (filter.IsLocked.HasValue)
@@ -761,104 +812,144 @@ public sealed partial class BaseItemRepository
if (!string.IsNullOrWhiteSpace(filter.HasNoAudioTrackWithLanguage))
{
- var lang = filter.HasNoAudioTrackWithLanguage;
- var foldersWithAudio = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Audio, lang));
+ var lang = NormalizeLanguage(filter.HasNoAudioTrackWithLanguage);
+ var undetermined = string.Equals(lang, "und", StringComparison.Ordinal);
+ var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Audio, lang);
+ // A track only an alternate version carries still belongs to the item a caller sees, so the
+ // item's own streams alone do not decide this. Same for every stream filter below.
+ var versionsWithAudio = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
+ var foldersWithAudio = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
baseQuery = baseQuery
.Where(e =>
- (!e.IsFolder && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Audio && ms.Language == lang))
+ (!e.IsFolder
+ && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Audio
+ && (ms.Language == lang || (undetermined && string.IsNullOrEmpty(ms.Language))))
+ && !versionsWithAudio.Contains(e.Id))
|| (e.IsFolder && !foldersWithAudio.Contains(e.Id)));
}
if (!string.IsNullOrWhiteSpace(filter.HasNoInternalSubtitleTrackWithLanguage))
{
- var lang = filter.HasNoInternalSubtitleTrackWithLanguage;
- var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: false));
+ var lang = NormalizeLanguage(filter.HasNoInternalSubtitleTrackWithLanguage);
+ var undetermined = string.Equals(lang, "und", StringComparison.Ordinal);
+ var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: false);
+ var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
+ var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
baseQuery = baseQuery
.Where(e =>
- (!e.IsFolder && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && !ms.IsExternal && ms.Language == lang))
+ (!e.IsFolder
+ && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && !ms.IsExternal
+ && (ms.Language == lang || (undetermined && string.IsNullOrEmpty(ms.Language))))
+ && !versionsWithSubtitles.Contains(e.Id))
|| (e.IsFolder && !foldersWithSubtitles.Contains(e.Id)));
}
if (!string.IsNullOrWhiteSpace(filter.HasNoExternalSubtitleTrackWithLanguage))
{
- var lang = filter.HasNoExternalSubtitleTrackWithLanguage;
- var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: true));
+ var lang = NormalizeLanguage(filter.HasNoExternalSubtitleTrackWithLanguage);
+ var undetermined = string.Equals(lang, "und", StringComparison.Ordinal);
+ var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: true);
+ var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
+ var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
baseQuery = baseQuery
.Where(e =>
- (!e.IsFolder && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && ms.IsExternal && ms.Language == lang))
+ (!e.IsFolder
+ && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && ms.IsExternal
+ && (ms.Language == lang || (undetermined && string.IsNullOrEmpty(ms.Language))))
+ && !versionsWithSubtitles.Contains(e.Id))
|| (e.IsFolder && !foldersWithSubtitles.Contains(e.Id)));
}
if (!string.IsNullOrWhiteSpace(filter.HasNoSubtitleTrackWithLanguage))
{
- var lang = filter.HasNoSubtitleTrackWithLanguage;
- var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang));
+ var lang = NormalizeLanguage(filter.HasNoSubtitleTrackWithLanguage);
+ var undetermined = string.Equals(lang, "und", StringComparison.Ordinal);
+ var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang);
+ var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
+ var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
baseQuery = baseQuery
.Where(e =>
- (!e.IsFolder && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && ms.Language == lang))
+ (!e.IsFolder
+ && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle
+ && (ms.Language == lang || (undetermined && string.IsNullOrEmpty(ms.Language))))
+ && !versionsWithSubtitles.Contains(e.Id))
|| (e.IsFolder && !foldersWithSubtitles.Contains(e.Id)));
}
if (filter.HasSubtitles.HasValue)
{
var hasSubtitles = filter.HasSubtitles.Value;
- var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasSubtitles());
+ var criteria = new HasSubtitles();
+ var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
+ var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
if (hasSubtitles)
{
baseQuery = baseQuery
.Where(e =>
- (!e.IsFolder && e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle))
+ (!e.IsFolder && (e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle)
+ || versionsWithSubtitles.Contains(e.Id)))
|| (e.IsFolder && foldersWithSubtitles.Contains(e.Id)));
}
else
{
baseQuery = baseQuery
.Where(e =>
- (!e.IsFolder && !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle))
+ (!e.IsFolder && !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle)
+ && !versionsWithSubtitles.Contains(e.Id))
|| (e.IsFolder && !foldersWithSubtitles.Contains(e.Id)));
}
}
if (filter.SubtitleLanguages.Count > 0)
{
- var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, filter.SubtitleLanguages));
+ var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, filter.SubtitleLanguages);
+ var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
+ var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
baseQuery = baseQuery
.Where(e =>
- (!e.IsFolder && e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle
- && (filter.SubtitleLanguages.Contains(f.Language) || (filter.SubtitleLanguages.Contains("und") && string.IsNullOrEmpty(f.Language)))))
+ (!e.IsFolder && (e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle
+ && (filter.SubtitleLanguages.Contains(f.Language) || (filter.SubtitleLanguages.Contains("und") && string.IsNullOrEmpty(f.Language))))
+ || versionsWithSubtitles.Contains(e.Id)))
|| (e.IsFolder && foldersWithSubtitles.Contains(e.Id)));
}
if (filter.AudioLanguages.Count > 0)
{
- var foldersWithAudio = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Audio, filter.AudioLanguages));
+ var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Audio, filter.AudioLanguages);
+ var versionsWithAudio = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
+ var foldersWithAudio = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
baseQuery = baseQuery
.Where(e =>
- (!e.IsFolder && e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Audio
- && (filter.AudioLanguages.Contains(f.Language) || (filter.AudioLanguages.Contains("und") && string.IsNullOrEmpty(f.Language)))))
+ (!e.IsFolder && (e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Audio
+ && (filter.AudioLanguages.Contains(f.Language) || (filter.AudioLanguages.Contains("und") && string.IsNullOrEmpty(f.Language))))
+ || versionsWithAudio.Contains(e.Id)))
|| (e.IsFolder && foldersWithAudio.Contains(e.Id)));
}
if (filter.HasChapterImages.HasValue)
{
var hasChapterImages = filter.HasChapterImages.Value;
- var foldersWithChapterImages = DescendantQueryHelper.GetFolderIdsMatching(context, new HasChapterImages());
+ var criteria = new HasChapterImages();
+ var versionsWithChapterImages = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
+ var foldersWithChapterImages = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
if (hasChapterImages)
{
baseQuery = baseQuery
.Where(e =>
- (!e.IsFolder && e.Chapters!.Any(f => f.ImagePath != null))
+ (!e.IsFolder && (e.Chapters!.Any(f => f.ImagePath != null)
+ || versionsWithChapterImages.Contains(e.Id)))
|| (e.IsFolder && foldersWithChapterImages.Contains(e.Id)));
}
else
{
baseQuery = baseQuery
.Where(e =>
- (!e.IsFolder && !e.Chapters!.Any(f => f.ImagePath != null))
+ (!e.IsFolder && !e.Chapters!.Any(f => f.ImagePath != null)
+ && !versionsWithChapterImages.Contains(e.Id))
|| (e.IsFolder && !foldersWithChapterImages.Contains(e.Id)));
}
}
diff --git a/Jellyfin.Server.Implementations/Item/ItemCountService.cs b/Jellyfin.Server.Implementations/Item/ItemCountService.cs
index fd683fb57e..a320ba89d1 100644
--- a/Jellyfin.Server.Implementations/Item/ItemCountService.cs
+++ b/Jellyfin.Server.Implementations/Item/ItemCountService.cs
@@ -318,13 +318,14 @@ public class ItemCountService : IItemCountService
var parentIdsArray = parentIds.ToArray();
var hierarchicalCounts = dbContext.BaseItems
- .Where(b => b.ParentId.HasValue && parentIdsArray.Contains(b.ParentId.Value))
+ .Where(b => b.ParentId.HasValue)
+ .WhereOneOrMany(parentIdsArray, b => b.ParentId!.Value)
.GroupBy(b => b.ParentId!.Value)
.Select(g => new { ParentId = g.Key, Count = g.Count() })
.ToDictionary(x => x.ParentId, x => x.Count);
var linkedCounts = dbContext.LinkedChildren
- .Where(lc => parentIdsArray.Contains(lc.ParentId))
+ .WhereOneOrMany(parentIdsArray, lc => lc.ParentId)
.GroupBy(lc => lc.ParentId)
.Select(g => new { ParentId = g.Key, Count = g.Count() })
.ToDictionary(x => x.ParentId, x => x.Count);
diff --git a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs
index 3585f85c61..efff3457a3 100644
--- a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs
+++ b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs
@@ -538,7 +538,7 @@ public class ItemPersistenceService : IItemPersistenceService
var childIdsToCheck = resolvedChildren.Select(c => c.ChildId).Distinct().ToList();
var existingChildIds = childIdsToCheck.Count > 0
? context.BaseItems
- .Where(e => childIdsToCheck.Contains(e.Id))
+ .WhereOneOrMany(childIdsToCheck, e => e.Id)
.Select(e => e.Id)
.ToHashSet()
: [];
diff --git a/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs b/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs
index 40300b7fa2..de112d7aa4 100644
--- a/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs
+++ b/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs
@@ -7,6 +7,7 @@ using System.Collections.Generic;
using System.Linq;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations;
+using Jellyfin.Extensions;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Persistence;
using Microsoft.EntityFrameworkCore;
@@ -83,26 +84,27 @@ public class LinkedChildrenService : ILinkedChildrenService
{
using var dbContext = _dbProvider.CreateDbContext();
- var lowerNames = artistNames.Select(n => n.ToLowerInvariant()).ToArray();
+ var cleanNames = artistNames.Select(n => (Original: n, Clean: n.GetCleanValue())).ToArray();
+ var cleanValues = cleanNames.Select(x => x.Clean).ToArray();
+
var artists = dbContext.BaseItems
.AsNoTracking()
.Where(e => e.Type == _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]!)
- .Where(e => lowerNames.Contains(e.Name!.ToLower()))
+ .Where(e => cleanValues.Contains(e.CleanName))
.ToArray();
var lookup = artists
- .GroupBy(e => e.Name!, StringComparer.OrdinalIgnoreCase)
+ .GroupBy(e => e.CleanName!)
.ToDictionary(
g => g.Key,
- g => g.Select(f => _queryHelpers.DeserializeBaseItem(f)).Where(dto => dto is not null).Cast().ToArray(),
- StringComparer.OrdinalIgnoreCase);
+ g => g.Select(f => _queryHelpers.DeserializeBaseItem(f)).Where(dto => dto is not null).Cast().ToArray());
- var result = new Dictionary(artistNames.Count);
- foreach (var name in artistNames)
+ var result = new Dictionary(cleanNames.Length);
+ foreach (var (original, clean) in cleanNames)
{
- if (lookup.TryGetValue(name, out var artistArray))
+ if (lookup.TryGetValue(clean, out var artistArray))
{
- result[name] = artistArray;
+ result[original] = artistArray;
}
}
diff --git a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
index a592d0e6e2..aaa363b046 100644
--- a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
+++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
@@ -351,7 +351,11 @@ public class PeopleRepository(IDbContextFactory dbProvider, I
if (!filter.ItemId.IsEmpty())
{
- query = query.Where(e => e.BaseItems!.Any(w => w.ItemId.Equals(filter.ItemId)));
+ var itemId = filter.ItemId;
+ query = query.Where(e => context.PeopleBaseItemMap
+ .Where(m => m.ItemId.Equals(itemId))
+ .Select(m => m.PeopleId)
+ .Contains(e.Id));
}
if (filter.ParentId != null)
@@ -361,7 +365,11 @@ public class PeopleRepository(IDbContextFactory dbProvider, I
if (!filter.AppearsInItemId.IsEmpty())
{
- query = query.Where(e => e.BaseItems!.Any(w => w.ItemId.Equals(filter.AppearsInItemId)));
+ var appearsInItemId = filter.AppearsInItemId;
+ query = query.Where(e => context.PeopleBaseItemMap
+ .Where(m => m.ItemId.Equals(appearsInItemId))
+ .Select(m => m.PeopleId)
+ .Contains(e.Id));
}
var queryPersonTypes = filter.PersonTypes.Where(IsValidPersonType).ToList();
diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs
index 81408d9aa8..fea6084267 100644
--- a/Jellyfin.Server.Implementations/Users/UserManager.cs
+++ b/Jellyfin.Server.Implementations/Users/UserManager.cs
@@ -225,17 +225,8 @@ namespace Jellyfin.Server.Implementations.Users
?? throw new ResourceNotFoundException(nameof(user.Id));
dbContext.Entry(dbUser).CurrentValues.SetValues(user);
- dbUser.Permissions.Clear();
- foreach (var permission in user.Permissions)
- {
- dbUser.Permissions.Add(new Permission(permission.Kind, permission.Value));
- }
-
- dbUser.Preferences.Clear();
- foreach (var preference in user.Preferences)
- {
- dbUser.Preferences.Add(new Preference(preference.Kind, preference.Value));
- }
+ SyncPermissions(dbUser, user.Permissions);
+ SyncPreferences(dbUser, user.Preferences);
dbUser.AccessSchedules.Clear();
foreach (var accessSchedule in user.AccessSchedules)
@@ -269,6 +260,60 @@ namespace Jellyfin.Server.Implementations.Users
}
}
+ private static void SyncPermissions(User dbUser, ICollection source)
+ {
+ var incoming = new Dictionary();
+ foreach (var permission in source)
+ {
+ incoming[permission.Kind] = permission.Value;
+ }
+
+ foreach (var existing in dbUser.Permissions)
+ {
+ if (incoming.Remove(existing.Kind, out var value))
+ {
+ // EF only marks the row modified if the value actually differs, so an update that
+ // touches nothing but the user row - a session activity stamp - writes no children.
+ existing.Value = value;
+ }
+ else
+ {
+ dbUser.Permissions.Remove(existing);
+ }
+ }
+
+ foreach (var (kind, value) in incoming)
+ {
+ dbUser.Permissions.Add(new Permission(kind, value));
+ }
+ }
+
+ private static void SyncPreferences(User dbUser, ICollection source)
+ {
+ var incoming = new Dictionary();
+ foreach (var preference in source)
+ {
+ incoming[preference.Kind] = preference.Value;
+ }
+
+ foreach (var existing in dbUser.Preferences)
+ {
+ if (incoming.Remove(existing.Kind, out var value))
+ {
+ existing.Value = value;
+ }
+ else
+ {
+ dbUser.Preferences.Remove(existing);
+ }
+ }
+
+ foreach (var (kind, value) in incoming)
+ {
+ dbUser.Preferences.Add(new Preference(kind, value));
+ }
+ }
+
internal async Task CreateUserInternalAsync(string name, JellyfinDbContext dbContext)
{
// TODO: Remove after user item data is migrated.
diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
index 9a68889352..10c21ee03c 100644
--- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
+++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
@@ -1318,7 +1318,7 @@ namespace MediaBrowser.Controller.MediaEncoding
arg.Append(canvasArgs);
}
- arg.Append(" -i file:\"").Append(subtitlePath).Append('\"');
+ arg.Append(" -i file:\"").Append(subtitlePath.EscapeProcessArgument()).Append('\"');
}
if (state.AudioStream is not null && state.AudioStream.IsExternal)
@@ -1330,7 +1330,7 @@ namespace MediaBrowser.Controller.MediaEncoding
arg.Append(' ').Append(seekAudioParam);
}
- arg.Append(" -i \"").Append(state.AudioStream.Path).Append('"');
+ arg.Append(" -i \"").Append(state.AudioStream.Path.EscapeProcessArgument()).Append('"');
}
// Disable auto inserted SW scaler for HW decoders in case of changed resolution.
diff --git a/MediaBrowser.Controller/Session/SessionInfo.cs b/MediaBrowser.Controller/Session/SessionInfo.cs
index fb68bfb770..d9bc79dec5 100644
--- a/MediaBrowser.Controller/Session/SessionInfo.cs
+++ b/MediaBrowser.Controller/Session/SessionInfo.cs
@@ -28,6 +28,7 @@ namespace MediaBrowser.Controller.Session
private readonly Lock _progressLock = new();
private Timer _progressTimer;
private PlaybackProgressInfo _lastProgressInfo;
+ private long? _lastPlaybackCheckInPositionTicks;
private bool _disposed;
@@ -124,6 +125,22 @@ namespace MediaBrowser.Controller.Session
/// The last playback check in.
public DateTime LastPlaybackCheckIn { get; set; }
+ ///
+ /// Gets the position reported by the client at the last playback check-in.
+ ///
+ /// The position ticks, or if the client did not report a position.
+ [JsonIgnore]
+ public long? LastPlaybackCheckInPositionTicks
+ {
+ get
+ {
+ lock (_progressLock)
+ {
+ return _lastPlaybackCheckInPositionTicks;
+ }
+ }
+ }
+
///
/// Gets or sets the last paused date.
///
@@ -372,6 +389,7 @@ namespace MediaBrowser.Controller.Session
lock (_progressLock)
{
+ _lastPlaybackCheckInPositionTicks = progressInfo.PositionTicks;
_lastProgressInfo = progressInfo;
if (_progressTimer is null)
diff --git a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs
index 12a5ab877c..fbe8afc66e 100644
--- a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs
+++ b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs
@@ -14,7 +14,6 @@ using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaEncoding;
-using MediaBrowser.MediaEncoding.Encoder;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
@@ -160,7 +159,7 @@ namespace MediaBrowser.MediaEncoding.Attachments
CultureInfo.InvariantCulture,
"-dump_attachment:{0} \"{1}\" ",
attachment.Index,
- EncodingUtils.NormalizePath(attachmentPath));
+ attachmentPath.EscapeProcessArgument());
missingPaths.Add(attachmentPath);
}
@@ -425,7 +424,7 @@ namespace MediaBrowser.MediaEncoding.Attachments
"-dump_attachment:{1} \"{2}\" -i {0} {3}",
inputPath,
attachmentStreamIndex,
- EncodingUtils.NormalizePath(outputPath),
+ outputPath.EscapeProcessArgument(),
hasVideoOrAudioStream ? "-t 0 -f null null" : string.Empty);
int exitCode;
diff --git a/MediaBrowser.MediaEncoding/Encoder/EncodingUtils.cs b/MediaBrowser.MediaEncoding/Encoder/EncodingUtils.cs
index 2daeac7343..a525dcfa62 100644
--- a/MediaBrowser.MediaEncoding/Encoder/EncodingUtils.cs
+++ b/MediaBrowser.MediaEncoding/Encoder/EncodingUtils.cs
@@ -4,6 +4,7 @@ using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
+using Jellyfin.Extensions;
using MediaBrowser.Model.MediaInfo;
namespace MediaBrowser.MediaEncoding.Encoder
@@ -42,7 +43,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
// If there's more than one we'll need to use the concat command
if (inputFiles.Count > 1)
{
- var files = string.Join('|', inputFiles.Select(NormalizePath));
+ var files = string.Join('|', inputFiles.Select(f => f.EscapeProcessArgument()));
return string.Format(CultureInfo.InvariantCulture, "concat:\"{0}\"", files);
}
@@ -64,21 +65,9 @@ namespace MediaBrowser.MediaEncoding.Encoder
return string.Format(CultureInfo.InvariantCulture, "\"{0}\"", path);
}
- // Quotes are valid path characters in linux and they need to be escaped here with a leading \
- path = NormalizePath(path);
+ path = path.EscapeProcessArgument();
return string.Format(CultureInfo.InvariantCulture, "{1}:\"{0}\"", path, inputPrefix);
}
-
- ///
- /// Normalizes the path.
- ///
- /// The path.
- /// System.String.
- public static string NormalizePath(string path)
- {
- // Quotes are valid path characters in linux and they need to be escaped here with a leading \
- return path.Replace("\"", "\\\"", StringComparison.Ordinal);
- }
}
}
diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
index bd516f0a9f..e8c636e7fb 100644
--- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
+++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
@@ -12,6 +12,7 @@ using System.Text;
using System.Threading;
using System.Threading.Tasks;
using AsyncKeyedLock;
+using Jellyfin.Extensions;
using MediaBrowser.Common;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Extensions;
@@ -453,7 +454,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
encodingParam = " -sub_charenc " + encodingParam;
}
- var args = string.Format(CultureInfo.InvariantCulture, "-y {0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, inputPath, outputPath);
+ var args = string.Format(CultureInfo.InvariantCulture, "-y {0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, inputPath.EscapeProcessArgument(), outputPath.EscapeProcessArgument());
await ExtractSubtitlesForFile(
inputPath,
@@ -631,7 +632,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
streamIndex,
outputCodec,
outputFormatOption,
- outputPath);
+ outputPath.EscapeProcessArgument());
}
await ExtractSubtitlesForFile(inputPath, args, outputPaths, cancellationToken).ConfigureAwait(false);
@@ -689,7 +690,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
streamIndex,
outputCodec,
outputFormatOption,
- outputPath);
+ outputPath.EscapeProcessArgument());
}
if (outputPaths.Count > 0)
diff --git a/MediaBrowser.Model/Dlna/TranscodingProfile.cs b/MediaBrowser.Model/Dlna/TranscodingProfile.cs
index f49b24976a..b5adee173b 100644
--- a/MediaBrowser.Model/Dlna/TranscodingProfile.cs
+++ b/MediaBrowser.Model/Dlna/TranscodingProfile.cs
@@ -141,6 +141,7 @@ public class TranscodingProfile
/// Gets or sets a value indicating whether breaking the video stream on non-keyframes is supported.
///
[DefaultValue(false)]
+ [XmlIgnore]
[XmlAttribute("breakOnNonKeyFrames")]
[Obsolete("This is always false")]
public bool? BreakOnNonKeyFrames { get; set; }
diff --git a/MediaBrowser.Model/Entities/ProviderIdsExtensions.cs b/MediaBrowser.Model/Entities/ProviderIdsExtensions.cs
index 385a86d31c..09eba92d9e 100644
--- a/MediaBrowser.Model/Entities/ProviderIdsExtensions.cs
+++ b/MediaBrowser.Model/Entities/ProviderIdsExtensions.cs
@@ -1,14 +1,16 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
using System.Linq;
+using System.Text.RegularExpressions;
namespace MediaBrowser.Model.Entities;
///
/// Class ProviderIdsExtensions.
///
-public static class ProviderIdsExtensions
+public static partial class ProviderIdsExtensions
{
///
/// Case-insensitive dictionary of string representation.
@@ -20,6 +22,27 @@ public static class ProviderIdsExtensions
enumValue => enumValue.ToString(),
StringComparer.OrdinalIgnoreCase);
+ ///
+ /// The known id formats, keyed by provider name.
+ ///
+ private static readonly Dictionary> _providerIdValidators =
+ new(StringComparer.OrdinalIgnoreCase)
+ {
+ [MetadataProvider.Imdb.ToString()] = value => ImdbIdRegex().IsMatch(value),
+ [MetadataProvider.Tmdb.ToString()] = IsPositiveNumber,
+ [MetadataProvider.TmdbCollection.ToString()] = IsPositiveNumber,
+ [MetadataProvider.AudioDbArtist.ToString()] = IsPositiveNumber,
+ [MetadataProvider.AudioDbAlbum.ToString()] = IsPositiveNumber,
+
+ // Every MusicBrainz id is an MBID.
+ [MetadataProvider.MusicBrainzAlbum.ToString()] = IsGuid,
+ [MetadataProvider.MusicBrainzAlbumArtist.ToString()] = IsGuid,
+ [MetadataProvider.MusicBrainzArtist.ToString()] = IsGuid,
+ [MetadataProvider.MusicBrainzReleaseGroup.ToString()] = IsGuid,
+ [MetadataProvider.MusicBrainzRecording.ToString()] = IsGuid,
+ [MetadataProvider.MusicBrainzTrack.ToString()] = IsGuid
+ };
+
///
/// Checks if this instance has an id for the given provider.
///
@@ -101,6 +124,26 @@ public static class ProviderIdsExtensions
return instance.GetProviderId(provider.ToString());
}
+ ///
+ /// Checks whether a value can be an id of the given provider.
+ ///
+ /// The provider name.
+ /// The provider id.
+ /// true if the value has a plausible format for the provider; otherwise, false.
+ ///
+ /// Providers regularly hand out an id belonging to a different service, e.g. an IMDb person id in the
+ /// TMDb field. Such an id is not just useless, it also makes the owning provider fail for the item.
+ ///
+ public static bool IsValidProviderId(string? name, string? value)
+ {
+ if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(value))
+ {
+ return false;
+ }
+
+ return !_providerIdValidators.TryGetValue(name, out var isValid) || isValid(value);
+ }
+
///
/// Sets a provider id.
///
@@ -121,6 +164,14 @@ public static class ProviderIdsExtensions
return false;
}
+ name = name.Trim();
+ value = value.Trim();
+
+ if (!IsValidProviderId(name, value))
+ {
+ return false;
+ }
+
// Ensure it exists
instance.ProviderIds ??= new Dictionary(StringComparer.OrdinalIgnoreCase);
@@ -153,7 +204,6 @@ public static class ProviderIdsExtensions
/// The instance.
/// The name, this should not contain a '=' character.
/// The value.
- /// Due to how deserialization from the database works the name cannot contain '='.
public static void SetProviderId(this IHasProviderIds instance, string name, string value)
{
ArgumentNullException.ThrowIfNull(instance);
@@ -166,17 +216,27 @@ public static class ProviderIdsExtensions
throw new ArgumentException("Provider id name cannot contain '='", nameof(name));
}
- // Ensure it exists
- instance.ProviderIds ??= new Dictionary(StringComparer.OrdinalIgnoreCase);
+ instance.TrySetProviderId(name, value);
+ }
- // Match on internal MetadataProvider enum string values before adding arbitrary providers
- if (_metadataProviderEnumDictionary.TryGetValue(name, out var enumValue))
+ ///
+ /// Replaces all provider ids, dropping the ones that cannot belong to the provider they are filed under.
+ ///
+ /// The instance.
+ /// The provider ids to set.
+ public static void SetProviderIds(this IHasProviderIds instance, IReadOnlyDictionary? providerIds)
+ {
+ ArgumentNullException.ThrowIfNull(instance);
+
+ instance.ProviderIds = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ if (providerIds is null)
{
- instance.ProviderIds[enumValue] = value;
+ return;
}
- else
+
+ foreach (var (name, value) in providerIds)
{
- instance.ProviderIds[name] = value;
+ instance.TrySetProviderId(name, value);
}
}
@@ -213,4 +273,15 @@ public static class ProviderIdsExtensions
instance.ProviderIds?.Remove(provider.ToString());
}
+
+ private static bool IsPositiveNumber(string value)
+ => int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var id) && id > 0;
+
+ private static bool IsGuid(string value)
+ => Guid.TryParse(value, CultureInfo.InvariantCulture, out _);
+
+ // An IMDb id is a type prefix (tt for titles, nm for people, co for companies, ...) followed by
+ // digits. The prefix is optional because a bare number has always been accepted for a title.
+ [GeneratedRegex(@"^(tt|nm|co|ev|ch|ni)?[0-9]+$", RegexOptions.IgnoreCase)]
+ private static partial Regex ImdbIdRegex();
}
diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs
index 8788c860bc..c810fa523e 100644
--- a/MediaBrowser.Providers/Manager/MetadataService.cs
+++ b/MediaBrowser.Providers/Manager/MetadataService.cs
@@ -260,21 +260,40 @@ namespace MediaBrowser.Providers.Manager
switch (lookupInfo)
{
case EpisodeInfo episodeInfo:
- episodeInfo.SeriesProviderIds = result.ProviderIds;
+ episodeInfo.SeriesProviderIds = GetValidProviderIds(result.ProviderIds);
episodeInfo.ProviderIds.Clear();
break;
case SeasonInfo seasonInfo:
- seasonInfo.SeriesProviderIds = result.ProviderIds;
+ seasonInfo.SeriesProviderIds = GetValidProviderIds(result.ProviderIds);
seasonInfo.ProviderIds.Clear();
break;
default:
- lookupInfo.ProviderIds = result.ProviderIds;
+ lookupInfo.SetProviderIds(result.ProviderIds);
lookupInfo.Name = result.Name;
lookupInfo.Year = result.ProductionYear;
break;
}
}
+ private static Dictionary GetValidProviderIds(IReadOnlyDictionary providerIds)
+ {
+ var validProviderIds = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ if (providerIds is null)
+ {
+ return validProviderIds;
+ }
+
+ foreach (var (name, value) in providerIds)
+ {
+ if (ProviderIdsExtensions.IsValidProviderId(name, value))
+ {
+ validProviderIds[name] = value;
+ }
+ }
+
+ return validProviderIds;
+ }
+
protected async Task SaveItemAsync(MetadataResult result, ItemUpdateType reason, bool reattachUserData, CancellationToken cancellationToken)
{
await result.Item.UpdateToRepositoryAsync(reason, cancellationToken).ConfigureAwait(false);
@@ -835,6 +854,7 @@ namespace MediaBrowser.Providers.Manager
}
}
+ var hasRemoteMetadata = false;
var isLocalLocked = temp.Item.IsLocked;
if (!isLocalLocked && (options.ReplaceAllMetadata || options.MetadataRefreshMode > MetadataRefreshMode.ValidationOnly))
{
@@ -849,6 +869,7 @@ namespace MediaBrowser.Providers.Manager
var remoteResult = await ExecuteRemoteProviders(temp, logName, false, id, remoteProviders, cancellationToken).ConfigureAwait(false);
+ hasRemoteMetadata = remoteResult.UpdateType.HasFlag(ItemUpdateType.MetadataDownload);
refreshResult.UpdateType |= remoteResult.UpdateType;
refreshResult.ErrorMessage = remoteResult.ErrorMessage;
refreshResult.Failures += remoteResult.Failures;
@@ -858,7 +879,12 @@ namespace MediaBrowser.Providers.Manager
{
if (refreshResult.UpdateType > ItemUpdateType.None)
{
- if (!options.RemoveOldMetadata)
+ // Erasing the old values is only safe when a remote provider returned something to
+ // replace them with. If every one of them failed there is no replacement, and wiping the
+ // item would turn a provider being temporarily unreachable into permanent data loss.
+ // A single failure is not enough: Identify asks for the erasure precisely because the
+ // previous match was wrong, and an unrelated provider throwing must not undo that.
+ if (!options.RemoveOldMetadata || (refreshResult.Failures > 0 && !hasRemoteMetadata))
{
// Add existing metadata to provider result if it does not exist there
MergeData(metadata, temp, [], false, false);
@@ -956,6 +982,8 @@ namespace MediaBrowser.Providers.Manager
taglineIsFallback |= string.IsNullOrEmpty(temp.Item.Tagline) && !string.IsNullOrEmpty(result.Item.Tagline);
}
+ LogInvalidProviderIds(result, providerName, logName);
+
MergeData(result, temp, [], replaceData, false);
MergeNewData(temp.Item, id);
@@ -981,6 +1009,58 @@ namespace MediaBrowser.Providers.Manager
return refreshResult;
}
+ ///
+ /// Reports the ids a provider returned that cannot belong to the provider they are filed under.
+ ///
+ ///
+ /// The ids are dropped when merging, this names the provider that produced them so the source of a
+ /// recurring bad id can be found.
+ ///
+ private void LogInvalidProviderIds(MetadataResult result, string providerName, string logName)
+ {
+ if (!Logger.IsEnabled(LogLevel.Debug))
+ {
+ return;
+ }
+
+ LogInvalidProviderIds(result.Item?.ProviderIds, providerName, logName, null);
+
+ if (result.People is null)
+ {
+ return;
+ }
+
+ foreach (var person in result.People)
+ {
+ LogInvalidProviderIds(person.ProviderIds, providerName, logName, person.Name);
+ }
+ }
+
+ private void LogInvalidProviderIds(IReadOnlyDictionary providerIds, string providerName, string logName, string personName)
+ {
+ if (providerIds is null)
+ {
+ return;
+ }
+
+ foreach (var (key, value) in providerIds)
+ {
+ if (ProviderIdsExtensions.IsValidProviderId(key, value))
+ {
+ continue;
+ }
+
+ if (personName is null)
+ {
+ Logger.LogDebug("Discarding {Key} id '{Value}' returned by {Provider} for {Item}", key, value, providerName, logName);
+ }
+ else
+ {
+ Logger.LogDebug("Discarding {Key} id '{Value}' returned by {Provider} for {Person} of {Item}", key, value, providerName, personName, logName);
+ }
+ }
+ }
+
private void MergeNewData(TItemType source, TIdType lookupInfo)
{
// Copy new provider id's that may have been obtained
@@ -988,8 +1068,18 @@ namespace MediaBrowser.Providers.Manager
{
var key = providerId.Key;
- // Don't replace existing Id's.
- lookupInfo.ProviderIds.TryAdd(key, providerId.Value);
+ if (!ProviderIdsExtensions.IsValidProviderId(key, providerId.Value))
+ {
+ continue;
+ }
+
+ // Don't replace existing Id's, unless the one already there is unusable - handing that
+ // one to the providers that have yet to run is what makes them fail.
+ if (!lookupInfo.ProviderIds.TryGetValue(key, out var existingId)
+ || !ProviderIdsExtensions.IsValidProviderId(key, existingId))
+ {
+ lookupInfo.ProviderIds[key] = providerId.Value;
+ }
}
}
@@ -1128,6 +1218,9 @@ namespace MediaBrowser.Providers.Manager
if (!lockedFields.Contains(MetadataField.Cast))
{
+ RemoveInvalidProviderIds(sourceResult.People);
+ RemoveInvalidProviderIds(targetResult.People);
+
if (replaceData || targetResult.People is null || targetResult.People.Count == 0)
{
targetResult.People = sourceResult.People;
@@ -1199,15 +1292,31 @@ namespace MediaBrowser.Providers.Manager
{
var key = id.Key;
- // Don't replace existing Id's.
- if (replaceData)
+ // An id that cannot belong to the provider it is filed under only breaks that provider on
+ // the next refresh, so never let one in - not even when replacing all metadata.
+ if (!ProviderIdsExtensions.IsValidProviderId(key, id.Value))
+ {
+ continue;
+ }
+
+ // Don't replace existing Id's, unless the stored one is unusable - that one is the bad
+ // match the refresh is meant to repair.
+ if (replaceData
+ || !target.ProviderIds.TryGetValue(key, out var existingId)
+ || !ProviderIdsExtensions.IsValidProviderId(key, existingId))
{
target.ProviderIds[key] = id.Value;
}
- else
- {
- target.ProviderIds.TryAdd(key, id.Value);
- }
+ }
+
+ // A bad id no provider offered a replacement for still has to go, otherwise the item keeps
+ // failing the same way on every refresh.
+ foreach (var key in target.ProviderIds
+ .Where(id => !ProviderIdsExtensions.IsValidProviderId(id.Key, id.Value))
+ .Select(id => id.Key)
+ .ToArray())
+ {
+ target.ProviderIds.Remove(key);
}
if (replaceData || !target.CriticRating.HasValue)
@@ -1275,6 +1384,32 @@ namespace MediaBrowser.Providers.Manager
}
}
+ private static void RemoveInvalidProviderIds(IReadOnlyList people)
+ {
+ if (people is null)
+ {
+ return;
+ }
+
+ foreach (var person in people)
+ {
+ if (person.ProviderIds is null || person.ProviderIds.Count == 0)
+ {
+ continue;
+ }
+
+ var invalidKeys = person.ProviderIds
+ .Where(id => !ProviderIdsExtensions.IsValidProviderId(id.Key, id.Value))
+ .Select(id => id.Key)
+ .ToArray();
+
+ foreach (var key in invalidKeys)
+ {
+ person.ProviderIds.Remove(key);
+ }
+ }
+ }
+
private static void MergePeople(IReadOnlyList source, IReadOnlyList target)
{
var sourceByName = source.ToLookup(p => p.Name.RemoveDiacritics(), StringComparer.OrdinalIgnoreCase);
diff --git a/MediaBrowser.Providers/Music/AlbumInfoExtensions.cs b/MediaBrowser.Providers/Music/AlbumInfoExtensions.cs
index d3fce37c71..2923dd3290 100644
--- a/MediaBrowser.Providers/Music/AlbumInfoExtensions.cs
+++ b/MediaBrowser.Providers/Music/AlbumInfoExtensions.cs
@@ -23,11 +23,11 @@ namespace MediaBrowser.Providers.Music
public static string? GetReleaseGroupId(this AlbumInfo info)
{
- var id = info.GetProviderId(MetadataProvider.MusicBrainzReleaseGroup);
+ var id = MusicBrainzId(MetadataProvider.MusicBrainzReleaseGroup, info.GetProviderId(MetadataProvider.MusicBrainzReleaseGroup));
if (string.IsNullOrEmpty(id))
{
- return info.SongInfos.Select(i => i.GetProviderId(MetadataProvider.MusicBrainzReleaseGroup))
+ return info.SongInfos.Select(i => MusicBrainzId(MetadataProvider.MusicBrainzReleaseGroup, i.GetProviderId(MetadataProvider.MusicBrainzReleaseGroup)))
.FirstOrDefault(i => !string.IsNullOrEmpty(i));
}
@@ -36,11 +36,11 @@ namespace MediaBrowser.Providers.Music
public static string? GetReleaseId(this AlbumInfo info)
{
- var id = info.GetProviderId(MetadataProvider.MusicBrainzAlbum);
+ var id = MusicBrainzId(MetadataProvider.MusicBrainzAlbum, info.GetProviderId(MetadataProvider.MusicBrainzAlbum));
if (string.IsNullOrEmpty(id))
{
- return info.SongInfos.Select(i => i.GetProviderId(MetadataProvider.MusicBrainzAlbum))
+ return info.SongInfos.Select(i => MusicBrainzId(MetadataProvider.MusicBrainzAlbum, i.GetProviderId(MetadataProvider.MusicBrainzAlbum)))
.FirstOrDefault(i => !string.IsNullOrEmpty(i));
}
@@ -50,15 +50,17 @@ namespace MediaBrowser.Providers.Music
public static string? GetMusicBrainzArtistId(this AlbumInfo info)
{
info.ProviderIds.TryGetValue(MetadataProvider.MusicBrainzAlbumArtist.ToString(), out string? id);
+ id = MusicBrainzId(MetadataProvider.MusicBrainzAlbumArtist, id);
if (string.IsNullOrEmpty(id))
{
info.ArtistProviderIds.TryGetValue(MetadataProvider.MusicBrainzArtist.ToString(), out id);
+ id = MusicBrainzId(MetadataProvider.MusicBrainzArtist, id);
}
if (string.IsNullOrEmpty(id))
{
- return info.SongInfos.Select(i => i.GetProviderId(MetadataProvider.MusicBrainzAlbumArtist))
+ return info.SongInfos.Select(i => MusicBrainzId(MetadataProvider.MusicBrainzAlbumArtist, i.GetProviderId(MetadataProvider.MusicBrainzAlbumArtist)))
.FirstOrDefault(i => !string.IsNullOrEmpty(i));
}
@@ -68,14 +70,21 @@ namespace MediaBrowser.Providers.Music
public static string? GetMusicBrainzArtistId(this ArtistInfo info)
{
info.ProviderIds.TryGetValue(MetadataProvider.MusicBrainzArtist.ToString(), out var id);
+ id = MusicBrainzId(MetadataProvider.MusicBrainzArtist, id);
if (string.IsNullOrEmpty(id))
{
- return info.SongInfos.Select(i => i.GetProviderId(MetadataProvider.MusicBrainzAlbumArtist))
+ return info.SongInfos.Select(i => MusicBrainzId(MetadataProvider.MusicBrainzAlbumArtist, i.GetProviderId(MetadataProvider.MusicBrainzAlbumArtist)))
.FirstOrDefault(i => !string.IsNullOrEmpty(i));
}
return id;
}
+
+ ///
+ /// Returns the id if it can be an id of the given provider, otherwise null.
+ ///
+ private static string? MusicBrainzId(MetadataProvider provider, string? id)
+ => ProviderIdsExtensions.IsValidProviderId(provider.ToString(), id) ? id : null;
}
}
diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs
index 397c916a4f..7dee5fd31d 100644
--- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs
+++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs
@@ -14,6 +14,7 @@ using MediaBrowser.Providers.Music;
using MetaBrainz.MusicBrainz;
using MetaBrainz.MusicBrainz.Interfaces.Entities;
using MetaBrainz.MusicBrainz.Interfaces.Searches;
+using Microsoft.Extensions.Logging;
namespace MediaBrowser.Providers.Plugins.MusicBrainz;
@@ -22,6 +23,17 @@ namespace MediaBrowser.Providers.Plugins.MusicBrainz;
///
public class MusicBrainzAlbumProvider : IRemoteMetadataProvider, IHasOrder
{
+ private readonly ILogger _logger;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The logger.
+ public MusicBrainzAlbumProvider(ILogger logger)
+ {
+ _logger = logger;
+ }
+
///
public string Name => "MusicBrainz";
@@ -32,21 +44,26 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider> GetSearchResults(AlbumInfo searchInfo, CancellationToken cancellationToken)
{
var query = MusicBrainz.Plugin.Instance!.MusicBrainzQuery;
- var releaseId = searchInfo.GetReleaseId();
- var releaseGroupId = searchInfo.GetReleaseGroupId();
+ var releaseId = MusicBrainzQueryExtensions.ParseMusicBrainzId(searchInfo.GetReleaseId(), "release", _logger);
+ var releaseGroupId = MusicBrainzQueryExtensions.ParseMusicBrainzId(searchInfo.GetReleaseGroupId(), "release group", _logger);
- if (!string.IsNullOrEmpty(releaseId))
+ if (releaseId is not null)
{
- var releaseResult = await query.LookupReleaseAsync(new Guid(releaseId), Include.Artists | Include.ReleaseGroups, cancellationToken).ConfigureAwait(false);
- return GetReleaseResult(releaseResult).SingleItemAsEnumerable();
+ var releaseResult = await query.LookupReleaseOrNullAsync(releaseId.Value, Include.Artists | Include.ReleaseGroups, _logger, cancellationToken).ConfigureAwait(false);
+ if (releaseResult is not null)
+ {
+ return GetReleaseResult(releaseResult).SingleItemAsEnumerable();
+ }
}
- if (!string.IsNullOrEmpty(releaseGroupId))
+ if (releaseGroupId is not null)
{
- var releaseGroupResult = await query.LookupReleaseGroupAsync(new Guid(releaseGroupId), Include.Releases, null, cancellationToken).ConfigureAwait(false);
-
- // No need to pass the cancellation token to GetReleaseGroupResultAsync as we're already passing it to ToBlockingEnumerable
- return GetReleaseGroupResultAsync(releaseGroupResult.Releases, CancellationToken.None).ToBlockingEnumerable(cancellationToken);
+ var releaseGroupResult = await query.LookupReleaseGroupOrNullAsync(releaseGroupId.Value, Include.Releases, _logger, cancellationToken).ConfigureAwait(false);
+ if (releaseGroupResult is not null)
+ {
+ // No need to pass the cancellation token to GetReleaseGroupResultAsync as we're already passing it to ToBlockingEnumerable
+ return GetReleaseGroupResultAsync(releaseGroupResult.Releases, CancellationToken.None).ToBlockingEnumerable(cancellationToken);
+ }
}
var artistMusicBrainzId = searchInfo.GetMusicBrainzArtistId();
@@ -102,8 +119,11 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider> GetMetadata(AlbumInfo info, CancellationToken cancellationToken)
{
var query = MusicBrainz.Plugin.Instance!.MusicBrainzQuery;
- var releaseId = info.GetReleaseId();
- var releaseGroupId = info.GetReleaseGroupId();
+ var releaseId = MusicBrainzQueryExtensions.ParseMusicBrainzId(info.GetReleaseId(), "release", _logger);
+ var releaseGroupId = MusicBrainzQueryExtensions.ParseMusicBrainzId(info.GetReleaseGroupId(), "release group", _logger);
var result = new MetadataResult
{
@@ -165,15 +185,15 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider 0 ? releaseGroupLookup.Releases[0].Id.ToString() : null;
+ var releaseGroupLookup = await query.LookupReleaseGroupOrNullAsync(releaseGroupId.Value, Include.None, _logger, cancellationToken).ConfigureAwait(false);
+ releaseId = releaseGroupLookup?.Releases?.Count > 0 ? releaseGroupLookup.Releases[0].Id : null;
}
// If there is no release ID, lookup a release with the info we have
- if (string.IsNullOrWhiteSpace(releaseId))
+ if (releaseId is null)
{
var artistMusicBrainzId = info.GetMusicBrainzArtistId();
IRelease? releaseResult = null;
@@ -193,55 +213,61 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider
public class MusicBrainzArtistProvider : IRemoteMetadataProvider, IHasOrder
{
+ private readonly ILogger _logger;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The logger.
+ public MusicBrainzArtistProvider(ILogger logger)
+ {
+ _logger = logger;
+ }
+
///
public string Name => "MusicBrainz";
@@ -32,12 +44,15 @@ public class MusicBrainzArtistProvider : IRemoteMetadataProvider> GetSearchResults(ArtistInfo searchInfo, CancellationToken cancellationToken)
{
var query = MusicBrainz.Plugin.Instance!.MusicBrainzQuery;
- var artistId = searchInfo.GetMusicBrainzArtistId();
+ var artistId = MusicBrainzQueryExtensions.ParseMusicBrainzId(searchInfo.GetMusicBrainzArtistId(), "artist", _logger);
- if (!string.IsNullOrWhiteSpace(artistId))
+ if (artistId is not null)
{
- var artistResult = await query.LookupArtistAsync(new Guid(artistId), Include.Aliases, null, null, cancellationToken).ConfigureAwait(false);
- return GetResultFromResponse(artistResult).SingleItemAsEnumerable();
+ var artistResult = await query.LookupArtistOrNullAsync(artistId.Value, Include.Aliases, _logger, cancellationToken).ConfigureAwait(false);
+ if (artistResult is not null)
+ {
+ return GetResultFromResponse(artistResult).SingleItemAsEnumerable();
+ }
}
if (string.IsNullOrWhiteSpace(searchInfo.Name))
@@ -99,22 +114,22 @@ public class MusicBrainzArtistProvider : IRemoteMetadataProvider { Item = new MusicArtist() };
- var musicBrainzId = info.GetMusicBrainzArtistId();
+ var musicBrainzId = MusicBrainzQueryExtensions.ParseMusicBrainzId(info.GetMusicBrainzArtistId(), "artist", _logger);
// If we don't have an id yet, resolve one by name so we can look the artist up.
- if (string.IsNullOrWhiteSpace(musicBrainzId))
+ if (musicBrainzId is null)
{
var searchResults = await GetSearchResults(info, cancellationToken).ConfigureAwait(false);
- musicBrainzId = searchResults.FirstOrDefault()?.GetProviderId(MetadataProvider.MusicBrainzArtist);
+ musicBrainzId = MusicBrainzQueryExtensions.ParseMusicBrainzId(searchResults.FirstOrDefault()?.GetProviderId(MetadataProvider.MusicBrainzArtist), "artist", _logger);
}
- if (string.IsNullOrWhiteSpace(musicBrainzId))
+ if (musicBrainzId is null)
{
return result;
}
var query = Plugin.Instance!.MusicBrainzQuery;
- var artist = await query.LookupArtistAsync(new Guid(musicBrainzId), Include.Genres | Include.Tags, null, null, cancellationToken).ConfigureAwait(false);
+ var artist = await query.LookupArtistOrNullAsync(musicBrainzId.Value, Include.Genres | Include.Tags, _logger, cancellationToken).ConfigureAwait(false);
if (artist is null)
{
diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzQueryExtensions.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzQueryExtensions.cs
new file mode 100644
index 0000000000..f3df41e942
--- /dev/null
+++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzQueryExtensions.cs
@@ -0,0 +1,112 @@
+using System;
+using System.Net;
+using System.Threading;
+using System.Threading.Tasks;
+using MetaBrainz.Common;
+using MetaBrainz.MusicBrainz;
+using MetaBrainz.MusicBrainz.Interfaces.Entities;
+using Microsoft.Extensions.Logging;
+
+namespace MediaBrowser.Providers.Plugins.MusicBrainz;
+
+///
+/// Helpers for talking to MusicBrainz with identifiers that are not guaranteed to be valid.
+///
+internal static class MusicBrainzQueryExtensions
+{
+ ///
+ /// Parses a MusicBrainz identifier, which may come from user-supplied tags or NFO files and is therefore not
+ /// guaranteed to be a valid GUID.
+ ///
+ /// The identifier to parse.
+ /// The type of entity the identifier refers to, used for logging.
+ /// The logger.
+ /// The parsed identifier, or if it is missing or malformed.
+ public static Guid? ParseMusicBrainzId(string? id, string entityType, ILogger logger)
+ {
+ if (string.IsNullOrWhiteSpace(id))
+ {
+ return null;
+ }
+
+ if (!Guid.TryParse(id, out var parsedId))
+ {
+ logger.LogDebug("Ignoring malformed MusicBrainz {EntityType} id {Id}", entityType, id);
+ return null;
+ }
+
+ return parsedId;
+ }
+
+ ///
+ /// Looks up a release, treating an unknown identifier as missing data rather than an error.
+ ///
+ /// The MusicBrainz query client.
+ /// The release identifier.
+ /// The additional data to include in the lookup.
+ /// The logger.
+ /// The cancellation token.
+ /// The release, or if MusicBrainz does not have it.
+ public static Task LookupReleaseOrNullAsync(this Query query, Guid releaseId, Include include, ILogger logger, CancellationToken cancellationToken)
+ => NotFoundAsNullAsync(
+ () => query.LookupReleaseAsync(releaseId, include, cancellationToken),
+ "release",
+ releaseId,
+ logger);
+
+ ///
+ /// Looks up a release group, treating an unknown identifier as missing data rather than an error.
+ ///
+ /// The MusicBrainz query client.
+ /// The release group identifier.
+ /// The additional data to include in the lookup.
+ /// The logger.
+ /// The cancellation token.
+ /// The release group, or if MusicBrainz does not have it.
+ public static Task LookupReleaseGroupOrNullAsync(this Query query, Guid releaseGroupId, Include include, ILogger logger, CancellationToken cancellationToken)
+ => NotFoundAsNullAsync(
+ () => query.LookupReleaseGroupAsync(releaseGroupId, include, null, cancellationToken),
+ "release group",
+ releaseGroupId,
+ logger);
+
+ ///
+ /// Looks up an artist, treating an unknown identifier as missing data rather than an error.
+ ///
+ /// The MusicBrainz query client.
+ /// The artist identifier.
+ /// The additional data to include in the lookup.
+ /// The logger.
+ /// The cancellation token.
+ /// The artist, or if MusicBrainz does not have it.
+ public static Task LookupArtistOrNullAsync(this Query query, Guid artistId, Include include, ILogger logger, CancellationToken cancellationToken)
+ => NotFoundAsNullAsync(
+ () => query.LookupArtistAsync(artistId, include, null, null, cancellationToken),
+ "artist",
+ artistId,
+ logger);
+
+ ///
+ /// Runs a lookup, mapping a "not found" response to . Identifiers stored on a library item
+ /// can refer to entities that no longer exist in MusicBrainz, which is not an error worth failing a refresh over.
+ ///
+ /// The type of entity being looked up.
+ /// The lookup to run.
+ /// The type of entity being looked up, used for logging.
+ /// The identifier being looked up, used for logging.
+ /// The logger.
+ /// The entity, or if MusicBrainz does not have it.
+ private static async Task NotFoundAsNullAsync(Func> lookup, string entityType, Guid id, ILogger logger)
+ where T : class
+ {
+ try
+ {
+ return await lookup().ConfigureAwait(false);
+ }
+ catch (HttpError ex) when (ex.Status == HttpStatusCode.NotFound)
+ {
+ logger.LogDebug("MusicBrainz has no {EntityType} with id {Id}", entityType, id);
+ return null;
+ }
+ }
+}
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs
index 78be5804e3..23f8d89c67 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs
@@ -1,6 +1,4 @@
-using System;
using System.Collections.Generic;
-using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Threading;
@@ -56,7 +54,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets
///
public async Task> GetImages(BaseItem item, CancellationToken cancellationToken)
{
- var tmdbId = Convert.ToInt32(item.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture);
+ item.TryGetTmdbId(out var tmdbId);
if (tmdbId <= 0)
{
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs
index 1bc2d3654f..0a75b71264 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs
@@ -1,4 +1,3 @@
-using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
@@ -42,7 +41,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets
///
public async Task> GetSearchResults(BoxSetInfo searchInfo, CancellationToken cancellationToken)
{
- var tmdbId = Convert.ToInt32(searchInfo.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture);
+ searchInfo.TryGetTmdbId(out var tmdbId);
var language = searchInfo.MetadataLanguage;
if (tmdbId > 0)
@@ -97,7 +96,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets
///
public async Task> GetMetadata(BoxSetInfo info, CancellationToken cancellationToken)
{
- var tmdbId = Convert.ToInt32(info.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture);
+ info.TryGetTmdbId(out var tmdbId);
var language = info.MetadataLanguage;
// We don't already have an Id, need to fetch it
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs
index b188f5deb4..e686577311 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs
@@ -1,6 +1,4 @@
-using System;
using System.Collections.Generic;
-using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Threading;
@@ -61,7 +59,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
var language = item.GetPreferredMetadataLanguage();
var countryCode = item.GetPreferredMetadataCountryCode();
- var movieTmdbId = Convert.ToInt32(item.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture);
+ item.TryGetTmdbId(out var movieTmdbId);
if (movieTmdbId <= 0)
{
var movieImdbId = item.GetProviderId(MetadataProvider.Imdb);
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs
index 8811a1787a..ef952082da 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs
@@ -54,11 +54,11 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
///
public async Task> GetSearchResults(MovieInfo searchInfo, CancellationToken cancellationToken)
{
- if (searchInfo.TryGetProviderId(MetadataProvider.Tmdb, out var id))
+ if (searchInfo.TryGetTmdbId(out var tmdbId))
{
var movie = await _tmdbClientManager
.GetMovieAsync(
- int.Parse(id, CultureInfo.InvariantCulture),
+ tmdbId,
searchInfo.MetadataLanguage,
TmdbUtils.GetImageLanguagesParam(searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode),
searchInfo.MetadataCountryCode,
@@ -90,7 +90,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
}
IReadOnlyList? movieResults = null;
- if (searchInfo.TryGetProviderId(MetadataProvider.Imdb, out id))
+ if (searchInfo.TryGetProviderId(MetadataProvider.Imdb, out var id))
{
var result = await _tmdbClientManager.FindByExternalIdAsync(
id,
@@ -151,11 +151,13 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
///
public async Task> GetMetadata(MovieInfo info, CancellationToken cancellationToken)
{
- var tmdbId = info.GetProviderId(MetadataProvider.Tmdb);
+ // A stored id that is not a TMDb id is treated as no id, so the search below can repair it
+ // rather than the lookup failing for as long as the bad id stays on the item.
+ info.TryGetTmdbId(out var tmdbId);
var imdbId = info.GetProviderId(MetadataProvider.Imdb);
var config = Plugin.Instance.Configuration;
- if (string.IsNullOrEmpty(tmdbId) && string.IsNullOrEmpty(imdbId))
+ if (tmdbId <= 0 && string.IsNullOrEmpty(imdbId))
{
// ParseName is required here.
// Caller provides the filename with extension stripped and NOT the parsed filename
@@ -166,26 +168,26 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
if (searchResults?.Count > 0)
{
- tmdbId = searchResults[0].Id.ToString(CultureInfo.InvariantCulture);
+ tmdbId = searchResults[0].Id;
}
}
- if (string.IsNullOrEmpty(tmdbId) && !string.IsNullOrEmpty(imdbId))
+ if (tmdbId <= 0 && !string.IsNullOrEmpty(imdbId))
{
var movieResultFromImdbId = await _tmdbClientManager.FindByExternalIdAsync(imdbId, FindExternalSource.Imdb, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false);
if (movieResultFromImdbId?.MovieResults?.Count > 0)
{
- tmdbId = movieResultFromImdbId.MovieResults[0].Id.ToString(CultureInfo.InvariantCulture);
+ tmdbId = movieResultFromImdbId.MovieResults[0].Id;
}
}
- if (string.IsNullOrEmpty(tmdbId))
+ if (tmdbId <= 0)
{
return new MetadataResult();
}
var movieResult = await _tmdbClientManager
- .GetMovieAsync(Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture), info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage, info.MetadataCountryCode), info.MetadataCountryCode, cancellationToken)
+ .GetMovieAsync(tmdbId, info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage, info.MetadataCountryCode), info.MetadataCountryCode, cancellationToken)
.ConfigureAwait(false);
if (movieResult is null)
@@ -208,7 +210,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
Item = movie
};
- movie.SetProviderId(MetadataProvider.Tmdb, tmdbId);
+ movie.SetProviderId(MetadataProvider.Tmdb, tmdbId.ToString(CultureInfo.InvariantCulture));
movie.TrySetProviderId(MetadataProvider.Imdb, movieResult.ImdbId);
if (movieResult.BelongsToCollection is not null)
{
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs
index 33888ddf4f..d38614811c 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs
@@ -1,5 +1,4 @@
using System.Collections.Generic;
-using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Threading;
@@ -54,14 +53,14 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People
{
var person = (Person)item;
- if (!person.TryGetProviderId(MetadataProvider.Tmdb, out var personTmdbId))
+ if (!person.TryGetTmdbId(out var personTmdbId))
{
return Enumerable.Empty();
}
var language = item.GetPreferredMetadataLanguage();
var countryCode = item.GetPreferredMetadataCountryCode();
- var personResult = await _tmdbClientManager.GetPersonAsync(int.Parse(personTmdbId, CultureInfo.InvariantCulture), language, countryCode, cancellationToken).ConfigureAwait(false);
+ var personResult = await _tmdbClientManager.GetPersonAsync(personTmdbId, language, countryCode, cancellationToken).ConfigureAwait(false);
if (personResult?.Images?.Profiles is null)
{
return Enumerable.Empty();
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs
index e456b4e881..695f347a9a 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs
@@ -1,4 +1,3 @@
-using System;
using System.Collections.Generic;
using System.Globalization;
using System.Net.Http;
@@ -37,9 +36,9 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People
///
public async Task> GetSearchResults(PersonLookupInfo searchInfo, CancellationToken cancellationToken)
{
- if (searchInfo.TryGetProviderId(MetadataProvider.Tmdb, out var personTmdbId))
+ if (searchInfo.TryGetTmdbId(out var personTmdbId))
{
- var personResult = await _tmdbClientManager.GetPersonAsync(int.Parse(personTmdbId, CultureInfo.InvariantCulture), searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode, cancellationToken).ConfigureAwait(false);
+ var personResult = await _tmdbClientManager.GetPersonAsync(personTmdbId, searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode, cancellationToken).ConfigureAwait(false);
if (personResult is not null)
{
@@ -89,7 +88,9 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People
///
public async Task> GetMetadata(PersonLookupInfo info, CancellationToken cancellationToken)
{
- var personTmdbId = Convert.ToInt32(info.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture);
+ // A person can carry another provider's id under the TMDb key, which is no more usable here
+ // than no id at all, so both take the search path and get the stored id repaired.
+ info.TryGetTmdbId(out var personTmdbId);
// We don't already have an Id, need to fetch it
if (personTmdbId <= 0)
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs
index 7ae54cdcd3..1f8c87397d 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs
@@ -1,6 +1,4 @@
-using System;
using System.Collections.Generic;
-using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Threading;
@@ -56,9 +54,9 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
var episode = (Controller.Entities.TV.Episode)item;
var series = episode.Series;
- var seriesTmdbId = Convert.ToInt32(series?.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture);
+ var seriesTmdbId = 0;
- if (series is null || seriesTmdbId <= 0)
+ if (series?.TryGetTmdbId(out seriesTmdbId) != true)
{
return Enumerable.Empty();
}
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs
index 21b822c97c..8172ab14df 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs
@@ -91,8 +91,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
info.SeriesProviderIds.TryGetValue(MetadataProvider.Tmdb.ToString(), out string? tmdbId);
- var seriesTmdbId = Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture);
- if (seriesTmdbId <= 0)
+ if (!TmdbUtils.TryParseTmdbId(tmdbId, out var seriesTmdbId))
{
return metadataResult;
}
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs
index 5b2f0d26e4..bc44d0266d 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs
@@ -1,6 +1,4 @@
-using System;
using System.Collections.Generic;
-using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Threading;
@@ -57,9 +55,9 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
var season = (Season)item;
var series = season?.Series;
- var seriesTmdbId = Convert.ToInt32(series?.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture);
+ var seriesTmdbId = 0;
- if (seriesTmdbId <= 0 || season?.IndexNumber is null)
+ if (season?.IndexNumber is null || series?.TryGetTmdbId(out seriesTmdbId) != true)
{
return Enumerable.Empty();
}
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs
index 9a997c80b1..9b8803f171 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs
@@ -1,4 +1,3 @@
-using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
@@ -51,13 +50,13 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
var seasonNumber = info.IndexNumber;
- if (string.IsNullOrWhiteSpace(seriesTmdbId) || !seasonNumber.HasValue)
+ if (!seasonNumber.HasValue || !TmdbUtils.TryParseTmdbId(seriesTmdbId, out var seriesId))
{
return result;
}
var seasonResult = await _tmdbClientManager
- .GetSeasonAsync(Convert.ToInt32(seriesTmdbId, CultureInfo.InvariantCulture), seasonNumber.Value, info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage, info.MetadataCountryCode), info.MetadataCountryCode, cancellationToken)
+ .GetSeasonAsync(seriesId, seasonNumber.Value, info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage, info.MetadataCountryCode), info.MetadataCountryCode, cancellationToken)
.ConfigureAwait(false);
if (seasonResult is null)
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs
index f2e7d0c6e4..dc4f860604 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs
@@ -1,6 +1,4 @@
-using System;
using System.Collections.Generic;
-using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Threading;
@@ -57,9 +55,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
///
public async Task> GetImages(BaseItem item, CancellationToken cancellationToken)
{
- var tmdbId = item.GetProviderId(MetadataProvider.Tmdb);
-
- if (string.IsNullOrEmpty(tmdbId))
+ if (!item.TryGetTmdbId(out var tmdbId))
{
return Enumerable.Empty();
}
@@ -68,7 +64,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
// TODO use image languages if All Languages isn't toggled, but there's currently no way to get that value in here
var series = await _tmdbClientManager
- .GetSeriesAsync(Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture), null, null, null, cancellationToken)
+ .GetSeriesAsync(tmdbId, null, null, null, cancellationToken)
.ConfigureAwait(false);
if (series?.Images is null)
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs
index 9bb15ca479..9e201f2d7c 100755
--- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs
@@ -54,10 +54,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
///
public async Task> GetSearchResults(SeriesInfo searchInfo, CancellationToken cancellationToken)
{
- if (searchInfo.TryGetProviderId(MetadataProvider.Tmdb, out var tmdbId))
+ if (searchInfo.TryGetTmdbId(out var tmdbId))
{
var series = await _tmdbClientManager
- .GetSeriesAsync(Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture), searchInfo.MetadataLanguage, searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode, cancellationToken)
+ .GetSeriesAsync(tmdbId, searchInfo.MetadataLanguage, searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode, cancellationToken)
.ConfigureAwait(false);
if (series is not null)
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs
index 7e6b9beee9..c83174f97f 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs
@@ -2,6 +2,7 @@ using System;
using System.Collections.Frozen;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
using System.Text.RegularExpressions;
using Jellyfin.Data.Enums;
using MediaBrowser.Model.Entities;
@@ -62,6 +63,33 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
[GeneratedRegex(@"[\W_-[·]]+")]
private static partial Regex NonWordRegex();
+ ///
+ /// Gets the TMDb id of an item, if it has one TMDb can be queried with.
+ ///
+ /// The item.
+ /// The TMDb id.
+ /// true if the item has a usable TMDb id; otherwise, false.
+ public static bool TryGetTmdbId(this IHasProviderIds instance, out int tmdbId)
+ {
+ instance.TryGetProviderId(MetadataProvider.Tmdb, out var value);
+
+ return TryParseTmdbId(value, out tmdbId);
+ }
+
+ ///
+ /// Parses a TMDb id.
+ ///
+ /// The stored id.
+ /// The TMDb id.
+ /// true if the value is a usable TMDb id; otherwise, false.
+ public static bool TryParseTmdbId(string? value, out int tmdbId)
+ {
+ // Another provider can have filed one of its own ids under the TMDb key, e.g. an IMDb person
+ // id. Reporting that as "no id" lets the caller fall back to a search and repair the id,
+ // instead of throwing on every refresh of the item.
+ return int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out tmdbId) && tmdbId > 0;
+ }
+
///
/// Cleans the name according to TMDb requirements.
///
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs
index bfd0fac34a..6b08f8dd7e 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs
@@ -8,7 +8,7 @@ using Jellyfin.Database.Implementations.MatchCriteria;
namespace Jellyfin.Database.Implementations;
///
-/// Provides methods for querying item hierarchies using iterative traversal.
+/// Provides methods for querying item hierarchies.
/// Uses AncestorIds and LinkedChildren tables for parent-child traversal.
///
public static class DescendantQueryHelper
@@ -32,11 +32,18 @@ public static class DescendantQueryHelper
{
ArgumentNullException.ThrowIfNull(context);
- var descendants = TraverseHierarchyDown(context, [parentId]);
+ var (closureRoots, linkRoots) = ResolveLinkedRoots(context, parentId);
- descendants.Remove(parentId);
+ var hierarchyDescendants = ClosureDescendants(context, closureRoots);
- return descendants.AsQueryable();
+ var linkedDescendants = context.LinkedChildren
+ .WhereOneOrMany(linkRoots, e => e.ParentId)
+ .Select(e => e.ChildId);
+
+ return hierarchyDescendants
+ .Concat(linkedDescendants)
+ .Where(e => !e.Equals(parentId))
+ .Distinct();
}
///
@@ -51,11 +58,9 @@ public static class DescendantQueryHelper
{
ArgumentNullException.ThrowIfNull(context);
- var descendants = TraverseHierarchyDownOwned(context, [parentId]);
-
- descendants.Remove(parentId);
-
- return descendants.AsQueryable();
+ return ClosureDescendants(context, [parentId])
+ .Where(e => !e.Equals(parentId))
+ .Distinct();
}
///
@@ -76,11 +81,11 @@ public static class DescendantQueryHelper
return [];
}
- var seedSet = new HashSet(parentIds);
- var descendants = TraverseHierarchyDownOwned(context, seedSet);
+ var descendants = ClosureDescendants(context, parentIds)
+ .Distinct()
+ .ToHashSet();
- // Remove the seed IDs — callers want only descendants
- descendants.ExceptWith(seedSet);
+ descendants.ExceptWith(parentIds);
return descendants;
}
@@ -96,28 +101,106 @@ public static class DescendantQueryHelper
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(criteria);
- var matchingItemIds = criteria switch
+
+ // Both sides of a version group can hold a folder a caller would see as matching: the
+ // alternate carries its own AncestorIds rows and may sit in a different library than the
+ // primary it is reported against, and the primary is the item that becomes visible.
+ var reportedItemIds = MatchingMediaOwnerIds(context, criteria)
+ .Concat(GetPrimaryVersionIdsMatching(context, criteria))
+ .Distinct();
+
+ // One hop up the closure covers every ancestor level.
+ var hierarchyAncestors = context.AncestorIds
+ .Where(e => reportedItemIds.Contains(e.ItemId))
+ .Select(e => e.ParentItemId);
+
+ var linkParents = ResolveLinkParents(context, reportedItemIds, hierarchyAncestors);
+
+ // Read back as a sub-select so the result stays composable. Off the primary key, which is one
+ // row per id: LinkedChildren would yield one row per link and lean on the outer Distinct.
+ var linkedParents = context.BaseItems
+ .WhereOneOrMany(linkParents, e => e.Id)
+ .Select(e => e.Id);
+
+ var linkedParentAncestors = context.AncestorIds
+ .WhereOneOrMany(linkParents, e => e.ItemId)
+ .Select(e => e.ParentItemId);
+
+ // The chain an item carries stops at its collection folders, so this hop crosses that seam to
+ // the UserRootFolder above them. One statement for both sides beats a sub-select per side.
+ var seamAncestors = context.AncestorIds
+ .Where(e => hierarchyAncestors.Contains(e.ItemId) || linkedParentAncestors.Contains(e.ItemId))
+ .Select(e => e.ParentItemId);
+
+ return hierarchyAncestors
+ .Concat(linkedParents)
+ .Concat(linkedParentAncestors)
+ .Concat(seamAncestors)
+ .Distinct();
+ }
+
+ ///
+ /// Gets a queryable of the IDs of the primary versions whose alternate version's media matches the
+ /// criteria.
+ ///
+ /// Database context.
+ /// The matching criteria to apply.
+ /// Queryable of primary version item IDs.
+ ///
+ /// For callers that already test an item's own media with their own indexed predicate: this covers
+ /// exactly what such a predicate misses, and the filtered PrimaryVersionId index keeps it to the few
+ /// items that have versions at all.
+ ///
+ public static IQueryable GetPrimaryVersionIdsMatching(JellyfinDbContext context, FolderMatchCriteria criteria)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+ ArgumentNullException.ThrowIfNull(criteria);
+
+ // Anchored on the alternates rather than on the matches: "has a primary version" is served by
+ // the partial PrimaryVersionId index, which holds only the few items that are second files, so
+ // this costs a seek each into the stream index instead of a second pass over every stream row.
+ var alternates = context.BaseItems.Where(v => v.PrimaryVersionId.HasValue);
+
+ if (criteria is HasChapterImages)
{
- HasSubtitles => context.MediaStreamInfos
- .Where(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle)
- .Select(ms => ms.ItemId)
- .Distinct()
- .ToHashSet(),
- HasChapterImages => context.Chapters
+ return alternates
+ .Where(v => context.Chapters.Any(c => c.ItemId.Equals(v.Id) && c.ImagePath != null))
+ .Select(v => v.PrimaryVersionId!.Value);
+ }
+
+ var matchingStreams = MatchingMediaStreams(context, criteria);
+
+ return alternates
+ .Where(v => matchingStreams.Any(ms => ms.ItemId.Equals(v.Id)))
+ .Select(v => v.PrimaryVersionId!.Value);
+ }
+
+ // The ids of the items whose own media matches. Kept to the stream and chapter tables so their
+ // covering indexes answer this outright: projecting the BaseItems navigation instead would add a
+ // primary-key lookup per stream row rather than one per matching item, and the leading key of both
+ // indexes leaves the ids already grouped, so the Distinct costs no sort.
+ private static IQueryable MatchingMediaOwnerIds(JellyfinDbContext context, FolderMatchCriteria criteria)
+ => criteria is HasChapterImages
+ ? context.Chapters
.Where(c => c.ImagePath != null)
.Select(c => c.ItemId)
.Distinct()
- .ToHashSet(),
- HasMediaStreamType m => GetMatchingMediaStreamItemIds(context, m),
+ : MatchingMediaStreams(context, criteria)
+ .Select(ms => ms.ItemId)
+ .Distinct();
+
+ // The stream rows a criteria matches. One definition, so the owner projection and the alternate
+ // projection cannot drift apart despite reading it from opposite ends.
+ private static IQueryable MatchingMediaStreams(JellyfinDbContext context, FolderMatchCriteria criteria)
+ => criteria switch
+ {
+ HasSubtitles => context.MediaStreamInfos
+ .Where(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle),
+ HasMediaStreamType m => GetMatchingMediaStreams(context, m),
_ => throw new ArgumentOutOfRangeException(nameof(criteria), $"Unknown criteria type: {criteria.GetType().Name}")
};
- var ancestors = TraverseHierarchyUp(context, matchingItemIds);
-
- return ancestors.AsQueryable();
- }
-
- private static HashSet GetMatchingMediaStreamItemIds(JellyfinDbContext context, HasMediaStreamType criteria)
+ private static IQueryable GetMatchingMediaStreams(JellyfinDbContext context, HasMediaStreamType criteria)
{
var query = context.MediaStreamInfos
.Where(ms => ms.StreamType == criteria.StreamType
@@ -130,130 +213,128 @@ public static class DescendantQueryHelper
query = query.Where(ms => ms.IsExternal == isExternal);
}
- return query.Select(ms => ms.ItemId).Distinct().ToHashSet();
+ return query;
}
- ///
- /// Traverses DOWN the hierarchy from parent folders to find all descendants.
- ///
- private static HashSet TraverseHierarchyDown(JellyfinDbContext context, ICollection startIds)
+ private static IQueryable ClosureDescendants(JellyfinDbContext context, IReadOnlyList roots)
{
- var visited = new HashSet(startIds);
- var folderStack = new HashSet(startIds);
+ var direct = context.AncestorIds
+ .WhereOneOrMany(roots, e => e.ParentItemId)
+ .Select(e => e.ItemId);
- while (folderStack.Count != 0)
+ // An item carries its own chain plus its collection folders, never the UserRootFolder.
+ var indirect = context.AncestorIds
+ .Where(e => direct.Contains(e.ParentItemId))
+ .Select(e => e.ItemId);
+
+ return direct.Concat(indirect);
+ }
+
+ // Resolves the folders whose linked children lead, at any depth, to a matching item.
+ private static List ResolveLinkParents(JellyfinDbContext context, IQueryable matchingItemIds, IQueryable ancestorsOfMatches)
+ {
+ // An alternate version is a second file for the item that links it, not a child of it, so that
+ // edge is not walked. It is also the one link a non-folder owns, and there is one per remuxed
+ // movie: walking it would swell this list from the BoxSet and Playlist count to the item count,
+ // and the list is bound into every statement the returned queryable is embedded in.
+ var containerLinks = context.LinkedChildren
+ .Where(e => e.ChildType != LinkedChildType.LocalAlternateVersion
+ && e.ChildType != LinkedChildType.LinkedAlternateVersion);
+
+ // A link sits above the closure and above another link alike, so the hop repeats until nothing
+ // new turns up.
+ var resolved = containerLinks
+ .Where(e => matchingItemIds.Contains(e.ChildId) || ancestorsOfMatches.Contains(e.ChildId))
+ .Select(e => e.ParentId)
+ .Distinct()
+ .ToHashSet();
+
+ var frontier = resolved.ToList();
+
+ while (frontier.Count != 0)
{
- var currentFolders = folderStack.ToArray();
- folderStack.Clear();
+ var containingFolders = context.AncestorIds
+ .WhereOneOrMany(frontier, e => e.ItemId)
+ .Select(e => e.ParentItemId);
- var directChildren = context.AncestorIds
- .WhereOneOrMany(currentFolders, e => e.ParentItemId)
- .Select(e => e.ItemId)
+ var directLinkParents = containerLinks
+ .WhereOneOrMany(frontier, e => e.ChildId)
+ .Select(e => e.ParentId);
+
+ var indirectLinkParents = containerLinks
+ .Where(e => containingFolders.Contains(e.ChildId))
+ .Select(e => e.ParentId);
+
+ var next = directLinkParents
+ .Concat(indirectLinkParents)
+ .Distinct()
.ToArray();
- var linkedChildren = context.LinkedChildren
- .WhereOneOrMany(currentFolders, e => e.ParentId)
- .Select(e => e.ChildId)
- .ToArray();
-
- var allChildren = directChildren.Concat(linkedChildren).Distinct().ToArray();
-
- if (allChildren.Length == 0)
+ frontier = [];
+ foreach (var id in next)
{
- break;
- }
-
- var childFolders = context.BaseItems
- .WhereOneOrMany(allChildren, e => e.Id)
- .Where(e => e.IsFolder)
- .Select(e => e.Id)
- .ToHashSet();
-
- foreach (var childId in allChildren)
- {
- if (visited.Add(childId) && childFolders.Contains(childId))
+ // Cyclic links terminate on the resolved set.
+ if (resolved.Add(id))
{
- folderStack.Add(childId);
+ frontier.Add(id);
}
}
}
- return visited;
+ return [.. resolved];
}
- ///
- /// Traverses DOWN the hierarchy using only AncestorIds (ownership), not LinkedChildren.
- ///
- private static HashSet TraverseHierarchyDownOwned(JellyfinDbContext context, ICollection startIds)
+ // Resolves the roots the descendant sub-selects are anchored on: those contributing their closure,
+ // and those contributing their linked children.
+ private static (List ClosureRoots, List LinkRoots) ResolveLinkedRoots(JellyfinDbContext context, Guid parentId)
{
- var visited = new HashSet(startIds);
- var folderStack = new HashSet(startIds);
+ var closureRoots = new List { parentId };
+ var linkRoots = new List { parentId };
+ var visited = new HashSet { parentId };
+ var frontier = new List { parentId };
- while (folderStack.Count != 0)
+ while (frontier.Count != 0)
{
- var currentFolders = folderStack.ToArray();
- folderStack.Clear();
+ var closureIds = ClosureDescendants(context, frontier);
- var directChildren = context.AncestorIds
- .WhereOneOrMany(currentFolders, e => e.ParentItemId)
- .Select(e => e.ItemId)
- .ToArray();
+ var linkedIds = context.LinkedChildren
+ .WhereOneOrMany(frontier, e => e.ParentId)
+ .Select(e => e.ChildId);
- if (directChildren.Length == 0)
- {
- break;
- }
-
- var childFolders = context.BaseItems
- .WhereOneOrMany(directChildren, e => e.Id)
- .Where(e => e.IsFolder)
+ var linkedFolders = context.BaseItems
+ .Where(e => e.IsFolder && linkedIds.Contains(e.Id))
.Select(e => e.Id)
.ToHashSet();
- foreach (var childId in directChildren)
- {
- if (visited.Add(childId) && childFolders.Contains(childId))
- {
- folderStack.Add(childId);
- }
- }
- }
-
- return visited;
- }
-
- ///
- /// Traverses UP the hierarchy from items to find all ancestor folders.
- ///
- private static HashSet TraverseHierarchyUp(JellyfinDbContext context, ICollection startIds)
- {
- var ancestors = new HashSet();
- var itemStack = new HashSet(startIds);
-
- while (itemStack.Count != 0)
- {
- var currentItems = itemStack.ToArray();
- itemStack.Clear();
-
- var ancestorParents = context.AncestorIds
- .WhereOneOrMany(currentItems, e => e.ItemId)
- .Select(e => e.ParentItemId)
- .ToArray();
-
- var linkedParents = context.LinkedChildren
- .WhereOneOrMany(currentItems, e => e.ChildId)
+ // Folders whose own links have to be followed. Driven off LinkedChildren because owning a
+ // link is the rare property, so the folder check only reaches rows that can qualify. That
+ // check stays: a non-folder owns links too (a movie and its alternate versions).
+ var linkOwners = context.LinkedChildren
+ .Where(e => (closureIds.Contains(e.ParentId) || linkedIds.Contains(e.ParentId))
+ && e.Parent!.IsFolder)
.Select(e => e.ParentId)
+ .Distinct()
.ToArray();
- foreach (var parentId in ancestorParents.Concat(linkedParents))
+ frontier = [];
+ foreach (var id in linkOwners.Concat(linkedFolders))
{
- if (ancestors.Add(parentId))
+ if (!visited.Add(id))
{
- itemStack.Add(parentId);
+ continue;
+ }
+
+ frontier.Add(id);
+ linkRoots.Add(id);
+
+ // Only a folder reached through a link adds a closure the roots so far do not cover.
+ if (linkedFolders.Contains(id))
+ {
+ closureRoots.Add(id);
}
}
}
- return ancestors;
+ return (closureRoots, linkRoots);
}
}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Permission.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Permission.cs
index 84b86574cc..eae02dda1c 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Permission.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Permission.cs
@@ -37,7 +37,7 @@ namespace Jellyfin.Database.Implementations.Entities
///
/// Gets or sets the id of the associated user.
///
- public Guid? UserId { get; set; }
+ public Guid UserId { get; set; }
///
/// Gets the type of this permission.
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Preference.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Preference.cs
index c02ea7375a..9bd159f2cf 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Preference.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Preference.cs
@@ -35,7 +35,7 @@ namespace Jellyfin.Database.Implementations.Entities
///
/// Gets or sets the id of the associated user.
///
- public Guid? UserId { get; set; }
+ public Guid UserId { get; set; }
///
/// Gets the type of this preference.
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs
index b10e210e5d..bf6568d10a 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs
@@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
-using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Database.Implementations.Interfaces;
@@ -326,7 +325,6 @@ namespace Jellyfin.Database.Implementations.Entities
///
/// Gets the list of permissions this user has.
///
- [ForeignKey("Permission_Permissions_Guid")]
public virtual ICollection Permissions { get; private set; }
/*
@@ -339,7 +337,6 @@ namespace Jellyfin.Database.Implementations.Entities
///
/// Gets the list of preferences this user has.
///
- [ForeignKey("Preference_Preferences_Guid")]
public virtual ICollection Preferences { get; private set; }
///
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/MediaStreamInfoConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/MediaStreamInfoConfiguration.cs
index afa9eee363..48c537bbd3 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/MediaStreamInfoConfiguration.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/MediaStreamInfoConfiguration.cs
@@ -13,5 +13,9 @@ public class MediaStreamInfoConfiguration : IEntityTypeConfiguration builder)
{
builder.HasKey(e => new { e.ItemId, e.StreamIndex });
+
+ // Covering index for the stream filters. ItemId comes second because it is what they project and
+ // dedupe on; Language and IsExternal follow only to keep their predicates off the table.
+ builder.HasIndex(e => new { e.StreamType, e.ItemId, e.Language, e.IsExternal });
}
}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PermissionConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PermissionConfiguration.cs
index d2aed54eb1..ae53a36724 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PermissionConfiguration.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PermissionConfiguration.cs
@@ -14,10 +14,8 @@ namespace Jellyfin.Database.Implementations.ModelConfiguration
{
// Used to get a user's permissions or a specific permission for a user.
// Also prevents multiple values being created for a user.
- // Filtered over non-null user ids for when other entities (groups, API keys) get permissions
builder
.HasIndex(p => new { p.UserId, p.Kind })
- .HasFilter("[UserId] IS NOT NULL")
.IsUnique();
}
}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PreferenceConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PreferenceConfiguration.cs
index 207051bcd1..5306078ed4 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PreferenceConfiguration.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PreferenceConfiguration.cs
@@ -14,7 +14,6 @@ namespace Jellyfin.Database.Implementations.ModelConfiguration
{
builder
.HasIndex(p => new { p.UserId, p.Kind })
- .HasFilter("[UserId] IS NOT NULL")
.IsUnique();
}
}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260812050902_AddMediaStreamFilterIndex.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260812050902_AddMediaStreamFilterIndex.Designer.cs
new file mode 100644
index 0000000000..afa6840a97
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260812050902_AddMediaStreamFilterIndex.Designer.cs
@@ -0,0 +1,1813 @@
+//
+using System;
+using Jellyfin.Database.Implementations;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace Jellyfin.Database.Providers.Sqlite.Migrations
+{
+ [DbContext(typeof(JellyfinDbContext))]
+ [Migration("20260812050902_AddMediaStreamFilterIndex")]
+ partial class AddMediaStreamFilterIndex
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("DayOfWeek")
+ .HasColumnType("INTEGER");
+
+ b.Property("EndHour")
+ .HasColumnType("REAL");
+
+ b.Property("StartHour")
+ .HasColumnType("REAL");
+
+ b.Property("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AccessSchedules");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ActivityLog", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("DateCreated")
+ .HasColumnType("TEXT");
+
+ b.Property("ItemId")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("LogSeverity")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property("Overview")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property("ShortOverview")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DateCreated");
+
+ b.ToTable("ActivityLogs");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b =>
+ {
+ b.Property("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property("ParentItemId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemId", "ParentItemId");
+
+ b.HasIndex("ParentItemId");
+
+ b.ToTable("AncestorIds");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b =>
+ {
+ b.Property("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property("Index")
+ .HasColumnType("INTEGER");
+
+ b.Property("Codec")
+ .HasColumnType("TEXT");
+
+ b.Property("CodecTag")
+ .HasColumnType("TEXT");
+
+ b.Property("Comment")
+ .HasColumnType("TEXT");
+
+ b.Property("Filename")
+ .HasColumnType("TEXT");
+
+ b.Property("MimeType")
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemId", "Index");
+
+ b.ToTable("AttachmentStreamInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("Album")
+ .HasColumnType("TEXT");
+
+ b.Property("AlbumArtists")
+ .HasColumnType("TEXT");
+
+ b.Property("Artists")
+ .HasColumnType("TEXT");
+
+ b.Property("Audio")
+ .HasColumnType("INTEGER");
+
+ b.Property("ChannelId")
+ .HasColumnType("TEXT");
+
+ b.Property("CleanName")
+ .HasColumnType("TEXT");
+
+ b.Property("CommunityRating")
+ .HasColumnType("REAL");
+
+ b.Property("CriticRating")
+ .HasColumnType("REAL");
+
+ b.Property("CustomRating")
+ .HasColumnType("TEXT");
+
+ b.Property("Data")
+ .HasColumnType("TEXT");
+
+ b.Property("DateCreated")
+ .HasColumnType("TEXT");
+
+ b.Property("DateLastMediaAdded")
+ .HasColumnType("TEXT");
+
+ b.Property("DateLastRefreshed")
+ .HasColumnType("TEXT");
+
+ b.Property("DateLastSaved")
+ .HasColumnType("TEXT");
+
+ b.Property("DateModified")
+ .HasColumnType("TEXT");
+
+ b.Property("EndDate")
+ .HasColumnType("TEXT");
+
+ b.Property("EpisodeTitle")
+ .HasColumnType("TEXT");
+
+ b.Property("ExternalId")
+ .HasColumnType("TEXT");
+
+ b.Property("ExternalSeriesId")
+ .HasColumnType("TEXT");
+
+ b.Property("ExternalServiceId")
+ .HasColumnType("TEXT");
+
+ b.Property("ExtraType")
+ .HasColumnType("INTEGER");
+
+ b.Property("ForcedSortName")
+ .HasColumnType("TEXT");
+
+ b.Property("Genres")
+ .HasColumnType("TEXT");
+
+ b.Property("Height")
+ .HasColumnType("INTEGER");
+
+ b.Property("IndexNumber")
+ .HasColumnType("INTEGER");
+
+ b.Property("InheritedParentalRatingSubValue")
+ .HasColumnType("INTEGER");
+
+ b.Property("InheritedParentalRatingValue")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsFolder")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsInMixedFolder")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsMovie")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsRepeat")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsSeries")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsVirtualItem")
+ .HasColumnType("INTEGER");
+
+ b.Property("LUFS")
+ .HasColumnType("REAL");
+
+ b.Property("MediaType")
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizationGain")
+ .HasColumnType("REAL");
+
+ b.Property("OfficialRating")
+ .HasColumnType("TEXT");
+
+ b.Property("OriginalLanguage")
+ .HasColumnType("TEXT");
+
+ b.Property("OriginalTitle")
+ .HasColumnType("TEXT");
+
+ b.Property("Overview")
+ .HasColumnType("TEXT");
+
+ b.Property("OwnerId")
+ .HasColumnType("TEXT");
+
+ b.Property("ParentId")
+ .HasColumnType("TEXT");
+
+ b.Property("ParentIndexNumber")
+ .HasColumnType("INTEGER");
+
+ b.Property("Path")
+ .HasColumnType("TEXT");
+
+ b.Property("PreferredMetadataCountryCode")
+ .HasColumnType("TEXT");
+
+ b.Property("PreferredMetadataLanguage")
+ .HasColumnType("TEXT");
+
+ b.Property("PremiereDate")
+ .HasColumnType("TEXT");
+
+ b.Property("PresentationUniqueKey")
+ .HasColumnType("TEXT");
+
+ b.Property("PrimaryVersionId")
+ .HasColumnType("TEXT");
+
+ b.Property("ProductionLocations")
+ .HasColumnType("TEXT");
+
+ b.Property("ProductionYear")
+ .HasColumnType("INTEGER");
+
+ b.Property("RunTimeTicks")
+ .HasColumnType("INTEGER");
+
+ b.Property("SeasonId")
+ .HasColumnType("TEXT");
+
+ b.Property("SeasonName")
+ .HasColumnType("TEXT");
+
+ b.Property("SeriesId")
+ .HasColumnType("TEXT");
+
+ b.Property("SeriesName")
+ .HasColumnType("TEXT");
+
+ b.Property("SeriesPresentationUniqueKey")
+ .HasColumnType("TEXT");
+
+ b.Property("ShowId")
+ .HasColumnType("TEXT");
+
+ b.Property("Size")
+ .HasColumnType("INTEGER");
+
+ b.Property("SortName")
+ .HasColumnType("TEXT");
+
+ b.Property("StartDate")
+ .HasColumnType("TEXT");
+
+ b.Property("Studios")
+ .HasColumnType("TEXT");
+
+ b.Property("Tagline")
+ .HasColumnType("TEXT");
+
+ b.Property("Tags")
+ .HasColumnType("TEXT");
+
+ b.Property("TopParentId")
+ .HasColumnType("TEXT");
+
+ b.Property("TotalBitrate")
+ .HasColumnType("INTEGER");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("UnratedType")
+ .HasColumnType("TEXT");
+
+ b.Property("Width")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name");
+
+ b.HasIndex("OwnerId");
+
+ b.HasIndex("ParentId");
+
+ b.HasIndex("Path");
+
+ b.HasIndex("PresentationUniqueKey");
+
+ b.HasIndex("PrimaryVersionId")
+ .HasFilter("\"PrimaryVersionId\" IS NOT NULL");
+
+ b.HasIndex("SeasonId");
+
+ b.HasIndex("SeriesId");
+
+ b.HasIndex("SeriesName");
+
+ b.HasIndex("ExtraType", "OwnerId");
+
+ b.HasIndex("TopParentId", "Id");
+
+ b.HasIndex("Type", "CleanName");
+
+ b.HasIndex("TopParentId", "Type", "IsVirtualItem")
+ .HasFilter("\"PrimaryVersionId\" IS NULL AND (\"OwnerId\" IS NULL OR \"ExtraType\" IS NOT NULL)");
+
+ b.HasIndex("Type", "TopParentId", "Id");
+
+ b.HasIndex("Type", "TopParentId", "PresentationUniqueKey");
+
+ b.HasIndex("Type", "TopParentId", "SortName");
+
+ b.HasIndex("Type", "TopParentId", "StartDate");
+
+ b.HasIndex("MediaType", "TopParentId", "IsVirtualItem", "PresentationUniqueKey");
+
+ b.HasIndex("TopParentId", "IsFolder", "IsVirtualItem", "DateCreated");
+
+ b.HasIndex("TopParentId", "MediaType", "IsVirtualItem", "DateCreated");
+
+ b.HasIndex("TopParentId", "Type", "IsVirtualItem", "DateCreated");
+
+ b.HasIndex("Type", "SeriesPresentationUniqueKey", "IsFolder", "IsVirtualItem");
+
+ b.HasIndex("Type", "SeriesPresentationUniqueKey", "ParentIndexNumber", "IndexNumber");
+
+ b.HasIndex("Type", "SeriesPresentationUniqueKey", "PresentationUniqueKey", "SortName");
+
+ b.HasIndex("IsFolder", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated");
+
+ b.HasIndex("Type", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated");
+
+ b.ToTable("BaseItems");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+
+ b.HasData(
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000001"),
+ IsFolder = false,
+ IsInMixedFolder = false,
+ IsLocked = false,
+ IsMovie = false,
+ IsRepeat = false,
+ IsSeries = false,
+ IsVirtualItem = false,
+ Name = "This is a placeholder item for UserData that has been detached from its original item",
+ Type = "PLACEHOLDER"
+ });
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("Blurhash")
+ .HasColumnType("BLOB");
+
+ b.Property("DateModified")
+ .HasColumnType("TEXT");
+
+ b.Property("Height")
+ .HasColumnType("INTEGER");
+
+ b.Property("ImageType")
+ .HasColumnType("INTEGER");
+
+ b.Property("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property("Path")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Width")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ItemId", "ImageType");
+
+ b.ToTable("BaseItemImageInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("INTEGER");
+
+ b.Property("ItemId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id", "ItemId");
+
+ b.HasIndex("ItemId");
+
+ b.ToTable("BaseItemMetadataFields");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b =>
+ {
+ b.Property("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property("ProviderId")
+ .HasColumnType("TEXT");
+
+ b.Property("ProviderValue")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemId", "ProviderId");
+
+ b.HasIndex("ProviderId", "ItemId", "ProviderValue");
+
+ b.ToTable("BaseItemProviders");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("INTEGER");
+
+ b.Property("ItemId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id", "ItemId");
+
+ b.HasIndex("ItemId");
+
+ b.ToTable("BaseItemTrailerTypes");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b =>
+ {
+ b.Property("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property("ChapterIndex")
+ .HasColumnType("INTEGER");
+
+ b.Property("ImageDateModified")
+ .HasColumnType("TEXT");
+
+ b.Property("ImagePath")
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .HasColumnType("TEXT");
+
+ b.Property("StartPositionTicks")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId", "ChapterIndex");
+
+ b.ToTable("Chapters");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.CustomItemDisplayPreferences", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("Client")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property("Key")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property("Value")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "ItemId", "Client", "Key")
+ .IsUnique();
+
+ b.ToTable("CustomItemDisplayPreferences");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("ChromecastVersion")
+ .HasColumnType("INTEGER");
+
+ b.Property("Client")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property("DashboardTheme")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property("EnableNextVideoInfoOverlay")
+ .HasColumnType("INTEGER");
+
+ b.Property("IndexBy")
+ .HasColumnType("INTEGER");
+
+ b.Property("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property("ScrollDirection")
+ .HasColumnType("INTEGER");
+
+ b.Property("ShowBackdrop")
+ .HasColumnType("INTEGER");
+
+ b.Property("ShowSidebar")
+ .HasColumnType("INTEGER");
+
+ b.Property("SkipBackwardLength")
+ .HasColumnType("INTEGER");
+
+ b.Property("SkipForwardLength")
+ .HasColumnType("INTEGER");
+
+ b.Property("TvHome")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "ItemId", "Client")
+ .IsUnique();
+
+ b.ToTable("DisplayPreferences");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("DisplayPreferencesId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Order")
+ .HasColumnType("INTEGER");
+
+ b.Property("Type")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DisplayPreferencesId");
+
+ b.ToTable("HomeSection");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property("Path")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId")
+ .IsUnique();
+
+ b.ToTable("ImageInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("Client")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property("IndexBy")
+ .HasColumnType("INTEGER");
+
+ b.Property("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property("RememberIndexing")
+ .HasColumnType("INTEGER");
+
+ b.Property("RememberSorting")
+ .HasColumnType("INTEGER");
+
+ b.Property("SortBy")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property("SortOrder")
+ .HasColumnType("INTEGER");
+
+ b.Property("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property("ViewType")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("ItemDisplayPreferences");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b =>
+ {
+ b.Property("ItemValueId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CleanValue")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Type")
+ .HasColumnType("INTEGER");
+
+ b.Property("Value")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemValueId");
+
+ b.HasIndex("Type", "CleanValue");
+
+ b.HasIndex("Type", "Value")
+ .IsUnique();
+
+ b.ToTable("ItemValues");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b =>
+ {
+ b.Property("ItemValueId")
+ .HasColumnType("TEXT");
+
+ b.Property("ItemId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemValueId", "ItemId");
+
+ b.HasIndex("ItemId");
+
+ b.ToTable("ItemValuesMap");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b =>
+ {
+ b.Property("ItemId")
+ .HasColumnType("TEXT");
+
+ b.PrimitiveCollection("KeyframeTicks")
+ .HasColumnType("TEXT");
+
+ b.Property("TotalDuration")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId");
+
+ b.ToTable("KeyframeData");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.LinkedChildEntity", b =>
+ {
+ b.Property("ParentId")
+ .HasColumnType("TEXT");
+
+ b.Property("SortOrder")
+ .HasColumnType("INTEGER");
+
+ b.Property("ChildId")
+ .HasColumnType("TEXT");
+
+ b.Property("ChildType")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ParentId", "SortOrder");
+
+ b.HasIndex("ChildId", "ChildType");
+
+ b.HasIndex("ParentId", "ChildType");
+
+ b.ToTable("LinkedChildren", (string)null);
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaSegment", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("EndTicks")
+ .HasColumnType("INTEGER");
+
+ b.Property("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property("SegmentProviderId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("StartTicks")
+ .HasColumnType("INTEGER");
+
+ b.Property("Type")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.ToTable("MediaSegments");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b =>
+ {
+ b.Property("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property("StreamIndex")
+ .HasColumnType("INTEGER");
+
+ b.Property("AspectRatio")
+ .HasColumnType("TEXT");
+
+ b.Property