Merge remote-tracking branch 'upstream/master' into fix-metadata-language-fallback
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"dotnet-ef": {
|
||||
"version": "10.0.10",
|
||||
"version": "10.0.11",
|
||||
"commands": [
|
||||
"dotnet-ef"
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
+4
-1
@@ -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)
|
||||
|
||||
+22
-22
@@ -26,28 +26,28 @@
|
||||
<PackageVersion Include="libse" Version="4.0.12" />
|
||||
<PackageVersion Include="LrcParser" Version="2025.623.0" />
|
||||
<PackageVersion Include="MetaBrainz.MusicBrainz" Version="8.0.1" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="5.6.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="5.6.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.6.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.6.0" />
|
||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="5.9.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.9.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.9.0" />
|
||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
|
||||
<PackageVersion Include="MimeTypes" Version="2.5.2" />
|
||||
<PackageVersion Include="Morestachio" Version="5.0.1.670" />
|
||||
<PackageVersion Include="Moq" Version="4.18.4" />
|
||||
@@ -78,7 +78,7 @@
|
||||
<PackageVersion Include="Svg.Skia" Version="3.7.0" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="10.2.3" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.3" />
|
||||
<PackageVersion Include="System.Text.Json" Version="10.0.10" />
|
||||
<PackageVersion Include="System.Text.Json" Version="10.0.11" />
|
||||
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
|
||||
<PackageVersion Include="z440.atl.core" Version="7.16.0" />
|
||||
<PackageVersion Include="TMDbLib" Version="3.0.0" />
|
||||
|
||||
@@ -151,7 +151,7 @@ namespace Emby.Naming.Common
|
||||
|
||||
CleanStrings =
|
||||
[
|
||||
@"^\s*(?<cleaned>.+?)[ _\,\.\(\)\[\]\-](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*(?<cleaned>.+?)[ _\,\.\(\)\[\]\-](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*(?<cleaned>.+?)((\s*\[[^\]]+\]\s*)+)(\.[^\s]+)?$",
|
||||
@"^\s*(?<cleaned>.+?)\WE[0-9]+(-|~)E?[0-9]+(\W|$)",
|
||||
@"^\s*\[[^\]]+\](?!\.\w+$)\s*(?<cleaned>.+)",
|
||||
|
||||
@@ -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<BaseItem> 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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,12 @@
|
||||
"CleanupUserDataTaskDescription": "ניקוי כל המידע של המשתמש (מצב צפייה, מועדפים וכו) ממדיה שאינה קיימת מעל 90 יום.",
|
||||
"CleanupUserDataTask": "משימת ניקוי מידע משתמש",
|
||||
"LyricDownloadFailureFromForItem": "הורדת המילים מ-{0} עבור {1} נכשלה",
|
||||
"Original": "מקור"
|
||||
"Original": "מקור",
|
||||
"NameExtraBehindTheScenes": "מאחורי הקלעים",
|
||||
"NameExtraClip": "קליפ",
|
||||
"NameExtraDeletedScene": "סצנה שנמחקה",
|
||||
"NameExtraFeaturette": "סרט קצר",
|
||||
"NameExtraInterview": "ריאיון",
|
||||
"NameExtraSample": "דגימה",
|
||||
"NameExtraScene": "סצנה"
|
||||
}
|
||||
|
||||
@@ -120,5 +120,6 @@
|
||||
"NameExtraThemeSong": "Főcímdal",
|
||||
"NameExtraThemeVideo": "Főcímvideó",
|
||||
"NameExtraTrailer": "Előzetes",
|
||||
"NameExtraUnknown": "Extra"
|
||||
"NameExtraUnknown": "Extra",
|
||||
"NameExtraNumbered": "{0} {1}"
|
||||
}
|
||||
|
||||
@@ -24,5 +24,10 @@
|
||||
"TaskDownloadMissingSubtitles": "Ներբեռնել պակասող ենթագրերը",
|
||||
"AppDeviceValues": "Հավելված` {0}, Սարք `{1}",
|
||||
"ChapterNameValue": "Գլուխ {0}",
|
||||
"Collections": "Հավաքածուներ"
|
||||
"Collections": "Հավաքածուներ",
|
||||
"Artists": "Երաժիշտներ",
|
||||
"Default": "Լռելյայն",
|
||||
"Favorites": "Ընտրյալ",
|
||||
"Forced": "Ստիպուած",
|
||||
"Genres": "Ոճ"
|
||||
}
|
||||
|
||||
@@ -118,5 +118,8 @@
|
||||
"NameExtraSample": "Campione",
|
||||
"NameExtraShort": "Corto",
|
||||
"NameExtraThemeSong": "Sigla musicale",
|
||||
"NameExtraTrailer": "Trailer"
|
||||
"NameExtraTrailer": "Trailer",
|
||||
"NameExtraFeaturette": "Caratteristica",
|
||||
"NameExtraThemeVideo": "Video tematico",
|
||||
"NameExtraUnknown": "Extra"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
|
||||
@@ -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ă"
|
||||
}
|
||||
|
||||
@@ -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": "Короткометражка"
|
||||
}
|
||||
|
||||
@@ -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í",
|
||||
|
||||
@@ -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ë"
|
||||
}
|
||||
|
||||
@@ -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": "Додатак"
|
||||
}
|
||||
|
||||
@@ -116,5 +116,10 @@
|
||||
"NameExtraScene": "Scen",
|
||||
"NameExtraShort": "Kortfilm",
|
||||
"NameExtraThemeSong": "Signaturmelodi",
|
||||
"NameExtraTrailer": "Trailer"
|
||||
"NameExtraTrailer": "Trailer",
|
||||
"NameExtraClip": "Klipp",
|
||||
"NameExtraFeaturette": "Kortfilm",
|
||||
"NameExtraSample": "Prov",
|
||||
"NameExtraThemeVideo": "Signaturvideo",
|
||||
"NameExtraUnknown": "Extra"
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -115,11 +115,11 @@
|
||||
"NameExtraFeaturette": "花絮",
|
||||
"NameExtraInterview": "采访",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "样本",
|
||||
"NameExtraScene": "场景",
|
||||
"NameExtraShort": "短片",
|
||||
"NameExtraSample": "试看",
|
||||
"NameExtraScene": "精选片段",
|
||||
"NameExtraShort": "短篇",
|
||||
"NameExtraThemeSong": "主题曲",
|
||||
"NameExtraThemeVideo": "主题视频",
|
||||
"NameExtraTrailer": "预告片",
|
||||
"NameExtraUnknown": "额外"
|
||||
"NameExtraUnknown": "额外内容"
|
||||
}
|
||||
|
||||
@@ -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": "額外內容"
|
||||
}
|
||||
|
||||
@@ -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": "額外內容"
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the latest movies, deduplicated so each movie only appears once.
|
||||
/// </summary>
|
||||
/// <param name="context">The database context.</param>
|
||||
/// <param name="baseQuery">The query to pull movies from, with filters already applied.</param>
|
||||
/// <param name="filter">The original query filter, used when loading the final items.</param>
|
||||
/// <param name="limit">How many items to return.</param>
|
||||
/// <returns>The latest movies, newest first.</returns>
|
||||
private IReadOnlyList<BaseItemDto> GetLatestMovieItems(
|
||||
JellyfinDbContext context,
|
||||
IQueryable<BaseItemEntity> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the latest TV show items with smart Season/Series container selection.
|
||||
/// </summary>
|
||||
|
||||
@@ -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<Func<BaseItemEntity, bool>> 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<Guid> VersionsMatchingDimension(JellyfinDbContext context, Expression<Func<BaseItemEntity, bool>> bound)
|
||||
=> context.BaseItems
|
||||
.Where(v => v.PrimaryVersionId != null)
|
||||
.Where(bound)
|
||||
.Select(v => v.PrimaryVersionId!.Value);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IQueryable<BaseItemEntity> TranslateQuery(
|
||||
IQueryable<BaseItemEntity> 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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
: [];
|
||||
|
||||
@@ -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<MusicArtist>().ToArray(),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
g => g.Select(f => _queryHelpers.DeserializeBaseItem(f)).Where(dto => dto is not null).Cast<MusicArtist>().ToArray());
|
||||
|
||||
var result = new Dictionary<string, MusicArtist[]>(artistNames.Count);
|
||||
foreach (var name in artistNames)
|
||||
var result = new Dictionary<string, MusicArtist[]>(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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -351,7 +351,11 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> 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<JellyfinDbContext> 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();
|
||||
|
||||
@@ -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<Permission> source)
|
||||
{
|
||||
var incoming = new Dictionary<PermissionKind, bool>();
|
||||
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<Preference> source)
|
||||
{
|
||||
var incoming = new Dictionary<PreferenceKind, string>();
|
||||
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<User> CreateUserInternalAsync(string name, JellyfinDbContext dbContext)
|
||||
{
|
||||
// TODO: Remove after user item data is migrated.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
/// <value>The last playback check in.</value>
|
||||
public DateTime LastPlaybackCheckIn { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the position reported by the client at the last playback check-in.
|
||||
/// </summary>
|
||||
/// <value>The position ticks, or <see langword="null"/> if the client did not report a position.</value>
|
||||
[JsonIgnore]
|
||||
public long? LastPlaybackCheckInPositionTicks
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_progressLock)
|
||||
{
|
||||
return _lastPlaybackCheckInPositionTicks;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the last paused date.
|
||||
/// </summary>
|
||||
@@ -372,6 +389,7 @@ namespace MediaBrowser.Controller.Session
|
||||
|
||||
lock (_progressLock)
|
||||
{
|
||||
_lastPlaybackCheckInPositionTicks = progressInfo.PositionTicks;
|
||||
_lastProgressInfo = progressInfo;
|
||||
|
||||
if (_progressTimer is null)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes the path.
|
||||
/// </summary>
|
||||
/// <param name="path">The path.</param>
|
||||
/// <returns>System.String.</returns>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -141,6 +141,7 @@ public class TranscodingProfile
|
||||
/// Gets or sets a value indicating whether breaking the video stream on non-keyframes is supported.
|
||||
/// </summary>
|
||||
[DefaultValue(false)]
|
||||
[XmlIgnore]
|
||||
[XmlAttribute("breakOnNonKeyFrames")]
|
||||
[Obsolete("This is always false")]
|
||||
public bool? BreakOnNonKeyFrames { get; set; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Class ProviderIdsExtensions.
|
||||
/// </summary>
|
||||
public static class ProviderIdsExtensions
|
||||
public static partial class ProviderIdsExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Case-insensitive dictionary of <see cref="MetadataProvider"/> string representation.
|
||||
@@ -20,6 +22,27 @@ public static class ProviderIdsExtensions
|
||||
enumValue => enumValue.ToString(),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// The known id formats, keyed by provider name.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, Func<string, bool>> _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
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Checks if this instance has an id for the given provider.
|
||||
/// </summary>
|
||||
@@ -101,6 +124,26 @@ public static class ProviderIdsExtensions
|
||||
return instance.GetProviderId(provider.ToString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a value can be an id of the given provider.
|
||||
/// </summary>
|
||||
/// <param name="name">The provider name.</param>
|
||||
/// <param name="value">The provider id.</param>
|
||||
/// <returns><c>true</c> if the value has a plausible format for the provider; otherwise, <c>false</c>.</returns>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets a provider id.
|
||||
/// </summary>
|
||||
@@ -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<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@@ -153,7 +204,6 @@ public static class ProviderIdsExtensions
|
||||
/// <param name="instance">The instance.</param>
|
||||
/// <param name="name">The name, this should not contain a '=' character.</param>
|
||||
/// <param name="value">The value.</param>
|
||||
/// <remarks>Due to how deserialization from the database works the name cannot contain '='.</remarks>
|
||||
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<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
instance.TrySetProviderId(name, value);
|
||||
}
|
||||
|
||||
// Match on internal MetadataProvider enum string values before adding arbitrary providers
|
||||
if (_metadataProviderEnumDictionary.TryGetValue(name, out var enumValue))
|
||||
/// <summary>
|
||||
/// Replaces all provider ids, dropping the ones that cannot belong to the provider they are filed under.
|
||||
/// </summary>
|
||||
/// <param name="instance">The instance.</param>
|
||||
/// <param name="providerIds">The provider ids to set.</param>
|
||||
public static void SetProviderIds(this IHasProviderIds instance, IReadOnlyDictionary<string, string>? providerIds)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(instance);
|
||||
|
||||
instance.ProviderIds = new Dictionary<string, string>(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();
|
||||
}
|
||||
|
||||
@@ -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<string, string> GetValidProviderIds(IReadOnlyDictionary<string, string> providerIds)
|
||||
{
|
||||
var validProviderIds = new Dictionary<string, string>(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<TItemType> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reports the ids a provider returned that cannot belong to the provider they are filed under.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The ids are dropped when merging, this names the provider that produced them so the source of a
|
||||
/// recurring bad id can be found.
|
||||
/// </remarks>
|
||||
private void LogInvalidProviderIds(MetadataResult<TItemType> 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<string, string> 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<PersonInfo> 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<PersonInfo> source, IReadOnlyList<PersonInfo> target)
|
||||
{
|
||||
var sourceByName = source.ToLookup(p => p.Name.RemoveDiacritics(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the id if it can be an id of the given provider, otherwise <c>null</c>.
|
||||
/// </summary>
|
||||
private static string? MusicBrainzId(MetadataProvider provider, string? id)
|
||||
=> ProviderIdsExtensions.IsValidProviderId(provider.ToString(), id) ? id : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
/// </summary>
|
||||
public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, AlbumInfo>, IHasOrder
|
||||
{
|
||||
private readonly ILogger<MusicBrainzAlbumProvider> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MusicBrainzAlbumProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public MusicBrainzAlbumProvider(ILogger<MusicBrainzAlbumProvider> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "MusicBrainz";
|
||||
|
||||
@@ -32,21 +44,26 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu
|
||||
public async Task<IEnumerable<RemoteSearchResult>> 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<MusicAlbum, Albu
|
||||
foreach (var result in releaseSearchResults)
|
||||
{
|
||||
// Fetch full release info, otherwise artists are missing
|
||||
var fullResult = await query.LookupReleaseAsync(result.Id, Include.Artists | Include.ReleaseGroups, cancellationToken).ConfigureAwait(false);
|
||||
yield return GetReleaseResult(fullResult);
|
||||
var fullResult = await query.LookupReleaseOrNullAsync(result.Id, Include.Artists | Include.ReleaseGroups, _logger, cancellationToken).ConfigureAwait(false);
|
||||
if (fullResult is not null)
|
||||
{
|
||||
yield return GetReleaseResult(fullResult);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,8 +176,8 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu
|
||||
public async Task<MetadataResult<MusicAlbum>> 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<MusicAlbum>
|
||||
{
|
||||
@@ -165,15 +185,15 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu
|
||||
};
|
||||
|
||||
// If there is a release group, but no release ID, try to match the release
|
||||
if (string.IsNullOrWhiteSpace(releaseId) && !string.IsNullOrWhiteSpace(releaseGroupId))
|
||||
if (releaseId is null && releaseGroupId is not null)
|
||||
{
|
||||
// TODO: Actually try to match the release. Simply taking the first result is stupid.
|
||||
var releaseGroupLookup = await query.LookupReleaseGroupAsync(new Guid(releaseGroupId), Include.None, null, cancellationToken).ConfigureAwait(false);
|
||||
releaseId = releaseGroupLookup.Releases?.Count > 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<MusicAlbum, Albu
|
||||
|
||||
if (releaseResult is not null)
|
||||
{
|
||||
releaseId = releaseResult.Id.ToString();
|
||||
releaseId = releaseResult.Id;
|
||||
|
||||
if (releaseResult.ReleaseGroup?.Id is not null)
|
||||
{
|
||||
releaseGroupId = releaseResult.ReleaseGroup.Id.ToString();
|
||||
releaseGroupId = releaseResult.ReleaseGroup.Id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(releaseId) && string.IsNullOrWhiteSpace(releaseGroupId))
|
||||
if (releaseId is null && releaseGroupId is null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
// Fetch the full release (and its release group) so we can populate everything MusicBrainz returns.
|
||||
IRelease? release = null;
|
||||
if (!string.IsNullOrWhiteSpace(releaseId))
|
||||
if (releaseId is not null)
|
||||
{
|
||||
release = await query.LookupReleaseAsync(
|
||||
new Guid(releaseId),
|
||||
release = await query.LookupReleaseOrNullAsync(
|
||||
releaseId.Value,
|
||||
Include.Artists | Include.ReleaseGroups | Include.Labels | Include.Genres | Include.Tags,
|
||||
_logger,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(releaseGroupId) && release?.ReleaseGroup?.Id is not null)
|
||||
if (releaseGroupId is null && release?.ReleaseGroup?.Id is not null)
|
||||
{
|
||||
releaseGroupId = release.ReleaseGroup.Id.ToString();
|
||||
releaseGroupId = release.ReleaseGroup.Id;
|
||||
}
|
||||
}
|
||||
|
||||
IReleaseGroup? releaseGroup = null;
|
||||
if (!string.IsNullOrWhiteSpace(releaseGroupId))
|
||||
if (releaseGroupId is not null)
|
||||
{
|
||||
releaseGroup = await query.LookupReleaseGroupAsync(
|
||||
new Guid(releaseGroupId),
|
||||
releaseGroup = await query.LookupReleaseGroupOrNullAsync(
|
||||
releaseGroupId.Value,
|
||||
Include.Artists | Include.Genres | Include.Tags,
|
||||
null,
|
||||
_logger,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (release is null && releaseGroup is null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
result.HasMetadata = true;
|
||||
|
||||
if (!string.IsNullOrEmpty(releaseId))
|
||||
if (releaseId is not null)
|
||||
{
|
||||
result.Item.SetProviderId(MetadataProvider.MusicBrainzAlbum, releaseId);
|
||||
result.Item.SetProviderId(MetadataProvider.MusicBrainzAlbum, releaseId.Value.ToString());
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(releaseGroupId))
|
||||
if (releaseGroupId is not null)
|
||||
{
|
||||
result.Item.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, releaseGroupId);
|
||||
result.Item.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, releaseGroupId.Value.ToString());
|
||||
}
|
||||
|
||||
Populate(result.Item, release, releaseGroup);
|
||||
|
||||
@@ -13,6 +13,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;
|
||||
|
||||
@@ -21,6 +22,17 @@ namespace MediaBrowser.Providers.Plugins.MusicBrainz;
|
||||
/// </summary>
|
||||
public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, ArtistInfo>, IHasOrder
|
||||
{
|
||||
private readonly ILogger<MusicBrainzArtistProvider> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MusicBrainzArtistProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public MusicBrainzArtistProvider(ILogger<MusicBrainzArtistProvider> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "MusicBrainz";
|
||||
|
||||
@@ -32,12 +44,15 @@ public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, Ar
|
||||
public async Task<IEnumerable<RemoteSearchResult>> 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<MusicArtist, Ar
|
||||
{
|
||||
var result = new MetadataResult<MusicArtist> { 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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Helpers for talking to MusicBrainz with identifiers that are not guaranteed to be valid.
|
||||
/// </summary>
|
||||
internal static class MusicBrainzQueryExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Parses a MusicBrainz identifier, which may come from user-supplied tags or NFO files and is therefore not
|
||||
/// guaranteed to be a valid GUID.
|
||||
/// </summary>
|
||||
/// <param name="id">The identifier to parse.</param>
|
||||
/// <param name="entityType">The type of entity the identifier refers to, used for logging.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <returns>The parsed identifier, or <see langword="null"/> if it is missing or malformed.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a release, treating an unknown identifier as missing data rather than an error.
|
||||
/// </summary>
|
||||
/// <param name="query">The MusicBrainz query client.</param>
|
||||
/// <param name="releaseId">The release identifier.</param>
|
||||
/// <param name="include">The additional data to include in the lookup.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The release, or <see langword="null"/> if MusicBrainz does not have it.</returns>
|
||||
public static Task<IRelease?> LookupReleaseOrNullAsync(this Query query, Guid releaseId, Include include, ILogger logger, CancellationToken cancellationToken)
|
||||
=> NotFoundAsNullAsync(
|
||||
() => query.LookupReleaseAsync(releaseId, include, cancellationToken),
|
||||
"release",
|
||||
releaseId,
|
||||
logger);
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a release group, treating an unknown identifier as missing data rather than an error.
|
||||
/// </summary>
|
||||
/// <param name="query">The MusicBrainz query client.</param>
|
||||
/// <param name="releaseGroupId">The release group identifier.</param>
|
||||
/// <param name="include">The additional data to include in the lookup.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The release group, or <see langword="null"/> if MusicBrainz does not have it.</returns>
|
||||
public static Task<IReleaseGroup?> LookupReleaseGroupOrNullAsync(this Query query, Guid releaseGroupId, Include include, ILogger logger, CancellationToken cancellationToken)
|
||||
=> NotFoundAsNullAsync(
|
||||
() => query.LookupReleaseGroupAsync(releaseGroupId, include, null, cancellationToken),
|
||||
"release group",
|
||||
releaseGroupId,
|
||||
logger);
|
||||
|
||||
/// <summary>
|
||||
/// Looks up an artist, treating an unknown identifier as missing data rather than an error.
|
||||
/// </summary>
|
||||
/// <param name="query">The MusicBrainz query client.</param>
|
||||
/// <param name="artistId">The artist identifier.</param>
|
||||
/// <param name="include">The additional data to include in the lookup.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The artist, or <see langword="null"/> if MusicBrainz does not have it.</returns>
|
||||
public static Task<IArtist?> LookupArtistOrNullAsync(this Query query, Guid artistId, Include include, ILogger logger, CancellationToken cancellationToken)
|
||||
=> NotFoundAsNullAsync(
|
||||
() => query.LookupArtistAsync(artistId, include, null, null, cancellationToken),
|
||||
"artist",
|
||||
artistId,
|
||||
logger);
|
||||
|
||||
/// <summary>
|
||||
/// Runs a lookup, mapping a "not found" response to <see langword="null"/>. 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.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of entity being looked up.</typeparam>
|
||||
/// <param name="lookup">The lookup to run.</param>
|
||||
/// <param name="entityType">The type of entity being looked up, used for logging.</param>
|
||||
/// <param name="id">The identifier being looked up, used for logging.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <returns>The entity, or <see langword="null"/> if MusicBrainz does not have it.</returns>
|
||||
private static async Task<T?> NotFoundAsNullAsync<T>(Func<Task<T>> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
/// <inheritdoc />
|
||||
public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken)
|
||||
{
|
||||
var tmdbId = Convert.ToInt32(item.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture);
|
||||
item.TryGetTmdbId(out var tmdbId);
|
||||
|
||||
if (tmdbId <= 0)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
/// <inheritdoc />
|
||||
public async Task<IEnumerable<RemoteSearchResult>> 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
|
||||
/// <inheritdoc />
|
||||
public async Task<MetadataResult<BoxSet>> 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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -54,11 +54,11 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
|
||||
/// <inheritdoc />
|
||||
public async Task<IEnumerable<RemoteSearchResult>> 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<SearchMovie>? 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
|
||||
/// <inheritdoc />
|
||||
public async Task<MetadataResult<Movie>> 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<Movie>();
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -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<RemoteImageInfo>();
|
||||
}
|
||||
|
||||
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<RemoteImageInfo>();
|
||||
|
||||
@@ -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
|
||||
/// <inheritdoc />
|
||||
public async Task<IEnumerable<RemoteSearchResult>> 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
|
||||
/// <inheritdoc />
|
||||
public async Task<MetadataResult<Person>> 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)
|
||||
|
||||
@@ -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<RemoteImageInfo>();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<RemoteImageInfo>();
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
/// <inheritdoc />
|
||||
public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken)
|
||||
{
|
||||
var tmdbId = item.GetProviderId(MetadataProvider.Tmdb);
|
||||
|
||||
if (string.IsNullOrEmpty(tmdbId))
|
||||
if (!item.TryGetTmdbId(out var tmdbId))
|
||||
{
|
||||
return Enumerable.Empty<RemoteImageInfo>();
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -54,10 +54,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
|
||||
/// <inheritdoc />
|
||||
public async Task<IEnumerable<RemoteSearchResult>> 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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the TMDb id of an item, if it has one TMDb can be queried with.
|
||||
/// </summary>
|
||||
/// <param name="instance">The item.</param>
|
||||
/// <param name="tmdbId">The TMDb id.</param>
|
||||
/// <returns><c>true</c> if the item has a usable TMDb id; otherwise, <c>false</c>.</returns>
|
||||
public static bool TryGetTmdbId(this IHasProviderIds instance, out int tmdbId)
|
||||
{
|
||||
instance.TryGetProviderId(MetadataProvider.Tmdb, out var value);
|
||||
|
||||
return TryParseTmdbId(value, out tmdbId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a TMDb id.
|
||||
/// </summary>
|
||||
/// <param name="value">The stored id.</param>
|
||||
/// <param name="tmdbId">The TMDb id.</param>
|
||||
/// <returns><c>true</c> if the value is a usable TMDb id; otherwise, <c>false</c>.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleans the name according to TMDb requirements.
|
||||
/// </summary>
|
||||
|
||||
+201
-120
@@ -8,7 +8,7 @@ using Jellyfin.Database.Implementations.MatchCriteria;
|
||||
namespace Jellyfin.Database.Implementations;
|
||||
|
||||
/// <summary>
|
||||
/// Provides methods for querying item hierarchies using iterative traversal.
|
||||
/// Provides methods for querying item hierarchies.
|
||||
/// Uses AncestorIds and LinkedChildren tables for parent-child traversal.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -76,11 +81,11 @@ public static class DescendantQueryHelper
|
||||
return [];
|
||||
}
|
||||
|
||||
var seedSet = new HashSet<Guid>(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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a queryable of the IDs of the primary versions whose alternate version's media matches the
|
||||
/// criteria.
|
||||
/// </summary>
|
||||
/// <param name="context">Database context.</param>
|
||||
/// <param name="criteria">The matching criteria to apply.</param>
|
||||
/// <returns>Queryable of primary version item IDs.</returns>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public static IQueryable<Guid> 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<Guid> 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<MediaStreamInfo> 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<Guid> GetMatchingMediaStreamItemIds(JellyfinDbContext context, HasMediaStreamType criteria)
|
||||
private static IQueryable<MediaStreamInfo> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Traverses DOWN the hierarchy from parent folders to find all descendants.
|
||||
/// </summary>
|
||||
private static HashSet<Guid> TraverseHierarchyDown(JellyfinDbContext context, ICollection<Guid> startIds)
|
||||
private static IQueryable<Guid> ClosureDescendants(JellyfinDbContext context, IReadOnlyList<Guid> roots)
|
||||
{
|
||||
var visited = new HashSet<Guid>(startIds);
|
||||
var folderStack = new HashSet<Guid>(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<Guid> ResolveLinkParents(JellyfinDbContext context, IQueryable<Guid> matchingItemIds, IQueryable<Guid> 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];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Traverses DOWN the hierarchy using only AncestorIds (ownership), not LinkedChildren.
|
||||
/// </summary>
|
||||
private static HashSet<Guid> TraverseHierarchyDownOwned(JellyfinDbContext context, ICollection<Guid> startIds)
|
||||
// Resolves the roots the descendant sub-selects are anchored on: those contributing their closure,
|
||||
// and those contributing their linked children.
|
||||
private static (List<Guid> ClosureRoots, List<Guid> LinkRoots) ResolveLinkedRoots(JellyfinDbContext context, Guid parentId)
|
||||
{
|
||||
var visited = new HashSet<Guid>(startIds);
|
||||
var folderStack = new HashSet<Guid>(startIds);
|
||||
var closureRoots = new List<Guid> { parentId };
|
||||
var linkRoots = new List<Guid> { parentId };
|
||||
var visited = new HashSet<Guid> { parentId };
|
||||
var frontier = new List<Guid> { 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Traverses UP the hierarchy from items to find all ancestor folders.
|
||||
/// </summary>
|
||||
private static HashSet<Guid> TraverseHierarchyUp(JellyfinDbContext context, ICollection<Guid> startIds)
|
||||
{
|
||||
var ancestors = new HashSet<Guid>();
|
||||
var itemStack = new HashSet<Guid>(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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ namespace Jellyfin.Database.Implementations.Entities
|
||||
/// <summary>
|
||||
/// Gets or sets the id of the associated user.
|
||||
/// </summary>
|
||||
public Guid? UserId { get; set; }
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the type of this permission.
|
||||
|
||||
@@ -35,7 +35,7 @@ namespace Jellyfin.Database.Implementations.Entities
|
||||
/// <summary>
|
||||
/// Gets or sets the id of the associated user.
|
||||
/// </summary>
|
||||
public Guid? UserId { get; set; }
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the type of this preference.
|
||||
|
||||
@@ -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
|
||||
/// <summary>
|
||||
/// Gets the list of permissions this user has.
|
||||
/// </summary>
|
||||
[ForeignKey("Permission_Permissions_Guid")]
|
||||
public virtual ICollection<Permission> Permissions { get; private set; }
|
||||
|
||||
/*
|
||||
@@ -339,7 +337,6 @@ namespace Jellyfin.Database.Implementations.Entities
|
||||
/// <summary>
|
||||
/// Gets the list of preferences this user has.
|
||||
/// </summary>
|
||||
[ForeignKey("Preference_Preferences_Guid")]
|
||||
public virtual ICollection<Preference> Preferences { get; private set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
|
||||
+4
@@ -13,5 +13,9 @@ public class MediaStreamInfoConfiguration : IEntityTypeConfiguration<MediaStream
|
||||
public void Configure(EntityTypeBuilder<MediaStreamInfo> 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 });
|
||||
}
|
||||
}
|
||||
|
||||
-2
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
-1
@@ -14,7 +14,6 @@ namespace Jellyfin.Database.Implementations.ModelConfiguration
|
||||
{
|
||||
builder
|
||||
.HasIndex(p => new { p.UserId, p.Kind })
|
||||
.HasFilter("[UserId] IS NOT NULL")
|
||||
.IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
+1813
File diff suppressed because it is too large
Load Diff
+27
@@ -0,0 +1,27 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jellyfin.Database.Providers.Sqlite.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddMediaStreamFilterIndex : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MediaStreamInfos_StreamType_ItemId_Language_IsExternal",
|
||||
table: "MediaStreamInfos",
|
||||
columns: new[] { "StreamType", "ItemId", "Language", "IsExternal" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_MediaStreamInfos_StreamType_ItemId_Language_IsExternal",
|
||||
table: "MediaStreamInfos");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1807
File diff suppressed because it is too large
Load Diff
+120
@@ -0,0 +1,120 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jellyfin.Database.Providers.Sqlite.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class RemoveOrphanedUserPermissionsAndPreferences : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("DELETE FROM Permissions WHERE UserId IS NULL;");
|
||||
migrationBuilder.Sql("DELETE FROM Preferences WHERE UserId IS NULL;");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Preferences_UserId_Kind",
|
||||
table: "Preferences");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Permissions_UserId_Kind",
|
||||
table: "Permissions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Preference_Preferences_Guid",
|
||||
table: "Preferences");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Permission_Permissions_Guid",
|
||||
table: "Permissions");
|
||||
|
||||
migrationBuilder.AlterColumn<Guid>(
|
||||
name: "UserId",
|
||||
table: "Preferences",
|
||||
type: "TEXT",
|
||||
nullable: false,
|
||||
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
|
||||
oldClrType: typeof(Guid),
|
||||
oldType: "TEXT",
|
||||
oldNullable: true);
|
||||
|
||||
migrationBuilder.AlterColumn<Guid>(
|
||||
name: "UserId",
|
||||
table: "Permissions",
|
||||
type: "TEXT",
|
||||
nullable: false,
|
||||
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
|
||||
oldClrType: typeof(Guid),
|
||||
oldType: "TEXT",
|
||||
oldNullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Preferences_UserId_Kind",
|
||||
table: "Preferences",
|
||||
columns: ["UserId", "Kind"],
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Permissions_UserId_Kind",
|
||||
table: "Permissions",
|
||||
columns: ["UserId", "Kind"],
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Preferences_UserId_Kind",
|
||||
table: "Preferences");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Permissions_UserId_Kind",
|
||||
table: "Permissions");
|
||||
|
||||
migrationBuilder.AlterColumn<Guid>(
|
||||
name: "UserId",
|
||||
table: "Preferences",
|
||||
type: "TEXT",
|
||||
nullable: true,
|
||||
oldClrType: typeof(Guid),
|
||||
oldType: "TEXT");
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "Preference_Preferences_Guid",
|
||||
table: "Preferences",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AlterColumn<Guid>(
|
||||
name: "UserId",
|
||||
table: "Permissions",
|
||||
type: "TEXT",
|
||||
nullable: true,
|
||||
oldClrType: typeof(Guid),
|
||||
oldType: "TEXT");
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "Permission_Permissions_Guid",
|
||||
table: "Permissions",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Preferences_UserId_Kind",
|
||||
table: "Preferences",
|
||||
columns: ["UserId", "Kind"],
|
||||
unique: true,
|
||||
filter: "[UserId] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Permissions_UserId_Kind",
|
||||
table: "Permissions",
|
||||
columns: ["UserId", "Kind"],
|
||||
unique: true,
|
||||
filter: "[UserId] IS NOT NULL");
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-15
@@ -15,7 +15,7 @@ namespace Jellyfin.Server.Implementations.Migrations
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.11");
|
||||
|
||||
modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b =>
|
||||
{
|
||||
@@ -1012,6 +1012,8 @@ namespace Jellyfin.Server.Implementations.Migrations
|
||||
|
||||
b.HasKey("ItemId", "StreamIndex");
|
||||
|
||||
b.HasIndex("StreamType", "ItemId", "Language", "IsExternal");
|
||||
|
||||
b.ToTable("MediaStreamInfos");
|
||||
|
||||
b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
|
||||
@@ -1078,14 +1080,11 @@ namespace Jellyfin.Server.Implementations.Migrations
|
||||
b.Property<int>("Kind")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("Permission_Permissions_Guid")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<uint>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("UserId")
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Value")
|
||||
@@ -1094,8 +1093,7 @@ namespace Jellyfin.Server.Implementations.Migrations
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId", "Kind")
|
||||
.IsUnique()
|
||||
.HasFilter("[UserId] IS NOT NULL");
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Permissions");
|
||||
|
||||
@@ -1111,14 +1109,11 @@ namespace Jellyfin.Server.Implementations.Migrations
|
||||
b.Property<int>("Kind")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("Preference_Preferences_Guid")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<uint>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("UserId")
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Value")
|
||||
@@ -1129,8 +1124,7 @@ namespace Jellyfin.Server.Implementations.Migrations
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId", "Kind")
|
||||
.IsUnique()
|
||||
.HasFilter("[UserId] IS NOT NULL");
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Preferences");
|
||||
|
||||
@@ -1699,7 +1693,8 @@ namespace Jellyfin.Server.Implementations.Migrations
|
||||
b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
|
||||
.WithMany("Permissions")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b =>
|
||||
@@ -1707,7 +1702,8 @@ namespace Jellyfin.Server.Implementations.Migrations
|
||||
b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
|
||||
.WithMany("Preferences")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b =>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using ICU4N.Text;
|
||||
|
||||
@@ -173,5 +174,41 @@ namespace Jellyfin.Extensions
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Escapes an argument so that it survives command line parsing as a single argument when it is wrapped in double quotes by the caller.
|
||||
/// </summary>
|
||||
/// <param name="value">The argument to escape.</param>
|
||||
/// <returns>The escaped argument.</returns>
|
||||
public static string EscapeProcessArgument(this string value)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
|
||||
var span = value.AsSpan();
|
||||
if (!span.Contains('"'))
|
||||
{
|
||||
var trailing = span.Length - span.TrimEnd('\\').Length;
|
||||
return trailing == 0 ? value : string.Concat(value, new string('\\', trailing));
|
||||
}
|
||||
|
||||
var escaped = new StringBuilder(value.Length + 8);
|
||||
var backslashes = 0;
|
||||
|
||||
foreach (var character in span)
|
||||
{
|
||||
if (character == '\\')
|
||||
{
|
||||
backslashes++;
|
||||
continue;
|
||||
}
|
||||
|
||||
escaped
|
||||
.Append('\\', character == '"' ? (backslashes * 2) + 1 : backslashes)
|
||||
.Append(character);
|
||||
backslashes = 0;
|
||||
}
|
||||
|
||||
return escaped.Append('\\', backslashes * 2).ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,8 +188,8 @@ namespace Jellyfin.LiveTv.IO
|
||||
var commandLineArgs = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"-i \"{0}\" {2} -map_metadata -1 -threads {6} {3}{4}{5} -y \"{1}\"",
|
||||
inputTempFile,
|
||||
targetFile.Replace("\"", "\\\"", StringComparison.Ordinal), // Escape quotes in filename
|
||||
inputTempFile.EscapeProcessArgument(),
|
||||
targetFile.EscapeProcessArgument(),
|
||||
videoArgs,
|
||||
GetAudioArgs(mediaSource),
|
||||
subtitleArgs,
|
||||
|
||||
@@ -326,7 +326,7 @@ namespace Jellyfin.LiveTv.TunerHosts.HdHomerun
|
||||
BufferMs = 0,
|
||||
Container = "ts",
|
||||
Id = id,
|
||||
SupportsDirectPlay = false,
|
||||
SupportsDirectPlay = true,
|
||||
SupportsDirectStream = true,
|
||||
SupportsTranscoding = true,
|
||||
IsInfiniteStream = true,
|
||||
|
||||
@@ -75,5 +75,28 @@ namespace Jellyfin.Extensions.Tests
|
||||
var result = str.AsSpan().RightPart(needle).ToString();
|
||||
Assert.Equal(expectedResult, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("", "")]
|
||||
[InlineData("/media/movies/Film.mkv", "/media/movies/Film.mkv")]
|
||||
[InlineData(@"C:\media\movies\Film.mkv", @"C:\media\movies\Film.mkv")]
|
||||
[InlineData(@"/media/a""b.mkv", @"/media/a\""b.mkv")]
|
||||
[InlineData(@"/media/a\""b.mkv", @"/media/a\\\""b.mkv")]
|
||||
[InlineData(@"/media/a\\""b.mkv", @"/media/a\\\\\""b.mkv")]
|
||||
[InlineData(@"/media/a\b""c.mkv", @"/media/a\b\""c.mkv")]
|
||||
[InlineData(@"/media/trailing\", @"/media/trailing\\")]
|
||||
[InlineData(@"/media/evil\"" -f lavfi -i sine .mkv", @"/media/evil\\\"" -f lavfi -i sine .mkv")]
|
||||
public void EscapeProcessArgument_ValidInput_Corrects(string input, string expectedResult)
|
||||
{
|
||||
Assert.Equal(expectedResult, input.EscapeProcessArgument());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/media/movies/Film with spaces.mkv")]
|
||||
[InlineData(@"C:\media\movies\Film.mkv")]
|
||||
public void EscapeProcessArgument_NothingToEscape_ReturnsSameInstance(string input)
|
||||
{
|
||||
Assert.Same(input, input.EscapeProcessArgument());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,6 +186,109 @@ namespace Jellyfin.Model.Tests.Entities
|
||||
Assert.Null(nullProvider.ProviderIds);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(nameof(MetadataProvider.Imdb), "tt0113375", true)]
|
||||
[InlineData(nameof(MetadataProvider.Imdb), "nm0000123", true)]
|
||||
[InlineData(nameof(MetadataProvider.Imdb), "0113375", true)]
|
||||
[InlineData(nameof(MetadataProvider.Imdb), "https://www.imdb.com/title/tt0113375", false)]
|
||||
[InlineData(nameof(MetadataProvider.Tmdb), "11", true)]
|
||||
[InlineData(nameof(MetadataProvider.Tmdb), "nm0000123", false)]
|
||||
[InlineData(nameof(MetadataProvider.Tmdb), "0", false)]
|
||||
[InlineData(nameof(MetadataProvider.Tmdb), "-11", false)]
|
||||
[InlineData(nameof(MetadataProvider.TmdbCollection), "nm0000123", false)]
|
||||
[InlineData(nameof(MetadataProvider.AudioDbArtist), "111239", true)]
|
||||
[InlineData(nameof(MetadataProvider.AudioDbArtist), "a3cb23fc-acd3-4ce0-8f36-1e5aa6a18432", false)]
|
||||
[InlineData(nameof(MetadataProvider.MusicBrainzArtist), "a3cb23fc-acd3-4ce0-8f36-1e5aa6a18432", true)]
|
||||
[InlineData(nameof(MetadataProvider.MusicBrainzArtist), "111239", false)]
|
||||
[InlineData(nameof(MetadataProvider.MusicBrainzAlbum), "not-an-mbid", false)]
|
||||
[InlineData(nameof(MetadataProvider.Tvdb), "anything-goes", true)]
|
||||
[InlineData("SomePlugin", "anything-goes", true)]
|
||||
[InlineData(nameof(MetadataProvider.Tmdb), null, false)]
|
||||
[InlineData(null, "11", false)]
|
||||
public void IsValidProviderId_ChecksKnownFormats(string? name, string? value, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, ProviderIdsExtensions.IsValidProviderId(name, value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrySetProviderId_ForeignId_False()
|
||||
{
|
||||
var provider = new ProviderIdsExtensionsTestsObject();
|
||||
|
||||
Assert.False(provider.TrySetProviderId(MetadataProvider.Tmdb, "nm0000123"));
|
||||
Assert.Empty(provider.ProviderIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrySetProviderId_ForeignId_KeepsExisting()
|
||||
{
|
||||
var provider = new ProviderIdsExtensionsTestsObject();
|
||||
provider.ProviderIds[MetadataProvider.Tmdb.ToString()] = "11";
|
||||
|
||||
Assert.False(provider.TrySetProviderId(MetadataProvider.Tmdb, "nm0000123"));
|
||||
Assert.Equal("11", provider.GetProviderId(MetadataProvider.Tmdb));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(nameof(MetadataProvider.Imdb), " tt0113375 ")]
|
||||
[InlineData(" Imdb", ExampleImdbId)]
|
||||
public void TrySetProviderId_SurroundingWhitespace_Trimmed(string name, string value)
|
||||
{
|
||||
var provider = new ProviderIdsExtensionsTestsObject();
|
||||
|
||||
Assert.True(provider.TrySetProviderId(name, value));
|
||||
Assert.Equal(ExampleImdbId, provider.GetProviderId(MetadataProvider.Imdb));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetProviderIds_ReplacesAll()
|
||||
{
|
||||
var provider = new ProviderIdsExtensionsTestsObject();
|
||||
provider.ProviderIds[MetadataProvider.Tvdb.ToString()] = "12345";
|
||||
|
||||
provider.SetProviderIds(new Dictionary<string, string>
|
||||
{
|
||||
[MetadataProvider.Imdb.ToString()] = ExampleImdbId
|
||||
});
|
||||
|
||||
Assert.Equal(ExampleImdbId, provider.GetProviderId(MetadataProvider.Imdb));
|
||||
Assert.False(provider.HasProviderId(MetadataProvider.Tvdb));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetProviderIds_ForeignId_Dropped()
|
||||
{
|
||||
var provider = new ProviderIdsExtensionsTestsObject();
|
||||
|
||||
provider.SetProviderIds(new Dictionary<string, string>
|
||||
{
|
||||
[MetadataProvider.Tmdb.ToString()] = "nm0000123",
|
||||
[MetadataProvider.Imdb.ToString()] = ExampleImdbId,
|
||||
[MetadataProvider.Tvdb.ToString()] = string.Empty
|
||||
});
|
||||
|
||||
Assert.False(provider.HasProviderId(MetadataProvider.Tmdb));
|
||||
Assert.False(provider.HasProviderId(MetadataProvider.Tvdb));
|
||||
Assert.Equal(ExampleImdbId, provider.GetProviderId(MetadataProvider.Imdb));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetProviderIds_Null_Clears()
|
||||
{
|
||||
var provider = new ProviderIdsExtensionsTestsObject();
|
||||
provider.ProviderIds[MetadataProvider.Imdb.ToString()] = ExampleImdbId;
|
||||
|
||||
provider.SetProviderIds(null);
|
||||
|
||||
Assert.Empty(provider.ProviderIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetProviderIds_NullInstance_ThrowsArgumentNullException()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => ProviderIdsExtensions.SetProviderIds(null!, new Dictionary<string, string>()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveProviderId_Null_Remove()
|
||||
{
|
||||
|
||||
@@ -23,6 +23,7 @@ namespace Jellyfin.Naming.Tests.Video
|
||||
[InlineData("Crouching.Tiger.Hidden.Dragon.BDrip.mkv", "Crouching.Tiger.Hidden.Dragon")]
|
||||
[InlineData("Crouching.Tiger.Hidden.Dragon.BDrip-HDC.mkv", "Crouching.Tiger.Hidden.Dragon")]
|
||||
[InlineData("Crouching.Tiger.Hidden.Dragon.4K.UltraHD.HDR.BDrip-HDC.mkv", "Crouching.Tiger.Hidden.Dragon")]
|
||||
[InlineData("Last.Call.for.Nowhere.WEB-DL.1080p", "Last.Call.for.Nowhere")]
|
||||
[InlineData("[HorribleSubs] Made in Abyss - 13 [720p].mkv", "Made in Abyss")]
|
||||
[InlineData("[Tsundere] Kore wa Zombie Desu ka of the Dead [BDRip h264 1920x1080 FLAC]", "Kore wa Zombie Desu ka of the Dead")]
|
||||
[InlineData("[Erai-raws] Jujutsu Kaisen - 03 [720p][Multiple Subtitle].mkv", "Jujutsu Kaisen")]
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Enums;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.Movies;
|
||||
using MediaBrowser.Controller.IO;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Persistence;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.IO;
|
||||
using MediaBrowser.Providers.Manager;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Providers.Tests.Manager
|
||||
{
|
||||
public class MetadataServiceRefreshTests
|
||||
{
|
||||
[Theory]
|
||||
// RemoveOldMetadata is only ever set by an explicit user action - a refresh with "replace all
|
||||
// metadata", or Identify. A provider failing must not silently downgrade that to a merge: the
|
||||
// providers that did answer supplied the replacement, and the old values are the wrong match
|
||||
// the user asked to get rid of.
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task RefreshWithProviders_ReplaceAllMetadata_ErasesOldDataWhenAProviderAnswers(bool allProvidersSucceed)
|
||||
{
|
||||
var item = new Movie
|
||||
{
|
||||
Name = "Test Movie",
|
||||
Overview = "existing overview"
|
||||
};
|
||||
|
||||
// The provider owning the overview fails, so it contributes nothing to the replacement.
|
||||
var failing = new Mock<IRemoteMetadataProvider<Movie, MovieInfo>>(MockBehavior.Loose);
|
||||
failing.Setup(p => p.Name).Returns("Failing");
|
||||
failing.Setup(p => p.GetMetadata(It.IsAny<MovieInfo>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(allProvidersSucceed
|
||||
? Task.FromResult(new MetadataResult<Movie> { HasMetadata = true, Item = new Movie() })
|
||||
: Task.FromException<MetadataResult<Movie>>(new FormatException("bad id")));
|
||||
|
||||
var succeeding = new Mock<IRemoteMetadataProvider<Movie, MovieInfo>>(MockBehavior.Loose);
|
||||
succeeding.Setup(p => p.Name).Returns("Succeeding");
|
||||
succeeding.Setup(p => p.GetMetadata(It.IsAny<MovieInfo>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new MetadataResult<Movie>
|
||||
{
|
||||
HasMetadata = true,
|
||||
Item = new Movie { Name = "Test Movie", Tagline = "new tagline" }
|
||||
});
|
||||
|
||||
var service = new TestMetadataService();
|
||||
var result = await service.RefreshWithProvidersInternal(
|
||||
new MetadataResult<Movie> { Item = item },
|
||||
new MovieInfo { Name = item.Name },
|
||||
new MetadataRefreshOptions(Mock.Of<IDirectoryService>())
|
||||
{
|
||||
MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
|
||||
ReplaceAllMetadata = true,
|
||||
RemoveOldMetadata = true
|
||||
},
|
||||
[failing.Object, succeeding.Object]).ConfigureAwait(true);
|
||||
|
||||
Assert.Equal(allProvidersSucceed ? 0 : 1, result.Failures);
|
||||
Assert.Equal("new tagline", item.Tagline);
|
||||
Assert.Null(item.Overview);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshWithProviders_ReplaceAllMetadata_KeepsExistingDataWhenEveryRemoteProviderFails()
|
||||
{
|
||||
var item = new Movie
|
||||
{
|
||||
Name = "Test Movie",
|
||||
Overview = "existing overview"
|
||||
};
|
||||
|
||||
// Something has to contribute for the merge to run at all, otherwise the item is never touched
|
||||
// and the case is moot. The local provider is the replacement the remote ones did not deliver.
|
||||
var local = new Mock<ILocalMetadataProvider<Movie>>(MockBehavior.Loose);
|
||||
local.Setup(p => p.Name).Returns("Local");
|
||||
local.Setup(p => p.GetMetadata(It.IsAny<ItemInfo>(), It.IsAny<IDirectoryService>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new MetadataResult<Movie>
|
||||
{
|
||||
HasMetadata = true,
|
||||
Item = new Movie { Name = "Test Movie", Tagline = "new tagline" }
|
||||
});
|
||||
|
||||
var remote = new Mock<IRemoteMetadataProvider<Movie, MovieInfo>>(MockBehavior.Loose);
|
||||
remote.Setup(p => p.Name).Returns("Failing");
|
||||
remote.Setup(p => p.GetMetadata(It.IsAny<MovieInfo>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.FromException<MetadataResult<Movie>>(new HttpRequestException("unreachable")));
|
||||
|
||||
var service = new TestMetadataService();
|
||||
var result = await service.RefreshWithProvidersInternal(
|
||||
new MetadataResult<Movie> { Item = item },
|
||||
new MovieInfo { Name = item.Name },
|
||||
new MetadataRefreshOptions(Mock.Of<IDirectoryService>())
|
||||
{
|
||||
MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
|
||||
ReplaceAllMetadata = true,
|
||||
RemoveOldMetadata = true
|
||||
},
|
||||
[local.Object, remote.Object]).ConfigureAwait(true);
|
||||
|
||||
Assert.Equal(1, result.Failures);
|
||||
Assert.Equal("new tagline", item.Tagline);
|
||||
|
||||
// No remote provider answered, so erasing the overview would lose it for good.
|
||||
Assert.Equal("existing overview", item.Overview);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshWithProviders_ForeignProviderId_NotStored()
|
||||
{
|
||||
var item = new Movie { Name = "Test Movie" };
|
||||
|
||||
var provider = new Mock<IRemoteMetadataProvider<Movie, MovieInfo>>(MockBehavior.Loose);
|
||||
provider.Setup(p => p.Name).Returns("Provider");
|
||||
provider.Setup(p => p.GetMetadata(It.IsAny<MovieInfo>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(() =>
|
||||
{
|
||||
var found = new Movie { Name = "Test Movie" };
|
||||
found.ProviderIds[MetadataProvider.Tmdb.ToString()] = "nm0000123";
|
||||
found.ProviderIds[MetadataProvider.Imdb.ToString()] = "tt0113375";
|
||||
return new MetadataResult<Movie> { HasMetadata = true, Item = found };
|
||||
});
|
||||
|
||||
var service = new TestMetadataService();
|
||||
await service.RefreshWithProvidersInternal(
|
||||
new MetadataResult<Movie> { Item = item },
|
||||
new MovieInfo { Name = item.Name },
|
||||
new MetadataRefreshOptions(Mock.Of<IDirectoryService>())
|
||||
{
|
||||
MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
|
||||
ReplaceAllMetadata = true
|
||||
},
|
||||
[provider.Object]).ConfigureAwait(true);
|
||||
|
||||
Assert.False(item.HasProviderId(MetadataProvider.Tmdb));
|
||||
Assert.Equal("tt0113375", item.GetProviderId(MetadataProvider.Imdb));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshWithProviders_ForeignProviderId_ReplacedInLookupInfo()
|
||||
{
|
||||
var item = new Movie { Name = "Test Movie" };
|
||||
var lookupInfo = new MovieInfo { Name = item.Name };
|
||||
lookupInfo.ProviderIds[MetadataProvider.Tmdb.ToString()] = "nm0000123";
|
||||
|
||||
var answering = new Mock<IRemoteMetadataProvider<Movie, MovieInfo>>(MockBehavior.Loose);
|
||||
answering.Setup(p => p.Name).Returns("Answering");
|
||||
answering.Setup(p => p.GetMetadata(It.IsAny<MovieInfo>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(() =>
|
||||
{
|
||||
var found = new Movie { Name = "Test Movie" };
|
||||
found.ProviderIds[MetadataProvider.Tmdb.ToString()] = "12345";
|
||||
return new MetadataResult<Movie> { HasMetadata = true, Item = found };
|
||||
});
|
||||
|
||||
string? tmdbIdSeenBySecondProvider = null;
|
||||
var following = new Mock<IRemoteMetadataProvider<Movie, MovieInfo>>(MockBehavior.Loose);
|
||||
following.Setup(p => p.Name).Returns("Following");
|
||||
following.Setup(p => p.GetMetadata(It.IsAny<MovieInfo>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((MovieInfo info, CancellationToken _) =>
|
||||
{
|
||||
tmdbIdSeenBySecondProvider = info.GetProviderId(MetadataProvider.Tmdb);
|
||||
return new MetadataResult<Movie> { HasMetadata = false };
|
||||
});
|
||||
|
||||
var service = new TestMetadataService();
|
||||
await service.RefreshWithProvidersInternal(
|
||||
new MetadataResult<Movie> { Item = item },
|
||||
lookupInfo,
|
||||
new MetadataRefreshOptions(Mock.Of<IDirectoryService>())
|
||||
{
|
||||
MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
|
||||
ReplaceAllMetadata = true
|
||||
},
|
||||
[answering.Object, following.Object]).ConfigureAwait(true);
|
||||
|
||||
// The stored id cannot be a TMDb one, so the provider that still has to run must get the id
|
||||
// that was just found instead of failing on the same bad one.
|
||||
Assert.Equal("12345", tmdbIdSeenBySecondProvider);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task RefreshWithProviders_ForeignPersonProviderId_NotStored(bool replaceAllMetadata)
|
||||
{
|
||||
var item = new Movie { Name = "Test Movie" };
|
||||
var existing = new MetadataResult<Movie> { Item = item };
|
||||
existing.AddPerson(new PersonInfo { Name = "Some Actor", Type = PersonKind.Actor });
|
||||
|
||||
var provider = new Mock<IRemoteMetadataProvider<Movie, MovieInfo>>(MockBehavior.Loose);
|
||||
provider.Setup(p => p.Name).Returns("Provider");
|
||||
provider.Setup(p => p.GetMetadata(It.IsAny<MovieInfo>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(() =>
|
||||
{
|
||||
var person = new PersonInfo { Name = "Some Actor", Type = PersonKind.Actor };
|
||||
person.ProviderIds[MetadataProvider.Tmdb.ToString()] = "nm0000123";
|
||||
person.ProviderIds[MetadataProvider.Imdb.ToString()] = "nm0000123";
|
||||
|
||||
var found = new MetadataResult<Movie> { HasMetadata = true, Item = new Movie { Name = "Test Movie" } };
|
||||
found.AddPerson(person);
|
||||
return found;
|
||||
});
|
||||
|
||||
var service = new TestMetadataService();
|
||||
await service.RefreshWithProvidersInternal(
|
||||
existing,
|
||||
new MovieInfo { Name = item.Name },
|
||||
new MetadataRefreshOptions(Mock.Of<IDirectoryService>())
|
||||
{
|
||||
MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
|
||||
ReplaceAllMetadata = replaceAllMetadata
|
||||
},
|
||||
[provider.Object]).ConfigureAwait(true);
|
||||
|
||||
var mergedPerson = Assert.Single(existing.People);
|
||||
Assert.False(mergedPerson.HasProviderId(MetadataProvider.Tmdb));
|
||||
Assert.Equal("nm0000123", mergedPerson.GetProviderId(MetadataProvider.Imdb));
|
||||
}
|
||||
|
||||
private sealed class TestMetadataService : MetadataService<Movie, MovieInfo>
|
||||
{
|
||||
public TestMetadataService()
|
||||
: base(
|
||||
Mock.Of<IServerConfigurationManager>(),
|
||||
NullLogger<MetadataService<Movie, MovieInfo>>.Instance,
|
||||
Mock.Of<IProviderManager>(),
|
||||
Mock.Of<IFileSystem>(),
|
||||
Mock.Of<ILibraryManager>(),
|
||||
Mock.Of<IExternalDataManager>(),
|
||||
Mock.Of<IItemRepository>())
|
||||
{
|
||||
}
|
||||
|
||||
public Task<RefreshResult> RefreshWithProvidersInternal(
|
||||
MetadataResult<Movie> metadata,
|
||||
MovieInfo id,
|
||||
MetadataRefreshOptions options,
|
||||
ICollection<IMetadataProvider> providers)
|
||||
=> RefreshWithProviders(metadata, id, options, providers, ImageProvider, false, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Providers.Music;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Providers.Tests.Music;
|
||||
|
||||
public static class AlbumInfoExtensionsTests
|
||||
{
|
||||
private const string ExampleMbid = "59b5a40b-e2fd-3f18-a218-e8c9aae12ab5";
|
||||
private const string SongMbid = "6c301dbd-6ccb-3403-a6c4-6a22240a0297";
|
||||
|
||||
[Theory]
|
||||
[InlineData(ExampleMbid, ExampleMbid)]
|
||||
// Another provider's id under a MusicBrainz key reads as no id, so the caller searches instead of
|
||||
// handing a value the MusicBrainz client throws on.
|
||||
[InlineData("111239", null)]
|
||||
[InlineData("", null)]
|
||||
public static void GetReleaseId_OnlyReturnsMbids(string id, string? expected)
|
||||
{
|
||||
var info = new AlbumInfo();
|
||||
info.ProviderIds[MetadataProvider.MusicBrainzAlbum.ToString()] = id;
|
||||
|
||||
Assert.Equal(expected, info.GetReleaseId());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public static void GetReleaseId_ForeignId_FallsBackToSongs()
|
||||
{
|
||||
var song = new SongInfo();
|
||||
song.ProviderIds[MetadataProvider.MusicBrainzAlbum.ToString()] = SongMbid;
|
||||
|
||||
var info = new AlbumInfo { SongInfos = [song] };
|
||||
info.ProviderIds[MetadataProvider.MusicBrainzAlbum.ToString()] = "111239";
|
||||
|
||||
Assert.Equal(SongMbid, info.GetReleaseId());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public static void GetMusicBrainzArtistId_ForeignId_FallsBackToArtistIds()
|
||||
{
|
||||
var info = new AlbumInfo();
|
||||
info.ProviderIds[MetadataProvider.MusicBrainzAlbumArtist.ToString()] = "111239";
|
||||
info.ArtistProviderIds[MetadataProvider.MusicBrainzArtist.ToString()] = ExampleMbid;
|
||||
|
||||
Assert.Equal(ExampleMbid, info.GetMusicBrainzArtistId());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ExampleMbid, ExampleMbid)]
|
||||
[InlineData("111239", null)]
|
||||
public static void GetMusicBrainzArtistId_ArtistInfo_OnlyReturnsMbids(string id, string? expected)
|
||||
{
|
||||
var info = new ArtistInfo();
|
||||
info.ProviderIds[MetadataProvider.MusicBrainzArtist.ToString()] = id;
|
||||
|
||||
Assert.Equal(expected, info.GetMusicBrainzArtistId());
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using MediaBrowser.Controller.Entities.Movies;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Providers.Plugins.Tmdb;
|
||||
using Xunit;
|
||||
|
||||
@@ -34,5 +36,40 @@ namespace Jellyfin.Providers.Tests.Tmdb
|
||||
{
|
||||
Assert.Equal(expected, TmdbUtils.AdjustImageLanguage(imageLanguage, requestLanguage));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("11", true, 11)]
|
||||
// An id another provider filed under the TMDb key must not throw, it is simply not a TMDb id.
|
||||
[InlineData("nm0000123", false, 0)]
|
||||
[InlineData("tt0113375", false, 0)]
|
||||
[InlineData("11.0", false, 0)]
|
||||
[InlineData("-11", false, 0)]
|
||||
[InlineData("0", false, 0)]
|
||||
[InlineData("", false, 0)]
|
||||
[InlineData(null, false, 0)]
|
||||
public static void TryParseTmdbId_OnlyAcceptsTmdbIds(string? value, bool expected, int expectedId)
|
||||
{
|
||||
Assert.Equal(expected, TmdbUtils.TryParseTmdbId(value, out var tmdbId));
|
||||
Assert.Equal(expectedId, tmdbId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("11", true, 11)]
|
||||
[InlineData("nm0000123", false, 0)]
|
||||
public static void TryGetTmdbId_OnlyAcceptsTmdbIds(string value, bool expected, int expectedId)
|
||||
{
|
||||
var item = new Movie();
|
||||
item.ProviderIds[MetadataProvider.Tmdb.ToString()] = value;
|
||||
|
||||
Assert.Equal(expected, item.TryGetTmdbId(out var tmdbId));
|
||||
Assert.Equal(expectedId, tmdbId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public static void TryGetTmdbId_NoId_False()
|
||||
{
|
||||
Assert.False(new Movie().TryGetTmdbId(out var tmdbId));
|
||||
Assert.Equal(0, tmdbId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-32
@@ -4,11 +4,6 @@ using System;
|
||||
using System.Linq;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Database.Implementations.Locking;
|
||||
using Jellyfin.Database.Providers.Sqlite;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Tests.Item;
|
||||
@@ -18,22 +13,10 @@ namespace Jellyfin.Server.Implementations.Tests.Item;
|
||||
/// (BaseItemRepository.TranslateQuery) and the DatePlayed ordering (OrderMapper) translate
|
||||
/// and evaluate correctly on the SQLite provider.
|
||||
/// </summary>
|
||||
public sealed class AlternateVersionQueryTranslationTests : IDisposable
|
||||
public sealed class AlternateVersionQueryTranslationTests : SqliteDbTestFixture
|
||||
{
|
||||
private readonly SqliteConnection _connection;
|
||||
private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
|
||||
|
||||
public AlternateVersionQueryTranslationTests()
|
||||
{
|
||||
_connection = new SqliteConnection("Data Source=:memory:");
|
||||
_connection.Open();
|
||||
|
||||
_dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
|
||||
.UseSqlite(_connection)
|
||||
.Options;
|
||||
|
||||
using var ctx = CreateDbContext();
|
||||
ctx.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -220,18 +203,4 @@ public sealed class AlternateVersionQueryTranslationTests : IDisposable
|
||||
ctx.SaveChanges();
|
||||
return (user.Id, primary.Id, versionA.Id, versionB.Id);
|
||||
}
|
||||
|
||||
private JellyfinDbContext CreateDbContext()
|
||||
{
|
||||
return new JellyfinDbContext(
|
||||
_dbOptions,
|
||||
NullLogger<JellyfinDbContext>.Instance,
|
||||
new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
|
||||
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_connection.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
+2
-50
@@ -3,17 +3,8 @@ using System.Linq;
|
||||
using Emby.Server.Implementations.Data;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Database.Implementations.Locking;
|
||||
using Jellyfin.Database.Providers.Sqlite;
|
||||
using Jellyfin.Server.Implementations.Item;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Model.Configuration;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind;
|
||||
|
||||
@@ -24,46 +15,16 @@ namespace Jellyfin.Server.Implementations.Tests.Item;
|
||||
/// <c>GetItemValues</c>. A query without a <c>Limit</c> used to have its total record count
|
||||
/// silently disabled, so callers got a populated <c>Items</c> array next to a zero total.
|
||||
/// </summary>
|
||||
public sealed class BaseItemRepositoryByNameTotalCountTests : IDisposable
|
||||
public sealed class BaseItemRepositoryByNameTotalCountTests : SqliteDbTestFixture
|
||||
{
|
||||
private readonly SqliteConnection _connection;
|
||||
private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
|
||||
private readonly BaseItemRepository _repository;
|
||||
private readonly ItemTypeLookup _itemTypeLookup;
|
||||
|
||||
public BaseItemRepositoryByNameTotalCountTests()
|
||||
{
|
||||
_connection = new SqliteConnection("Data Source=:memory:");
|
||||
_connection.Open();
|
||||
|
||||
_dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
|
||||
.UseSqlite(_connection)
|
||||
.Options;
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
ctx.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
|
||||
factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
|
||||
|
||||
_itemTypeLookup = new ItemTypeLookup();
|
||||
|
||||
var serverConfigurationManager = new Mock<IServerConfigurationManager>();
|
||||
serverConfigurationManager.Setup(c => c.Configuration).Returns(new ServerConfiguration());
|
||||
|
||||
_repository = new BaseItemRepository(
|
||||
factory.Object,
|
||||
new Mock<IServerApplicationHost>().Object,
|
||||
_itemTypeLookup,
|
||||
serverConfigurationManager.Object,
|
||||
NullLogger<BaseItemRepository>.Instance);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_connection.Dispose();
|
||||
_repository = CreateBaseItemRepository(_itemTypeLookup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -187,13 +148,4 @@ public sealed class BaseItemRepositoryByNameTotalCountTests : IDisposable
|
||||
|
||||
ctx.SaveChanges();
|
||||
}
|
||||
|
||||
private JellyfinDbContext CreateDbContext()
|
||||
{
|
||||
return new JellyfinDbContext(
|
||||
_dbOptions,
|
||||
NullLogger<JellyfinDbContext>.Instance,
|
||||
new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
|
||||
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
|
||||
}
|
||||
}
|
||||
|
||||
+2
-50
@@ -3,64 +3,25 @@ using System.Linq;
|
||||
using Emby.Server.Implementations.Data;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Database.Implementations.Locking;
|
||||
using Jellyfin.Database.Providers.Sqlite;
|
||||
using Jellyfin.Server.Implementations.Item;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Model.Configuration;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Tests.Item;
|
||||
|
||||
public sealed class BaseItemRepositoryGroupingTests : IDisposable
|
||||
public sealed class BaseItemRepositoryGroupingTests : SqliteDbTestFixture
|
||||
{
|
||||
private readonly SqliteConnection _connection;
|
||||
private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
|
||||
private readonly BaseItemRepository _repository;
|
||||
private readonly string _movieTypeName;
|
||||
|
||||
public BaseItemRepositoryGroupingTests()
|
||||
{
|
||||
_connection = new SqliteConnection("Data Source=:memory:");
|
||||
_connection.Open();
|
||||
|
||||
_dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
|
||||
.UseSqlite(_connection)
|
||||
.Options;
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
ctx.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
|
||||
factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
|
||||
|
||||
var itemTypeLookup = new ItemTypeLookup();
|
||||
_movieTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie];
|
||||
|
||||
var serverConfigurationManager = new Mock<IServerConfigurationManager>();
|
||||
serverConfigurationManager.Setup(c => c.Configuration).Returns(new ServerConfiguration());
|
||||
|
||||
_repository = new BaseItemRepository(
|
||||
factory.Object,
|
||||
new Mock<IServerApplicationHost>().Object,
|
||||
itemTypeLookup,
|
||||
serverConfigurationManager.Object,
|
||||
NullLogger<BaseItemRepository>.Instance);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_connection.Dispose();
|
||||
_repository = CreateBaseItemRepository(itemTypeLookup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -132,13 +93,4 @@ public sealed class BaseItemRepositoryGroupingTests : IDisposable
|
||||
IsVirtualItem = false
|
||||
};
|
||||
}
|
||||
|
||||
private JellyfinDbContext CreateDbContext()
|
||||
{
|
||||
return new JellyfinDbContext(
|
||||
_dbOptions,
|
||||
NullLogger<JellyfinDbContext>.Instance,
|
||||
new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
|
||||
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
|
||||
}
|
||||
}
|
||||
|
||||
+553
@@ -0,0 +1,553 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Emby.Server.Implementations.Data;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Server.Implementations.Item;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using Xunit;
|
||||
using LinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Tests.Item;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the filters resolving "folders with a matching descendant" through
|
||||
/// <see cref="DescendantQueryHelper.GetFolderIdsMatching"/>, positive and negated.
|
||||
/// </summary>
|
||||
public sealed class BaseItemRepositoryStreamFilterTests : SqliteDbTestFixture
|
||||
{
|
||||
private const string FolderType = "MediaBrowser.Controller.Entities.Folder";
|
||||
private const string BoxSetType = "MediaBrowser.Controller.Entities.Movies.BoxSet";
|
||||
private const string MovieType = "MediaBrowser.Controller.Entities.Movies.Movie";
|
||||
|
||||
private readonly BaseItemRepository _repository;
|
||||
|
||||
private readonly Guid _library = Guid.NewGuid();
|
||||
private readonly Guid _withSubtitles = Guid.NewGuid();
|
||||
private readonly Guid _withoutSubtitles = Guid.NewGuid();
|
||||
private readonly Guid _collection = Guid.NewGuid();
|
||||
private readonly Guid _linkedSeries = Guid.NewGuid();
|
||||
private readonly Guid _linkedEpisode = Guid.NewGuid();
|
||||
|
||||
// A version group in a library of its own, so it cannot move the assertions above: an SD primary
|
||||
// that carries nothing, and a 4K second file carrying the subtitles, chapter image and audio.
|
||||
private readonly Guid _versionLibrary = Guid.NewGuid();
|
||||
private readonly Guid _versionedMovie = Guid.NewGuid();
|
||||
private readonly Guid _alternateVersion = Guid.NewGuid();
|
||||
|
||||
// A series in the same library, so the folder branch of the resolution filter has a version group
|
||||
// to reach through as well: an SD episode whose second file is 4K.
|
||||
private readonly Guid _versionedSeries = Guid.NewGuid();
|
||||
private readonly Guid _versionedEpisode = Guid.NewGuid();
|
||||
private readonly Guid _episodeAlternate = Guid.NewGuid();
|
||||
|
||||
// An unprobed primary: only its second file carries dimensions, and they are SD.
|
||||
private readonly Guid _unprobedMovie = Guid.NewGuid();
|
||||
private readonly Guid _unprobedAlternate = Guid.NewGuid();
|
||||
|
||||
// A plain SD movie with no second file, as the control the version groups are read against.
|
||||
private readonly Guid _sdMovie = Guid.NewGuid();
|
||||
|
||||
// An unprobed primary whose only second file is HD, so the HD bucket has to place it off nulls.
|
||||
private readonly Guid _hdOnlyByVersion = Guid.NewGuid();
|
||||
private readonly Guid _hdOnlyAlternate = Guid.NewGuid();
|
||||
|
||||
// Three files for one movie: the HD one would place it in the HD bucket on its own, the 4K one has
|
||||
// to win. Only a group holding both can tell the HD bucket's upper guard from its lower one.
|
||||
private readonly Guid _threeWayMovie = Guid.NewGuid();
|
||||
private readonly Guid _threeWayHd = Guid.NewGuid();
|
||||
private readonly Guid _threeWay4K = Guid.NewGuid();
|
||||
|
||||
public BaseItemRepositoryStreamFilterTests()
|
||||
{
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
Seed(ctx);
|
||||
}
|
||||
|
||||
_repository = CreateBaseItemRepository(new ItemTypeLookup());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasSubtitles_MatchesTheItemAndItsParentFolder()
|
||||
{
|
||||
var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasSubtitles = true });
|
||||
|
||||
Assert.Contains(_withSubtitles, ids);
|
||||
// The library is a folder, and it has a descendant with subtitles.
|
||||
Assert.Contains(_library, ids);
|
||||
Assert.DoesNotContain(_withoutSubtitles, ids);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasSubtitles_Negated_ExcludesTheItemAndItsParentFolder()
|
||||
{
|
||||
var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasSubtitles = false });
|
||||
|
||||
Assert.Contains(_withoutSubtitles, ids);
|
||||
Assert.DoesNotContain(_withSubtitles, ids);
|
||||
Assert.DoesNotContain(_library, ids);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubtitleLanguages_MatchesTheRequestedLanguageOnly()
|
||||
{
|
||||
Assert.Contains(_withSubtitles, _repository.GetItemIdsList(new InternalItemsQuery { SubtitleLanguages = ["ger"] }));
|
||||
Assert.DoesNotContain(_withSubtitles, _repository.GetItemIdsList(new InternalItemsQuery { SubtitleLanguages = ["fre"] }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasNoSubtitleTrackWithLanguage_ExcludesTheMatchingItemAndFolder()
|
||||
{
|
||||
var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasNoSubtitleTrackWithLanguage = "ger" });
|
||||
|
||||
Assert.Contains(_withoutSubtitles, ids);
|
||||
Assert.DoesNotContain(_withSubtitles, ids);
|
||||
Assert.DoesNotContain(_library, ids);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasSubtitles_MatchesACollectionLinkingAFolder()
|
||||
{
|
||||
var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasSubtitles = true });
|
||||
|
||||
Assert.Contains(_linkedSeries, ids);
|
||||
Assert.Contains(_collection, ids);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasSubtitles_Negated_ExcludesACollectionLinkingAFolder()
|
||||
{
|
||||
var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasSubtitles = false });
|
||||
|
||||
Assert.DoesNotContain(_linkedSeries, ids);
|
||||
Assert.DoesNotContain(_collection, ids);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasChapterImages_MatchesTheItemAndItsParentFolder()
|
||||
{
|
||||
var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasChapterImages = true });
|
||||
|
||||
Assert.Contains(_withSubtitles, ids);
|
||||
Assert.Contains(_library, ids);
|
||||
Assert.DoesNotContain(_withoutSubtitles, ids);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasSubtitles_MatchesAnItemWhoseAlternateVersionCarriesThem()
|
||||
{
|
||||
var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasSubtitles = true });
|
||||
|
||||
Assert.Contains(_versionedMovie, ids);
|
||||
Assert.Contains(_versionLibrary, ids);
|
||||
// The second file is never listed on its own, which is why its tracks have to count for the primary.
|
||||
Assert.DoesNotContain(_alternateVersion, ids);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasSubtitles_Negated_ExcludesAnItemWhoseAlternateVersionCarriesThem()
|
||||
{
|
||||
var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasSubtitles = false });
|
||||
|
||||
Assert.DoesNotContain(_versionedMovie, ids);
|
||||
Assert.DoesNotContain(_versionLibrary, ids);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubtitleLanguages_MatchesTheLanguageOnAnAlternateVersion()
|
||||
{
|
||||
Assert.Contains(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { SubtitleLanguages = ["ger"] }));
|
||||
Assert.DoesNotContain(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { SubtitleLanguages = ["fre"] }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasNoSubtitleTrackWithLanguage_ExcludesAnItemWhoseAlternateVersionHasIt()
|
||||
{
|
||||
var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasNoSubtitleTrackWithLanguage = "ger" });
|
||||
|
||||
Assert.DoesNotContain(_versionedMovie, ids);
|
||||
Assert.DoesNotContain(_versionLibrary, ids);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AudioLanguages_MatchesTheLanguageOnAnAlternateVersion()
|
||||
{
|
||||
Assert.Contains(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { AudioLanguages = ["fre"] }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasNoAudioTrackWithLanguage_ExcludesAnItemWhoseAlternateVersionHasIt()
|
||||
{
|
||||
Assert.DoesNotContain(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { HasNoAudioTrackWithLanguage = "fre" }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasChapterImages_MatchesAnItemWhoseAlternateVersionCarriesThem()
|
||||
{
|
||||
Assert.Contains(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { HasChapterImages = true }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Is4K_MatchesAnItemWhoseAlternateVersionIs4K()
|
||||
{
|
||||
// The primary file is SD; the resolution a caller can actually play is the 4K second file's.
|
||||
Assert.Contains(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { Is4K = true }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MinWidth_MatchesAnItemWhoseAlternateVersionIsWideEnough()
|
||||
{
|
||||
Assert.Contains(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { MinWidth = 3000 }));
|
||||
Assert.DoesNotContain(_withSubtitles, _repository.GetItemIdsList(new InternalItemsQuery { MinWidth = 3000 }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaxWidth_ExcludesAnItemWhoseAlternateVersionBreachesTheBound()
|
||||
{
|
||||
// The SD primary is narrow enough on its own, but the 4K second file is what a caller would play.
|
||||
Assert.DoesNotContain(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { MaxWidth = 1920 }));
|
||||
Assert.Contains(_sdMovie, _repository.GetItemIdsList(new InternalItemsQuery { MaxWidth = 1920 }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaxHeight_ExcludesAnItemWhoseAlternateVersionBreachesTheBound()
|
||||
{
|
||||
Assert.DoesNotContain(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { MaxHeight = 1080 }));
|
||||
Assert.Contains(_sdMovie, _repository.GetItemIdsList(new InternalItemsQuery { MaxHeight = 1080 }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsHD_False_ExcludesAnSdPrimaryWhoseAlternateVersionIsBetter()
|
||||
{
|
||||
var ids = _repository.GetItemIdsList(new InternalItemsQuery { IsHD = false });
|
||||
|
||||
// 720x480 on its own, but the version group tops out at 4K.
|
||||
Assert.DoesNotContain(_versionedMovie, ids);
|
||||
Assert.Contains(_sdMovie, ids);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsHD_False_MatchesAPrimaryPlacedOnlyByItsAlternateVersion()
|
||||
{
|
||||
// The primary carries no dimensions at all; the SD second file is the group's best.
|
||||
Assert.Contains(_unprobedMovie, _repository.GetItemIdsList(new InternalItemsQuery { IsHD = false }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsHD_True_ExcludesAnItemWhoseVersionGroupReaches4K()
|
||||
{
|
||||
var ids = _repository.GetItemIdsList(new InternalItemsQuery { IsHD = true });
|
||||
|
||||
Assert.DoesNotContain(_versionedMovie, ids);
|
||||
Assert.DoesNotContain(_unprobedMovie, ids);
|
||||
// The 1920-wide second file alone would say HD; the 4K third file is the group's best.
|
||||
Assert.DoesNotContain(_threeWayMovie, ids);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Is4K_MatchesAnItemWhoseVersionGroupHoldsBothHdAnd4K()
|
||||
{
|
||||
Assert.Contains(_threeWayMovie, _repository.GetItemIdsList(new InternalItemsQuery { Is4K = true }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsHD_True_MatchesAPrimaryPlacedOnlyByItsAlternateVersion()
|
||||
{
|
||||
// The primary carries no dimensions of its own; the HD second file is the group's best.
|
||||
Assert.Contains(_hdOnlyByVersion, _repository.GetItemIdsList(new InternalItemsQuery { IsHD = true }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Is4K_MatchesTheSeriesOfAnEpisodeWhoseAlternateVersionIs4K()
|
||||
{
|
||||
// The folder branch buckets a descendant the same way the item branch buckets a top-level item.
|
||||
Assert.Contains(_versionedSeries, _repository.GetItemIdsList(new InternalItemsQuery { Is4K = true }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsHD_False_ExcludesTheSeriesOfAnSdEpisodeWithABetterAlternateVersion()
|
||||
{
|
||||
// Before the version group was consulted on descendants too, the SD episode alone matched here
|
||||
// while the same pair at top level did not.
|
||||
Assert.DoesNotContain(_versionedSeries, _repository.GetItemIdsList(new InternalItemsQuery { IsHD = false }));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("und")]
|
||||
[InlineData("UND")]
|
||||
public void HasNoAudioTrackWithLanguage_TreatsUndeterminedCaseInsensitively(string language)
|
||||
{
|
||||
var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasNoAudioTrackWithLanguage = language });
|
||||
|
||||
// The alternate version carries an audio track with no language, which is what "und" stands for,
|
||||
// so the item it is reported against does have one.
|
||||
Assert.DoesNotContain(_unprobedMovie, ids);
|
||||
Assert.Contains(_versionedMovie, ids);
|
||||
}
|
||||
|
||||
private void Seed(JellyfinDbContext context)
|
||||
{
|
||||
context.BaseItems.Add(new BaseItemEntity { Id = _library, Type = FolderType, Name = "Library", IsFolder = true });
|
||||
context.BaseItems.Add(new BaseItemEntity { Id = _withSubtitles, Type = MovieType, Name = "With subtitles" });
|
||||
context.BaseItems.Add(new BaseItemEntity { Id = _withoutSubtitles, Type = MovieType, Name = "Without subtitles" });
|
||||
|
||||
foreach (var itemId in new[] { _withSubtitles, _withoutSubtitles })
|
||||
{
|
||||
context.AncestorIds.Add(new AncestorId
|
||||
{
|
||||
ItemId = itemId,
|
||||
ParentItemId = _library,
|
||||
Item = null!,
|
||||
ParentItem = null!
|
||||
});
|
||||
|
||||
context.MediaStreamInfos.Add(new MediaStreamInfo
|
||||
{
|
||||
ItemId = itemId,
|
||||
StreamIndex = 0,
|
||||
StreamType = MediaStreamTypeEntity.Video,
|
||||
Item = null!
|
||||
});
|
||||
}
|
||||
|
||||
context.MediaStreamInfos.Add(new MediaStreamInfo
|
||||
{
|
||||
ItemId = _withSubtitles,
|
||||
StreamIndex = 1,
|
||||
StreamType = MediaStreamTypeEntity.Subtitle,
|
||||
Language = "ger",
|
||||
Item = null!
|
||||
});
|
||||
|
||||
// A collection linking a folder: the match is two edges away, one link then one closure hop.
|
||||
context.BaseItems.Add(new BaseItemEntity { Id = _collection, Type = BoxSetType, Name = "Collection", IsFolder = true });
|
||||
context.BaseItems.Add(new BaseItemEntity { Id = _linkedSeries, Type = FolderType, Name = "Linked series", IsFolder = true });
|
||||
context.BaseItems.Add(new BaseItemEntity { Id = _linkedEpisode, Type = MovieType, Name = "Linked episode" });
|
||||
|
||||
context.AncestorIds.Add(new AncestorId
|
||||
{
|
||||
ItemId = _linkedEpisode,
|
||||
ParentItemId = _linkedSeries,
|
||||
Item = null!,
|
||||
ParentItem = null!
|
||||
});
|
||||
|
||||
context.LinkedChildren.Add(new LinkedChildEntity
|
||||
{
|
||||
ParentId = _collection,
|
||||
ChildId = _linkedSeries,
|
||||
ChildType = LinkedChildType.Manual,
|
||||
SortOrder = 0
|
||||
});
|
||||
|
||||
context.MediaStreamInfos.Add(new MediaStreamInfo
|
||||
{
|
||||
ItemId = _linkedEpisode,
|
||||
StreamIndex = 0,
|
||||
StreamType = MediaStreamTypeEntity.Subtitle,
|
||||
Language = "ger",
|
||||
Item = null!
|
||||
});
|
||||
|
||||
context.Chapters.Add(new Chapter
|
||||
{
|
||||
ItemId = _withSubtitles,
|
||||
ChapterIndex = 0,
|
||||
StartPositionTicks = 0,
|
||||
ImagePath = "/chapter.jpg",
|
||||
Item = null!
|
||||
});
|
||||
|
||||
SeedVersionGroup(context);
|
||||
|
||||
context.SaveChanges();
|
||||
}
|
||||
|
||||
// An SD primary whose only extras live on a 4K second file, so every filter has to reach through
|
||||
// PrimaryVersionId to answer correctly.
|
||||
private void SeedVersionGroup(JellyfinDbContext context)
|
||||
{
|
||||
context.BaseItems.Add(new BaseItemEntity { Id = _versionLibrary, Type = FolderType, Name = "Version library", IsFolder = true });
|
||||
context.BaseItems.Add(new BaseItemEntity { Id = _versionedMovie, Type = MovieType, Name = "Versioned movie", Width = 720, Height = 480 });
|
||||
context.BaseItems.Add(new BaseItemEntity
|
||||
{
|
||||
Id = _alternateVersion,
|
||||
Type = MovieType,
|
||||
Name = "Versioned movie 4K",
|
||||
PrimaryVersionId = _versionedMovie,
|
||||
Width = 3840,
|
||||
Height = 2160
|
||||
});
|
||||
|
||||
foreach (var itemId in new[] { _versionedMovie, _alternateVersion })
|
||||
{
|
||||
context.AncestorIds.Add(new AncestorId
|
||||
{
|
||||
ItemId = itemId,
|
||||
ParentItemId = _versionLibrary,
|
||||
Item = null!,
|
||||
ParentItem = null!
|
||||
});
|
||||
}
|
||||
|
||||
context.MediaStreamInfos.Add(new MediaStreamInfo
|
||||
{
|
||||
ItemId = _alternateVersion,
|
||||
StreamIndex = 0,
|
||||
StreamType = MediaStreamTypeEntity.Subtitle,
|
||||
Language = "ger",
|
||||
Item = null!
|
||||
});
|
||||
|
||||
context.MediaStreamInfos.Add(new MediaStreamInfo
|
||||
{
|
||||
ItemId = _alternateVersion,
|
||||
StreamIndex = 1,
|
||||
StreamType = MediaStreamTypeEntity.Audio,
|
||||
Language = "fre",
|
||||
Item = null!
|
||||
});
|
||||
|
||||
SeedVersionedSeries(context);
|
||||
SeedUnprobedVersionGroup(context);
|
||||
|
||||
context.Chapters.Add(new Chapter
|
||||
{
|
||||
ItemId = _alternateVersion,
|
||||
ChapterIndex = 0,
|
||||
StartPositionTicks = 0,
|
||||
ImagePath = "/alternate-chapter.jpg",
|
||||
Item = null!
|
||||
});
|
||||
}
|
||||
|
||||
// The same SD primary / 4K second file pair one level down, so the resolution filter has to answer
|
||||
// for the series off its descendants.
|
||||
private void SeedVersionedSeries(JellyfinDbContext context)
|
||||
{
|
||||
context.BaseItems.Add(new BaseItemEntity { Id = _versionedSeries, Type = FolderType, Name = "Versioned series", IsFolder = true });
|
||||
context.BaseItems.Add(new BaseItemEntity { Id = _versionedEpisode, Type = MovieType, Name = "Versioned episode", Width = 720, Height = 480 });
|
||||
context.BaseItems.Add(new BaseItemEntity
|
||||
{
|
||||
Id = _episodeAlternate,
|
||||
Type = MovieType,
|
||||
Name = "Versioned episode 4K",
|
||||
PrimaryVersionId = _versionedEpisode,
|
||||
Width = 3840,
|
||||
Height = 2160
|
||||
});
|
||||
|
||||
context.AncestorIds.Add(new AncestorId
|
||||
{
|
||||
ItemId = _versionedSeries,
|
||||
ParentItemId = _versionLibrary,
|
||||
Item = null!,
|
||||
ParentItem = null!
|
||||
});
|
||||
|
||||
foreach (var itemId in new[] { _versionedEpisode, _episodeAlternate })
|
||||
{
|
||||
context.AncestorIds.Add(new AncestorId
|
||||
{
|
||||
ItemId = itemId,
|
||||
ParentItemId = _versionedSeries,
|
||||
Item = null!,
|
||||
ParentItem = null!
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// A primary that was never probed, so only its second file can place it in a bucket. Its audio track
|
||||
// declares no language, which is what the "und" filters stand in for.
|
||||
private void SeedUnprobedVersionGroup(JellyfinDbContext context)
|
||||
{
|
||||
context.BaseItems.Add(new BaseItemEntity { Id = _sdMovie, Type = MovieType, Name = "SD movie", Width = 720, Height = 480 });
|
||||
context.AncestorIds.Add(new AncestorId
|
||||
{
|
||||
ItemId = _sdMovie,
|
||||
ParentItemId = _versionLibrary,
|
||||
Item = null!,
|
||||
ParentItem = null!
|
||||
});
|
||||
|
||||
context.BaseItems.Add(new BaseItemEntity { Id = _unprobedMovie, Type = MovieType, Name = "Unprobed movie" });
|
||||
context.BaseItems.Add(new BaseItemEntity
|
||||
{
|
||||
Id = _unprobedAlternate,
|
||||
Type = MovieType,
|
||||
Name = "Unprobed movie SD",
|
||||
PrimaryVersionId = _unprobedMovie,
|
||||
Width = 720,
|
||||
Height = 480
|
||||
});
|
||||
|
||||
foreach (var itemId in new[] { _unprobedMovie, _unprobedAlternate })
|
||||
{
|
||||
context.AncestorIds.Add(new AncestorId
|
||||
{
|
||||
ItemId = itemId,
|
||||
ParentItemId = _versionLibrary,
|
||||
Item = null!,
|
||||
ParentItem = null!
|
||||
});
|
||||
}
|
||||
|
||||
context.MediaStreamInfos.Add(new MediaStreamInfo
|
||||
{
|
||||
ItemId = _unprobedAlternate,
|
||||
StreamIndex = 0,
|
||||
StreamType = MediaStreamTypeEntity.Audio,
|
||||
Item = null!
|
||||
});
|
||||
|
||||
SeedMixedVersionGroups(context);
|
||||
}
|
||||
|
||||
// The two groups that separate the HD bucket's lower bound from its upper one: one that only a 4K
|
||||
// third file keeps out of HD, and one that only an HD second file puts into it.
|
||||
private void SeedMixedVersionGroups(JellyfinDbContext context)
|
||||
{
|
||||
context.BaseItems.Add(new BaseItemEntity { Id = _threeWayMovie, Type = MovieType, Name = "Three-way movie", Width = 720, Height = 480 });
|
||||
context.BaseItems.Add(new BaseItemEntity
|
||||
{
|
||||
Id = _threeWayHd,
|
||||
Type = MovieType,
|
||||
Name = "Three-way movie HD",
|
||||
PrimaryVersionId = _threeWayMovie,
|
||||
Width = 1920,
|
||||
Height = 1080
|
||||
});
|
||||
context.BaseItems.Add(new BaseItemEntity
|
||||
{
|
||||
Id = _threeWay4K,
|
||||
Type = MovieType,
|
||||
Name = "Three-way movie 4K",
|
||||
PrimaryVersionId = _threeWayMovie,
|
||||
Width = 3840,
|
||||
Height = 2160
|
||||
});
|
||||
|
||||
context.BaseItems.Add(new BaseItemEntity { Id = _hdOnlyByVersion, Type = MovieType, Name = "HD only by version" });
|
||||
context.BaseItems.Add(new BaseItemEntity
|
||||
{
|
||||
Id = _hdOnlyAlternate,
|
||||
Type = MovieType,
|
||||
Name = "HD only by version, HD file",
|
||||
PrimaryVersionId = _hdOnlyByVersion,
|
||||
Width = 1920,
|
||||
Height = 1080
|
||||
});
|
||||
|
||||
foreach (var itemId in new[] { _threeWayMovie, _threeWayHd, _threeWay4K, _hdOnlyByVersion, _hdOnlyAlternate })
|
||||
{
|
||||
context.AncestorIds.Add(new AncestorId
|
||||
{
|
||||
ItemId = itemId,
|
||||
ParentItemId = _versionLibrary,
|
||||
Item = null!,
|
||||
ParentItem = null!
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,526 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Database.Implementations.MatchCriteria;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Tests.Item;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the descendant traversals against the SQLite provider: the sets they resolve, and that
|
||||
/// they stay sub-selects instead of inlining every descendant id into the statement.
|
||||
/// </summary>
|
||||
public sealed class DescendantQueryHelperTests : SqliteDbTestFixture
|
||||
{
|
||||
private const string FolderType = "MediaBrowser.Controller.Entities.Folder";
|
||||
private const string BoxSetType = "MediaBrowser.Controller.Entities.Movies.BoxSet";
|
||||
private const string MovieType = "MediaBrowser.Controller.Entities.Movies.Movie";
|
||||
|
||||
private readonly Dictionary<Guid, int> _linkCounters = new();
|
||||
|
||||
public DescendantQueryHelperTests()
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAllDescendantIds_Hierarchy_ReturnsEveryLevelWithoutTheParent()
|
||||
{
|
||||
var library = Guid.NewGuid();
|
||||
var series = Guid.NewGuid();
|
||||
var season = Guid.NewGuid();
|
||||
var episode = Guid.NewGuid();
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
AddFolder(ctx, library);
|
||||
AddFolder(ctx, series);
|
||||
AddFolder(ctx, season);
|
||||
AddItem(ctx, episode, MovieType);
|
||||
|
||||
// AncestorIds is a closure: production writes one row per ancestor, not just the parent.
|
||||
AddAncestors(ctx, series, library);
|
||||
AddAncestors(ctx, season, series, library);
|
||||
AddAncestors(ctx, episode, season, series, library);
|
||||
ctx.SaveChanges();
|
||||
}
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
var descendants = DescendantQueryHelper.GetAllDescendantIds(ctx, library).ToHashSet();
|
||||
|
||||
Assert.Equal(new[] { series, season, episode }.Order(), descendants.Order());
|
||||
Assert.DoesNotContain(library, descendants);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAllDescendantIds_LinkedFolder_IncludesItsOwnDescendants()
|
||||
{
|
||||
var boxSet = Guid.NewGuid();
|
||||
var series = Guid.NewGuid();
|
||||
var episode = Guid.NewGuid();
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
AddItem(ctx, boxSet, BoxSetType, isFolder: true);
|
||||
AddFolder(ctx, series);
|
||||
AddItem(ctx, episode, MovieType);
|
||||
|
||||
AddAncestors(ctx, episode, series);
|
||||
AddLink(ctx, boxSet, series);
|
||||
ctx.SaveChanges();
|
||||
}
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
var descendants = DescendantQueryHelper.GetAllDescendantIds(ctx, boxSet).ToHashSet();
|
||||
|
||||
Assert.Contains(series, descendants);
|
||||
Assert.Contains(episode, descendants);
|
||||
}
|
||||
}
|
||||
|
||||
// Timeout so that a missing termination guard fails the test instead of hanging the run.
|
||||
[Fact(Timeout = 30000)]
|
||||
public void GetAllDescendantIds_NestedLinks_AreFollowedAndCyclesTerminate()
|
||||
{
|
||||
var outer = Guid.NewGuid();
|
||||
var inner = Guid.NewGuid();
|
||||
var movie = Guid.NewGuid();
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
AddItem(ctx, outer, BoxSetType, isFolder: true);
|
||||
AddItem(ctx, inner, BoxSetType, isFolder: true);
|
||||
AddItem(ctx, movie, MovieType);
|
||||
|
||||
AddLink(ctx, outer, inner);
|
||||
AddLink(ctx, inner, movie);
|
||||
// The traversal must not spin on this cycle.
|
||||
AddLink(ctx, inner, outer);
|
||||
ctx.SaveChanges();
|
||||
}
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
var descendants = DescendantQueryHelper.GetAllDescendantIds(ctx, outer).ToHashSet();
|
||||
|
||||
Assert.Contains(inner, descendants);
|
||||
Assert.Contains(movie, descendants);
|
||||
Assert.DoesNotContain(outer, descendants);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAllDescendantIds_LinksOfNonFolders_AreNotFollowed()
|
||||
{
|
||||
var library = Guid.NewGuid();
|
||||
var movie = Guid.NewGuid();
|
||||
var alternateVersion = Guid.NewGuid();
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
AddFolder(ctx, library);
|
||||
AddItem(ctx, movie, MovieType);
|
||||
AddItem(ctx, alternateVersion, MovieType);
|
||||
|
||||
AddAncestors(ctx, movie, library);
|
||||
// An alternate version hangs off the movie by link, and the movie is not a folder.
|
||||
AddLink(ctx, movie, alternateVersion);
|
||||
ctx.SaveChanges();
|
||||
}
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
var descendants = DescendantQueryHelper.GetAllDescendantIds(ctx, library).ToHashSet();
|
||||
|
||||
Assert.Contains(movie, descendants);
|
||||
Assert.DoesNotContain(alternateVersion, descendants);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAllDescendantIds_ClosureSeamAboveTheCollectionFolder_IsCrossed()
|
||||
{
|
||||
var userRoot = Guid.NewGuid();
|
||||
var collectionFolder = Guid.NewGuid();
|
||||
var series = Guid.NewGuid();
|
||||
var episode = Guid.NewGuid();
|
||||
var boxSet = Guid.NewGuid();
|
||||
var linkedMovie = Guid.NewGuid();
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
AddFolder(ctx, userRoot);
|
||||
AddFolder(ctx, collectionFolder);
|
||||
AddFolder(ctx, series);
|
||||
AddItem(ctx, episode, MovieType);
|
||||
AddItem(ctx, boxSet, BoxSetType, isFolder: true);
|
||||
AddItem(ctx, linkedMovie, MovieType);
|
||||
|
||||
// An item carries its own chain plus its collection folder, but not the user root above
|
||||
// it, so one hop from the user root stops at the collection folder.
|
||||
AddAncestors(ctx, collectionFolder, userRoot);
|
||||
AddAncestors(ctx, series, collectionFolder);
|
||||
AddAncestors(ctx, episode, series, collectionFolder);
|
||||
AddAncestors(ctx, boxSet, collectionFolder);
|
||||
// The box set is only reachable across the seam, and its links have to be followed too.
|
||||
AddLink(ctx, boxSet, linkedMovie);
|
||||
ctx.SaveChanges();
|
||||
}
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
var descendants = DescendantQueryHelper.GetAllDescendantIds(ctx, userRoot).ToHashSet();
|
||||
|
||||
Assert.Equal(
|
||||
new[] { collectionFolder, series, episode, boxSet, linkedMovie }.Order(),
|
||||
descendants.Order());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetOwnedDescendantIds_ClosureSeamAboveTheCollectionFolder_IsCrossed()
|
||||
{
|
||||
var userRoot = Guid.NewGuid();
|
||||
var collectionFolder = Guid.NewGuid();
|
||||
var series = Guid.NewGuid();
|
||||
var episode = Guid.NewGuid();
|
||||
var boxSet = Guid.NewGuid();
|
||||
var linkedMovie = Guid.NewGuid();
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
AddFolder(ctx, userRoot);
|
||||
AddFolder(ctx, collectionFolder);
|
||||
AddFolder(ctx, series);
|
||||
AddItem(ctx, episode, MovieType);
|
||||
AddItem(ctx, boxSet, BoxSetType, isFolder: true);
|
||||
AddItem(ctx, linkedMovie, MovieType);
|
||||
|
||||
AddAncestors(ctx, collectionFolder, userRoot);
|
||||
AddAncestors(ctx, series, collectionFolder);
|
||||
AddAncestors(ctx, episode, series, collectionFolder);
|
||||
AddAncestors(ctx, boxSet, collectionFolder);
|
||||
AddLink(ctx, boxSet, linkedMovie);
|
||||
ctx.SaveChanges();
|
||||
}
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
// Owned only: the linked movie stays out, or deleting a library would delete it.
|
||||
var expected = new[] { collectionFolder, series, episode, boxSet }.Order();
|
||||
|
||||
Assert.Equal(expected, DescendantQueryHelper.GetOwnedDescendantIds(ctx, userRoot).ToHashSet().Order());
|
||||
Assert.Equal(expected, DescendantQueryHelper.GetOwnedDescendantIdsBatch(ctx, [userRoot]).Order());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFolderIdsMatching_LinkAboveAClosure_ReturnsTheLinkingFolder()
|
||||
{
|
||||
var collections = Guid.NewGuid();
|
||||
var boxSet = Guid.NewGuid();
|
||||
var library = Guid.NewGuid();
|
||||
var series = Guid.NewGuid();
|
||||
var episode = Guid.NewGuid();
|
||||
var otherLibrary = Guid.NewGuid();
|
||||
var otherBoxSet = Guid.NewGuid();
|
||||
var silentMovie = Guid.NewGuid();
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
AddFolder(ctx, collections);
|
||||
AddItem(ctx, boxSet, BoxSetType, isFolder: true);
|
||||
AddFolder(ctx, library);
|
||||
AddFolder(ctx, series);
|
||||
AddItem(ctx, episode, MovieType);
|
||||
|
||||
AddAncestors(ctx, boxSet, collections);
|
||||
AddAncestors(ctx, series, library);
|
||||
AddAncestors(ctx, episode, series, library);
|
||||
// The link lands on the series, not on the episode that carries the subtitles.
|
||||
AddLink(ctx, boxSet, series);
|
||||
AddStream(ctx, episode, MediaStreamTypeEntity.Subtitle);
|
||||
|
||||
AddFolder(ctx, otherLibrary);
|
||||
AddItem(ctx, otherBoxSet, BoxSetType, isFolder: true);
|
||||
AddItem(ctx, silentMovie, MovieType);
|
||||
AddAncestors(ctx, otherBoxSet, collections);
|
||||
AddAncestors(ctx, silentMovie, otherLibrary);
|
||||
AddLink(ctx, otherBoxSet, silentMovie);
|
||||
// A stream of another type: the criteria, not the mere presence of a stream, decides.
|
||||
AddStream(ctx, silentMovie, MediaStreamTypeEntity.Video);
|
||||
|
||||
ctx.SaveChanges();
|
||||
}
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
var folders = DescendantQueryHelper.GetFolderIdsMatching(ctx, new HasSubtitles()).ToHashSet();
|
||||
|
||||
Assert.Equal(new[] { library, series, boxSet, collections }.Order(), folders.Order());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact(Timeout = 30000)]
|
||||
public void GetFolderIdsMatching_NestedLinks_AreFollowedAndCyclesTerminate()
|
||||
{
|
||||
var outer = Guid.NewGuid();
|
||||
var inner = Guid.NewGuid();
|
||||
var movie = Guid.NewGuid();
|
||||
var silentSet = Guid.NewGuid();
|
||||
var silentMovie = Guid.NewGuid();
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
AddItem(ctx, outer, BoxSetType, isFolder: true);
|
||||
AddItem(ctx, inner, BoxSetType, isFolder: true);
|
||||
AddItem(ctx, movie, MovieType);
|
||||
|
||||
AddLink(ctx, outer, inner);
|
||||
AddLink(ctx, inner, movie);
|
||||
// Resolving the link parents must not spin on this cycle.
|
||||
AddLink(ctx, inner, outer);
|
||||
AddStream(ctx, movie, MediaStreamTypeEntity.Subtitle);
|
||||
|
||||
AddItem(ctx, silentSet, BoxSetType, isFolder: true);
|
||||
AddItem(ctx, silentMovie, MovieType);
|
||||
AddLink(ctx, silentSet, silentMovie);
|
||||
AddStream(ctx, silentMovie, MediaStreamTypeEntity.Video);
|
||||
|
||||
ctx.SaveChanges();
|
||||
}
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
var folders = DescendantQueryHelper.GetFolderIdsMatching(ctx, new HasSubtitles()).ToHashSet();
|
||||
|
||||
Assert.Equal(new[] { inner, outer }.Order(), folders.Order());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFolderIdsMatching_ClosureSeamAboveTheCollectionFolder_IsCrossed()
|
||||
{
|
||||
var userRoot = Guid.NewGuid();
|
||||
var collectionFolder = Guid.NewGuid();
|
||||
var series = Guid.NewGuid();
|
||||
var episode = Guid.NewGuid();
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
AddFolder(ctx, userRoot);
|
||||
AddFolder(ctx, collectionFolder);
|
||||
AddFolder(ctx, series);
|
||||
AddItem(ctx, episode, MovieType);
|
||||
|
||||
// The closure is not transitive at this seam: no item records the user root.
|
||||
AddAncestors(ctx, episode, series, collectionFolder);
|
||||
AddAncestors(ctx, series, collectionFolder);
|
||||
AddAncestors(ctx, collectionFolder, userRoot);
|
||||
AddStream(ctx, episode, MediaStreamTypeEntity.Subtitle);
|
||||
ctx.SaveChanges();
|
||||
}
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
var folders = DescendantQueryHelper.GetFolderIdsMatching(ctx, new HasSubtitles()).ToHashSet();
|
||||
|
||||
Assert.Equal(new[] { series, collectionFolder, userRoot }.Order(), folders.Order());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFolderIdsMatching_LinkedFolder_MatchesOnLanguageOnly()
|
||||
{
|
||||
var boxSet = Guid.NewGuid();
|
||||
var series = Guid.NewGuid();
|
||||
var episode = Guid.NewGuid();
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
AddItem(ctx, boxSet, BoxSetType, isFolder: true);
|
||||
AddFolder(ctx, series);
|
||||
AddItem(ctx, episode, MovieType);
|
||||
|
||||
AddAncestors(ctx, episode, series);
|
||||
AddLink(ctx, boxSet, series);
|
||||
AddStream(ctx, episode, MediaStreamTypeEntity.Subtitle, "ger");
|
||||
ctx.SaveChanges();
|
||||
}
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
var german = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, ["ger"]);
|
||||
var french = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, ["fre"]);
|
||||
|
||||
Assert.Equal(new[] { series, boxSet }.Order(), DescendantQueryHelper.GetFolderIdsMatching(ctx, german).ToHashSet().Order());
|
||||
Assert.Empty(DescendantQueryHelper.GetFolderIdsMatching(ctx, french).ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFolderIdsMatching_AlternateVersionLinks_AreNotWalked()
|
||||
{
|
||||
var collections = Guid.NewGuid();
|
||||
var boxSet = Guid.NewGuid();
|
||||
var library = Guid.NewGuid();
|
||||
var movie = Guid.NewGuid();
|
||||
var alternateVersion = Guid.NewGuid();
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
AddFolder(ctx, collections);
|
||||
AddItem(ctx, boxSet, BoxSetType, isFolder: true);
|
||||
AddFolder(ctx, library);
|
||||
AddItem(ctx, movie, MovieType);
|
||||
AddItem(ctx, alternateVersion, MovieType);
|
||||
|
||||
AddAncestors(ctx, boxSet, collections);
|
||||
AddAncestors(ctx, movie, library);
|
||||
AddAncestors(ctx, alternateVersion, library);
|
||||
// Only the second file carries the subtitles, and it hangs off the movie by an alternate
|
||||
// version link. The movie is not a folder, so that link is not a parent-child edge.
|
||||
AddLink(ctx, movie, alternateVersion, LinkedChildType.LocalAlternateVersion);
|
||||
AddLink(ctx, boxSet, movie);
|
||||
AddStream(ctx, alternateVersion, MediaStreamTypeEntity.Subtitle);
|
||||
ctx.SaveChanges();
|
||||
}
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
var folders = DescendantQueryHelper.GetFolderIdsMatching(ctx, new HasSubtitles()).ToHashSet();
|
||||
|
||||
// The library still matches: the alternate version carries its own closure. The box set does
|
||||
// not, matching the descendant side, which does not follow a non-folder's links either.
|
||||
Assert.Equal([library], folders);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetOwnedDescendantIds_IgnoresLinkedChildren()
|
||||
{
|
||||
var boxSet = Guid.NewGuid();
|
||||
var owned = Guid.NewGuid();
|
||||
var linked = Guid.NewGuid();
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
AddItem(ctx, boxSet, BoxSetType, isFolder: true);
|
||||
AddItem(ctx, owned, MovieType);
|
||||
AddItem(ctx, linked, MovieType);
|
||||
|
||||
AddAncestors(ctx, owned, boxSet);
|
||||
AddLink(ctx, boxSet, linked);
|
||||
ctx.SaveChanges();
|
||||
}
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
Assert.Equal([owned], DescendantQueryHelper.GetOwnedDescendantIds(ctx, boxSet).ToArray());
|
||||
Assert.Equal([owned], DescendantQueryHelper.GetOwnedDescendantIdsBatch(ctx, [boxSet]).ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAllDescendantIds_StatementSizeDoesNotGrowWithTheLibrary()
|
||||
{
|
||||
var small = SeedLibrary(10);
|
||||
var large = SeedLibrary(500);
|
||||
|
||||
using var ctx = CreateDbContext();
|
||||
|
||||
var smallSql = CountingQuery(ctx, small).ToQueryString();
|
||||
var largeSql = CountingQuery(ctx, large).ToQueryString();
|
||||
|
||||
// Reading the ids into memory and handing them back as AsQueryable() makes EF inline one
|
||||
// literal per descendant, which is what allocated megabytes per call.
|
||||
Assert.Equal(smallSql.Length, largeSql.Length);
|
||||
Assert.Contains("AncestorIds", smallSql, StringComparison.Ordinal);
|
||||
Assert.Equal(10, CountingQuery(ctx, small).Count());
|
||||
Assert.Equal(500, CountingQuery(ctx, large).Count());
|
||||
}
|
||||
|
||||
private static IQueryable<BaseItemEntity> CountingQuery(JellyfinDbContext context, Guid libraryId)
|
||||
{
|
||||
var descendantIds = DescendantQueryHelper.GetAllDescendantIds(context, libraryId);
|
||||
|
||||
return context.BaseItems
|
||||
.AsNoTracking()
|
||||
.Where(b => descendantIds.Contains(b.Id))
|
||||
.Where(DescendantQueryHelper.IsCountableLeaf);
|
||||
}
|
||||
|
||||
private Guid SeedLibrary(int childCount)
|
||||
{
|
||||
var library = Guid.NewGuid();
|
||||
|
||||
using var ctx = CreateDbContext();
|
||||
AddFolder(ctx, library);
|
||||
for (var i = 0; i < childCount; i++)
|
||||
{
|
||||
var child = Guid.NewGuid();
|
||||
AddItem(ctx, child, MovieType);
|
||||
AddAncestors(ctx, child, library);
|
||||
}
|
||||
|
||||
ctx.SaveChanges();
|
||||
|
||||
return library;
|
||||
}
|
||||
|
||||
private static void AddFolder(JellyfinDbContext context, Guid id)
|
||||
=> AddItem(context, id, FolderType, isFolder: true);
|
||||
|
||||
private static void AddItem(JellyfinDbContext context, Guid id, string type, bool isFolder = false)
|
||||
=> context.BaseItems.Add(new BaseItemEntity
|
||||
{
|
||||
Id = id,
|
||||
Type = type,
|
||||
Name = type + " " + id,
|
||||
IsFolder = isFolder
|
||||
});
|
||||
|
||||
private static void AddStream(JellyfinDbContext context, Guid itemId, MediaStreamTypeEntity type, string? language = null)
|
||||
=> context.MediaStreamInfos.Add(new MediaStreamInfo
|
||||
{
|
||||
ItemId = itemId,
|
||||
StreamIndex = 0,
|
||||
StreamType = type,
|
||||
Language = language,
|
||||
Item = null!
|
||||
});
|
||||
|
||||
private static void AddAncestors(JellyfinDbContext context, Guid itemId, params Guid[] ancestorIds)
|
||||
{
|
||||
foreach (var ancestorId in ancestorIds)
|
||||
{
|
||||
context.AncestorIds.Add(new AncestorId
|
||||
{
|
||||
ItemId = itemId,
|
||||
ParentItemId = ancestorId,
|
||||
Item = null!,
|
||||
ParentItem = null!
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// LinkedChildren is keyed on (ParentId, SortOrder), so every link of a parent needs its own slot.
|
||||
private void AddLink(JellyfinDbContext context, Guid parentId, Guid childId, LinkedChildType childType = LinkedChildType.Manual)
|
||||
{
|
||||
_linkCounters.TryGetValue(parentId, out var sortOrder);
|
||||
_linkCounters[parentId] = sortOrder + 1;
|
||||
|
||||
context.LinkedChildren.Add(new LinkedChildEntity
|
||||
{
|
||||
ParentId = parentId,
|
||||
ChildId = childId,
|
||||
ChildType = childType,
|
||||
SortOrder = sortOrder
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Database.Implementations.Locking;
|
||||
using Jellyfin.Database.Providers.Sqlite;
|
||||
using Jellyfin.Server.Implementations.Item;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Persistence;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Tests.Item;
|
||||
|
||||
public sealed class ItemCountServiceTests : IDisposable
|
||||
{
|
||||
private readonly SqliteConnection _connection;
|
||||
private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
|
||||
private readonly IApplicationPaths _applicationPaths;
|
||||
private readonly ItemCountService _service;
|
||||
|
||||
public ItemCountServiceTests()
|
||||
{
|
||||
_applicationPaths = new Mock<IApplicationPaths>().Object;
|
||||
|
||||
_connection = new SqliteConnection("Data Source=:memory:");
|
||||
_connection.Open();
|
||||
|
||||
_dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
|
||||
.UseSqlite(_connection)
|
||||
.Options;
|
||||
|
||||
using (var context = CreateDbContext())
|
||||
{
|
||||
context.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
|
||||
factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
|
||||
|
||||
_service = new ItemCountService(
|
||||
factory.Object,
|
||||
new Mock<IItemTypeLookup>().Object,
|
||||
new Mock<IItemQueryHelpers>().Object);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_connection.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetChildCountBatch_LargeParentIdSet_DoesNotExceedSqliteVariableLimit()
|
||||
{
|
||||
var hierarchicalParentId = Guid.NewGuid();
|
||||
var linkedParentId = Guid.NewGuid();
|
||||
|
||||
var hierarchicalChildId = Guid.NewGuid();
|
||||
var linkedChildId1 = Guid.NewGuid();
|
||||
var linkedChildId2 = Guid.NewGuid();
|
||||
|
||||
using (var context = CreateDbContext())
|
||||
{
|
||||
context.BaseItems.AddRange(
|
||||
CreateItem(hierarchicalParentId),
|
||||
CreateItem(linkedParentId),
|
||||
CreateItem(hierarchicalChildId, hierarchicalParentId),
|
||||
CreateItem(linkedChildId1),
|
||||
CreateItem(linkedChildId2));
|
||||
|
||||
context.LinkedChildren.AddRange(
|
||||
new LinkedChildEntity
|
||||
{
|
||||
ParentId = linkedParentId,
|
||||
ChildId = linkedChildId1,
|
||||
ChildType = LinkedChildType.Manual,
|
||||
SortOrder = 0
|
||||
},
|
||||
new LinkedChildEntity
|
||||
{
|
||||
ParentId = linkedParentId,
|
||||
ChildId = linkedChildId2,
|
||||
ChildType = LinkedChildType.Manual,
|
||||
SortOrder = 1
|
||||
});
|
||||
|
||||
context.SaveChanges();
|
||||
}
|
||||
|
||||
var parentIds = Enumerable.Range(0, 40_000)
|
||||
.Select(_ => Guid.NewGuid())
|
||||
.ToList();
|
||||
|
||||
parentIds.Add(hierarchicalParentId);
|
||||
parentIds.Add(linkedParentId);
|
||||
|
||||
var result = _service.GetChildCountBatch(parentIds, null);
|
||||
|
||||
Assert.Equal(1, result[hierarchicalParentId]);
|
||||
Assert.Equal(2, result[linkedParentId]);
|
||||
Assert.Equal(parentIds.Count, result.Count);
|
||||
}
|
||||
|
||||
private static BaseItemEntity CreateItem(Guid id, Guid? parentId = null)
|
||||
{
|
||||
return new BaseItemEntity
|
||||
{
|
||||
Id = id,
|
||||
Type = "Folder",
|
||||
ParentId = parentId,
|
||||
IsFolder = true
|
||||
};
|
||||
}
|
||||
|
||||
private JellyfinDbContext CreateDbContext()
|
||||
{
|
||||
return new JellyfinDbContext(
|
||||
_dbOptions,
|
||||
NullLogger<JellyfinDbContext>.Instance,
|
||||
new SqliteDatabaseProvider(
|
||||
_applicationPaths,
|
||||
NullLogger<SqliteDatabaseProvider>.Instance),
|
||||
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
|
||||
}
|
||||
}
|
||||
@@ -3,49 +3,27 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Locking;
|
||||
using Jellyfin.Database.Providers.Sqlite;
|
||||
using Jellyfin.Server.Implementations.Item;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Configuration;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Tests.Item;
|
||||
|
||||
public sealed class ItemPersistenceOwnedRowTests : IDisposable
|
||||
public sealed class ItemPersistenceOwnedRowTests : SqliteDbTestFixture
|
||||
{
|
||||
private readonly SqliteConnection _connection;
|
||||
private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
|
||||
private readonly ItemPersistenceService _service;
|
||||
private readonly IApplicationPaths _applicationPaths;
|
||||
private readonly ILibraryManager? _previousLibraryManager;
|
||||
private readonly IServerConfigurationManager? _previousConfigurationManager;
|
||||
|
||||
public ItemPersistenceOwnedRowTests()
|
||||
{
|
||||
_applicationPaths = new Mock<IApplicationPaths>().Object;
|
||||
|
||||
_connection = new SqliteConnection("Data Source=:memory:");
|
||||
_connection.Open();
|
||||
|
||||
_dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
|
||||
.UseSqlite(_connection)
|
||||
.Options;
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
ctx.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
// BaseItem resolves these through process-wide statics; restored in Dispose.
|
||||
_previousLibraryManager = BaseItem.LibraryManager;
|
||||
_previousConfigurationManager = BaseItem.ConfigurationManager;
|
||||
@@ -59,20 +37,17 @@ public sealed class ItemPersistenceOwnedRowTests : IDisposable
|
||||
configurationManager.Setup(c => c.Configuration).Returns(new ServerConfiguration());
|
||||
BaseItem.ConfigurationManager = configurationManager.Object;
|
||||
|
||||
var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
|
||||
factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
|
||||
|
||||
_service = new ItemPersistenceService(
|
||||
factory.Object,
|
||||
CreateDbContextFactory(),
|
||||
new Mock<IServerApplicationHost>().Object,
|
||||
NullLogger<ItemPersistenceService>.Instance);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
BaseItem.LibraryManager = _previousLibraryManager!;
|
||||
BaseItem.ConfigurationManager = _previousConfigurationManager!;
|
||||
_connection.Dispose();
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -140,10 +115,4 @@ public sealed class ItemPersistenceOwnedRowTests : IDisposable
|
||||
book.SetImage(new ItemImageInfo { Path = "/img/primary.jpg", Type = ImageType.Primary }, 0);
|
||||
return book;
|
||||
}
|
||||
|
||||
private JellyfinDbContext CreateDbContext() => new(
|
||||
_dbOptions,
|
||||
NullLogger<JellyfinDbContext>.Instance,
|
||||
new SqliteDatabaseProvider(_applicationPaths, NullLogger<SqliteDatabaseProvider>.Instance),
|
||||
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
|
||||
}
|
||||
|
||||
+2
-34
@@ -4,42 +4,27 @@ using Emby.Server.Implementations.Data;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Database.Implementations.Locking;
|
||||
using Jellyfin.Database.Providers.Sqlite;
|
||||
using Jellyfin.Server.Implementations.Item;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Persistence;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Tests.Item;
|
||||
|
||||
public sealed class PeopleRepositoryUpdatePeopleTests : IDisposable
|
||||
public sealed class PeopleRepositoryUpdatePeopleTests : SqliteDbTestFixture
|
||||
{
|
||||
private static readonly Guid _itemId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||
|
||||
private readonly SqliteConnection _connection;
|
||||
private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
|
||||
private readonly PeopleRepository _repository;
|
||||
|
||||
public PeopleRepositoryUpdatePeopleTests()
|
||||
{
|
||||
_connection = new SqliteConnection("Data Source=:memory:");
|
||||
_connection.Open();
|
||||
|
||||
_dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
|
||||
.UseSqlite(_connection)
|
||||
.Options;
|
||||
|
||||
var itemTypeLookup = new ItemTypeLookup();
|
||||
|
||||
using (var ctx = CreateDbContext())
|
||||
{
|
||||
ctx.Database.EnsureCreated();
|
||||
ctx.BaseItems.Add(new BaseItemEntity
|
||||
{
|
||||
Id = _itemId,
|
||||
@@ -53,20 +38,12 @@ public sealed class PeopleRepositoryUpdatePeopleTests : IDisposable
|
||||
ctx.SaveChanges();
|
||||
}
|
||||
|
||||
var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
|
||||
factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
|
||||
|
||||
_repository = new PeopleRepository(
|
||||
factory.Object,
|
||||
CreateDbContextFactory(),
|
||||
itemTypeLookup,
|
||||
new Mock<IItemQueryHelpers>().Object);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_connection.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdatePeople_SamePersonAndTypeWithDifferentRoles_KeepsEveryCredit()
|
||||
{
|
||||
@@ -174,13 +151,4 @@ public sealed class PeopleRepositoryUpdatePeopleTests : IDisposable
|
||||
Role = role
|
||||
};
|
||||
}
|
||||
|
||||
private JellyfinDbContext CreateDbContext()
|
||||
{
|
||||
return new JellyfinDbContext(
|
||||
_dbOptions,
|
||||
NullLogger<JellyfinDbContext>.Instance,
|
||||
new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
|
||||
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user