diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index b4d77bc4c6..df2b50e269 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "dotnet-ef": { - "version": "9.0.9", + "version": "9.0.10", "commands": [ "dotnet-ef" ] diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e1900d5833..ef81678ddd 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,4 +1,11 @@ -# Joshua must review all changes to deployment and build.sh -.ci/* @joshuaboniface -deployment/* @joshuaboniface -build.sh @joshuaboniface +# Joshua must review all changes to bump_version and any files it touches +bump_version @joshuaboniface +.github/ISSUE_TEMPLATE @joshuaboniface +MediaBrowser.Common/MediaBrowser.Common.csproj @joshuaboniface +Jellyfin.Data/Jellyfin.Data.csproj @joshuaboniface +MediaBrowser.Controller/MediaBrowser.Controller.csproj @joshuaboniface +MediaBrowser.Model/MediaBrowser.Model.csproj @joshuaboniface +Emby.Naming/Emby.Naming.csproj @joshuaboniface +src/Jellyfin.Extensions/Jellyfin.Extensions.csproj @joshuaboniface +# Core must approve all changes within the repo config +.github/ @jellyfin/core diff --git a/.github/workflows/ci-codeql-analysis.yml b/.github/workflows/ci-codeql-analysis.yml index 4dd2402293..53daf0991f 100644 --- a/.github/workflows/ci-codeql-analysis.yml +++ b/.github/workflows/ci-codeql-analysis.yml @@ -27,11 +27,11 @@ jobs: dotnet-version: '9.0.x' - name: Initialize CodeQL - uses: github/codeql-action/init@f443b600d91635bebf5b0d9ebc620189c0d6fba5 # v4.30.8 + uses: github/codeql-action/init@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0 with: languages: ${{ matrix.language }} queries: +security-extended - name: Autobuild - uses: github/codeql-action/autobuild@f443b600d91635bebf5b0d9ebc620189c0d6fba5 # v4.30.8 + uses: github/codeql-action/autobuild@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@f443b600d91635bebf5b0d9ebc620189c0d6fba5 # v4.30.8 + uses: github/codeql-action/analyze@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0 diff --git a/.github/workflows/ci-compat.yml b/.github/workflows/ci-compat.yml index a8104a917d..0651233386 100644 --- a/.github/workflows/ci-compat.yml +++ b/.github/workflows/ci-compat.yml @@ -26,7 +26,7 @@ jobs: dotnet build Jellyfin.Server -o ./out - name: Upload Head - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: abi-head retention-days: 14 @@ -65,7 +65,7 @@ jobs: dotnet build Jellyfin.Server -o ./out - name: Upload Head - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: abi-base retention-days: 14 @@ -85,13 +85,13 @@ jobs: steps: - name: Download abi-head - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: name: abi-head path: abi-head - name: Download abi-base - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: name: abi-base path: abi-base diff --git a/.github/workflows/ci-openapi.yml b/.github/workflows/ci-openapi.yml index 7cca2af274..d37602690d 100644 --- a/.github/workflows/ci-openapi.yml +++ b/.github/workflows/ci-openapi.yml @@ -27,7 +27,7 @@ jobs: - name: Generate openapi.json run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests" - name: Upload openapi.json - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: openapi-head retention-days: 14 @@ -61,7 +61,7 @@ jobs: - name: Generate openapi.json run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests" - name: Upload openapi.json - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: openapi-base retention-days: 14 @@ -80,12 +80,12 @@ jobs: - openapi-base steps: - name: Download openapi-head - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: name: openapi-head path: openapi-head - name: Download openapi-base - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: name: openapi-base path: openapi-base @@ -158,7 +158,7 @@ jobs: run: |- echo "JELLYFIN_VERSION=$(date +'%Y%m%d%H%M%S')" >> $GITHUB_ENV - name: Download openapi-head - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: name: openapi-head path: openapi-head @@ -220,7 +220,7 @@ jobs: run: |- echo "JELLYFIN_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV - name: Download openapi-head - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: name: openapi-head path: openapi-head diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 846835491a..b9fdd456f1 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -35,7 +35,7 @@ jobs: --verbosity minimal - name: Merge code coverage results - uses: danielpalme/ReportGenerator-GitHub-Action@9870ed167742d546b99962ff815fcc1098355ed8 # v5.4.17 + uses: danielpalme/ReportGenerator-GitHub-Action@f3c6b3f8a29686284ef7a7cf6dccb79a01d98444 # v5.4.18 with: reports: "**/coverage.cobertura.xml" targetdir: "merged/" diff --git a/Directory.Packages.props b/Directory.Packages.props index 121630f8c7..481f22b9d3 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -26,32 +26,32 @@ - - + + - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + @@ -84,11 +84,11 @@ - - - + + + - + diff --git a/Emby.Naming/Emby.Naming.csproj b/Emby.Naming/Emby.Naming.csproj index 20b32f3a62..b84c961165 100644 --- a/Emby.Naming/Emby.Naming.csproj +++ b/Emby.Naming/Emby.Naming.csproj @@ -36,7 +36,7 @@ Jellyfin Contributors Jellyfin.Naming - 10.11.0 + 10.12.0 https://github.com/jellyfin/jellyfin GPL-3.0-only diff --git a/Emby.Naming/TV/SeasonPathParser.cs b/Emby.Naming/TV/SeasonPathParser.cs index 98ee1e4b8f..eafb09a6a3 100644 --- a/Emby.Naming/TV/SeasonPathParser.cs +++ b/Emby.Naming/TV/SeasonPathParser.cs @@ -10,10 +10,10 @@ namespace Emby.Naming.TV /// public static partial class SeasonPathParser { - [GeneratedRegex(@"^\s*((?(?>\d+))(?:st|nd|rd|th|\.)*(?!\s*[Ee]\d+))\s*(?:[[시즌]*|[シーズン]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?.*)$")] + [GeneratedRegex(@"^\s*((?(?>\d+))(?:st|nd|rd|th|\.)*(?!\s*[Ee]\d+))\s*(?:[[시즌]*|[シーズン]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?.*)$", RegexOptions.IgnoreCase)] private static partial Regex ProcessPre(); - [GeneratedRegex(@"^\s*(?:[[시즌]*|[シーズン]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?(?>\d+)(?!\s*[Ee]\d+))(?.*)$")] + [GeneratedRegex(@"^\s*(?:[[시즌]*|[シーズン]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?(?>\d+)(?!\s*[Ee]\d+))(?.*)$", RegexOptions.IgnoreCase)] private static partial Regex ProcessPost(); /// @@ -86,7 +86,7 @@ namespace Emby.Naming.TV } } - if (filename.StartsWith('s')) + if (filename.Length > 0 && (filename[0] == 'S' || filename[0] == 's')) { var testFilename = filename.AsSpan()[1..]; @@ -113,8 +113,10 @@ namespace Emby.Naming.TV var numberString = match.Groups["seasonnumber"]; if (numberString.Success) { - var seasonNumber = int.Parse(numberString.Value, CultureInfo.InvariantCulture); - return (seasonNumber, true); + if (int.TryParse(numberString.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seasonNumber)) + { + return (seasonNumber, true); + } } return (null, false); diff --git a/Emby.Server.Implementations/Chapters/ChapterManager.cs b/Emby.Server.Implementations/Chapters/ChapterManager.cs index b4daa2a143..d09ed30ae3 100644 --- a/Emby.Server.Implementations/Chapters/ChapterManager.cs +++ b/Emby.Server.Implementations/Chapters/ChapterManager.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.IO; using System.Linq; using System.Threading; @@ -224,7 +223,7 @@ public class ChapterManager : IChapterManager if (saveChapters && changesMade) { - _chapterRepository.SaveChapters(video.Id, chapters); + SaveChapters(video, chapters); } DeleteDeadImages(currentImages, chapters); @@ -235,7 +234,9 @@ public class ChapterManager : IChapterManager /// public void SaveChapters(Video video, IReadOnlyList chapters) { - _chapterRepository.SaveChapters(video.Id, chapters); + // Remove any chapters that are outside of the runtime of the video + var validChapters = chapters.Where(c => c.StartPositionTicks < video.RunTimeTicks).ToList(); + _chapterRepository.SaveChapters(video.Id, validChapters); } /// @@ -251,23 +252,9 @@ public class ChapterManager : IChapterManager } /// - public void DeleteChapterImages(Video video) + public async Task DeleteChapterDataAsync(Guid itemId, CancellationToken cancellationToken) { - var path = _pathManager.GetChapterImageFolderPath(video); - try - { - if (Directory.Exists(path)) - { - _logger.LogInformation("Removing chapter images for {Name} [{Id}]", video.Name, video.Id); - Directory.Delete(path, true); - } - } - catch (Exception ex) - { - _logger.LogWarning("Failed to remove chapter image folder for {Item}: {Exception}", video.Id, ex); - } - - _chapterRepository.DeleteChapters(video.Id); + await _chapterRepository.DeleteChaptersAsync(itemId, cancellationToken).ConfigureAwait(false); } private IReadOnlyList GetSavedChapterImages(Video video, IDirectoryService directoryService) diff --git a/Emby.Server.Implementations/IO/ManagedFileSystem.cs b/Emby.Server.Implementations/IO/ManagedFileSystem.cs index c9630b8945..97e89ca3d9 100644 --- a/Emby.Server.Implementations/IO/ManagedFileSystem.cs +++ b/Emby.Server.Implementations/IO/ManagedFileSystem.cs @@ -152,6 +152,10 @@ namespace Emby.Server.Implementations.IO /// public void MoveDirectory(string source, string destination) { + // Make sure parent directory of target exists + var parent = Directory.GetParent(destination); + parent?.Create(); + try { Directory.Move(source, destination); @@ -248,47 +252,40 @@ namespace Emby.Server.Implementations.IO { result.IsDirectory = info is DirectoryInfo || (info.Attributes & FileAttributes.Directory) == FileAttributes.Directory; - // if (!result.IsDirectory) - // { - // result.IsHidden = (info.Attributes & FileAttributes.Hidden) == FileAttributes.Hidden; - // } - if (info is FileInfo fileInfo) { - result.Length = fileInfo.Length; - - // Issue #2354 get the size of files behind symbolic links. Also Enum.HasFlag is bad as it boxes! - if ((fileInfo.Attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint) + result.CreationTimeUtc = GetCreationTimeUtc(info); + result.LastWriteTimeUtc = GetLastWriteTimeUtc(info); + if (fileInfo.LinkTarget is not null) { try { - using (var fileHandle = File.OpenHandle(fileInfo.FullName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + var targetFileInfo = (FileInfo?)fileInfo.ResolveLinkTarget(returnFinalTarget: true); + if (targetFileInfo is not null) { - result.Length = RandomAccess.GetLength(fileHandle); + result.Exists = targetFileInfo.Exists; + if (result.Exists) + { + result.Length = targetFileInfo.Length; + result.CreationTimeUtc = GetCreationTimeUtc(targetFileInfo); + result.LastWriteTimeUtc = GetLastWriteTimeUtc(targetFileInfo); + } + } + else + { + result.Exists = false; } - } - catch (FileNotFoundException ex) - { - // Dangling symlinks cannot be detected before opening the file unfortunately... - _logger.LogError(ex, "Reading the file size of the symlink at {Path} failed. Marking the file as not existing.", fileInfo.FullName); - result.Exists = false; } catch (UnauthorizedAccessException ex) { _logger.LogError(ex, "Reading the file at {Path} failed due to a permissions exception.", fileInfo.FullName); } - catch (IOException ex) - { - // IOException generally means the file is not accessible due to filesystem issues - // Catch this exception and mark the file as not exist to ignore it - _logger.LogError(ex, "Reading the file at {Path} failed due to an IO Exception. Marking the file as not existing", fileInfo.FullName); - result.Exists = false; - } + } + else + { + result.Length = fileInfo.Length; } } - - result.CreationTimeUtc = GetCreationTimeUtc(info); - result.LastWriteTimeUtc = GetLastWriteTimeUtc(info); } else { diff --git a/Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs b/Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs index bafe3ad436..959acd4751 100644 --- a/Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs +++ b/Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs @@ -51,8 +51,7 @@ public class DotIgnoreIgnoreRule : IResolverIgnoreRule } // Fast path in case the ignore files isn't a symlink and is empty - if ((dirIgnoreFile.Attributes & FileAttributes.ReparsePoint) == 0 - && dirIgnoreFile.Length == 0) + if (dirIgnoreFile.LinkTarget is null && dirIgnoreFile.Length == 0) { return true; } @@ -93,6 +92,12 @@ public class DotIgnoreIgnoreRule : IResolverIgnoreRule private static string GetFileContent(FileInfo dirIgnoreFile) { + dirIgnoreFile = (FileInfo?)dirIgnoreFile.ResolveLinkTarget(returnFinalTarget: true) ?? dirIgnoreFile; + if (!dirIgnoreFile.Exists) + { + return string.Empty; + } + using (var reader = dirIgnoreFile.OpenText()) { return reader.ReadToEnd(); diff --git a/Emby.Server.Implementations/Library/ExternalDataManager.cs b/Emby.Server.Implementations/Library/ExternalDataManager.cs index d3cfa1d256..4ad0f999bf 100644 --- a/Emby.Server.Implementations/Library/ExternalDataManager.cs +++ b/Emby.Server.Implementations/Library/ExternalDataManager.cs @@ -3,6 +3,7 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; +using MediaBrowser.Controller.Chapters; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.MediaSegments; @@ -20,6 +21,7 @@ public class ExternalDataManager : IExternalDataManager private readonly IMediaSegmentManager _mediaSegmentManager; private readonly IPathManager _pathManager; private readonly ITrickplayManager _trickplayManager; + private readonly IChapterManager _chapterManager; private readonly ILogger _logger; /// @@ -29,18 +31,21 @@ public class ExternalDataManager : IExternalDataManager /// The media segment manager. /// The path manager. /// The trickplay manager. + /// The chapter manager. /// The logger. public ExternalDataManager( IKeyframeManager keyframeManager, IMediaSegmentManager mediaSegmentManager, IPathManager pathManager, ITrickplayManager trickplayManager, + IChapterManager chapterManager, ILogger logger) { _keyframeManager = keyframeManager; _mediaSegmentManager = mediaSegmentManager; _pathManager = pathManager; _trickplayManager = trickplayManager; + _chapterManager = chapterManager; _logger = logger; } @@ -67,5 +72,6 @@ public class ExternalDataManager : IExternalDataManager await _keyframeManager.DeleteKeyframeDataAsync(itemId, cancellationToken).ConfigureAwait(false); await _mediaSegmentManager.DeleteSegmentsAsync(itemId, cancellationToken).ConfigureAwait(false); await _trickplayManager.DeleteTrickplayDataAsync(itemId, cancellationToken).ConfigureAwait(false); + await _chapterManager.DeleteChapterDataAsync(itemId, cancellationToken).ConfigureAwait(false); } } diff --git a/Emby.Server.Implementations/Library/MusicManager.cs b/Emby.Server.Implementations/Library/MusicManager.cs index e0c8ae371b..e19ad3ef6e 100644 --- a/Emby.Server.Implementations/Library/MusicManager.cs +++ b/Emby.Server.Implementations/Library/MusicManager.cs @@ -28,7 +28,9 @@ namespace Emby.Server.Implementations.Library public IReadOnlyList GetInstantMixFromSong(Audio item, User? user, DtoOptions dtoOptions) { - return GetInstantMixFromGenres(item.Genres, user, dtoOptions); + var instantMixItems = GetInstantMixFromGenres(item.Genres, user, dtoOptions); + + return [item, .. instantMixItems.Where(i => !i.Id.Equals(item.Id))]; } /// diff --git a/Emby.Server.Implementations/Localization/Core/es-MX.json b/Emby.Server.Implementations/Localization/Core/es-MX.json index 52a26c1af2..2830c657b6 100644 --- a/Emby.Server.Implementations/Localization/Core/es-MX.json +++ b/Emby.Server.Implementations/Localization/Core/es-MX.json @@ -30,7 +30,7 @@ "ItemAddedWithName": "{0} fue agregado a la biblioteca", "ItemRemovedWithName": "{0} fue removido de la biblioteca", "LabelIpAddressValue": "Dirección IP: {0}", - "LabelRunningTimeValue": "Tiempo de reproducción: {0}", + "LabelRunningTimeValue": "Tiempo corriendo: {0}", "Latest": "Recientes", "MessageApplicationUpdated": "El servidor Jellyfin ha sido actualizado", "MessageApplicationUpdatedTo": "El servidor Jellyfin ha sido actualizado a {0}", diff --git a/Emby.Server.Implementations/Localization/Core/et.json b/Emby.Server.Implementations/Localization/Core/et.json index b81ae459a8..a3f9dc2f8f 100644 --- a/Emby.Server.Implementations/Localization/Core/et.json +++ b/Emby.Server.Implementations/Localization/Core/et.json @@ -1,14 +1,14 @@ { "TaskCleanActivityLogDescription": "Kustutab määratud ajast vanemad tegevuslogi kirjed.", - "UserDownloadingItemWithValues": "{0} laeb alla {1}", + "UserDownloadingItemWithValues": "{0} laadib alla {1}", "HeaderRecordingGroups": "Salvestusrühmad", "TaskOptimizeDatabaseDescription": "Tihendab ja puhastab andmebaasi. Selle toimingu tegemine pärast meediakogu andmebaasiga seotud muudatuste skannimist võib jõudlust parandada.", "TaskOptimizeDatabase": "Optimeeri andmebaasi", "TaskDownloadMissingSubtitlesDescription": "Otsib veebist puuduvaid subtiitreid vastavalt määratud metaandmete seadetele.", - "TaskDownloadMissingSubtitles": "Laadi alla puuduvad subtiitrid", + "TaskDownloadMissingSubtitles": "Hangi puuduvad subtiitrid", "TaskRefreshChannelsDescription": "Värskendab veebikanalite teavet.", "TaskRefreshChannels": "Värskenda kanaleid", - "TaskCleanTranscodeDescription": "Kustutab üle ühe päeva vanused transkodeerimisfailid.", + "TaskCleanTranscodeDescription": "Kustutab üle ühe päeva vanused transkoodimisfailid.", "TaskCleanTranscode": "Puhasta transkoodimise kataloog", "TaskUpdatePluginsDescription": "Laadib alla ja paigaldab nende pluginate uuendused, mis on seadistatud automaatselt uuenduma.", "TaskUpdatePlugins": "Uuenda pluginaid", @@ -44,7 +44,7 @@ "TvShows": "Sarjad", "System": "Süsteem", "Sync": "Sünkrooni", - "Songs": "Laulud", + "Songs": "Lood", "Shows": "Sarjad", "ServerNameNeedsToBeRestarted": "{0} tuleb taaskäivitada", "ScheduledTaskFailedWithName": "{0} nurjus", @@ -92,7 +92,7 @@ "HeaderNextUp": "Järgmisena", "HeaderLiveTV": "Otse TV", "HeaderFavoriteSongs": "Lemmiklood", - "HeaderFavoriteShows": "Lemmikseriaalid", + "HeaderFavoriteShows": "Lemmiksarjad", "HeaderFavoriteEpisodes": "Lemmikepisoodid", "HeaderFavoriteArtists": "Lemmikesitajad", "HeaderFavoriteAlbums": "Lemmikalbumid", @@ -122,20 +122,20 @@ "UserOnlineFromDevice": "{0} on ühendatud seadmest {1}", "External": "Väline", "HearingImpaired": "Kuulmispuudega", - "TaskKeyframeExtractorDescription": "Eraldab videofailidest võtmekaadreid, et luua täpsemaid HLS-i esitusloendeid. See ülesanne võib kesta pikka aega.", - "TaskKeyframeExtractor": "Võtmekaadrite eraldamine", + "TaskKeyframeExtractorDescription": "Eraldab videofailidest võtmekaadrid, et luua täpsemaid HLS-i esitusloendeid. See võib kesta pikka aega.", + "TaskKeyframeExtractor": "Eralda võtmekaadrid", "TaskRefreshTrickplayImages": "Loo trickplay pildid", "TaskRefreshTrickplayImagesDescription": "Loob trickplay eelvaated videotele lubatud meediakogudes.", - "TaskAudioNormalization": "Normaliseeri heli", + "TaskAudioNormalization": "Normaliseeri helitugevus", "TaskAudioNormalizationDescription": "Otsib failidest helitugevuse normaliseerimise teavet.", - "TaskCleanCollectionsAndPlaylistsDescription": "Eemaldab kogumikest ja esitusloenditest asjad, mida enam ei eksisteeri.", + "TaskCleanCollectionsAndPlaylistsDescription": "Eemaldab kogumikest ja esitusloenditest üksused, mida enam ei eksisteeri.", "TaskCleanCollectionsAndPlaylists": "Puhasta kogumikud ja esitusloendid", - "TaskDownloadMissingLyrics": "Lae alla puuduolev lüürika", - "TaskDownloadMissingLyricsDescription": "Lae lauludele alla lüürika", + "TaskDownloadMissingLyrics": "Hangi puuduvad laulusõnad", + "TaskDownloadMissingLyricsDescription": "Laulusõnade allalaadimine", "TaskMoveTrickplayImagesDescription": "Liigutab trickplay pildid meediakogu sätete kohaselt.", - "TaskExtractMediaSegments": "Meediasegmentide skaneerimine", + "TaskExtractMediaSegments": "Skaneeri meediasegmente", "TaskExtractMediaSegmentsDescription": "Eraldab või võtab meediasegmendid MediaSegment'i lubavatest pluginatest.", "TaskMoveTrickplayImages": "Muuda trickplay piltide asukoht", - "CleanupUserDataTask": "Kasutajaandmete puhastamise ülesanne", + "CleanupUserDataTask": "Puhasta kasutajaandmed", "CleanupUserDataTaskDescription": "Puhastab kõik kasutajaandmed (vaatamise olek, lemmikute olek jne) meediast, mis pole enam vähemalt 90 päeva saadaval olnud." } diff --git a/Emby.Server.Implementations/Localization/Core/gsw.json b/Emby.Server.Implementations/Localization/Core/gsw.json index f847d83d14..e1ee8cf7c4 100644 --- a/Emby.Server.Implementations/Localization/Core/gsw.json +++ b/Emby.Server.Implementations/Localization/Core/gsw.json @@ -11,7 +11,7 @@ "Collections": "Sammlungen", "DeviceOfflineWithName": "{0} wurde getrennt", "DeviceOnlineWithName": "{0} ist verbunden", - "FailedLoginAttemptWithUserName": "Fehlgeschlagener Anmeldeversuch von {0}", + "FailedLoginAttemptWithUserName": "Fählgschlagene Ameldeversuech vo {0}", "Favorites": "Favorite", "Folders": "Ordner", "Genres": "Genre", diff --git a/Emby.Server.Implementations/Localization/Core/hi.json b/Emby.Server.Implementations/Localization/Core/hi.json index 813b18ad4b..80db975ccb 100644 --- a/Emby.Server.Implementations/Localization/Core/hi.json +++ b/Emby.Server.Implementations/Localization/Core/hi.json @@ -129,5 +129,12 @@ "TaskAudioNormalization": "श्रव्य सामान्यीकरण", "TaskAudioNormalizationDescription": "श्रव्य सामान्यीकरण के लिए फाइलें अन्वेषण करें", "TaskDownloadMissingLyrics": "लापता गानों के बोल डाउनलोड करेँ", - "TaskDownloadMissingLyricsDescription": "गानों के बोल डाउनलोड करता है" + "TaskDownloadMissingLyricsDescription": "गानों के बोल डाउनलोड करता है", + "TaskExtractMediaSegments": "मीडिया सेगमेंट स्कैन", + "TaskExtractMediaSegmentsDescription": "मीडियासेगमेंट सक्षम प्लगइन्स से मीडिया सेगमेंट निकालता है या प्राप्त करता है।", + "TaskMoveTrickplayImages": "ट्रिकप्ले छवि स्थान माइग्रेट करें", + "TaskMoveTrickplayImagesDescription": "लाइब्रेरी सेटिंग्स के अनुसार मौजूदा ट्रिकप्ले फ़ाइलों को स्थानांतरित करता है।", + "TaskCleanCollectionsAndPlaylistsDescription": "संग्रहों और प्लेलिस्टों से उन आइटमों को हटाता है जो अब मौजूद नहीं हैं।", + "TaskCleanCollectionsAndPlaylists": "संग्रह और प्लेलिस्ट साफ़ करें", + "CleanupUserDataTask": "यूज़र डेटा की सफाई करता है।" } diff --git a/Emby.Server.Implementations/Localization/Core/ko.json b/Emby.Server.Implementations/Localization/Core/ko.json index efc9f61ddf..3d1b1ed271 100644 --- a/Emby.Server.Implementations/Localization/Core/ko.json +++ b/Emby.Server.Implementations/Localization/Core/ko.json @@ -136,5 +136,7 @@ "TaskMoveTrickplayImages": "트릭플레이 이미지 위치 마이그레이션", "TaskMoveTrickplayImagesDescription": "추출된 트릭플레이 이미지를 라이브러리 설정에 따라 이동합니다.", "TaskDownloadMissingLyrics": "누락된 가사 다운로드", - "TaskDownloadMissingLyricsDescription": "가사 다운로드" + "TaskDownloadMissingLyricsDescription": "가사 다운로드", + "CleanupUserDataTask": "사용자 데이터 정리 작업", + "CleanupUserDataTaskDescription": "최소 90일 이상 존재하지 않는 미디어에 대한 사용자 데이터(시청 상태, 즐겨찾기 등)를 정리합니다." } diff --git a/Emby.Server.Implementations/Localization/Core/mn.json b/Emby.Server.Implementations/Localization/Core/mn.json index 7b44f94873..a684ff2041 100644 --- a/Emby.Server.Implementations/Localization/Core/mn.json +++ b/Emby.Server.Implementations/Localization/Core/mn.json @@ -1,16 +1,16 @@ { - "Books": "Номууд", + "Books": "Номнууд", "HeaderNextUp": "Дараа нь", "HeaderContinueWatching": "Үргэлжлүүлэн үзэх", "Songs": "Дуунууд", - "Playlists": "Тоглуулах жагсаалт", - "Movies": "Кино", + "Playlists": "Тоглуулах жагсаалтууд", + "Movies": "Кинонууд", "Latest": "Сүүлийн үеийн", "Genres": "Төрлүүд", "Favorites": "Дуртай", - "Collections": "Багц", + "Collections": "Цуглуулгууд", "Artists": "Уран бүтээлчид", - "Albums": "Цомгууд", + "Albums": "Дуут цомгууд", "TaskExtractMediaSegments": "Медиа сегмент шалга", "TaskExtractMediaSegmentsDescription": "MediaSegment идэвхжүүлсэн залгаасуудаас медиа сегментүүдийг задлах эсвэл олж авах.", "TaskMoveTrickplayImages": "Трикплэй зургуудын байршлыг шилжүүлэх", @@ -63,15 +63,15 @@ "CameraImageUploadedFrom": "{0}-с шинэ зураг байршуулагдлаа", "Channels": "Сувгууд", "ChapterNameValue": "{0}-р бүлэг", - "Default": "Өгөгдмөл", + "Default": "Анхдагч", "DeviceOfflineWithName": "{0}-н холболт саллаа", "DeviceOnlineWithName": "{0} холбогдлоо", "FailedLoginAttemptWithUserName": "{0}-н нэвтрэх оролдлого амжилтгүй", - "Folders": "Хавтаснууд", + "Folders": "Хавтасууд", "Forced": "Хүчээр", "HeaderAlbumArtists": "Цомгийн уран бүтээлчид", "HeaderFavoriteAlbums": "Дуртай цомгууд", - "HeaderLiveTV": "Шууд", + "HeaderLiveTV": "Шууд ТВ", "HeaderRecordingGroups": "Бичлэгийн бүлгүүд", "HearingImpaired": "Сонсголын бэрхшээлтэй", "HomeVideos": "Үндсэн дүрсүүд", @@ -84,8 +84,8 @@ "MessageApplicationUpdatedTo": "Jellyfin Server {0} болж шинэчлэгдлээ", "MessageServerConfigurationUpdated": "Server-н тохиргоо шинэчлэгдлээ", "MixedContent": "Холимог агуулга", - "Music": "Дуу", - "MusicVideos": "Дууны клип", + "Music": "Хөгжим", + "MusicVideos": "Дууны клипүүд", "NameInstallFailed": "{0} суулгахад алдаа гарлаа", "NameSeasonNumber": "{0}-р улирал", "NameSeasonUnknown": "Улирал олдсонгүй", @@ -101,15 +101,15 @@ "NotificationOptionUserLockedOut": "Хэрэглэгчийг түгжив", "NotificationOptionVideoPlayback": "Бичлэгийг тоглуулж эхлэв", "Photos": "Зургууд", - "Plugin": "Plugin", + "Plugin": "Плагин", "PluginInstalledWithName": "{0}-г суулгалаа", "PluginUninstalledWithName": "{0}-г устгалаа", "PluginUpdatedWithName": "{0}-г шинэчиллээ", "ProviderValue": "Нийлүүлэгч: {0}", "ScheduledTaskStartedWithName": "{0}-г эхлүүлэв", "ServerNameNeedsToBeRestarted": "{0}-г дахин асаана уу", - "Shows": "Нэвтрүүлгүүд", - "Sync": "Дахин", + "Shows": "Шоу", + "Sync": "Синхрончлох", "System": "Систем", "TvShows": "ТВ нэвтрүүлгүүд", "Undefined": "Танисангүй", @@ -122,7 +122,7 @@ "UserPolicyUpdatedWithName": "Хэрэглэгчийн журмыг {0}-д зориулан шинэчиллээ", "UserStartedPlayingItemWithValues": "{0}-г {2} дээр {1}-г тоглуулж байна", "UserStoppedPlayingItemWithValues": "{0}-г {2} дээр {1}-г тоглуулж дуусгалаа", - "ValueSpecialEpisodeName": "Тусгай - {0}", + "ValueSpecialEpisodeName": "Онцгой - {0}", "VersionNumber": "Хувилбар {0}", "TasksMaintenanceCategory": "Засвар", "TasksLibraryCategory": "Сан", diff --git a/Emby.Server.Implementations/Localization/Core/mr.json b/Emby.Server.Implementations/Localization/Core/mr.json index 9cfeb407b6..727bbee168 100644 --- a/Emby.Server.Implementations/Localization/Core/mr.json +++ b/Emby.Server.Implementations/Localization/Core/mr.json @@ -132,5 +132,10 @@ "TaskDownloadMissingLyrics": "उपलब्ध नसलेली गीतपट्टी (Lyrics) डाउनलोड करा", "TaskAudioNormalization": "ऑडिओ सामान्यीकरण", "TaskAudioNormalizationDescription": "ऑडिओ सामान्यीकरणाचा डाटा स्कॅन करतो.", - "TaskDownloadMissingLyricsDescription": "गाण्यांची गीतपट्टी (Lyrics) डाउनलोड करतो" + "TaskDownloadMissingLyricsDescription": "गाण्यांची गीतपट्टी (Lyrics) डाउनलोड करतो", + "TaskExtractMediaSegmentsDescription": "सक्रिय असलेल्या प्लगिनमधून मीडिया विभाग प्राप्त करते.", + "TaskMoveTrickplayImagesDescription": "लायब्ररीच्या सेटिंग्जप्रमाणे आधीपासून अस्तित्वात असलेल्या ट्रिकप्ले फाइल्सचे स्थान बदलते.", + "TaskCleanCollectionsAndPlaylistsDescription": "जे संग्रह आणि प्लेलिस्ट आता अस्तित्वात नाहीत, त्यांमधील घटक हटवते.", + "CleanupUserDataTask": "वापरकर्ता डेटाची स्वच्छता प्रक्रिया", + "CleanupUserDataTaskDescription": "९० दिवसांहून अधिक काळ अनुपस्थित असलेल्या माध्यमांवरील सर्व वापरकर्ता माहिती (जसे पाहण्याची स्थिती, आवडी इ.) हटवते." } diff --git a/Emby.Server.Implementations/Localization/Core/pa.json b/Emby.Server.Implementations/Localization/Core/pa.json index 6062d97003..ced9204b46 100644 --- a/Emby.Server.Implementations/Localization/Core/pa.json +++ b/Emby.Server.Implementations/Localization/Core/pa.json @@ -134,6 +134,8 @@ "TaskCleanCollectionsAndPlaylistsDescription": "ਕਲੈਕਸ਼ਨਾਂ ਅਤੇ ਪਲੇਲਿਸਟਾਂ ਵਿੱਚੋਂ ਉਹ ਆਈਟਮ ਹਟਾਉਂਦਾ ਹੈ ਜੋ ਹੁਣ ਮੌਜੂਦ ਨਹੀਂ ਹਨ।", "TaskCleanCollectionsAndPlaylists": "ਕਲੈਕਸ਼ਨਾਂ ਅਤੇ ਪਲੇਲਿਸਟਾਂ ਨੂੰ ਸਾਫ ਕਰੋ", "TaskAudioNormalization": "ਆਵਾਜ਼ ਸਧਾਰਣੀਕਰਨ", - "TaskRefreshTrickplayImagesDescription": "ਚਲ ਰਹੀ ਲਾਇਬ੍ਰੇਰੀਆਂ ਵਿੱਚ ਵੀਡੀਓਜ਼ ਲਈ ਟ੍ਰਿਕਪਲੇ ਪ੍ਰੀਵਿਊ ਬਣਾਉਂਦਾ ਹੈ।", - "TaskKeyframeExtractorDescription": "ਕੀ-ਫ੍ਰੇਮਜ਼ ਨੂੰ ਵੀਡੀਓ ਫਾਈਲਾਂ ਵਿੱਚੋਂ ਨਿਕਾਲਦਾ ਹੈ ਤਾਂ ਜੋ ਹੋਰ ਜ਼ਿਆਦਾ ਸਟਿਕ ਹੋਣ ਵਾਲੀਆਂ HLS ਪਲੇਲਿਸਟਾਂ ਬਣਾਈਆਂ ਜਾ ਸਕਣ। ਇਹ ਕੰਮ ਲੰਬੇ ਸਮੇਂ ਤੱਕ ਚੱਲ ਸਕਦਾ ਹੈ।" + "TaskRefreshTrickplayImagesDescription": "ਵੀਡੀਓ ਲਈ ਟ੍ਰਿਕਪਲੇ ਪ੍ਰੀਵਿਊ ਬਣਾਉਂਦਾ ਹੈ (ਜੇ ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਚੁਣਿਆ ਗਿਆ ਹੈ)।", + "TaskKeyframeExtractorDescription": "ਕੀ-ਫ੍ਰੇਮਜ਼ ਨੂੰ ਵੀਡੀਓ ਫਾਈਲਾਂ ਵਿੱਚੋਂ ਨਿਕਾਲਦਾ ਹੈ ਤਾਂ ਜੋ ਹੋਰ ਜ਼ਿਆਦਾ ਸਟਿਕ ਹੋਣ ਵਾਲੀਆਂ HLS ਪਲੇਲਿਸਟਾਂ ਬਣਾਈਆਂ ਜਾ ਸਕਣ। ਇਹ ਕੰਮ ਲੰਬੇ ਸਮੇਂ ਤੱਕ ਚੱਲ ਸਕਦਾ ਹੈ।", + "CleanupUserDataTaskDescription": "ਘੱਟੋ-ਘੱਟ 90 ਦਿਨਾਂ ਤੋਂ ਮੌਜੂਦ ਨਾ ਹੋਣ ਵਾਲੇ ਮੀਡੀਆ ਤੋਂ ਸਾਰੇ ਉਪਭੋਗਤਾ ਡੇਟਾ (ਵਾਚ ਸਟੇਟ, ਮਨਪਸੰਦ ਸਟੇਟਸ ਆਦਿ) ਨੂੰ ਸਾਫ਼ ਕਰਦਾ ਹੈ।", + "CleanupUserDataTask": "ਯੂਜ਼ਰ ਡਾਟਾ ਸਾਫ਼ ਕਰਨ ਦਾ ਕੰਮ" } diff --git a/Emby.Server.Implementations/Localization/Core/zh-HK.json b/Emby.Server.Implementations/Localization/Core/zh-HK.json index 39141d8416..c8800e256e 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-HK.json +++ b/Emby.Server.Implementations/Localization/Core/zh-HK.json @@ -23,7 +23,7 @@ "HeaderFavoriteShows": "最愛的節目", "HeaderFavoriteSongs": "最愛的歌曲", "HeaderLiveTV": "電視直播", - "HeaderNextUp": "接著播放", + "HeaderNextUp": "繼續觀看", "HeaderRecordingGroups": "錄製組", "HomeVideos": "家庭影片", "Inherit": "繼承", @@ -127,8 +127,8 @@ "HearingImpaired": "聽力障礙", "TaskRefreshTrickplayImages": "建立 Trickplay 圖像", "TaskRefreshTrickplayImagesDescription": "為已啟用 Trickplay 的媒體庫內的影片建立 Trickplay 預覽圖。", - "TaskExtractMediaSegments": "掃描媒體段落", - "TaskExtractMediaSegmentsDescription": "從MediaSegment中被允許的插件獲取媒體段落。", + "TaskExtractMediaSegments": "掃描媒體分段資訊", + "TaskExtractMediaSegmentsDescription": "從允許MediaSegment 功能的插件中獲取媒體片段。", "TaskDownloadMissingLyrics": "下載欠缺歌詞", "TaskDownloadMissingLyricsDescription": "下載歌詞", "TaskCleanCollectionsAndPlaylists": "整理媒體與播放清單", @@ -137,5 +137,6 @@ "TaskCleanCollectionsAndPlaylistsDescription": "從資料庫及播放清單中移除已不存在的項目。", "TaskMoveTrickplayImagesDescription": "根據媒體庫設定移動現有的 Trickplay 檔案。", "TaskMoveTrickplayImages": "轉移 Trickplay 影像位置", - "CleanupUserDataTask": "用戶資料清理工作" + "CleanupUserDataTask": "用戶資料清理工作", + "CleanupUserDataTaskDescription": "從用戶數據中清除已經被刪除超過 90 日的媒體相關資料。" } diff --git a/Emby.Server.Implementations/Updates/InstallationManager.cs b/Emby.Server.Implementations/Updates/InstallationManager.cs index 678475b31f..5ff4001601 100644 --- a/Emby.Server.Implementations/Updates/InstallationManager.cs +++ b/Emby.Server.Implementations/Updates/InstallationManager.cs @@ -223,15 +223,14 @@ namespace Emby.Server.Implementations.Updates Guid id = default, Version? specificVersion = null) { - if (name is not null) - { - availablePackages = availablePackages.Where(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); - } - if (!id.IsEmpty()) { availablePackages = availablePackages.Where(x => x.Id.Equals(id)); } + else if (name is not null) + { + availablePackages = availablePackages.Where(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + } if (specificVersion is not null) { diff --git a/Jellyfin.Api/Controllers/DynamicHlsController.cs b/Jellyfin.Api/Controllers/DynamicHlsController.cs index 2614fe9956..fe6f855b5e 100644 --- a/Jellyfin.Api/Controllers/DynamicHlsController.cs +++ b/Jellyfin.Api/Controllers/DynamicHlsController.cs @@ -1625,8 +1625,11 @@ public class DynamicHlsController : BaseJellyfinApiController var useLegacySegmentOption = _mediaEncoder.EncoderVersion < _minFFmpegHlsSegmentOptions; - // fMP4 needs this flag to write the audio packet DTS/PTS including the initial delay into MOOF::TRAF::TFDT - hlsArguments += $" {(useLegacySegmentOption ? "-hls_ts_options" : "-hls_segment_options")} movflags=+frag_discont"; + if (state.VideoStream is not null && state.IsOutputVideo) + { + // fMP4 needs this flag to write the audio packet DTS/PTS including the initial delay into MOOF::TRAF::TFDT + hlsArguments += $" {(useLegacySegmentOption ? "-hls_ts_options" : "-hls_segment_options")} movflags=+frag_discont"; + } segmentFormat = "fmp4" + outputFmp4HeaderArg; } diff --git a/Jellyfin.Api/Formatters/XmlOutputFormatter.cs b/Jellyfin.Api/Formatters/XmlOutputFormatter.cs index 8dbb91d0aa..46256c09d7 100644 --- a/Jellyfin.Api/Formatters/XmlOutputFormatter.cs +++ b/Jellyfin.Api/Formatters/XmlOutputFormatter.cs @@ -1,4 +1,8 @@ +using System; using System.Net.Mime; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Formatters; namespace Jellyfin.Api.Formatters; @@ -6,7 +10,7 @@ namespace Jellyfin.Api.Formatters; /// /// Xml output formatter. /// -public sealed class XmlOutputFormatter : StringOutputFormatter +public sealed class XmlOutputFormatter : TextOutputFormatter { /// /// Initializes a new instance of the class. @@ -15,5 +19,24 @@ public sealed class XmlOutputFormatter : StringOutputFormatter { SupportedMediaTypes.Clear(); SupportedMediaTypes.Add(MediaTypeNames.Text.Xml); + + SupportedEncodings.Add(Encoding.UTF8); + SupportedEncodings.Add(Encoding.Unicode); + } + + /// + public override async Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(selectedEncoding); + + var valueAsString = context.Object?.ToString(); + if (string.IsNullOrEmpty(valueAsString)) + { + return; + } + + var response = context.HttpContext.Response; + await response.WriteAsync(valueAsString, selectedEncoding).ConfigureAwait(false); } } diff --git a/Jellyfin.Data/Jellyfin.Data.csproj b/Jellyfin.Data/Jellyfin.Data.csproj index 45374c22f7..fd852ece93 100644 --- a/Jellyfin.Data/Jellyfin.Data.csproj +++ b/Jellyfin.Data/Jellyfin.Data.csproj @@ -18,7 +18,7 @@ Jellyfin Contributors Jellyfin.Data - 10.11.0 + 10.12.0 https://github.com/jellyfin/jellyfin GPL-3.0-only diff --git a/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs b/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs index e5c3cef3d3..70483c36cc 100644 --- a/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs +++ b/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs @@ -128,7 +128,8 @@ public class BackupService : IBackupService var targetPath = Path.GetFullPath(Path.Combine(target, Path.GetRelativePath(source, item.FullName))); if (!sourcePath.StartsWith(fullSourcePath, StringComparison.Ordinal) - || !targetPath.StartsWith(fullTargetRoot, StringComparison.Ordinal)) + || !targetPath.StartsWith(fullTargetRoot, StringComparison.Ordinal) + || Path.EndsInDirectorySeparator(item.FullName)) { continue; } @@ -199,7 +200,7 @@ public class BackupService : IBackupService var zipEntry = zipArchive.GetEntry(NormalizePathSeparator(Path.Combine("Database", $"{entityType.Type.Name}.json"))); if (zipEntry is null) { - _logger.LogInformation("No backup of expected table {Table} is present in backup. Continue anyway.", entityType.Type.Name); + _logger.LogInformation("No backup of expected table {Table} is present in backup, continuing anyway", entityType.Type.Name); continue; } @@ -223,7 +224,7 @@ public class BackupService : IBackupService } catch (Exception ex) { - _logger.LogError(ex, "Could not store entity {Entity} continue anyway.", item); + _logger.LogError(ex, "Could not store entity {Entity}, continuing anyway", item); } } @@ -233,11 +234,11 @@ public class BackupService : IBackupService _logger.LogInformation("Try restore Database"); await dbContext.SaveChangesAsync().ConfigureAwait(false); - _logger.LogInformation("Restored database."); + _logger.LogInformation("Restored database"); } } - _logger.LogInformation("Restored Jellyfin system from {Date}.", manifest.DateCreated); + _logger.LogInformation("Restored Jellyfin system from {Date}", manifest.DateCreated); } } @@ -263,6 +264,8 @@ public class BackupService : IBackupService Options = Map(backupOptions) }; + _logger.LogInformation("Running database optimization before backup"); + await _jellyfinDatabaseProvider.RunScheduledOptimisation(CancellationToken.None).ConfigureAwait(false); var backupFolder = Path.Combine(_applicationPaths.BackupPath); @@ -281,130 +284,154 @@ public class BackupService : IBackupService } var backupPath = Path.Combine(backupFolder, $"jellyfin-backup-{manifest.DateCreated.ToLocalTime():yyyyMMddHHmmss}.zip"); - _logger.LogInformation("Attempt to create a new backup at {BackupPath}", backupPath); - var fileStream = File.OpenWrite(backupPath); - await using (fileStream.ConfigureAwait(false)) - using (var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Create, false)) + + try { - _logger.LogInformation("Start backup process."); - var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); - await using (dbContext.ConfigureAwait(false)) + _logger.LogInformation("Attempting to create a new backup at {BackupPath}", backupPath); + var fileStream = File.OpenWrite(backupPath); + await using (fileStream.ConfigureAwait(false)) + using (var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Create, false)) { - dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; - static IAsyncEnumerable GetValues(IQueryable dbSet) + _logger.LogInformation("Starting backup process"); + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) { - var method = dbSet.GetType().GetMethod(nameof(DbSet.AsAsyncEnumerable))!; - var enumerable = method.Invoke(dbSet, null)!; - return (IAsyncEnumerable)enumerable; - } + dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; - // include the migration history as well - var historyRepository = dbContext.GetService(); - var migrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false); - - ICollection<(Type Type, string SourceName, Func> ValueFactory)> entityTypes = [ - .. typeof(JellyfinDbContext) - .GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance) - .Where(e => e.PropertyType.IsAssignableTo(typeof(IQueryable))) - .Select(e => (Type: e.PropertyType, dbContext.Model.FindEntityType(e.PropertyType.GetGenericArguments()[0])!.GetSchemaQualifiedTableName()!, ValueFactory: new Func>(() => GetValues((IQueryable)e.GetValue(dbContext)!)))), - (Type: typeof(HistoryRow), SourceName: nameof(HistoryRow), ValueFactory: () => migrations.ToAsyncEnumerable()) - ]; - manifest.DatabaseTables = entityTypes.Select(e => e.Type.Name).ToArray(); - var transaction = await dbContext.Database.BeginTransactionAsync().ConfigureAwait(false); - - await using (transaction.ConfigureAwait(false)) - { - _logger.LogInformation("Begin Database backup"); - - foreach (var entityType in entityTypes) + static IAsyncEnumerable GetValues(IQueryable dbSet) { - _logger.LogInformation("Begin backup of entity {Table}", entityType.SourceName); - var zipEntry = zipArchive.CreateEntry(NormalizePathSeparator(Path.Combine("Database", $"{entityType.SourceName}.json"))); - var entities = 0; - var zipEntryStream = zipEntry.Open(); - await using (zipEntryStream.ConfigureAwait(false)) + var method = dbSet.GetType().GetMethod(nameof(DbSet.AsAsyncEnumerable))!; + var enumerable = method.Invoke(dbSet, null)!; + return (IAsyncEnumerable)enumerable; + } + + // include the migration history as well + var historyRepository = dbContext.GetService(); + var migrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false); + + ICollection<(Type Type, string SourceName, Func> ValueFactory)> entityTypes = + [ + .. typeof(JellyfinDbContext) + .GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance) + .Where(e => e.PropertyType.IsAssignableTo(typeof(IQueryable))) + .Select(e => (Type: e.PropertyType, dbContext.Model.FindEntityType(e.PropertyType.GetGenericArguments()[0])!.GetSchemaQualifiedTableName()!, ValueFactory: new Func>(() => GetValues((IQueryable)e.GetValue(dbContext)!)))), + (Type: typeof(HistoryRow), SourceName: nameof(HistoryRow), ValueFactory: () => migrations.ToAsyncEnumerable()) + ]; + manifest.DatabaseTables = entityTypes.Select(e => e.Type.Name).ToArray(); + var transaction = await dbContext.Database.BeginTransactionAsync().ConfigureAwait(false); + + await using (transaction.ConfigureAwait(false)) + { + _logger.LogInformation("Begin Database backup"); + + foreach (var entityType in entityTypes) { - var jsonSerializer = new Utf8JsonWriter(zipEntryStream); - await using (jsonSerializer.ConfigureAwait(false)) + _logger.LogInformation("Begin backup of entity {Table}", entityType.SourceName); + var zipEntry = zipArchive.CreateEntry(NormalizePathSeparator(Path.Combine("Database", $"{entityType.SourceName}.json"))); + var entities = 0; + var zipEntryStream = zipEntry.Open(); + await using (zipEntryStream.ConfigureAwait(false)) { - jsonSerializer.WriteStartArray(); - - var set = entityType.ValueFactory().ConfigureAwait(false); - await foreach (var item in set.ConfigureAwait(false)) + var jsonSerializer = new Utf8JsonWriter(zipEntryStream); + await using (jsonSerializer.ConfigureAwait(false)) { - entities++; - try + jsonSerializer.WriteStartArray(); + + var set = entityType.ValueFactory().ConfigureAwait(false); + await foreach (var item in set.ConfigureAwait(false)) { - JsonSerializer.SerializeToDocument(item, _serializerSettings).WriteTo(jsonSerializer); - } - catch (Exception ex) - { - _logger.LogError(ex, "Could not load entity {Entity}", item); - throw; + entities++; + try + { + using var document = JsonSerializer.SerializeToDocument(item, _serializerSettings); + document.WriteTo(jsonSerializer); + } + catch (Exception ex) + { + _logger.LogError(ex, "Could not load entity {Entity}", item); + throw; + } } + + jsonSerializer.WriteEndArray(); } - - jsonSerializer.WriteEndArray(); } - } - _logger.LogInformation("backup of entity {Table} with {Number} created", entityType.Type.Name, entities); + _logger.LogInformation("Backup of entity {Table} with {Number} created", entityType.SourceName, entities); + } } } - } - _logger.LogInformation("Backup of folder {Table}", _applicationPaths.ConfigurationDirectoryPath); - foreach (var item in Directory.EnumerateFiles(_applicationPaths.ConfigurationDirectoryPath, "*.xml", SearchOption.TopDirectoryOnly) - .Union(Directory.EnumerateFiles(_applicationPaths.ConfigurationDirectoryPath, "*.json", SearchOption.TopDirectoryOnly))) - { - zipArchive.CreateEntryFromFile(item, NormalizePathSeparator(Path.Combine("Config", Path.GetFileName(item)))); - } - - void CopyDirectory(string source, string target, string filter = "*") - { - if (!Directory.Exists(source)) + _logger.LogInformation("Backup of folder {Table}", _applicationPaths.ConfigurationDirectoryPath); + foreach (var item in Directory.EnumerateFiles(_applicationPaths.ConfigurationDirectoryPath, "*.xml", SearchOption.TopDirectoryOnly) + .Union(Directory.EnumerateFiles(_applicationPaths.ConfigurationDirectoryPath, "*.json", SearchOption.TopDirectoryOnly))) { - return; + zipArchive.CreateEntryFromFile(item, NormalizePathSeparator(Path.Combine("Config", Path.GetFileName(item)))); } - _logger.LogInformation("Backup of folder {Table}", source); - - foreach (var item in Directory.EnumerateFiles(source, filter, SearchOption.AllDirectories)) + void CopyDirectory(string source, string target, string filter = "*") { - zipArchive.CreateEntryFromFile(item, NormalizePathSeparator(Path.Combine(target, Path.GetRelativePath(source, item)))); + if (!Directory.Exists(source)) + { + return; + } + + _logger.LogInformation("Backup of folder {Table}", source); + + foreach (var item in Directory.EnumerateFiles(source, filter, SearchOption.AllDirectories)) + { + zipArchive.CreateEntryFromFile(item, NormalizePathSeparator(Path.Combine(target, Path.GetRelativePath(source, item)))); + } + } + + CopyDirectory(Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "users"), Path.Combine("Config", "users")); + CopyDirectory(Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "ScheduledTasks"), Path.Combine("Config", "ScheduledTasks")); + CopyDirectory(Path.Combine(_applicationPaths.RootFolderPath), "Root"); + CopyDirectory(Path.Combine(_applicationPaths.DataPath, "collections"), Path.Combine("Data", "collections")); + CopyDirectory(Path.Combine(_applicationPaths.DataPath, "playlists"), Path.Combine("Data", "playlists")); + CopyDirectory(Path.Combine(_applicationPaths.DataPath, "ScheduledTasks"), Path.Combine("Data", "ScheduledTasks")); + if (backupOptions.Subtitles) + { + CopyDirectory(Path.Combine(_applicationPaths.DataPath, "subtitles"), Path.Combine("Data", "subtitles")); + } + + if (backupOptions.Trickplay) + { + CopyDirectory(Path.Combine(_applicationPaths.DataPath, "trickplay"), Path.Combine("Data", "trickplay")); + } + + if (backupOptions.Metadata) + { + CopyDirectory(Path.Combine(_applicationPaths.InternalMetadataPath), Path.Combine("Data", "metadata")); + } + + var manifestStream = zipArchive.CreateEntry(ManifestEntryName).Open(); + await using (manifestStream.ConfigureAwait(false)) + { + await JsonSerializer.SerializeAsync(manifestStream, manifest).ConfigureAwait(false); } } - CopyDirectory(Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "users"), Path.Combine("Config", "users")); - CopyDirectory(Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "ScheduledTasks"), Path.Combine("Config", "ScheduledTasks")); - CopyDirectory(Path.Combine(_applicationPaths.RootFolderPath), "Root"); - CopyDirectory(Path.Combine(_applicationPaths.DataPath, "collections"), Path.Combine("Data", "collections")); - CopyDirectory(Path.Combine(_applicationPaths.DataPath, "playlists"), Path.Combine("Data", "playlists")); - CopyDirectory(Path.Combine(_applicationPaths.DataPath, "ScheduledTasks"), Path.Combine("Data", "ScheduledTasks")); - if (backupOptions.Subtitles) - { - CopyDirectory(Path.Combine(_applicationPaths.DataPath, "subtitles"), Path.Combine("Data", "subtitles")); - } - - if (backupOptions.Trickplay) - { - CopyDirectory(Path.Combine(_applicationPaths.DataPath, "trickplay"), Path.Combine("Data", "trickplay")); - } - - if (backupOptions.Metadata) - { - CopyDirectory(Path.Combine(_applicationPaths.InternalMetadataPath), Path.Combine("Data", "metadata")); - } - - var manifestStream = zipArchive.CreateEntry(ManifestEntryName).Open(); - await using (manifestStream.ConfigureAwait(false)) - { - await JsonSerializer.SerializeAsync(manifestStream, manifest).ConfigureAwait(false); - } + _logger.LogInformation("Backup created"); + return Map(manifest, backupPath); } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create backup, removing {BackupPath}", backupPath); + try + { + if (File.Exists(backupPath)) + { + File.Delete(backupPath); + } + } + catch (Exception innerEx) + { + _logger.LogWarning(innerEx, "Unable to remove failed backup"); + } - _logger.LogInformation("Backup created"); - return Map(manifest, backupPath); + throw; + } } /// @@ -422,7 +449,7 @@ public class BackupService : IBackupService } catch (Exception ex) { - _logger.LogError(ex, "Tried to load archive from {Path} but failed.", archivePath); + _logger.LogWarning(ex, "Tried to load manifest from archive {Path} but failed", archivePath); return null; } @@ -459,7 +486,7 @@ public class BackupService : IBackupService } catch (Exception ex) { - _logger.LogError(ex, "Could not load {BackupArchive} path.", item); + _logger.LogWarning(ex, "Tried to load manifest from archive {Path} but failed", item); } } diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs index f4396301cb..b939c4ab21 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs @@ -614,6 +614,13 @@ public sealed class BaseItemRepository else { context.BaseItemProviders.Where(e => e.ItemId == entity.Id).ExecuteDelete(); + context.BaseItemImageInfos.Where(e => e.ItemId == entity.Id).ExecuteDelete(); + + if (entity.Images is { Count: > 0 }) + { + context.BaseItemImageInfos.AddRange(entity.Images); + } + context.BaseItems.Attach(entity).State = EntityState.Modified; } } @@ -1756,7 +1763,8 @@ public sealed class BaseItemRepository if (!string.IsNullOrWhiteSpace(filter.Path)) { - baseQuery = baseQuery.Where(e => e.Path == filter.Path); + var pathToQuery = GetPathToSave(filter.Path); + baseQuery = baseQuery.Where(e => e.Path == pathToQuery); } if (!string.IsNullOrWhiteSpace(filter.PresentationUniqueKey)) @@ -2014,7 +2022,7 @@ public sealed class BaseItemRepository if (filter.ArtistIds.Length > 0) { - baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Artist, filter.ArtistIds); + baseQuery = baseQuery.WhereReferencedItemMultipleTypes(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ArtistIds); } if (filter.AlbumArtistIds.Length > 0) @@ -2024,7 +2032,18 @@ public sealed class BaseItemRepository if (filter.ContributingArtistIds.Length > 0) { - baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Artist, filter.ContributingArtistIds); + var contributingNames = context.BaseItems + .Where(b => filter.ContributingArtistIds.Contains(b.Id)) + .Select(b => b.CleanName); + + baseQuery = baseQuery.Where(e => + e.ItemValues!.Any(ivm => + ivm.ItemValue.Type == ItemValueType.Artist && + contributingNames.Contains(ivm.ItemValue.CleanValue)) + && + !e.ItemValues!.Any(ivm => + ivm.ItemValue.Type == ItemValueType.AlbumArtist && + contributingNames.Contains(ivm.ItemValue.CleanValue))); } if (filter.AlbumIds.Length > 0) @@ -2035,7 +2054,7 @@ public sealed class BaseItemRepository if (filter.ExcludeArtistIds.Length > 0) { - baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Artist, filter.ExcludeArtistIds, true); + baseQuery = baseQuery.WhereReferencedItemMultipleTypes(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ExcludeArtistIds, true); } if (filter.GenreIds.Count > 0) @@ -2342,17 +2361,23 @@ public sealed class BaseItemRepository if (filter.HasImdbId.HasValue) { - baseQuery = baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId == "imdb")); + baseQuery = filter.HasImdbId.Value + ? baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId.ToLower() == MetadataProvider.Imdb.ToString().ToLower())) + : baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != MetadataProvider.Imdb.ToString().ToLower())); } if (filter.HasTmdbId.HasValue) { - baseQuery = baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId == "tmdb")); + baseQuery = filter.HasTmdbId.Value + ? baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId.ToLower() == MetadataProvider.Tmdb.ToString().ToLower())) + : baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != MetadataProvider.Tmdb.ToString().ToLower())); } if (filter.HasTvdbId.HasValue) { - baseQuery = baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId == "tvdb")); + baseQuery = filter.HasTvdbId.Value + ? baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId.ToLower() == MetadataProvider.Tvdb.ToString().ToLower())) + : baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != MetadataProvider.Tvdb.ToString().ToLower())); } var queryTopParentIds = filter.TopParentIds; diff --git a/Jellyfin.Server.Implementations/Item/ChapterRepository.cs b/Jellyfin.Server.Implementations/Item/ChapterRepository.cs index e0d23a2613..98700f3224 100644 --- a/Jellyfin.Server.Implementations/Item/ChapterRepository.cs +++ b/Jellyfin.Server.Implementations/Item/ChapterRepository.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Drawing; @@ -82,11 +84,14 @@ public class ChapterRepository : IChapterRepository } /// - public void DeleteChapters(Guid itemId) + public async Task DeleteChaptersAsync(Guid itemId, CancellationToken cancellationToken) { - using var context = _dbProvider.CreateDbContext(); - context.Chapters.Where(c => c.ItemId.Equals(itemId)).ExecuteDelete(); - context.SaveChanges(); + var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + await dbContext.Chapters.Where(c => c.ItemId.Equals(itemId)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); + await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } } private Chapter Map(ChapterInfo chapterInfo, int index, Guid itemId) diff --git a/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs b/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs index b2f54be7e2..570d6cb9b7 100644 --- a/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs +++ b/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs @@ -14,7 +14,7 @@ public static class StorageHelper { private const long TwoGigabyte = 2_147_483_647L; private const long FiveHundredAndTwelveMegaByte = 536_870_911L; - private static readonly string[] _byteHumanizedSuffixes = ["B", "KB", "MB", "GB", "TB", "PB", "EB"]; + private static readonly string[] _byteHumanizedSuffixes = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"]; /// /// Tests the available storage capacity on the jellyfin paths with estimated minimum values. @@ -27,7 +27,7 @@ public static class StorageHelper TestDataDirectorySize(applicationPaths.LogDirectoryPath, logger, FiveHundredAndTwelveMegaByte); TestDataDirectorySize(applicationPaths.CachePath, logger, TwoGigabyte); TestDataDirectorySize(applicationPaths.ProgramDataPath, logger, TwoGigabyte); - TestDataDirectorySize(applicationPaths.TempDirectory, logger, TwoGigabyte); + TestDataDirectorySize(applicationPaths.TempDirectory, logger, FiveHundredAndTwelveMegaByte); } /// @@ -77,7 +77,7 @@ public static class StorageHelper var drive = new DriveInfo(path); if (threshold != -1 && drive.AvailableFreeSpace < threshold) { - throw new InvalidOperationException($"The path `{path}` has insufficient free space. Required: at least {HumanizeStorageSize(threshold)}."); + throw new InvalidOperationException($"The path `{path}` has insufficient free space. Available: {HumanizeStorageSize(drive.AvailableFreeSpace)}, Required: {HumanizeStorageSize(threshold)}."); } logger.LogInformation( diff --git a/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs b/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs index f20fb2d92d..49a9fda943 100644 --- a/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs +++ b/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Security.Cryptography; using System.Text.Json; @@ -92,33 +93,38 @@ namespace Jellyfin.Server.Implementations.Users } /// - public async Task StartForgotPasswordProcess(User user, bool isInNetwork) + public async Task StartForgotPasswordProcess(User? user, string enteredUsername, bool isInNetwork) { - byte[] bytes = new byte[4]; - RandomNumberGenerator.Fill(bytes); - string pin = BitConverter.ToString(bytes); - DateTime expireTime = DateTime.UtcNow.AddMinutes(30); - string filePath = _passwordResetFileBase + user.Id + ".json"; - SerializablePasswordReset spr = new SerializablePasswordReset - { - ExpirationDate = expireTime, - Pin = pin, - PinFile = filePath, - UserName = user.Username - }; + var usernameHash = enteredUsername.ToUpperInvariant().GetMD5().ToString("N", CultureInfo.InvariantCulture); + var pinFile = _passwordResetFileBase + usernameHash + ".json"; - FileStream fileStream = AsyncFile.Create(filePath); - await using (fileStream.ConfigureAwait(false)) + if (user is not null && isInNetwork) { - await JsonSerializer.SerializeAsync(fileStream, spr).ConfigureAwait(false); + byte[] bytes = new byte[4]; + RandomNumberGenerator.Fill(bytes); + string pin = BitConverter.ToString(bytes); + + SerializablePasswordReset spr = new SerializablePasswordReset + { + ExpirationDate = expireTime, + Pin = pin, + PinFile = pinFile, + UserName = user.Username + }; + + FileStream fileStream = AsyncFile.Create(pinFile); + await using (fileStream.ConfigureAwait(false)) + { + await JsonSerializer.SerializeAsync(fileStream, spr).ConfigureAwait(false); + } } return new ForgotPasswordResult { Action = ForgotPasswordAction.PinCode, PinExpirationDate = expireTime, - PinFile = filePath + PinFile = pinFile }; } diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs index d0b41a7f6b..b534ccd1bd 100644 --- a/Jellyfin.Server.Implementations/Users/UserManager.cs +++ b/Jellyfin.Server.Implementations/Users/UserManager.cs @@ -508,23 +508,18 @@ namespace Jellyfin.Server.Implementations.Users public async Task StartForgotPasswordProcess(string enteredUsername, bool isInNetwork) { var user = string.IsNullOrWhiteSpace(enteredUsername) ? null : GetUserByName(enteredUsername); + var passwordResetProvider = GetPasswordResetProvider(user); + + var result = await passwordResetProvider + .StartForgotPasswordProcess(user, enteredUsername, isInNetwork) + .ConfigureAwait(false); if (user is not null && isInNetwork) { - var passwordResetProvider = GetPasswordResetProvider(user); - var result = await passwordResetProvider - .StartForgotPasswordProcess(user, isInNetwork) - .ConfigureAwait(false); - await UpdateUserAsync(user).ConfigureAwait(false); - return result; } - return new ForgotPasswordResult - { - Action = ForgotPasswordAction.InNetworkRequired, - PinFile = string.Empty - }; + return result; } /// @@ -760,8 +755,13 @@ namespace Jellyfin.Server.Implementations.Users return GetAuthenticationProviders(user)[0]; } - private IPasswordResetProvider GetPasswordResetProvider(User user) + private IPasswordResetProvider GetPasswordResetProvider(User? user) { + if (user is null) + { + return _defaultPasswordResetProvider; + } + return GetPasswordResetProviders(user)[0]; } diff --git a/Jellyfin.Server/Filters/RetryOnTemporarilyUnavailableFilter.cs b/Jellyfin.Server/Filters/RetryOnTemporarilyUnavailableFilter.cs index 08caac0d38..8b7268513a 100644 --- a/Jellyfin.Server/Filters/RetryOnTemporarilyUnavailableFilter.cs +++ b/Jellyfin.Server/Filters/RetryOnTemporarilyUnavailableFilter.cs @@ -8,7 +8,7 @@ internal class RetryOnTemporarilyUnavailableFilter : IOperationFilter { public void Apply(OpenApiOperation operation, OperationFilterContext context) { - operation.Responses.Add( + operation.Responses.TryAdd( "503", new OpenApiResponse { diff --git a/Jellyfin.Server/Filters/SecurityRequirementsOperationFilter.cs b/Jellyfin.Server/Filters/SecurityRequirementsOperationFilter.cs index 401392a633..8f57572696 100644 --- a/Jellyfin.Server/Filters/SecurityRequirementsOperationFilter.cs +++ b/Jellyfin.Server/Filters/SecurityRequirementsOperationFilter.cs @@ -66,15 +66,8 @@ public class SecurityRequirementsOperationFilter : IOperationFilter return; } - if (!operation.Responses.ContainsKey("401")) - { - operation.Responses.Add("401", new OpenApiResponse { Description = "Unauthorized" }); - } - - if (!operation.Responses.ContainsKey("403")) - { - operation.Responses.Add("403", new OpenApiResponse { Description = "Forbidden" }); - } + operation.Responses.TryAdd("401", new OpenApiResponse { Description = "Unauthorized" }); + operation.Responses.TryAdd("403", new OpenApiResponse { Description = "Forbidden" }); var scheme = new OpenApiSecurityScheme { diff --git a/Jellyfin.Server/Infrastructure/SymlinkFollowingPhysicalFileResultExecutor.cs b/Jellyfin.Server/Infrastructure/SymlinkFollowingPhysicalFileResultExecutor.cs deleted file mode 100644 index 910b5c4672..0000000000 --- a/Jellyfin.Server/Infrastructure/SymlinkFollowingPhysicalFileResultExecutor.cs +++ /dev/null @@ -1,151 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) .NET Foundation and Contributors -// -// All rights reserved. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -using System; -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Extensions; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Infrastructure; -using Microsoft.Extensions.Logging; -using Microsoft.Net.Http.Headers; - -namespace Jellyfin.Server.Infrastructure -{ - /// - public class SymlinkFollowingPhysicalFileResultExecutor : PhysicalFileResultExecutor - { - /// - /// Initializes a new instance of the class. - /// - /// An instance of the interface. - public SymlinkFollowingPhysicalFileResultExecutor(ILoggerFactory loggerFactory) : base(loggerFactory) - { - } - - /// - protected override FileMetadata GetFileInfo(string path) - { - var fileInfo = new FileInfo(path); - var length = fileInfo.Length; - // This may or may not be fixed in .NET 6, but looks like it will not https://github.com/dotnet/aspnetcore/issues/34371 - if ((fileInfo.Attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint) - { - using var fileHandle = File.OpenHandle(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - length = RandomAccess.GetLength(fileHandle); - } - - return new FileMetadata - { - Exists = fileInfo.Exists, - Length = length, - LastModified = fileInfo.LastWriteTimeUtc - }; - } - - /// - protected override async Task WriteFileAsync(ActionContext context, PhysicalFileResult result, RangeItemHeaderValue? range, long rangeLength) - { - ArgumentNullException.ThrowIfNull(context); - ArgumentNullException.ThrowIfNull(result); - - if (range is not null && rangeLength == 0) - { - return; - } - - // It's a bit of wasted IO to perform this check again, but non-symlinks shouldn't use this code - if (!IsSymLink(result.FileName)) - { - await base.WriteFileAsync(context, result, range, rangeLength).ConfigureAwait(false); - return; - } - - var response = context.HttpContext.Response; - - if (range is not null) - { - await SendFileAsync( - result.FileName, - response, - offset: range.From ?? 0L, - count: rangeLength).ConfigureAwait(false); - return; - } - - await SendFileAsync( - result.FileName, - response, - offset: 0, - count: null).ConfigureAwait(false); - } - - private async Task SendFileAsync(string filePath, HttpResponse response, long offset, long? count, CancellationToken cancellationToken = default) - { - var fileInfo = GetFileInfo(filePath); - if (offset < 0 || offset > fileInfo.Length) - { - throw new ArgumentOutOfRangeException(nameof(offset), offset, string.Empty); - } - - if (count.HasValue - && (count.Value < 0 || count.Value > fileInfo.Length - offset)) - { - throw new ArgumentOutOfRangeException(nameof(count), count, string.Empty); - } - - // Copied from SendFileFallback.SendFileAsync - const int BufferSize = 1024 * 16; - - var useRequestAborted = !cancellationToken.CanBeCanceled; - var localCancel = useRequestAborted ? response.HttpContext.RequestAborted : cancellationToken; - - var fileStream = new FileStream( - filePath, - FileMode.Open, - FileAccess.Read, - FileShare.ReadWrite, - bufferSize: BufferSize, - options: FileOptions.Asynchronous | FileOptions.SequentialScan); - await using (fileStream.ConfigureAwait(false)) - { - try - { - localCancel.ThrowIfCancellationRequested(); - fileStream.Seek(offset, SeekOrigin.Begin); - await StreamCopyOperation - .CopyToAsync(fileStream, response.Body, count, BufferSize, localCancel) - .ConfigureAwait(true); - } - catch (OperationCanceledException) when (useRequestAborted) - { - } - } - } - - private static bool IsSymLink(string path) => (File.GetAttributes(path) & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint; - } -} diff --git a/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs index a954d307e1..8c8563190d 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs @@ -55,9 +55,25 @@ namespace Jellyfin.Server.Migrations.Routines }; var dataPath = _paths.DataPath; - using (var connection = new SqliteConnection($"Filename={Path.Combine(dataPath, DbFilename)}")) + var activityLogPath = Path.Combine(dataPath, DbFilename); + if (!File.Exists(activityLogPath)) + { + _logger.LogWarning("{ActivityLogDb} doesn't exist, nothing to migrate", activityLogPath); + return; + } + + using (var connection = new SqliteConnection($"Filename={activityLogPath}")) { connection.Open(); + var tableQuery = connection.Query("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='ActivityLog';"); + foreach (var row in tableQuery) + { + if (row.GetInt32(0) == 0) + { + _logger.LogWarning("Table 'ActivityLog' doesn't exist in {ActivityLogPath}, nothing to migrate", activityLogPath); + return; + } + } using var userDbConnection = new SqliteConnection($"Filename={Path.Combine(dataPath, "users.db")}"); userDbConnection.Open(); diff --git a/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs index c6699c21df..0de775e03a 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs @@ -50,9 +50,28 @@ namespace Jellyfin.Server.Migrations.Routines public void Perform() { var dataPath = _appPaths.DataPath; - using (var connection = new SqliteConnection($"Filename={Path.Combine(dataPath, DbFilename)}")) + var dbFilePath = Path.Combine(dataPath, DbFilename); + + if (!File.Exists(dbFilePath)) + { + _logger.LogWarning("{Path} doesn't exist, nothing to migrate", dbFilePath); + return; + } + + using (var connection = new SqliteConnection($"Filename={dbFilePath}")) { connection.Open(); + + var tableQuery = connection.Query("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='Tokens';"); + foreach (var row in tableQuery) + { + if (row.GetInt32(0) == 0) + { + _logger.LogWarning("Table 'Tokens' doesn't exist in {Path}, nothing to migrate", dbFilePath); + return; + } + } + using var dbContext = _dbProvider.CreateDbContext(); var authenticatedDevices = connection.Query("SELECT * FROM Tokens"); diff --git a/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs index 0d9952ce97..ffd06fea0d 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs @@ -78,9 +78,27 @@ namespace Jellyfin.Server.Migrations.Routines var displayPrefs = new HashSet(StringComparer.OrdinalIgnoreCase); var customDisplayPrefs = new HashSet(StringComparer.OrdinalIgnoreCase); var dbFilePath = Path.Combine(_paths.DataPath, DbFilename); + + if (!File.Exists(dbFilePath)) + { + _logger.LogWarning("{Path} doesn't exist, nothing to migrate", dbFilePath); + return; + } + using (var connection = new SqliteConnection($"Filename={dbFilePath}")) { connection.Open(); + + var tableQuery = connection.Query("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='userdisplaypreferences';"); + foreach (var row in tableQuery) + { + if (row.GetInt32(0) == 0) + { + _logger.LogWarning("Table 'userdisplaypreferences' doesn't exist in {Path}, nothing to migrate", dbFilePath); + return; + } + } + using var dbContext = _provider.CreateDbContext(); var results = connection.Query("SELECT * FROM userdisplaypreferences"); diff --git a/Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs b/Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs index c199ee4d6b..aa55309264 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs @@ -122,6 +122,16 @@ public class MigrateKeyframeData : IDatabaseMigrationRoutine { lastWriteTimeUtc = File.GetLastWriteTimeUtc(filePath); } + catch (ArgumentOutOfRangeException e) + { + _logger.LogDebug("Skipping {Path}: {Exception}", filePath, e.Message); + return null; + } + catch (UnauthorizedAccessException e) + { + _logger.LogDebug("Skipping {Path}: {Exception}", filePath, e.Message); + return null; + } catch (IOException e) { _logger.LogDebug("Skipping {Path}: {Exception}", filePath, e.Message); @@ -135,14 +145,21 @@ public class MigrateKeyframeData : IDatabaseMigrationRoutine return Path.Join(keyframeCachePath, prefix, filename); } - private static bool TryReadFromCache(string? cachePath, [NotNullWhen(true)] out MediaEncoding.Keyframes.KeyframeData? cachedResult) + private bool TryReadFromCache(string? cachePath, [NotNullWhen(true)] out MediaEncoding.Keyframes.KeyframeData? cachedResult) { if (File.Exists(cachePath)) { - var bytes = File.ReadAllBytes(cachePath); - cachedResult = JsonSerializer.Deserialize(bytes, _jsonOptions); + try + { + var bytes = File.ReadAllBytes(cachePath); + cachedResult = JsonSerializer.Deserialize(bytes, _jsonOptions); - return cachedResult is not null; + return cachedResult is not null; + } + catch (JsonException jsonException) + { + _logger.LogWarning(jsonException, "Failed to read {Path}", cachePath); + } } cachedResult = null; diff --git a/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs index e5584fb947..8c3361ee16 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs @@ -57,11 +57,28 @@ public class MigrateUserDb : IMigrationRoutine public void Perform() { var dataPath = _paths.DataPath; + var userDbPath = Path.Combine(dataPath, DbFilename); + if (!File.Exists(userDbPath)) + { + _logger.LogWarning("{UserDbPath} doesn't exist, nothing to migrate", userDbPath); + return; + } + _logger.LogInformation("Migrating the user database may take a while, do not stop Jellyfin."); - using (var connection = new SqliteConnection($"Filename={Path.Combine(dataPath, DbFilename)}")) + using (var connection = new SqliteConnection($"Filename={userDbPath}")) { connection.Open(); + var tableQuery = connection.Query("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='LocalUsersv2';"); + foreach (var row in tableQuery) + { + if (row.GetInt32(0) == 0) + { + _logger.LogWarning("Table 'LocalUsersv2' doesn't exist in {UserDbPath}, nothing to migrate", userDbPath); + return; + } + } + using var dbContext = _provider.CreateDbContext(); var queryResult = connection.Query("SELECT * FROM LocalUsersv2"); diff --git a/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs b/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs index 8b394dd7aa..fbf9c16377 100644 --- a/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs +++ b/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs @@ -224,6 +224,18 @@ public class MoveExtractedFiles : IAsyncMigrationRoutine return null; } + catch (UnauthorizedAccessException e) + { + _logger.LogDebug("Skipping subtitle at index {Index} for {Path}: {Exception}", attachmentStreamIndex, mediaPath, e.Message); + + return null; + } + catch (ArgumentOutOfRangeException e) + { + _logger.LogDebug("Skipping attachment at index {Index} for {Path}: {Exception}", attachmentStreamIndex, mediaPath, e.Message); + + return null; + } filename = (mediaPath + attachmentStreamIndex.ToString(CultureInfo.InvariantCulture) + "_" + date.Value.Ticks.ToString(CultureInfo.InvariantCulture)).GetMD5().ToString("D", CultureInfo.InvariantCulture); } @@ -263,6 +275,18 @@ public class MoveExtractedFiles : IAsyncMigrationRoutine { date = File.GetLastWriteTimeUtc(path); } + catch (ArgumentOutOfRangeException e) + { + _logger.LogDebug("Skipping subtitle at index {Index} for {Path}: {Exception}", streamIndex, path, e.Message); + + return null; + } + catch (UnauthorizedAccessException e) + { + _logger.LogDebug("Skipping subtitle at index {Index} for {Path}: {Exception}", streamIndex, path, e.Message); + + return null; + } catch (IOException e) { _logger.LogDebug("Skipping subtitle at index {Index} for {Path}: {Exception}", streamIndex, path, e.Message); diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs index dc7fa5eb36..93f71fdc69 100644 --- a/Jellyfin.Server/Program.cs +++ b/Jellyfin.Server/Program.cs @@ -184,6 +184,12 @@ namespace Jellyfin.Server .AddSingleton(e)) .Build(); + /* + * Initialize the transcode path marker so we avoid starting Jellyfin in a broken state. + * This should really be a part of IApplicationPaths but this path is configured differently. + */ + _ = appHost.ConfigurationManager.GetTranscodePath(); + // Re-use the host service provider in the app host since ASP.NET doesn't allow a custom service collection. appHost.ServiceProvider = _jellyfinHost.Services; PrepareDatabaseProvider(appHost.ServiceProvider); diff --git a/Jellyfin.Server/ServerSetupApp/SetupServer.cs b/Jellyfin.Server/ServerSetupApp/SetupServer.cs index 72626e8532..00d9fcc025 100644 --- a/Jellyfin.Server/ServerSetupApp/SetupServer.cs +++ b/Jellyfin.Server/ServerSetupApp/SetupServer.cs @@ -250,6 +250,7 @@ public sealed class SetupServer : IDisposable { "isInReportingMode", _isUnhealthy }, { "retryValue", retryAfterValue }, { "logs", startupLogEntries }, + { "networkManagerReady", networkManager is not null }, { "localNetworkRequest", networkManager is not null && context.Connection.RemoteIpAddress is not null && networkManager.IsInLocalNetwork(context.Connection.RemoteIpAddress) } }, new ByteCounterStream(context.Response.BodyWriter.AsStream(), IODefaults.FileStreamBufferSize, true, _startupUiRenderer.ParserOptions)) diff --git a/Jellyfin.Server/ServerSetupApp/index.mstemplate.html b/Jellyfin.Server/ServerSetupApp/index.mstemplate.html index 523f38d74a..9ec6efa2b9 100644 --- a/Jellyfin.Server/ServerSetupApp/index.mstemplate.html +++ b/Jellyfin.Server/ServerSetupApp/index.mstemplate.html @@ -213,7 +213,12 @@ {{#ELSE}} + {{#IF networkManagerReady}}

Please visit this page from your local network to view detailed startup logs.

+ {{#ELSE}} +

Initializing network settings. Please wait.

+ {{/ELSE}} + {{/IF}} {{/ELSE}} {{/IF}} diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs index aa8f6dd1cd..5032b2aec1 100644 --- a/Jellyfin.Server/Startup.cs +++ b/Jellyfin.Server/Startup.cs @@ -16,15 +16,12 @@ using Jellyfin.Networking.HappyEyeballs; using Jellyfin.Server.Extensions; using Jellyfin.Server.HealthChecks; using Jellyfin.Server.Implementations.Extensions; -using Jellyfin.Server.Infrastructure; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Extensions; using MediaBrowser.XbmcMetadata; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.StaticFiles; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -69,8 +66,6 @@ namespace Jellyfin.Server options.HttpsPort = _serverApplicationHost.HttpsPort; }); - // TODO remove once this is fixed upstream https://github.com/dotnet/aspnetcore/issues/34371 - services.AddSingleton, SymlinkFollowingPhysicalFileResultExecutor>(); services.AddJellyfinApi(_serverApplicationHost.GetApiPluginAssemblies(), _serverConfigurationManager.GetNetworkConfiguration()); services.AddJellyfinDbContext(_serverApplicationHost.ConfigurationManager, _configuration); services.AddJellyfinApiSwagger(); diff --git a/MediaBrowser.Common/MediaBrowser.Common.csproj b/MediaBrowser.Common/MediaBrowser.Common.csproj index de6be4707e..9af13b0a72 100644 --- a/MediaBrowser.Common/MediaBrowser.Common.csproj +++ b/MediaBrowser.Common/MediaBrowser.Common.csproj @@ -8,7 +8,7 @@ Jellyfin Contributors Jellyfin.Common - 10.11.0 + 10.12.0 https://github.com/jellyfin/jellyfin GPL-3.0-only diff --git a/MediaBrowser.Controller/Authentication/IPasswordResetProvider.cs b/MediaBrowser.Controller/Authentication/IPasswordResetProvider.cs index 592ce99556..36cd5c5d14 100644 --- a/MediaBrowser.Controller/Authentication/IPasswordResetProvider.cs +++ b/MediaBrowser.Controller/Authentication/IPasswordResetProvider.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CS1591 using System; @@ -15,11 +13,12 @@ namespace MediaBrowser.Controller.Authentication bool IsEnabled { get; } - Task StartForgotPasswordProcess(User user, bool isInNetwork); + Task StartForgotPasswordProcess(User? user, string enteredUsername, bool isInNetwork); Task RedeemPasswordResetPin(string pin); } +#nullable disable public class PasswordPinCreationResult { public string PinFile { get; set; } diff --git a/MediaBrowser.Controller/Chapters/IChapterManager.cs b/MediaBrowser.Controller/Chapters/IChapterManager.cs index 7532e56c60..25656fd625 100644 --- a/MediaBrowser.Controller/Chapters/IChapterManager.cs +++ b/MediaBrowser.Controller/Chapters/IChapterManager.cs @@ -48,8 +48,10 @@ public interface IChapterManager Task RefreshChapterImages(Video video, IDirectoryService directoryService, IReadOnlyList chapters, bool extractImages, bool saveChapters, CancellationToken cancellationToken); /// - /// Deletes the chapter images. + /// Deletes the chapter data. /// - /// Video to use. - void DeleteChapterImages(Video video); + /// The item id. + /// The cancellation token. + /// Task. + Task DeleteChapterDataAsync(Guid itemId, CancellationToken cancellationToken); } diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs index e9a3836902..03ee447088 100644 --- a/MediaBrowser.Controller/Entities/Folder.cs +++ b/MediaBrowser.Controller/Entities/Folder.cs @@ -715,9 +715,18 @@ namespace MediaBrowser.Controller.Entities } else { - items = GetRecursiveChildren(user, query, out totalCount); + // Save pagination params before clearing them to prevent pagination from happening + // before sorting. PostFilterAndSort will apply pagination after sorting. + var limit = query.Limit; + var startIndex = query.StartIndex; query.Limit = null; - query.StartIndex = null; // override these here as they have already been applied + query.StartIndex = null; + + items = GetRecursiveChildren(user, query, out totalCount); + + // Restore pagination params so PostFilterAndSort can apply them after sorting + query.Limit = limit; + query.StartIndex = startIndex; } var result = PostFilterAndSort(items, query); @@ -980,20 +989,16 @@ namespace MediaBrowser.Controller.Entities else { // need to pass this param to the children. + // Note: Don't pass Limit/StartIndex here as pagination should happen after sorting in PostFilterAndSort var childQuery = new InternalItemsQuery { DisplayAlbumFolders = query.DisplayAlbumFolders, - Limit = query.Limit, - StartIndex = query.StartIndex, NameStartsWith = query.NameStartsWith, NameStartsWithOrGreater = query.NameStartsWithOrGreater, NameLessThan = query.NameLessThan }; items = GetChildren(user, true, out totalItemCount, childQuery).Where(filter); - - query.Limit = null; - query.StartIndex = null; } var result = PostFilterAndSort(items, query); diff --git a/MediaBrowser.Controller/MediaBrowser.Controller.csproj b/MediaBrowser.Controller/MediaBrowser.Controller.csproj index 3353ad63f1..b5d14e94b1 100644 --- a/MediaBrowser.Controller/MediaBrowser.Controller.csproj +++ b/MediaBrowser.Controller/MediaBrowser.Controller.csproj @@ -8,7 +8,7 @@ Jellyfin Contributors Jellyfin.Controller - 10.11.0 + 10.12.0 https://github.com/jellyfin/jellyfin GPL-3.0-only diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs index c81e639a22..a1d8915353 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs @@ -2390,8 +2390,8 @@ namespace MediaBrowser.Controller.MediaEncoding || (requestHasSDR && videoStream.VideoRangeType == VideoRangeType.DOVIWithSDR) || (requestHasHDR10 && videoStream.VideoRangeType == VideoRangeType.HDR10Plus))) { - // If the video stream is in a static HDR format, don't allow copy if the client does not support HDR10 or HLG. - if (videoStream.VideoRangeType is VideoRangeType.HDR10 or VideoRangeType.HLG) + // If the video stream is in HDR10+ or a static HDR format, don't allow copy if the client does not support HDR10 or HLG. + if (videoStream.VideoRangeType is VideoRangeType.HDR10Plus or VideoRangeType.HDR10 or VideoRangeType.HLG) { return false; } diff --git a/MediaBrowser.Controller/Persistence/IChapterRepository.cs b/MediaBrowser.Controller/Persistence/IChapterRepository.cs index 0844ddb364..64b90fd638 100644 --- a/MediaBrowser.Controller/Persistence/IChapterRepository.cs +++ b/MediaBrowser.Controller/Persistence/IChapterRepository.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; using MediaBrowser.Model.Entities; namespace MediaBrowser.Controller.Persistence; @@ -13,7 +15,9 @@ public interface IChapterRepository /// Deletes the chapters. ///
/// The item. - void DeleteChapters(Guid itemId); + /// The cancellation token. + /// Task. + Task DeleteChaptersAsync(Guid itemId, CancellationToken cancellationToken); /// /// Saves the chapters. diff --git a/MediaBrowser.MediaEncoding/Configuration/EncodingConfigurationStore.cs b/MediaBrowser.MediaEncoding/Configuration/EncodingConfigurationStore.cs index 2f158157e8..19c1de9f74 100644 --- a/MediaBrowser.MediaEncoding/Configuration/EncodingConfigurationStore.cs +++ b/MediaBrowser.MediaEncoding/Configuration/EncodingConfigurationStore.cs @@ -18,10 +18,16 @@ namespace MediaBrowser.MediaEncoding.Configuration public void Validate(object oldConfig, object newConfig) { - var newPath = ((EncodingOptions)newConfig).TranscodingTempPath; + var oldEncodingOptions = (EncodingOptions)oldConfig; + var newEncodingOptions = (EncodingOptions)newConfig; + + ArgumentNullException.ThrowIfNull(oldEncodingOptions, nameof(oldConfig)); + ArgumentNullException.ThrowIfNull(newEncodingOptions, nameof(newConfig)); + + var newPath = newEncodingOptions.TranscodingTempPath; if (!string.IsNullOrWhiteSpace(newPath) - && !string.Equals(((EncodingOptions)oldConfig).TranscodingTempPath, newPath, StringComparison.Ordinal)) + && !string.Equals(oldEncodingOptions.TranscodingTempPath, newPath, StringComparison.Ordinal)) { // Validate if (!Directory.Exists(newPath)) @@ -33,6 +39,12 @@ namespace MediaBrowser.MediaEncoding.Configuration newPath)); } } + + if (!string.IsNullOrWhiteSpace(newEncodingOptions.EncoderAppPath) + && !string.Equals(oldEncodingOptions.EncoderAppPath, newEncodingOptions.EncoderAppPath, StringComparison.Ordinal)) + { + throw new InvalidOperationException("Unable to update encoder app path."); + } } } } diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs index 8350d1613b..b7fef842b3 100644 --- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs +++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs @@ -1122,7 +1122,15 @@ namespace MediaBrowser.MediaEncoding.Encoder private void StartProcess(ProcessWrapper process) { process.Process.Start(); - process.Process.PriorityClass = ProcessPriorityClass.BelowNormal; + + try + { + process.Process.PriorityClass = ProcessPriorityClass.BelowNormal; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Unable to set process priority to BelowNormal for {ProcessFileName}", process.Process.StartInfo.FileName); + } lock (_runningProcessesLock) { diff --git a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs index 00a9ae797d..eb312029a1 100644 --- a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs +++ b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs @@ -930,6 +930,15 @@ namespace MediaBrowser.MediaEncoding.Probing { stream.Rotation = data.Rotation; } + + // Parse video frame cropping metadata from side_data + // TODO: save them and make HW filters to apply them in HWA pipelines + else if (string.Equals(data.SideDataType, "Frame Cropping", StringComparison.OrdinalIgnoreCase)) + { + // Streams containing artificially added frame cropping + // metadata should not be marked as anamorphic. + stream.IsAnamorphic = false; + } } } diff --git a/MediaBrowser.Model/MediaBrowser.Model.csproj b/MediaBrowser.Model/MediaBrowser.Model.csproj index e9dab6bc8a..ef025d02dc 100644 --- a/MediaBrowser.Model/MediaBrowser.Model.csproj +++ b/MediaBrowser.Model/MediaBrowser.Model.csproj @@ -8,7 +8,7 @@ Jellyfin Contributors Jellyfin.Model - 10.11.0 + 10.12.0 https://github.com/jellyfin/jellyfin GPL-3.0-only diff --git a/MediaBrowser.Model/Users/ForgotPasswordAction.cs b/MediaBrowser.Model/Users/ForgotPasswordAction.cs index f198476e3b..55907e6c84 100644 --- a/MediaBrowser.Model/Users/ForgotPasswordAction.cs +++ b/MediaBrowser.Model/Users/ForgotPasswordAction.cs @@ -1,11 +1,15 @@ #pragma warning disable CS1591 +using System; + namespace MediaBrowser.Model.Users { public enum ForgotPasswordAction { + [Obsolete("Returning different actions represents a security concern.")] ContactAdmin = 0, PinCode = 1, + [Obsolete("Returning different actions represents a security concern.")] InNetworkRequired = 2 } } diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs index 1d83263c5e..4c83845992 100644 --- a/MediaBrowser.Providers/Manager/MetadataService.cs +++ b/MediaBrowser.Providers/Manager/MetadataService.cs @@ -229,6 +229,11 @@ namespace MediaBrowser.Providers.Manager if (file is not null) { item.DateModified = file.LastWriteTimeUtc; + + if (!file.IsDirectory) + { + item.Size = file.Length; + } } } diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs index ad9edb031c..82c6e3011a 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs @@ -138,6 +138,8 @@ namespace MediaBrowser.Providers.Plugins.Omdb } var item = itemResult.Item; + item.IndexNumber = episodeNumber; + item.ParentIndexNumber = seasonNumber; var seasonResult = await GetSeasonRootObject(seriesImdbId, seasonNumber, cancellationToken).ConfigureAwait(false); diff --git a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs index 18cdba7a00..02818a0e24 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs @@ -66,7 +66,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets var language = item.GetPreferredMetadataLanguage(); // TODO use image languages if All Languages isn't toggled, but there's currently no way to get that value in here - var collection = await _tmdbClientManager.GetCollectionAsync(tmdbId, null, null, cancellationToken).ConfigureAwait(false); + var collection = await _tmdbClientManager.GetCollectionAsync(tmdbId, null, null, null, cancellationToken).ConfigureAwait(false); if (collection?.Images is null) { diff --git a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs index c76c65591f..34c9abae12 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs @@ -47,7 +47,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets if (tmdbId > 0) { - var collection = await _tmdbClientManager.GetCollectionAsync(tmdbId, language, TmdbUtils.GetImageLanguagesParam(language), cancellationToken).ConfigureAwait(false); + var collection = await _tmdbClientManager.GetCollectionAsync(tmdbId, language, TmdbUtils.GetImageLanguagesParam(language, searchInfo.MetadataCountryCode), searchInfo.MetadataCountryCode, cancellationToken).ConfigureAwait(false); if (collection is null) { @@ -70,7 +70,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets return new[] { result }; } - var collectionSearchResults = await _tmdbClientManager.SearchCollectionAsync(searchInfo.Name, language, cancellationToken).ConfigureAwait(false); + var collectionSearchResults = await _tmdbClientManager.SearchCollectionAsync(searchInfo.Name, language, searchInfo.MetadataCountryCode, cancellationToken).ConfigureAwait(false); var collections = new RemoteSearchResult[collectionSearchResults.Count]; for (var i = 0; i < collectionSearchResults.Count; i++) @@ -95,6 +95,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets { var tmdbId = Convert.ToInt32(info.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture); var language = info.MetadataLanguage; + // We don't already have an Id, need to fetch it if (tmdbId <= 0) { @@ -102,7 +103,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets // Caller provides the filename with extension stripped and NOT the parsed filename var parsedName = _libraryManager.ParseName(info.Name); var cleanedName = TmdbUtils.CleanName(parsedName.Name); - var searchResults = await _tmdbClientManager.SearchCollectionAsync(cleanedName, language, cancellationToken).ConfigureAwait(false); + var searchResults = await _tmdbClientManager.SearchCollectionAsync(cleanedName, language, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); if (searchResults is not null && searchResults.Count > 0) { @@ -114,7 +115,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets if (tmdbId > 0) { - var collection = await _tmdbClientManager.GetCollectionAsync(tmdbId, language, TmdbUtils.GetImageLanguagesParam(language), cancellationToken).ConfigureAwait(false); + var collection = await _tmdbClientManager.GetCollectionAsync(tmdbId, language, TmdbUtils.GetImageLanguagesParam(language, info.MetadataCountryCode), info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); if (collection is not null) { diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs index 1696a2c498..fcc3574107 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs @@ -59,6 +59,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies public async Task> GetImages(BaseItem item, CancellationToken cancellationToken) { var language = item.GetPreferredMetadataLanguage(); + var countryCode = item.GetPreferredMetadataCountryCode(); var movieTmdbId = Convert.ToInt32(item.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture); if (movieTmdbId <= 0) @@ -69,7 +70,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies return Enumerable.Empty(); } - var movieResult = await _tmdbClientManager.FindByExternalIdAsync(movieImdbId, FindExternalSource.Imdb, language, cancellationToken).ConfigureAwait(false); + var movieResult = await _tmdbClientManager.FindByExternalIdAsync(movieImdbId, FindExternalSource.Imdb, language, countryCode, cancellationToken).ConfigureAwait(false); if (movieResult?.MovieResults is not null && movieResult.MovieResults.Count > 0) { movieTmdbId = movieResult.MovieResults[0].Id; @@ -83,7 +84,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies // TODO use image languages if All Languages isn't toggled, but there's currently no way to get that value in here var movie = await _tmdbClientManager - .GetMovieAsync(movieTmdbId, null, null, cancellationToken) + .GetMovieAsync(movieTmdbId, null, null, null, cancellationToken) .ConfigureAwait(false); if (movie?.Images is null) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs index ab072be03f..414a0a3c9b 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs @@ -59,7 +59,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies .GetMovieAsync( int.Parse(id, CultureInfo.InvariantCulture), searchInfo.MetadataLanguage, - TmdbUtils.GetImageLanguagesParam(searchInfo.MetadataLanguage), + TmdbUtils.GetImageLanguagesParam(searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode), + searchInfo.MetadataCountryCode, cancellationToken) .ConfigureAwait(false); @@ -93,7 +94,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies var result = await _tmdbClientManager.FindByExternalIdAsync( id, FindExternalSource.Imdb, - TmdbUtils.GetImageLanguagesParam(searchInfo.MetadataLanguage), + TmdbUtils.GetImageLanguagesParam(searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode), + searchInfo.MetadataCountryCode, cancellationToken).ConfigureAwait(false); movieResults = result?.MovieResults; } @@ -103,7 +105,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies var result = await _tmdbClientManager.FindByExternalIdAsync( id, FindExternalSource.TvDb, - TmdbUtils.GetImageLanguagesParam(searchInfo.MetadataLanguage), + TmdbUtils.GetImageLanguagesParam(searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode), + searchInfo.MetadataCountryCode, cancellationToken).ConfigureAwait(false); movieResults = result?.MovieResults; } @@ -111,7 +114,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies if (movieResults is null) { movieResults = await _tmdbClientManager - .SearchMovieAsync(searchInfo.Name, searchInfo.Year ?? 0, searchInfo.MetadataLanguage, cancellationToken) + .SearchMovieAsync(searchInfo.Name, searchInfo.Year ?? 0, searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode, cancellationToken) .ConfigureAwait(false); } @@ -152,7 +155,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies // Caller provides the filename with extension stripped and NOT the parsed filename var parsedName = _libraryManager.ParseName(info.Name); var cleanedName = TmdbUtils.CleanName(parsedName.Name); - var searchResults = await _tmdbClientManager.SearchMovieAsync(cleanedName, info.Year ?? parsedName.Year ?? 0, info.MetadataLanguage, cancellationToken).ConfigureAwait(false); + + var searchResults = await _tmdbClientManager.SearchMovieAsync(cleanedName, info.Year ?? parsedName.Year ?? 0, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); if (searchResults.Count > 0) { @@ -162,7 +166,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies if (string.IsNullOrEmpty(tmdbId) && !string.IsNullOrEmpty(imdbId)) { - var movieResultFromImdbId = await _tmdbClientManager.FindByExternalIdAsync(imdbId, FindExternalSource.Imdb, info.MetadataLanguage, cancellationToken).ConfigureAwait(false); + var movieResultFromImdbId = await _tmdbClientManager.FindByExternalIdAsync(imdbId, FindExternalSource.Imdb, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); if (movieResultFromImdbId?.MovieResults.Count > 0) { tmdbId = movieResultFromImdbId.MovieResults[0].Id.ToString(CultureInfo.InvariantCulture); @@ -175,7 +179,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies } var movieResult = await _tmdbClientManager - .GetMovieAsync(Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture), info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage), cancellationToken) + .GetMovieAsync(Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture), info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage, info.MetadataCountryCode), info.MetadataCountryCode, cancellationToken) .ConfigureAwait(false); if (movieResult is null) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs index 9e5404b325..33888ddf4f 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs @@ -60,7 +60,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People } var language = item.GetPreferredMetadataLanguage(); - var personResult = await _tmdbClientManager.GetPersonAsync(int.Parse(personTmdbId, CultureInfo.InvariantCulture), language, cancellationToken).ConfigureAwait(false); + var countryCode = item.GetPreferredMetadataCountryCode(); + var personResult = await _tmdbClientManager.GetPersonAsync(int.Parse(personTmdbId, CultureInfo.InvariantCulture), language, countryCode, cancellationToken).ConfigureAwait(false); if (personResult?.Images?.Profiles is null) { return Enumerable.Empty(); diff --git a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs index 98c46895d7..4b32d0f6bf 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs @@ -39,7 +39,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People { if (searchInfo.TryGetProviderId(MetadataProvider.Tmdb, out var personTmdbId)) { - var personResult = await _tmdbClientManager.GetPersonAsync(int.Parse(personTmdbId, CultureInfo.InvariantCulture), searchInfo.MetadataLanguage, cancellationToken).ConfigureAwait(false); + var personResult = await _tmdbClientManager.GetPersonAsync(int.Parse(personTmdbId, CultureInfo.InvariantCulture), searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode, cancellationToken).ConfigureAwait(false); if (personResult is not null) { @@ -101,7 +101,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People if (personTmdbId > 0) { - var person = await _tmdbClientManager.GetPersonAsync(personTmdbId, info.MetadataLanguage, cancellationToken).ConfigureAwait(false); + var person = await _tmdbClientManager.GetPersonAsync(personTmdbId, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); if (person is null) { return result; diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs index 7de0e430f2..7ae54cdcd3 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs @@ -75,7 +75,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV // TODO use image languages if All Languages isn't toggled, but there's currently no way to get that value in here var episodeResult = await _tmdbClientManager - .GetEpisodeAsync(seriesTmdbId, seasonNumber, episodeNumber.Value, series.DisplayOrder, null, null, cancellationToken) + .GetEpisodeAsync(seriesTmdbId, seasonNumber, episodeNumber.Value, series.DisplayOrder, null, null, null, cancellationToken) .ConfigureAwait(false); var stills = episodeResult?.Images?.Stills; diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs index 7d0900cfda..e30c555cb4 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs @@ -113,7 +113,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV List? result = null; for (int? episode = startindex; episode <= endindex; episode++) { - var episodeInfo = await _tmdbClientManager.GetEpisodeAsync(seriesTmdbId, seasonNumber, episode.Value, info.SeriesDisplayOrder, info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage), cancellationToken).ConfigureAwait(false); + var episodeInfo = await _tmdbClientManager.GetEpisodeAsync(seriesTmdbId, seasonNumber, episode.Value, info.SeriesDisplayOrder, info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage, info.MetadataCountryCode), info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); if (episodeInfo is not null) { (result ??= new List()).Add(episodeInfo); @@ -157,7 +157,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV else { episodeResult = await _tmdbClientManager - .GetEpisodeAsync(seriesTmdbId, seasonNumber, episodeNumber.Value, info.SeriesDisplayOrder, info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage), cancellationToken) + .GetEpisodeAsync(seriesTmdbId, seasonNumber, episodeNumber.Value, info.SeriesDisplayOrder, info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage, info.MetadataCountryCode), info.MetadataCountryCode, cancellationToken) .ConfigureAwait(false); } @@ -177,8 +177,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV var item = new Episode { - IndexNumber = info.IndexNumber, - ParentIndexNumber = info.ParentIndexNumber, + IndexNumber = episodeNumber, + ParentIndexNumber = seasonNumber, IndexNumberEnd = info.IndexNumberEnd, Name = episodeResult.Name, PremiereDate = episodeResult.AirDate, diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs index a743601ed3..5b2f0d26e4 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs @@ -68,7 +68,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV // TODO use image languages if All Languages isn't toggled, but there's currently no way to get that value in here var seasonResult = await _tmdbClientManager - .GetSeasonAsync(seriesTmdbId, season.IndexNumber.Value, null, null, cancellationToken) + .GetSeasonAsync(seriesTmdbId, season.IndexNumber.Value, null, null, null, cancellationToken) .ConfigureAwait(false); var posters = seasonResult?.Images?.Posters; diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs index cfef0d6561..1b429039e7 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs @@ -54,7 +54,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV } var seasonResult = await _tmdbClientManager - .GetSeasonAsync(Convert.ToInt32(seriesTmdbId, CultureInfo.InvariantCulture), seasonNumber.Value, info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage), cancellationToken) + .GetSeasonAsync(Convert.ToInt32(seriesTmdbId, CultureInfo.InvariantCulture), seasonNumber.Value, info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage, info.MetadataCountryCode), info.MetadataCountryCode, cancellationToken) .ConfigureAwait(false); if (seasonResult is null) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs index 2cb4fe1c15..5cba84dcb3 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs @@ -68,7 +68,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV // TODO use image languages if All Languages isn't toggled, but there's currently no way to get that value in here var series = await _tmdbClientManager - .GetSeriesAsync(Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture), null, null, cancellationToken) + .GetSeriesAsync(Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture), null, null, null, cancellationToken) .ConfigureAwait(false); if (series?.Images is null) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs index 8791712c71..f0828e8263 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs @@ -57,7 +57,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV if (searchInfo.TryGetProviderId(MetadataProvider.Tmdb, out var tmdbId)) { var series = await _tmdbClientManager - .GetSeriesAsync(Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture), searchInfo.MetadataLanguage, searchInfo.MetadataLanguage, cancellationToken) + .GetSeriesAsync(Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture), searchInfo.MetadataLanguage, searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode, cancellationToken) .ConfigureAwait(false); if (series is not null) @@ -71,7 +71,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV if (searchInfo.TryGetProviderId(MetadataProvider.Imdb, out var imdbId)) { var findResult = await _tmdbClientManager - .FindByExternalIdAsync(imdbId, FindExternalSource.Imdb, searchInfo.MetadataLanguage, cancellationToken) + .FindByExternalIdAsync(imdbId, FindExternalSource.Imdb, searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode, cancellationToken) .ConfigureAwait(false); var tvResults = findResult?.TvResults; @@ -92,7 +92,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV if (searchInfo.TryGetProviderId(MetadataProvider.Tvdb, out var tvdbId)) { var findResult = await _tmdbClientManager - .FindByExternalIdAsync(tvdbId, FindExternalSource.TvDb, searchInfo.MetadataLanguage, cancellationToken) + .FindByExternalIdAsync(tvdbId, FindExternalSource.TvDb, searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode, cancellationToken) .ConfigureAwait(false); var tvResults = findResult?.TvResults; @@ -110,7 +110,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV } } - var tvSearchResults = await _tmdbClientManager.SearchSeriesAsync(searchInfo.Name, searchInfo.MetadataLanguage, cancellationToken: cancellationToken) + var tvSearchResults = await _tmdbClientManager.SearchSeriesAsync(searchInfo.Name, searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode, cancellationToken: cancellationToken) .ConfigureAwait(false); var remoteResults = new RemoteSearchResult[tvSearchResults.Count]; @@ -173,7 +173,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV if (string.IsNullOrEmpty(tmdbId) && info.TryGetProviderId(MetadataProvider.Imdb, out var imdbId)) { - var searchResult = await _tmdbClientManager.FindByExternalIdAsync(imdbId, FindExternalSource.Imdb, info.MetadataLanguage, cancellationToken).ConfigureAwait(false); + var searchResult = await _tmdbClientManager.FindByExternalIdAsync(imdbId, FindExternalSource.Imdb, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); if (searchResult?.TvResults.Count > 0) { tmdbId = searchResult.TvResults[0].Id.ToString(CultureInfo.InvariantCulture); @@ -182,7 +182,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV if (string.IsNullOrEmpty(tmdbId) && info.TryGetProviderId(MetadataProvider.Tvdb, out var tvdbId)) { - var searchResult = await _tmdbClientManager.FindByExternalIdAsync(tvdbId, FindExternalSource.TvDb, info.MetadataLanguage, cancellationToken).ConfigureAwait(false); + var searchResult = await _tmdbClientManager.FindByExternalIdAsync(tvdbId, FindExternalSource.TvDb, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); if (searchResult?.TvResults.Count > 0) { tmdbId = searchResult.TvResults[0].Id.ToString(CultureInfo.InvariantCulture); @@ -196,7 +196,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV // Caller provides the filename with extension stripped and NOT the parsed filename var parsedName = _libraryManager.ParseName(info.Name); var cleanedName = TmdbUtils.CleanName(parsedName.Name); - var searchResults = await _tmdbClientManager.SearchSeriesAsync(cleanedName, info.MetadataLanguage, info.Year ?? parsedName.Year ?? 0, cancellationToken).ConfigureAwait(false); + var searchResults = await _tmdbClientManager.SearchSeriesAsync(cleanedName, info.MetadataLanguage, info.MetadataCountryCode, info.Year ?? parsedName.Year ?? 0, cancellationToken).ConfigureAwait(false); if (searchResults.Count > 0) { @@ -212,7 +212,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV cancellationToken.ThrowIfCancellationRequested(); var tvShow = await _tmdbClientManager - .GetSeriesAsync(tmdbIdInt, info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage), cancellationToken) + .GetSeriesAsync(tmdbIdInt, info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage, info.MetadataCountryCode), info.MetadataCountryCode, cancellationToken) .ConfigureAwait(false); if (tvShow is null) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs index 767004c9e5..fedf345988 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs @@ -51,9 +51,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// The movie's TMDb id. /// The movie's language. /// A comma-separated list of image languages. + /// The country code, ISO 3166-1. /// The cancellation token. /// The TMDb movie or null if not found. - public async Task GetMovieAsync(int tmdbId, string? language, string? imageLanguages, CancellationToken cancellationToken) + public async Task GetMovieAsync(int tmdbId, string? language, string? imageLanguages, string? countryCode, CancellationToken cancellationToken) { var key = $"movie-{tmdbId.ToString(CultureInfo.InvariantCulture)}-{language}"; if (_memoryCache.TryGetValue(key, out Movie? movie)) @@ -71,7 +72,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb movie = await _tmDbClient.GetMovieAsync( tmdbId, - TmdbUtils.NormalizeLanguage(language), + TmdbUtils.NormalizeLanguage(language, countryCode), imageLanguages, extraMethods, cancellationToken).ConfigureAwait(false); @@ -90,9 +91,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// The collection's TMDb id. /// The collection's language. /// A comma-separated list of image languages. + /// The country code, ISO 3166-1. /// The cancellation token. /// The TMDb collection or null if not found. - public async Task GetCollectionAsync(int tmdbId, string? language, string? imageLanguages, CancellationToken cancellationToken) + public async Task GetCollectionAsync(int tmdbId, string? language, string? imageLanguages, string? countryCode, CancellationToken cancellationToken) { var key = $"collection-{tmdbId.ToString(CultureInfo.InvariantCulture)}-{language}"; if (_memoryCache.TryGetValue(key, out Collection? collection)) @@ -104,7 +106,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb collection = await _tmDbClient.GetCollectionAsync( tmdbId, - TmdbUtils.NormalizeLanguage(language), + TmdbUtils.NormalizeLanguage(language, countryCode), imageLanguages, CollectionMethods.Images, cancellationToken).ConfigureAwait(false); @@ -123,9 +125,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// The tv show's TMDb id. /// The tv show's language. /// A comma-separated list of image languages. + /// The country code, ISO 3166-1. /// The cancellation token. /// The TMDb tv show information or null if not found. - public async Task GetSeriesAsync(int tmdbId, string? language, string? imageLanguages, CancellationToken cancellationToken) + public async Task GetSeriesAsync(int tmdbId, string? language, string? imageLanguages, string? countryCode, CancellationToken cancellationToken) { var key = $"series-{tmdbId.ToString(CultureInfo.InvariantCulture)}-{language}"; if (_memoryCache.TryGetValue(key, out TvShow? series)) @@ -143,7 +146,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb series = await _tmDbClient.GetTvShowAsync( tmdbId, - language: TmdbUtils.NormalizeLanguage(language), + language: TmdbUtils.NormalizeLanguage(language, countryCode), includeImageLanguage: imageLanguages, extraMethods: extraMethods, cancellationToken: cancellationToken).ConfigureAwait(false); @@ -163,9 +166,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// The display order. /// The tv show's language. /// A comma-separated list of image languages. + /// The country code, ISO 3166-1. /// The cancellation token. /// The TMDb tv show episode group information or null if not found. - private async Task GetSeriesGroupAsync(int tvShowId, string displayOrder, string? language, string? imageLanguages, CancellationToken cancellationToken) + private async Task GetSeriesGroupAsync(int tvShowId, string displayOrder, string? language, string? imageLanguages, string? countryCode, CancellationToken cancellationToken) { TvGroupType? groupType = string.Equals(displayOrder, "originalAirDate", StringComparison.Ordinal) ? TvGroupType.OriginalAirDate : @@ -190,7 +194,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb await EnsureClientConfigAsync().ConfigureAwait(false); - var series = await GetSeriesAsync(tvShowId, language, imageLanguages, cancellationToken).ConfigureAwait(false); + var series = await GetSeriesAsync(tvShowId, language, imageLanguages, countryCode, cancellationToken).ConfigureAwait(false); var episodeGroupId = series?.EpisodeGroups.Results.Find(g => g.Type == groupType)?.Id; if (episodeGroupId is null) @@ -200,7 +204,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb group = await _tmDbClient.GetTvEpisodeGroupsAsync( episodeGroupId, - language: TmdbUtils.NormalizeLanguage(language), + language: TmdbUtils.NormalizeLanguage(language, countryCode), cancellationToken: cancellationToken).ConfigureAwait(false); if (group is not null) @@ -218,9 +222,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// The season number. /// The tv season's language. /// A comma-separated list of image languages. + /// The country code, ISO 3166-1. /// The cancellation token. /// The TMDb tv season information or null if not found. - public async Task GetSeasonAsync(int tvShowId, int seasonNumber, string? language, string? imageLanguages, CancellationToken cancellationToken) + public async Task GetSeasonAsync(int tvShowId, int seasonNumber, string? language, string? imageLanguages, string? countryCode, CancellationToken cancellationToken) { var key = $"season-{tvShowId.ToString(CultureInfo.InvariantCulture)}-s{seasonNumber.ToString(CultureInfo.InvariantCulture)}-{language}"; if (_memoryCache.TryGetValue(key, out TvSeason? season)) @@ -233,7 +238,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb season = await _tmDbClient.GetTvSeasonAsync( tvShowId, seasonNumber, - language: TmdbUtils.NormalizeLanguage(language), + language: TmdbUtils.NormalizeLanguage(language, countryCode), includeImageLanguage: imageLanguages, extraMethods: TvSeasonMethods.Credits | TvSeasonMethods.Images | TvSeasonMethods.ExternalIds | TvSeasonMethods.Videos, cancellationToken: cancellationToken).ConfigureAwait(false); @@ -255,9 +260,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// The display order. /// The episode's language. /// A comma-separated list of image languages. + /// The country code, ISO 3166-1. /// The cancellation token. /// The TMDb tv episode information or null if not found. - public async Task GetEpisodeAsync(int tvShowId, int seasonNumber, int episodeNumber, string displayOrder, string? language, string? imageLanguages, CancellationToken cancellationToken) + public async Task GetEpisodeAsync(int tvShowId, int seasonNumber, int episodeNumber, string displayOrder, string? language, string? imageLanguages, string? countryCode, CancellationToken cancellationToken) { var key = $"episode-{tvShowId.ToString(CultureInfo.InvariantCulture)}-s{seasonNumber.ToString(CultureInfo.InvariantCulture)}e{episodeNumber.ToString(CultureInfo.InvariantCulture)}-{displayOrder}-{language}"; if (_memoryCache.TryGetValue(key, out TvEpisode? episode)) @@ -267,7 +273,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb await EnsureClientConfigAsync().ConfigureAwait(false); - var group = await GetSeriesGroupAsync(tvShowId, displayOrder, language, imageLanguages, cancellationToken).ConfigureAwait(false); + var group = await GetSeriesGroupAsync(tvShowId, displayOrder, language, imageLanguages, countryCode, cancellationToken).ConfigureAwait(false); if (group is not null) { var season = group.Groups.Find(s => s.Order == seasonNumber); @@ -284,7 +290,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb tvShowId, seasonNumber, episodeNumber, - language: TmdbUtils.NormalizeLanguage(language), + language: TmdbUtils.NormalizeLanguage(language, countryCode), includeImageLanguage: imageLanguages, extraMethods: TvEpisodeMethods.Credits | TvEpisodeMethods.Images | TvEpisodeMethods.ExternalIds | TvEpisodeMethods.Videos, cancellationToken: cancellationToken).ConfigureAwait(false); @@ -301,10 +307,11 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// Gets a person eg. cast or crew member from the TMDb API based on its TMDb id. /// /// The person's TMDb id. - /// The episode's language. + /// The person's language. + /// The country code, ISO 3166-1. /// The cancellation token. /// The TMDb person information or null if not found. - public async Task GetPersonAsync(int personTmdbId, string language, CancellationToken cancellationToken) + public async Task GetPersonAsync(int personTmdbId, string language, string? countryCode, CancellationToken cancellationToken) { var key = $"person-{personTmdbId.ToString(CultureInfo.InvariantCulture)}-{language}"; if (_memoryCache.TryGetValue(key, out Person? person)) @@ -316,7 +323,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb person = await _tmDbClient.GetPersonAsync( personTmdbId, - TmdbUtils.NormalizeLanguage(language), + TmdbUtils.NormalizeLanguage(language, countryCode), PersonMethods.TvCredits | PersonMethods.MovieCredits | PersonMethods.Images | PersonMethods.ExternalIds, cancellationToken).ConfigureAwait(false); @@ -334,12 +341,14 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// The item's external id. /// The source of the id eg. IMDb. /// The item's language. + /// The country code, ISO 3166-1. /// The cancellation token. /// The TMDb item or null if not found. public async Task FindByExternalIdAsync( string externalId, FindExternalSource source, string language, + string? countryCode, CancellationToken cancellationToken) { var key = $"find-{source.ToString()}-{externalId.ToString(CultureInfo.InvariantCulture)}-{language}"; @@ -353,7 +362,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb result = await _tmDbClient.FindAsync( source, externalId, - TmdbUtils.NormalizeLanguage(language), + TmdbUtils.NormalizeLanguage(language, countryCode), cancellationToken).ConfigureAwait(false); if (result is not null) @@ -369,10 +378,11 @@ namespace MediaBrowser.Providers.Plugins.Tmdb ///
/// The name of the tv show. /// The tv show's language. + /// The country code, ISO 3166-1. /// The year the tv show first aired. /// The cancellation token. /// The TMDb tv show information. - public async Task> SearchSeriesAsync(string name, string language, int year = 0, CancellationToken cancellationToken = default) + public async Task> SearchSeriesAsync(string name, string language, string? countryCode, int year = 0, CancellationToken cancellationToken = default) { var key = $"searchseries-{name}-{year.ToString(CultureInfo.InvariantCulture)}-{language}"; if (_memoryCache.TryGetValue(key, out SearchContainer? series) && series is not null) @@ -383,7 +393,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb await EnsureClientConfigAsync().ConfigureAwait(false); var searchResults = await _tmDbClient - .SearchTvShowAsync(name, TmdbUtils.NormalizeLanguage(language), includeAdult: Plugin.Instance.Configuration.IncludeAdult, firstAirDateYear: year, cancellationToken: cancellationToken) + .SearchTvShowAsync(name, TmdbUtils.NormalizeLanguage(language, countryCode), includeAdult: Plugin.Instance.Configuration.IncludeAdult, firstAirDateYear: year, cancellationToken: cancellationToken) .ConfigureAwait(false); if (searchResults.Results.Count > 0) @@ -431,7 +441,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// The TMDb movie information. public Task> SearchMovieAsync(string name, string language, CancellationToken cancellationToken) { - return SearchMovieAsync(name, 0, language, cancellationToken); + return SearchMovieAsync(name, 0, language, null, cancellationToken); } /// @@ -440,9 +450,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// The name of the movie. /// The release year of the movie. /// The movie's language. + /// The country code, ISO 3166-1. /// The cancellation token. /// The TMDb movie information. - public async Task> SearchMovieAsync(string name, int year, string language, CancellationToken cancellationToken) + public async Task> SearchMovieAsync(string name, int year, string language, string? countryCode, CancellationToken cancellationToken) { var key = $"moviesearch-{name}-{year.ToString(CultureInfo.InvariantCulture)}-{language}"; if (_memoryCache.TryGetValue(key, out SearchContainer? movies) && movies is not null) @@ -453,7 +464,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb await EnsureClientConfigAsync().ConfigureAwait(false); var searchResults = await _tmDbClient - .SearchMovieAsync(name, TmdbUtils.NormalizeLanguage(language), includeAdult: Plugin.Instance.Configuration.IncludeAdult, year: year, cancellationToken: cancellationToken) + .SearchMovieAsync(name, TmdbUtils.NormalizeLanguage(language, countryCode), includeAdult: Plugin.Instance.Configuration.IncludeAdult, year: year, cancellationToken: cancellationToken) .ConfigureAwait(false); if (searchResults.Results.Count > 0) @@ -469,9 +480,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// /// The name of the collection. /// The collection's language. + /// The country code, ISO 3166-1. /// The cancellation token. /// The TMDb collection information. - public async Task> SearchCollectionAsync(string name, string language, CancellationToken cancellationToken) + public async Task> SearchCollectionAsync(string name, string language, string? countryCode, CancellationToken cancellationToken) { var key = $"collectionsearch-{name}-{language}"; if (_memoryCache.TryGetValue(key, out SearchContainer? collections) && collections is not null) @@ -482,7 +494,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb await EnsureClientConfigAsync().ConfigureAwait(false); var searchResults = await _tmDbClient - .SearchCollectionAsync(name, TmdbUtils.NormalizeLanguage(language), cancellationToken: cancellationToken) + .SearchCollectionAsync(name, TmdbUtils.NormalizeLanguage(language, countryCode), cancellationToken: cancellationToken) .ConfigureAwait(false); if (searchResults.Results.Count > 0) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs index 2db8cae7e5..f5e59a2789 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs @@ -105,14 +105,15 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// Normalizes a language string for use with TMDb's include image language parameter. /// /// The preferred language as either a 2 letter code with or without country code. + /// The country code, ISO 3166-1. /// The comma separated language string. - public static string GetImageLanguagesParam(string preferredLanguage) + public static string GetImageLanguagesParam(string preferredLanguage, string? countryCode = null) { var languages = new List(); if (!string.IsNullOrEmpty(preferredLanguage)) { - preferredLanguage = NormalizeLanguage(preferredLanguage); + preferredLanguage = NormalizeLanguage(preferredLanguage, countryCode); languages.Add(preferredLanguage); @@ -140,15 +141,24 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// Normalizes a language string for use with TMDb's language parameter. /// /// The language code. + /// The country code. /// The normalized language code. [return: NotNullIfNotNull(nameof(language))] - public static string? NormalizeLanguage(string? language) + public static string? NormalizeLanguage(string? language, string? countryCode = null) { if (string.IsNullOrEmpty(language)) { return language; } + // Handle es-419 (Latin American Spanish) by converting to regional variant + if (string.Equals(language, "es-419", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(countryCode)) + { + language = string.Equals(countryCode, "AR", StringComparison.OrdinalIgnoreCase) + ? "es-AR" + : "es-MX"; + } + // TMDb requires this to be uppercase // Everything after the hyphen must be written in uppercase due to a way TMDb wrote their API. // See here: https://www.themoviedb.org/talk/5119221d760ee36c642af4ad?page=3#56e372a0c3a3685a9e0019ab diff --git a/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs b/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs index 4cbcd8f27c..3f83f1d829 100644 --- a/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs +++ b/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs @@ -316,7 +316,11 @@ namespace MediaBrowser.XbmcMetadata.Parsers if (userData is not null) { userData.Played = played; - _userDataManager.SaveUserData(user, item, userData, UserDataSaveReason.Import, CancellationToken.None); + + if (!item.Id.IsEmpty()) + { + _userDataManager.SaveUserData(user, item, userData, UserDataSaveReason.Import, CancellationToken.None); + } } } } @@ -333,7 +337,11 @@ namespace MediaBrowser.XbmcMetadata.Parsers if (userData is not null) { userData.PlayCount = count; - _userDataManager.SaveUserData(user, item, userData, UserDataSaveReason.Import, CancellationToken.None); + + if (!item.Id.IsEmpty()) + { + _userDataManager.SaveUserData(user, item, userData, UserDataSaveReason.Import, CancellationToken.None); + } } } } @@ -350,7 +358,11 @@ namespace MediaBrowser.XbmcMetadata.Parsers if (userData is not null) { userData.LastPlayedDate = lastPlayed; - _userDataManager.SaveUserData(user, item, userData, UserDataSaveReason.Import, CancellationToken.None); + + if (!item.Id.IsEmpty()) + { + _userDataManager.SaveUserData(user, item, userData, UserDataSaveReason.Import, CancellationToken.None); + } } } } diff --git a/SharedVersion.cs b/SharedVersion.cs index d26eb31aec..3b394d28b2 100644 --- a/SharedVersion.cs +++ b/SharedVersion.cs @@ -1,4 +1,4 @@ using System.Reflection; -[assembly: AssemblyVersion("10.11.0")] -[assembly: AssemblyFileVersion("10.11.0")] +[assembly: AssemblyVersion("10.12.0")] +[assembly: AssemblyFileVersion("10.12.0")] diff --git a/bump_version b/bump_version index 6d08dc72fe..0516a1806d 100755 --- a/bump_version +++ b/bump_version @@ -58,7 +58,7 @@ for subproject in ${jellyfin_subprojects[@]}; do done # Set the version in the GitHub issue template file -sed -i "s|${old_version}|${new_version_sed}|g" ${issue_template_file} +sed -i "s|${old_version}|${new_version_sed}|g" "${issue_template_file}" # Stage the changed files for commit git add . diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs index 4d5cfb8c9b..f386e882e2 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs @@ -53,6 +53,34 @@ public static class JellyfinQueryHelperExtensions return baseQuery.Where(ReferencedItemFilterExpressionBuilder(context, itemValueType, referenceIds, invert)); } + /// + /// Builds a query that checks referenced ItemValues for a cross BaseItem lookup. + /// + /// The source query. + /// The database context. + /// The type of item value to reference. + /// The list of BaseItem ids to check matches. + /// If set an exclusion check is performed instead. + /// A Query. + public static IQueryable WhereReferencedItemMultipleTypes( + this IQueryable baseQuery, + JellyfinDbContext context, + IList itemValueTypes, + IList referenceIds, + bool invert = false) + { + var itemFilter = OneOrManyExpressionBuilder(referenceIds, f => f.Id); + var typeFilter = OneOrManyExpressionBuilder(itemValueTypes, iv => iv.Type); + + return baseQuery.Where(item => + context.ItemValues + .Where(typeFilter) + .Join(context.ItemValuesMap, e => e.ItemValueId, e => e.ItemValueId, (itemVal, map) => new { itemVal, map }) + .Any(val => + context.BaseItems.Where(itemFilter).Any(e => e.CleanName == val.itemVal.CleanValue) + && val.map.ItemId == item.Id) == EF.Constant(!invert)); + } + /// /// Builds a query expression that checks referenced ItemValues for a cross BaseItem lookup. /// diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs index b90a2e056f..7bcc7eeca4 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs @@ -52,10 +52,14 @@ public class OptimisticLockBehavior : IEntityFrameworkCoreLockingBehavior _logger = logger; _writePolicy = Policy - .HandleInner(e => e.Message.Contains("database is locked", StringComparison.InvariantCultureIgnoreCase)) + .HandleInner(e => + e.Message.Contains("database is locked", StringComparison.InvariantCultureIgnoreCase) || + e.Message.Contains("database table is locked", StringComparison.InvariantCultureIgnoreCase)) .WaitAndRetry(sleepDurations.Length, backoffProvider, RetryHandle); _writeAsyncPolicy = Policy - .HandleInner(e => e.Message.Contains("database is locked", StringComparison.InvariantCultureIgnoreCase)) + .HandleInner(e => + e.Message.Contains("database is locked", StringComparison.InvariantCultureIgnoreCase) || + e.Message.Contains("database table is locked", StringComparison.InvariantCultureIgnoreCase)) .WaitAndRetryAsync(sleepDurations.Length, backoffProvider, RetryHandle); void RetryHandle(Exception exception, TimeSpan timespan, int retryNo, Context context) diff --git a/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj b/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj index 1613d83bc3..f52fd014da 100644 --- a/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj +++ b/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj @@ -15,7 +15,7 @@ Jellyfin Contributors Jellyfin.Extensions - 10.11.0 + 10.12.0 https://github.com/jellyfin/jellyfin GPL-3.0-only diff --git a/src/Jellyfin.MediaEncoding.Keyframes/FfProbe/FfProbeKeyframeExtractor.cs b/src/Jellyfin.MediaEncoding.Keyframes/FfProbe/FfProbeKeyframeExtractor.cs index a0dafb8f19..cbe97a8210 100644 --- a/src/Jellyfin.MediaEncoding.Keyframes/FfProbe/FfProbeKeyframeExtractor.cs +++ b/src/Jellyfin.MediaEncoding.Keyframes/FfProbe/FfProbeKeyframeExtractor.cs @@ -42,7 +42,15 @@ public static class FfProbeKeyframeExtractor try { process.Start(); - process.PriorityClass = ProcessPriorityClass.BelowNormal; + try + { + process.PriorityClass = ProcessPriorityClass.BelowNormal; + } + catch + { + // We do not care if process priority setting fails + // Ideally log a warning but this does not have a logger available + } return ParseStream(process.StandardOutput); } diff --git a/tests/Jellyfin.Naming.Tests/TV/SeasonPathParserTests.cs b/tests/Jellyfin.Naming.Tests/TV/SeasonPathParserTests.cs index 4c8ba58d04..0c3671f4fb 100644 --- a/tests/Jellyfin.Naming.Tests/TV/SeasonPathParserTests.cs +++ b/tests/Jellyfin.Naming.Tests/TV/SeasonPathParserTests.cs @@ -7,23 +7,38 @@ public class SeasonPathParserTests { [Theory] [InlineData("/Drive/Season 1", "/Drive", 1, true)] + [InlineData("/Drive/SEASON 1", "/Drive", 1, true)] [InlineData("/Drive/Staffel 1", "/Drive", 1, true)] + [InlineData("/Drive/STAFFEL 1", "/Drive", 1, true)] [InlineData("/Drive/Stagione 1", "/Drive", 1, true)] + [InlineData("/Drive/STAGIONE 1", "/Drive", 1, true)] [InlineData("/Drive/sæson 1", "/Drive", 1, true)] + [InlineData("/Drive/SÆSON 1", "/Drive", 1, true)] [InlineData("/Drive/Temporada 1", "/Drive", 1, true)] + [InlineData("/Drive/TEMPORADA 1", "/Drive", 1, true)] [InlineData("/Drive/series 1", "/Drive", 1, true)] + [InlineData("/Drive/SERIES 1", "/Drive", 1, true)] [InlineData("/Drive/Kausi 1", "/Drive", 1, true)] + [InlineData("/Drive/KAUSI 1", "/Drive", 1, true)] [InlineData("/Drive/Säsong 1", "/Drive", 1, true)] + [InlineData("/Drive/SÄSONG 1", "/Drive", 1, true)] [InlineData("/Drive/Seizoen 1", "/Drive", 1, true)] + [InlineData("/Drive/SEIZOEN 1", "/Drive", 1, true)] [InlineData("/Drive/Seasong 1", "/Drive", 1, true)] + [InlineData("/Drive/SEASONG 1", "/Drive", 1, true)] [InlineData("/Drive/Sezon 1", "/Drive", 1, true)] + [InlineData("/Drive/SEZON 1", "/Drive", 1, true)] [InlineData("/Drive/sezona 1", "/Drive", 1, true)] + [InlineData("/Drive/SEZONA 1", "/Drive", 1, true)] [InlineData("/Drive/sezóna 1", "/Drive", 1, true)] + [InlineData("/Drive/SEZÓNA 1", "/Drive", 1, true)] [InlineData("/Drive/Sezonul 1", "/Drive", 1, true)] + [InlineData("/Drive/SEZONUL 1", "/Drive", 1, true)] [InlineData("/Drive/시즌 1", "/Drive", 1, true)] [InlineData("/Drive/シーズン 1", "/Drive", 1, true)] [InlineData("/Drive/сезон 1", "/Drive", 1, true)] [InlineData("/Drive/Сезон 1", "/Drive", 1, true)] + [InlineData("/Drive/СЕЗОН 1", "/Drive", 1, true)] [InlineData("/Drive/Season 10", "/Drive", 10, true)] [InlineData("/Drive/Season 100", "/Drive", 100, true)] [InlineData("/Drive/s1", "/Drive", 1, true)] @@ -46,8 +61,14 @@ public class SeasonPathParserTests [InlineData("/Drive/s06e05", "/Drive", null, false)] [InlineData("/Drive/The.Legend.of.Condor.Heroes.2017.V2.web-dl.1080p.h264.aac-hdctv", "/Drive", null, false)] [InlineData("/Drive/extras", "/Drive", 0, true)] + [InlineData("/Drive/EXTRAS", "/Drive", 0, true)] [InlineData("/Drive/specials", "/Drive", 0, true)] + [InlineData("/Drive/SPECIALS", "/Drive", 0, true)] [InlineData("/Drive/Episode 1 Season 2", "/Drive", null, false)] + [InlineData("/Drive/Episode 1 SEASON 2", "/Drive", null, false)] + [InlineData("/media/YouTube/Devyn Johnston/2024-01-24 4070 Ti SUPER in under 7 minutes", "/media/YouTube/Devyn Johnston", null, false)] + [InlineData("/media/YouTube/Devyn Johnston/2025-01-28 5090 vs 2 SFF Cases", "/media/YouTube/Devyn Johnston", null, false)] + [InlineData("/Drive/202401244070", "/Drive", null, false)] public void GetSeasonNumberFromPathTest(string path, string? parentPath, int? seasonNumber, bool isSeasonDirectory) { var result = SeasonPathParser.Parse(path, parentPath, true, true);