aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.config/dotnet-tools.json2
-rw-r--r--.github/workflows/ci-codeql-analysis.yml6
-rw-r--r--CONTRIBUTORS.md5
-rw-r--r--Directory.Packages.props8
-rw-r--r--Emby.Naming/Common/NamingOptions.cs2
-rw-r--r--Emby.Server.Implementations/Localization/Core/da.json14
-rw-r--r--Emby.Server.Implementations/Localization/Core/en-GB.json14
-rw-r--r--Emby.Server.Implementations/Localization/Core/eo.json6
-rw-r--r--Emby.Server.Implementations/Localization/Core/eu.json9
-rw-r--r--Emby.Server.Implementations/Localization/Core/fi.json7
-rw-r--r--Emby.Server.Implementations/Localization/Core/fo.json6
-rw-r--r--Emby.Server.Implementations/Localization/Core/gl.json16
-rw-r--r--Emby.Server.Implementations/Localization/Core/he.json9
-rw-r--r--Emby.Server.Implementations/Localization/Core/hu.json3
-rw-r--r--Emby.Server.Implementations/Localization/Core/hy.json7
-rw-r--r--Emby.Server.Implementations/Localization/Core/it.json5
-rw-r--r--Emby.Server.Implementations/Localization/Core/lt-LT.json6
-rw-r--r--Emby.Server.Implementations/Localization/Core/pt-PT.json123
-rw-r--r--Emby.Server.Implementations/Localization/Core/pt.json147
-rw-r--r--Emby.Server.Implementations/Localization/Core/ro.json5
-rw-r--r--Emby.Server.Implementations/Localization/Core/ru.json6
-rw-r--r--Emby.Server.Implementations/Localization/Core/sk.json2
-rw-r--r--Emby.Server.Implementations/Localization/Core/sq.json15
-rw-r--r--Emby.Server.Implementations/Localization/Core/sr.json15
-rw-r--r--Emby.Server.Implementations/Localization/Core/sv.json7
-rw-r--r--Emby.Server.Implementations/Localization/Core/ta.json17
-rw-r--r--Emby.Server.Implementations/Localization/Core/tr.json2
-rw-r--r--Emby.Server.Implementations/Localization/Core/zh-CN.json8
-rw-r--r--Emby.Server.Implementations/Localization/Core/zh-HK.json19
-rw-r--r--Emby.Server.Implementations/Localization/Core/zh-TW.json15
-rw-r--r--Emby.Server.Implementations/Session/SessionManager.cs2
-rw-r--r--Jellyfin.Server.Implementations/Item/ItemCountService.cs5
-rw-r--r--Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs2
-rw-r--r--Jellyfin.Server.Implementations/Users/UserManager.cs67
-rw-r--r--MediaBrowser.Controller/Session/SessionInfo.cs18
-rw-r--r--MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs94
-rw-r--r--MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs33
-rw-r--r--MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzQueryExtensions.cs112
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Permission.cs2
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Preference.cs2
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs3
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PermissionConfiguration.cs2
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PreferenceConfiguration.cs1
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260815063607_RemoveOrphanedUserPermissionsAndPreferences.Designer.cs1805
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260815063607_RemoveOrphanedUserPermissionsAndPreferences.cs120
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs24
-rw-r--r--tests/Jellyfin.Naming.Tests/Video/CleanStringTests.cs1
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs130
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs6
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/SessionManager/IdlePlaybackTests.cs80
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionInfoTests.cs92
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerUpdateUserTests.cs181
52 files changed, 3027 insertions, 261 deletions
diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index bc574d7054..e7d991d94a 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -3,7 +3,7 @@
"isRoot": true,
"tools": {
"dotnet-ef": {
- "version": "10.0.10",
+ "version": "10.0.11",
"commands": [
"dotnet-ef"
]
diff --git a/.github/workflows/ci-codeql-analysis.yml b/.github/workflows/ci-codeql-analysis.yml
index 3aea4abaaa..2b51105652 100644
--- a/.github/workflows/ci-codeql-analysis.yml
+++ b/.github/workflows/ci-codeql-analysis.yml
@@ -32,13 +32,13 @@ jobs:
dotnet-version: '10.0.x'
- name: Initialize CodeQL
- uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
+ uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
languages: ${{ matrix.language }}
queries: +security-extended
- name: Autobuild
- uses: github/codeql-action/autobuild@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
+ uses: github/codeql-action/autobuild@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
+ uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md
index d61f1703b2..99f24e3a14 100644
--- a/CONTRIBUTORS.md
+++ b/CONTRIBUTORS.md
@@ -143,6 +143,7 @@
- [Smith00101010](https://github.com/Smith00101010)
- [sorinyo2004](https://github.com/sorinyo2004)
- [Soumyadip Auddy](https://github.com/SoumyadipAuddy)
+ - [st7105](https://github.com/st7105)
- [sparky8251](https://github.com/sparky8251)
- [spookbits](https://github.com/spookbits)
- [ssenart](https://github.com/ssenart)
@@ -237,6 +238,8 @@
- [elio42](https://github.com/elio42)
- [rwebster85](https://github.com/rwebster85)
- [Florin-Popescu](https://github.com/Florin-Popescu)
+ - [m0g3r](https://github.com/m0g3r)
+ - [martin-77](https://github.com/martin-77)
# Emby Contributors
@@ -247,7 +250,7 @@
- [Mark2xv](https://github.com/Mark2xv)
- [ScottRapsey](https://github.com/ScottRapsey)
- [skynet600](https://github.com/skynet600)
- - [Cheesegeezer](https://githum.com/Cheesegeezer)
+ - [Cheesegeezer](https://github.com/Cheesegeezer)
- [Radeon](https://github.com/radeonorama)
- [gcw07](https://github.com/gcw07)
- [SivaramAdhiappan](https://github.com/shivaram1190)
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 9857493c59..ac9830fc7e 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -29,9 +29,9 @@
<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.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" />
@@ -47,7 +47,7 @@
<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.8.1" />
+ <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" />
diff --git a/Emby.Naming/Common/NamingOptions.cs b/Emby.Naming/Common/NamingOptions.cs
index 0d93403758..9ecefb4c7b 100644
--- a/Emby.Naming/Common/NamingOptions.cs
+++ b/Emby.Naming/Common/NamingOptions.cs
@@ -151,7 +151,7 @@ namespace Emby.Naming.Common
CleanStrings =
[
- @"^\s*(?<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>.+)",
diff --git a/Emby.Server.Implementations/Localization/Core/da.json b/Emby.Server.Implementations/Localization/Core/da.json
index 5f5bc1b214..6e5532ead9 100644
--- a/Emby.Server.Implementations/Localization/Core/da.json
+++ b/Emby.Server.Implementations/Localization/Core/da.json
@@ -51,7 +51,7 @@
"Shows": "Serier",
"StartupEmbyServerIsLoading": "Jellyfin er i gang med at starte. Prøv igen om et øjeblik.",
"SubtitleDownloadFailureFromForItem": "Undertekster kunne ikke hentes fra {0} til {1}",
- "TvShows": "TV-serier",
+ "TvShows": "Serier",
"UserCreatedWithName": "Bruger {0} er blevet oprettet",
"UserDeletedWithName": "Brugeren {0} er nu slettet",
"UserDownloadingItemWithValues": "{0} henter {1}",
@@ -80,8 +80,8 @@
"TaskRefreshChapterImagesDescription": "Laver miniaturebilleder for videoer, der har kapitler.",
"TaskRefreshChannelsDescription": "Opdaterer information for internetkanaler.",
"TaskRefreshChannels": "Opdatér kanaler",
- "TaskCleanTranscodeDescription": "Fjerner omkodningsfiler, som er mere end 1 dag gamle.",
- "TaskCleanTranscode": "Tøm omkodningsmappen",
+ "TaskCleanTranscodeDescription": "Fjerner transkodningsfiler, som er mere end 1 dag gamle.",
+ "TaskCleanTranscode": "Tøm transkodningsmappen",
"TaskRefreshPeople": "Opdatér personer",
"TaskRefreshPeopleDescription": "Opdaterer metadata for skuespillere og instruktører i dit mediebibliotek.",
"TaskCleanActivityLogDescription": "Sletter linjer i aktivitetsloggen ældre end den konfigurerede alder.",
@@ -109,17 +109,17 @@
"CleanupUserDataTaskDescription": "Rydder alle brugerdata (eks. visning- og favoritstatus) fra medier, der har været utilgængelige i mindst 90 dage.",
"LyricDownloadFailureFromForItem": "Sangtekster kunne ikke downloades fra {0} til {1}",
"Original": "Original",
- "NameExtraBehindTheScenes": "Bag Scenerne",
+ "NameExtraBehindTheScenes": "Bag scenerne",
"NameExtraClip": "Klip",
- "NameExtraDeletedScene": "Slettet Scene",
+ "NameExtraDeletedScene": "Slettet scene",
"NameExtraFeaturette": "Featurette",
"NameExtraInterview": "Interview",
"NameExtraNumbered": "{0} {1}",
"NameExtraSample": "Smagsprøve",
"NameExtraScene": "Scene",
"NameExtraShort": "Kort",
- "NameExtraThemeSong": "Tema Sang",
- "NameExtraThemeVideo": "Tema Video",
+ "NameExtraThemeSong": "Temasang",
+ "NameExtraThemeVideo": "Temavideo",
"NameExtraTrailer": "Trailer",
"NameExtraUnknown": "Ekstra"
}
diff --git a/Emby.Server.Implementations/Localization/Core/en-GB.json b/Emby.Server.Implementations/Localization/Core/en-GB.json
index 298d60d277..5f1759e9d0 100644
--- a/Emby.Server.Implementations/Localization/Core/en-GB.json
+++ b/Emby.Server.Implementations/Localization/Core/en-GB.json
@@ -108,5 +108,17 @@
"CleanupUserDataTask": "User data cleanup task",
"CleanupUserDataTaskDescription": "Cleans all user data (Watch state, favourite status etc) from media that is no longer present for at least 90 days.",
"LyricDownloadFailureFromForItem": "Lyrics failed to download from {0} for {1}",
- "Original": "Original"
+ "Original": "Original",
+ "NameExtraBehindTheScenes": "Behind The Scenes",
+ "NameExtraClip": "Clip",
+ "NameExtraDeletedScene": "Deleted Scene",
+ "NameExtraFeaturette": "Featurette",
+ "NameExtraInterview": "Interview",
+ "NameExtraSample": "Sample",
+ "NameExtraScene": "Scene",
+ "NameExtraShort": "Short",
+ "NameExtraThemeSong": "Theme Song",
+ "NameExtraThemeVideo": "Theme Video",
+ "NameExtraTrailer": "Trailer",
+ "NameExtraUnknown": "Extra"
}
diff --git a/Emby.Server.Implementations/Localization/Core/eo.json b/Emby.Server.Implementations/Localization/Core/eo.json
index 133a2755a8..e59f089459 100644
--- a/Emby.Server.Implementations/Localization/Core/eo.json
+++ b/Emby.Server.Implementations/Localization/Core/eo.json
@@ -97,5 +97,9 @@
"TaskAudioNormalizationDescription": "Skanas dosierojn por sonnivelaj normaligaj datumoj.",
"TaskRefreshTrickplayImages": "Generi la bildojn por TrickPlay (Antaŭrigardo rapida antaŭen)",
"TaskAudioNormalization": "Normaligo Sonnivela",
- "HearingImpaired": "Surda"
+ "HearingImpaired": "Surda",
+ "NameExtraDeletedScene": "Forigita sceno",
+ "NameExtraScene": "Sceno",
+ "NameExtraThemeSong": "Tema Kanto",
+ "Original": "Originala"
}
diff --git a/Emby.Server.Implementations/Localization/Core/eu.json b/Emby.Server.Implementations/Localization/Core/eu.json
index 643c919843..df6855d6e3 100644
--- a/Emby.Server.Implementations/Localization/Core/eu.json
+++ b/Emby.Server.Implementations/Localization/Core/eu.json
@@ -114,5 +114,12 @@
"NameExtraScene": "Eszena",
"NameExtraShort": "Laburra",
"NameExtraThemeSong": "Gai-abestia",
- "NameExtraThemeVideo": "Gai-bideoa"
+ "NameExtraThemeVideo": "Gai-bideoa",
+ "NameExtraBehindTheScenes": "Eszenen atzean",
+ "NameExtraClip": "Klipa",
+ "NameExtraDeletedScene": "Ezabatutako eszena",
+ "NameExtraFeaturette": "Erreportajea",
+ "NameExtraInterview": "Elkarrizketa",
+ "NameExtraTrailer": "Trailerra",
+ "NameExtraUnknown": "Extra"
}
diff --git a/Emby.Server.Implementations/Localization/Core/fi.json b/Emby.Server.Implementations/Localization/Core/fi.json
index d080eb0236..36a248a1d1 100644
--- a/Emby.Server.Implementations/Localization/Core/fi.json
+++ b/Emby.Server.Implementations/Localization/Core/fi.json
@@ -108,5 +108,10 @@
"CleanupUserDataTask": "Käyttäjätietojen puhdistustehtävä",
"CleanupUserDataTaskDescription": "Puhdistaa kaikki käyttäjätiedot (katselutila, suosikit ym.) medioista, joita ei ole ollut saatavilla yli 90 päivään.",
"LyricDownloadFailureFromForItem": "Sanoitusten lataus kohteesta {0} kappaleelle {1} epäonnistui",
- "Original": "Alkuperäinen"
+ "Original": "Alkuperäinen",
+ "NameExtraBehindTheScenes": "Kulissien Takana",
+ "NameExtraClip": "Klippi",
+ "NameExtraDeletedScene": "Poistettu Kohtaus",
+ "NameExtraFeaturette": "Lyhytelokuva",
+ "NameExtraInterview": "Haastattelu"
}
diff --git a/Emby.Server.Implementations/Localization/Core/fo.json b/Emby.Server.Implementations/Localization/Core/fo.json
index 29e007866f..d1e9065d97 100644
--- a/Emby.Server.Implementations/Localization/Core/fo.json
+++ b/Emby.Server.Implementations/Localization/Core/fo.json
@@ -1,6 +1,6 @@
{
"Artists": "Tónlistafólk",
- "Collections": "Søvn",
+ "Collections": "Samlingar",
"Default": "Forsett",
"External": "Ytri",
"Genres": "Greinar",
@@ -55,8 +55,8 @@
"TaskUpdatePluginsDescription": "Niðurtekur og innleggur dagføringar til ískoytisforrit ið eru stillaði til at dagførast sjálvvirkandi.",
"TaskCleanTranscodeDescription": "Strikar umkotaðar fílar ið eru eldri enn 1 dag.",
"TaskOptimizeDatabase": "Albøt dátugrunn",
- "NameSeasonNumber": "Sesong {0}",
- "NameSeasonUnknown": "Ókend sesong",
+ "NameSeasonNumber": "Umfar {0}",
+ "NameSeasonUnknown": "Ókent umfar",
"ScheduledTaskFailedWithName": "{0} miseydnaðist",
"Undefined": "Óskilmarkað",
"TasksMaintenanceCategory": "Viðlíkahald",
diff --git a/Emby.Server.Implementations/Localization/Core/gl.json b/Emby.Server.Implementations/Localization/Core/gl.json
index a68db0076a..9eb9786b8a 100644
--- a/Emby.Server.Implementations/Localization/Core/gl.json
+++ b/Emby.Server.Implementations/Localization/Core/gl.json
@@ -107,5 +107,19 @@
"TaskAudioNormalizationDescription": "Escanea ficheiros á procura de datos de normalización de volume.",
"CleanupUserDataTask": "Tarefa de limpeza de datos dos usuarios",
"CleanupUserDataTaskDescription": "Limpa todos os datos do usuario (estado de visualización, de favorito etc.) dos medios ausentes polo menos 90 días.",
- "Original": "Orixinal"
+ "Original": "Orixinal",
+ "LyricDownloadFailureFromForItem": "Non se puideron descargar as letras desde {0} para {1}",
+ "NameExtraBehindTheScenes": "Detrás das Cámaras",
+ "NameExtraClip": "Clip",
+ "NameExtraDeletedScene": "Escena Eliminada",
+ "NameExtraFeaturette": "Reportaxe especial",
+ "NameExtraInterview": "Entrevista",
+ "NameExtraNumbered": "{0} {1}",
+ "NameExtraSample": "Mostra",
+ "NameExtraScene": "Escena",
+ "NameExtraShort": "Curtametraxe",
+ "NameExtraThemeSong": "Canción principal",
+ "NameExtraThemeVideo": "Vídeo da canción principal",
+ "NameExtraTrailer": "Tráiler",
+ "NameExtraUnknown": "Extra"
}
diff --git a/Emby.Server.Implementations/Localization/Core/he.json b/Emby.Server.Implementations/Localization/Core/he.json
index 48056b0bb8..0ca7c6aa08 100644
--- a/Emby.Server.Implementations/Localization/Core/he.json
+++ b/Emby.Server.Implementations/Localization/Core/he.json
@@ -108,5 +108,12 @@
"CleanupUserDataTaskDescription": "ניקוי כל המידע של המשתמש (מצב צפייה, מועדפים וכו) ממדיה שאינה קיימת מעל 90 יום.",
"CleanupUserDataTask": "משימת ניקוי מידע משתמש",
"LyricDownloadFailureFromForItem": "הורדת המילים מ-{0} עבור {1} נכשלה",
- "Original": "מקור"
+ "Original": "מקור",
+ "NameExtraBehindTheScenes": "מאחורי הקלעים",
+ "NameExtraClip": "קליפ",
+ "NameExtraDeletedScene": "סצנה שנמחקה",
+ "NameExtraFeaturette": "סרט קצר",
+ "NameExtraInterview": "ריאיון",
+ "NameExtraSample": "דגימה",
+ "NameExtraScene": "סצנה"
}
diff --git a/Emby.Server.Implementations/Localization/Core/hu.json b/Emby.Server.Implementations/Localization/Core/hu.json
index f7982352ee..4a327e7b02 100644
--- a/Emby.Server.Implementations/Localization/Core/hu.json
+++ b/Emby.Server.Implementations/Localization/Core/hu.json
@@ -120,5 +120,6 @@
"NameExtraThemeSong": "Főcímdal",
"NameExtraThemeVideo": "Főcímvideó",
"NameExtraTrailer": "Előzetes",
- "NameExtraUnknown": "Extra"
+ "NameExtraUnknown": "Extra",
+ "NameExtraNumbered": "{0} {1}"
}
diff --git a/Emby.Server.Implementations/Localization/Core/hy.json b/Emby.Server.Implementations/Localization/Core/hy.json
index b79b540bf4..80be71ed70 100644
--- a/Emby.Server.Implementations/Localization/Core/hy.json
+++ b/Emby.Server.Implementations/Localization/Core/hy.json
@@ -24,5 +24,10 @@
"TaskDownloadMissingSubtitles": "Ներբեռնել պակասող ենթագրերը",
"AppDeviceValues": "Հավելված` {0}, Սարք `{1}",
"ChapterNameValue": "Գլուխ {0}",
- "Collections": "Հավաքածուներ"
+ "Collections": "Հավաքածուներ",
+ "Artists": "Երաժիշտներ",
+ "Default": "Լռելյայն",
+ "Favorites": "Ընտրյալ",
+ "Forced": "Ստիպուած",
+ "Genres": "Ոճ"
}
diff --git a/Emby.Server.Implementations/Localization/Core/it.json b/Emby.Server.Implementations/Localization/Core/it.json
index 3f4a6e3e54..41097ed92e 100644
--- a/Emby.Server.Implementations/Localization/Core/it.json
+++ b/Emby.Server.Implementations/Localization/Core/it.json
@@ -118,5 +118,8 @@
"NameExtraSample": "Campione",
"NameExtraShort": "Corto",
"NameExtraThemeSong": "Sigla musicale",
- "NameExtraTrailer": "Trailer"
+ "NameExtraTrailer": "Trailer",
+ "NameExtraFeaturette": "Caratteristica",
+ "NameExtraThemeVideo": "Video tematico",
+ "NameExtraUnknown": "Extra"
}
diff --git a/Emby.Server.Implementations/Localization/Core/lt-LT.json b/Emby.Server.Implementations/Localization/Core/lt-LT.json
index 121994692f..b0fb6c52ba 100644
--- a/Emby.Server.Implementations/Localization/Core/lt-LT.json
+++ b/Emby.Server.Implementations/Localization/Core/lt-LT.json
@@ -59,8 +59,8 @@
"UserOfflineFromDevice": "{0} buvo atjungtas nuo {1}",
"UserOnlineFromDevice": "{0} prisijungęs iš {1}",
"UserPasswordChangedWithName": "Slaptažodis pakeistas naudotojui {0}",
- "UserStartedPlayingItemWithValues": "{0} paleidž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",
@@ -86,7 +86,7 @@
"TasksMaintenanceCategory": "Priežiūra",
"TaskCleanActivityLog": "Išvalyti veiklos žurnalą",
"Undefined": "Neapibrėžtas",
- "Forced": "Priverstinai",
+ "Forced": "Priverstinis",
"Default": "Numatytas",
"TaskCleanActivityLogDescription": "Ištrina senesnius nei nustatytas amžius veiklos žurnalo įrašus.",
"TaskOptimizeDatabase": "Optimizuoti duomenų bazę",
diff --git a/Emby.Server.Implementations/Localization/Core/pt-PT.json b/Emby.Server.Implementations/Localization/Core/pt-PT.json
index ce7f6d120e..e71475afd8 100644
--- a/Emby.Server.Implementations/Localization/Core/pt-PT.json
+++ b/Emby.Server.Implementations/Localization/Core/pt-PT.json
@@ -5,108 +5,121 @@
"Books": "Livros",
"ChapterNameValue": "Capítulo {0}",
"Collections": "Coleções",
- "FailedLoginAttemptWithUserName": "Tentativa de login falhada a partir de {0}",
+ "FailedLoginAttemptWithUserName": "Tentativa falhada de início de sessão do utilizador {0}",
"Favorites": "Favoritos",
"Folders": "Pastas",
"Genres": "Géneros",
"HeaderContinueWatching": "Continuar a ver",
- "HeaderFavoriteEpisodes": "Episódios Favoritos",
- "HeaderFavoriteShows": "Séries Favoritas",
- "HeaderLiveTV": "TV em Direto",
- "HeaderNextUp": "A Seguir",
- "HomeVideos": "Vídeos Caseiros",
+ "HeaderFavoriteEpisodes": "Episódios favoritos",
+ "HeaderFavoriteShows": "Séries favoritas",
+ "HeaderLiveTV": "TV em direto",
+ "HeaderNextUp": "A seguir",
+ "HomeVideos": "Vídeos caseiros",
"Inherit": "Herdar",
"LabelIpAddressValue": "Endereço IP: {0}",
"LabelRunningTimeValue": "Duração: {0}",
- "Latest": "Mais Recente",
- "MixedContent": "Conteúdo Misto",
+ "Latest": "Mais recente",
+ "MixedContent": "Conteúdo misto",
"Movies": "Filmes",
"Music": "Música",
"MusicVideos": "Videoclipes",
- "NameInstallFailed": "{0} falha na instalação",
+ "NameInstallFailed": "Falha na instalação de {0}",
"NameSeasonNumber": "Temporada {0}",
- "NameSeasonUnknown": "Temporada Desconhecida",
+ "NameSeasonUnknown": "Temporada desconhecida",
"NewVersionIsAvailable": "Está disponível para transferência uma nova versão do servidor Jellyfin.",
"NotificationOptionApplicationUpdateAvailable": "Atualização de aplicação disponível",
"NotificationOptionApplicationUpdateInstalled": "Atualização de aplicação instalada",
- "NotificationOptionAudioPlayback": "Reprodução Iniciada",
- "NotificationOptionAudioPlaybackStopped": "Reprodução Parada",
+ "NotificationOptionAudioPlayback": "Reprodução de áudio iniciada",
+ "NotificationOptionAudioPlaybackStopped": "Reprodução de áudio interrompida",
"NotificationOptionCameraImageUploaded": "Imagem da câmara enviada",
"NotificationOptionInstallationFailed": "Falha na instalação",
"NotificationOptionNewLibraryContent": "Novo conteúdo adicionado",
- "NotificationOptionPluginError": "Falha na extensão",
- "NotificationOptionPluginInstalled": "Extensão instalada",
- "NotificationOptionPluginUninstalled": "Extensão desinstalada",
- "NotificationOptionPluginUpdateInstalled": "Extensão atualizada",
+ "NotificationOptionPluginError": "Falha no plugin",
+ "NotificationOptionPluginInstalled": "Plugin instalado",
+ "NotificationOptionPluginUninstalled": "Plugin desinstalado",
+ "NotificationOptionPluginUpdateInstalled": "Atualização de plugin instalada",
"NotificationOptionServerRestartRequired": "Necessário reiniciar o servidor",
"NotificationOptionTaskFailed": "Falha em tarefa agendada",
"NotificationOptionUserLockedOut": "Utilizador bloqueado",
- "NotificationOptionVideoPlayback": "Reprodução do vídeo iniciada",
- "NotificationOptionVideoPlaybackStopped": "Reprodução do vídeo parada",
+ "NotificationOptionVideoPlayback": "Reprodução de vídeo iniciada",
+ "NotificationOptionVideoPlaybackStopped": "Reprodução de vídeo interrompida",
"Photos": "Fotografias",
"PluginInstalledWithName": "{0} foi instalado",
"PluginUninstalledWithName": "{0} foi desinstalado",
"PluginUpdatedWithName": "{0} foi atualizado",
"ScheduledTaskFailedWithName": "{0} falhou",
"Shows": "Séries",
- "StartupEmbyServerIsLoading": "O servidor Jellyfin está a iniciar. Tente novamente mais tarde.",
- "SubtitleDownloadFailureFromForItem": "Falha na transferência de legendas a partir de {0} para {1}",
- "TvShows": "Séries",
+ "StartupEmbyServerIsLoading": "O servidor Jellyfin está a iniciar. Tenta novamente dentro de instantes.",
+ "SubtitleDownloadFailureFromForItem": "Falha ao transferir legendas de {0} para {1}",
+ "TvShows": "Séries de TV",
"UserCreatedWithName": "Utilizador {0} criado",
- "UserDeletedWithName": "Utilizador {0} apagado",
+ "UserDeletedWithName": "Utilizador {0} eliminado",
"UserDownloadingItemWithValues": "{0} está a transferir {1}",
"UserLockedOutWithName": "Utilizador {0} bloqueado",
- "UserOfflineFromDevice": "{0} desligou-se a partir de {1}",
- "UserOnlineFromDevice": "{0} ligou-se a partir de {1}",
+ "UserOfflineFromDevice": "{0} desligou-se de {1}",
+ "UserOnlineFromDevice": "{0} está online em {1}",
"UserPasswordChangedWithName": "Palavra-passe alterada para o utilizador {0}",
"UserStartedPlayingItemWithValues": "{0} está a reproduzir {1} em {2}",
"UserStoppedPlayingItemWithValues": "{0} terminou a reprodução de {1} em {2}",
"VersionNumber": "Versão {0}",
- "TaskDownloadMissingSubtitlesDescription": "Procurar na internet por legendas em falta baseado na configuração de metadados.",
+ "TaskDownloadMissingSubtitlesDescription": "Procura na Internet legendas em falta com base na configuração dos metadados.",
"TaskDownloadMissingSubtitles": "Transferir legendas em falta",
- "TaskRefreshChannelsDescription": "Atualizar informação sobre canais da Internet.",
- "TaskRefreshChannels": "Atualizar Canais",
- "TaskCleanTranscodeDescription": "Apagar ficheiros de transcode com mais de um dia.",
- "TaskCleanTranscode": "Limpar a Diretoria de Transcode",
- "TaskUpdatePluginsDescription": "Faz o download e instala updates para os plugins que estão configurados para atualizar automaticamente.",
- "TaskUpdatePlugins": "Atualizar Plugins",
- "TaskRefreshPeopleDescription": "Atualizar metadados para elenco e equipa técnica da tua mediateca.",
- "TaskRefreshPeople": "Atualizar Pessoas",
- "TaskCleanLogsDescription": "Apagar ficheiros de log que têm mais de {0} dias.",
- "TaskCleanLogs": "Limpar a Diretoria de Logs",
- "TaskRefreshLibraryDescription": "Analisar a mediateca para novos ficheiros e atualizar os metadados.",
- "TaskRefreshLibrary": "Analisar mediateca",
- "TaskRefreshChapterImagesDescription": "Criar thumbnails para os vídeos que têm capítulos.",
- "TaskRefreshChapterImages": "Extrair Imagens dos Capítulos",
- "TaskCleanCacheDescription": "Apagar ficheiros em cache que já não são necessários.",
- "TaskCleanCache": "Limpar Cache",
+ "TaskRefreshChannelsDescription": "Atualiza as informações dos canais da Internet.",
+ "TaskRefreshChannels": "Atualizar canais",
+ "TaskCleanTranscodeDescription": "Elimina ficheiros de transcodificação com mais de um dia.",
+ "TaskCleanTranscode": "Limpar pasta de transcodificação",
+ "TaskUpdatePluginsDescription": "Transfere e instala atualizações dos plugins configurados para atualização automática.",
+ "TaskUpdatePlugins": "Atualizar plugins",
+ "TaskRefreshPeopleDescription": "Atualiza os metadados de atores e realizadores na tua biblioteca multimédia.",
+ "TaskRefreshPeople": "Atualizar pessoas",
+ "TaskCleanLogsDescription": "Elimina ficheiros de registo com mais de {0} dias.",
+ "TaskCleanLogs": "Limpar pasta de registos",
+ "TaskRefreshLibraryDescription": "Analisa a biblioteca multimédia à procura de novos ficheiros e atualiza os metadados.",
+ "TaskRefreshLibrary": "Analisar biblioteca multimédia",
+ "TaskRefreshChapterImagesDescription": "Cria miniaturas para vídeos que têm capítulos.",
+ "TaskRefreshChapterImages": "Extrair imagens dos capítulos",
+ "TaskCleanCacheDescription": "Elimina ficheiros de cache que já não são necessários ao sistema.",
+ "TaskCleanCache": "Limpar pasta de cache",
"TasksChannelsCategory": "Canais da Internet",
"TasksApplicationCategory": "Aplicação",
- "TasksLibraryCategory": "Mediateca",
+ "TasksLibraryCategory": "Biblioteca",
"TasksMaintenanceCategory": "Manutenção",
- "TaskCleanActivityLogDescription": "Apaga as entradas do registo de atividade anteriores à data configurada.",
+ "TaskCleanActivityLogDescription": "Elimina as entradas do registo de atividade mais antigas do que o período configurado.",
"TaskCleanActivityLog": "Limpar registo de atividade",
"Undefined": "Indefinido",
"Forced": "Forçado",
"Default": "Predefinição",
- "TaskOptimizeDatabaseDescription": "Otimiza e liberta espaço livre na base de dados. A execução desta tarefa depois de analisar a mediateca ou efetuar outras alterações que impliquem modificações na base de dados pode melhorar o desempenho.",
+ "TaskOptimizeDatabaseDescription": "Compacta a base de dados e liberta espaço não utilizado. A execução desta tarefa depois de analisar a biblioteca ou de outras alterações que modifiquem a base de dados pode melhorar o desempenho.",
"TaskOptimizeDatabase": "Otimizar base de dados",
- "TaskKeyframeExtractorDescription": "Extrai quadros-chave de ficheiros de video para criar listas de reprodução HLS mais precisas. Esta tarefa pode demorar algum tempo.",
- "TaskKeyframeExtractor": "Extrator de Quadros-chave",
+ "TaskKeyframeExtractorDescription": "Extrai fotogramas-chave de ficheiros de vídeo para criar playlists HLS mais precisas. Esta tarefa pode demorar algum tempo.",
+ "TaskKeyframeExtractor": "Extrator de fotogramas-chave",
"External": "Externo",
- "HearingImpaired": "Surdo",
+ "HearingImpaired": "Deficiência auditiva",
"TaskRefreshTrickplayImages": "Gerar imagens de trickplay",
"TaskRefreshTrickplayImagesDescription": "Cria pré-visualizações de trickplay para vídeos nas bibliotecas ativadas.",
"TaskAudioNormalizationDescription": "Analisa os ficheiros para obter dados de normalização de áudio.",
"TaskAudioNormalization": "Normalização de áudio",
- "TaskExtractMediaSegments": "Analisar segmentos de multimédia",
- "TaskDownloadMissingLyrics": "Transferir letra em falta",
- "TaskMoveTrickplayImages": "Migrar a localização da imagem do Trickplay",
- "TaskDownloadMissingLyricsDescription": "Transferir letra para músicas",
- "TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos de multimédia a partir de plugins com suporte para MediaSegment.",
- "TaskMoveTrickplayImagesDescription": "Move os ficheiros trickplay existentes de acordo com as definições da mediateca.",
- "CleanupUserDataTaskDescription": "Apaga todos os dados de utilizador (estados de reprodução, favoritos, etc) de arquivos média não presentes há 90 dias ou mais.",
+ "TaskExtractMediaSegments": "Analisar segmentos multimédia",
+ "TaskDownloadMissingLyrics": "Transferir letras em falta",
+ "TaskMoveTrickplayImages": "Migrar localização das imagens de trickplay",
+ "TaskDownloadMissingLyricsDescription": "Transfere letras de músicas",
+ "TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos multimédia de plugins com MediaSegment ativado.",
+ "TaskMoveTrickplayImagesDescription": "Move os ficheiros de trickplay existentes de acordo com as definições da biblioteca.",
+ "CleanupUserDataTaskDescription": "Remove todos os dados de utilizador (estado de reprodução, estado de favorito, etc.) de conteúdos multimédia que já não estejam presentes há pelo menos 90 dias.",
"CleanupUserDataTask": "Limpeza de dados de utilizador",
"Original": "Original",
- "LyricDownloadFailureFromForItem": "Erro ao descarregar letras de {0} para {1}"
+ "LyricDownloadFailureFromForItem": "Falha ao transferir letras de {0} para {1}",
+ "NameExtraDeletedScene": "Cena eliminada",
+ "NameExtraInterview": "Entrevista",
+ "NameExtraUnknown": "Extra",
+ "NameExtraBehindTheScenes": "Bastidores",
+ "NameExtraSample": "Amostra",
+ "NameExtraScene": "Cena",
+ "NameExtraClip": "Excerto",
+ "NameExtraFeaturette": "Minidocumentário",
+ "NameExtraThemeSong": "Tema musical",
+ "NameExtraThemeVideo": "Vídeo temático",
+ "NameExtraShort": "Curta-metragem",
+ "NameExtraNumbered": "{0} {1}",
+ "NameExtraTrailer": "Trailer"
}
diff --git a/Emby.Server.Implementations/Localization/Core/pt.json b/Emby.Server.Implementations/Localization/Core/pt.json
index 9a6e213d9a..8299a25992 100644
--- a/Emby.Server.Implementations/Localization/Core/pt.json
+++ b/Emby.Server.Implementations/Localization/Core/pt.json
@@ -1,124 +1,125 @@
{
- "HeaderLiveTV": "TV Em Direto",
+ "HeaderLiveTV": "TV em direto",
"Collections": "Coleções",
"Books": "Livros",
"Artists": "Artistas",
- "HeaderNextUp": "A Seguir",
- "HeaderFavoriteEpisodes": "Episódios Favoritos",
- "HeaderFavoriteShows": "Séries Favoritas",
+ "HeaderNextUp": "A seguir",
+ "HeaderFavoriteEpisodes": "Episódios favoritos",
+ "HeaderFavoriteShows": "Séries favoritas",
"HeaderContinueWatching": "Continuar a ver",
"Genres": "Géneros",
"Folders": "Pastas",
"Favorites": "Favoritos",
- "UserDownloadingItemWithValues": "{0} está sendo baixado {1}",
+ "UserDownloadingItemWithValues": "{0} está a transferir {1}",
"VersionNumber": "Versão {0}",
"UserStoppedPlayingItemWithValues": "{0} terminou a reprodução de {1} em {2}",
- "UserStartedPlayingItemWithValues": "{0} está reproduzindo {1} em {2}",
- "UserPasswordChangedWithName": "A senha do usuário {0} foi alterada",
- "UserOnlineFromDevice": "{0} está online a partir de {1}",
- "UserOfflineFromDevice": "{0} desconectou-se a partir de {1}",
- "UserLockedOutWithName": "O usuário {0} foi bloqueado",
- "UserDeletedWithName": "O usuário {0} foi removido",
- "UserCreatedWithName": "O usuário {0} foi criado",
- "TvShows": "Séries",
- "SubtitleDownloadFailureFromForItem": "Falha na transferência de legendas de {0} para {1}",
- "StartupEmbyServerIsLoading": "O servidor Jellyfin está iniciando. Tente novamente dentro de momentos.",
+ "UserStartedPlayingItemWithValues": "{0} está a reproduzir {1} em {2}",
+ "UserPasswordChangedWithName": "Palavra-passe alterada para o utilizador {0}",
+ "UserOnlineFromDevice": "{0} está online em {1}",
+ "UserOfflineFromDevice": "{0} desligou-se de {1}",
+ "UserLockedOutWithName": "Utilizador {0} bloqueado",
+ "UserDeletedWithName": "Utilizador {0} eliminado",
+ "UserCreatedWithName": "Utilizador {0} criado",
+ "TvShows": "Séries de TV",
+ "SubtitleDownloadFailureFromForItem": "Falha ao transferir legendas de {0} para {1}",
+ "StartupEmbyServerIsLoading": "O servidor Jellyfin está a iniciar. Tenta novamente dentro de instantes.",
"ScheduledTaskFailedWithName": "{0} falhou",
"PluginUpdatedWithName": "{0} foi atualizado",
"PluginUninstalledWithName": "{0} foi desinstalado",
"PluginInstalledWithName": "{0} foi instalado",
- "NotificationOptionVideoPlaybackStopped": "Reprodução de vídeo parada",
+ "NotificationOptionVideoPlaybackStopped": "Reprodução de vídeo interrompida",
"NotificationOptionVideoPlayback": "Reprodução de vídeo iniciada",
- "NotificationOptionUserLockedOut": "Usuário bloqueado",
- "NotificationOptionTaskFailed": "Falha na tarefa agendada",
- "NotificationOptionServerRestartRequired": "É necessário reiniciar o servidor",
- "NotificationOptionPluginUpdateInstalled": "Plugin atualizado",
+ "NotificationOptionUserLockedOut": "Utilizador bloqueado",
+ "NotificationOptionTaskFailed": "Falha em tarefa agendada",
+ "NotificationOptionServerRestartRequired": "Necessário reiniciar o servidor",
+ "NotificationOptionPluginUpdateInstalled": "Atualização de plugin instalada",
"NotificationOptionPluginUninstalled": "Plugin desinstalado",
"NotificationOptionPluginInstalled": "Plugin instalado",
"NotificationOptionPluginError": "Falha no plugin",
"NotificationOptionNewLibraryContent": "Novo conteúdo adicionado",
- "NotificationOptionInstallationFailed": "Falha de instalação",
- "NotificationOptionCameraImageUploaded": "Imagem de câmera enviada",
- "NotificationOptionAudioPlaybackStopped": "Reprodução Parada",
- "NotificationOptionAudioPlayback": "Reprodução Iniciada",
- "NotificationOptionApplicationUpdateInstalled": "A atualização do aplicativo foi instalada",
- "NotificationOptionApplicationUpdateAvailable": "Uma atualização do aplicativo está disponível",
- "NewVersionIsAvailable": "Uma nova versão do servidor Jellyfin está disponível para download.",
- "NameSeasonUnknown": "Temporada Desconhecida",
+ "NotificationOptionInstallationFailed": "Falha na instalação",
+ "NotificationOptionCameraImageUploaded": "Imagem da câmara enviada",
+ "NotificationOptionAudioPlaybackStopped": "Reprodução de áudio interrompida",
+ "NotificationOptionAudioPlayback": "Reprodução de áudio iniciada",
+ "NotificationOptionApplicationUpdateInstalled": "Atualização de aplicação instalada",
+ "NotificationOptionApplicationUpdateAvailable": "Atualização de aplicação disponível",
+ "NewVersionIsAvailable": "Está disponível para transferência uma nova versão do servidor Jellyfin.",
+ "NameSeasonUnknown": "Temporada desconhecida",
"NameSeasonNumber": "Temporada {0}",
"NameInstallFailed": "Falha na instalação de {0}",
"MusicVideos": "Videoclipes",
"Music": "Música",
- "MixedContent": "Conteúdo diverso",
- "Latest": "Mais Recente",
+ "MixedContent": "Conteúdo misto",
+ "Latest": "Mais recente",
"LabelRunningTimeValue": "Duração: {0}",
- "LabelIpAddressValue": "Endereço de IP: {0}",
+ "LabelIpAddressValue": "Endereço IP: {0}",
"Inherit": "Herdar",
- "HomeVideos": "Vídeos Caseiros",
+ "HomeVideos": "Vídeos caseiros",
"Shows": "Séries",
"Photos": "Fotografias",
"Movies": "Filmes",
- "FailedLoginAttemptWithUserName": "Tentativa de início de sessão falhada a partir de {0}",
+ "FailedLoginAttemptWithUserName": "Tentativa falhada de início de sessão do utilizador {0}",
"ChapterNameValue": "Capítulo {0}",
"AuthenticationSucceededWithUserName": "{0} autenticado com sucesso",
"AppDeviceValues": "Aplicação: {0}, Dispositivo: {1}",
- "TaskCleanCache": "Limpar Diretório de Cache",
+ "TaskCleanCache": "Limpar pasta de cache",
"TasksApplicationCategory": "Aplicação",
- "TasksLibraryCategory": "Mediateca",
+ "TasksLibraryCategory": "Biblioteca",
"TasksMaintenanceCategory": "Manutenção",
- "TaskRefreshChannels": "Atualizar Canais",
- "TaskUpdatePlugins": "Atualizar Plugins",
- "TaskCleanLogsDescription": "Deletar arquivos de log que existe a mais de {0} dias.",
- "TaskCleanLogs": "Limpar diretório de logs",
- "TaskRefreshLibrary": "Analisar mediateca",
+ "TaskRefreshChannels": "Atualizar canais",
+ "TaskUpdatePlugins": "Atualizar plugins",
+ "TaskCleanLogsDescription": "Elimina ficheiros de registo com mais de {0} dias.",
+ "TaskCleanLogs": "Limpar pasta de registos",
+ "TaskRefreshLibrary": "Analisar biblioteca multimédia",
"TaskRefreshChapterImagesDescription": "Cria miniaturas para vídeos que têm capítulos.",
- "TaskCleanCacheDescription": "Apaga ficheiros em cache que já não são usados pelo sistema.",
- "TasksChannelsCategory": "Canais de Internet",
- "TaskRefreshChapterImages": "Extrair Imagens do Capítulo",
- "TaskDownloadMissingSubtitlesDescription": "Pesquisa na Internet as legendas em falta com base na configuração de metadados.",
+ "TaskCleanCacheDescription": "Elimina ficheiros de cache que já não são necessários ao sistema.",
+ "TasksChannelsCategory": "Canais da Internet",
+ "TaskRefreshChapterImages": "Extrair imagens dos capítulos",
+ "TaskDownloadMissingSubtitlesDescription": "Procura na Internet legendas em falta com base na configuração dos metadados.",
"TaskDownloadMissingSubtitles": "Transferir legendas em falta",
- "TaskRefreshChannelsDescription": "Atualiza as informações do canal da Internet.",
- "TaskCleanTranscodeDescription": "Apagar os ficheiros com mais de um dia, de Transcode.",
- "TaskCleanTranscode": "Limpar o diretório de Transcode",
- "TaskUpdatePluginsDescription": "Baixa e instala as atualizações para plug-ins configurados para atualização automática.",
- "TaskRefreshPeopleDescription": "Atualizar metadados para elenco e equipa técnica da tua mediateca.",
+ "TaskRefreshChannelsDescription": "Atualiza as informações dos canais da Internet.",
+ "TaskCleanTranscodeDescription": "Elimina ficheiros de transcodificação com mais de um dia.",
+ "TaskCleanTranscode": "Limpar pasta de transcodificação",
+ "TaskUpdatePluginsDescription": "Transfere e instala atualizações dos plugins configurados para atualização automática.",
+ "TaskRefreshPeopleDescription": "Atualiza os metadados de atores e realizadores na tua biblioteca multimédia.",
"TaskRefreshPeople": "Atualizar pessoas",
- "TaskRefreshLibraryDescription": "Analisar a mediateca para novos ficheiros e atualizar os metadados.",
- "TaskCleanActivityLog": "Limpar registro de atividade",
+ "TaskRefreshLibraryDescription": "Analisa a biblioteca multimédia à procura de novos ficheiros e atualiza os metadados.",
+ "TaskCleanActivityLog": "Limpar registo de atividade",
"Undefined": "Indefinido",
"Forced": "Forçado",
"Default": "Predefinição",
- "TaskCleanActivityLogDescription": "Apaga itens no registro com idade acima do que é configurado.",
+ "TaskCleanActivityLogDescription": "Elimina as entradas do registo de atividade mais antigas do que o período configurado.",
"TaskOptimizeDatabase": "Otimizar base de dados",
- "TaskOptimizeDatabaseDescription": "Otimiza e liberta espaço livre na base de dados. A execução desta tarefa depois de analisar a mediateca ou efetuar outras alterações que impliquem modificações na base de dados pode melhorar o desempenho.",
+ "TaskOptimizeDatabaseDescription": "Compacta a base de dados e liberta espaço não utilizado. A execução desta tarefa depois de analisar a biblioteca ou de outras alterações que modifiquem a base de dados pode melhorar o desempenho.",
"External": "Externo",
- "HearingImpaired": "Problemas auditivos",
- "TaskKeyframeExtractor": "Extrator de quadro-chave",
- "TaskKeyframeExtractorDescription": "Retira frames chave do video para criar listas HLS precisas. Esta tarefa pode correr durante algum tempo.",
- "TaskRefreshTrickplayImages": "Gerar imagens de Trickplay",
- "TaskRefreshTrickplayImagesDescription": "Cria miniaturas de pré-visualização (Trickplay) para vídeos nas bibliotecas ativadas.",
+ "HearingImpaired": "Deficiência auditiva",
+ "TaskKeyframeExtractor": "Extrator de fotogramas-chave",
+ "TaskKeyframeExtractorDescription": "Extrai fotogramas-chave de ficheiros de vídeo para criar playlists HLS mais precisas. Esta tarefa pode demorar algum tempo.",
+ "TaskRefreshTrickplayImages": "Gerar imagens de trickplay",
+ "TaskRefreshTrickplayImagesDescription": "Cria pré-visualizações de trickplay para vídeos nas bibliotecas ativadas.",
"TaskAudioNormalizationDescription": "Analisa os ficheiros para obter dados de normalização de áudio.",
"TaskAudioNormalization": "Normalização de áudio",
- "TaskDownloadMissingLyrics": "Transferir letra em falta",
- "TaskDownloadMissingLyricsDescription": "Transferir letra para músicas",
- "TaskMoveTrickplayImagesDescription": "Move os ficheiros Trickplay existentes de acordo com as definições da mediateca.",
- "TaskExtractMediaSegments": "Analisar segmentos de multimédia",
- "TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos de multimédia a partir de plugins com suporte para MediaSegment.",
- "TaskMoveTrickplayImages": "Migrar a localização das imagens de Trickplay",
- "CleanupUserDataTask": "Task de limpeza de dados do usuário",
- "CleanupUserDataTaskDescription": "Remove todos os dados do usuário (progresso, favoritos etc) de mídias que não estão presentes há pelo menos 90 dias.",
+ "TaskDownloadMissingLyrics": "Transferir letras em falta",
+ "TaskDownloadMissingLyricsDescription": "Transfere letras de músicas",
+ "TaskMoveTrickplayImagesDescription": "Move os ficheiros de trickplay existentes de acordo com as definições da biblioteca.",
+ "TaskExtractMediaSegments": "Analisar segmentos multimédia",
+ "TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos multimédia de plugins com MediaSegment ativado.",
+ "TaskMoveTrickplayImages": "Migrar localização das imagens de trickplay",
+ "CleanupUserDataTask": "Limpeza de dados de utilizador",
+ "CleanupUserDataTaskDescription": "Remove todos os dados de utilizador (estado de reprodução, estado de favorito, etc.) de conteúdos multimédia que já não estejam presentes há pelo menos 90 dias.",
"Original": "Original",
- "LyricDownloadFailureFromForItem": "Erro ao descarregar letras de {0} para {1}",
+ "LyricDownloadFailureFromForItem": "Falha ao transferir letras de {0} para {1}",
"NameExtraBehindTheScenes": "Bastidores",
- "NameExtraClip": "Clip",
- "NameExtraDeletedScene": "Cena Eliminada",
- "NameExtraFeaturette": "Média-metragem",
+ "NameExtraClip": "Excerto",
+ "NameExtraDeletedScene": "Cena eliminada",
+ "NameExtraFeaturette": "Minidocumentário",
"NameExtraInterview": "Entrevista",
"NameExtraSample": "Amostra",
"NameExtraShort": "Curta-metragem",
- "NameExtraThemeSong": "Tema Principal",
- "NameExtraThemeVideo": "Vídeo de Abertura",
+ "NameExtraThemeSong": "Tema musical",
+ "NameExtraThemeVideo": "Vídeo temático",
"NameExtraScene": "Cena",
"NameExtraUnknown": "Extra",
- "NameExtraTrailer": "Trailer"
+ "NameExtraTrailer": "Trailer",
+ "NameExtraNumbered": "{0} {1}"
}
diff --git a/Emby.Server.Implementations/Localization/Core/ro.json b/Emby.Server.Implementations/Localization/Core/ro.json
index ea83b88951..358c19881f 100644
--- a/Emby.Server.Implementations/Localization/Core/ro.json
+++ b/Emby.Server.Implementations/Localization/Core/ro.json
@@ -108,5 +108,8 @@
"CleanupUserDataTask": "Sarcina de curatare a datelor utilizatorului",
"CleanupUserDataTaskDescription": "Sterge toate datele utilizatorului (starea vizionarii, starea favoritelor etc.) de pe suporturile media care nu mai sunt prezente timp de cel puțin 90 de zile.",
"LyricDownloadFailureFromForItem": "Versurile nu au putut fi descărcate din {0} pentru {1}",
- "Original": "Original"
+ "Original": "Original",
+ "NameExtraBehindTheScenes": "În culise",
+ "NameExtraClip": "Clip",
+ "NameExtraDeletedScene": "Scenă ștearsă"
}
diff --git a/Emby.Server.Implementations/Localization/Core/ru.json b/Emby.Server.Implementations/Localization/Core/ru.json
index 6382fc083f..c13fd3ebdc 100644
--- a/Emby.Server.Implementations/Localization/Core/ru.json
+++ b/Emby.Server.Implementations/Localization/Core/ru.json
@@ -109,7 +109,7 @@
"CleanupUserDataTaskDescription": "Очищает все пользовательские данные (состояние просмотра, статус избранного и т.д.) с медиа, отсутствующих по меньшей мере в течение 90 дней.",
"Original": "Оригинальный",
"LyricDownloadFailureFromForItem": "Не получилось скачать текст песни с {0} для {1}",
- "NameExtraBehindTheScenes": "За кулисами",
+ "NameExtraBehindTheScenes": "За кадром",
"NameExtraClip": "Отрывок",
"NameExtraDeletedScene": "Удалённая сцена",
"NameExtraFeaturette": "Короткометражка",
@@ -119,5 +119,7 @@
"NameExtraThemeSong": "Заглавная песня",
"NameExtraThemeVideo": "Заглавное видео",
"NameExtraTrailer": "Трейлер",
- "NameExtraUnknown": "Дополнительный материал"
+ "NameExtraUnknown": "Дополнительный материал",
+ "NameExtraNumbered": "{0} {1}",
+ "NameExtraShort": "Короткометражка"
}
diff --git a/Emby.Server.Implementations/Localization/Core/sk.json b/Emby.Server.Implementations/Localization/Core/sk.json
index babacc31a9..9573eeefe4 100644
--- a/Emby.Server.Implementations/Localization/Core/sk.json
+++ b/Emby.Server.Implementations/Localization/Core/sk.json
@@ -91,7 +91,7 @@
"Default": "Predvolené",
"TaskOptimizeDatabaseDescription": "Zmenší databázu a odstráni prázdne miesto. Spustenie tejto úlohy po skenovaní knižnice alebo po iných zmenách zahŕňajúcich úpravy databáze môže zlepšiť výkon.",
"TaskOptimizeDatabase": "Optimalizovať databázu",
- "TaskKeyframeExtractorDescription": "Extrahuje kľúčové snímky z video súborov na vytvorenie presnejších HLS playlistov. Táto úloha môže trvať dlhšiu dobu.",
+ "TaskKeyframeExtractorDescription": "Extrahuje kľúčové snímky z video súborov na vytvorenie presnejších HLS zoznamov prehrávania. Táto úloha môže trvať dlhšiu dobu.",
"TaskKeyframeExtractor": "Extraktor kľúčových snímkov",
"External": "Externé",
"HearingImpaired": "Sluchovo postihnutí",
diff --git a/Emby.Server.Implementations/Localization/Core/sq.json b/Emby.Server.Implementations/Localization/Core/sq.json
index e13f7b09e1..11b25e9795 100644
--- a/Emby.Server.Implementations/Localization/Core/sq.json
+++ b/Emby.Server.Implementations/Localization/Core/sq.json
@@ -108,5 +108,18 @@
"CleanupUserDataTaskDescription": "Pastron të gjitha të dhënat e përdorueseve (gjendja e shikimit, statusi i të preferuarave etj.) nga mediat që nuk janë më të pranishme për të paktën 90 ditë.",
"CleanupUserDataTask": "Veprim për pastrimin të dhënave të përdorueseve",
"LyricDownloadFailureFromForItem": "Teksti i këngës nuk arriti të shkarkohej nga {0} për {1}",
- "Original": "Origjinal"
+ "Original": "Origjinal",
+ "NameExtraBehindTheScenes": "Pamje nga prapaskenat",
+ "NameExtraClip": "Pjesë",
+ "NameExtraDeletedScene": "Skenë e fshirë",
+ "NameExtraFeaturette": "Film i shkurtër",
+ "NameExtraInterview": "Intervistë",
+ "NameExtraNumbered": "{0} {1}",
+ "NameExtraSample": "Shembull",
+ "NameExtraScene": "Skenë",
+ "NameExtraShort": "Film i shkurtër",
+ "NameExtraThemeSong": "Kënga e temës",
+ "NameExtraThemeVideo": "Videoja e temës",
+ "NameExtraTrailer": "Parapamje",
+ "NameExtraUnknown": "Shtesë"
}
diff --git a/Emby.Server.Implementations/Localization/Core/sr.json b/Emby.Server.Implementations/Localization/Core/sr.json
index 92f309c80c..de0e425098 100644
--- a/Emby.Server.Implementations/Localization/Core/sr.json
+++ b/Emby.Server.Implementations/Localization/Core/sr.json
@@ -108,5 +108,18 @@
"TaskMoveTrickplayImages": "Промени локацију сличица за визуелно премотавање",
"TaskDownloadMissingLyricsDescription": "Преузми стихове песама",
"LyricDownloadFailureFromForItem": "Није успело преузимање стихова са {0} за {1}",
- "Original": "Изворно"
+ "Original": "Изворно",
+ "NameExtraBehindTheScenes": "Иза кулиса",
+ "NameExtraClip": "Исечак",
+ "NameExtraDeletedScene": "Обрисана сцена",
+ "NameExtraFeaturette": "Кратки прилог",
+ "NameExtraInterview": "Интервју",
+ "NameExtraNumbered": "{0} {1}",
+ "NameExtraSample": "Узорак",
+ "NameExtraScene": "Сцена",
+ "NameExtraShort": "Кратки филм",
+ "NameExtraThemeSong": "Музичка тема",
+ "NameExtraThemeVideo": "Спот музичке теме",
+ "NameExtraTrailer": "Реклама",
+ "NameExtraUnknown": "Додатак"
}
diff --git a/Emby.Server.Implementations/Localization/Core/sv.json b/Emby.Server.Implementations/Localization/Core/sv.json
index 30c85baaba..7d741bca36 100644
--- a/Emby.Server.Implementations/Localization/Core/sv.json
+++ b/Emby.Server.Implementations/Localization/Core/sv.json
@@ -116,5 +116,10 @@
"NameExtraScene": "Scen",
"NameExtraShort": "Kortfilm",
"NameExtraThemeSong": "Signaturmelodi",
- "NameExtraTrailer": "Trailer"
+ "NameExtraTrailer": "Trailer",
+ "NameExtraClip": "Klipp",
+ "NameExtraFeaturette": "Kortfilm",
+ "NameExtraSample": "Prov",
+ "NameExtraThemeVideo": "Signaturvideo",
+ "NameExtraUnknown": "Extra"
}
diff --git a/Emby.Server.Implementations/Localization/Core/ta.json b/Emby.Server.Implementations/Localization/Core/ta.json
index f613b973db..256f349926 100644
--- a/Emby.Server.Implementations/Localization/Core/ta.json
+++ b/Emby.Server.Implementations/Localization/Core/ta.json
@@ -106,5 +106,20 @@
"TaskExtractMediaSegments": "மீடியா பிரிவு ஸ்கேன்",
"TaskExtractMediaSegmentsDescription": "மீடியாசெக்மென்ட் இயக்கப்பட்ட செருகுநிரல்களிலிருந்து மீடியா பிரிவுகளைப் பிரித்தெடுக்கிறது அல்லது பெறுகிறது.",
"CleanupUserDataTaskDescription": "குறைந்தது 90 நாட்களுக்கு இல்லாத மீடியாவிலிருந்து அனைத்து பயனர் தரவையும் (கண்காணிப்பு நிலை, பிடித்த நிலை போன்றவை) சுத்தம் செய்கிறது.",
- "CleanupUserDataTask": "பயனர் தரவை சுத்தம் செய்யும் பணி"
+ "CleanupUserDataTask": "பயனர் தரவை சுத்தம் செய்யும் பணி",
+ "LyricDownloadFailureFromForItem": "{0} இலிருந்து {1} க்கு பாடல் வரிகளைப் பதிவிறக்க முடியவில்லை",
+ "NameExtraBehindTheScenes": "திரைக்குப் பின்னால்",
+ "NameExtraClip": "துண்டுக்காட்சி",
+ "NameExtraDeletedScene": "நீக்கப்பட்ட காட்சிகள்",
+ "NameExtraFeaturette": "திரைப்பின்னணித் தொகுப்பு",
+ "NameExtraInterview": "நேர்காணல்",
+ "NameExtraSample": "மாதிரி",
+ "NameExtraScene": "காட்சி",
+ "NameExtraShort": "குறுகிய",
+ "NameExtraThemeSong": "மையப் பாடல்",
+ "NameExtraThemeVideo": "மையக் காணொளி",
+ "NameExtraTrailer": "முன்னோட்டம்",
+ "NameExtraUnknown": "கூடுதல்",
+ "Original": "அசல்",
+ "NameExtraNumbered": "{0} {1}"
}
diff --git a/Emby.Server.Implementations/Localization/Core/tr.json b/Emby.Server.Implementations/Localization/Core/tr.json
index 1aa4b6a4b6..67f4ecc4d5 100644
--- a/Emby.Server.Implementations/Localization/Core/tr.json
+++ b/Emby.Server.Implementations/Localization/Core/tr.json
@@ -1,5 +1,5 @@
{
- "AppDeviceValues": "Uygulama: {0}, Aygıt: {1}",
+ "AppDeviceValues": "Uygulama: {0}, Cihaz: {1}",
"Artists": "Sanatçılar",
"AuthenticationSucceededWithUserName": "{0} kimliği başarıyla doğrulandı",
"Books": "Kitaplar",
diff --git a/Emby.Server.Implementations/Localization/Core/zh-CN.json b/Emby.Server.Implementations/Localization/Core/zh-CN.json
index d6e4be01e8..9590d9c1c9 100644
--- a/Emby.Server.Implementations/Localization/Core/zh-CN.json
+++ b/Emby.Server.Implementations/Localization/Core/zh-CN.json
@@ -115,11 +115,11 @@
"NameExtraFeaturette": "花絮",
"NameExtraInterview": "采访",
"NameExtraNumbered": "{0} {1}",
- "NameExtraSample": "样本",
- "NameExtraScene": "场景",
- "NameExtraShort": "短片",
+ "NameExtraSample": "试看",
+ "NameExtraScene": "精选片段",
+ "NameExtraShort": "短篇",
"NameExtraThemeSong": "主题曲",
"NameExtraThemeVideo": "主题视频",
"NameExtraTrailer": "预告片",
- "NameExtraUnknown": "额外"
+ "NameExtraUnknown": "额外内容"
}
diff --git a/Emby.Server.Implementations/Localization/Core/zh-HK.json b/Emby.Server.Implementations/Localization/Core/zh-HK.json
index b2fcbc93a2..9557742d39 100644
--- a/Emby.Server.Implementations/Localization/Core/zh-HK.json
+++ b/Emby.Server.Implementations/Localization/Core/zh-HK.json
@@ -10,8 +10,8 @@
"Folders": "資料夾",
"Genres": "風格",
"HeaderContinueWatching": "繼續睇返",
- "HeaderFavoriteEpisodes": "心水嘅劇集",
- "HeaderFavoriteShows": "心水嘅節目",
+ "HeaderFavoriteEpisodes": "心水劇集",
+ "HeaderFavoriteShows": "心水節目",
"HeaderLiveTV": "電視直播",
"HeaderNextUp": "跟住落嚟",
"HomeVideos": "家庭影片",
@@ -108,5 +108,18 @@
"CleanupUserDataTask": "清理使用者資料嘅任務",
"CleanupUserDataTaskDescription": "清理已消失至少 90 日嘅媒體用家數據(包括觀看狀態、心水狀態等)。",
"LyricDownloadFailureFromForItem": "冇辦法從 {0} 下載 {1} 嘅歌詞",
- "Original": "原始"
+ "Original": "原始",
+ "NameExtraBehindTheScenes": "幕後花絮",
+ "NameExtraClip": "片段",
+ "NameExtraDeletedScene": "刪減場景",
+ "NameExtraFeaturette": "花絮",
+ "NameExtraInterview": "采訪",
+ "NameExtraNumbered": "{0} {1}",
+ "NameExtraSample": "試看",
+ "NameExtraScene": "精選片段",
+ "NameExtraShort": "短篇",
+ "NameExtraThemeSong": "主題曲",
+ "NameExtraThemeVideo": "主題影片",
+ "NameExtraTrailer": "預告片",
+ "NameExtraUnknown": "額外內容"
}
diff --git a/Emby.Server.Implementations/Localization/Core/zh-TW.json b/Emby.Server.Implementations/Localization/Core/zh-TW.json
index 4e0dff87b9..bcc5b0b44f 100644
--- a/Emby.Server.Implementations/Localization/Core/zh-TW.json
+++ b/Emby.Server.Implementations/Localization/Core/zh-TW.json
@@ -108,5 +108,18 @@
"CleanupUserDataTask": "用戶資料清理工作",
"CleanupUserDataTaskDescription": "從用戶資料中清除已被刪除超過 90 天的媒體的相關資料。",
"Original": "原作",
- "LyricDownloadFailureFromForItem": "無法從 {0} 下載 {1} 的歌詞"
+ "LyricDownloadFailureFromForItem": "無法從 {0} 下載 {1} 的歌詞",
+ "NameExtraBehindTheScenes": "幕後花絮",
+ "NameExtraClip": "片段",
+ "NameExtraDeletedScene": "刪減場景",
+ "NameExtraFeaturette": "花絮",
+ "NameExtraInterview": "采訪",
+ "NameExtraNumbered": "{0} {1}",
+ "NameExtraSample": "試看",
+ "NameExtraScene": "精選片段",
+ "NameExtraShort": "短篇",
+ "NameExtraThemeSong": "主題曲",
+ "NameExtraThemeVideo": "主題影片",
+ "NameExtraTrailer": "預告片",
+ "NameExtraUnknown": "額外內容"
}
diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs
index 5d62332552..f4aa0ad03a 100644
--- a/Emby.Server.Implementations/Session/SessionManager.cs
+++ b/Emby.Server.Implementations/Session/SessionManager.cs
@@ -655,7 +655,7 @@ namespace Emby.Server.Implementations.Session
ItemId = session.NowPlayingItem is null ? Guid.Empty : session.NowPlayingItem.Id,
SessionId = session.Id,
MediaSourceId = session.PlayState?.MediaSourceId,
- PositionTicks = session.PlayState?.PositionTicks
+ PositionTicks = session.LastPlaybackCheckInPositionTicks
}).ConfigureAwait(false);
}
catch (Exception ex)
diff --git a/Jellyfin.Server.Implementations/Item/ItemCountService.cs b/Jellyfin.Server.Implementations/Item/ItemCountService.cs
index fd683fb57e..a320ba89d1 100644
--- a/Jellyfin.Server.Implementations/Item/ItemCountService.cs
+++ b/Jellyfin.Server.Implementations/Item/ItemCountService.cs
@@ -318,13 +318,14 @@ public class ItemCountService : IItemCountService
var parentIdsArray = parentIds.ToArray();
var hierarchicalCounts = dbContext.BaseItems
- .Where(b => b.ParentId.HasValue && parentIdsArray.Contains(b.ParentId.Value))
+ .Where(b => b.ParentId.HasValue)
+ .WhereOneOrMany(parentIdsArray, b => b.ParentId!.Value)
.GroupBy(b => b.ParentId!.Value)
.Select(g => new { ParentId = g.Key, Count = g.Count() })
.ToDictionary(x => x.ParentId, x => x.Count);
var linkedCounts = dbContext.LinkedChildren
- .Where(lc => parentIdsArray.Contains(lc.ParentId))
+ .WhereOneOrMany(parentIdsArray, lc => lc.ParentId)
.GroupBy(lc => lc.ParentId)
.Select(g => new { ParentId = g.Key, Count = g.Count() })
.ToDictionary(x => x.ParentId, x => x.Count);
diff --git a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs
index 3585f85c61..efff3457a3 100644
--- a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs
+++ b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs
@@ -538,7 +538,7 @@ public class ItemPersistenceService : IItemPersistenceService
var childIdsToCheck = resolvedChildren.Select(c => c.ChildId).Distinct().ToList();
var existingChildIds = childIdsToCheck.Count > 0
? context.BaseItems
- .Where(e => childIdsToCheck.Contains(e.Id))
+ .WhereOneOrMany(childIdsToCheck, e => e.Id)
.Select(e => e.Id)
.ToHashSet()
: [];
diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs
index 81408d9aa8..fea6084267 100644
--- a/Jellyfin.Server.Implementations/Users/UserManager.cs
+++ b/Jellyfin.Server.Implementations/Users/UserManager.cs
@@ -225,17 +225,8 @@ namespace Jellyfin.Server.Implementations.Users
?? throw new ResourceNotFoundException(nameof(user.Id));
dbContext.Entry(dbUser).CurrentValues.SetValues(user);
- dbUser.Permissions.Clear();
- foreach (var permission in user.Permissions)
- {
- dbUser.Permissions.Add(new Permission(permission.Kind, permission.Value));
- }
-
- dbUser.Preferences.Clear();
- foreach (var preference in user.Preferences)
- {
- dbUser.Preferences.Add(new Preference(preference.Kind, preference.Value));
- }
+ SyncPermissions(dbUser, user.Permissions);
+ SyncPreferences(dbUser, user.Preferences);
dbUser.AccessSchedules.Clear();
foreach (var accessSchedule in user.AccessSchedules)
@@ -269,6 +260,60 @@ namespace Jellyfin.Server.Implementations.Users
}
}
+ private static void SyncPermissions(User dbUser, ICollection<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.
diff --git a/MediaBrowser.Controller/Session/SessionInfo.cs b/MediaBrowser.Controller/Session/SessionInfo.cs
index fb68bfb770..d9bc79dec5 100644
--- a/MediaBrowser.Controller/Session/SessionInfo.cs
+++ b/MediaBrowser.Controller/Session/SessionInfo.cs
@@ -28,6 +28,7 @@ namespace MediaBrowser.Controller.Session
private readonly Lock _progressLock = new();
private Timer _progressTimer;
private PlaybackProgressInfo _lastProgressInfo;
+ private long? _lastPlaybackCheckInPositionTicks;
private bool _disposed;
@@ -125,6 +126,22 @@ namespace MediaBrowser.Controller.Session
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>
/// <value>The last paused date.</value>
@@ -372,6 +389,7 @@ namespace MediaBrowser.Controller.Session
lock (_progressLock)
{
+ _lastPlaybackCheckInPositionTicks = progressInfo.PositionTicks;
_lastProgressInfo = progressInfo;
if (_progressTimer is null)
diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs
index 397c916a4f..7dee5fd31d 100644
--- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs
+++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs
@@ -14,6 +14,7 @@ using MediaBrowser.Providers.Music;
using MetaBrainz.MusicBrainz;
using MetaBrainz.MusicBrainz.Interfaces.Entities;
using MetaBrainz.MusicBrainz.Interfaces.Searches;
+using Microsoft.Extensions.Logging;
namespace MediaBrowser.Providers.Plugins.MusicBrainz;
@@ -22,6 +23,17 @@ namespace MediaBrowser.Providers.Plugins.MusicBrainz;
/// </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);
diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs
index a9e950fb64..c3d13ed42c 100644
--- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs
+++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs
@@ -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)
{
diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzQueryExtensions.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzQueryExtensions.cs
new file mode 100644
index 0000000000..f3df41e942
--- /dev/null
+++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzQueryExtensions.cs
@@ -0,0 +1,112 @@
+using System;
+using System.Net;
+using System.Threading;
+using System.Threading.Tasks;
+using MetaBrainz.Common;
+using MetaBrainz.MusicBrainz;
+using MetaBrainz.MusicBrainz.Interfaces.Entities;
+using Microsoft.Extensions.Logging;
+
+namespace MediaBrowser.Providers.Plugins.MusicBrainz;
+
+/// <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;
+ }
+ }
+}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Permission.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Permission.cs
index 84b86574cc..eae02dda1c 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Permission.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Permission.cs
@@ -37,7 +37,7 @@ namespace Jellyfin.Database.Implementations.Entities
/// <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.
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Preference.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Preference.cs
index c02ea7375a..9bd159f2cf 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Preference.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Preference.cs
@@ -35,7 +35,7 @@ namespace Jellyfin.Database.Implementations.Entities
/// <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.
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs
index b10e210e5d..bf6568d10a 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs
@@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
-using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Database.Implementations.Interfaces;
@@ -326,7 +325,6 @@ namespace Jellyfin.Database.Implementations.Entities
/// <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/>
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PermissionConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PermissionConfiguration.cs
index d2aed54eb1..ae53a36724 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PermissionConfiguration.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PermissionConfiguration.cs
@@ -14,10 +14,8 @@ namespace Jellyfin.Database.Implementations.ModelConfiguration
{
// Used to get a user's permissions or a specific permission for a user.
// Also prevents multiple values being created for a user.
- // Filtered over non-null user ids for when other entities (groups, API keys) get permissions
builder
.HasIndex(p => new { p.UserId, p.Kind })
- .HasFilter("[UserId] IS NOT NULL")
.IsUnique();
}
}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PreferenceConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PreferenceConfiguration.cs
index 207051bcd1..5306078ed4 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PreferenceConfiguration.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PreferenceConfiguration.cs
@@ -14,7 +14,6 @@ namespace Jellyfin.Database.Implementations.ModelConfiguration
{
builder
.HasIndex(p => new { p.UserId, p.Kind })
- .HasFilter("[UserId] IS NOT NULL")
.IsUnique();
}
}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260815063607_RemoveOrphanedUserPermissionsAndPreferences.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260815063607_RemoveOrphanedUserPermissionsAndPreferences.Designer.cs
new file mode 100644
index 0000000000..9c5d2cb36c
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260815063607_RemoveOrphanedUserPermissionsAndPreferences.Designer.cs
@@ -0,0 +1,1805 @@
+// <auto-generated />
+using System;
+using Jellyfin.Database.Implementations;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace Jellyfin.Database.Providers.Sqlite.Migrations
+{
+ [DbContext(typeof(JellyfinDbContext))]
+ [Migration("20260815063607_RemoveOrphanedUserPermissionsAndPreferences")]
+ partial class RemoveOrphanedUserPermissionsAndPreferences
+ {
+ /// <inheritdoc />
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "10.0.11");
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("DayOfWeek")
+ .HasColumnType("INTEGER");
+
+ b.Property<double>("EndHour")
+ .HasColumnType("REAL");
+
+ b.Property<double>("StartHour")
+ .HasColumnType("REAL");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AccessSchedules");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ActivityLog", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime>("DateCreated")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ItemId")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property<int>("LogSeverity")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Name")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Overview")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property<uint>("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("ShortOverview")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Type")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DateCreated");
+
+ b.ToTable("ActivityLogs");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("ParentItemId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemId", "ParentItemId");
+
+ b.HasIndex("ParentItemId");
+
+ b.ToTable("AncestorIds");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("Index")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Codec")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CodecTag")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Comment")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Filename")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("MimeType")
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemId", "Index");
+
+ b.ToTable("AttachmentStreamInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Album")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("AlbumArtists")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Artists")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("Audio")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("ChannelId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CleanName")
+ .HasColumnType("TEXT");
+
+ b.Property<float?>("CommunityRating")
+ .HasColumnType("REAL");
+
+ b.Property<float?>("CriticRating")
+ .HasColumnType("REAL");
+
+ b.Property<string>("CustomRating")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Data")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("DateCreated")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("DateLastMediaAdded")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("DateLastRefreshed")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("DateLastSaved")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("DateModified")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("EndDate")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("EpisodeTitle")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ExternalId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ExternalSeriesId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ExternalServiceId")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("ExtraType")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("ForcedSortName")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Genres")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("Height")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("IndexNumber")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("InheritedParentalRatingSubValue")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("InheritedParentalRatingValue")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsFolder")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsInMixedFolder")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsMovie")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsRepeat")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsSeries")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsVirtualItem")
+ .HasColumnType("INTEGER");
+
+ b.Property<float?>("LUFS")
+ .HasColumnType("REAL");
+
+ b.Property<string>("MediaType")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Name")
+ .HasColumnType("TEXT");
+
+ b.Property<float?>("NormalizationGain")
+ .HasColumnType("REAL");
+
+ b.Property<string>("OfficialRating")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("OriginalLanguage")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("OriginalTitle")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Overview")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid?>("OwnerId")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid?>("ParentId")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("ParentIndexNumber")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Path")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PreferredMetadataCountryCode")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PreferredMetadataLanguage")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("PremiereDate")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PresentationUniqueKey")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid?>("PrimaryVersionId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ProductionLocations")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("ProductionYear")
+ .HasColumnType("INTEGER");
+
+ b.Property<long?>("RunTimeTicks")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("SeasonId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("SeasonName")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid?>("SeriesId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("SeriesName")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("SeriesPresentationUniqueKey")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ShowId")
+ .HasColumnType("TEXT");
+
+ b.Property<long?>("Size")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("SortName")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("StartDate")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Studios")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Tagline")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Tags")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid?>("TopParentId")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("TotalBitrate")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Type")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("UnratedType")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("Width")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name");
+
+ b.HasIndex("OwnerId");
+
+ b.HasIndex("ParentId");
+
+ b.HasIndex("Path");
+
+ b.HasIndex("PresentationUniqueKey");
+
+ b.HasIndex("PrimaryVersionId")
+ .HasFilter("\"PrimaryVersionId\" IS NOT NULL");
+
+ b.HasIndex("SeasonId");
+
+ b.HasIndex("SeriesId");
+
+ b.HasIndex("SeriesName");
+
+ b.HasIndex("ExtraType", "OwnerId");
+
+ b.HasIndex("TopParentId", "Id");
+
+ b.HasIndex("Type", "CleanName");
+
+ b.HasIndex("TopParentId", "Type", "IsVirtualItem")
+ .HasFilter("\"PrimaryVersionId\" IS NULL AND (\"OwnerId\" IS NULL OR \"ExtraType\" IS NOT NULL)");
+
+ b.HasIndex("Type", "TopParentId", "Id");
+
+ b.HasIndex("Type", "TopParentId", "PresentationUniqueKey");
+
+ b.HasIndex("Type", "TopParentId", "SortName");
+
+ b.HasIndex("Type", "TopParentId", "StartDate");
+
+ b.HasIndex("MediaType", "TopParentId", "IsVirtualItem", "PresentationUniqueKey");
+
+ b.HasIndex("TopParentId", "IsFolder", "IsVirtualItem", "DateCreated");
+
+ b.HasIndex("TopParentId", "MediaType", "IsVirtualItem", "DateCreated");
+
+ b.HasIndex("TopParentId", "Type", "IsVirtualItem", "DateCreated");
+
+ b.HasIndex("Type", "SeriesPresentationUniqueKey", "IsFolder", "IsVirtualItem");
+
+ b.HasIndex("Type", "SeriesPresentationUniqueKey", "ParentIndexNumber", "IndexNumber");
+
+ b.HasIndex("Type", "SeriesPresentationUniqueKey", "PresentationUniqueKey", "SortName");
+
+ b.HasIndex("IsFolder", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated");
+
+ b.HasIndex("Type", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated");
+
+ b.ToTable("BaseItems");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+
+ b.HasData(
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000001"),
+ IsFolder = false,
+ IsInMixedFolder = false,
+ IsLocked = false,
+ IsMovie = false,
+ IsRepeat = false,
+ IsSeries = false,
+ IsVirtualItem = false,
+ Name = "This is a placeholder item for UserData that has been detached from its original item",
+ Type = "PLACEHOLDER"
+ });
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<byte[]>("Blurhash")
+ .HasColumnType("BLOB");
+
+ b.Property<DateTime?>("DateModified")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("Height")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("ImageType")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Path")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<int>("Width")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ItemId", "ImageType");
+
+ b.ToTable("BaseItemImageInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b =>
+ {
+ b.Property<int>("Id")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id", "ItemId");
+
+ b.HasIndex("ItemId");
+
+ b.ToTable("BaseItemMetadataFields");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ProviderId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ProviderValue")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemId", "ProviderId");
+
+ b.HasIndex("ProviderId", "ItemId", "ProviderValue");
+
+ b.ToTable("BaseItemProviders");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b =>
+ {
+ b.Property<int>("Id")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id", "ItemId");
+
+ b.HasIndex("ItemId");
+
+ b.ToTable("BaseItemTrailerTypes");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("ChapterIndex")
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime?>("ImageDateModified")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ImagePath")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Name")
+ .HasColumnType("TEXT");
+
+ b.Property<long>("StartPositionTicks")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId", "ChapterIndex");
+
+ b.ToTable("Chapters");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.CustomItemDisplayPreferences", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Client")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Key")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Value")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "ItemId", "Client", "Key")
+ .IsUnique();
+
+ b.ToTable("CustomItemDisplayPreferences");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("ChromecastVersion")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Client")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("DashboardTheme")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("EnableNextVideoInfoOverlay")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("IndexBy")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("ScrollDirection")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("ShowBackdrop")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("ShowSidebar")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("SkipBackwardLength")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("SkipForwardLength")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("TvHome")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "ItemId", "Client")
+ .IsUnique();
+
+ b.ToTable("DisplayPreferences");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("DisplayPreferencesId")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Order")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Type")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DisplayPreferencesId");
+
+ b.ToTable("HomeSection");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime>("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Path")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId")
+ .IsUnique();
+
+ b.ToTable("ImageInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Client")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("IndexBy")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("RememberIndexing")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("RememberSorting")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("SortBy")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property<int>("SortOrder")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("ViewType")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("ItemDisplayPreferences");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b =>
+ {
+ b.Property<Guid>("ItemValueId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CleanValue")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<int>("Type")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Value")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemValueId");
+
+ b.HasIndex("Type", "CleanValue");
+
+ b.HasIndex("Type", "Value")
+ .IsUnique();
+
+ b.ToTable("ItemValues");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b =>
+ {
+ b.Property<Guid>("ItemValueId")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemValueId", "ItemId");
+
+ b.HasIndex("ItemId");
+
+ b.ToTable("ItemValuesMap");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.PrimitiveCollection<string>("KeyframeTicks")
+ .HasColumnType("TEXT");
+
+ b.Property<long>("TotalDuration")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId");
+
+ b.ToTable("KeyframeData");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.LinkedChildEntity", b =>
+ {
+ b.Property<Guid>("ParentId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("SortOrder")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ChildId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("ChildType")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ParentId", "SortOrder");
+
+ b.HasIndex("ChildId", "ChildType");
+
+ b.HasIndex("ParentId", "ChildType");
+
+ b.ToTable("LinkedChildren", (string)null);
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaSegment", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<long>("EndTicks")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("SegmentProviderId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<long>("StartTicks")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Type")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.ToTable("MediaSegments");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("StreamIndex")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("AspectRatio")
+ .HasColumnType("TEXT");
+
+ b.Property<float?>("AverageFrameRate")
+ .HasColumnType("REAL");
+
+ b.Property<int?>("BitDepth")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("BitRate")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("BlPresentFlag")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("ChannelLayout")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("Channels")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Codec")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CodecTag")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CodecTimeBase")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ColorPrimaries")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ColorSpace")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ColorTransfer")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Comment")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("DvBlSignalCompatibilityId")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("DvLevel")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("DvProfile")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("DvVersionMajor")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("DvVersionMinor")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("ElPresentFlag")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool?>("Hdr10PlusPresentFlag")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("Height")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool?>("IsAnamorphic")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool?>("IsAvc")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsDefault")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsExternal")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsForced")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool?>("IsHearingImpaired")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool?>("IsInterlaced")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsOriginal")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("KeyFrames")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Language")
+ .HasColumnType("TEXT");
+
+ b.Property<float?>("Level")
+ .HasColumnType("REAL");
+
+ b.Property<string>("NalLengthSize")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Path")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PixelFormat")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Profile")
+ .HasColumnType("TEXT");
+
+ b.Property<float?>("RealFrameRate")
+ .HasColumnType("REAL");
+
+ b.Property<int?>("RefFrames")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("Rotation")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("RpuPresentFlag")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("SampleRate")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("StreamType")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("TimeBase")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Title")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("Width")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId", "StreamIndex");
+
+ b.ToTable("MediaStreamInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Name")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PersonType")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name");
+
+ b.ToTable("Peoples");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("PeopleId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Role")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("ListOrder")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("SortOrder")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId", "PeopleId", "Role");
+
+ b.HasIndex("ItemId", "ListOrder");
+
+ b.HasIndex("ItemId", "SortOrder");
+
+ b.HasIndex("PeopleId", "ItemId");
+
+ b.ToTable("PeopleBaseItemMap");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Kind")
+ .HasColumnType("INTEGER");
+
+ b.Property<uint>("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("Value")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "Kind")
+ .IsUnique();
+
+ b.ToTable("Permissions");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Kind")
+ .HasColumnType("INTEGER");
+
+ b.Property<uint>("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Value")
+ .IsRequired()
+ .HasMaxLength(65535)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "Kind")
+ .IsUnique();
+
+ b.ToTable("Preferences");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.ApiKey", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("AccessToken")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("DateCreated")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("DateLastActivity")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Name")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AccessToken")
+ .IsUnique();
+
+ b.ToTable("ApiKeys");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("AccessToken")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("AppName")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("AppVersion")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("DateCreated")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("DateLastActivity")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("DateModified")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("DeviceId")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("DeviceName")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("IsActive")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AccessToken", "DateLastActivity");
+
+ b.HasIndex("DeviceId", "DateLastActivity");
+
+ b.HasIndex("UserId", "DeviceId");
+
+ b.ToTable("Devices");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.DeviceOptions", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("CustomName")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("DeviceId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DeviceId")
+ .IsUnique();
+
+ b.ToTable("DeviceOptions");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.TrickplayInfo", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("Width")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Bandwidth")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Height")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Interval")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("ThumbnailCount")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("TileHeight")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("TileWidth")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId", "Width");
+
+ b.ToTable("TrickplayInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("AudioLanguagePreference")
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("AuthenticationProviderId")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CastReceiverId")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("DisplayCollectionsView")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("DisplayMissingEpisodes")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("EnableAutoLogin")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("EnableLocalPassword")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("EnableNextEpisodeAutoPlay")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("EnableUserPreferenceAccess")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("HidePlayedInLatest")
+ .HasColumnType("INTEGER");
+
+ b.Property<long>("InternalId")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("InvalidLoginAttemptCount")
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime?>("LastActivityDate")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("LastLoginDate")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("LoginAttemptsBeforeLockout")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("MaxActiveSessions")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("MaxParentalRatingScore")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("MaxParentalRatingSubScore")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("MustUpdatePassword")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("NormalizedUsername")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Password")
+ .HasMaxLength(65535)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PasswordResetProviderId")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("PlayDefaultAudioTrack")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("RememberAudioSelections")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("RememberSubtitleSelections")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("RemoteClientBitrateLimit")
+ .HasColumnType("INTEGER");
+
+ b.Property<uint>("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("SubtitleLanguagePreference")
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property<int>("SubtitleMode")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("SyncPlayAccess")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Username")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedUsername")
+ .IsUnique();
+
+ b.HasIndex("Username")
+ .IsUnique();
+
+ b.ToTable("Users");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CustomDataKey")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("AudioStreamIndex")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsFavorite")
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime?>("LastPlayedDate")
+ .HasColumnType("TEXT");
+
+ b.Property<bool?>("Likes")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("PlayCount")
+ .HasColumnType("INTEGER");
+
+ b.Property<long>("PlaybackPositionTicks")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("Played")
+ .HasColumnType("INTEGER");
+
+ b.Property<double?>("Rating")
+ .HasColumnType("REAL");
+
+ b.Property<DateTime?>("RetentionDate")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("SubtitleStreamIndex")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId", "UserId", "CustomDataKey");
+
+ b.HasIndex("ItemId", "UserId", "IsFavorite");
+
+ b.HasIndex("ItemId", "UserId", "LastPlayedDate");
+
+ b.HasIndex("ItemId", "UserId", "PlaybackPositionTicks");
+
+ b.HasIndex("ItemId", "UserId", "Played");
+
+ b.HasIndex("UserId", "IsFavorite", "ItemId");
+
+ b.HasIndex("UserId", "ItemId", "LastPlayedDate");
+
+ b.HasIndex("UserId", "Played", "ItemId");
+
+ b.ToTable("UserData");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithMany("AccessSchedules")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("Parents")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "ParentItem")
+ .WithMany("Children")
+ .HasForeignKey("ParentItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+
+ b.Navigation("ParentItem");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany()
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Owner")
+ .WithMany("Extras")
+ .HasForeignKey("OwnerId")
+ .OnDelete(DeleteBehavior.NoAction);
+
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "DirectParent")
+ .WithMany("DirectChildren")
+ .HasForeignKey("ParentId")
+ .OnDelete(DeleteBehavior.Cascade);
+
+ b.Navigation("DirectParent");
+
+ b.Navigation("Owner");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("Images")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("LockedFields")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("Provider")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("TrailerTypes")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("Chapters")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithMany("DisplayPreferences")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.DisplayPreferences", null)
+ .WithMany("HomeSections")
+ .HasForeignKey("DisplayPreferencesId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithOne("ProfileImage")
+ .HasForeignKey("Jellyfin.Database.Implementations.Entities.ImageInfo", "UserId")
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithMany("ItemDisplayPreferences")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("ItemValues")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Jellyfin.Database.Implementations.Entities.ItemValue", "ItemValue")
+ .WithMany("BaseItemsMap")
+ .HasForeignKey("ItemValueId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+
+ b.Navigation("ItemValue");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany()
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.LinkedChildEntity", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Child")
+ .WithMany("LinkedChildOfEntities")
+ .HasForeignKey("ChildId")
+ .OnDelete(DeleteBehavior.NoAction)
+ .IsRequired();
+
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Parent")
+ .WithMany("LinkedChildEntities")
+ .HasForeignKey("ParentId")
+ .OnDelete(DeleteBehavior.NoAction)
+ .IsRequired();
+
+ b.Navigation("Child");
+
+ b.Navigation("Parent");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("MediaStreams")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("Peoples")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Jellyfin.Database.Implementations.Entities.People", "People")
+ .WithMany("BaseItems")
+ .HasForeignKey("PeopleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+
+ b.Navigation("People");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithMany("Permissions")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithMany("Preferences")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("UserData")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b =>
+ {
+ b.Navigation("Chapters");
+
+ b.Navigation("Children");
+
+ b.Navigation("DirectChildren");
+
+ b.Navigation("Extras");
+
+ b.Navigation("Images");
+
+ b.Navigation("ItemValues");
+
+ b.Navigation("LinkedChildEntities");
+
+ b.Navigation("LinkedChildOfEntities");
+
+ b.Navigation("LockedFields");
+
+ b.Navigation("MediaStreams");
+
+ b.Navigation("Parents");
+
+ b.Navigation("Peoples");
+
+ b.Navigation("Provider");
+
+ b.Navigation("TrailerTypes");
+
+ b.Navigation("UserData");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b =>
+ {
+ b.Navigation("HomeSections");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b =>
+ {
+ b.Navigation("BaseItemsMap");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b =>
+ {
+ b.Navigation("BaseItems");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b =>
+ {
+ b.Navigation("AccessSchedules");
+
+ b.Navigation("DisplayPreferences");
+
+ b.Navigation("ItemDisplayPreferences");
+
+ b.Navigation("Permissions");
+
+ b.Navigation("Preferences");
+
+ b.Navigation("ProfileImage");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260815063607_RemoveOrphanedUserPermissionsAndPreferences.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260815063607_RemoveOrphanedUserPermissionsAndPreferences.cs
new file mode 100644
index 0000000000..3d4cf90441
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260815063607_RemoveOrphanedUserPermissionsAndPreferences.cs
@@ -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");
+ }
+ }
+}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs
index cdf5c84826..35a62d4907 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs
@@ -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 =>
{
@@ -1078,14 +1078,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 +1091,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 +1107,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 +1122,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 +1691,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 +1700,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 =>
diff --git a/tests/Jellyfin.Naming.Tests/Video/CleanStringTests.cs b/tests/Jellyfin.Naming.Tests/Video/CleanStringTests.cs
index df5819d747..8b4a6ff4e4 100644
--- a/tests/Jellyfin.Naming.Tests/Video/CleanStringTests.cs
+++ b/tests/Jellyfin.Naming.Tests/Video/CleanStringTests.cs
@@ -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")]
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs
new file mode 100644
index 0000000000..0766ca8d1e
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs
@@ -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));
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs
index 2ed880ed9c..d973076ed3 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs
@@ -349,7 +349,7 @@ namespace Jellyfin.Server.Implementations.Tests.Localization
});
var translated = localizationManager.GetLocalizedString("Artists", "de");
- Assert.Equal("Interpreten", translated);
+ Assert.Equal("Künstler", translated);
}
[Fact]
@@ -406,7 +406,7 @@ namespace Jellyfin.Server.Implementations.Tests.Localization
{
CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("fr");
var translated = localizationManager.GetServerLocalizedString("Artists");
- Assert.Equal("Interpreten", translated);
+ Assert.Equal("Künstler", translated);
}
finally
{
@@ -427,7 +427,7 @@ namespace Jellyfin.Server.Implementations.Tests.Localization
{
CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("de");
var translated = localizationManager.GetLocalizedString("Artists");
- Assert.Equal("Interpreten", translated);
+ Assert.Equal("Künstler", translated);
}
finally
{
diff --git a/tests/Jellyfin.Server.Implementations.Tests/SessionManager/IdlePlaybackTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/IdlePlaybackTests.cs
new file mode 100644
index 0000000000..7722707cbe
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/IdlePlaybackTests.cs
@@ -0,0 +1,80 @@
+using System;
+using System.Reflection;
+using System.Threading.Tasks;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Devices;
+using MediaBrowser.Controller.Drawing;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Events;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Session;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.SessionManager;
+
+public class IdlePlaybackTests
+{
+ [Theory]
+ [InlineData(null, null)]
+ [InlineData(123456789L, 123456789L)]
+ public async Task CheckForIdlePlayback_StopsAtLastClientReportedPosition(long? clientPositionTicks, long? expectedPositionTicks)
+ {
+ var playbackStopped = new TaskCompletionSource<long?>(TaskCreationOptions.RunContinuationsAsynchronously);
+ var eventManager = new Mock<IEventManager>();
+ eventManager
+ .Setup(manager => manager.PublishAsync(It.IsAny<PlaybackStopEventArgs>()))
+ .Callback<PlaybackStopEventArgs>(eventArgs => playbackStopped.TrySetResult(eventArgs.PlaybackPositionTicks))
+ .Returns(Task.CompletedTask);
+ await using var sessionManager = new Emby.Server.Implementations.Session.SessionManager(
+ NullLogger<Emby.Server.Implementations.Session.SessionManager>.Instance,
+ eventManager.Object,
+ Mock.Of<IUserDataManager>(),
+ Mock.Of<IServerConfigurationManager>(),
+ Mock.Of<ILibraryManager>(),
+ Mock.Of<IUserManager>(),
+ Mock.Of<IMusicManager>(),
+ Mock.Of<IDtoService>(),
+ Mock.Of<IImageProcessor>(),
+ Mock.Of<IServerApplicationHost>(),
+ Mock.Of<IDeviceManager>(),
+ Mock.Of<IMediaSourceManager>(),
+ Mock.Of<IHostApplicationLifetime>());
+ var session = await sessionManager.LogSessionActivity(
+ "Test Client",
+ "1.0.0",
+ "test-device",
+ "Test Device",
+ "127.0.0.1",
+ null);
+ session.NowPlayingItem = new BaseItemDto
+ {
+ Id = Guid.NewGuid(),
+ Name = "Test Item"
+ };
+ session.PlayState.PositionTicks = 987654321;
+
+ if (clientPositionTicks.HasValue)
+ {
+ session.StartAutomaticProgress(new PlaybackProgressInfo
+ {
+ IsPaused = true,
+ PositionTicks = clientPositionTicks
+ });
+ session.StopAutomaticProgress();
+ }
+
+ var idlePlaybackCallback = typeof(Emby.Server.Implementations.Session.SessionManager)
+ .GetMethod("CheckForIdlePlayback", BindingFlags.Instance | BindingFlags.NonPublic)!;
+ idlePlaybackCallback.Invoke(sessionManager, new object?[] { null });
+
+ var stoppedPositionTicks = await playbackStopped.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
+
+ Assert.Equal(expectedPositionTicks, stoppedPositionTicks);
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionInfoTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionInfoTests.cs
new file mode 100644
index 0000000000..c5b8f661b5
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionInfoTests.cs
@@ -0,0 +1,92 @@
+using System;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Session;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.SessionManager;
+
+public class SessionInfoTests
+{
+ [Fact]
+ public async Task StartAutomaticProgress_SnapshotsClientReportedPosition()
+ {
+ await using var session = new SessionInfo(Mock.Of<ISessionManager>(), NullLogger.Instance);
+ var progressInfo = new PlaybackProgressInfo
+ {
+ IsPaused = true,
+ PositionTicks = 123456789
+ };
+
+ session.StartAutomaticProgress(progressInfo);
+
+ Assert.Equal(progressInfo.PositionTicks, session.LastPlaybackCheckInPositionTicks);
+ }
+
+ [Fact]
+ public async Task AutomaticProgress_AdvancesEstimatedPositionWithoutAdvancingSnapshot()
+ {
+ var sessionManager = new Mock<ISessionManager>();
+ await using var session = new SessionInfo(sessionManager.Object, NullLogger.Instance);
+ var automaticProgress = new TaskCompletionSource<long?>(TaskCreationOptions.RunContinuationsAsynchronously);
+ const long reportedPositionTicks = 123456789;
+
+ sessionManager
+ .Setup(manager => manager.OnPlaybackProgress(It.IsAny<PlaybackProgressInfo>(), true))
+ .Callback<PlaybackProgressInfo, bool>((info, _) =>
+ {
+ session.PlayState.PositionTicks = info.PositionTicks;
+ automaticProgress.TrySetResult(info.PositionTicks);
+ })
+ .Returns(Task.CompletedTask);
+ session.PlayState.PositionTicks = reportedPositionTicks;
+
+ session.StartAutomaticProgress(new PlaybackProgressInfo
+ {
+ PositionTicks = reportedPositionTicks
+ });
+
+ var estimatedPositionTicks = await automaticProgress.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
+ session.StopAutomaticProgress();
+
+ Assert.Equal(reportedPositionTicks + TimeSpan.TicksPerSecond, estimatedPositionTicks);
+ Assert.Equal(estimatedPositionTicks, session.PlayState.PositionTicks);
+ Assert.Equal(reportedPositionTicks, session.LastPlaybackCheckInPositionTicks);
+ }
+
+ [Fact]
+ public async Task StartAutomaticProgress_ReplacesSnapshotOnLaterClientReport()
+ {
+ await using var session = new SessionInfo(Mock.Of<ISessionManager>(), NullLogger.Instance);
+ session.StartAutomaticProgress(new PlaybackProgressInfo
+ {
+ IsPaused = true,
+ PositionTicks = 123456789
+ });
+
+ session.StartAutomaticProgress(new PlaybackProgressInfo
+ {
+ IsPaused = true,
+ PositionTicks = 987654321
+ });
+
+ Assert.Equal(987654321, session.LastPlaybackCheckInPositionTicks);
+ }
+
+ [Fact]
+ public async Task StartAutomaticProgress_PreservesExactPausedPosition()
+ {
+ await using var session = new SessionInfo(Mock.Of<ISessionManager>(), NullLogger.Instance);
+ var pausedProgress = new PlaybackProgressInfo
+ {
+ IsPaused = true,
+ PositionTicks = 314159265
+ };
+
+ session.StartAutomaticProgress(pausedProgress);
+
+ Assert.Equal(pausedProgress.PositionTicks, session.LastPlaybackCheckInPositionTicks);
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerUpdateUserTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerUpdateUserTests.cs
new file mode 100644
index 0000000000..c940f92109
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerUpdateUserTests.cs
@@ -0,0 +1,181 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Data;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Enums;
+using Jellyfin.Database.Implementations.Locking;
+using Jellyfin.Database.Providers.Sqlite;
+using Jellyfin.Server.Implementations.Users;
+using MediaBrowser.Common;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Drawing;
+using MediaBrowser.Controller.Events;
+using MediaBrowser.Model.Cryptography;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Users;
+
+public sealed class UserManagerUpdateUserTests : IDisposable
+{
+ private readonly SqliteConnection _connection;
+ private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
+ private readonly UserManager _userManager;
+
+ public UserManagerUpdateUserTests()
+ {
+ _connection = new SqliteConnection("Data Source=:memory:");
+ _connection.Open();
+
+ _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
+ .UseSqlite(_connection)
+ .Options;
+
+ // Create the schema
+ using var ctx = CreateDbContext();
+ ctx.Database.EnsureCreated();
+
+ var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
+ factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
+ factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>()))
+ .ReturnsAsync(CreateDbContext);
+
+ var cryptoProvider = new Mock<ICryptoProvider>();
+ var configManager = new Mock<IServerConfigurationManager>();
+ var appPaths = new Mock<IServerApplicationPaths>();
+ appPaths.Setup(x => x.ProgramDataPath).Returns(Path.GetTempPath());
+ configManager.Setup(x => x.ApplicationPaths).Returns(appPaths.Object);
+
+ var appHost = new Mock<IApplicationHost>();
+
+ var defaultAuthProvider = new DefaultAuthenticationProvider(
+ NullLogger<DefaultAuthenticationProvider>.Instance,
+ cryptoProvider.Object);
+ var invalidAuthProvider = new InvalidAuthProvider();
+ var defaultPasswordResetProvider = new DefaultPasswordResetProvider(
+ configManager.Object,
+ appHost.Object);
+
+ _userManager = new UserManager(
+ factory.Object,
+ new NoopEventManager(),
+ new Mock<INetworkManager>().Object,
+ appHost.Object,
+ new Mock<IImageProcessor>().Object,
+ NullLogger<UserManager>.Instance,
+ configManager.Object,
+ [defaultPasswordResetProvider],
+ [defaultAuthProvider, invalidAuthProvider]);
+ }
+
+ public void Dispose()
+ {
+ _userManager.Dispose();
+ _connection.Dispose();
+ }
+
+ [Fact]
+ public async Task UpdateUserAsync_DoesNotDetachPermissionsOrPreferences()
+ {
+ var user = await _userManager.CreateUserAsync("orphanuser");
+ var permissionCount = user.Permissions.Count;
+ var preferenceCount = user.Preferences.Count;
+
+ user.LastActivityDate = DateTime.UtcNow;
+ await _userManager.UpdateUserAsync(user);
+ await _userManager.UpdateUserAsync(user);
+
+ await using var context = CreateDbContext();
+ Assert.Equal(permissionCount, await context.Permissions.CountAsync(TestContext.Current.CancellationToken));
+ Assert.Equal(preferenceCount, await context.Preferences.CountAsync(TestContext.Current.CancellationToken));
+ Assert.All(
+ await context.Permissions.ToListAsync(TestContext.Current.CancellationToken),
+ permission => Assert.Equal(user.Id, permission.UserId));
+ Assert.All(
+ await context.Preferences.ToListAsync(TestContext.Current.CancellationToken),
+ preference => Assert.Equal(user.Id, preference.UserId));
+ }
+
+ [Fact]
+ public async Task UpdateUserAsync_WhenOnlyTheUserRowChanged_LeavesChildRowsUntouched()
+ {
+ var user = await _userManager.CreateUserAsync("churnuser");
+ var before = await ReadChildRowsAsync();
+
+ // A session activity stamp goes through the same path. It must not rewrite all 37 child
+ // rows, which is what tearing the collections down and rebuilding them used to do.
+ user.LastActivityDate = DateTime.UtcNow;
+ await _userManager.UpdateUserAsync(user);
+
+ Assert.Equal(before, await ReadChildRowsAsync());
+ }
+
+ [Fact]
+ public async Task UpdateUserAsync_AppliesPermissionAndPreferenceChanges()
+ {
+ var user = await _userManager.CreateUserAsync("policyuser");
+ Assert.False(user.HasPermission(PermissionKind.IsAdministrator));
+
+ user.SetPermission(PermissionKind.IsAdministrator, true);
+ user.SetPreference(PreferenceKind.BlockedTags, ["spoilers"]);
+ user.Permissions.Remove(user.Permissions.First(permission => permission.Kind == PermissionKind.EnableAllChannels));
+
+ await _userManager.UpdateUserAsync(user);
+
+ var reloaded = _userManager.GetUserById(user.Id)!;
+ Assert.True(reloaded.HasPermission(PermissionKind.IsAdministrator));
+ Assert.Equal(new[] { "spoilers" }, reloaded.GetPreference(PreferenceKind.BlockedTags));
+ Assert.DoesNotContain(reloaded.Permissions, permission => permission.Kind == PermissionKind.EnableAllChannels);
+
+ await using var context = CreateDbContext();
+ Assert.Equal(reloaded.Permissions.Count, await context.Permissions.CountAsync(TestContext.Current.CancellationToken));
+ }
+
+ private JellyfinDbContext CreateDbContext()
+ {
+ return new JellyfinDbContext(
+ _dbOptions,
+ NullLogger<JellyfinDbContext>.Instance,
+ new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
+ new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
+ }
+
+ /// <summary>
+ /// Reads the identity and concurrency token of every permission and preference row.
+ /// </summary>
+ private async Task<List<(string Table, int Id, int Kind, uint RowVersion)>> ReadChildRowsAsync()
+ {
+ await using var context = CreateDbContext();
+ var permissions = await context.Permissions
+ .OrderBy(permission => permission.Id)
+ .Select(permission => new ValueTuple<string, int, int, uint>("Permission", permission.Id, (int)permission.Kind, permission.RowVersion))
+ .ToListAsync(TestContext.Current.CancellationToken);
+ var preferences = await context.Preferences
+ .OrderBy(preference => preference.Id)
+ .Select(preference => new ValueTuple<string, int, int, uint>("Preference", preference.Id, (int)preference.Kind, preference.RowVersion))
+ .ToListAsync(TestContext.Current.CancellationToken);
+
+ return permissions.Concat(preferences).ToList();
+ }
+
+ private sealed class NoopEventManager : IEventManager
+ {
+ public void Publish<T>(T eventArgs)
+ where T : EventArgs
+ {
+ }
+
+ public Task PublishAsync<T>(T eventArgs)
+ where T : EventArgs
+ => Task.CompletedTask;
+ }
+}