diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 5fc7834fcc..bc574d7054 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "dotnet-ef": { - "version": "10.0.8", + "version": "10.0.10", "commands": [ "dotnet-ef" ] diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index dc93d2c84e..d6833ea2be 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,11 +1,15 @@ **Changes** +**Code assistance** + + **Issues** diff --git a/.github/workflows/ci-codeql-analysis.yml b/.github/workflows/ci-codeql-analysis.yml index 06a66bab53..ca7213a690 100644 --- a/.github/workflows/ci-codeql-analysis.yml +++ b/.github/workflows/ci-codeql-analysis.yml @@ -24,21 +24,21 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup .NET - uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: '10.0.x' - name: Initialize CodeQL - uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 with: languages: ${{ matrix.language }} queries: +security-extended - name: Autobuild - uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + uses: github/codeql-action/autobuild@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 diff --git a/.github/workflows/ci-compat.yml b/.github/workflows/ci-compat.yml index a0564027e3..bf28129368 100644 --- a/.github/workflows/ci-compat.yml +++ b/.github/workflows/ci-compat.yml @@ -11,13 +11,13 @@ jobs: permissions: read-all steps: - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.head.sha }} repository: ${{ github.event.pull_request.head.repo.full_name }} - name: Setup .NET - uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: '10.0.x' @@ -40,14 +40,14 @@ jobs: permissions: read-all steps: - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.head.sha }} repository: ${{ github.event.pull_request.head.repo.full_name }} fetch-depth: 0 - name: Setup .NET - uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: '10.0.x' diff --git a/.github/workflows/ci-format.yml b/.github/workflows/ci-format.yml index a9eebf0663..73c8e7e50c 100644 --- a/.github/workflows/ci-format.yml +++ b/.github/workflows/ci-format.yml @@ -15,9 +15,9 @@ jobs: format-check: runs-on: ubuntu-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0 + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: ${{ env.SDK_VERSION }} diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 6da1334039..5b29a66382 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -20,9 +20,9 @@ jobs: runs-on: "${{ matrix.os }}" steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0 + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: ${{ env.SDK_VERSION }} diff --git a/.github/workflows/commands.yml b/.github/workflows/commands.yml index 43ef0aab37..d14ce6d508 100644 --- a/.github/workflows/commands.yml +++ b/.github/workflows/commands.yml @@ -24,7 +24,7 @@ jobs: reactions: '+1' - name: Checkout the latest code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: token: ${{ secrets.JF_BOT_TOKEN }} fetch-depth: 0 @@ -40,12 +40,12 @@ jobs: runs-on: ubuntu-latest steps: - name: pull in script - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: jellyfin/jellyfin-triage-script - name: install python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.14' cache: 'pip' diff --git a/.github/workflows/issue-stale.yml b/.github/workflows/issue-stale.yml index d6372ef6f4..9adac6b995 100644 --- a/.github/workflows/issue-stale.yml +++ b/.github/workflows/issue-stale.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest if: ${{ contains(github.repository, 'jellyfin/') }} steps: - - uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0 + - uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0 with: repo-token: ${{ secrets.JF_BOT_TOKEN }} ascending: true diff --git a/.github/workflows/issue-template-check.yml b/.github/workflows/issue-template-check.yml index ef5c7c09f2..3220bcbeac 100644 --- a/.github/workflows/issue-template-check.yml +++ b/.github/workflows/issue-template-check.yml @@ -10,12 +10,12 @@ jobs: issues: write steps: - name: pull in script - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: jellyfin/jellyfin-triage-script - name: install python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.14' cache: 'pip' diff --git a/.github/workflows/openapi-generate.yml b/.github/workflows/openapi-generate.yml index 122bbd69ac..48bd8adb03 100644 --- a/.github/workflows/openapi-generate.yml +++ b/.github/workflows/openapi-generate.yml @@ -22,13 +22,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs.ref }} repository: ${{ inputs.repository }} - name: Configure .NET - uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: '10.0.x' diff --git a/.github/workflows/openapi-pull-request.yml b/.github/workflows/openapi-pull-request.yml index d11b0140f7..9ed1873942 100644 --- a/.github/workflows/openapi-pull-request.yml +++ b/.github/workflows/openapi-pull-request.yml @@ -10,7 +10,7 @@ jobs: base_ref: ${{ steps.ancestor.outputs.base_ref }} steps: - name: Checkout Repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.head.sha }} repository: ${{ github.event.pull_request.head.repo.full_name }} diff --git a/.github/workflows/pull-request-stale.yaml b/.github/workflows/pull-request-stale.yaml index 6f225a4714..f92a9be9d5 100644 --- a/.github/workflows/pull-request-stale.yaml +++ b/.github/workflows/pull-request-stale.yaml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest if: ${{ contains(github.repository, 'jellyfin/') }} steps: - - uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0 + - uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0 with: repo-token: ${{ secrets.JF_BOT_TOKEN }} ascending: true diff --git a/.github/workflows/release-bump-version.yaml b/.github/workflows/release-bump-version.yaml index 5bb668c89c..4d6e1ad6fa 100644 --- a/.github/workflows/release-bump-version.yaml +++ b/.github/workflows/release-bump-version.yaml @@ -33,7 +33,7 @@ jobs: yq-version: v4.9.8 - name: Checkout Repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ env.TAG_BRANCH }} @@ -66,7 +66,7 @@ jobs: NEXT_VERSION: ${{ github.event.inputs.NEXT_VERSION }} steps: - name: Checkout Repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ env.TAG_BRANCH }} diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index a4a9793ccc..ef27a90ac2 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -90,6 +90,7 @@ - [mark-monteiro](https://github.com/mark-monteiro) - [MarkCiliaVincenti](https://github.com/MarkCiliaVincenti) - [Martin Reuter](https://github.com/reuterma24) + - [Matt Teahan](https://github.com/matt-teahan) - [Matt07211](https://github.com/Matt07211) - [Matthew Jones](https://github.com/matthew-jones-uk) - [Maxr1998](https://github.com/Maxr1998) @@ -233,6 +234,7 @@ - [dwandw](https://github.com/dwandw) - [Lampan-git](https://github.com/Lampan-git) - [elio42](https://github.com/elio42) + - [rwebster85](https://github.com/rwebster85) # Emby Contributors diff --git a/Directory.Packages.props b/Directory.Packages.props index 1c26dd34e8..15f36872e0 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -26,57 +26,59 @@ - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + - + - + - + + - - - + + + diff --git a/Emby.Naming/Book/BookFileNameParser.cs b/Emby.Naming/Book/BookFileNameParser.cs index 28625f16de..080e25969b 100644 --- a/Emby.Naming/Book/BookFileNameParser.cs +++ b/Emby.Naming/Book/BookFileNameParser.cs @@ -1,3 +1,4 @@ +using System; using System.Text.RegularExpressions; namespace Emby.Naming.Book @@ -5,7 +6,7 @@ namespace Emby.Naming.Book /// /// Helper class to retrieve basic metadata from a book filename. /// - public static class BookFileNameParser + public static partial class BookFileNameParser { private const string NameMatchGroup = "name"; private const string IndexMatchGroup = "index"; @@ -15,14 +16,17 @@ namespace Emby.Naming.Book private static readonly Regex[] _nameMatches = [ // seriesName (seriesYear) #index (of count) (year) where only seriesName and index are required - new Regex(@"^(?.+?)((\s\((?[0-9]{4})\))?)\s#(?[0-9]+)((\s\(of\s(?[0-9]+)\))?)((\s\((?[0-9]{4})\))?)$"), - new Regex(@"^(?.+?)\s\((?.+?),\s#(?[0-9]+)\)((\s\((?[0-9]{4})\))?)$"), - new Regex(@"^(?[0-9]+)\s\-\s(?.+?)((\s\((?[0-9]{4})\))?)$"), + new Regex(@"^(?.+?)((\s\((?[0-9]{4})\))?)\s#(?[0-9]+)(?:\.0)?((\s\(of\s(?[0-9]+)\))?)((\s\((?[0-9]{4})\))?)$"), + new Regex(@"^(?.+?)\s\((?.+?),\s#(?[0-9]+)\)(?:\.0)?((\s\((?[0-9]{4})\))?)$"), + new Regex(@"^(?[0-9]+)(?:\.0)?\s\-\s(?.+?)((\s\((?[0-9]{4})\))?)$"), new Regex(@"(?.*)\((?[0-9]{4})\)"), // last resort matches the whole string as the name new Regex(@"(?.*)") ]; + [GeneratedRegex(@"^(?.+?)(\sv(?[0-9]+))?(\sc(?[0-9]+))?$")] + private static partial Regex ComicRegex(); + /// /// Parse a filename name to retrieve the book name, series name, index, and year. /// @@ -48,7 +52,22 @@ namespace Emby.Naming.Book if (match.Groups.TryGetValue(NameMatchGroup, out Group? nameGroup) && nameGroup.Success) { - result.Name = nameGroup.Value.Trim(); + var comicMatch = ComicRegex().Match(nameGroup.Value.Trim()); + + if (comicMatch.Success) + { + if (comicMatch.Groups.TryGetValue("volume", out Group? volumeGroup) && volumeGroup.Success && int.TryParse(volumeGroup.ValueSpan, out var volume)) + { + result.ParentIndex = volume; + } + + if (comicMatch.Groups.TryGetValue("chapter", out Group? chapterGroup) && chapterGroup.Success && int.TryParse(chapterGroup.ValueSpan, out var chapter)) + { + result.Index = chapter; + } + } + + result.Name = nameGroup.ValueSpan.Trim().ToString(); } if (match.Groups.TryGetValue(IndexMatchGroup, out Group? indexGroup) && indexGroup.Success && int.TryParse(indexGroup.Value, out var index)) diff --git a/Emby.Naming/Book/BookFileNameParserResult.cs b/Emby.Naming/Book/BookFileNameParserResult.cs index f29716b9e3..f313b202c5 100644 --- a/Emby.Naming/Book/BookFileNameParserResult.cs +++ b/Emby.Naming/Book/BookFileNameParserResult.cs @@ -1,5 +1,3 @@ -using System; - namespace Emby.Naming.Book { /// @@ -14,6 +12,7 @@ namespace Emby.Naming.Book { Name = null; Index = null; + ParentIndex = null; Year = null; SeriesName = null; } @@ -28,6 +27,11 @@ namespace Emby.Naming.Book /// public int? Index { get; set; } + /// + /// Gets or sets the parent index number. + /// + public int? ParentIndex { get; set; } + /// /// Gets or sets the publication year. /// diff --git a/Emby.Naming/Common/NamingOptions.cs b/Emby.Naming/Common/NamingOptions.cs index 21638ba9e7..fb1b2b523b 100644 --- a/Emby.Naming/Common/NamingOptions.cs +++ b/Emby.Naming/Common/NamingOptions.cs @@ -57,7 +57,6 @@ namespace Emby.Naming.Common ".nrg", ".nsv", ".nuv", - ".ogg", ".ogm", ".ogv", ".pva", diff --git a/Emby.Naming/TV/EpisodePathParser.cs b/Emby.Naming/TV/EpisodePathParser.cs index 8cd5a126e0..0c737964b4 100644 --- a/Emby.Naming/TV/EpisodePathParser.cs +++ b/Emby.Naming/TV/EpisodePathParser.cs @@ -125,7 +125,7 @@ namespace Emby.Naming.TV result.Success = true; } } - else if (DateTime.TryParse(match.Groups[0].ValueSpan, out date)) + else if (DateTime.TryParse(match.Groups[0].ValueSpan, CultureInfo.InvariantCulture, out date)) { result.Year = date.Year; result.Month = date.Month; diff --git a/Emby.Naming/TV/SeriesInfo.cs b/Emby.Naming/TV/SeriesInfo.cs index 5d6cb4bd37..e145ff8003 100644 --- a/Emby.Naming/TV/SeriesInfo.cs +++ b/Emby.Naming/TV/SeriesInfo.cs @@ -25,5 +25,11 @@ namespace Emby.Naming.TV /// /// The name of the series. public string? Name { get; set; } + + /// + /// Gets or sets the year of the series. + /// + /// The year of the series. + public int? Year { get; set; } } } diff --git a/Emby.Naming/TV/SeriesResolver.cs b/Emby.Naming/TV/SeriesResolver.cs index 0b7309bae0..733e2418c2 100644 --- a/Emby.Naming/TV/SeriesResolver.cs +++ b/Emby.Naming/TV/SeriesResolver.cs @@ -21,7 +21,7 @@ namespace Emby.Naming.TV /// Regex that matches titles with year in parentheses. Captures the title (which may be /// numeric) before the year, i.e. turns "1923 (2022)" into "1923". /// - [GeneratedRegex(@"(?.+?)\s*\(\d{4}\)")] + [GeneratedRegex(@"(?<title>.+?)\s*\((?<year>[0-9]{4})\)")] private static partial Regex TitleWithYearRegex(); /// <summary> @@ -43,7 +43,8 @@ namespace Emby.Naming.TV seriesName = titleWithYearMatch.Groups["title"].Value.Trim(); return new SeriesInfo(path) { - Name = seriesName + Name = seriesName, + Year = int.TryParse(titleWithYearMatch.Groups["year"].ValueSpan, out var year) ? year : null }; } } diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index 14380c33bf..0c1c7d3f5b 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -39,6 +39,8 @@ using Emby.Server.Implementations.SyncPlay; using Emby.Server.Implementations.TV; using Emby.Server.Implementations.Updates; using Jellyfin.Api.Helpers; +using Jellyfin.Data; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Drawing; using Jellyfin.MediaEncoding.Hls.Playlist; using Jellyfin.Networking.Manager; @@ -93,6 +95,9 @@ using MediaBrowser.Model.Net; using MediaBrowser.Model.Serialization; using MediaBrowser.Model.System; using MediaBrowser.Model.Tasks; +using MediaBrowser.Providers.Books; +using MediaBrowser.Providers.Books.ComicBookInfo; +using MediaBrowser.Providers.Books.ComicInfo; using MediaBrowser.Providers.Lyric; using MediaBrowser.Providers.Manager; using MediaBrowser.Providers.Plugins.ListenBrainz; @@ -414,6 +419,8 @@ namespace Emby.Server.Implementations { Logger.LogInformation("Running startup tasks"); + EnsureStartupWizardIntegrity(); + Resolve<ITaskManager>().AddTasks(GetExports<IScheduledTask>(false)); ConfigurationManager.ConfigurationUpdated += OnConfigurationUpdated; @@ -433,6 +440,24 @@ namespace Emby.Server.Implementations return Task.CompletedTask; } + private void EnsureStartupWizardIntegrity() + { + if (ConfigurationManager.CommonConfiguration.IsStartupWizardCompleted) + { + return; + } + + var hasConfiguredAdministrator = Resolve<IUserManager>().GetUsers() + .Any(user => user.HasPermission(PermissionKind.IsAdministrator) && !string.IsNullOrEmpty(user.Password)); + + if (hasConfiguredAdministrator) + { + Logger.LogWarning("The startup wizard is marked incomplete but a configured administrator already exists. Marking setup as completed to prevent the unauthenticated setup endpoints from being reachable."); + ConfigurationManager.Configuration.IsStartupWizardCompleted = true; + ConfigurationManager.SaveConfiguration(); + } + } + /// <inheritdoc/> public void Init(IServiceCollection serviceCollection) { @@ -496,6 +521,14 @@ namespace Emby.Server.Implementations serviceCollection.AddSingleton<ListenBrainzLabsClient>(); serviceCollection.AddSingleton<ListenBrainzSimilarArtistProvider>(); + // register the generic local metadata provider for comic files + serviceCollection.AddSingleton<ComicProvider>(); + + // register the actual implementations of the local metadata provider for comic files + serviceCollection.AddSingleton<IComicProvider, ComicBookInfoProvider>(); + serviceCollection.AddSingleton<IComicProvider, ExternalComicInfoProvider>(); + serviceCollection.AddSingleton<IComicProvider, InternalComicInfoProvider>(); + serviceCollection.AddSingleton(NetManager); serviceCollection.AddSingleton<ITaskManager, TaskManager>(); diff --git a/Emby.Server.Implementations/Devices/DeviceId.cs b/Emby.Server.Implementations/Devices/DeviceId.cs index 0b3c3bbd4f..4929568935 100644 --- a/Emby.Server.Implementations/Devices/DeviceId.cs +++ b/Emby.Server.Implementations/Devices/DeviceId.cs @@ -1,5 +1,3 @@ -#pragma warning disable CS1591 - using System; using System.Globalization; using System.IO; @@ -10,6 +8,9 @@ using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.Devices { + /// <summary> + /// Provides the persistent unique identifier of this server installation. + /// </summary> public class DeviceId { private readonly IApplicationPaths _appPaths; @@ -18,12 +19,20 @@ namespace Emby.Server.Implementations.Devices private string? _id; + /// <summary> + /// Initializes a new instance of the <see cref="DeviceId"/> class. + /// </summary> + /// <param name="appPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param> + /// <param name="logger">Instance of the <see cref="ILogger{DeviceId}"/> interface.</param> public DeviceId(IApplicationPaths appPaths, ILogger<DeviceId> logger) { _appPaths = appPaths; _logger = logger; } + /// <summary> + /// Gets the device id, loading it from disk or generating and persisting a new one if none exists. + /// </summary> public string Value => _id ??= GetDeviceId(); private string CachePath => Path.Combine(_appPaths.DataPath, "device.txt"); diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index 3cd72a8ac1..8cbf42585d 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -71,6 +71,8 @@ namespace Emby.Server.Implementations.Dto { BaseItemKind.Person, [ BaseItemKind.Audio, + BaseItemKind.AudioBook, + BaseItemKind.Book, BaseItemKind.Episode, BaseItemKind.Movie, BaseItemKind.LiveTvProgram, @@ -167,9 +169,13 @@ namespace Emby.Server.Implementations.Dto // Batch-fetch user data for all items Dictionary<Guid, UserItemData>? userDataBatch = null; + IReadOnlyDictionary<Guid, VersionResumeData>? resumeDataBatch = null; if (user is not null && options.EnableUserData) { userDataBatch = _userDataRepository.GetUserDataBatch(accessibleItems, user); + + // For items with alternate versions, the most recently played version drives resume. + resumeDataBatch = _userDataRepository.GetResumeUserDataBatch(accessibleItems, user); } // Pre-compute collection folders once to avoid N+1 queries in CanDelete @@ -248,7 +254,8 @@ namespace Emby.Server.Implementations.Dto allCollectionFolders, childCountBatch, playedCountBatch, - artistsBatch); + artistsBatch, + resumeDataBatch?.GetValueOrDefault(item.Id)); if (item is LiveTvChannel tvChannel) { @@ -309,7 +316,8 @@ namespace Emby.Server.Implementations.Dto List<Folder>? allCollectionFolders = null, Dictionary<Guid, int>? childCountBatch = null, Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null, - IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null) + IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null, + VersionResumeData? resumeData = null) { var dto = new BaseItemDto { @@ -353,7 +361,8 @@ namespace Emby.Server.Implementations.Dto options, userData, childCountBatch, - playedCountBatch); + playedCountBatch, + resumeData); } if (item is IHasMediaSources @@ -369,7 +378,7 @@ namespace Emby.Server.Implementations.Dto AttachStudios(dto, item); } - AttachBasicFields(dto, item, owner, options, artistsBatch); + AttachBasicFields(dto, item, owner, options, artistsBatch, user); if (options.ContainsField(ItemFields.CanDelete)) { @@ -538,7 +547,8 @@ namespace Emby.Server.Implementations.Dto DtoOptions options, UserItemData? userData = null, Dictionary<Guid, int>? childCountBatch = null, - Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null) + Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null, + VersionResumeData? resumeData = null) { if (item.IsFolder) { @@ -600,6 +610,9 @@ namespace Emby.Server.Implementations.Dto // Use pre-fetched user data dto.UserData = GetUserItemDataDto(userData, item.Id); item.FillUserDataDtoValues(dto.UserData, userData, dto, user, options); + + // For items with alternate versions, the most recently played version drives resume. + resumeData?.ApplyTo(dto.UserData); } else { @@ -943,7 +956,8 @@ namespace Emby.Server.Implementations.Dto /// <param name="owner">The owner.</param> /// <param name="options">The options.</param> /// <param name="artistsBatch">Optional pre-fetched artist lookup shared across a batch of items.</param> - private void AttachBasicFields(BaseItemDto dto, BaseItem item, BaseItem? owner, DtoOptions options, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null) + /// <param name="user">The user, for per-user values such as the accessible media source count.</param> + private void AttachBasicFields(BaseItemDto dto, BaseItem item, BaseItem? owner, DtoOptions options, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null, User? user = null) { if (options.ContainsField(ItemFields.DateCreated)) { @@ -1257,7 +1271,12 @@ namespace Emby.Server.Implementations.Dto if (options.ContainsField(ItemFields.MediaSourceCount)) { - var mediaSourceCount = video.MediaSourceCount; + // Match the per-user filtering of the media sources: versions the user cannot + // access are not selectable, so they must not count towards the badge either. + var mediaSourceCount = user is null + || (!video.PrimaryVersionId.HasValue && video.LinkedAlternateVersions.Length == 0 && !video.HasLocalAlternateVersions) + ? video.MediaSourceCount + : video.GetAllVersions().Count(v => v.Id.Equals(video.Id) || v.IsVisibleStandalone(user)); if (mediaSourceCount != 1) { dto.MediaSourceCount = mediaSourceCount; @@ -1366,38 +1385,22 @@ namespace Emby.Server.Implementations.Dto } } - if (options.PreferEpisodeParentPoster) + if (options.GetImageLimit(ImageType.Primary) > 0) { var episodeSeason = episode.Season; var seasonPrimaryTag = episodeSeason is not null ? GetTagAndFillBlurhash(dto, episodeSeason, ImageType.Primary) : null; - BaseItem? posterParent = null; if (seasonPrimaryTag is not null) { dto.ParentPrimaryImageItemId = episodeSeason!.Id; dto.ParentPrimaryImageTag = seasonPrimaryTag; - posterParent = episodeSeason; } else if (episodeSeries is not null && dto.SeriesPrimaryImageTag is not null) { dto.ParentPrimaryImageItemId = episodeSeries.Id; dto.ParentPrimaryImageTag = dto.SeriesPrimaryImageTag; - posterParent = episodeSeries; - } - - if (posterParent is not null) - { - if (dto.ImageTags is not null && dto.ImageTags.Remove(ImageType.Primary, out var ownPrimaryTag)) - { - // Only drop the episode's own primary blurhash; keep the poster parent's. - dto.ImageBlurHashes?.GetValueOrDefault(ImageType.Primary)?.Remove(ownPrimaryTag); - } - - dto.SeriesPrimaryImageTag = null; - dto.PrimaryImageAspectRatio = null; - AttachPrimaryImageAspectRatio(dto, posterParent); } } diff --git a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs index e9bf3b93a7..dc7f972c13 100644 --- a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs +++ b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs @@ -127,8 +127,12 @@ namespace Emby.Server.Implementations.HttpServer { receiveResult = await _socket.ReceiveAsync(memory, cancellationToken).ConfigureAwait(false); } - catch (WebSocketException ex) + catch (Exception ex) when (ex is WebSocketException or ObjectDisposedException or OperationCanceledException) { + // ObjectDisposedException/OperationCanceledException: the socket was torn + // down underneath us (e.g. by the keep-alive watchdog after the connection + // was declared lost). Fall through so Closed is still raised and the + // session can release this connection. _logger.LogWarning("WS {IP} error receiving data: {Message}", RemoteEndPoint, ex.Message); break; } diff --git a/Emby.Server.Implementations/IO/ManagedFileSystem.cs b/Emby.Server.Implementations/IO/ManagedFileSystem.cs index 199407044b..ede9b27592 100644 --- a/Emby.Server.Implementations/IO/ManagedFileSystem.cs +++ b/Emby.Server.Implementations/IO/ManagedFileSystem.cs @@ -691,7 +691,7 @@ namespace Emby.Server.Implementations.IO } catch (Exception ex) when (ex is UnauthorizedAccessException or DirectoryNotFoundException or SecurityException) { - _logger.LogError(ex, "Failed to enumerate path {Path}", path); + _logger.LogWarning("Failed to enumerate path \"{Path}\": {Message}", path, ex.Message); return Enumerable.Empty<string>(); } } diff --git a/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs b/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs index 095934f896..7cae2a671b 100644 --- a/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs +++ b/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.IO; +using Jellyfin.Api.Extensions; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Enums; using MediaBrowser.Common.Configuration; @@ -14,7 +15,6 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; -using MediaBrowser.Model.Querying; namespace Emby.Server.Implementations.Images { @@ -28,51 +28,23 @@ namespace Emby.Server.Implementations.Images { var view = (CollectionFolder)item; var viewType = view.CollectionType; - - BaseItemKind[] includeItemTypes; - - switch (viewType) - { - case CollectionType.movies: - includeItemTypes = new[] { BaseItemKind.Movie }; - break; - case CollectionType.tvshows: - includeItemTypes = new[] { BaseItemKind.Series }; - break; - case CollectionType.music: - includeItemTypes = new[] { BaseItemKind.MusicArtist }; // Music albums usually don't have dedicated backdrops, so use artist instead - break; - case CollectionType.musicvideos: - includeItemTypes = new[] { BaseItemKind.MusicVideo }; - break; - case CollectionType.books: - includeItemTypes = new[] { BaseItemKind.Book, BaseItemKind.AudioBook }; - break; - case CollectionType.boxsets: - includeItemTypes = new[] { BaseItemKind.BoxSet }; - break; - case CollectionType.homevideos: - case CollectionType.photos: - includeItemTypes = new[] { BaseItemKind.Video, BaseItemKind.Photo }; - break; - default: - includeItemTypes = new[] { BaseItemKind.Video, BaseItemKind.Audio, BaseItemKind.Photo, BaseItemKind.Movie, BaseItemKind.Series }; - break; - } - + var includeItemTypes = DtoExtensions.GetBaseItemKindsForCollectionType(viewType); var recursive = viewType != CollectionType.playlists; + if (viewType == CollectionType.music) + { + // Music albums usually don't have dedicated backdrops, so use artist instead + includeItemTypes = [BaseItemKind.MusicArtist]; + } + return view.GetItemList(new InternalItemsQuery { CollapseBoxSetItems = false, Recursive = recursive, DtoOptions = new DtoOptions(false), - ImageTypes = new[] { ImageType.Primary }, + ImageTypes = [ImageType.Primary], Limit = 8, - OrderBy = new[] - { - (ItemSortBy.Random, SortOrder.Ascending) - }, + OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)], IncludeItemTypes = includeItemTypes }); } diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 3691f4e19d..52569ed66a 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -2315,9 +2315,13 @@ namespace Emby.Server.Implementations.Library { var comparer = Comparers.FirstOrDefault(c => name == c.Type); - // If it requires a user, create a new one, and assign the user if (comparer is IUserBaseItemComparer) { + if (user is null) + { + throw new ArgumentException($"Sort key '{name}' requires a user, but none was provided."); + } + var userComparer = (IUserBaseItemComparer)Activator.CreateInstance(comparer.GetType())!; // only null for Nullable<T> instances userComparer.User = user; @@ -3886,5 +3890,17 @@ namespace Emby.Server.Implementations.Library { return _mediaStreamRepository.GetMediaStreamLanguages(mediaStreamType); } + + /// <inheritdoc /> + public IReadOnlyList<string> GetMediaStreamLanguages(MediaStreamType mediaStreamType, InternalItemsQuery query) + { + if (query.User is not null) + { + AddUserToQuery(query, query.User); + } + + SetTopParentOrAncestorIds(query); + return _itemRepository.GetMediaStreamLanguages(query, mediaStreamType); + } } } diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs index c369fb0957..97e00177b6 100644 --- a/Emby.Server.Implementations/Library/MediaSourceManager.cs +++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs @@ -229,7 +229,11 @@ namespace Emby.Server.Implementations.Library list.Add(source); } - return SortMediaSources(list, item.Id).ToArray(); + var preferredId = mediaSources.Count > 0 && Guid.TryParse(mediaSources[0].Id, out var topSourceId) + ? topSourceId + : item.Id; + + return SortMediaSources(list, preferredId).ToArray(); } /// <inheritdoc />> @@ -406,6 +410,59 @@ namespace Emby.Server.Implementations.Library source.SupportsDirectStream = user.HasPermission(PermissionKind.EnablePlaybackRemuxing); } } + + sources = SetAlternateVersionResumeStates(item, sources, user); + } + + return sources; + } + + /// <summary> + /// When the queried item is a primary, moves the most recently played version to the front so + /// that resuming without an explicit source selection plays the version that was last watched. + /// A directly queried alternate version keeps its own source first. Per-user playback position + /// is not surfaced on the source itself; it is carried by each version's own UserData. + /// </summary> + /// <param name="item">The queried item.</param> + /// <param name="sources">The item's media sources.</param> + /// <param name="user">The user.</param> + /// <returns>The media sources, reordered when a version drives resume.</returns> + private IReadOnlyList<MediaSourceInfo> SetAlternateVersionResumeStates(BaseItem item, IReadOnlyList<MediaSourceInfo> sources, User user) + { + // For a video, multiple sources means alternate versions. + if (item is not Video video || sources.Count < 2) + { + return sources; + } + + var versions = video.GetAllVersions(); + if (versions.Count < 2) + { + return sources; + } + + var userDataByVersion = _userDataManager.GetUserDataBatch(versions, user); + var dataBySourceId = new Dictionary<string, UserItemData>(versions.Count, StringComparer.OrdinalIgnoreCase); + foreach (var version in versions) + { + if (userDataByVersion.TryGetValue(version.Id, out var data)) + { + dataBySourceId[version.Id.ToString("N", CultureInfo.InvariantCulture)] = data; + } + } + + // Reorder only for a resumable (in-progress) version; + // a completed version has no position to resume, so it must not be pulled to the front here. + var resumeSource = VersionPlaybackSelector.SelectMostRecentlyPlayed( + sources, + source => source.Id is not null ? dataBySourceId.GetValueOrDefault(source.Id) : null, + data => data.PlaybackPositionTicks > 0); + + if (resumeSource is not null && !video.PrimaryVersionId.HasValue && !ReferenceEquals(sources[0], resumeSource)) + { + var reordered = new List<MediaSourceInfo>(sources.Count) { resumeSource }; + reordered.AddRange(sources.Where(s => !ReferenceEquals(s, resumeSource))); + return reordered; } return sources; diff --git a/Emby.Server.Implementations/Library/PathExtensions.cs b/Emby.Server.Implementations/Library/PathExtensions.cs index 7591359ea4..7d0f3900c5 100644 --- a/Emby.Server.Implementations/Library/PathExtensions.cs +++ b/Emby.Server.Implementations/Library/PathExtensions.cs @@ -29,17 +29,41 @@ namespace Emby.Server.Implementations.Library throw new ArgumentException("String can't be empty.", nameof(attribute)); } - var attributeIndex = str.IndexOf(attribute, StringComparison.OrdinalIgnoreCase); - - // Must be at least 3 characters after the attribute =, ], any character, - // then we offset it by 1, because we want the index and not length. - var maxIndex = str.Length - attribute.Length - 2; - while (attributeIndex > -1 && attributeIndex < maxIndex) + // Allow tmdb as an alias for tmdbid, tvdb for tvdbid, etc. + // The code below only supports aliases for attributes in the form of "<alias>id". + ReadOnlySpan<char> shortAttr = attribute switch { - var attributeEnd = attributeIndex + attribute.Length; + _ when attribute.Equals("tmdbid", StringComparison.OrdinalIgnoreCase) => "tmdb", + _ when attribute.Equals("tvdbid", StringComparison.OrdinalIgnoreCase) => "tvdb", + _ when attribute.Equals("imdbid", StringComparison.OrdinalIgnoreCase) => "imdb", + _ => ReadOnlySpan<char>.Empty + }; + + for (int strIndex = 0, attributeIndex = 0; attributeIndex > -1;) + { + // We may want to use imdbid pattern matching later, so we don't want to modify the original 'str'. + var subStr = str[strIndex..]; + int attributeEnd = 0; + + if (shortAttr.Length > 0) + { + // If we are using an alias it should be shorter (and a prefix), so let's search for that. + attributeIndex = subStr.IndexOf(shortAttr, StringComparison.OrdinalIgnoreCase); + attributeEnd = attributeIndex + shortAttr.Length; + } + else + { + attributeIndex = subStr.IndexOf(attribute, StringComparison.OrdinalIgnoreCase); + attributeEnd = attributeIndex + attribute.Length; + } + + // The next iteration should start at the end of the attribute we just found. + // If attributeIndex < 0, the loop will end and strIndex won't be used again. + strIndex += attributeEnd; + if (attributeIndex > 0) { - var attributeOpener = str[attributeIndex - 1]; + var attributeOpener = subStr[attributeIndex - 1]; var attributeCloser = attributeOpener switch { '[' => ']', @@ -47,20 +71,37 @@ namespace Emby.Server.Implementations.Library '{' => '}', _ => '\0' }; - if (attributeCloser != '\0' && (str[attributeEnd] == '=' || str[attributeEnd] == '-')) - { - var closingIndex = str[attributeEnd..].IndexOf(attributeCloser); - // Must be at least 1 character before the closing bracket. - if (closingIndex > 1) + if (attributeCloser != '\0') + { + if (shortAttr.Length > 0 + && attributeEnd + 1 < subStr.Length + && (subStr[attributeEnd] is 'i' or 'I') + && (subStr[attributeEnd + 1] is 'd' or 'D')) { - return str[(attributeEnd + 1)..(attributeEnd + closingIndex)].Trim().ToString(); + // We were searching for a shortened attribute, but it's followed by "id" - let's skip it. + attributeEnd += 2; + } + + // attributeEnd points at '='. + // We need at least 1 more character and the closing bracket after that. + if (attributeEnd + 2 < subStr.Length && (subStr[attributeEnd] is '=' or '-')) + { + var closingIndex = subStr[attributeEnd..].IndexOf(attributeCloser); + + // Must be at least 1 character before the closing bracket. + if (closingIndex > 1) + { + var trimmed = subStr[(attributeEnd + 1)..(attributeEnd + closingIndex)].Trim(); + + if (trimmed.Length > 0) + { + return trimmed.ToString(); + } + } } } } - - str = str[attributeEnd..]; - attributeIndex = str.IndexOf(attribute, StringComparison.OrdinalIgnoreCase); } // for imdbid we also accept pattern matching @@ -70,16 +111,6 @@ namespace Emby.Server.Implementations.Library return match ? imdbId.ToString() : null; } - // Allow tmdb as an alias for tmdbid - if (attribute.Equals("tmdbid", StringComparison.OrdinalIgnoreCase)) - { - var tmdbValue = str.GetAttributeValue("tmdb"); - if (tmdbValue is not null) - { - return tmdbValue; - } - } - return null; } diff --git a/Emby.Server.Implementations/Library/PathManager.cs b/Emby.Server.Implementations/Library/PathManager.cs index fad948ad97..2a50fcc7fe 100644 --- a/Emby.Server.Implementations/Library/PathManager.cs +++ b/Emby.Server.Implementations/Library/PathManager.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Globalization; using System.IO; +using Jellyfin.Extensions; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; @@ -43,7 +44,19 @@ public class PathManager : IPathManager public string? GetAttachmentPath(string mediaSourceId, string fileName) { var folder = GetAttachmentFolderPath(mediaSourceId); - return folder is null ? null : Path.Combine(folder, fileName); + if (folder is null) + { + return null; + } + + var safeName = PathHelper.GetSafeLeafFileName(fileName); + if (safeName is null) + { + _logger.LogWarning("Rejecting attachment filename '{FileName}' for MediaSource {MediaSourceId}: not a valid leaf name.", fileName, mediaSourceId); + return null; + } + + return Path.Combine(folder, safeName); } /// <inheritdoc /> diff --git a/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs index 1e885aad6e..7d51a0daa0 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CS1591 using System; @@ -18,7 +16,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Books { private readonly string[] _validExtensions = { ".azw", ".azw3", ".cb7", ".cbr", ".cbt", ".cbz", ".epub", ".mobi", ".pdf" }; - protected override Book Resolve(ItemResolveArgs args) + protected override Book? Resolve(ItemResolveArgs args) { var collectionType = args.GetCollectionType(); @@ -47,13 +45,14 @@ namespace Emby.Server.Implementations.Library.Resolvers.Books Path = args.Path, Name = result.Name ?? string.Empty, IndexNumber = result.Index, + ParentIndexNumber = result.ParentIndex, ProductionYear = result.Year, SeriesName = result.SeriesName ?? Path.GetFileName(Path.GetDirectoryName(args.Path)), IsInMixedFolder = true, }; } - private Book GetBook(ItemResolveArgs args) + private Book? GetBook(ItemResolveArgs args) { var bookFiles = args.FileSystemChildren.Where(f => { @@ -78,6 +77,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Books Path = bookFiles[0].FullName, Name = result.Name ?? string.Empty, IndexNumber = result.Index, + ParentIndexNumber = result.ParentIndex, ProductionYear = result.Year, SeriesName = result.SeriesName ?? string.Empty, }; diff --git a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs index 68b66ab7f5..80375ae12d 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs @@ -376,15 +376,24 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies // We need to only look at the name of this actual item (not parents) var justName = item.IsInMixedFolder ? Path.GetFileName(item.Path.AsSpan()) : Path.GetFileName(item.ContainingFolderPath.AsSpan()); - var tmdbid = justName.GetAttributeValue("tmdbid"); + // The fallback filename is only used when the item isn't in a mixed folder + var fileName = item.IsInMixedFolder ? ReadOnlySpan<char>.Empty : Path.GetFileName(item.Path.AsSpan()); - // If not in a mixed folder and ID not found in folder path, check filename - if (string.IsNullOrEmpty(tmdbid) && !item.IsInMixedFolder) + item.TrySetProviderId(MetadataProvider.Tmdb, GetIdFromNameOrPath(justName, fileName, "tmdbid")); + item.TrySetProviderId(MetadataProvider.Tvdb, GetIdFromNameOrPath(justName, fileName, "tvdbid")); + + string GetIdFromNameOrPath(ReadOnlySpan<char> name, ReadOnlySpan<char> fallbackName, string attribute) { - tmdbid = Path.GetFileName(item.Path.AsSpan()).GetAttributeValue("tmdbid"); - } + var id = name.GetAttributeValue(attribute); - item.TrySetProviderId(MetadataProvider.Tmdb, tmdbid); + // If not in a mixed folder and ID not found in folder path, check filename + if (string.IsNullOrEmpty(id) && !item.IsInMixedFolder) + { + id = fallbackName.GetAttributeValue(attribute); + } + + return id; + } if (!string.IsNullOrEmpty(item.Path)) { diff --git a/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs b/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs index 14798dda65..74c1f69616 100644 --- a/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs @@ -1,6 +1,7 @@ #nullable disable using System; +using System.Collections.Generic; using System.IO; using System.Linq; using Jellyfin.Data.Enums; @@ -46,7 +47,16 @@ namespace Emby.Server.Implementations.Library.Resolvers } // It's a directory-based playlist if the directory contains a playlist file - var filePaths = Directory.EnumerateFiles(args.Path, "*", new EnumerationOptions { IgnoreInaccessible = true }); + IEnumerable<string> filePaths; + try + { + filePaths = Directory.EnumerateFiles(args.Path, "*", new EnumerationOptions { IgnoreInaccessible = true }); + } + catch (IOException) + { + return null; + } + if (filePaths.Any(f => f.EndsWith(PlaylistXmlSaver.DefaultPlaylistFilename, StringComparison.OrdinalIgnoreCase))) { return new Playlist diff --git a/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs index 769d721665..8d40eab006 100644 --- a/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs @@ -57,6 +57,11 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV return null; } + if (args.Parent is not null && args.Parent.IsRoot) + { + return null; + } + var seriesInfo = Naming.TV.SeriesResolver.Resolve(_namingOptions, args.Path); var collectionType = args.GetCollectionType(); @@ -69,7 +74,8 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV return new Series { Path = args.Path, - Name = seriesInfo.Name + Name = seriesInfo.Name, + ProductionYear = seriesInfo.Year }; } } diff --git a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs index b4ed12a20c..57d1f7c770 100644 --- a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs +++ b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs @@ -53,6 +53,7 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; private readonly IItemQueryHelpers _queryHelpers; private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly ILibraryManager _libraryManager; /// <summary> /// Initializes a new instance of the <see cref="MovieSimilarItemsProvider"/> class. @@ -60,14 +61,17 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie /// <param name="dbProvider">The database context factory.</param> /// <param name="queryHelpers">The shared query helpers.</param> /// <param name="serverConfigurationManager">The server configuration manager.</param> + /// <param name="libraryManager">The library manager.</param> public MovieSimilarItemsProvider( IDbContextFactory<JellyfinDbContext> dbProvider, IItemQueryHelpers queryHelpers, - IServerConfigurationManager serverConfigurationManager) + IServerConfigurationManager serverConfigurationManager, + ILibraryManager libraryManager) { _dbProvider = dbProvider; _queryHelpers = queryHelpers; _serverConfigurationManager = serverConfigurationManager; + _libraryManager = libraryManager; } /// <inheritdoc/> @@ -156,6 +160,11 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie IsPlayed = false }; + if (query.User is not null) + { + _libraryManager.ConfigureUserAccess(filter, query.User); + } + _queryHelpers.PrepareFilterQuery(filter); var baseQuery = _queryHelpers.PrepareItemQuery(context, filter); baseQuery = _queryHelpers.TranslateQuery(baseQuery, context, filter); diff --git a/Emby.Server.Implementations/Library/UserDataManager.cs b/Emby.Server.Implementations/Library/UserDataManager.cs index 1281f1587f..0680046c11 100644 --- a/Emby.Server.Implementations/Library/UserDataManager.cs +++ b/Emby.Server.Implementations/Library/UserDataManager.cs @@ -192,7 +192,8 @@ namespace Emby.Server.Implementations.Library } else { - var userData = item.UserData?.Where(e => e.UserId.Equals(user.Id)).Select(Map).FirstOrDefault(); + var userDataRow = ResolveUserDataRow(item, item.UserData?.Where(e => e.UserId.Equals(user.Id))); + var userData = userDataRow is not null ? Map(userDataRow) : null; if (userData is not null) { result[item.Id] = userData; @@ -211,36 +212,128 @@ namespace Emby.Server.Implementations.Library return result; } - // Build a single query for all missing items + // Build a single query for all missing items. Fetch rows by item alone so rows kept + // under keys from older metadata resolve the same way as the in-memory path. var allItemIds = itemsNeedingQuery.Select(x => x.Item.Id).ToList(); - var allKeys = itemsNeedingQuery.SelectMany(x => x.Keys).Distinct().ToList(); - if (allKeys.Count > 0) + using var context = _repository.CreateDbContext(); + var userDataArray = context.UserData + .AsNoTracking() + .Where(e => e.UserId.Equals(user.Id)) + .WhereOneOrMany(allItemIds, e => e.ItemId) + .ToArray(); + + var userDataByItem = userDataArray.GroupBy(e => e.ItemId).ToDictionary(g => g.Key, g => g.ToArray()); + foreach (var (item, keys) in itemsNeedingQuery) { - using var context = _repository.CreateDbContext(); - var userDataArray = context.UserData - .AsNoTracking() - .Where(e => e.UserId.Equals(user.Id)) - .WhereOneOrMany(allItemIds, e => e.ItemId) - .WhereOneOrMany(allKeys, e => e.CustomDataKey) - .ToArray(); - - var userDataByItem = userDataArray.GroupBy(e => e.ItemId).ToDictionary(g => g.Key, g => g.ToArray()); - foreach (var (item, keys) in itemsNeedingQuery) + UserItemData userData; + if (userDataByItem.TryGetValue(item.Id, out var itemUserData) && itemUserData.Length > 0) { - UserItemData userData; - if (userDataByItem.TryGetValue(item.Id, out var itemUserData) && itemUserData.Length > 0) - { - var directDataReference = itemUserData.FirstOrDefault(e => e.CustomDataKey == item.Id.ToString("N")); - userData = directDataReference is not null ? Map(directDataReference) : Map(itemUserData.First()); - } - else - { - userData = new UserItemData { Key = keys.Count > 0 ? keys[0] : string.Empty }; - } + userData = Map(ResolveUserDataRow(item, itemUserData)!); + } + else + { + userData = new UserItemData { Key = keys.Count > 0 ? keys[0] : string.Empty }; + } - result[item.Id] = userData; - var cacheKey = GetCacheKey(user.InternalId, item.Id); - _cache.AddOrUpdate(cacheKey, userData); + result[item.Id] = userData; + var cacheKey = GetCacheKey(user.InternalId, item.Id); + _cache.AddOrUpdate(cacheKey, userData); + } + + return result; + } + + /// <inheritdoc /> + public VersionResumeData? GetResumeUserData(User user, BaseItem item) + { + return GetResumeUserDataBatch([item], user).GetValueOrDefault(item.Id); + } + + /// <inheritdoc /> + public IReadOnlyDictionary<Guid, VersionResumeData> GetResumeUserDataBatch(IReadOnlyList<BaseItem> items, User user) + { + ArgumentNullException.ThrowIfNull(user); + + var result = new Dictionary<Guid, VersionResumeData>(); + + // Candidate primaries: a directly queried version (PrimaryVersionId set) keeps its own data. + // Linked alternates are already known in memory; only the local-alternate existence check + // would otherwise hit the database (one query per item via Video.HasLocalAlternateVersions), + // so collect those ids and resolve them all in a single query below. + List<Video>? candidates = null; + List<Guid>? localProbeIds = null; + foreach (var item in items) + { + if (item is not Video video || video.PrimaryVersionId.HasValue) + { + continue; + } + + (candidates ??= []).Add(video); + + if (video.LinkedAlternateVersions.Length == 0) + { + (localProbeIds ??= []).Add(video.Id); + } + } + + if (candidates is null) + { + return result; + } + + HashSet<Guid>? withLocalAlternates = null; + if (localProbeIds is not null) + { + using var dbContext = _repository.CreateDbContext(); + withLocalAlternates = dbContext.LinkedChildren + .Where(lc => lc.ChildType == Jellyfin.Database.Implementations.Entities.LinkedChildType.LocalAlternateVersion) + .WhereOneOrMany(localProbeIds, lc => lc.ParentId) + .Select(lc => lc.ParentId) + .Distinct() + .ToHashSet(); + } + + List<(Guid PrimaryId, IReadOnlyList<Video> Versions)>? versionGroups = null; + List<BaseItem>? allVersions = null; + + foreach (var video in candidates) + { + // Only items that actually have alternate versions aggregate over them. + if (video.LinkedAlternateVersions.Length == 0 + && (withLocalAlternates is null || !withLocalAlternates.Contains(video.Id))) + { + continue; + } + + var versions = video.GetAllVersions(); + if (versions.Count < 2) + { + continue; + } + + (versionGroups ??= []).Add((video.Id, versions)); + (allVersions ??= []).AddRange(versions); + } + + if (versionGroups is null) + { + return result; + } + + var userDataByVersion = GetUserDataBatch(allVersions!.DistinctBy(i => i.Id).ToList(), user); + + foreach (var (primaryId, versions) in versionGroups) + { + // Consider both in-progress and completed versions so a finished alternate still marks the primary as played. + var resumeVersion = VersionPlaybackSelector.SelectMostRecentlyPlayed( + versions, + version => userDataByVersion.GetValueOrDefault(version.Id), + data => data.PlaybackPositionTicks > 0 || data.Played); + + if (resumeVersion is not null) + { + result[primaryId] = new VersionResumeData(resumeVersion.Id, userDataByVersion[resumeVersion.Id]); } } @@ -259,12 +352,41 @@ namespace Emby.Server.Implementations.Library /// <inheritdoc /> public UserItemData? GetUserData(User user, BaseItem item) { - return item.UserData?.Where(e => e.UserId.Equals(user.Id)).Select(Map).FirstOrDefault() ?? new UserItemData() + ArgumentNullException.ThrowIfNull(user); + var row = ResolveUserDataRow(item, item.UserData?.Where(e => e.UserId.Equals(user.Id))); + return row is not null ? Map(row) : new UserItemData() { Key = item.GetUserDataKeys()[0], }; } + /// <summary> + /// Picks the row matching the item's current user data keys, in key order, so rows left behind + /// under keys from older metadata don't take priority over the rows the write path updates. + /// </summary> + /// <param name="item">The item whose keys to match.</param> + /// <param name="rows">The candidate user data rows for a single user.</param> + /// <returns>The best matching row, or <c>null</c> when there are none.</returns> + private static UserData? ResolveUserDataRow(BaseItem item, IEnumerable<UserData>? rows) + { + var candidates = rows?.ToList(); + if (candidates is null || candidates.Count == 0) + { + return null; + } + + foreach (var key in item.GetUserDataKeys()) + { + var match = candidates.Find(e => string.Equals(e.CustomDataKey, key, StringComparison.Ordinal)); + if (match is not null) + { + return match; + } + } + + return candidates[0]; + } + /// <inheritdoc /> public UserItemDataDto? GetUserDataDto(BaseItem item, User user) => GetUserDataDto(item, null, user, new DtoOptions()); @@ -281,6 +403,10 @@ namespace Emby.Server.Implementations.Library var dto = GetUserItemDataDto(userData, item.Id); item.FillUserDataDtoValues(dto, userData, itemDto, user, options); + + // For an item with alternate versions, surface the most recently played version's resume point. + GetResumeUserData(user, item)?.ApplyTo(dto); + return dto; } @@ -385,5 +511,41 @@ namespace Emby.Server.Implementations.Library return playedToCompletion; } + + /// <inheritdoc /> + public void ResetPlaybackStreamSelections(User user, BaseItem item) + { + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(item); + + using var dbContext = _repository.CreateDbContext(); + var rows = dbContext.UserData + .Where(e => e.ItemId == item.Id && e.UserId == user.Id + && (e.AudioStreamIndex != null || e.SubtitleStreamIndex != null)) + .ToList(); + + if (rows.Count == 0) + { + return; + } + + foreach (var row in rows) + { + row.AudioStreamIndex = null; + row.SubtitleStreamIndex = null; + } + + dbContext.SaveChanges(); + + var cacheKey = GetCacheKey(user.InternalId, item.Id); + if (_cache.TryGet(cacheKey, out var cached)) + { + cached.AudioStreamIndex = null; + cached.SubtitleStreamIndex = null; + _cache.AddOrUpdate(cacheKey, cached); + } + + item.UserData = dbContext.UserData.Where(e => e.ItemId == item.Id).AsNoTracking().ToArray(); + } } } diff --git a/Emby.Server.Implementations/Localization/Core/az.json b/Emby.Server.Implementations/Localization/Core/az.json new file mode 100644 index 0000000000..6ab18c8534 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/az.json @@ -0,0 +1,19 @@ +{ + "Books": "Kitablar", + "HomeVideos": "Ev Videoları", + "Latest": "Ən son", + "MixedContent": "Qarışıq məzmun", + "Movies": "Filmlər", + "Music": "Musiqi", + "MusicVideos": "Musiqi Videoları", + "NameSeasonUnknown": "Mövsüm Naməlum", + "NewVersionIsAvailable": "Jellyfin Serverin yeni versiyası yükləmək üçün əlçatandır.", + "NotificationOptionApplicationUpdateAvailable": "Tətbiq yeniləməsi mövcuddur", + "NotificationOptionApplicationUpdateInstalled": "Tətbiq yeniləməsi quraşdırılıb", + "NotificationOptionAudioPlayback": "Audio oxutma başladı", + "NotificationOptionAudioPlaybackStopped": "Audio oxutma dayandırıldı", + "NotificationOptionCameraImageUploaded": "Kamera şəkli yükləndi", + "NotificationOptionInstallationFailed": "Quraşdırma uğursuzluğu", + "NotificationOptionNewLibraryContent": "Yeni məzmun əlavə edildi", + "NotificationOptionPluginError": "Plugin uğursuzluğu" +} diff --git a/Emby.Server.Implementations/Localization/Core/da.json b/Emby.Server.Implementations/Localization/Core/da.json index 697d9c090f..de56b6fd66 100644 --- a/Emby.Server.Implementations/Localization/Core/da.json +++ b/Emby.Server.Implementations/Localization/Core/da.json @@ -9,7 +9,7 @@ "Favorites": "Favoritter", "Folders": "Mapper", "Genres": "Genrer", - "HeaderContinueWatching": "Fortsæt afspilning", + "HeaderContinueWatching": "Fortsæt med at se", "HeaderFavoriteEpisodes": "Yndlingsafsnit", "HeaderFavoriteShows": "Yndlingsserier", "HeaderLiveTV": "Live-TV", @@ -65,7 +65,7 @@ "TaskDownloadMissingSubtitlesDescription": "Søger på internettet efter manglende undertekster baseret på metadata-konfigurationen.", "TaskDownloadMissingSubtitles": "Hent manglende undertekster", "TaskUpdatePluginsDescription": "Henter og installerer opdateringer for plugins, som er konfigurerede til at blive opdateret automatisk.", - "TaskUpdatePlugins": "Opdater plugins", + "TaskUpdatePlugins": "Opdatér plugins", "TaskCleanLogsDescription": "Sletter log-filer som er mere end {0} dage gamle.", "TaskCleanLogs": "Ryd log-mappe", "TaskRefreshLibraryDescription": "Scanner dit mediebibliotek for nye filer og opdateret metadata.", @@ -79,10 +79,10 @@ "TaskRefreshChapterImages": "Udtræk kapitelbilleder", "TaskRefreshChapterImagesDescription": "Laver miniaturebilleder for videoer, der har kapitler.", "TaskRefreshChannelsDescription": "Opdaterer information for internetkanaler.", - "TaskRefreshChannels": "Opdater kanaler", + "TaskRefreshChannels": "Opdatér kanaler", "TaskCleanTranscodeDescription": "Fjerner omkodningsfiler, som er mere end 1 dag gamle.", "TaskCleanTranscode": "Tøm omkodningsmappen", - "TaskRefreshPeople": "Opdater personer", + "TaskRefreshPeople": "Opdatér personer", "TaskRefreshPeopleDescription": "Opdaterer metadata for skuespillere og instruktører i dit mediebibliotek.", "TaskCleanActivityLogDescription": "Sletter linjer i aktivitetsloggen ældre end den konfigurerede alder.", "TaskCleanActivityLog": "Ryd aktivitetslog", @@ -90,7 +90,7 @@ "Forced": "Tvunget", "Default": "Standard", "TaskOptimizeDatabaseDescription": "Komprimerer databasen for at frigøre plads. Denne handling køres efter at have scannet mediebiblioteket, eller efter at have lavet ændringer til databasen.", - "TaskOptimizeDatabase": "Optimer database", + "TaskOptimizeDatabase": "Optimér database", "TaskKeyframeExtractorDescription": "Udtrækker rammer fra videofiler for at lave mere præcise HLS-playlister. Denne opgave kan tage lang tid.", "TaskKeyframeExtractor": "Udtræk nøglerammer", "External": "Ekstern", @@ -99,7 +99,7 @@ "TaskRefreshTrickplayImagesDescription": "Laver trickplay-billeder for videoer i aktiverede biblioteker.", "TaskAudioNormalizationDescription": "Skanner filer for data vedrørende lydnormalisering.", "TaskAudioNormalization": "Lydnormalisering", - "TaskDownloadMissingLyricsDescription": "Søger på internettet efter manglende sangtekster baseret på metadata-konfigurationen", + "TaskDownloadMissingLyricsDescription": "Download sangtekster", "TaskDownloadMissingLyrics": "Hent manglende sangtekster", "TaskExtractMediaSegments": "Scan for mediesegmenter", "TaskMoveTrickplayImages": "Migrer billedelokationer for trickplay-billeder", diff --git a/Emby.Server.Implementations/Localization/Core/en-GB.json b/Emby.Server.Implementations/Localization/Core/en-GB.json index be152b515f..298d60d277 100644 --- a/Emby.Server.Implementations/Localization/Core/en-GB.json +++ b/Emby.Server.Implementations/Localization/Core/en-GB.json @@ -106,5 +106,7 @@ "TaskMoveTrickplayImages": "Migrate Trickplay Image Location", "TaskMoveTrickplayImagesDescription": "Moves existing trickplay files according to the library settings.", "CleanupUserDataTask": "User data cleanup task", - "CleanupUserDataTaskDescription": "Cleans all user data (Watch state, favourite status etc) from media that is no longer present for at least 90 days." + "CleanupUserDataTaskDescription": "Cleans all user data (Watch state, favourite status etc) from media that is no longer present for at least 90 days.", + "LyricDownloadFailureFromForItem": "Lyrics failed to download from {0} for {1}", + "Original": "Original" } diff --git a/Emby.Server.Implementations/Localization/Core/es-AR.json b/Emby.Server.Implementations/Localization/Core/es-AR.json index 28366a41b7..bccfdd4c19 100644 --- a/Emby.Server.Implementations/Localization/Core/es-AR.json +++ b/Emby.Server.Implementations/Localization/Core/es-AR.json @@ -106,5 +106,7 @@ "TaskMoveTrickplayImagesDescription": "Mueve archivos existentes de trickplay de acuerdo a la configuración de la biblioteca.", "TaskMoveTrickplayImages": "Migrar Ubicación de Imagen de Trickplay", "CleanupUserDataTaskDescription": "Limpia todos los datos del usuario (estado de visualización, estado de los favoritos, etc.) que no están presentes en la biblioteca por al menos 90 días.", - "CleanupUserDataTask": "Tarea de limpieza de datos de usuarios" + "CleanupUserDataTask": "Tarea de limpieza de datos de usuarios", + "LyricDownloadFailureFromForItem": "No se pudo descargar la letra desde {0} para {1}", + "Original": "Original" } diff --git a/Emby.Server.Implementations/Localization/Core/es_419.json b/Emby.Server.Implementations/Localization/Core/es_419.json index 1f13451060..4404354a88 100644 --- a/Emby.Server.Implementations/Localization/Core/es_419.json +++ b/Emby.Server.Implementations/Localization/Core/es_419.json @@ -106,5 +106,7 @@ "TaskExtractMediaSegments": "Escaneo de segmentos de medios", "TaskMoveTrickplayImages": "Migrar la ubicación de la imagen de Trickplay", "CleanupUserDataTask": "Tarea de limpieza de datos de usuario", - "CleanupUserDataTaskDescription": "Limpia todos los datos de usuario (estado de visualización, favoritos, etc.) que no están presentes en la biblioteca por al menos 90 días." + "CleanupUserDataTaskDescription": "Limpia todos los datos de usuario (estado de visualización, favoritos, etc.) que no están presentes en la biblioteca por al menos 90 días.", + "LyricDownloadFailureFromForItem": "No se pudo descargar las letras de {0} para {1}", + "Original": "Original" } diff --git a/Emby.Server.Implementations/Localization/Core/fo.json b/Emby.Server.Implementations/Localization/Core/fo.json index 4fb9f4329c..e079ada064 100644 --- a/Emby.Server.Implementations/Localization/Core/fo.json +++ b/Emby.Server.Implementations/Localization/Core/fo.json @@ -1,7 +1,7 @@ { - "Artists": "Listafólk", + "Artists": "Tónlistafólk", "Collections": "Søvn", - "Default": "Sjálvgildi", + "Default": "Forsett", "External": "Ytri", "Genres": "Greinar", "AppDeviceValues": "App: {0}, Eind: {1}", @@ -12,5 +12,43 @@ "Forced": "Kravt", "FailedLoginAttemptWithUserName": "Miseydnað innritanarroynd frá {0}", "HeaderFavoriteEpisodes": "Yndispartar", - "LabelIpAddressValue": "IP atsetur: {0}" + "LabelIpAddressValue": "IP-atsetur: {0}", + "AuthenticationSucceededWithUserName": "{0} varð samgildur", + "HeaderFavoriteShows": "Yndisrøðir", + "HeaderLiveTV": "Beinleiðis sjónvarp", + "HearingImpaired": "Hoyrnarveik", + "Inherit": "Arvar", + "LabelRunningTimeValue": "Spælitíð: {0}", + "Latest": "Seinastu", + "LyricDownloadFailureFromForItem": "Miseydnaðist at niðurtakað sangtekst fyri {1} frá {0}", + "NameInstallFailed": "{0} innlegging miseydnaðist", + "NewVersionIsAvailable": "Ein nýggj útgáva av Jellyfin ambætaranum er tøk.", + "NotificationOptionNewLibraryContent": "Nýtt tilfar innlagt", + "NotificationOptionPluginInstalled": "Ískoytisforrit innlagt", + "NotificationOptionPluginUninstalled": "Ískoytisforrit strikað", + "NotificationOptionPluginUpdateInstalled": "Ískoytisforrit dagført", + "NotificationOptionUserLockedOut": "Brúkari útihýstur", + "Photos": "Ljósmyndir", + "PluginInstalledWithName": "{0} innlagt", + "PluginUninstalledWithName": "{0} strikað", + "PluginUpdatedWithName": "{0} dagført", + "Shows": "Røðir", + "SubtitleDownloadFailureFromForItem": "Miseydnaðist at niðurtakað undirtekstir til {1} frá {0}", + "TvShows": "Sjónvarpsrøðir", + "UserCreatedWithName": "Brúkari {0} er stovnaður", + "UserDeletedWithName": "Brúkari {0} er strikaður", + "UserDownloadingItemWithValues": "{0} niðurtekur {1}", + "UserLockedOutWithName": "Brúkari {0} er útihýstur", + "VersionNumber": "Útgáva {0}", + "TasksLibraryCategory": "Savn", + "TaskRefreshLibrary": "Skanna miðlasavn", + "TaskCleanLogsDescription": "Strikar gerðalistafílur eldri enn {0} dagar.", + "TaskUpdatePlugins": "Dagfør ískoytisforrit", + "TaskRefreshChannels": "Endurinnles rásir", + "TaskDownloadMissingLyricsDescription": "Niðurtekur sangtekstir", + "Movies": "Filmar", + "MixedContent": "Blandað innihald", + "Music": "Tónleikur", + "UserStartedPlayingItemWithValues": "{0} spælur {1} á {2}", + "HeaderContinueWatching": "Hald áfram at hyggja" } diff --git a/Emby.Server.Implementations/Localization/Core/gl.json b/Emby.Server.Implementations/Localization/Core/gl.json index d3740130ee..a68db0076a 100644 --- a/Emby.Server.Implementations/Localization/Core/gl.json +++ b/Emby.Server.Implementations/Localization/Core/gl.json @@ -106,5 +106,6 @@ "TaskRefreshTrickplayImages": "Xerar miniaturas de previsualización", "TaskAudioNormalizationDescription": "Escanea ficheiros á procura de datos de normalización de volume.", "CleanupUserDataTask": "Tarefa de limpeza de datos dos usuarios", - "CleanupUserDataTaskDescription": "Limpa todos os datos do usuario (estado de visualización, de favorito etc.) dos medios ausentes polo menos 90 días." + "CleanupUserDataTaskDescription": "Limpa todos os datos do usuario (estado de visualización, de favorito etc.) dos medios ausentes polo menos 90 días.", + "Original": "Orixinal" } diff --git a/Emby.Server.Implementations/Localization/Core/he.json b/Emby.Server.Implementations/Localization/Core/he.json index af34bf092e..48056b0bb8 100644 --- a/Emby.Server.Implementations/Localization/Core/he.json +++ b/Emby.Server.Implementations/Localization/Core/he.json @@ -106,5 +106,7 @@ "TaskExtractMediaSegmentsDescription": "מחלץ חלקי מדיה מתוספים המאפשרים זאת.", "TaskMoveTrickplayImagesDescription": "הזזת קבצי Trickplay קיימים בהתאם להגדרות הספרייה.", "CleanupUserDataTaskDescription": "ניקוי כל המידע של המשתמש (מצב צפייה, מועדפים וכו) ממדיה שאינה קיימת מעל 90 יום.", - "CleanupUserDataTask": "משימת ניקוי מידע משתמש" + "CleanupUserDataTask": "משימת ניקוי מידע משתמש", + "LyricDownloadFailureFromForItem": "הורדת המילים מ-{0} עבור {1} נכשלה", + "Original": "מקור" } diff --git a/Emby.Server.Implementations/Localization/Core/hi.json b/Emby.Server.Implementations/Localization/Core/hi.json index e98a5fbac1..5fbf61c627 100644 --- a/Emby.Server.Implementations/Localization/Core/hi.json +++ b/Emby.Server.Implementations/Localization/Core/hi.json @@ -105,5 +105,6 @@ "TaskExtractMediaSegmentsDescription": "मीडियासेगमेंट सक्षम प्लगइन्स से मीडिया सेगमेंट निकालता है या प्राप्त करता है।", "TaskMoveTrickplayImages": "ट्रिकप्ले छवि स्थान माइग्रेट करें", "TaskMoveTrickplayImagesDescription": "लाइब्रेरी सेटिंग्स के अनुसार मौजूदा ट्रिकप्ले फ़ाइलों को स्थानांतरित करता है।", - "CleanupUserDataTask": "यूज़र डेटा सफाई कार्य" + "CleanupUserDataTask": "यूज़र डेटा सफाई कार्य", + "Original": "असली" } diff --git a/Emby.Server.Implementations/Localization/Core/hr.json b/Emby.Server.Implementations/Localization/Core/hr.json index 8794339fb1..442c26b30b 100644 --- a/Emby.Server.Implementations/Localization/Core/hr.json +++ b/Emby.Server.Implementations/Localization/Core/hr.json @@ -107,5 +107,6 @@ "TaskMoveTrickplayImagesDescription": "Premješta postojeće datoteke brzog pregledavanja u postavke biblioteke.", "CleanupUserDataTask": "Zadatak čišćenja korisničkih podataka", "CleanupUserDataTaskDescription": "Briše sve korisničke podatke (stanje gledanja, status favorita itd.) s medija koji više nisu prisutni najmanje 90 dana.", - "Original": "Original" + "Original": "Original", + "LyricDownloadFailureFromForItem": "Preuzimanje tekstova pjesmi od {0} za {1} nije uspjelo" } diff --git a/Emby.Server.Implementations/Localization/Core/id.json b/Emby.Server.Implementations/Localization/Core/id.json index 65c03e70f2..3502ec39ad 100644 --- a/Emby.Server.Implementations/Localization/Core/id.json +++ b/Emby.Server.Implementations/Localization/Core/id.json @@ -106,5 +106,7 @@ "TaskExtractMediaSegments": "Scan Segmen media", "TaskMoveTrickplayImages": "Migrasikan Lokasi Gambar Trickplay", "TaskDownloadMissingLyrics": "Unduh Lirik yang Hilang", - "CleanupUserDataTask": "Tugas Pembersihan Data Pengguna" + "CleanupUserDataTask": "Tugas Pembersihan Data Pengguna", + "LyricDownloadFailureFromForItem": "Lirik gagal di download dari {0} untuk {1}", + "Original": "Asli" } diff --git a/Emby.Server.Implementations/Localization/Core/is.json b/Emby.Server.Implementations/Localization/Core/is.json index c9ca00afdf..825ad3084e 100644 --- a/Emby.Server.Implementations/Localization/Core/is.json +++ b/Emby.Server.Implementations/Localization/Core/is.json @@ -103,5 +103,10 @@ "TaskDownloadMissingLyrics": "Sækja söngtexta sem vantar", "TaskExtractMediaSegments": "Skönnun efnishluta", "CleanupUserDataTask": "Hreinsun notendagagna", - "CleanupUserDataTaskDescription": "Hreinsar öll notendagögn (spilunarstöðu, uppáhöld o.s.frv.) um gögn sem hafa ekki verið til staðar í að lámarki 90 daga." + "CleanupUserDataTaskDescription": "Hreinsar öll notendagögn (spilunarstöðu, uppáhöld o.s.frv.) um gögn sem hafa ekki verið til staðar í að lámarki 90 daga.", + "LyricDownloadFailureFromForItem": "Ekki tókst að niðurhala texta frá {0} fyrir {1}", + "Original": "Upprunaleg", + "TaskExtractMediaSegmentsDescription": "Sækir myndbúta úr viðbótum þar sem MediaSegment er virkt.", + "TaskMoveTrickplayImages": "Flytja geymslustað fyrir Trickplay-myndir", + "TaskMoveTrickplayImagesDescription": "Flytur fyrirliggjandi Trickplay-skrár í samræmi við stillingar safnsins." } diff --git a/Emby.Server.Implementations/Localization/Core/ja.json b/Emby.Server.Implementations/Localization/Core/ja.json index 39e5af717c..78b7ec744b 100644 --- a/Emby.Server.Implementations/Localization/Core/ja.json +++ b/Emby.Server.Implementations/Localization/Core/ja.json @@ -106,5 +106,7 @@ "TaskDownloadMissingLyrics": "失われた歌詞をダウンロード", "TaskExtractMediaSegmentsDescription": "MediaSegment 対応プラグインからメディア セグメントを抽出または取得します。", "CleanupUserDataTask": "ユーザーデータのクリーンアップタスク", - "CleanupUserDataTaskDescription": "90日以上存在しないメディアに対して、視聴状態やお気に入り状態などのユーザーデータをすべて削除します。" + "CleanupUserDataTaskDescription": "90日以上存在しないメディアに対して、視聴状態やお気に入り状態などのユーザーデータをすべて削除します。", + "LyricDownloadFailureFromForItem": "歌詞", + "Original": "オリジナル" } diff --git a/Emby.Server.Implementations/Localization/Core/kn.json b/Emby.Server.Implementations/Localization/Core/kn.json index f053619a7a..6009b50fe0 100644 --- a/Emby.Server.Implementations/Localization/Core/kn.json +++ b/Emby.Server.Implementations/Localization/Core/kn.json @@ -80,7 +80,7 @@ "NotificationOptionInstallationFailed": "ಸ್ಥಾಪನ ವೈಫಲ್ಯ", "NotificationOptionNewLibraryContent": "ಹೊಸ ವಿಷಯವನ್ನು ಒಳಗೊಂಡಿದೆ", "NotificationOptionPluginError": "ಪ್ಲಗಿನ್ ವೈಫಲ್ಯ", - "NotificationOptionPluginInstalled": "ಪ್ಲಗಿನ್ ವೈಫಲ್ಯ", + "NotificationOptionPluginInstalled": "ಪ್ಲಗಿನ್ ಸ್ಥಾಪಿಸಲಾಗಿದೆ", "NotificationOptionPluginUpdateInstalled": "ಪ್ಲಗಿನ್ ನವೀಕರಣವನ್ನು ಸ್ಥಾಪಿಸಲಾಗಿದೆ", "NotificationOptionServerRestartRequired": "ಸರ್ವರ್ ಮರುಪ್ರಾರಂಭದ ಅಗತ್ಯವಿದೆ", "NotificationOptionTaskFailed": "ನಿಗದಿತ ಕಾರ್ಯ ವೈಫಲ್ಯ", diff --git a/Emby.Server.Implementations/Localization/Core/ko.json b/Emby.Server.Implementations/Localization/Core/ko.json index 5d64405d19..a210125d34 100644 --- a/Emby.Server.Implementations/Localization/Core/ko.json +++ b/Emby.Server.Implementations/Localization/Core/ko.json @@ -106,5 +106,7 @@ "TaskDownloadMissingLyrics": "누락된 가사 다운로드", "TaskDownloadMissingLyricsDescription": "가사 다운로드", "CleanupUserDataTask": "사용자 데이터 정리 작업", - "CleanupUserDataTaskDescription": "최소 90일 이상 존재하지 않는 미디어에 대한 사용자 데이터(시청 상태, 즐겨찾기 등)를 정리합니다." + "CleanupUserDataTaskDescription": "최소 90일 이상 존재하지 않는 미디어에 대한 사용자 데이터(시청 상태, 즐겨찾기 등)를 정리합니다.", + "LyricDownloadFailureFromForItem": "{1}에 대한 가사를 {0}에서 다운로드하지 못했습니다", + "Original": "원본" } diff --git a/Emby.Server.Implementations/Localization/Core/lv.json b/Emby.Server.Implementations/Localization/Core/lv.json index 4a1b248e76..76fa9e3cf7 100644 --- a/Emby.Server.Implementations/Localization/Core/lv.json +++ b/Emby.Server.Implementations/Localization/Core/lv.json @@ -107,5 +107,6 @@ "TaskDownloadMissingLyricsDescription": "Lejupielādēt vārdus dziesmām", "CleanupUserDataTask": "Lietotāju datu tīrīšanas uzdevums", "CleanupUserDataTaskDescription": "Notīra visus lietotāja datus (skatīšanās stāvokļus, favorītu statusi utt.) no medijiem, kas vairs nav pieejami vismaz 90 dienas.", - "Original": "Oriģināls" + "Original": "Oriģināls", + "LyricDownloadFailureFromForItem": "Dziesmu vārdi nevarēja tikt lejupielādēti no {0} priekš {1}" } diff --git a/Emby.Server.Implementations/Localization/Core/pt-PT.json b/Emby.Server.Implementations/Localization/Core/pt-PT.json index dd482d1e9b..ce7f6d120e 100644 --- a/Emby.Server.Implementations/Localization/Core/pt-PT.json +++ b/Emby.Server.Implementations/Localization/Core/pt-PT.json @@ -107,5 +107,6 @@ "TaskMoveTrickplayImagesDescription": "Move os ficheiros trickplay existentes de acordo com as definições da mediateca.", "CleanupUserDataTaskDescription": "Apaga todos os dados de utilizador (estados de reprodução, favoritos, etc) de arquivos média não presentes há 90 dias ou mais.", "CleanupUserDataTask": "Limpeza de dados de utilizador", - "Original": "Original" + "Original": "Original", + "LyricDownloadFailureFromForItem": "Erro ao descarregar letras de {0} para {1}" } diff --git a/Emby.Server.Implementations/Localization/Core/si.json b/Emby.Server.Implementations/Localization/Core/si.json index 0967ef424b..8efc0d1f2e 100644 --- a/Emby.Server.Implementations/Localization/Core/si.json +++ b/Emby.Server.Implementations/Localization/Core/si.json @@ -1 +1,15 @@ -{} +{ + "AppDeviceValues": "ඇප්: {0}, උපාංග: {1}", + "Artists": "කලාකරුවන්", + "AuthenticationSucceededWithUserName": "{0} සාර්ථකව තහවුරු කරන ලදී", + "Books": "පොත්", + "ChapterNameValue": "{0} වෙනි පරිච්ඡේදය", + "Collections": "සංහිතා", + "Default": "පෙරනිමි", + "External": "බාහිර", + "FailedLoginAttemptWithUserName": "{0} වෙතින් සිදුකළ පිවිසීමේ උත්සාහය අසාර්ථක විය", + "Favorites": "ප්‍රියතමයන්", + "Folders": "ෆෝල්ඩර", + "Forced": "නියමිත", + "Genres": "ප්‍රභේද" +} diff --git a/Emby.Server.Implementations/Localization/Core/sk.json b/Emby.Server.Implementations/Localization/Core/sk.json index afea835bd4..7ae8857e5d 100644 --- a/Emby.Server.Implementations/Localization/Core/sk.json +++ b/Emby.Server.Implementations/Localization/Core/sk.json @@ -106,5 +106,7 @@ "TaskDownloadMissingLyrics": "Stiahnuť chýbajúce texty piesní", "TaskDownloadMissingLyricsDescription": "Stiahne texty pre piesne", "CleanupUserDataTask": "Prečistiť používateľské dáta", - "CleanupUserDataTaskDescription": "Vyčistí všetky dáta používateľa (stav sledovania, stav obľúbených atď.) z médií, ktoré už neexistujú aspoň 90 dní." + "CleanupUserDataTaskDescription": "Vyčistí všetky dáta používateľa (stav sledovania, stav obľúbených atď.) z médií, ktoré už neexistujú aspoň 90 dní.", + "LyricDownloadFailureFromForItem": "Text piesne sa nepodarilo stiahnuť z {0} pre {1}", + "Original": "Originál" } diff --git a/Emby.Server.Implementations/Localization/Core/sl-SI.json b/Emby.Server.Implementations/Localization/Core/sl-SI.json index 8c8ed3254a..a1b5b714af 100644 --- a/Emby.Server.Implementations/Localization/Core/sl-SI.json +++ b/Emby.Server.Implementations/Localization/Core/sl-SI.json @@ -106,5 +106,7 @@ "TaskAudioNormalization": "Normalizacija zvoka", "TaskAudioNormalizationDescription": "Pregled datotek za podatke o normalizaciji zvoka.", "CleanupUserDataTask": "Čiščenje uporabniških podatkov", - "CleanupUserDataTaskDescription": "Izbriše vse uporabniške podatke (stanje ogleda, priljubljene itd.) za vsebine, ki že več kot 90 dni niso na voljo." + "CleanupUserDataTaskDescription": "Izbriše vse uporabniške podatke (stanje ogleda, priljubljene itd.) za vsebine, ki že več kot 90 dni niso na voljo.", + "LyricDownloadFailureFromForItem": "Besedila ni bilo mogoče prenesti iz {0} za {1}", + "Original": "Original" } diff --git a/Emby.Server.Implementations/Localization/Core/sq.json b/Emby.Server.Implementations/Localization/Core/sq.json index b1f76aafbb..e13f7b09e1 100644 --- a/Emby.Server.Implementations/Localization/Core/sq.json +++ b/Emby.Server.Implementations/Localization/Core/sq.json @@ -106,5 +106,7 @@ "TaskAudioNormalization": "Normalizimi i audios", "TaskAudioNormalizationDescription": "Skannon skedarët për të dhëna të normalizimit të audios.", "CleanupUserDataTaskDescription": "Pastron të gjitha të dhënat e përdorueseve (gjendja e shikimit, statusi i të preferuarave etj.) nga mediat që nuk janë më të pranishme për të paktën 90 ditë.", - "CleanupUserDataTask": "Veprim për pastrimin të dhënave të përdorueseve" + "CleanupUserDataTask": "Veprim për pastrimin të dhënave të përdorueseve", + "LyricDownloadFailureFromForItem": "Teksti i këngës nuk arriti të shkarkohej nga {0} për {1}", + "Original": "Origjinal" } diff --git a/Emby.Server.Implementations/Localization/Core/sw.json b/Emby.Server.Implementations/Localization/Core/sw.json index 0967ef424b..c117a0eae2 100644 --- a/Emby.Server.Implementations/Localization/Core/sw.json +++ b/Emby.Server.Implementations/Localization/Core/sw.json @@ -1 +1,5 @@ -{} +{ + "Artists": "Wasanii", + "Books": "Vitabu", + "Collections": "Mikusanyiko" +} diff --git a/Emby.Server.Implementations/Localization/Core/zh-HK.json b/Emby.Server.Implementations/Localization/Core/zh-HK.json index 8b9665cf9a..b2fcbc93a2 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-HK.json +++ b/Emby.Server.Implementations/Localization/Core/zh-HK.json @@ -77,8 +77,8 @@ "TaskCleanLogsDescription": "自動刪走超過 {0} 日嘅紀錄檔。", "TaskCleanLogs": "清理日誌資料夾", "TaskRefreshLibrary": "掃描媒體櫃", - "TaskRefreshChapterImagesDescription": "幫有章節嘅影片整返啲章節縮圖。", - "TaskRefreshChapterImages": "擷取章節圖片", + "TaskRefreshChapterImagesDescription": "幫有章節嘅影片整返啲章節預覽圖。", + "TaskRefreshChapterImages": "擷取章節圖像", "TaskCleanCacheDescription": "刪走系統已經唔再需要嘅快取檔案。", "TaskCleanCache": "清理快取(Cache)資料夾", "TasksChannelsCategory": "網路頻道", @@ -106,5 +106,7 @@ "TaskMoveTrickplayImagesDescription": "根據媒體櫃設定,將現有嘅 Trickplay(快轉預覽)檔案搬去對應位置。", "TaskMoveTrickplayImages": "搬移快轉預覽圖嘅位置", "CleanupUserDataTask": "清理使用者資料嘅任務", - "CleanupUserDataTaskDescription": "清理已消失至少 90 日嘅媒體用家數據(包括觀看狀態、心水狀態等)。" + "CleanupUserDataTaskDescription": "清理已消失至少 90 日嘅媒體用家數據(包括觀看狀態、心水狀態等)。", + "LyricDownloadFailureFromForItem": "冇辦法從 {0} 下載 {1} 嘅歌詞", + "Original": "原始" } diff --git a/Emby.Server.Implementations/Localization/Core/zh-TW.json b/Emby.Server.Implementations/Localization/Core/zh-TW.json index 5dace3b0b7..4e0dff87b9 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-TW.json +++ b/Emby.Server.Implementations/Localization/Core/zh-TW.json @@ -106,5 +106,7 @@ "TaskMoveTrickplayImages": "遷移快轉縮圖位置", "TaskMoveTrickplayImagesDescription": "根據媒體庫的設定遷移快轉縮圖的檔案。", "CleanupUserDataTask": "用戶資料清理工作", - "CleanupUserDataTaskDescription": "從用戶資料中清除已被刪除超過 90 天的媒體的相關資料。" + "CleanupUserDataTaskDescription": "從用戶資料中清除已被刪除超過 90 天的媒體的相關資料。", + "Original": "原作", + "LyricDownloadFailureFromForItem": "無法從 {0} 下載 {1} 的歌詞" } diff --git a/Emby.Server.Implementations/Localization/LocalizationManager.cs b/Emby.Server.Implementations/Localization/LocalizationManager.cs index 843e35afcc..0331ec39e5 100644 --- a/Emby.Server.Implementations/Localization/LocalizationManager.cs +++ b/Emby.Server.Implementations/Localization/LocalizationManager.cs @@ -261,6 +261,24 @@ namespace Emby.Server.Implementations.Localization _cultures); } + /// <inheritdoc /> + public string? GetLanguageDisplayName(string language) + { + if (string.IsNullOrEmpty(language)) + { + return null; + } + + var displayName = FindLanguageInfo(language)?.DisplayName; + if (displayName is null) + { + return null; + } + + // Truncate at the first delimiter to avoid cluttered display names + return displayName.Split([';', ','], StringSplitOptions.None)[0].Trim(); + } + /// <inheritdoc /> public IReadOnlyList<CountryInfo> GetCountries() { @@ -356,6 +374,27 @@ namespace Emby.Server.Implementations.Localization { ArgumentException.ThrowIfNullOrEmpty(rating); + // Some providers may list multiple ratings separated by '/' (e.g. "SE:15 / SE:15+ / SE:Från 15 år"). + // Try each one in order and use the first that resolves. + var ratingValues = rating.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + foreach (var ratingValue in ratingValues) + { + var score = GetSingleRatingScore(ratingValue, countryCode); + if (score is not null) + { + return score; + } + } + + return null; + } + + /// <summary> + /// Resolves a single rating value to a score. + /// </summary> + private ParentalRatingScore? GetSingleRatingScore(string rating, string? countryCode) + { // Handle unrated content if (_unratedValues.Contains(rating.AsSpan(), StringComparison.OrdinalIgnoreCase)) { @@ -364,7 +403,7 @@ namespace Emby.Server.Implementations.Localization // Convert ints directly // This may override some of the locale specific age ratings (but those always map to the same age) - if (int.TryParse(rating, out var ratingAge)) + if (TryParseRatingAsScore(rating, out var ratingAge)) { return new(ratingAge, null); } @@ -466,6 +505,13 @@ namespace Emby.Server.Implementations.Localization return true; } + // If it's not a recognized rating string, fall back to using the number as the score + if (TryParseRatingAsScore(ratingPart, out var numericScore)) + { + result = new ParentalRatingScore(numericScore, null); + return true; + } + _logger.LogWarning( "Rating '{Rating}' not found in the '{CountryCode}' rating system, treating as unrated", rating, @@ -480,6 +526,18 @@ namespace Emby.Server.Implementations.Localization return true; } + /// <summary> + /// Tries to parse a rating as a number, allowing an optional trailing '+' (e.g. "16" or "18+"). + /// </summary> + /// <param name="ratingValue">Rating value to parse.</param> + /// <param name="score">Parsed score.</param> + /// <returns>Returns true if parsing was successful.</returns> + private static bool TryParseRatingAsScore(ReadOnlySpan<char> ratingValue, out int score) + { + var trimmed = ratingValue.TrimEnd('+'); + return int.TryParse(trimmed, out score); + } + /// <inheritdoc /> public string GetLocalizedString(string phrase) { @@ -566,11 +624,15 @@ namespace Emby.Server.Implementations.Localization private static string GetResourceFilename(string culture) { - var parts = culture.Split('-'); + // Region codes may use a '-' (BCP-47, e.g. "pt-BR") or '_' (e.g. "es_419", "ar_SA") separator. + // Normalize the casing (lower-case language, upper-case region) while preserving the separator + // so the result matches the embedded resource file name, which is case-sensitive. + var separatorIndex = culture.IndexOfAny(['-', '_']); - if (parts.Length == 2) + if (separatorIndex > 0) { - culture = parts[0].ToLowerInvariant() + "-" + parts[1].ToUpperInvariant(); + var separator = culture[separatorIndex]; + culture = culture[..separatorIndex].ToLowerInvariant() + separator + culture[(separatorIndex + 1)..].ToUpperInvariant(); } else { diff --git a/Emby.Server.Implementations/Localization/Ratings/gr.json b/Emby.Server.Implementations/Localization/Ratings/gr.json index 794bf0b313..73abab72d3 100644 --- a/Emby.Server.Implementations/Localization/Ratings/gr.json +++ b/Emby.Server.Implementations/Localization/Ratings/gr.json @@ -10,21 +10,42 @@ } }, { - "ratingStrings": ["K12"], + "ratingStrings": ["K12", "K-12", "12"], + "ratingScore": { + "score": 12, + "subScore": null + } + }, + { + "ratingStrings": ["K13", "K-13", "13"], "ratingScore": { "score": 13, "subScore": null } }, { - "ratingStrings": ["K15"], + "ratingStrings": ["K15", "K-15", "15"], "ratingScore": { "score": 15, "subScore": null } }, { - "ratingStrings": ["K18"], + "ratingStrings": ["K16", "K-16", "16"], + "ratingScore": { + "score": 16, + "subScore": null + } + }, + { + "ratingStrings": ["K17", "K-17", "17"], + "ratingScore": { + "score": 17, + "subScore": null + } + }, + { + "ratingStrings": ["K18", "K-18", "18", "18+"], "ratingScore": { "score": 18, "subScore": null diff --git a/Emby.Server.Implementations/Localization/Ratings/se.json b/Emby.Server.Implementations/Localization/Ratings/se.json index 70084995d1..818565e16b 100644 --- a/Emby.Server.Implementations/Localization/Ratings/se.json +++ b/Emby.Server.Implementations/Localization/Ratings/se.json @@ -10,7 +10,7 @@ } }, { - "ratingStrings": ["7"], + "ratingStrings": ["7", "7+", "7 År", "Från 7 år"], "ratingScore": { "score": 7, "subScore": null @@ -31,7 +31,7 @@ } }, { - "ratingStrings": ["11"], + "ratingStrings": ["11", "11+", "11 År", "Från 11 år"], "ratingScore": { "score": 11, "subScore": null @@ -45,11 +45,18 @@ } }, { - "ratingStrings": ["15"], + "ratingStrings": ["15", "15+", "15 År", "Från 15 år"], "ratingScore": { "score": 15, "subScore": null } + }, + { + "ratingStrings": ["18", "18+", "Barnförbjuden", "Bfj"], + "ratingScore": { + "score": 18, + "subScore": null + } } ] } diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs index b2dc89be28..e4939205c9 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs @@ -4,6 +4,7 @@ using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; +using System.Text; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; @@ -234,7 +235,7 @@ public partial class AudioNormalizationTask : IScheduledTask { FileName = _mediaEncoder.EncoderPath, Arguments = args, - RedirectStandardOutput = false, + StandardErrorEncoding = Encoding.UTF8, RedirectStandardError = true }, }) diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs index 92d7a3907a..8d133dc074 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Jellyfin.Database.Implementations; +using MediaBrowser.Controller.Library; using MediaBrowser.Model.Globalization; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; @@ -17,6 +18,7 @@ public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask private readonly ILogger<OptimizeDatabaseTask> _logger; private readonly ILocalizationManager _localization; private readonly IJellyfinDatabaseProvider _jellyfinDatabaseProvider; + private readonly ILibraryManager _libraryManager; /// <summary> /// Initializes a new instance of the <see cref="OptimizeDatabaseTask" /> class. @@ -24,14 +26,17 @@ public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param> /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param> /// <param name="jellyfinDatabaseProvider">Instance of the JellyfinDatabaseProvider that can be used for provider specific operations.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> public OptimizeDatabaseTask( ILogger<OptimizeDatabaseTask> logger, ILocalizationManager localization, - IJellyfinDatabaseProvider jellyfinDatabaseProvider) + IJellyfinDatabaseProvider jellyfinDatabaseProvider, + ILibraryManager libraryManager) { _logger = logger; _localization = localization; _jellyfinDatabaseProvider = jellyfinDatabaseProvider; + _libraryManager = libraryManager; } /// <inheritdoc /> @@ -68,6 +73,15 @@ public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask /// <inheritdoc /> public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { + // Vacuuming/checkpointing requires an exclusive lock on the database. Running it while a library scan is in + // progress causes both operations to contend for the database and can stall the scan, so defer optimization + // until no scan is running. The task will run again on its next trigger. + if (_libraryManager.IsScanRunning) + { + _logger.LogInformation("Skipping database optimization because a library scan is currently running."); + return; + } + _logger.LogInformation("Optimizing and vacuuming jellyfin.db..."); try diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs index 6e4e5c7808..dff9a473af 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs @@ -4,11 +4,18 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Globalization; +using MediaBrowser.Model.IO; using MediaBrowser.Model.Tasks; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.ScheduledTasks.Tasks; @@ -20,6 +27,9 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask private readonly ILibraryManager _libraryManager; private readonly ILocalizationManager _localization; private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory; + private readonly IFileSystem _fileSystem; + private readonly ILogger<PeopleValidationTask> _logger; + private readonly IItemTypeLookup _itemTypeLookup; /// <summary> /// Initializes a new instance of the <see cref="PeopleValidationTask" /> class. @@ -27,11 +37,23 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param> /// <param name="dbContextFactory">Instance of the <see cref="IDbContextFactory{TContext}"/> interface.</param> - public PeopleValidationTask(ILibraryManager libraryManager, ILocalizationManager localization, IDbContextFactory<JellyfinDbContext> dbContextFactory) + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="logger">Instance of the <see cref="ILogger{PeopleValidationTask}"/> interface.</param> + /// <param name="itemTypeLookup">Instance of the <see cref="IItemTypeLookup"/> interface.</param> + public PeopleValidationTask( + ILibraryManager libraryManager, + ILocalizationManager localization, + IDbContextFactory<JellyfinDbContext> dbContextFactory, + IFileSystem fileSystem, + ILogger<PeopleValidationTask> logger, + IItemTypeLookup itemTypeLookup) { _libraryManager = libraryManager; _localization = localization; _dbContextFactory = dbContextFactory; + _fileSystem = fileSystem; + _logger = logger; + _itemTypeLookup = itemTypeLookup; } /// <inheritdoc /> @@ -71,13 +93,19 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask /// <inheritdoc /> public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { - IProgress<double> subProgress = new Progress<double>((val) => progress.Report(val / 2)); - await _libraryManager.ValidatePeopleAsync(subProgress, cancellationToken).ConfigureAwait(false); + // People validation performs heavy database writes that contend with an active library scan. + // Defer it until the scan has finished; the task will run again on its next trigger. + if (_libraryManager.IsScanRunning) + { + _logger.LogInformation("Skipping people validation because a library scan is currently running."); + return; + } - subProgress = new Progress<double>((val) => progress.Report((val / 2) + 50)); + // Phase 1: Deduplicate and remove orphaned people (0-33%) var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); await using (context.ConfigureAwait(false)) { + IProgress<double> subProgress = new Progress<double>((val) => progress.Report(val / 3)); var dupQuery = context.Peoples .GroupBy(e => new { e.Name, e.PersonType }) .Where(e => e.Count() > 1) @@ -123,7 +151,113 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask ArrayPool<Guid[]>.Shared.Return(buffer); } + var peopleToDelete = await context.Peoples + .Where(p => !context.PeopleBaseItemMap.Any(m => m.PeopleId.Equals(p.Id))) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + _logger.LogInformation("Removed {Count} orphaned people.", peopleToDelete); + subProgress.Report(100); } + + // Phase 2: Validate people (33-66%). Runs after orphaned PeopleBaseItemMap entries are + // cleaned up above, so dead people are removed in a single pass instead of requiring a second run. + IProgress<double> validateProgress = new Progress<double>((val) => progress.Report((val / 3) + 33)); + await _libraryManager.ValidatePeopleAsync(validateProgress, cancellationToken).ConfigureAwait(false); + + // Phase 3: Refresh images for people missing them (66-100%) + IProgress<double> refreshProgress = new Progress<double>((val) => progress.Report((val / 3) + 66)); + await RefreshPeopleImagesAsync(refreshProgress, cancellationToken).ConfigureAwait(false); + + progress.Report(100); + } + + private async Task RefreshPeopleImagesAsync(IProgress<double> progress, CancellationToken cancellationToken) + { + var thirtyDaysAgo = DateTime.UtcNow.AddDays(-30); + var personTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Person]; + + var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (context.ConfigureAwait(false)) + { + const int PartitionSize = 100; + + var numPeople = await context.BaseItems + .AsNoTracking() + .Where(b => b.Type == personTypeName) + .Where(b => b.DateLastRefreshed == null || b.DateLastRefreshed < thirtyDaysAgo) + .Where(b => + !b.Images!.Any(i => i.ImageType == ImageInfoImageType.Primary) || + string.IsNullOrEmpty(b.Overview)) + .CountAsync(cancellationToken) + .ConfigureAwait(false); + + _logger.LogDebug("Found {Count} people needing image/overview refresh", numPeople); + + if (numPeople == 0) + { + progress.Report(100); + return; + } + + var numComplete = 0; + var numRefreshed = 0; + + await foreach (var entry in context.BaseItems + .AsNoTracking() + .Where(b => b.Type == personTypeName) + .Where(b => b.DateLastRefreshed == null || b.DateLastRefreshed < thirtyDaysAgo) + .Where(b => + !b.Images!.Any(i => i.ImageType == ImageInfoImageType.Primary) || + string.IsNullOrEmpty(b.Overview)) + .OrderBy(b => b.Id) + .WithPartitionProgress(partition => _logger.LogDebug("Processing people partition {Partition}", partition)) + .PartitionEagerAsync(PartitionSize, cancellationToken) + .WithCancellation(cancellationToken) + .ConfigureAwait(false)) + { + if (await RefreshPersonAsync(entry.Id, cancellationToken).ConfigureAwait(false)) + { + numRefreshed++; + } + + numComplete++; + progress.Report(100.0 * numComplete / numPeople); + } + + _logger.LogInformation("Refreshed metadata for {Count} people missing images or overview", numRefreshed); + } + } + + private async Task<bool> RefreshPersonAsync(Guid personId, CancellationToken cancellationToken) + { + try + { + if (_libraryManager.GetItemById(personId) is not Person item) + { + return false; + } + + var hasImage = item.HasImage(MediaBrowser.Model.Entities.ImageType.Primary); + var hasOverview = !string.IsNullOrEmpty(item.Overview); + + var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem)) + { + ImageRefreshMode = hasImage ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.Default, + MetadataRefreshMode = hasOverview ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.Default + }; + + await item.RefreshMetadata(options, cancellationToken).ConfigureAwait(false); + return true; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error refreshing images for person {PersonId}", personId); + return false; + } } } diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs index 18811ef3a9..5d62332552 100644 --- a/Emby.Server.Implementations/Session/SessionManager.cs +++ b/Emby.Server.Implementations/Session/SessionManager.cs @@ -343,6 +343,10 @@ namespace Emby.Server.Implementations.Session _activeLiveStreamSessions.TryRemove(liveStreamId, out _); } } + else + { + liveStreamNeedsToBeClosed = true; + } if (liveStreamNeedsToBeClosed) { @@ -637,8 +641,7 @@ namespace Emby.Server.Implementations.Session if (playingSessions.Count > 0) { var idle = playingSessions - .Where(i => (DateTime.UtcNow - i.LastPlaybackCheckIn).TotalMinutes > 5) - .ToList(); + .Where(i => (DateTime.UtcNow - i.LastPlaybackCheckIn).TotalMinutes > 5); foreach (var session in idle) { @@ -725,6 +728,31 @@ namespace Emby.Server.Implementations.Session return item; } + /// <summary> + /// Resolves the item whose user data (playback position, played status) should be updated + /// for a playback report. When an alternate version is played the client reports the displayed + /// item as <c>ItemId</c> and the played version as <c>MediaSourceId</c>. + /// </summary> + /// <param name="libraryItem">The now playing (displayed) item.</param> + /// <param name="mediaSourceId">The reported media source id.</param> + /// <returns>The item to track progress against.</returns> + private BaseItem GetProgressItem(BaseItem libraryItem, string mediaSourceId) + { + if (libraryItem is Video libraryVideo + && !string.IsNullOrEmpty(mediaSourceId) + && Guid.TryParse(mediaSourceId, out var mediaSourceItemId) + && !mediaSourceItemId.Equals(libraryVideo.Id)) + { + var versionItem = libraryVideo.GetAlternateVersion(mediaSourceItemId); + if (versionItem is not null) + { + return versionItem; + } + } + + return libraryItem; + } + /// <summary> /// Used to report that playback has started for an item. /// </summary> @@ -756,9 +784,10 @@ namespace Emby.Server.Implementations.Session if (libraryItem is not null) { + var progressItem = GetProgressItem(libraryItem, info.MediaSourceId); foreach (var user in users) { - OnPlaybackStart(user, libraryItem); + OnPlaybackStart(user, progressItem); } } @@ -890,9 +919,10 @@ namespace Emby.Server.Implementations.Session // only update saved user data on actual check-ins, not automated ones if (libraryItem is not null && !isAutomated) { + var progressItem = GetProgressItem(libraryItem, info.MediaSourceId); foreach (var user in users) { - OnPlaybackProgress(user, libraryItem, info); + OnPlaybackProgress(user, progressItem, info); } } @@ -952,6 +982,20 @@ namespace Emby.Server.Implementations.Session if (changed) { _userDataManager.SaveUserData(user, item, data, UserDataSaveReason.PlaybackProgress, CancellationToken.None); + + // A completed version marks every alternate version played and clears their resume points, so the + // whole movie leaves Continue Watching and reads as watched everywhere. (Per-version resume positions + // only persist while nothing has been completed yet.) + if (data.Played == true && item is Video playedVideo) + { + playedVideo.PropagatePlayedState(user, true); + } + } + + if ((!user.RememberAudioSelections && info.AudioStreamIndex.HasValue) + || (!user.RememberSubtitleSelections && info.SubtitleStreamIndex.HasValue)) + { + _userDataManager.ResetPlaybackStreamSelections(user, item); } } @@ -1083,9 +1127,10 @@ namespace Emby.Server.Implementations.Session if (libraryItem is not null) { + var progressItem = GetProgressItem(libraryItem, info.MediaSourceId); foreach (var user in users) { - playedToCompletion = OnPlaybackStopped(user, libraryItem, info.PositionTicks, info.Failed); + playedToCompletion = OnPlaybackStopped(user, progressItem, info.PositionTicks, info.Failed); } } @@ -1138,6 +1183,14 @@ namespace Emby.Server.Implementations.Session _userDataManager.SaveUserData(user, item, data, UserDataSaveReason.PlaybackFinished, CancellationToken.None); + // A completed version marks every alternate version played and clears their resume points, so the + // whole movie leaves Continue Watching and reads as watched everywhere. (Per-version resume positions + // only persist while nothing has been completed yet.) + if (data.Played == true && item is Video playedVideo) + { + playedVideo.PropagatePlayedState(user, true); + } + return playedToCompletion; } diff --git a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs index 6a26e92e14..2582ed9df0 100644 --- a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs +++ b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs @@ -246,8 +246,21 @@ namespace Emby.Server.Implementations.Session _logger.LogInformation("Lost {0} WebSockets.", lost.Count); foreach (var webSocket in lost) { - // TODO: handle session relative to the lost webSocket RemoveWebSocket(webSocket); + + // The connection stopped answering keep-alives, so a close frame will + // never arrive and the pending receive loop would hang forever, keeping + // the session (and e.g. its SyncPlay group membership) alive. Disposing + // the connection aborts the receive loop, which raises Closed and lets + // the session end normally. + try + { + webSocket.Dispose(); + } + catch (Exception exception) + { + _logger.LogWarning(exception, "Error disposing lost WebSocket from {RemoteEndPoint}.", webSocket.RemoteEndPoint); + } } } } diff --git a/Emby.Server.Implementations/Sorting/DateLastMediaAddedComparer.cs b/Emby.Server.Implementations/Sorting/DateLastMediaAddedComparer.cs index f10e7fcbb7..4159f8cf7d 100644 --- a/Emby.Server.Implementations/Sorting/DateLastMediaAddedComparer.cs +++ b/Emby.Server.Implementations/Sorting/DateLastMediaAddedComparer.cs @@ -3,34 +3,14 @@ using System; using Jellyfin.Data.Enums; -using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Sorting; using MediaBrowser.Model.Querying; namespace Emby.Server.Implementations.Sorting { - public class DateLastMediaAddedComparer : IUserBaseItemComparer + public class DateLastMediaAddedComparer : IBaseItemComparer { - /// <summary> - /// Gets or sets the user. - /// </summary> - /// <value>The user.</value> - public User User { get; set; } - - /// <summary> - /// Gets or sets the user manager. - /// </summary> - /// <value>The user manager.</value> - public IUserManager UserManager { get; set; } - - /// <summary> - /// Gets or sets the user data manager. - /// </summary> - /// <value>The user data manager.</value> - public IUserDataManager UserDataManager { get; set; } - /// <summary> /// Gets the name. /// </summary> diff --git a/Emby.Server.Implementations/SyncPlay/Group.cs b/Emby.Server.Implementations/SyncPlay/Group.cs index c2e834ad58..38a0018a70 100644 --- a/Emby.Server.Implementations/SyncPlay/Group.cs +++ b/Emby.Server.Implementations/SyncPlay/Group.cs @@ -206,7 +206,8 @@ namespace Emby.Server.Implementations.SyncPlay foreach (var itemId in queue) { var item = _libraryManager.GetItemById(itemId); - if (!item.IsVisibleStandalone(user)) + + if (item is null || !item.IsVisibleStandalone(user)) { return false; } diff --git a/Emby.Server.Implementations/TV/TVSeriesManager.cs b/Emby.Server.Implementations/TV/TVSeriesManager.cs index 535dc01a31..459ad1a17e 100644 --- a/Emby.Server.Implementations/TV/TVSeriesManager.cs +++ b/Emby.Server.Implementations/TV/TVSeriesManager.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using Jellyfin.Data; using Jellyfin.Data.Enums; @@ -136,11 +137,15 @@ namespace Emby.Server.Implementations.TV if (nextEpisode is not null) { + // The last played date and the version that was actually played live on the version item's user data + // The played state propagated to the sibling versions carries no date + var (playedVersion, lastPlayedDate) = GetMostRecentlyPlayedVersion(result.LastWatched, user); + nextEpisode = GetPreferredVersion(nextEpisode, result.LastWatched, playedVersion); + DateTime lastWatchedDate = DateTime.MinValue; if (result.LastWatched is not null) { - var userData = _userDataManager.GetUserData(user, result.LastWatched); - lastWatchedDate = userData?.LastPlayedDate ?? DateTime.MinValue.AddDays(1); + lastWatchedDate = lastPlayedDate ?? DateTime.MinValue.AddDays(1); } nextUpList.Add((lastWatchedDate, nextEpisode)); @@ -152,11 +157,13 @@ namespace Emby.Server.Implementations.TV if (nextPlayedEpisode is not null) { + var (playedVersion, lastPlayedDate) = GetMostRecentlyPlayedVersion(result.LastWatchedForRewatching, user); + nextPlayedEpisode = GetPreferredVersion(nextPlayedEpisode, result.LastWatchedForRewatching, playedVersion); + DateTime rewatchLastWatchedDate = DateTime.MinValue; if (result.LastWatchedForRewatching is not null) { - var userData = _userDataManager.GetUserData(user, result.LastWatchedForRewatching); - rewatchLastWatchedDate = userData?.LastPlayedDate ?? DateTime.MinValue.AddDays(1); + rewatchLastWatchedDate = lastPlayedDate ?? DateTime.MinValue.AddDays(1); } nextUpList.Add((rewatchLastWatchedDate, nextPlayedEpisode)); @@ -219,10 +226,13 @@ namespace Emby.Server.Implementations.TV if (nextEpisode is not null && !includeResumable) { - var userData = _userDataManager.GetUserData(user, nextEpisode); - if (userData?.PlaybackPositionTicks > 0) + // The resume progress may live on an alternate version + foreach (var version in nextEpisode.GetAllVersions()) { - return null; + if (_userDataManager.GetUserData(user, version)?.PlaybackPositionTicks > 0) + { + return null; + } } } @@ -237,6 +247,74 @@ namespace Emby.Server.Implementations.TV return DetermineNextEpisode(result, user, includeSpecials, includeResumable: false, includePlayed: true); } + /// <summary> + /// Gets the version of the last watched episode that was actually played, together with its last played date. + /// The version that was played carries the most recent LastPlayedDate. + /// dates. + /// </summary> + /// <param name="lastWatched">The last watched episode (any version).</param> + /// <param name="user">The user.</param> + /// <returns>The played version and its last played date.</returns> + private (Video? PlayedVersion, DateTime? LastPlayedDate) GetMostRecentlyPlayedVersion(BaseItem? lastWatched, User user) + { + if (lastWatched is not Video lastWatchedVideo) + { + return (null, null); + } + + var versions = lastWatchedVideo.GetAllVersions(); + var userDataByVersion = _userDataManager.GetUserDataBatch(versions, user); + + var playedVersion = VersionPlaybackSelector.SelectMostRecentlyPlayed( + versions, + version => userDataByVersion.GetValueOrDefault(version.Id), + data => data.LastPlayedDate.HasValue); + + return (playedVersion, playedVersion is null ? null : userDataByVersion[playedVersion.Id].LastPlayedDate); + } + + /// <summary> + /// When the last watched episode was played as an alternate version, prefer the next episode's version with the matching name, + /// so Next Up continues in the version the user has been watching instead of falling back to the primary. + /// </summary> + /// <param name="nextEpisode">The determined next episode (a primary).</param> + /// <param name="lastWatched">The last watched episode.</param> + /// <param name="playedVersion">The version of the last watched episode that was played.</param> + /// <returns>The matching version of the next episode, or the episode itself.</returns> + private Episode GetPreferredVersion(Episode nextEpisode, BaseItem? lastWatched, Video? playedVersion) + { + // No version preference, or the primary was played + if (lastWatched is not Video lastWatchedVideo + || playedVersion is null + || !playedVersion.PrimaryVersionId.HasValue) + { + return nextEpisode; + } + + // Match by version name + var playedVersionId = playedVersion.Id.ToString("N", CultureInfo.InvariantCulture); + var playedVersionName = lastWatchedVideo.GetMediaSources(false) + .FirstOrDefault(source => string.Equals(source.Id, playedVersionId, StringComparison.OrdinalIgnoreCase))?.Name; + + if (string.IsNullOrEmpty(playedVersionName)) + { + return nextEpisode; + } + + var matchingSource = nextEpisode.GetMediaSources(false) + .FirstOrDefault(source => string.Equals(source.Name, playedVersionName, StringComparison.OrdinalIgnoreCase)); + + if (matchingSource is not null + && Guid.TryParse(matchingSource.Id, out var matchingId) + && !matchingId.Equals(nextEpisode.Id) + && _libraryManager.GetItemById<Episode>(matchingId) is { } matchingVersion) + { + return matchingVersion; + } + + return nextEpisode; + } + private static string GetUniqueSeriesKey(Series series) { return series.GetPresentationUniqueKey(); diff --git a/Emby.Server.Implementations/Updates/InstallationManager.cs b/Emby.Server.Implementations/Updates/InstallationManager.cs index ef53e3b326..6a60f7f5f6 100644 --- a/Emby.Server.Implementations/Updates/InstallationManager.cs +++ b/Emby.Server.Implementations/Updates/InstallationManager.cs @@ -1,4 +1,5 @@ using System; +using System.Buffers; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; @@ -32,6 +33,8 @@ namespace Emby.Server.Implementations.Updates /// </summary> public class InstallationManager : IInstallationManager { + private static readonly SearchValues<char> InvalidPackageNameChars = SearchValues.Create([.. Path.GetInvalidFileNameChars(), '/', '\\']); + /// <summary> /// The logger. /// </summary> @@ -521,9 +524,27 @@ namespace Emby.Server.Implementations.Updates return; } + if (!IsValidPackageDirectoryName(package.Name)) + { + _logger.LogError("Refusing to install package with invalid name {PackageName}.", package.Name); + throw new InvalidDataException($"Plugin package name '{package.Name}' is not a valid directory name."); + } + // Always override the passed-in target (which is a file) and figure it out again string targetDir = Path.Combine(_appPaths.PluginsPath, package.Name); + var pluginsRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(_appPaths.PluginsPath)); + var resolvedTarget = Path.GetFullPath(targetDir); + if (!resolvedTarget.StartsWith(pluginsRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogError( + "Refusing to install package {PackageName}: resolved target {Resolved} is outside plugins directory {Root}.", + package.Name, + resolvedTarget, + pluginsRoot); + throw new InvalidDataException($"Plugin package name '{package.Name}' resolves outside the plugins directory."); + } + using var response = await _httpClientFactory.CreateClient(NamedClient.Default) .GetAsync(new Uri(package.SourceUrl), cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); @@ -572,6 +593,26 @@ namespace Emby.Server.Implementations.Updates _pluginManager.ImportPluginFrom(targetDir); } + private static bool IsValidPackageDirectoryName(string? name) + { + if (string.IsNullOrWhiteSpace(name)) + { + return false; + } + + if (name.Equals(".", StringComparison.Ordinal) || name.Equals("..", StringComparison.Ordinal)) + { + return false; + } + + if (name.IndexOfAny(InvalidPackageNameChars) >= 0) + { + return false; + } + + return true; + } + private async Task<bool> InstallPackageInternal(InstallationInfo package, CancellationToken cancellationToken) { LocalPlugin? plugin = _pluginManager.Plugins.FirstOrDefault(p => p.Id.Equals(package.Id) && p.Version.Equals(package.Version)) diff --git a/Jellyfin.Api/Controllers/ActivityLogController.cs b/Jellyfin.Api/Controllers/ActivityLogController.cs index d6cc0e71a4..8e17dd7d53 100644 --- a/Jellyfin.Api/Controllers/ActivityLogController.cs +++ b/Jellyfin.Api/Controllers/ActivityLogController.cs @@ -1,6 +1,6 @@ using System; -using System.Collections.Generic; using System.Threading.Tasks; +using Jellyfin.Api.Helpers; using Jellyfin.Data.Enums; using Jellyfin.Data.Queries; using Jellyfin.Database.Implementations.Enums; @@ -84,36 +84,9 @@ public class ActivityLogController : BaseJellyfinApiController ItemId = itemId, Username = username, Severity = severity, - OrderBy = GetOrderBy(sortBy ?? [], sortOrder ?? []), + OrderBy = RequestHelpers.GetOrderBy(sortBy ?? [], sortOrder ?? []), }; return await _activityManager.GetPagedResultAsync(query).ConfigureAwait(false); } - - private static (ActivityLogSortBy SortBy, SortOrder SortOrder)[] GetOrderBy( - IReadOnlyList<ActivityLogSortBy> sortBy, - IReadOnlyList<SortOrder> requestedSortOrder) - { - if (sortBy.Count == 0) - { - return []; - } - - var result = new (ActivityLogSortBy, SortOrder)[sortBy.Count]; - var i = 0; - for (; i < requestedSortOrder.Count; i++) - { - result[i] = (sortBy[i], requestedSortOrder[i]); - } - - // Add remaining elements with the first specified SortOrder - // or the default one if no SortOrders are specified - var order = requestedSortOrder.Count > 0 ? requestedSortOrder[0] : SortOrder.Ascending; - for (; i < sortBy.Count; i++) - { - result[i] = (sortBy[i], order); - } - - return result; - } } diff --git a/Jellyfin.Api/Controllers/FilterController.cs b/Jellyfin.Api/Controllers/FilterController.cs index cfc8be28ae..b458bd90f3 100644 --- a/Jellyfin.Api/Controllers/FilterController.cs +++ b/Jellyfin.Api/Controllers/FilterController.cs @@ -158,13 +158,43 @@ public class FilterController : BaseJellyfinApiController IsSeries = isSeries }; + var streamLanguageQuery = new InternalItemsQuery(user) + { + // It's possible that different langauges are only available on alternative versions. + // To fetch them all, owned items are included. + IncludeOwnedItems = true, + IncludeItemTypes = includeItemTypes, + DtoOptions = new DtoOptions + { + Fields = Array.Empty<ItemFields>(), + EnableImages = false, + EnableUserData = false + }, + IsAiring = isAiring, + IsMovie = isMovie, + IsSports = isSports, + IsKids = isKids, + IsNews = isNews, + IsSeries = isSeries + }; + if ((recursive ?? true) || parentItem is UserView || parentItem is ICollectionFolder) { - genreQuery.AncestorIds = parentItem is null ? Array.Empty<Guid>() : new[] { parentItem.Id }; + var ancestorIds = parentItem is null ? Array.Empty<Guid>() : new[] { parentItem.Id }; + genreQuery.AncestorIds = ancestorIds; + streamLanguageQuery.AncestorIds = ancestorIds; } else { genreQuery.Parent = parentItem; + streamLanguageQuery.Parent = parentItem; + } + + if ((includeItemTypes.Contains(BaseItemKind.Series) || includeItemTypes.Contains(BaseItemKind.Season)) + && !includeItemTypes.Contains(BaseItemKind.Episode)) + { + // streams are joined on epsiodes not shows or seasons + streamLanguageQuery.IncludeItemTypes = [.. includeItemTypes, BaseItemKind.Episode]; } if (includeItemTypes.Length == 1 @@ -188,10 +218,13 @@ public class FilterController : BaseJellyfinApiController }).ToArray(); } - if (includeItemTypes.Contains(BaseItemKind.Movie) || includeItemTypes.Contains(BaseItemKind.Series)) + if (includeItemTypes.Contains(BaseItemKind.Movie) + || includeItemTypes.Contains(BaseItemKind.Series) + || includeItemTypes.Contains(BaseItemKind.Season) + || includeItemTypes.Contains(BaseItemKind.Episode)) { filters.AudioLanguages = _libraryManager - .GetMediaStreamLanguages(MediaStreamType.Audio) + .GetMediaStreamLanguages(MediaStreamType.Audio, streamLanguageQuery) .Select(language => { var culture = _localization.FindLanguageInfo(language); @@ -204,7 +237,7 @@ public class FilterController : BaseJellyfinApiController .OrderBy(l => l.Name) .ToArray(); filters.SubtitleLanguages = _libraryManager - .GetMediaStreamLanguages(MediaStreamType.Subtitle) + .GetMediaStreamLanguages(MediaStreamType.Subtitle, streamLanguageQuery) .Select(language => { var culture = _localization.FindLanguageInfo(language); diff --git a/Jellyfin.Api/Controllers/HlsSegmentController.cs b/Jellyfin.Api/Controllers/HlsSegmentController.cs index b5365cd632..c61ee45830 100644 --- a/Jellyfin.Api/Controllers/HlsSegmentController.cs +++ b/Jellyfin.Api/Controllers/HlsSegmentController.cs @@ -60,11 +60,8 @@ public class HlsSegmentController : BaseJellyfinApiController public ActionResult GetHlsAudioSegmentLegacy([FromRoute, Required] string itemId, [FromRoute, Required] string segmentId) { // TODO: Deprecate with new iOS app - var file = string.Concat(segmentId, Path.GetExtension(Request.Path.Value.AsSpan())); - var transcodePath = _serverConfigurationManager.GetTranscodePath(); - file = Path.GetFullPath(Path.Combine(transcodePath, file)); - var fileDir = Path.GetDirectoryName(file); - if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodePath, StringComparison.InvariantCulture)) + var file = ValidateTranscodePath(string.Concat(segmentId, Path.GetExtension(Request.Path.Value.AsSpan()))); + if (file is null) { return BadRequest("Invalid segment."); } @@ -86,12 +83,9 @@ public class HlsSegmentController : BaseJellyfinApiController [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")] public ActionResult GetHlsPlaylistLegacy([FromRoute, Required] string itemId, [FromRoute, Required] string playlistId) { - var file = string.Concat(playlistId, Path.GetExtension(Request.Path.Value.AsSpan())); - var transcodePath = _serverConfigurationManager.GetTranscodePath(); - file = Path.GetFullPath(Path.Combine(transcodePath, file)); - var fileDir = Path.GetDirectoryName(file); - if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodePath, StringComparison.InvariantCulture) - || Path.GetExtension(file.AsSpan()).Equals(".m3u8", StringComparison.OrdinalIgnoreCase)) + var file = ValidateTranscodePath(string.Concat(playlistId, Path.GetExtension(Request.Path.Value.AsSpan()))); + if (file is null + || !Path.GetExtension(file.AsSpan()).Equals(".m3u8", StringComparison.OrdinalIgnoreCase)) { return BadRequest("Invalid segment."); } @@ -140,18 +134,13 @@ public class HlsSegmentController : BaseJellyfinApiController [FromRoute, Required] string segmentId, [FromRoute, Required] string segmentContainer) { - var file = string.Concat(segmentId, Path.GetExtension(Request.Path.Value.AsSpan())); - var transcodeFolderPath = _serverConfigurationManager.GetTranscodePath(); - - file = Path.GetFullPath(Path.Combine(transcodeFolderPath, file)); - var fileDir = Path.GetDirectoryName(file); - if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodeFolderPath, StringComparison.InvariantCulture)) + var file = ValidateTranscodePath(string.Concat(segmentId, Path.GetExtension(Request.Path.Value.AsSpan()))); + if (file is null) { return BadRequest("Invalid segment."); } - var normalizedPlaylistId = playlistId; - + var transcodeFolderPath = _serverConfigurationManager.GetTranscodePath(); var filePaths = _fileSystem.GetFilePaths(transcodeFolderPath); // Add . to start of segment container for future use. segmentContainer = segmentContainer.Insert(0, "."); @@ -161,7 +150,7 @@ public class HlsSegmentController : BaseJellyfinApiController var pathExtension = Path.GetExtension(path); if ((string.Equals(pathExtension, segmentContainer, StringComparison.OrdinalIgnoreCase) || string.Equals(pathExtension, ".m3u8", StringComparison.OrdinalIgnoreCase)) - && path.Contains(normalizedPlaylistId, StringComparison.OrdinalIgnoreCase)) + && path.Contains(playlistId, StringComparison.OrdinalIgnoreCase)) { playlistPath = path; break; @@ -173,6 +162,19 @@ public class HlsSegmentController : BaseJellyfinApiController : GetFileResult(file, playlistPath); } + private string? ValidateTranscodePath(string filename) + { + var transcodePath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(_serverConfigurationManager.GetTranscodePath())); + var file = Path.GetFullPath(filename, transcodePath); + // Require a separator after the transcode path so a sibling like "<transcodePath>-evil" can't pass. + if (!file.StartsWith(transcodePath + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + return file; + } + private ActionResult GetFileResult(string path, string playlistPath) { var transcodingJob = _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType.Hls); diff --git a/Jellyfin.Api/Controllers/ImageController.cs b/Jellyfin.Api/Controllers/ImageController.cs index ae792142b4..d492a2f5ba 100644 --- a/Jellyfin.Api/Controllers/ImageController.cs +++ b/Jellyfin.Api/Controllers/ImageController.cs @@ -125,7 +125,13 @@ public class ImageController : BaseJellyfinApiController { // Handle image/png; charset=utf-8 var mimeType = Request.ContentType?.Split(';').FirstOrDefault(); - var userDataPath = Path.Combine(_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username); + var userConfigurationDirectoryPath = _serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath; + var userDataPath = Path.Combine(userConfigurationDirectoryPath, user.Username); + if (!PathHelper.IsContainedIn(userConfigurationDirectoryPath, userDataPath)) + { + return BadRequest("Invalid user."); + } + if (user.ProfileImage is not null) { await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false); @@ -2049,7 +2055,7 @@ public class ImageController : BaseJellyfinApiController } // Check If-Modified-Since header for time-based validation - if (DateTime.TryParse(Request.Headers[HeaderNames.IfModifiedSince], out var ifModifiedSinceHeader)) + if (DateTime.TryParse(Request.Headers[HeaderNames.IfModifiedSince], CultureInfo.InvariantCulture, out var ifModifiedSinceHeader)) { // Return 304 if the image has not been modified since the client's cached version if (dateImageModified <= ifModifiedSinceHeader) diff --git a/Jellyfin.Api/Controllers/ItemUpdateController.cs b/Jellyfin.Api/Controllers/ItemUpdateController.cs index d560ee8238..36c82cf461 100644 --- a/Jellyfin.Api/Controllers/ItemUpdateController.cs +++ b/Jellyfin.Api/Controllers/ItemUpdateController.cs @@ -236,7 +236,7 @@ public class ItemUpdateController : BaseJellyfinApiController return NoContent(); } - private async Task UpdateItem(BaseItemDto request, BaseItem item) + internal async Task UpdateItem(BaseItemDto request, BaseItem item) { item.Name = request.Name; item.ForcedSortName = request.ForcedSortName; @@ -250,7 +250,11 @@ public class ItemUpdateController : BaseJellyfinApiController item.IndexNumber = request.IndexNumber; item.ParentIndexNumber = request.ParentIndexNumber; item.Overview = request.Overview; - item.Genres = request.Genres.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + + if (request.Genres is not null) + { + item.Genres = request.Genres.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + } if (item is Episode episode) { @@ -279,6 +283,11 @@ public class ItemUpdateController : BaseJellyfinApiController item.DateCreated = NormalizeDateTime(request.DateCreated.Value); } + if (request.SeriesName is not null && item is IHasSeries hasSeries) + { + hasSeries.SeriesName = request.SeriesName; + } + item.EndDate = request.EndDate.HasValue ? NormalizeDateTime(request.EndDate.Value) : null; item.PremiereDate = request.PremiereDate.HasValue ? NormalizeDateTime(request.PremiereDate.Value) : null; item.ProductionYear = request.ProductionYear; @@ -288,15 +297,27 @@ public class ItemUpdateController : BaseJellyfinApiController item.CustomRating = request.CustomRating; var currentTags = item.Tags; - var newTags = request.Tags.Select(t => t.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); - var removedTags = currentTags.Except(newTags).ToList(); - var addedTags = newTags.Except(currentTags).ToList(); - item.Tags = newTags; + List<string> removedTags; + List<string> addedTags; + if (request.Tags is not null) + { + var newTags = request.Tags.Select(t => t.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + removedTags = currentTags.Except(newTags).ToList(); + addedTags = newTags.Except(currentTags).ToList(); + item.Tags = newTags; + } + else + { + removedTags = []; + addedTags = []; + } if (item is Series rseries) { foreach (var season in rseries.Children.OfType<Season>()) { + season.SeriesName = rseries.Name; + if (!season.LockedFields.Contains(MetadataField.OfficialRating)) { season.OfficialRating = request.OfficialRating; @@ -314,6 +335,8 @@ public class ItemUpdateController : BaseJellyfinApiController foreach (var ep in season.Children.OfType<Episode>()) { + ep.SeriesName = rseries.Name; + if (!ep.LockedFields.Contains(MetadataField.OfficialRating)) { ep.OfficialRating = request.OfficialRating; @@ -403,15 +426,18 @@ public class ItemUpdateController : BaseJellyfinApiController item.RunTimeTicks = request.RunTimeTicks; } - foreach (var pair in request.ProviderIds.ToList()) + if (request.ProviderIds is not null) { - if (string.IsNullOrEmpty(pair.Value)) + foreach (var pair in request.ProviderIds.ToList()) { - request.ProviderIds.Remove(pair.Key); + if (string.IsNullOrEmpty(pair.Value)) + { + request.ProviderIds.Remove(pair.Key); + } } - } - item.ProviderIds = request.ProviderIds; + item.ProviderIds = request.ProviderIds; + } if (item is Video video) { diff --git a/Jellyfin.Api/Controllers/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs index 5f23f2fcee..385795e9f7 100644 --- a/Jellyfin.Api/Controllers/ItemsController.cs +++ b/Jellyfin.Api/Controllers/ItemsController.cs @@ -287,6 +287,8 @@ public class ItemsController : BaseJellyfinApiController QueryResult<BaseItem> result; Guid[] linkedChildAncestorIds = []; + + includeItemTypes ??= []; if (includeItemTypes.Length == 1 && (includeItemTypes[0] == BaseItemKind.BoxSet || includeItemTypes[0] == BaseItemKind.Playlist) && item is not BoxSet @@ -321,24 +323,20 @@ public class ItemsController : BaseJellyfinApiController recursive = true; includeItemTypes = [BaseItemKind.Playlist]; } - else if (folder is ICollectionFolder) + else if (folder is ICollectionFolder && includeItemTypes.Length == 0) { - if (includeItemTypes.Length == 0) + includeItemTypes = collectionType switch { - includeItemTypes = collectionType switch - { - CollectionType.boxsets => [BaseItemKind.BoxSet], - null => [BaseItemKind.Movie, BaseItemKind.Series], - _ => [] - }; - } + CollectionType.boxsets => [BaseItemKind.BoxSet], + _ => [] + }; + } - // When the client doesn't specify recursive/includeItemTypes, force the query - // through the database path where all filters (IsHD, genres, etc.) are applied. - if (includeItemTypes.Length > 0) - { - recursive ??= true; - } + // includeItemTypes on a library lists its contents recursively rather than just its + // immediate children, so default to a recursive query when the client didn't choose. + if (folder is ICollectionFolder && includeItemTypes.Length > 0) + { + recursive ??= true; } if (item is not UserRootFolder @@ -351,246 +349,248 @@ public class ItemsController : BaseJellyfinApiController return Unauthorized($"{user.Username} is not permitted to access Library {item.Name}."); } - if ((recursive.HasValue && recursive.Value) || ids.Length != 0 || item is not UserRootFolder) + // Build the query up front so the dispatch below can decide the path from it. + // Use search providers when searchTerm is provided. Providers return only IDs and scores; + // items are loaded server-side via folder.GetItems below, which applies user-access filtering. + Dictionary<Guid, float>? searchResultScores = null; + Guid[] itemIds = ids; + + if (!string.IsNullOrWhiteSpace(searchTerm)) { - // Use search providers when searchTerm is provided. Providers return only IDs and scores; - // items are loaded server-side via folder.GetItems below, which applies user-access filtering. - Dictionary<Guid, float>? searchResultScores = null; - Guid[] itemIds = ids; - - if (!string.IsNullOrWhiteSpace(searchTerm)) + var searchProviderQuery = new SearchProviderQuery { - var searchProviderQuery = new SearchProviderQuery - { - SearchTerm = searchTerm, - UserId = userId, - IncludeItemTypes = includeItemTypes, - ExcludeItemTypes = excludeItemTypes, - MediaTypes = mediaTypes, - Limit = limit.HasValue ? limit.Value * 3 : null, - ParentId = parentId - }; - - var searchResults = await _searchManager.GetSearchResultsAsync(searchProviderQuery, HttpContext.RequestAborted).ConfigureAwait(false); - if (searchResults.Count > 0) - { - searchResultScores = searchResults.ToDictionary(r => r.ItemId, r => r.Score); - itemIds = ids.Length > 0 - ? ids.Concat(searchResultScores.Keys).Distinct().ToArray() - : searchResultScores.Keys.ToArray(); - } - } - - var query = new InternalItemsQuery(user) - { - IsPlayed = isPlayed, - MediaTypes = mediaTypes, + SearchTerm = searchTerm, + UserId = userId, IncludeItemTypes = includeItemTypes, ExcludeItemTypes = excludeItemTypes, - Recursive = recursive ?? false, - OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder), - IsFavorite = isFavorite, - Limit = searchResultScores is null ? limit : null, - StartIndex = searchResultScores is null ? startIndex : null, - IsMissing = isMissing, - IsUnaired = isUnaired, - CollapseBoxSetItems = collapseBoxSetItems, - NameLessThan = nameLessThan, - NameStartsWith = nameStartsWith, - NameStartsWithOrGreater = nameStartsWithOrGreater, - HasImdbId = hasImdbId, - IsPlaceHolder = isPlaceHolder, - IsLocked = isLocked, - MinWidth = minWidth, - MinHeight = minHeight, - MaxWidth = maxWidth, - MaxHeight = maxHeight, - Is3D = is3D, - HasTvdbId = hasTvdbId, - HasTmdbId = hasTmdbId, - IsMovie = isMovie, - IsSeries = isSeries, - IsNews = isNews, - IsKids = isKids, - IsSports = isSports, - HasOverview = hasOverview, - HasOfficialRating = hasOfficialRating, - HasParentalRating = hasParentalRating, - HasSpecialFeature = hasSpecialFeature, - HasSubtitles = hasSubtitles, - HasThemeSong = hasThemeSong, - HasThemeVideo = hasThemeVideo, - HasTrailer = hasTrailer, - IsHD = isHd, - Is4K = is4K, - Tags = tags, - OfficialRatings = officialRatings, - Genres = genres, - ArtistIds = artistIds, - AlbumArtistIds = albumArtistIds, - ContributingArtistIds = contributingArtistIds, - GenreIds = genreIds, - StudioIds = studioIds, - Person = person, - PersonIds = personIds, - PersonTypes = personTypes, - Years = years, - ImageTypes = imageTypes, - VideoTypes = videoTypes, - AdjacentTo = adjacentTo, - ItemIds = itemIds, - MinCommunityRating = minCommunityRating, - MinCriticRating = minCriticRating, - ParentId = parentId ?? Guid.Empty, - IndexNumber = indexNumber, - ParentIndexNumber = parentIndexNumber, - EnableTotalRecordCount = enableTotalRecordCount, - ExcludeItemIds = excludeItemIds, - DtoOptions = dtoOptions, - SearchTerm = searchResultScores is null ? searchTerm : null, - MinDateLastSaved = minDateLastSaved?.ToUniversalTime(), - MinDateLastSavedForUser = minDateLastSavedForUser?.ToUniversalTime(), - MinPremiereDate = minPremiereDate?.ToUniversalTime(), - MaxPremiereDate = maxPremiereDate?.ToUniversalTime(), - AudioLanguages = audioLanguages, - SubtitleLanguages = subtitleLanguages, - LinkedChildAncestorIds = linkedChildAncestorIds, + MediaTypes = mediaTypes, + Limit = limit.HasValue ? limit.Value * 3 : null, + ParentId = parentId }; - if (ids.Length != 0 || !string.IsNullOrWhiteSpace(searchTerm)) + var searchResults = await _searchManager.GetSearchResultsAsync(searchProviderQuery, HttpContext.RequestAborted).ConfigureAwait(false); + if (searchResults.Count > 0) { - query.CollapseBoxSetItems = false; + searchResultScores = searchResults.ToDictionary(r => r.ItemId, r => r.Score); + itemIds = ids.Length > 0 + ? ids.Concat(searchResultScores.Keys).Distinct().ToArray() + : searchResultScores.Keys.ToArray(); } + } - if (query.SubtitleLanguages.Count > 0 && query.HasSubtitles.HasValue) + var query = new InternalItemsQuery(user) + { + IsPlayed = isPlayed, + MediaTypes = mediaTypes, + IncludeItemTypes = includeItemTypes, + ExcludeItemTypes = excludeItemTypes, + Recursive = recursive ?? false, + OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder), + IsFavorite = isFavorite, + Limit = searchResultScores is null ? limit : null, + StartIndex = searchResultScores is null ? startIndex : null, + IsMissing = isMissing, + IsUnaired = isUnaired, + CollapseBoxSetItems = collapseBoxSetItems, + NameLessThan = nameLessThan, + NameStartsWith = nameStartsWith, + NameStartsWithOrGreater = nameStartsWithOrGreater, + HasImdbId = hasImdbId, + IsPlaceHolder = isPlaceHolder, + IsLocked = isLocked, + MinWidth = minWidth, + MinHeight = minHeight, + MaxWidth = maxWidth, + MaxHeight = maxHeight, + Is3D = is3D, + HasTvdbId = hasTvdbId, + HasTmdbId = hasTmdbId, + IsMovie = isMovie, + IsSeries = isSeries, + IsNews = isNews, + IsKids = isKids, + IsSports = isSports, + HasOverview = hasOverview, + HasOfficialRating = hasOfficialRating, + HasParentalRating = hasParentalRating, + HasSpecialFeature = hasSpecialFeature, + HasSubtitles = hasSubtitles, + HasThemeSong = hasThemeSong, + HasThemeVideo = hasThemeVideo, + HasTrailer = hasTrailer, + IsHD = isHd, + Is4K = is4K, + Tags = tags, + OfficialRatings = officialRatings, + Genres = genres, + ArtistIds = artistIds, + AlbumArtistIds = albumArtistIds, + ContributingArtistIds = contributingArtistIds, + GenreIds = genreIds, + StudioIds = studioIds, + Person = person, + PersonIds = personIds, + PersonTypes = personTypes, + Years = years, + ImageTypes = imageTypes, + VideoTypes = videoTypes, + AdjacentTo = adjacentTo, + ItemIds = itemIds, + MinCommunityRating = minCommunityRating, + MinCriticRating = minCriticRating, + ParentId = parentId ?? Guid.Empty, + IndexNumber = indexNumber, + ParentIndexNumber = parentIndexNumber, + EnableTotalRecordCount = enableTotalRecordCount, + ExcludeItemIds = excludeItemIds, + DtoOptions = dtoOptions, + SearchTerm = searchResultScores is null ? searchTerm : null, + MinDateLastSaved = minDateLastSaved?.ToUniversalTime(), + MinDateLastSavedForUser = minDateLastSavedForUser?.ToUniversalTime(), + MinPremiereDate = minPremiereDate?.ToUniversalTime(), + MaxPremiereDate = maxPremiereDate?.ToUniversalTime(), + AudioLanguages = audioLanguages, + SubtitleLanguages = subtitleLanguages, + LinkedChildAncestorIds = linkedChildAncestorIds, + }; + + if (ids.Length != 0 || !string.IsNullOrWhiteSpace(searchTerm)) + { + query.CollapseBoxSetItems = false; + } + + if (query.SubtitleLanguages.Count > 0 && query.HasSubtitles.HasValue) + { + if (query.HasSubtitles.Value) { - if (query.HasSubtitles.Value) + // if we check for specific subtitles we don't need a separate check for subtitle existence + query.HasSubtitles = null; + } + else + { + // if we search for items without subtitles, we don't need to check for subtitles of a specific language + query.SubtitleLanguages = []; + } + } + + // for filter values that rely on media streams, we need to include alternative and linked versions + if (query.HasSubtitles.HasValue + || query.SubtitleLanguages.Count > 0 + || query.AudioLanguages.Count > 0 + || query.Is3D.HasValue + || query.IsHD.HasValue + || query.Is4K.HasValue + || query.VideoTypes.Length > 0 + ) + { + query.IncludeOwnedItems = true; + } + + query.ApplyFilters(filters); + + // Filter by Series Status + if (seriesStatus.Length != 0) + { + query.SeriesStatuses = seriesStatus; + } + + // Exclude Blocked Unrated Items + var blockedUnratedItems = user?.GetPreferenceValues<UnratedItem>(PreferenceKind.BlockUnratedItems); + if (blockedUnratedItems is not null) + { + query.BlockUnratedItems = blockedUnratedItems; + } + + // ExcludeLocationTypes + if (excludeLocationTypes.Any(t => t == LocationType.Virtual)) + { + query.IsVirtualItem = false; + } + + if (locationTypes.Length > 0 && locationTypes.Length < 4) + { + query.IsVirtualItem = locationTypes.Contains(LocationType.Virtual); + } + + // Min official rating + if (!string.IsNullOrWhiteSpace(minOfficialRating)) + { + query.MinParentalRating = _localization.GetRatingScore(minOfficialRating); + } + + // Max official rating + if (!string.IsNullOrWhiteSpace(maxOfficialRating)) + { + query.MaxParentalRating = _localization.GetRatingScore(maxOfficialRating); + } + + // Artists + if (artists.Length != 0) + { + query.ArtistIds = artists.Select(i => + { + try { - // if we check for specific subtitles we don't need a separate check for subtitle existence - query.HasSubtitles = null; + return _libraryManager.GetArtist(i, new DtoOptions(false)); } - else + catch { - // if we search for items without subtitles, we don't need to check for subtitles of a specific language - query.SubtitleLanguages = []; + return null; } - } + }).Where(i => i is not null).Select(i => i!.Id).ToArray(); + } - // for filter values that rely on media streams, we need to include alternative and linked versions - if (query.HasSubtitles.HasValue - || query.SubtitleLanguages.Count > 0 - || query.AudioLanguages.Count > 0 - || query.Is3D.HasValue - || query.IsHD.HasValue - || query.Is4K.HasValue - || query.VideoTypes.Length > 0 - ) + // ExcludeArtistIds + if (excludeArtistIds.Length != 0) + { + query.ExcludeArtistIds = excludeArtistIds; + } + + if (albumIds.Length != 0) + { + query.AlbumIds = albumIds; + } + + // Albums + if (albums.Length != 0) + { + query.AlbumIds = albums.SelectMany(i => { - query.IncludeOwnedItems = true; - } + return _libraryManager.GetItemIds(new InternalItemsQuery { IncludeItemTypes = [BaseItemKind.MusicAlbum], Name = i, Limit = 1 }); + }).ToArray(); + } - query.ApplyFilters(filters); - - // Filter by Series Status - if (seriesStatus.Length != 0) + // Studios + if (studios.Length != 0) + { + query.StudioIds = studios.Select(i => { - query.SeriesStatuses = seriesStatus; - } - - // Exclude Blocked Unrated Items - var blockedUnratedItems = user?.GetPreferenceValues<UnratedItem>(PreferenceKind.BlockUnratedItems); - if (blockedUnratedItems is not null) - { - query.BlockUnratedItems = blockedUnratedItems; - } - - // ExcludeLocationTypes - if (excludeLocationTypes.Any(t => t == LocationType.Virtual)) - { - query.IsVirtualItem = false; - } - - if (locationTypes.Length > 0 && locationTypes.Length < 4) - { - query.IsVirtualItem = locationTypes.Contains(LocationType.Virtual); - } - - // Min official rating - if (!string.IsNullOrWhiteSpace(minOfficialRating)) - { - query.MinParentalRating = _localization.GetRatingScore(minOfficialRating); - } - - // Max official rating - if (!string.IsNullOrWhiteSpace(maxOfficialRating)) - { - query.MaxParentalRating = _localization.GetRatingScore(maxOfficialRating); - } - - // Artists - if (artists.Length != 0) - { - query.ArtistIds = artists.Select(i => + try { - try - { - return _libraryManager.GetArtist(i, new DtoOptions(false)); - } - catch - { - return null; - } - }).Where(i => i is not null).Select(i => i!.Id).ToArray(); - } - - // ExcludeArtistIds - if (excludeArtistIds.Length != 0) - { - query.ExcludeArtistIds = excludeArtistIds; - } - - if (albumIds.Length != 0) - { - query.AlbumIds = albumIds; - } - - // Albums - if (albums.Length != 0) - { - query.AlbumIds = albums.SelectMany(i => - { - return _libraryManager.GetItemIds(new InternalItemsQuery { IncludeItemTypes = [BaseItemKind.MusicAlbum], Name = i, Limit = 1 }); - }).ToArray(); - } - - // Studios - if (studios.Length != 0) - { - query.StudioIds = studios.Select(i => - { - try - { - return _libraryManager.GetStudio(i); - } - catch - { - return null; - } - }).Where(i => i is not null).Select(i => i!.Id).ToArray(); - } - - // Apply default sorting if none requested - if (query.OrderBy.Count == 0) - { - // Albums by artist - if (query.ArtistIds.Length > 0 && query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes[0] == BaseItemKind.MusicAlbum) - { - query.OrderBy = [(ItemSortBy.ProductionYear, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Ascending)]; + return _libraryManager.GetStudio(i); } + catch + { + return null; + } + }).Where(i => i is not null).Select(i => i!.Id).ToArray(); + } + + // Apply default sorting if none requested + if (query.OrderBy.Count == 0) + { + // Albums by artist + if (query.ArtistIds.Length > 0 && query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes[0] == BaseItemKind.MusicAlbum) + { + query.OrderBy = [(ItemSortBy.ProductionYear, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Ascending)]; } + } - query.Parent = null; + query.Parent = null; + // At the user root an unfiltered, non-recursive request is a plain listing of the user's libraries + if ((recursive.HasValue && recursive.Value) || ids.Length != 0 || item is not UserRootFolder || query.HasFilters) + { // folder.GetItems applies user-access filtering via the InternalItemsQuery's User. result = folder.GetItems(query); if (searchResultScores is not null && searchResultScores.Count > 0) @@ -963,9 +963,15 @@ public class ItemsController : BaseJellyfinApiController var excludeItemIds = Array.Empty<Guid>(); if (excludeActiveSessions) { + // NowPlayingItem.Id is the displayed/primary id, but resume queries surface the actually-played + // alternate version's own id. Expand each active session to every version id so an in-progress + // alternate is excluded too, instead of leaking back into the resume list. excludeItemIds = _sessionManager.Sessions .Where(s => s.UserId.Equals(requestUserId) && s.NowPlayingItem is not null) - .Select(s => s.NowPlayingItem.Id) + .SelectMany(s => _libraryManager.GetItemById(s.NowPlayingItem.Id) is Video video + ? video.GetAllVersions().Select(v => v.Id) + : [s.NowPlayingItem.Id]) + .Distinct() .ToArray(); } diff --git a/Jellyfin.Api/Controllers/LiveTvController.cs b/Jellyfin.Api/Controllers/LiveTvController.cs index 113298c251..1c570daf21 100644 --- a/Jellyfin.Api/Controllers/LiveTvController.cs +++ b/Jellyfin.Api/Controllers/LiveTvController.cs @@ -1002,9 +1002,7 @@ public class LiveTvController : BaseJellyfinApiController { if (!string.IsNullOrEmpty(pw)) { - // TODO: remove ToLower when Convert.ToHexString supports lowercase - // Schedules Direct requires the hex to be lowercase - listingsProviderInfo.Password = Convert.ToHexString(SHA1.HashData(Encoding.UTF8.GetBytes(pw))).ToLowerInvariant(); + listingsProviderInfo.Password = Convert.ToHexStringLower(SHA1.HashData(Encoding.UTF8.GetBytes(pw))); } return await _listingsManager.SaveListingProvider(listingsProviderInfo, validateLogin, validateListings).ConfigureAwait(false); diff --git a/Jellyfin.Api/Controllers/PluginsController.cs b/Jellyfin.Api/Controllers/PluginsController.cs index 0105ecf7a7..79b8b60cb7 100644 --- a/Jellyfin.Api/Controllers/PluginsController.cs +++ b/Jellyfin.Api/Controllers/PluginsController.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Text.Json; using System.Threading.Tasks; using Jellyfin.Api.Attributes; +using Jellyfin.Extensions; using Jellyfin.Extensions.Json; using MediaBrowser.Common.Api; using MediaBrowser.Common.Plugins; @@ -226,10 +227,13 @@ public class PluginsController : BaseJellyfinApiController return NotFound(); } - if (!string.IsNullOrEmpty(plugin.Manifest.ImagePath)) + string? imagePath = plugin.Manifest.ImagePath; + if (!string.IsNullOrWhiteSpace(imagePath)) { - var imagePath = Path.Combine(plugin.Path, plugin.Manifest.ImagePath); - if (!System.IO.File.Exists(imagePath)) + var pluginPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(plugin.Path)); + imagePath = Path.GetFullPath(imagePath, pluginPath); + // Require a separator after the plugin path so a sibling like "<pluginPath>-evil" can't pass. + if (imagePath.StartsWith(pluginPath + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) is false || System.IO.File.Exists(imagePath) is false) { return NotFound(); } diff --git a/Jellyfin.Api/Controllers/StartupController.cs b/Jellyfin.Api/Controllers/StartupController.cs index fa6d9efe36..47c8f21241 100644 --- a/Jellyfin.Api/Controllers/StartupController.cs +++ b/Jellyfin.Api/Controllers/StartupController.cs @@ -140,6 +140,11 @@ public class StartupController : BaseJellyfinApiController return NotFound(); } + if (!string.IsNullOrEmpty(user.Password)) + { + return Forbid(); + } + if (string.IsNullOrWhiteSpace(startupUserDto.Password)) { return BadRequest("Password must not be empty"); diff --git a/Jellyfin.Api/Controllers/TrailersController.cs b/Jellyfin.Api/Controllers/TrailersController.cs index 121db66858..6bddd85337 100644 --- a/Jellyfin.Api/Controllers/TrailersController.cs +++ b/Jellyfin.Api/Controllers/TrailersController.cs @@ -122,6 +122,7 @@ public class TrailersController : BaseJellyfinApiController /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the trailers.</returns> [HttpGet] [ProducesResponseType(StatusCodes.Status200OK)] + [Obsolete("Use GetItems with includeItemTypes=Trailer instead.")] public async Task<ActionResult<QueryResult<BaseItemDto>>> GetTrailers( [FromQuery] Guid? userId, [FromQuery] string? maxOfficialRating, diff --git a/Jellyfin.Api/Controllers/TvShowsController.cs b/Jellyfin.Api/Controllers/TvShowsController.cs index 340a54e13b..6b0f10e02a 100644 --- a/Jellyfin.Api/Controllers/TvShowsController.cs +++ b/Jellyfin.Api/Controllers/TvShowsController.cs @@ -277,8 +277,15 @@ public class TvShowsController : BaseJellyfinApiController if (startItemId.HasValue) { + // The start item may be an alternate version, which is not part of the episode listing; start from its primary episode instead. + var startId = startItemId.Value; + if (_libraryManager.GetItemById<Video>(startId)?.PrimaryVersionId is { } primaryVersionId) + { + startId = primaryVersionId; + } + episodes = episodes - .SkipWhile(i => !startItemId.Value.Equals(i.Id)) + .SkipWhile(i => !startId.Equals(i.Id)) .ToList(); } diff --git a/Jellyfin.Api/Controllers/UserLibraryController.cs b/Jellyfin.Api/Controllers/UserLibraryController.cs index 25f781e496..a718035528 100644 --- a/Jellyfin.Api/Controllers/UserLibraryController.cs +++ b/Jellyfin.Api/Controllers/UserLibraryController.cs @@ -551,8 +551,6 @@ public class UserLibraryController : BaseJellyfinApiController var dtoOptions = new DtoOptions { Fields = fields } .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - dtoOptions.PreferEpisodeParentPoster = true; - var list = _userViewManager.GetLatestItems( new LatestItemsQuery { diff --git a/Jellyfin.Api/Extensions/DtoExtensions.cs b/Jellyfin.Api/Extensions/DtoExtensions.cs index 9c24be82ea..36a8e197a2 100644 --- a/Jellyfin.Api/Extensions/DtoExtensions.cs +++ b/Jellyfin.Api/Extensions/DtoExtensions.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using Jellyfin.Data.Enums; using MediaBrowser.Controller.Dto; using MediaBrowser.Model.Entities; @@ -9,6 +10,37 @@ namespace Jellyfin.Api.Extensions; /// </summary> public static class DtoExtensions { + /// <summary> + /// Gets the BaseItemKind values associated with the specified CollectionType. + /// </summary> + /// <param name="collectionType">The collection type to map to BaseItemKind values.</param> + /// <returns>An array of BaseItemKind values that correspond to the collection type.</returns> + public static BaseItemKind[] GetBaseItemKindsForCollectionType(CollectionType? collectionType) + { + switch (collectionType) + { + case CollectionType.movies: + return [BaseItemKind.Movie]; + case CollectionType.tvshows: + return [BaseItemKind.Series]; + case CollectionType.music: + return [BaseItemKind.MusicAlbum]; + case CollectionType.musicvideos: + return [BaseItemKind.MusicVideo]; + case CollectionType.books: + return [BaseItemKind.Book, BaseItemKind.AudioBook]; + case CollectionType.boxsets: + return [BaseItemKind.BoxSet]; + case CollectionType.homevideos: + case CollectionType.photos: + return [BaseItemKind.Video, BaseItemKind.Photo]; + case CollectionType.folders: + return []; + default: + return [BaseItemKind.Video, BaseItemKind.Audio, BaseItemKind.Photo, BaseItemKind.Movie, BaseItemKind.Series]; + } + } + /// <summary> /// Add additional DtoOptions. /// </summary> diff --git a/Jellyfin.Api/Helpers/RequestHelpers.cs b/Jellyfin.Api/Helpers/RequestHelpers.cs index 5072f902da..d14c3a9343 100644 --- a/Jellyfin.Api/Helpers/RequestHelpers.cs +++ b/Jellyfin.Api/Helpers/RequestHelpers.cs @@ -5,7 +5,6 @@ using System.Security.Claims; using System.Threading.Tasks; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; -using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; @@ -31,15 +30,16 @@ public static class RequestHelpers /// </summary> /// <param name="sortBy">Sort By. Comma delimited string.</param> /// <param name="requestedSortOrder">Sort Order. Comma delimited string.</param> + /// <typeparam name="TSortBy">The type of the sort by field.</typeparam> /// <returns>Order By.</returns> - public static (ItemSortBy, SortOrder)[] GetOrderBy(IReadOnlyList<ItemSortBy> sortBy, IReadOnlyList<SortOrder> requestedSortOrder) + public static (TSortBy, SortOrder)[] GetOrderBy<TSortBy>(IReadOnlyList<TSortBy> sortBy, IReadOnlyList<SortOrder> requestedSortOrder) { if (sortBy.Count == 0) { - return Array.Empty<(ItemSortBy, SortOrder)>(); + return Array.Empty<(TSortBy, SortOrder)>(); } - var result = new (ItemSortBy, SortOrder)[sortBy.Count]; + var result = new (TSortBy, SortOrder)[sortBy.Count]; var i = 0; // Add elements which have a SortOrder specified for (; i < requestedSortOrder.Count; i++) diff --git a/Jellyfin.Api/Helpers/StreamingHelpers.cs b/Jellyfin.Api/Helpers/StreamingHelpers.cs index bae2756303..6a6aac1327 100644 --- a/Jellyfin.Api/Helpers/StreamingHelpers.cs +++ b/Jellyfin.Api/Helpers/StreamingHelpers.cs @@ -144,6 +144,15 @@ public static class StreamingHelpers mediaSource = liveStreamInfo.Item1; state.DirectStreamProvider = liveStreamInfo.Item2; + // The requested live stream is no longer open. This commonly happens when a client keeps + // polling the HLS playlist (e.g. live.m3u8) after the stream was disposed because its + // consumer count dropped to zero. GetLiveStreamWithDirectStreamProvider returns a null + // MediaSource in that case, so return 404 instead of dereferencing it below. + if (mediaSource is null) + { + throw new ResourceNotFoundException($"The live stream with id {streamingRequest.LiveStreamId} could not be found or is no longer available."); + } + // Cap the max bitrate when it is too high. This is usually due to ffmpeg is unable to probe the source liveTV streams' bitrate. if (mediaSource.FallbackMaxStreamingBitrate is not null && streamingRequest.VideoBitRate is not null) { diff --git a/Jellyfin.Server.Implementations/Activity/ActivityManager.cs b/Jellyfin.Server.Implementations/Activity/ActivityManager.cs index ba24dc3864..f21e94a0fd 100644 --- a/Jellyfin.Server.Implementations/Activity/ActivityManager.cs +++ b/Jellyfin.Server.Implementations/Activity/ActivityManager.cs @@ -56,11 +56,11 @@ public class ActivityManager : IActivityManager var dbContext = await _provider.CreateDbContextAsync().ConfigureAwait(false); await using (dbContext.ConfigureAwait(false)) { - // TODO switch to LeftJoin in .NET 10. - var entries = from a in dbContext.ActivityLogs - join u in dbContext.Users on a.UserId equals u.Id into ugj - from u in ugj.DefaultIfEmpty() - select new ExpandedActivityLog { ActivityLog = a, Username = u.Username }; + var entries = dbContext.ActivityLogs.LeftJoin( + dbContext.Users, + a => a.UserId, + u => u.Id, + (a, u) => new ExpandedActivityLog { ActivityLog = a, Username = u == null ? null : u.Username }); if (query.HasUserId is not null) { diff --git a/Jellyfin.Server.Implementations/Devices/DeviceManager.cs b/Jellyfin.Server.Implementations/Devices/DeviceManager.cs index bcf348f8c6..d0d52a23fb 100644 --- a/Jellyfin.Server.Implementations/Devices/DeviceManager.cs +++ b/Jellyfin.Server.Implementations/Devices/DeviceManager.cs @@ -213,8 +213,10 @@ namespace Jellyfin.Server.Implementations.Devices var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); await using (dbContext.ConfigureAwait(false)) { - dbContext.Devices.Remove(device); - await dbContext.SaveChangesAsync().ConfigureAwait(false); + await dbContext.Devices + .Where(d => d.Id == device.Id) + .ExecuteDeleteAsync() + .ConfigureAwait(false); } } diff --git a/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs b/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs index a6dc5458ee..7c10a5dc77 100644 --- a/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs +++ b/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs @@ -12,6 +12,7 @@ using Jellyfin.Database.Implementations; using Jellyfin.Server.Implementations.StorageHelpers; using Jellyfin.Server.Implementations.SystemBackupService; using MediaBrowser.Controller; +using MediaBrowser.Controller.Library; using MediaBrowser.Controller.SystemBackupService; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -33,6 +34,7 @@ public class BackupService : IBackupService private readonly IServerApplicationPaths _applicationPaths; private readonly IJellyfinDatabaseProvider _jellyfinDatabaseProvider; private readonly IHostApplicationLifetime _hostApplicationLifetime; + private readonly ILibraryManager _libraryManager; private static readonly JsonSerializerOptions _serializerSettings = new JsonSerializerOptions(JsonSerializerDefaults.General) { AllowTrailingCommas = true, @@ -50,13 +52,15 @@ public class BackupService : IBackupService /// <param name="applicationPaths">The application paths.</param> /// <param name="jellyfinDatabaseProvider">The Jellyfin database Provider in use.</param> /// <param name="applicationLifetime">The SystemManager.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> public BackupService( ILogger<BackupService> logger, IDbContextFactory<JellyfinDbContext> dbProvider, IServerApplicationHost applicationHost, IServerApplicationPaths applicationPaths, IJellyfinDatabaseProvider jellyfinDatabaseProvider, - IHostApplicationLifetime applicationLifetime) + IHostApplicationLifetime applicationLifetime, + ILibraryManager libraryManager) { _logger = logger; _dbProvider = dbProvider; @@ -64,6 +68,7 @@ public class BackupService : IBackupService _applicationPaths = applicationPaths; _jellyfinDatabaseProvider = jellyfinDatabaseProvider; _hostApplicationLifetime = applicationLifetime; + _libraryManager = libraryManager; } /// <inheritdoc/> @@ -263,6 +268,14 @@ public class BackupService : IBackupService /// <inheritdoc/> public async Task<BackupManifestDto> CreateBackupAsync(BackupOptionsDto backupOptions) { + // Creating a backup runs a database optimization and reads the entire database under a transaction, both of + // which heavily contend with an active library scan and could capture an inconsistent database state. + if (_libraryManager.IsScanRunning) + { + _logger.LogWarning("Cannot create a backup while a library scan is running."); + throw new InvalidOperationException("Cannot create a backup while a library scan is running. Please try again once the scan has finished."); + } + var manifest = new BackupManifest() { DateCreated = DateTime.UtcNow, @@ -346,18 +359,39 @@ public class BackupService : IBackupService jsonSerializer.WriteStartArray(); var set = entityType.ValueFactory().ConfigureAwait(false); - await foreach (var item in set.ConfigureAwait(false)) + var enumerator = set.GetAsyncEnumerator(); + await using (enumerator) { - entities++; - try + while (true) { - using var document = JsonSerializer.SerializeToDocument(item, _serializerSettings); - document.WriteTo(jsonSerializer); - } - catch (Exception ex) - { - _logger.LogError(ex, "Could not load entity {Entity}", item); - throw; + bool hasNext; + try + { + hasNext = await enumerator.MoveNextAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Could not read next entity of type {Table}, the underlying data appears to be corrupt. Skipping this row and continuing backup; the affected database row should be inspected and fixed manually", entityType.SourceName); + continue; + } + + if (!hasNext) + { + break; + } + + var item = enumerator.Current; + 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; + } } } diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs index c5b5fbf6d8..6fe8563bf5 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs @@ -7,6 +7,7 @@ using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; using Microsoft.EntityFrameworkCore; using BaseItemDto = MediaBrowser.Controller.Entities.BaseItem; @@ -81,6 +82,41 @@ public sealed partial class BaseItemRepository _itemTypeLookup.MusicGenreTypes); } + /// <inheritdoc /> + public IReadOnlyList<string> GetMediaStreamLanguages(InternalItemsQuery filter, MediaStreamType mediaStreamType) + { + ArgumentNullException.ThrowIfNull(filter); + + using var context = _dbProvider.CreateDbContext(); + + return TranslateQuery( + context.BaseItems.Include(e => e.MediaStreams).Where(e => e.Id != EF.Constant(PlaceholderId)), + context, + new InternalItemsQuery(filter.User) + { + IncludeOwnedItems = filter.IncludeOwnedItems, + ExcludeItemTypes = filter.ExcludeItemTypes, + IncludeItemTypes = filter.IncludeItemTypes, + MediaTypes = filter.MediaTypes, + AncestorIds = filter.AncestorIds, + ItemIds = filter.ItemIds, + TopParentIds = filter.TopParentIds, + ParentId = filter.ParentId, + IsAiring = filter.IsAiring, + IsMovie = filter.IsMovie, + IsSports = filter.IsSports, + IsKids = filter.IsKids, + IsNews = filter.IsNews, + IsSeries = filter.IsSeries + }) + .Where(e => e.MediaStreams != null) + .SelectMany(e => e.MediaStreams!) + .Where(e => e.StreamType == (MediaStreamTypeEntity)mediaStreamType) + .Select(s => string.IsNullOrEmpty(s.Language) ? "und" : s.Language) // und = undetermined + .Distinct() + .ToArray(); + } + private string[] GetItemValueNames(IReadOnlyList<ItemValueType> itemValueTypes, IReadOnlyList<string> withItemTypes, IReadOnlyList<string> excludeItemTypes) { using var context = _dbProvider.CreateDbContext(); diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs index d6ddf8f5c8..a4de9feb05 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs @@ -62,18 +62,21 @@ public sealed partial class BaseItemRepository private IQueryable<BaseItemEntity> ApplyGroupingFilter(JellyfinDbContext context, IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter) { - // Collapse duplicates sharing a presentation key (e.g. alternate versions) by picking - // the min Id per group. Keep the grouped ids as an IQueryable sub-select; materializing + // Collapse duplicates sharing a presentation key (e.g. alternate versions), preferring the + // primary version (PrimaryVersionId is null) so detail pages and actions target it instead + // of an arbitrary alternate. Keep the grouped ids as an IQueryable sub-select; materializing // to a List would inline one bound parameter per id and hit SQLite's variable cap. var enableGroupByPresentationUniqueKey = EnableGroupByPresentationUniqueKey(filter); if (enableGroupByPresentationUniqueKey && filter.GroupBySeriesPresentationUniqueKey) { - var groupedIds = dbQuery.GroupBy(e => new { e.PresentationUniqueKey, e.SeriesPresentationUniqueKey }).Select(e => e.Min(x => x.Id)); + var groupedIds = dbQuery.GroupBy(e => new { e.PresentationUniqueKey, e.SeriesPresentationUniqueKey }) + .Select(g => g.Where(e => e.PrimaryVersionId == null).Min(e => (Guid?)e.Id) ?? g.Min(e => (Guid?)e.Id)); dbQuery = context.BaseItems.AsNoTracking().Where(e => groupedIds.Contains(e.Id)); } else if (enableGroupByPresentationUniqueKey) { - var groupedIds = dbQuery.GroupBy(e => e.PresentationUniqueKey).Select(e => e.Min(x => x.Id)); + var groupedIds = dbQuery.GroupBy(e => e.PresentationUniqueKey) + .Select(g => g.Where(e => e.PrimaryVersionId == null).Min(e => (Guid?)e.Id) ?? g.Min(e => (Guid?)e.Id)); dbQuery = context.BaseItems.AsNoTracking().Where(e => groupedIds.Contains(e.Id)); } else if (filter.GroupBySeriesPresentationUniqueKey) @@ -444,6 +447,7 @@ public sealed partial class BaseItemRepository if (filter.IncludeInheritedTags.Length > 0) { var includeTags = filter.IncludeInheritedTags.Select(e => e.GetCleanValue()).ToArray(); + var personTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Person]; var allowedTagItemIds = context.ItemValuesMap .Where(f => f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue)) .Select(f => f.ItemId); @@ -452,7 +456,10 @@ public sealed partial class BaseItemRepository allowedTagItemIds.Contains(e.Id) || (e.SeriesId.HasValue && allowedTagItemIds.Contains(e.SeriesId.Value)) || e.Parents!.Any(p => allowedTagItemIds.Contains(p.ParentItemId)) - || (e.TopParentId.HasValue && allowedTagItemIds.Contains(e.TopParentId.Value))); + || (e.TopParentId.HasValue && allowedTagItemIds.Contains(e.TopParentId.Value)) + + // People don't carry the tags of the media they appear in and would never match + || e.Type == personTypeName); } // Exclude alternate versions (have PrimaryVersionId set) and owned non-extra items. diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs index d905775aef..f19df6259e 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -513,13 +513,17 @@ public sealed partial class BaseItemRepository if (filter.IsResumable.HasValue) { var hasSeries = filter.IncludeItemTypes.Contains(BaseItemKind.Series); + var userId = filter.User!.Id; + var isResumable = filter.IsResumable.Value; + var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series]; + // In-progress user data rows; alternate versions track their own progress. + var inProgress = context.UserData + .Where(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0); + + IQueryable<Guid>? resumableSeriesIds = null; if (hasSeries) { - var userId = filter.User!.Id; - var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series]; - var isResumable = filter.IsResumable.Value; - // Aggregate per series in a single GROUP BY pass, instead of three full scans. var seriesEpisodeStats = context.BaseItems .AsNoTracking() @@ -535,26 +539,49 @@ public sealed partial class BaseItemRepository // A series is resumable if it has an in-progress episode, // or if it has both played and unplayed episodes (partially watched). - var resumableSeriesIds = seriesEpisodeStats + resumableSeriesIds = seriesEpisodeStats .Where(s => s.HasInProgress || (s.HasPlayed && s.HasUnplayed)) .Select(s => s.SeriesId); + } - // Non-series items: resumable if PlaybackPositionTicks > 0 - var resumableItemIds = context.UserData - .Where(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0) - .Select(ud => ud.ItemId); + if (isResumable) + { + // Resume queries surface the version that was actually played, which may be an alternate. + // Match each version on its own progress rather than coalescing onto the primary. + var inProgressIds = inProgress.Select(ud => ud.ItemId); - baseQuery = baseQuery.Where(e => - (e.Type == seriesTypeName && resumableSeriesIds.Contains(e.Id) == isResumable) - || (e.Type != seriesTypeName && resumableItemIds.Contains(e.Id) == isResumable)); + baseQuery = hasSeries + ? baseQuery.Where(e => + (e.Type == seriesTypeName && resumableSeriesIds!.Contains(e.Id)) + || (e.Type != seriesTypeName && inProgressIds.Contains(e.Id))) + : baseQuery.Where(e => inProgressIds.Contains(e.Id)); + + // When several versions of the same item are in progress, keep only the most recently played one, use id as tiebreaker. + // Only in-progress siblings can eliminate a candidate: a version without progress has a NULL max LastPlayedDate, + // which is never greater and never ties. Restricting the sibling scan to the in-progress set keeps this bounded by + // the user's Continue Watching count instead of forcing a full BaseItems scan (COALESCE keys are non-indexable) per row. + baseQuery = baseQuery.Where(e => e.Type == seriesTypeName || !context.BaseItems + .Where(s => s.Id != e.Id + && inProgressIds.Contains(s.Id) + && (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id)) + .Any(s => + inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate) + > inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate) + || (inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate) + == inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate) + && s.Id.CompareTo(e.Id) < 0))); } else { - var resumableItemIds = context.UserData - .Where(ud => ud.UserId == filter.User!.Id && ud.PlaybackPositionTicks > 0) - .Select(ud => ud.ItemId); - var isResumable = filter.IsResumable.Value; - baseQuery = baseQuery.Where(e => resumableItemIds.Contains(e.Id) == isResumable); + // Not-resumable queries operate on primaries only. + var resumableMovieIds = inProgress + .Join(context.BaseItems, ud => ud.ItemId, bi => bi.Id, (ud, bi) => bi.PrimaryVersionId ?? bi.Id); + + baseQuery = hasSeries + ? baseQuery.Where(e => + (e.Type == seriesTypeName && !resumableSeriesIds!.Contains(e.Id)) + || (e.Type != seriesTypeName && !resumableMovieIds.Contains(e.Id))) + : baseQuery.Where(e => !resumableMovieIds.Contains(e.Id)); } } @@ -586,8 +613,7 @@ public sealed partial class BaseItemRepository if (filter.AlbumIds.Length > 0) { - var subQuery = context.BaseItems.WhereOneOrMany(filter.AlbumIds, f => f.Id); - baseQuery = baseQuery.Where(e => subQuery.Any(f => f.Name == e.Album)); + baseQuery = baseQuery.Where(e => e.ParentId.HasValue && filter.AlbumIds.Contains(e.ParentId.Value)); } if (filter.ExcludeArtistIds.Length > 0) @@ -742,10 +768,13 @@ public sealed partial class BaseItemRepository } else if (filter.OwnerIds.Length == 0 && filter.ExtraTypes.Length == 0 && !filter.IncludeOwnedItems) { - // Exclude alternate versions and owned non-extra items from general queries. - // Alternate versions have PrimaryVersionId set (pointing to their primary). + // Exclude owned non-extra items from general queries. // Extras (trailers, etc.) have OwnerId set but also have ExtraType set - keep those. - baseQuery = baseQuery.Where(e => e.PrimaryVersionId == null && (e.OwnerId == null || e.ExtraType != null)); + // Alternate versions (PrimaryVersionId set) are normally excluded too, but resume queries + // keep them so the actually-played version can surface instead of collapsing onto the primary. + baseQuery = filter.IsResumable == true + ? baseQuery.Where(e => e.OwnerId == null || e.ExtraType != null) + : baseQuery.Where(e => e.PrimaryVersionId == null && (e.OwnerId == null || e.ExtraType != null)); } if (filter.OwnerIds.Length > 0) @@ -1060,6 +1089,7 @@ public sealed partial class BaseItemRepository { var includeTags = filter.IncludeInheritedTags.Select(e => e.GetCleanValue()).ToArray(); var isPlaylistOnlyQuery = includeTypes.Length == 1 && includeTypes.FirstOrDefault() == BaseItemKind.Playlist; + var personTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Person]; var allowedTagItemIds = context.ItemValuesMap .Where(f => f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue)) .Select(f => f.ItemId); @@ -1070,6 +1100,9 @@ public sealed partial class BaseItemRepository || e.Parents!.Any(p => allowedTagItemIds.Contains(p.ParentItemId)) || (e.TopParentId.HasValue && allowedTagItemIds.Contains(e.TopParentId.Value)) + // People don't carry the tags of the media they appear in and would never match + || e.Type == personTypeName + // A playlist should be accessible to its owner regardless of allowed tags || (isPlaylistOnlyQuery && e.Data!.Contains($"OwnerUserId\":\"{filter.User!.Id:N}\""))); } diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs index 94dedaeba8..57041276b7 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs @@ -167,6 +167,14 @@ public sealed partial class BaseItemRepository return false; } + // Resume queries surface the actually-played version (which may be an alternate sharing the + // primary's presentation key). The resumable filter already keeps one version per group, so + // presentation-key grouping must not collapse the surfaced version back onto the primary. + if (query.IsResumable == true) + { + return false; + } + if (query.GroupBySeriesPresentationUniqueKey) { return false; diff --git a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs index 7c0cfe7c15..b10f7c527e 100644 --- a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs +++ b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs @@ -65,8 +65,13 @@ public class ItemPersistenceService : IItemPersistenceService descendantIds.Add(id); } + // Use WhereOneOrMany instead of a raw HashSet.Contains so large id sets are bound as a + // single parameter (json_each) rather than one SQL variable per id, which would otherwise + // overflow SQLite's variable limit when deleting many items at once (e.g. migrations). + var ownerIds = descendantIds.ToArray(); var extraIds = context.BaseItems - .Where(e => e.OwnerId.HasValue && descendantIds.Contains(e.OwnerId.Value)) + .Where(e => e.OwnerId.HasValue) + .WhereOneOrMany(ownerIds, e => e.OwnerId!.Value) .Select(e => e.Id) .ToArray(); diff --git a/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs b/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs index 7fa33c8639..a25629132b 100644 --- a/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs +++ b/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs @@ -172,8 +172,7 @@ public class MediaStreamRepository : IMediaStreamRepository if (!string.IsNullOrEmpty(dto.Language)) { - var culture = _localization.FindLanguageInfo(dto.Language); - dto.LocalizedLanguage = culture?.DisplayName; + dto.LocalizedLanguage = _localization.GetLanguageDisplayName(dto.Language); } if (dto.Type is MediaStreamType.Audio) diff --git a/Jellyfin.Server.Implementations/Item/OrderMapper.cs b/Jellyfin.Server.Implementations/Item/OrderMapper.cs index d327b218a9..aac85d0131 100644 --- a/Jellyfin.Server.Implementations/Item/OrderMapper.cs +++ b/Jellyfin.Server.Implementations/Item/OrderMapper.cs @@ -34,7 +34,14 @@ public static class OrderMapper (ItemSortBy.AirTime, _) => e => e.SortName, (ItemSortBy.Runtime, _) => e => e.RunTimeTicks, (ItemSortBy.Random, _) => e => EF.Functions.Random(), - (ItemSortBy.DatePlayed, _) => e => e.UserData!.Where(f => f.UserId.Equals(query.User!.Id)).OrderBy(f => f.CustomDataKey).FirstOrDefault()!.LastPlayedDate, + (ItemSortBy.DatePlayed, not null) => e => + jellyfinDbContext.UserData + .Where(w => w.UserId == query.User.Id && (w.ItemId == e.Id || w.Item!.PrimaryVersionId == e.Id)) + .Max(f => f.LastPlayedDate), + (ItemSortBy.DatePlayed, null) => e => + jellyfinDbContext.UserData + .Where(w => w.ItemId == e.Id || w.Item!.PrimaryVersionId == e.Id) + .Max(f => f.LastPlayedDate), (ItemSortBy.PlayCount, _) => e => e.UserData!.Where(f => f.UserId.Equals(query.User!.Id)).OrderBy(f => f.CustomDataKey).FirstOrDefault()!.PlayCount, (ItemSortBy.IsFavoriteOrLiked, _) => e => e.UserData!.Where(f => f.UserId.Equals(query.User!.Id)).OrderBy(f => f.CustomDataKey).Select(f => (bool?)f.IsFavorite).FirstOrDefault() ?? false, (ItemSortBy.IsFolder, _) => e => e.IsFolder, diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs index 9be2eac4a1..81408d9aa8 100644 --- a/Jellyfin.Server.Implementations/Users/UserManager.cs +++ b/Jellyfin.Server.Implementations/Users/UserManager.cs @@ -170,7 +170,7 @@ namespace Jellyfin.Server.Implementations.Users { ThrowIfInvalidUsername(newName); - if (oldName.Equals(newName, StringComparison.OrdinalIgnoreCase)) + if (oldName.Equals(newName, StringComparison.Ordinal)) { throw new ArgumentException("The new and old names must be different."); } @@ -616,6 +616,12 @@ namespace Jellyfin.Server.Implementations.Users .SetProperty(f => f.LastActivityDate, date) .SetProperty(f => f.LastLoginDate, date)) .ConfigureAwait(false); + + // ExecuteUpdateAsync bypasses the change tracker, so keep the + // returned entity in sync. Otherwise SessionManager.LogSessionActivity + // saves this (stale) entity in full and reverts LastLoginDate. + user.LastActivityDate = date; + user.LastLoginDate = date; } await dbContext.Users @@ -631,6 +637,7 @@ namespace Jellyfin.Server.Implementations.Users if (maxInvalidLogins.HasValue && user.InvalidLoginAttemptCount >= maxInvalidLogins) { user.SetPermission(PermissionKind.IsDisabled, true); + dbContext.Update(user); await dbContext.SaveChangesAsync() .ConfigureAwait(false); await _eventManager.PublishAsync(new UserLockedOutEventArgs(user)).ConfigureAwait(false); @@ -882,8 +889,20 @@ namespace Jellyfin.Server.Implementations.Users var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); await using (dbContext.ConfigureAwait(false)) { - dbContext.Remove(user.ProfileImage); - await dbContext.SaveChangesAsync().ConfigureAwait(false); + // Remove the tracked profile image loaded from the database instead of the + // detached instance on the passed in user. That instance can carry a stale, + // never-persisted (temporary) key, which makes EF Core throw when it is marked + // for deletion, leaving the profile image impossible to clear or replace. + var dbUser = await UserQuery(dbContext) + .AsTracking() + .FirstOrDefaultAsync(u => u.Id == user.Id) + .ConfigureAwait(false); + if (dbUser?.ProfileImage is not null) + { + dbContext.Remove(dbUser.ProfileImage); + dbUser.ProfileImage = null; + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } } user.ProfileImage = null; @@ -892,7 +911,7 @@ namespace Jellyfin.Server.Implementations.Users internal static void ThrowIfInvalidUsername(string name) { - if (!string.IsNullOrWhiteSpace(name) && ValidUsernameRegex().IsMatch(name)) + if (!string.IsNullOrWhiteSpace(name) && ValidUsernameRegex().IsMatch(name) && !string.Equals(name, ".", StringComparison.Ordinal) && !string.Equals(name, "..", StringComparison.Ordinal)) { return; } diff --git a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs index 2aadedfa61..6db0edb237 100644 --- a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs @@ -48,6 +48,7 @@ namespace Jellyfin.Server.Extensions c.SwaggerEndpoint($"/{baseUrl}api-docs/openapi.json", "Jellyfin API"); c.InjectStylesheet($"/{baseUrl}api-docs/swagger/custom.css"); c.RoutePrefix = "api-docs/swagger"; + c.UseRequestInterceptor("""(req) => { req.headers['Authorization'] = `MediaBrowser Token=\"${req.headers['Authorization']}\"`; return req; }"""); }) .UseReDoc(c => { diff --git a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs index 9bf927bb95..a10be76e05 100644 --- a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs +++ b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs @@ -215,8 +215,11 @@ internal class JellyfinMigrationService logger.LogInformation("There are {Pending} migrations for stage {Stage}.", pendingCodeMigrations.Length, stage); migrations = pendingMigrations.OrderBy(e => e.Key).ToArray(); + var migrationIndex = 0; foreach (var item in migrations) { + // Surface generic "Running migration X of Y" progress in the always-visible startup UI header. + SetupServer.ReportActivity(StartupActivity.Migration(++migrationIndex, migrations.Length)); var migrationLogger = logger.With(_loggerFactory.CreateLogger(item.Migration.GetType().Name)).BeginGroup($"{item.Key}"); try { diff --git a/Jellyfin.Server/Migrations/Routines/20250420140000_MigrateAuthenticationDb.cs b/Jellyfin.Server/Migrations/Routines/20250420140000_MigrateAuthenticationDb.cs index 0de775e03a..cbdc6efb44 100644 --- a/Jellyfin.Server/Migrations/Routines/20250420140000_MigrateAuthenticationDb.cs +++ b/Jellyfin.Server/Migrations/Routines/20250420140000_MigrateAuthenticationDb.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using Emby.Server.Implementations.Data; using Jellyfin.Database.Implementations; @@ -79,9 +80,9 @@ namespace Jellyfin.Server.Migrations.Routines foreach (var row in authenticatedDevices) { var dateCreatedStr = row.GetString(9); - _ = DateTime.TryParse(dateCreatedStr, out var dateCreated); + _ = DateTime.TryParse(dateCreatedStr, CultureInfo.InvariantCulture, out var dateCreated); var dateLastActivityStr = row.GetString(10); - _ = DateTime.TryParse(dateLastActivityStr, out var dateLastActivity); + _ = DateTime.TryParse(dateLastActivityStr, CultureInfo.InvariantCulture, out var dateLastActivity); if (row.IsDBNull(6)) { diff --git a/Jellyfin.Server/Migrations/Routines/20260113230000_CleanupOrphanedExtras.cs b/Jellyfin.Server/Migrations/Routines/20260113230000_CleanupOrphanedExtras.cs index f4dfa49068..e8eeb2da20 100644 --- a/Jellyfin.Server/Migrations/Routines/20260113230000_CleanupOrphanedExtras.cs +++ b/Jellyfin.Server/Migrations/Routines/20260113230000_CleanupOrphanedExtras.cs @@ -76,25 +76,36 @@ public class CleanupOrphanedExtras : IAsyncMigrationRoutine _logger.LogInformation("Found {Count} orphaned extras to remove", orphanedItemIds.Count); - // Batch-resolve items for metadata path cleanup, then delete all at once - var itemsToDelete = new List<BaseItem>(); - foreach (var itemId in orphanedItemIds) + // Resolve items for metadata path cleanup, then delete in batches so we never issue one + // massive delete transaction and progress stays visible on large libraries. + _logger.LogInformation("Deleting {Count} orphaned extras...", orphanedItemIds.Count); + const int deleteBatchSize = 500; + var deletedSoFar = 0; + for (var offset = 0; offset < orphanedItemIds.Count; offset += deleteBatchSize) { - itemsToDelete.Add(BaseItemMapper.DeserializeBaseItem( - new Database.Implementations.Entities.BaseItemEntity() - { - Id = itemId.Id, - Path = itemId.Path, - Type = itemId.Type - }, - _logger, - null, - true)!); + cancellationToken.ThrowIfCancellationRequested(); + + var batch = orphanedItemIds.GetRange(offset, Math.Min(deleteBatchSize, orphanedItemIds.Count - offset)); + var itemsToDelete = batch + .Select(itemId => BaseItemMapper.DeserializeBaseItem( + new Database.Implementations.Entities.BaseItemEntity() + { + Id = itemId.Id, + Path = itemId.Path, + Type = itemId.Type + }, + _logger, + null, + true)!) + .ToList(); + + _libraryManager.DeleteItemsUnsafeFast(itemsToDelete); + + deletedSoFar += batch.Count; + _logger.LogInformation("Deleting orphaned extras: {Deleted}/{Total}", deletedSoFar, orphanedItemIds.Count); } - _libraryManager.DeleteItemsUnsafeFast(itemsToDelete); - - _logger.LogInformation("Successfully removed {Count} orphaned extras", itemsToDelete.Count); + _logger.LogInformation("Successfully removed {Count} orphaned extras", orphanedItemIds.Count); } } } diff --git a/Jellyfin.Server/Migrations/Routines/20260115120000_FixIncorrectOwnerIdRelationships.cs b/Jellyfin.Server/Migrations/Routines/20260115120000_FixIncorrectOwnerIdRelationships.cs index 0baf261a2e..e34182fd5d 100644 --- a/Jellyfin.Server/Migrations/Routines/20260115120000_FixIncorrectOwnerIdRelationships.cs +++ b/Jellyfin.Server/Migrations/Routines/20260115120000_FixIncorrectOwnerIdRelationships.cs @@ -136,19 +136,38 @@ public class FixIncorrectOwnerIdRelationships : IAsyncMigrationRoutine if (allIdsToDelete.Count > 0) { - // Batch-resolve items for metadata path cleanup, then delete all at once - var itemsToDelete = allIdsToDelete - .Select(id => _libraryManager.GetItemById(id)) - .Where(item => item is not null) - .ToList(); - _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!); + _logger.LogInformation("Deleting {Count} duplicate database entries...", allIdsToDelete.Count); - // Fall back to direct DB deletion for any items that couldn't be resolved via LibraryManager - var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet(); - var unresolvedIds = allIdsToDelete.Where(id => !deletedIds.Contains(id)).ToList(); - if (unresolvedIds.Count > 0) + // Delete in batches so progress is visible (item resolution and deletion can take a + // long time on large libraries) and so we never issue one massive delete transaction. + const int deleteBatchSize = 500; + var deletedSoFar = 0; + for (var offset = 0; offset < allIdsToDelete.Count; offset += deleteBatchSize) { - _persistenceService.DeleteItem(unresolvedIds); + cancellationToken.ThrowIfCancellationRequested(); + + var batchIds = allIdsToDelete.GetRange(offset, Math.Min(deleteBatchSize, allIdsToDelete.Count - offset)); + + // Resolve items for metadata path cleanup, then delete this batch + var itemsToDelete = batchIds + .Select(id => _libraryManager.GetItemById(id)) + .Where(item => item is not null) + .ToList(); + if (itemsToDelete.Count > 0) + { + _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!); + } + + // Fall back to direct DB deletion for any items that couldn't be resolved via LibraryManager + var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet(); + var unresolvedIds = batchIds.Where(id => !deletedIds.Contains(id)).ToList(); + if (unresolvedIds.Count > 0) + { + _persistenceService.DeleteItem(unresolvedIds); + } + + deletedSoFar += batchIds.Count; + _logger.LogInformation("Deleting duplicates: {Deleted}/{Total} items", deletedSoFar, allIdsToDelete.Count); } } diff --git a/Jellyfin.Server/Migrations/Routines/20260508120000_MergeDuplicateMusicArtists.cs b/Jellyfin.Server/Migrations/Routines/20260508120000_MergeDuplicateMusicArtists.cs index f598848465..bff6ebbfb0 100644 --- a/Jellyfin.Server/Migrations/Routines/20260508120000_MergeDuplicateMusicArtists.cs +++ b/Jellyfin.Server/Migrations/Routines/20260508120000_MergeDuplicateMusicArtists.cs @@ -182,23 +182,35 @@ public class MergeDuplicateMusicArtists : IAsyncMigrationRoutine // Resolve via LibraryManager so DeleteItemsUnsafeFast can also remove the // %MetadataPath%/artists/<Name> directories that the duplicate stubs left behind. // Fall back to the persistence service for any items the LibraryManager can't resolve. - var itemsToDelete = idsToDelete - .Select(id => _libraryManager.GetItemById(id)) - .Where(item => item is not null) - .ToList(); - if (itemsToDelete.Count > 0) + // Delete in batches so we never issue one massive delete transaction and progress stays visible. + _logger.LogInformation("Deleting {Count} duplicate MusicArtist records...", idsToDelete.Count); + const int deleteBatchSize = 500; + var deletedSoFar = 0; + for (var offset = 0; offset < idsToDelete.Count; offset += deleteBatchSize) { - _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!); - } + cancellationToken.ThrowIfCancellationRequested(); - var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet(); - var unresolvedIds = idsToDelete.Where(id => !deletedIds.Contains(id)).ToList(); - if (unresolvedIds.Count > 0) - { - _persistenceService.DeleteItem(unresolvedIds); - } + var batchIds = idsToDelete.GetRange(offset, Math.Min(deleteBatchSize, idsToDelete.Count - offset)); - _logger.LogInformation("Removed {Count} duplicate MusicArtist records.", idsToDelete.Count); + var itemsToDelete = batchIds + .Select(id => _libraryManager.GetItemById(id)) + .Where(item => item is not null) + .ToList(); + if (itemsToDelete.Count > 0) + { + _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!); + } + + var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet(); + var unresolvedIds = batchIds.Where(id => !deletedIds.Contains(id)).ToList(); + if (unresolvedIds.Count > 0) + { + _persistenceService.DeleteItem(unresolvedIds); + } + + deletedSoFar += batchIds.Count; + _logger.LogInformation("Deleting duplicate MusicArtist records: {Deleted}/{Total}", deletedSoFar, idsToDelete.Count); + } } } } diff --git a/Jellyfin.Server/Migrations/Routines/20260508130000_MergeDuplicatePeople.cs b/Jellyfin.Server/Migrations/Routines/20260508130000_MergeDuplicatePeople.cs index 10433599fa..f28c804d26 100644 --- a/Jellyfin.Server/Migrations/Routines/20260508130000_MergeDuplicatePeople.cs +++ b/Jellyfin.Server/Migrations/Routines/20260508130000_MergeDuplicatePeople.cs @@ -184,23 +184,35 @@ public class MergeDuplicatePeople : IAsyncMigrationRoutine // Resolve via LibraryManager so DeleteItemsUnsafeFast can also remove the // %MetadataPath%/People/<Letter>/<Name> directories the duplicate stubs left behind. - var itemsToDelete = idsToDelete - .Select(id => _libraryManager.GetItemById(id)) - .Where(item => item is not null) - .ToList(); - if (itemsToDelete.Count > 0) + // Delete in batches so we never issue one massive delete transaction and progress stays visible. + _logger.LogInformation("Deleting {Count} duplicate Person BaseItems...", idsToDelete.Count); + const int deleteBatchSize = 500; + var deletedSoFar = 0; + for (var offset = 0; offset < idsToDelete.Count; offset += deleteBatchSize) { - _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!); - } + cancellationToken.ThrowIfCancellationRequested(); - var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet(); - var unresolvedIds = idsToDelete.Where(id => !deletedIds.Contains(id)).ToList(); - if (unresolvedIds.Count > 0) - { - _persistenceService.DeleteItem(unresolvedIds); - } + var batchIds = idsToDelete.GetRange(offset, Math.Min(deleteBatchSize, idsToDelete.Count - offset)); - _logger.LogInformation("Removed {Count} duplicate Person BaseItems.", idsToDelete.Count); + var itemsToDelete = batchIds + .Select(id => _libraryManager.GetItemById(id)) + .Where(item => item is not null) + .ToList(); + if (itemsToDelete.Count > 0) + { + _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!); + } + + var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet(); + var unresolvedIds = batchIds.Where(id => !deletedIds.Contains(id)).ToList(); + if (unresolvedIds.Count > 0) + { + _persistenceService.DeleteItem(unresolvedIds); + } + + deletedSoFar += batchIds.Count; + _logger.LogInformation("Deleting duplicate Person BaseItems: {Deleted}/{Total}", deletedSoFar, idsToDelete.Count); + } } private async Task MergePeoplesRowsAsync(JellyfinDbContext context, CancellationToken cancellationToken) diff --git a/Jellyfin.Server/Migrations/Routines/20260610120000_RefreshCleanNamesAndValues.cs b/Jellyfin.Server/Migrations/Routines/20260610120000_RefreshCleanNamesAndValues.cs new file mode 100644 index 0000000000..7ade727d9b --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260610120000_RefreshCleanNamesAndValues.cs @@ -0,0 +1,173 @@ +using System; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using Jellyfin.Extensions; +using Jellyfin.Server.ServerSetupApp; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// Migration to refresh CleanName values for all library items and CleanValue values for all item values. +/// </summary> +[JellyfinMigration("2026-06-10T12:00:00", nameof(RefreshCleanNamesAndValues))] +[JellyfinMigrationBackup(JellyfinDb = true)] +public class RefreshCleanNamesAndValues : IAsyncMigrationRoutine +{ + private readonly IStartupLogger<RefreshCleanNamesAndValues> _logger; + private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; + + /// <summary> + /// Initializes a new instance of the <see cref="RefreshCleanNamesAndValues"/> class. + /// </summary> + /// <param name="logger">The logger.</param> + /// <param name="dbProvider">Instance of the <see cref="IDbContextFactory{JellyfinDbContext}"/> interface.</param> + public RefreshCleanNamesAndValues( + IStartupLogger<RefreshCleanNamesAndValues> logger, + IDbContextFactory<JellyfinDbContext> dbProvider) + { + _logger = logger; + _dbProvider = dbProvider; + } + + /// <inheritdoc /> + public async Task PerformAsync(CancellationToken cancellationToken) + { + await RefreshCleanNamesAsync(cancellationToken).ConfigureAwait(false); + await RefreshCleanValuesAsync(cancellationToken).ConfigureAwait(false); + } + + private async Task RefreshCleanNamesAsync(CancellationToken cancellationToken) + { + const int Limit = 10000; + int itemCount = 0; + + var sw = Stopwatch.StartNew(); + + using var context = _dbProvider.CreateDbContext(); + var records = context.BaseItems.Count(b => !string.IsNullOrEmpty(b.Name)); + _logger.LogInformation("Refreshing CleanName for {Count} library items", records); + + var processedInPartition = 0; + + await foreach (var item in context.BaseItems + .Where(b => !string.IsNullOrEmpty(b.Name)) + .OrderBy(e => e.Id) + .WithPartitionProgress((partition) => _logger.LogInformation("Processed: {Offset}/{Total} - Updated: {UpdatedCount} - Time: {Elapsed}", partition * Limit, records, itemCount, sw.Elapsed)) + .PartitionEagerAsync(Limit, cancellationToken) + .WithCancellation(cancellationToken) + .ConfigureAwait(false)) + { + try + { + var newCleanName = string.IsNullOrWhiteSpace(item.Name) ? string.Empty : item.Name.GetCleanValue(); + if (!string.Equals(newCleanName, item.CleanName, StringComparison.Ordinal)) + { + _logger.LogDebug( + "Updating CleanName for item {Id}: '{OldValue}' -> '{NewValue}'", + item.Id, + item.CleanName, + newCleanName); + item.CleanName = newCleanName; + itemCount++; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to update CleanName for item {Id} ({Name})", item.Id, item.Name); + } + + processedInPartition++; + + if (processedInPartition >= Limit) + { + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + // Clear tracked entities to avoid memory growth across partitions + context.ChangeTracker.Clear(); + processedInPartition = 0; + } + } + + // Save any remaining changes after the loop + if (processedInPartition > 0) + { + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + context.ChangeTracker.Clear(); + } + + _logger.LogInformation( + "Refreshed CleanName for {UpdatedCount} out of {TotalCount} items in {Time}", + itemCount, + records, + sw.Elapsed); + } + + private async Task RefreshCleanValuesAsync(CancellationToken cancellationToken) + { + const int Limit = 10000; + int itemCount = 0; + + var sw = Stopwatch.StartNew(); + + using var context = _dbProvider.CreateDbContext(); + var records = context.ItemValues.Count(b => !string.IsNullOrEmpty(b.Value)); + _logger.LogInformation("Refreshing CleanValue for {Count} item values", records); + + var processedInPartition = 0; + + await foreach (var item in context.ItemValues + .Where(b => !string.IsNullOrEmpty(b.Value)) + .OrderBy(e => e.ItemValueId) + .WithPartitionProgress((partition) => _logger.LogInformation("Processed: {Offset}/{Total} - Updated: {UpdatedCount} - Time: {Elapsed}", partition * Limit, records, itemCount, sw.Elapsed)) + .PartitionEagerAsync(Limit, cancellationToken) + .WithCancellation(cancellationToken) + .ConfigureAwait(false)) + { + try + { + var newCleanValue = string.IsNullOrWhiteSpace(item.Value) ? string.Empty : item.Value.GetCleanValue(); + if (!string.Equals(newCleanValue, item.CleanValue, StringComparison.Ordinal)) + { + _logger.LogDebug( + "Updating CleanValue for item value {Id}: '{OldValue}' -> '{NewValue}'", + item.ItemValueId, + item.CleanValue, + newCleanValue); + item.CleanValue = newCleanValue; + itemCount++; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to update CleanValue for item value {Id} ({Value})", item.ItemValueId, item.Value); + } + + processedInPartition++; + + if (processedInPartition >= Limit) + { + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + // Clear tracked entities to avoid memory growth across partitions + context.ChangeTracker.Clear(); + processedInPartition = 0; + } + } + + // Save any remaining changes after the loop + if (processedInPartition > 0) + { + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + context.ChangeTracker.Clear(); + } + + _logger.LogInformation( + "Refreshed CleanValue for {UpdatedCount} out of {TotalCount} item values in {Time}", + itemCount, + records, + sw.Elapsed); + } +} diff --git a/Jellyfin.Server/Migrations/Routines/20251008120000_RefreshCleanNames.cs b/Jellyfin.Server/Migrations/Routines/20260722120000_RefreshForcedSortNames.cs similarity index 55% rename from Jellyfin.Server/Migrations/Routines/20251008120000_RefreshCleanNames.cs rename to Jellyfin.Server/Migrations/Routines/20260722120000_RefreshForcedSortNames.cs index eca50ac100..e9eefc20dc 100644 --- a/Jellyfin.Server/Migrations/Routines/20251008120000_RefreshCleanNames.cs +++ b/Jellyfin.Server/Migrations/Routines/20260722120000_RefreshForcedSortNames.cs @@ -6,32 +6,38 @@ using System.Threading.Tasks; using Jellyfin.Database.Implementations; using Jellyfin.Extensions; using Jellyfin.Server.ServerSetupApp; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace Jellyfin.Server.Migrations.Routines; /// <summary> -/// Migration to refresh CleanName values for all library items. +/// Migration to recompute the SortName of all items that have a forced sort name. /// </summary> -[JellyfinMigration("2025-10-08T12:00:00", nameof(RefreshCleanNames))] +[JellyfinMigration("2026-07-22T12:00:00", nameof(RefreshForcedSortNames))] [JellyfinMigrationBackup(JellyfinDb = true)] -public class RefreshCleanNames : IAsyncMigrationRoutine +public class RefreshForcedSortNames : IAsyncMigrationRoutine { - private readonly IStartupLogger<RefreshCleanNames> _logger; + private readonly IStartupLogger<RefreshForcedSortNames> _logger; private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; + private readonly IServerConfigurationManager _configurationManager; /// <summary> - /// Initializes a new instance of the <see cref="RefreshCleanNames"/> class. + /// Initializes a new instance of the <see cref="RefreshForcedSortNames"/> class. /// </summary> /// <param name="logger">The logger.</param> /// <param name="dbProvider">Instance of the <see cref="IDbContextFactory{JellyfinDbContext}"/> interface.</param> - public RefreshCleanNames( - IStartupLogger<RefreshCleanNames> logger, - IDbContextFactory<JellyfinDbContext> dbProvider) + /// <param name="configurationManager">The server configuration manager providing the sort rules.</param> + public RefreshForcedSortNames( + IStartupLogger<RefreshForcedSortNames> logger, + IDbContextFactory<JellyfinDbContext> dbProvider, + IServerConfigurationManager configurationManager) { _logger = logger; _dbProvider = dbProvider; + _configurationManager = configurationManager; } /// <inheritdoc /> @@ -40,16 +46,20 @@ public class RefreshCleanNames : IAsyncMigrationRoutine const int Limit = 10000; int itemCount = 0; + var configuration = _configurationManager.Configuration; + // Only the Person type disables alphanumeric sorting; everything else uses the cleaning rules. + var personType = typeof(Person).ToString(); + var sw = Stopwatch.StartNew(); using var context = _dbProvider.CreateDbContext(); - var records = context.BaseItems.Count(b => !string.IsNullOrEmpty(b.Name)); - _logger.LogInformation("Refreshing CleanName for {Count} library items", records); + var records = context.BaseItems.Count(b => !string.IsNullOrEmpty(b.ForcedSortName)); + _logger.LogInformation("Refreshing SortName for {Count} library items with a forced sort name", records); var processedInPartition = 0; await foreach (var item in context.BaseItems - .Where(b => !string.IsNullOrEmpty(b.Name)) + .Where(b => !string.IsNullOrEmpty(b.ForcedSortName)) .OrderBy(e => e.Id) .WithPartitionProgress((partition) => _logger.LogInformation("Processed: {Offset}/{Total} - Updated: {UpdatedCount} - Time: {Elapsed}", partition * Limit, records, itemCount, sw.Elapsed)) .PartitionEagerAsync(Limit, cancellationToken) @@ -58,21 +68,22 @@ public class RefreshCleanNames : IAsyncMigrationRoutine { try { - var newCleanName = string.IsNullOrWhiteSpace(item.Name) ? string.Empty : item.Name.GetCleanValue(); - if (!string.Equals(newCleanName, item.CleanName, StringComparison.Ordinal)) + var enableAlphaNumericSorting = !string.Equals(item.Type, personType, StringComparison.Ordinal); + var newSortName = BaseItem.GetSortName(item.ForcedSortName!, enableAlphaNumericSorting, configuration); + if (!string.Equals(newSortName, item.SortName, StringComparison.Ordinal)) { _logger.LogDebug( - "Updating CleanName for item {Id}: '{OldValue}' -> '{NewValue}'", + "Updating SortName for item {Id}: '{OldValue}' -> '{NewValue}'", item.Id, - item.CleanName, - newCleanName); - item.CleanName = newCleanName; + item.SortName, + newSortName); + item.SortName = newSortName; itemCount++; } } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to update CleanName for item {Id} ({Name})", item.Id, item.Name); + _logger.LogWarning(ex, "Failed to update SortName for item {Id} ({Name})", item.Id, item.Name); } processedInPartition++; @@ -94,7 +105,7 @@ public class RefreshCleanNames : IAsyncMigrationRoutine } _logger.LogInformation( - "Refreshed CleanName for {UpdatedCount} out of {TotalCount} items in {Time}", + "Refreshed SortName for {UpdatedCount} out of {TotalCount} items in {Time}", itemCount, records, sw.Elapsed); diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs index af0d424aad..12f92efb35 100644 --- a/Jellyfin.Server/Program.cs +++ b/Jellyfin.Server/Program.cs @@ -133,10 +133,12 @@ namespace Jellyfin.Server } } + SetupServer.ReportActivity(StartupActivity.CheckingStorage); StorageHelper.TestCommonPathsForStorageCapacity(appPaths, StartupLogger.Logger.With(_loggerFactory.CreateLogger<Startup>()).BeginGroup($"Storage Check")); StartupHelpers.PerformStaticInitialization(); + SetupServer.ReportActivity(StartupActivity.Initializing); await ApplyStartupMigrationAsync(appPaths, startupConfig, options).ConfigureAwait(false); do @@ -195,6 +197,7 @@ namespace Jellyfin.Server if (!string.IsNullOrWhiteSpace(_restoreFromBackup)) { + SetupServer.ReportActivity(StartupActivity.RestoringBackup); await appHost.ServiceProvider.GetService<IBackupService>()!.RestoreBackupAsync(_restoreFromBackup).ConfigureAwait(false); _restoreFromBackup = null; _restartOnShutdown = true; @@ -202,9 +205,13 @@ namespace Jellyfin.Server } var jellyfinMigrationService = ActivatorUtilities.CreateInstance<JellyfinMigrationService>(appHost.ServiceProvider); + SetupServer.ReportActivity(StartupActivity.PreparingMigrations); await jellyfinMigrationService.PrepareSystemForMigration(_logger).ConfigureAwait(false); + // "Preparing migrations" carries through the DB read; per-migration progress is reported + // as "Running migration X of Y" from inside the step once the pending set is known. await jellyfinMigrationService.MigrateStepAsync(JellyfinMigrationStageTypes.CoreInitialisation, appHost.ServiceProvider).ConfigureAwait(false); + SetupServer.ReportActivity(StartupActivity.InitializingServices); await appHost.InitializeServices(startupConfig).ConfigureAwait(false); _appHost = appHost; diff --git a/Jellyfin.Server/ServerSetupApp/SetupServer.cs b/Jellyfin.Server/ServerSetupApp/SetupServer.cs index 37bb1abe71..598de5aa5f 100644 --- a/Jellyfin.Server/ServerSetupApp/SetupServer.cs +++ b/Jellyfin.Server/ServerSetupApp/SetupServer.cs @@ -14,7 +14,6 @@ using Jellyfin.Server.Extensions; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Net; using MediaBrowser.Controller; -using MediaBrowser.Model.IO; using MediaBrowser.Model.System; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -25,9 +24,6 @@ using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; -using Morestachio; -using Morestachio.Framework.IO.SingleStream; -using Morestachio.Rendering; using Serilog; using ILogger = Microsoft.Extensions.Logging.ILogger; @@ -44,7 +40,8 @@ public sealed class SetupServer : IDisposable private readonly ILoggerFactory _loggerFactory; private readonly IConfiguration _startupConfiguration; private readonly ServerConfigurationManager _configurationManager; - private IRenderer? _startupUiRenderer; + private static volatile string _currentActivity = StartupActivity.Starting; + private StartupUiRenderer? _startupUiRenderer; private IHost? _startupServer; private bool _disposed; private bool _isUnhealthy; @@ -76,6 +73,12 @@ public sealed class SetupServer : IDisposable internal static ConcurrentQueue<StartupLogTopic>? LogQueue { get; set; } = new(); + /// <summary> + /// Gets a generic, non-identifying summary of what startup is currently doing. This is shown in the + /// always-visible header of the startup UI to unauthenticated clients, so it never contains server specific details. + /// </summary> + internal static string CurrentActivity => _currentActivity; + /// <summary> /// Gets a value indicating whether Startup server is currently running. /// </summary> @@ -87,64 +90,9 @@ public sealed class SetupServer : IDisposable /// <returns>A Task.</returns> public async Task RunAsync() { - var fileTemplate = await File.ReadAllTextAsync(Path.Combine(AppContext.BaseDirectory, "ServerSetupApp", "index.mstemplate.html")).ConfigureAwait(false); - _startupUiRenderer = (await ParserOptionsBuilder.New() - .WithTemplate(fileTemplate) - .WithFormatter( - (Version version, int arg) => - { - // version type does not for some stupid reason implement IFormattable which morestachio relies on for ToString support therefor we need to do it manually. - return version.ToString(arg); - }, - "ToString") - .WithFormatter( - (StartupLogTopic logEntry, IEnumerable<StartupLogTopic> children) => - { - if (children.Any()) - { - var maxLevel = logEntry.LogLevel; - var stack = new Stack<StartupLogTopic>(children); - - while (maxLevel != LogLevel.Error && stack.Count > 0 && (logEntry = stack.Pop()) is not null) // error is the highest inherted error level. - { - maxLevel = maxLevel < logEntry.LogLevel ? logEntry.LogLevel : maxLevel; - foreach (var child in logEntry.Children) - { - stack.Push(child); - } - } - - return maxLevel; - } - - return logEntry.LogLevel; - }, - "FormatLogLevel") - .WithFormatter( - (LogLevel logLevel) => - { - switch (logLevel) - { - case LogLevel.Trace: - case LogLevel.Debug: - case LogLevel.None: - return "success"; - case LogLevel.Information: - return "info"; - case LogLevel.Warning: - return "warn"; - case LogLevel.Error: - return "danger"; - case LogLevel.Critical: - return "danger-strong"; - } - - return string.Empty; - }, - "ToString") - .BuildAndParseAsync() - .ConfigureAwait(false)) - .CreateCompiledRenderer(); + ReportActivity(StartupActivity.Starting); + _startupUiRenderer = await StartupUiRenderer.CreateAsync( + Path.Combine(AppContext.BaseDirectory, "ServerSetupApp", "index.mstemplate.html")).ConfigureAwait(false); ThrowIfDisposed(); var retryAfterValue = TimeSpan.FromSeconds(5); @@ -257,13 +205,14 @@ public sealed class SetupServer : IDisposable new Dictionary<string, object>() { { "isInReportingMode", _isUnhealthy }, + { "currentActivity", CurrentActivity }, { "retryValue", retryAfterValue }, { "version", version }, { "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)) + context.Response.BodyWriter.AsStream()) .ConfigureAwait(false); }); }); @@ -309,6 +258,16 @@ public sealed class SetupServer : IDisposable ObjectDisposedException.ThrowIf(_disposed, this); } + /// <summary> + /// Reports the current startup activity shown to all clients in the startup UI header. + /// Only pass generic, non-identifying text from <see cref="StartupActivity"/>. + /// </summary> + /// <param name="activity">A generic description such as <see cref="StartupActivity.PreparingMigrations"/>.</param> + internal static void ReportActivity(string activity) + { + _currentActivity = activity; + } + internal void SoftStop() { _isUnhealthy = true; diff --git a/Jellyfin.Server/ServerSetupApp/StartupActivity.cs b/Jellyfin.Server/ServerSetupApp/StartupActivity.cs new file mode 100644 index 0000000000..888cc617d4 --- /dev/null +++ b/Jellyfin.Server/ServerSetupApp/StartupActivity.cs @@ -0,0 +1,41 @@ +using System.Globalization; + +namespace Jellyfin.Server.ServerSetupApp; + +/// <summary> +/// A curated vocabulary of generic, non-identifying descriptions of what the server is doing during startup. +/// These are shown in the always-visible header of the startup UI to <b>unauthenticated</b> clients, so every +/// value must stay generic and must never contain server specific details (paths, names, plugin or migration ids, counts of items, etc.). +/// </summary> +public static class StartupActivity +{ + /// <summary>The default state before any work has been reported.</summary> + public const string Starting = "Starting up"; + + /// <summary>Validating that the configured storage locations are usable.</summary> + public const string CheckingStorage = "Checking storage"; + + /// <summary>Bringing up the migration subsystem and running early startup checks.</summary> + public const string Initializing = "Initializing server"; + + /// <summary>Preparing the system for migrations (e.g. taking safety backups).</summary> + public const string PreparingMigrations = "Preparing migrations"; + + /// <summary>Restoring from a backup.</summary> + public const string RestoringBackup = "Restoring backup"; + + /// <summary>Bringing up core services and plugins.</summary> + public const string InitializingServices = "Initializing services"; + + /// <summary>Running the final startup tasks.</summary> + public const string FinishingStartup = "Finishing startup"; + + /// <summary> + /// Builds a generic "Running migration X of Y" description. Only the numeric position and total are exposed. + /// </summary> + /// <param name="current">The 1-based index of the migration currently running.</param> + /// <param name="total">The total number of migrations in this batch.</param> + /// <returns>A generic progress description.</returns> + public static string Migration(int current, int total) + => string.Format(CultureInfo.InvariantCulture, "Running migration {0} of {1}", current, total); +} diff --git a/Jellyfin.Server/ServerSetupApp/StartupUiRenderer.cs b/Jellyfin.Server/ServerSetupApp/StartupUiRenderer.cs new file mode 100644 index 0000000000..db07b9d8c1 --- /dev/null +++ b/Jellyfin.Server/ServerSetupApp/StartupUiRenderer.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.Logging; +using Morestachio; +using Morestachio.Framework.IO.SingleStream; +using Morestachio.Rendering; + +namespace Jellyfin.Server.ServerSetupApp; + +/// <summary> +/// Compiles and renders the startup UI Morestachio template. +/// Shared by the live <see cref="SetupServer"/> and the standalone startup UI preview tool so both +/// exercise the exact same template and formatters. +/// </summary> +public sealed class StartupUiRenderer +{ + private readonly IRenderer _renderer; + + private StartupUiRenderer(IRenderer renderer) + { + _renderer = renderer; + } + + /// <summary> + /// Compiles the startup UI template located at <paramref name="templatePath"/>. + /// </summary> + /// <param name="templatePath">The full path to the <c>index.mstemplate.html</c> template.</param> + /// <returns>A ready to use <see cref="StartupUiRenderer"/>.</returns> + public static async Task<StartupUiRenderer> CreateAsync(string templatePath) + { + var fileTemplate = await File.ReadAllTextAsync(templatePath).ConfigureAwait(false); + var renderer = (await ParserOptionsBuilder.New() + .WithTemplate(fileTemplate) + .WithFormatter( + (Version version, int arg) => + { + // version type does not for some stupid reason implement IFormattable which morestachio relies on for ToString support therefor we need to do it manually. + return version.ToString(arg); + }, + "ToString") + .WithFormatter( + (StartupLogTopic logEntry, IEnumerable<StartupLogTopic> children) => + { + if (children.Any()) + { + var maxLevel = logEntry.LogLevel; + var stack = new Stack<StartupLogTopic>(children); + + while (maxLevel != LogLevel.Error && stack.Count > 0 && (logEntry = stack.Pop()) is not null) // error is the highest inherted error level. + { + maxLevel = maxLevel < logEntry.LogLevel ? logEntry.LogLevel : maxLevel; + foreach (var child in logEntry.Children) + { + stack.Push(child); + } + } + + return maxLevel; + } + + return logEntry.LogLevel; + }, + "FormatLogLevel") + .WithFormatter( + (LogLevel logLevel) => + { + switch (logLevel) + { + case LogLevel.Trace: + case LogLevel.Debug: + case LogLevel.None: + return "success"; + case LogLevel.Information: + return "info"; + case LogLevel.Warning: + return "warn"; + case LogLevel.Error: + return "danger"; + case LogLevel.Critical: + return "danger-strong"; + } + + return string.Empty; + }, + "ToString") + .BuildAndParseAsync() + .ConfigureAwait(false)) + .CreateCompiledRenderer(); + + return new StartupUiRenderer(renderer); + } + + /// <summary> + /// Renders the template with the provided model into the target stream. + /// </summary> + /// <param name="model">The values made available to the template.</param> + /// <param name="output">The stream the rendered HTML is written to.</param> + /// <returns>A Task.</returns> + public Task RenderAsync(IDictionary<string, object> model, Stream output) + { + return _renderer.RenderAsync( + model, + new ByteCounterStream(output, IODefaults.FileStreamBufferSize, true, _renderer.ParserOptions)); + } +} diff --git a/Jellyfin.Server/ServerSetupApp/index.mstemplate.html b/Jellyfin.Server/ServerSetupApp/index.mstemplate.html index 5706ce1fac..9c12762c31 100644 --- a/Jellyfin.Server/ServerSetupApp/index.mstemplate.html +++ b/Jellyfin.Server/ServerSetupApp/index.mstemplate.html @@ -1,189 +1,469 @@ <!DOCTYPE html> -<html> +<html lang="en"> <head> <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <meta name="color-scheme" content="dark" /> + <meta name="theme-color" content="#202020" /> <title> {{#IF isInReportingMode}} ❌ {{/IF}} - Jellyfin Startup + Jellyfin -
-
+
+ - {{^IF isInReportingMode}} -

Jellyfin Server {{version.ToString(2)}} still starting. Please wait.

- {{#ELSE}} -

Jellyfin Server {{version.ToString(2)}} has encountered an error and was not able to start.

- {{/ELSE}} - {{/IF}} - - {{#IF localNetworkRequest}} -

You can download the current log file here.

- {{/IF}} -
+ {{^IF isInReportingMode}} +
+ +

Jellyfin is still starting. Please wait… {{currentActivity}}

+
+ {{#ELSE}} +
+

Jellyfin has encountered an error and was not able to start.

+
+ {{/ELSE}} + {{/IF}} {{#DECLARE LogEntry |--}} {{#LET children = Children}} @@ -192,7 +472,7 @@
{{DateOfCreation}} - {{Content}}
    - {{--| #EACH children.Reverse() |-}} + {{--| #EACH children |-}} {{#IMPORT 'LogEntry'}} {{--| /EACH |-}}
@@ -205,31 +485,175 @@ {{--| /DECLARE}} {{#IF localNetworkRequest}} -
-
    - {{#FOREACH log IN logs.Reverse()}} - {{#IMPORT 'LogEntry' #WITH log}} - {{/FOREACH}} -
+
+
+

Startup log

+ Download logs +
+
+
    + {{#FOREACH log IN logs}} + {{#IMPORT 'LogEntry' #WITH log}} + {{/FOREACH}} +
+
- {{#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}}
+ -{{^IF isInReportingMode}} - -{{/IF}} - diff --git a/MediaBrowser.Controller/Channels/ChannelItemResult.cs b/MediaBrowser.Controller/Channels/ChannelItemResult.cs index ca7721991d..9557c91964 100644 --- a/MediaBrowser.Controller/Channels/ChannelItemResult.cs +++ b/MediaBrowser.Controller/Channels/ChannelItemResult.cs @@ -1,19 +1,29 @@ -#pragma warning disable CS1591 - using System; using System.Collections.Generic; namespace MediaBrowser.Controller.Channels { + /// + /// The result of a channel item query. + /// public class ChannelItemResult { + /// + /// Initializes a new instance of the class. + /// public ChannelItemResult() { Items = Array.Empty(); } + /// + /// Gets or sets the items. + /// public IReadOnlyList Items { get; set; } + /// + /// Gets or sets the total record count. + /// public int? TotalRecordCount { get; set; } } } diff --git a/MediaBrowser.Controller/Channels/ChannelItemType.cs b/MediaBrowser.Controller/Channels/ChannelItemType.cs index 3ce920e236..2608cb4c88 100644 --- a/MediaBrowser.Controller/Channels/ChannelItemType.cs +++ b/MediaBrowser.Controller/Channels/ChannelItemType.cs @@ -1,11 +1,18 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Controller.Channels { + /// + /// The type of a channel item. + /// public enum ChannelItemType { + /// + /// The item is a media item. + /// Media = 0, + /// + /// The item is a folder. + /// Folder = 1 } } diff --git a/MediaBrowser.Controller/Channels/ChannelLatestMediaSearch.cs b/MediaBrowser.Controller/Channels/ChannelLatestMediaSearch.cs index ebbe13763b..c6530814b9 100644 --- a/MediaBrowser.Controller/Channels/ChannelLatestMediaSearch.cs +++ b/MediaBrowser.Controller/Channels/ChannelLatestMediaSearch.cs @@ -1,11 +1,15 @@ #nullable disable -#pragma warning disable CS1591 - namespace MediaBrowser.Controller.Channels { + /// + /// The request for a latest media search in a channel. + /// public class ChannelLatestMediaSearch { + /// + /// Gets or sets the user id. + /// public string UserId { get; set; } } } diff --git a/MediaBrowser.Controller/Channels/ChannelParentalRating.cs b/MediaBrowser.Controller/Channels/ChannelParentalRating.cs index f77d81c166..a5a1ba5bf6 100644 --- a/MediaBrowser.Controller/Channels/ChannelParentalRating.cs +++ b/MediaBrowser.Controller/Channels/ChannelParentalRating.cs @@ -1,17 +1,33 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Controller.Channels { + /// + /// The parental rating of a channel. + /// public enum ChannelParentalRating { + /// + /// Suitable for a general audience. + /// GeneralAudience = 0, + /// + /// Parental guidance suggested (US PG). + /// UsPG = 1, + /// + /// Parents strongly cautioned (US PG-13). + /// UsPG13 = 2, + /// + /// Restricted (US R). + /// UsR = 3, + /// + /// Suitable for adults only. + /// Adult = 4 } } diff --git a/MediaBrowser.Controller/Channels/ChannelSearchInfo.cs b/MediaBrowser.Controller/Channels/ChannelSearchInfo.cs index 990b025bcb..d172b98b25 100644 --- a/MediaBrowser.Controller/Channels/ChannelSearchInfo.cs +++ b/MediaBrowser.Controller/Channels/ChannelSearchInfo.cs @@ -1,13 +1,20 @@ #nullable disable -#pragma warning disable CS1591 - namespace MediaBrowser.Controller.Channels { + /// + /// The request for a search in a channel. + /// public class ChannelSearchInfo { + /// + /// Gets or sets the search term. + /// public string SearchTerm { get; set; } + /// + /// Gets or sets the user id. + /// public string UserId { get; set; } } } diff --git a/MediaBrowser.Controller/Channels/IHasCacheKey.cs b/MediaBrowser.Controller/Channels/IHasCacheKey.cs index 7d5207c34a..4cdda38bd9 100644 --- a/MediaBrowser.Controller/Channels/IHasCacheKey.cs +++ b/MediaBrowser.Controller/Channels/IHasCacheKey.cs @@ -1,14 +1,15 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Controller.Channels { + /// + /// Interface for channels that provide a cache key. + /// public interface IHasCacheKey { /// /// Gets the cache key. /// /// The user identifier. - /// System.String. + /// The cache key. string? GetCacheKey(string? userId); } } diff --git a/MediaBrowser.Controller/Channels/ISupportsDelete.cs b/MediaBrowser.Controller/Channels/ISupportsDelete.cs index 0110bfa7a3..194654ca9e 100644 --- a/MediaBrowser.Controller/Channels/ISupportsDelete.cs +++ b/MediaBrowser.Controller/Channels/ISupportsDelete.cs @@ -1,15 +1,27 @@ -#pragma warning disable CS1591 - using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Entities; namespace MediaBrowser.Controller.Channels { + /// + /// Interface for channels that support deleting items. + /// public interface ISupportsDelete { + /// + /// Gets a value indicating whether the item can be deleted. + /// + /// The item. + /// true if the item can be deleted, false otherwise. bool CanDelete(BaseItem item); + /// + /// Deletes the item with the provided id. + /// + /// The item id. + /// The cancellation token. + /// A task representing the deletion of the item. Task DeleteItem(string id, CancellationToken cancellationToken); } } diff --git a/MediaBrowser.Controller/Channels/ISupportsLatestMedia.cs b/MediaBrowser.Controller/Channels/ISupportsLatestMedia.cs index 1935ec0f5f..82ca45d3ad 100644 --- a/MediaBrowser.Controller/Channels/ISupportsLatestMedia.cs +++ b/MediaBrowser.Controller/Channels/ISupportsLatestMedia.cs @@ -1,11 +1,12 @@ -#pragma warning disable CS1591 - using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; namespace MediaBrowser.Controller.Channels { + /// + /// Interface for channels that support retrieving the latest media. + /// public interface ISupportsLatestMedia { /// diff --git a/MediaBrowser.Controller/ClientEvent/ClientEventLogger.cs b/MediaBrowser.Controller/ClientEvent/ClientEventLogger.cs index 14dc64dabd..36f0d2195c 100644 --- a/MediaBrowser.Controller/ClientEvent/ClientEventLogger.cs +++ b/MediaBrowser.Controller/ClientEvent/ClientEventLogger.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.Threading.Tasks; +using Jellyfin.Extensions; namespace MediaBrowser.Controller.ClientEvent { @@ -21,8 +22,15 @@ namespace MediaBrowser.Controller.ClientEvent /// public async Task WriteDocumentAsync(string clientName, string clientVersion, Stream fileContents) { - var fileName = $"upload_{clientName}_{clientVersion}_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}.log"; + var safeClientName = PathHelper.GetSafeLeafFileName(clientName) ?? "unknown-client"; + var safeClientVersion = PathHelper.GetSafeLeafFileName(clientVersion) ?? "unknown-version"; + var fileName = $"upload_{safeClientName}_{safeClientVersion}_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}.log"; var logFilePath = Path.Combine(_applicationPaths.LogDirectoryPath, fileName); + if (!PathHelper.IsContainedIn(_applicationPaths.LogDirectoryPath, logFilePath)) + { + throw new ArgumentException("Path resolved to filename not in log directory"); + } + var fileStream = new FileStream(logFilePath, FileMode.CreateNew, FileAccess.Write, FileShare.None); await using (fileStream.ConfigureAwait(false)) { diff --git a/MediaBrowser.Controller/Dto/DtoOptions.cs b/MediaBrowser.Controller/Dto/DtoOptions.cs index d319feb6b2..052626355f 100644 --- a/MediaBrowser.Controller/Dto/DtoOptions.cs +++ b/MediaBrowser.Controller/Dto/DtoOptions.cs @@ -81,13 +81,6 @@ namespace MediaBrowser.Controller.Dto /// public bool AddCurrentProgram { get; set; } - /// - /// Gets or sets a value indicating whether an episode's portrait poster (its season's primary - /// image, falling back to the series') should replace the episode's own (16:9) primary image. - /// Used by views that render episodes as poster cards, e.g. "Latest". - /// - public bool PreferEpisodeParentPoster { get; set; } - /// /// Gets a value indicating whether the specified field is populated. /// diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index 21304768bd..21a726aaec 100644 --- a/MediaBrowser.Controller/Entities/BaseItem.cs +++ b/MediaBrowser.Controller/Entities/BaseItem.cs @@ -27,6 +27,7 @@ using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaSegments; using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; @@ -87,6 +88,8 @@ namespace MediaBrowser.Controller.Entities Model.Entities.ExtraType.Short }; + private static readonly char[] VersionDelimiters = ['-', '_', '.']; + private string _sortName; private string _forcedSortName; @@ -538,8 +541,8 @@ namespace MediaBrowser.Controller.Entities { if (!string.IsNullOrEmpty(ForcedSortName)) { - // Need the ToLower because that's what CreateSortName does - _sortName = ModifySortChunks(ForcedSortName).ToLowerInvariant(); + // Run the forced sort name through the same cleaning as auto-generated sort names. + _sortName = GetSortName(ForcedSortName, EnableAlphaNumericSorting, ConfigurationManager.Configuration); } else { @@ -924,19 +927,31 @@ namespace MediaBrowser.Controller.Entities /// System.String. protected virtual string CreateSortName() { - if (Name is null) + return GetSortName(Name, EnableAlphaNumericSorting, ConfigurationManager.Configuration); + } + + /// + /// Cleans a raw name into its sortable form by applying the configured sort rules. + /// + /// The raw name to clean. + /// Whether alphanumeric sorting rules should be applied. + /// The server configuration providing the sort rules. + /// The cleaned, sortable name, or null if is null. + public static string GetSortName(string name, bool enableAlphaNumericSorting, ServerConfiguration configuration) + { + if (name is null) { return null; // some items may not have name filled in properly } - if (!EnableAlphaNumericSorting) + if (!enableAlphaNumericSorting) { - return Name.TrimStart(); + return name.TrimStart(); } - var sortable = Name.Trim().ToLowerInvariant(); + var sortable = name.Trim().ToLowerInvariant(); - foreach (var search in ConfigurationManager.Configuration.SortRemoveWords) + foreach (var search in configuration.SortRemoveWords) { // Remove from beginning if a space follows if (sortable.StartsWith(search + " ", StringComparison.Ordinal)) @@ -954,12 +969,12 @@ namespace MediaBrowser.Controller.Entities } } - foreach (var removeChar in ConfigurationManager.Configuration.SortRemoveCharacters) + foreach (var removeChar in configuration.SortRemoveCharacters) { sortable = sortable.Replace(removeChar, string.Empty, StringComparison.Ordinal); } - foreach (var replaceChar in ConfigurationManager.Configuration.SortReplaceCharacters) + foreach (var replaceChar in configuration.SortReplaceCharacters) { sortable = sortable.Replace(replaceChar, " ", StringComparison.Ordinal); } @@ -1099,8 +1114,9 @@ namespace MediaBrowser.Controller.Entities } } - var list = GetAllItemsForMediaSources(); - var result = list.Select(i => GetVersionInfo(enablePathSubstitution, i.Item, i.MediaSourceType)).ToList(); + var list = GetAllItemsForMediaSources().ToList(); + var commonPrefix = GetCommonNamePrefix(list); + var result = list.Select(i => GetVersionInfo(enablePathSubstitution, i.Item, i.MediaSourceType, commonPrefix)).ToList(); if (IsActiveRecording()) { @@ -1110,17 +1126,15 @@ namespace MediaBrowser.Controller.Entities } } - return result.OrderBy(i => - { - if (i.VideoType == VideoType.VideoFile) - { - return 0; - } + // The source belonging to the item being queried sorts first so it is the default the client plays. + var selfId = Id.ToString("N", CultureInfo.InvariantCulture); - return 1; - }).ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0) - .ThenByDescending(i => i, new MediaSourceWidthComparator()) - .ToArray(); + return result + .OrderByDescending(i => string.Equals(i.Id, selfId, StringComparison.OrdinalIgnoreCase)) + .ThenBy(i => i.VideoType == VideoType.VideoFile ? 0 : 1) + .ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0) + .ThenByDescending(i => i, new MediaSourceWidthComparator()) + .ToArray(); } protected virtual IEnumerable<(BaseItem Item, MediaSourceType MediaSourceType)> GetAllItemsForMediaSources() @@ -1128,7 +1142,7 @@ namespace MediaBrowser.Controller.Entities return Enumerable.Empty<(BaseItem, MediaSourceType)>(); } - private MediaSourceInfo GetVersionInfo(bool enablePathSubstitution, BaseItem item, MediaSourceType type) + private MediaSourceInfo GetVersionInfo(bool enablePathSubstitution, BaseItem item, MediaSourceType type, string commonPrefix = null) { ArgumentNullException.ThrowIfNull(item); @@ -1141,7 +1155,7 @@ namespace MediaBrowser.Controller.Entities Protocol = protocol ?? MediaProtocol.File, MediaStreams = MediaSourceManager.GetMediaStreams(item.Id), MediaAttachments = MediaSourceManager.GetMediaAttachments(item.Id), - Name = GetMediaSourceName(item), + Name = GetMediaSourceName(item, commonPrefix), Path = enablePathSubstitution ? GetMappedPath(item, itemPath, protocol) : itemPath, RunTimeTicks = item.RunTimeTicks, Container = item.Container, @@ -1220,7 +1234,7 @@ namespace MediaBrowser.Controller.Entities return info; } - internal string GetMediaSourceName(BaseItem item) + internal string GetMediaSourceName(BaseItem item, string commonPrefix = null) { var terms = new List(); @@ -1228,12 +1242,31 @@ namespace MediaBrowser.Controller.Entities if (item.IsFileProtocol && !string.IsNullOrEmpty(path)) { var displayName = System.IO.Path.GetFileNameWithoutExtension(path); - if (HasLocalAlternateVersions) + + // Prefer the suffix that differs from the other versions: strip the prefix shared by + // all sibling files. This works regardless of folder layout, so it also labels episode + // versions that share a season folder (e.g. "Greyscale" instead of the full + // "Show - S01E02 - Title - Greyscale"). The prefix is already retreated to a delimiter + // boundary (see GetCommonVersionPrefix). + if (!string.IsNullOrEmpty(commonPrefix) + && displayName.Length > commonPrefix.Length + && displayName.StartsWith(commonPrefix, StringComparison.OrdinalIgnoreCase)) + { + var name = displayName.AsSpan(commonPrefix.Length).TrimStart([' ', .. VersionDelimiters]); + if (!name.IsWhiteSpace()) + { + terms.Add(name.ToString()); + } + } + + // Fall back to the containing folder name (the common layout for movie versions, and + // the path taken when no common prefix could be derived). + if (terms.Count == 0 && HasLocalAlternateVersions) { var containingFolderName = System.IO.Path.GetFileName(ContainingFolderPath); if (displayName.Length > containingFolderName.Length && displayName.StartsWith(containingFolderName, StringComparison.OrdinalIgnoreCase)) { - var name = displayName.AsSpan(containingFolderName.Length).TrimStart([' ', '-']); + var name = displayName.AsSpan(containingFolderName.Length).TrimStart([' ', .. VersionDelimiters]); if (!name.IsWhiteSpace()) { terms.Add(name.ToString()); @@ -1290,6 +1323,98 @@ namespace MediaBrowser.Controller.Entities return string.Join('/', terms); } + /// + /// Derives the prefix shared by the supplied media source items' file names, used to strip the + /// common part and surface a short version label per source. Returns null when there are fewer + /// than two file-based sources, since there is nothing to differentiate. + /// + /// The media source items. + /// The shared prefix, or null when no useful prefix exists. + private static string GetCommonNamePrefix(IReadOnlyList<(BaseItem Item, MediaSourceType MediaSourceType)> items) + { + var fileNames = new List(); + foreach (var (item, _) in items) + { + if (item.IsFileProtocol && !string.IsNullOrEmpty(item.Path)) + { + fileNames.Add(System.IO.Path.GetFileNameWithoutExtension(item.Path)); + } + } + + if (fileNames.Count < 2) + { + return null; + } + + var prefix = GetCommonVersionPrefix(fileNames); + return string.IsNullOrEmpty(prefix) ? null : prefix; + } + + /// + /// Computes the case-insensitive longest common prefix of the supplied version file names, + /// retreated to the last delimiter boundary. Retreating keeps the differing suffix intact: + /// it avoids slicing through a word every version shares (e.g. "Grey" in "Greyscale" and + /// "Greyish") while still trimming the common part when every version is suffixed (e.g. + /// "- Greyscale" / "- Colorized"). It prefers a structural delimiter ('-', '_', '.') so a + /// token shared by the descriptors but separated only by spaces (e.g. a common "2160p ") is + /// kept in the label, falling back to a space only when no structural delimiter is shared. The + /// separators mirror the version delimiters recognised by the naming layer (Emby.Naming + /// VideoFlagDelimiters). + /// + /// The version file names without extension; must contain at least one entry. + /// The shared prefix retreated to a separator boundary, or an empty string when none is shared. + internal static string GetCommonVersionPrefix(IReadOnlyList fileNames) + { + var prefix = fileNames[0]; + for (var i = 1; i < fileNames.Count && prefix.Length > 0; i++) + { + var name = fileNames[i]; + var length = Math.Min(prefix.Length, name.Length); + var common = 0; + while (common < length && char.ToUpperInvariant(prefix[common]) == char.ToUpperInvariant(name[common])) + { + common++; + } + + prefix = prefix[..common]; + } + + // If the common prefix is itself a whole file name then one version is unlabelled (the + // base name); the boundary already sits at the end of that name, so don't retreat into it. + var prefixIsWholeName = false; + for (var i = 0; i < fileNames.Count; i++) + { + if (fileNames[i].Length == prefix.Length) + { + prefixIsWholeName = true; + break; + } + } + + if (!prefixIsWholeName) + { + // Retreat to the last structural delimiter ('-', '_', '.'). + var cut = prefix.Length; + while (cut > 0 && Array.IndexOf(VersionDelimiters, prefix[cut - 1]) < 0) + { + cut--; + } + + if (cut == 0) + { + cut = prefix.Length; + while (cut > 0 && prefix[cut - 1] != ' ') + { + cut--; + } + } + + prefix = prefix[..cut]; + } + + return prefix; + } + public Task RefreshMetadata(CancellationToken cancellationToken) { return RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(FileSystem)), cancellationToken); @@ -2011,12 +2136,23 @@ namespace MediaBrowser.Controller.Entities // I think it is okay to do this here. // if this is only called when a user is manually forcing something to un-played // then it probably is what we want to do... + ResetPlayedState(data); + + UserDataManager.SaveUserData(user, this, data, UserDataSaveReason.TogglePlayed, CancellationToken.None); + } + + /// + /// Clears the played state on the supplied user data. + /// + /// The user data to reset. + protected static void ResetPlayedState(UserItemData data) + { + ArgumentNullException.ThrowIfNull(data); + data.PlayCount = 0; data.PlaybackPositionTicks = 0; data.LastPlayedDate = null; data.Played = false; - - UserDataManager.SaveUserData(user, this, data, UserDataSaveReason.TogglePlayed, CancellationToken.None); } /// @@ -2731,6 +2867,15 @@ namespace MediaBrowser.Controller.Entities return LibraryManager.Sort(GetExtras(user).Where(e => e.ExtraType == Model.Entities.ExtraType.ThemeVideo), user, orderBy).ToArray(); } + /// + /// Gets the ids of the items whose owned extras belong to this item. + /// + /// An array containing the owner ids. + protected virtual Guid[] GetExtraOwnerIds() + { + return [Id]; + } + /// /// Get all extras associated with this item, sorted by . /// @@ -2740,7 +2885,7 @@ namespace MediaBrowser.Controller.Entities { return LibraryManager.GetItemList(new InternalItemsQuery(user) { - OwnerIds = [Id], + OwnerIds = GetExtraOwnerIds(), OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending)] }); } @@ -2755,7 +2900,7 @@ namespace MediaBrowser.Controller.Entities { return LibraryManager.GetItemList(new InternalItemsQuery(user) { - OwnerIds = [Id], + OwnerIds = GetExtraOwnerIds(), ExtraTypes = extraTypes.ToArray(), OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending)] }); diff --git a/MediaBrowser.Controller/Entities/Book.cs b/MediaBrowser.Controller/Entities/Book.cs index 5187669373..8559681bdc 100644 --- a/MediaBrowser.Controller/Entities/Book.cs +++ b/MediaBrowser.Controller/Entities/Book.cs @@ -13,11 +13,6 @@ namespace MediaBrowser.Controller.Entities [Common.RequiresSourceSerialisation] public class Book : BaseItem, IHasLookupInfo, IHasSeries { - public Book() - { - this.RunTimeTicks = TimeSpan.TicksPerSecond; - } - [JsonIgnore] public override MediaType MediaType => MediaType.Book; diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs index 25cbcedc5f..b1f7f29bad 100644 --- a/MediaBrowser.Controller/Entities/Folder.cs +++ b/MediaBrowser.Controller/Entities/Folder.cs @@ -384,6 +384,7 @@ namespace MediaBrowser.Controller.Entities cancellationToken.ThrowIfCancellationRequested(); var validChildren = new List(); + var accessibleChildren = new List(); var validChildrenNeedGeneration = false; if (IsFileProtocol) @@ -438,12 +439,19 @@ namespace MediaBrowser.Controller.Entities { if (!IsLibraryFolderAccessible(directoryService, child, allowRemoveRoot)) { + // Preserve inaccessible items so they aren't treated as removed. + if (currentChildren.TryGetValue(child.Id, out var childrenToKeep)) + { + validChildren.Add(childrenToKeep); + } + continue; } if (currentChildren.TryGetValue(child.Id, out BaseItem currentChild)) { validChildren.Add(currentChild); + accessibleChildren.Add(currentChild); if (currentChild.UpdateFromResolvedItem(child) > ItemUpdateType.None) { @@ -480,11 +488,12 @@ namespace MediaBrowser.Controller.Entities child.SetParent(this); newItems.Add(child); validChildren.Add(child); + accessibleChildren.Add(child); } // That's all the new and changed ones - now see if any have been removed and need cleanup var itemsRemoved = currentChildren.Values.Except(validChildren).ToList(); - var shouldRemove = !IsRoot || allowRemoveRoot; + // If it's an AggregateFolder, don't remove // Collect replaced primaries for deferred deletion (after CreateItems) var replacedPrimaries = new List<(Video OldPrimary, Video NewPrimary)>(); @@ -497,7 +506,7 @@ namespace MediaBrowser.Controller.Entities .Where(p => !string.IsNullOrEmpty(p)) .ToHashSet(StringComparer.OrdinalIgnoreCase); - if (shouldRemove && itemsRemoved.Count > 0) + if (itemsRemoved.Count > 0) { foreach (var item in itemsRemoved) { @@ -703,7 +712,7 @@ namespace MediaBrowser.Controller.Entities validChildrenNeedGeneration = false; } - await ValidateSubFolders(validChildren.OfType().ToList(), directoryService, innerProgress, cancellationToken).ConfigureAwait(false); + await ValidateSubFolders(accessibleChildren.OfType().ToList(), directoryService, innerProgress, cancellationToken).ConfigureAwait(false); } if (refreshChildMetadata) @@ -742,7 +751,7 @@ namespace MediaBrowser.Controller.Entities validChildren = Children.ToList(); } - await RefreshMetadataRecursive(validChildren, refreshOptions, recursive, innerProgress, cancellationToken).ConfigureAwait(false); + await RefreshMetadataRecursive(accessibleChildren, refreshOptions, recursive, innerProgress, cancellationToken).ConfigureAwait(false); } } } diff --git a/MediaBrowser.Controller/Entities/IHasSpecialFeatures.cs b/MediaBrowser.Controller/Entities/IHasSpecialFeatures.cs index f47d2162f7..0cdc8bce03 100644 --- a/MediaBrowser.Controller/Entities/IHasSpecialFeatures.cs +++ b/MediaBrowser.Controller/Entities/IHasSpecialFeatures.cs @@ -1,12 +1,13 @@ #nullable disable -#pragma warning disable CS1591 - using System; using System.Collections.Generic; namespace MediaBrowser.Controller.Entities { + /// + /// Interface for items that have special features. + /// public interface IHasSpecialFeatures { /// diff --git a/MediaBrowser.Controller/Entities/IHasStartDate.cs b/MediaBrowser.Controller/Entities/IHasStartDate.cs index dab15eb018..47df09d1ce 100644 --- a/MediaBrowser.Controller/Entities/IHasStartDate.cs +++ b/MediaBrowser.Controller/Entities/IHasStartDate.cs @@ -1,11 +1,15 @@ -#pragma warning disable CS1591 - using System; namespace MediaBrowser.Controller.Entities { + /// + /// Interface for items that have a start date. + /// public interface IHasStartDate { + /// + /// Gets or sets the start date. + /// DateTime StartDate { get; set; } } } diff --git a/MediaBrowser.Controller/Entities/IItemByName.cs b/MediaBrowser.Controller/Entities/IItemByName.cs index 4928bda7a2..756dbecb98 100644 --- a/MediaBrowser.Controller/Entities/IItemByName.cs +++ b/MediaBrowser.Controller/Entities/IItemByName.cs @@ -1,19 +1,28 @@ -#pragma warning disable CS1591 - using System.Collections.Generic; namespace MediaBrowser.Controller.Entities { /// - /// Marker interface. + /// Marker interface for items that represent a name, like a genre or a studio. /// public interface IItemByName { + /// + /// Gets the items tagged with this name. + /// + /// The query. + /// The tagged items. IReadOnlyList GetTaggedItems(InternalItemsQuery query); } + /// + /// Interface for by-name items that can also be accessed as a regular library item. + /// public interface IHasDualAccess : IItemByName { + /// + /// Gets a value indicating whether the item is accessed by name. + /// bool IsAccessedByName { get; } } } diff --git a/MediaBrowser.Controller/Entities/ISupportsPlaceHolders.cs b/MediaBrowser.Controller/Entities/ISupportsPlaceHolders.cs index cdda8ea399..0f8904df5c 100644 --- a/MediaBrowser.Controller/Entities/ISupportsPlaceHolders.cs +++ b/MediaBrowser.Controller/Entities/ISupportsPlaceHolders.cs @@ -1,7 +1,8 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Controller.Entities { + /// + /// Interface for items that can be placeholders. + /// public interface ISupportsPlaceHolders { /// diff --git a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs index 422c40ce5d..3b1f6a961f 100644 --- a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs +++ b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs @@ -72,6 +72,102 @@ namespace MediaBrowser.Controller.Entities } } + /// + /// Gets a value indicating whether the query carries any criteria that narrows the + /// result set, as opposed to user context, pagination, sorting or DTO options. + /// + public bool HasFilters => + IncludeItemTypes.Length > 0 + || ExcludeItemTypes.Length > 0 + || Genres.Count > 0 + || GenreIds.Count > 0 + || Years.Length > 0 + || Tags.Length > 0 + || ExcludeTags.Length > 0 + || OfficialRatings.Length > 0 + || StudioIds.Length > 0 + || ArtistIds.Length > 0 + || AlbumArtistIds.Length > 0 + || ContributingArtistIds.Length > 0 + || ExcludeArtistIds.Length > 0 + || AlbumIds.Length > 0 + || PersonIds.Length > 0 + || PersonTypes.Length > 0 + || MediaTypes.Length > 0 + || VideoTypes.Length > 0 + || ImageTypes.Length > 0 + || SeriesStatuses.Length > 0 + || ItemIds.Length > 0 + || ExcludeItemIds.Length > 0 + || AudioLanguages.Count > 0 + || SubtitleLanguages.Count > 0 + || LinkedChildAncestorIds.Length > 0 + || AncestorIds.Length > 0 + || IsFavorite.HasValue + || IsFavoriteOrLiked.HasValue + || IsLiked.HasValue + || IsPlayed.HasValue + || IsResumable.HasValue + || IsFolder.HasValue + || IsMissing.HasValue + || IsUnaired.HasValue + || IsSpecialSeason.HasValue + || Is3D.HasValue + || IsHD.HasValue + || Is4K.HasValue + || IsLocked.HasValue + || IsPlaceHolder.HasValue + || IsMovie.HasValue + || IsSports.HasValue + || IsKids.HasValue + || IsNews.HasValue + || IsSeries.HasValue + || IsAiring.HasValue + || IsVirtualItem.HasValue + || HasImdbId.HasValue + || HasTmdbId.HasValue + || HasTvdbId.HasValue + || HasOverview.HasValue + || HasOfficialRating.HasValue + || HasParentalRating.HasValue + || HasThemeSong.HasValue + || HasThemeVideo.HasValue + || HasSubtitles.HasValue + || HasSpecialFeature.HasValue + || HasTrailer.HasValue + || HasChapterImages.HasValue + || MinCriticRating.HasValue + || MinCommunityRating.HasValue + || MinParentalRating is not null + || MinIndexNumber.HasValue + || MinParentAndIndexNumber.HasValue + || IndexNumber.HasValue + || ParentIndexNumber.HasValue + || AiredDuringSeason.HasValue + || MinWidth.HasValue + || MinHeight.HasValue + || MaxWidth.HasValue + || MaxHeight.HasValue + || MinPremiereDate.HasValue + || MaxPremiereDate.HasValue + || MinStartDate.HasValue + || MaxStartDate.HasValue + || MinEndDate.HasValue + || MaxEndDate.HasValue + || MinDateCreated.HasValue + || MinDateLastSaved.HasValue + || MinDateLastSavedForUser.HasValue + || AdjacentTo.HasValue + || !string.IsNullOrEmpty(NameStartsWith) + || !string.IsNullOrEmpty(NameStartsWithOrGreater) + || !string.IsNullOrEmpty(NameLessThan) + || !string.IsNullOrEmpty(NameContains) + || !string.IsNullOrEmpty(MinSortName) + || !string.IsNullOrEmpty(Name) + || !string.IsNullOrEmpty(Person) + || !string.IsNullOrEmpty(SearchTerm) + || !string.IsNullOrEmpty(Path); + public bool Recursive { get; set; } public int? StartIndex { get; set; } diff --git a/MediaBrowser.Controller/Entities/Person.cs b/MediaBrowser.Controller/Entities/Person.cs index 5cc4d322f7..14325d971a 100644 --- a/MediaBrowser.Controller/Entities/Person.cs +++ b/MediaBrowser.Controller/Entities/Person.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Text.Json.Serialization; +using Jellyfin.Database.Implementations.Entities; using Jellyfin.Extensions; using MediaBrowser.Controller.Providers; using Microsoft.Extensions.Logging; @@ -75,6 +76,16 @@ namespace MediaBrowser.Controller.Entities return false; } + /// + /// + /// People don't carry the tags of the media they appear in, so the allowed tags check + /// is skipped for them; otherwise no person would be visible to users with allowed tags configured. + /// + public override bool IsVisible(User user, bool skipAllowedTagsCheck = false) + { + return base.IsVisible(user, true); + } + public override bool IsSaveLocalMetadataEnabled() { return true; diff --git a/MediaBrowser.Controller/Entities/SourceType.cs b/MediaBrowser.Controller/Entities/SourceType.cs index be19e1bdae..97aa22dc04 100644 --- a/MediaBrowser.Controller/Entities/SourceType.cs +++ b/MediaBrowser.Controller/Entities/SourceType.cs @@ -1,11 +1,23 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Controller.Entities { + /// + /// The source of an item. + /// public enum SourceType { + /// + /// The item comes from a library. + /// Library = 0, + + /// + /// The item comes from a channel. + /// Channel = 1, + + /// + /// The item comes from live TV. + /// LiveTV = 2 } } diff --git a/MediaBrowser.Controller/Entities/UserRootFolder.cs b/MediaBrowser.Controller/Entities/UserRootFolder.cs index deed3631b8..d5be997b84 100644 --- a/MediaBrowser.Controller/Entities/UserRootFolder.cs +++ b/MediaBrowser.Controller/Entities/UserRootFolder.cs @@ -69,8 +69,14 @@ namespace MediaBrowser.Controller.Entities protected override QueryResult GetItemsInternal(InternalItemsQuery query) { - if (query.Recursive) + // The user root holds no items of its own - a plain listing returns the user's + // views. But a request carrying any filter is a search across the libraries, so + // resolve it through the recursive query path even when Recursive wasn't set; + // otherwise the filters would be silently dropped. Recursive is set so the + // downstream query (ancestor/top-parent scoping) treats it as a recursive search. + if (query.Recursive || query.HasFilters) { + query.Recursive = true; return QueryRecursive(query); } diff --git a/MediaBrowser.Controller/Entities/UserViewBuilder.cs b/MediaBrowser.Controller/Entities/UserViewBuilder.cs index cb05056601..c57ed2faf8 100644 --- a/MediaBrowser.Controller/Entities/UserViewBuilder.cs +++ b/MediaBrowser.Controller/Entities/UserViewBuilder.cs @@ -61,6 +61,9 @@ namespace MediaBrowser.Controller.Entities case CollectionType.folders: return GetResult(_libraryManager.GetUserRootFolder().GetChildren(user, true), query); + case CollectionType.books: + return GetBooks(queryParent, user, query); + case CollectionType.tvshows: return GetTvView(queryParent, user, query); @@ -190,6 +193,17 @@ namespace MediaBrowser.Controller.Entities return _libraryManager.GetItemsResult(query); } + private QueryResult GetBooks(Folder parent, User user, InternalItemsQuery query) + { + query.Recursive = true; + query.Parent = parent; + query.SetUser(user); + + query.IncludeItemTypes = new[] { BaseItemKind.Book, BaseItemKind.AudioBook }; + + return _libraryManager.GetItemsResult(query); + } + private QueryResult GetMovieMovies(Folder parent, User user, InternalItemsQuery query) { query.Recursive = true; diff --git a/MediaBrowser.Controller/Entities/Video.cs b/MediaBrowser.Controller/Entities/Video.cs index e7a5672ebd..0606fe1870 100644 --- a/MediaBrowser.Controller/Entities/Video.cs +++ b/MediaBrowser.Controller/Entities/Video.cs @@ -34,11 +34,11 @@ namespace MediaBrowser.Controller.Entities { public Video() { - AdditionalParts = Array.Empty(); - LocalAlternateVersions = Array.Empty(); - SubtitleFiles = Array.Empty(); - AudioFiles = Array.Empty(); - LinkedAlternateVersions = Array.Empty(); + AdditionalParts = []; + LocalAlternateVersions = []; + SubtitleFiles = []; + AudioFiles = []; + LinkedAlternateVersions = []; } [JsonIgnore] @@ -254,7 +254,7 @@ namespace MediaBrowser.Controller.Entities private int GetMediaSourceCount(HashSet callstack = null) { - callstack ??= new(); + callstack ??= []; if (PrimaryVersionId.HasValue) { var item = LibraryManager.GetItemById(PrimaryVersionId.Value); @@ -335,6 +335,102 @@ namespace MediaBrowser.Controller.Entities PresentationUniqueKey = CreatePresentationUniqueKey(); } + /// + /// Marks the played status of this video and propagates it to its alternate versions. + /// + /// The user. + /// The date played. + /// if set to true [reset position]. + public override void MarkPlayed(User user, DateTime? datePlayed, bool resetPosition) + { + base.MarkPlayed(user, datePlayed, resetPosition); + PropagatePlayedState(user, true, resetPosition); + } + + /// + /// Marks this video unplayed and propagates the change to its alternate versions. + /// + /// The user. + public override void MarkUnplayed(User user) + { + base.MarkUnplayed(user); + + // MarkUnplayed always clears the position on this video, so reset the versions too. + PropagatePlayedState(user, false, true); + } + + /// + /// Propagates the played status to every alternate version of this video. + /// + /// The user. + /// The played status to apply to the alternate versions. + /// When marking played, controls whether each version's resume point + /// is also reset (true) or left untouched (false). Ignored when marking unplayed, + /// which always fully resets every version. + public void PropagatePlayedState(User user, bool played, bool resetPosition = true) + { + ArgumentNullException.ThrowIfNull(user); + + if (!PrimaryVersionId.HasValue && LinkedAlternateVersions.Length == 0 && !HasLocalAlternateVersions) + { + return; + } + + foreach (var (item, _) in GetAllItemsForMediaSources()) + { + if (item.Id.Equals(Id) || item is not Video) + { + continue; + } + + if (played) + { + var dto = new UpdateUserItemDataDto { Played = true }; + if (resetPosition) + { + dto.PlaybackPositionTicks = 0; + } + + // SaveUserData only writes the fields set on the DTO, so play count and other state are preserved. + UserDataManager.SaveUserData(user, item, dto, UserDataSaveReason.TogglePlayed); + } + else + { + var data = UserDataManager.GetUserData(user, item); + if (data is null) + { + continue; + } + + ResetPlayedState(data); + UserDataManager.SaveUserData(user, item, data, UserDataSaveReason.TogglePlayed, CancellationToken.None); + } + } + } + + /// + /// Gets this video together with all of its alternate versions (local and linked and, when this + /// is itself an alternate, the primary and the primary's other versions), deduplicated. + /// + /// This video and every alternate version of it. + public IReadOnlyList [SupportedOSPlatform("macos")] -public static class ApplePlatformHelper +public static partial class ApplePlatformHelper { private static readonly string[] _av1DecodeBlacklistedCpuClass = ["M1", "M2"]; - private static string GetSysctlValue(ReadOnlySpan name) + internal static string GetSysctlValue(string name) { - IntPtr length = IntPtr.Zero; + nuint length = 0; // Get length of the value - int osStatus = SysctlByName(name, IntPtr.Zero, ref length, IntPtr.Zero, 0); - - if (osStatus != 0) + int osStatus = sysctlbyname(name, Span.Empty, ref length, IntPtr.Zero, 0); + if (osStatus != 0 || length == 0) { - throw new NotSupportedException($"Failed to get sysctl value for {System.Text.Encoding.UTF8.GetString(name)} with error {osStatus}"); + throw new NotSupportedException($"Failed to get sysctl value for {name} with error {osStatus}"); } - IntPtr buffer = Marshal.AllocHGlobal(length.ToInt32()); + byte[] buffer = ArrayPool.Shared.Rent((int)length); try { - osStatus = SysctlByName(name, buffer, ref length, IntPtr.Zero, 0); + osStatus = sysctlbyname(name, buffer.AsSpan()[..(int)length], ref length, IntPtr.Zero, 0); if (osStatus != 0) { - throw new NotSupportedException($"Failed to get sysctl value for {System.Text.Encoding.UTF8.GetString(name)} with error {osStatus}"); + throw new NotSupportedException($"Failed to get sysctl value for {name} with error {osStatus}"); } - return Marshal.PtrToStringAnsi(buffer) ?? string.Empty; + if (length < 1) + { + return string.Empty; + } + + ReadOnlySpan data = buffer.AsSpan()[..(int)(length - 1)]; + return Encoding.UTF8.GetString(data); } finally { - Marshal.FreeHGlobal(buffer); + ArrayPool.Shared.Return(buffer); } } - private static int SysctlByName(ReadOnlySpan name, IntPtr oldp, ref IntPtr oldlenp, IntPtr newp, uint newlen) - { - return NativeMethods.SysctlByName(name.ToArray(), oldp, ref oldlenp, newp, newlen); - } - /// /// Check if the current system has hardware acceleration for AV1 decoding. /// @@ -63,7 +65,7 @@ public static class ApplePlatformHelper try { - string cpuBrandString = GetSysctlValue("machdep.cpu.brand_string"u8); + string cpuBrandString = GetSysctlValue("machdep.cpu.brand_string"); return !_av1DecodeBlacklistedCpuClass.Any(blacklistedCpuClass => cpuBrandString.Contains(blacklistedCpuClass, StringComparison.OrdinalIgnoreCase)); } catch (NotSupportedException e) @@ -78,10 +80,7 @@ public static class ApplePlatformHelper return false; } - private static class NativeMethods - { - [DllImport("libc", EntryPoint = "sysctlbyname", SetLastError = true)] - [DefaultDllImportSearchPaths(DllImportSearchPath.SafeDirectories)] - internal static extern int SysctlByName(byte[] name, IntPtr oldp, ref IntPtr oldlenp, IntPtr newp, uint newlen); - } + [LibraryImport("libc", EntryPoint = "sysctlbyname", SetLastError = true)] + [DefaultDllImportSearchPaths(DllImportSearchPath.SafeDirectories)] + internal static partial int sysctlbyname([MarshalAs(UnmanagedType.LPStr)] string name, Span oldp, ref nuint oldlenp, IntPtr newp, nuint newlen); } diff --git a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs index 68d6d215b2..91d0c3d5a6 100644 --- a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs +++ b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs @@ -6,7 +6,9 @@ using System.Diagnostics; using System.Globalization; using System.Linq; using System.Runtime.Versioning; +using System.Text; using System.Text.RegularExpressions; +using System.Threading.Tasks; using MediaBrowser.Controller.MediaEncoding; using Microsoft.Extensions.Logging; @@ -184,8 +186,7 @@ namespace MediaBrowser.MediaEncoding.Encoder { "libavdevice", new Version(58, 13) }, { "libavfilter", new Version(7, 110) }, { "libswscale", new Version(5, 9) }, - { "libswresample", new Version(3, 9) }, - { "libpostproc", new Version(55, 9) } + { "libswresample", new Version(3, 9) } }; private readonly ILogger _logger; @@ -645,7 +646,9 @@ namespace MediaBrowser.MediaEncoding.Encoder WindowStyle = ProcessWindowStyle.Hidden, ErrorDialog = false, RedirectStandardInput = redirectStandardIn, + StandardOutputEncoding = Encoding.UTF8, RedirectStandardOutput = true, + StandardErrorEncoding = Encoding.UTF8, RedirectStandardError = true } }) @@ -660,8 +663,15 @@ namespace MediaBrowser.MediaEncoding.Encoder writer.Write(testKey); } - using var reader = readStdErr ? process.StandardError : process.StandardOutput; - return reader.ReadToEnd(); + // Drain both streams concurrently to prevent pipe hanging, see #17429 + using var standardOutput = process.StandardOutput; + using var standardError = process.StandardError; + var standardOutputTask = standardOutput.ReadToEndAsync(); + var standardErrorTask = standardError.ReadToEndAsync(); + process.WaitForExit(); + Task.WaitAll(standardOutputTask, standardErrorTask); + + return (readStdErr ? standardErrorTask : standardOutputTask).GetAwaiter().GetResult(); } } diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs index 66bf6ebd24..0ddd378352 100644 --- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs +++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs @@ -7,6 +7,7 @@ using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; +using System.Text; using System.Text.Json; using System.Text.RegularExpressions; using System.Threading; @@ -528,6 +529,7 @@ namespace MediaBrowser.MediaEncoding.Encoder UseShellExecute = false, // Must consume both or ffmpeg may hang due to deadlocks. + StandardOutputEncoding = Encoding.UTF8, RedirectStandardOutput = true, FileName = _ffprobePath, @@ -926,6 +928,25 @@ namespace MediaBrowser.MediaEncoding.Encoder throw new InvalidOperationException("EncodingHelper returned empty or invalid filter parameters."); } + // Normalize invalid PTS from containers for non keyframe only mode + if (!enableKeyFrameOnlyExtraction) + { + var fpsFilterIndex = filterParam.IndexOf("fps=", StringComparison.Ordinal); + if (fpsFilterIndex >= 0) + { + var inputFrameRate = (imageStream.ReferenceFrameRate.HasValue && imageStream.ReferenceFrameRate > 0) + ? imageStream.ReferenceFrameRate.Value : 30; + + var setPtsFilter = string.Create(CultureInfo.InvariantCulture, $"setpts=N/{inputFrameRate:F3}/TB,"); + + filterParam = filterParam.Insert(fpsFilterIndex, setPtsFilter); + } + else + { + throw new InvalidOperationException("EncodingHelper returned invalid filter parameters."); + } + } + try { return await ExtractVideoImagesOnIntervalInternal( diff --git a/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj b/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj index fc11047a7f..288cd3e189 100644 --- a/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj +++ b/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj @@ -9,6 +9,7 @@ net10.0 false true + true diff --git a/MediaBrowser.MediaEncoding/Probing/FFProbeHelpers.cs b/MediaBrowser.MediaEncoding/Probing/FFProbeHelpers.cs index 975c2b8161..fa2085ca6f 100644 --- a/MediaBrowser.MediaEncoding/Probing/FFProbeHelpers.cs +++ b/MediaBrowser.MediaEncoding/Probing/FFProbeHelpers.cs @@ -76,7 +76,13 @@ namespace MediaBrowser.MediaEncoding.Probing /// Dictionary{System.StringSystem.String}. private static Dictionary ConvertDictionaryToCaseInsensitive(IReadOnlyDictionary dict) { - return new Dictionary(dict, StringComparer.OrdinalIgnoreCase); + var result = new Dictionary(dict.Count, StringComparer.OrdinalIgnoreCase); + foreach (var (key, value) in dict) + { + result.TryAdd(key, value); + } + + return result; } } } diff --git a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs index 06060988e2..b6acfdbf3b 100644 --- a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs +++ b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs @@ -254,16 +254,38 @@ namespace MediaBrowser.MediaEncoding.Probing { if (mediaStream.Type == MediaStreamType.Audio && !mediaStream.BitRate.HasValue) { - mediaStream.BitRate = GetEstimatedAudioBitrate(mediaStream.Codec, mediaStream.Channels); + mediaStream.BitRate = GetEstimatedAudioBitrate(mediaStream.Codec, mediaStream.Profile, mediaStream.Channels); } } - var videoStreamsBitrate = info.MediaStreams.Where(i => i.Type == MediaStreamType.Video).Select(i => i.BitRate ?? 0).Sum(); - // If ffprobe reported the container bitrate as being the same as the video stream bitrate, then it's wrong - if (videoStreamsBitrate == (info.Bitrate ?? 0)) + // ffprobe frequently omits the per-stream video bitrate (common in MP4/MKV containers). + // Estimate the missing video bitrate as the container bitrate minus the combined stream bitrates. + var videoStreams = info.MediaStreams.Where(i => i.Type == MediaStreamType.Video).ToList(); + if (info.Bitrate.HasValue + && videoStreams.Count == 1 + && !videoStreams[0].BitRate.HasValue) { - info.InferTotalBitrate(true); + var otherStreams = info.MediaStreams + .Where(i => i.Type != MediaStreamType.Video && !i.IsExternal) + .ToList(); + + // Only attribute the leftover bitrate to the video stream if every audio stream's bitrate is known. + var audioBitratesKnown = otherStreams + .Where(i => i.Type == MediaStreamType.Audio) + .All(i => i.BitRate.HasValue); + + if (audioBitratesKnown) + { + var estimatedVideoBitrate = info.Bitrate.Value - otherStreams.Sum(i => i.BitRate ?? 0); + if (estimatedVideoBitrate > 0) + { + videoStreams[0].BitRate = estimatedVideoBitrate; + } + } } + + // If the container bitrate is still unknown, infer it from the sum of the streams. + info.InferTotalBitrate(); } return info; @@ -316,54 +338,34 @@ namespace MediaBrowser.MediaEncoding.Probing return string.Join(',', splitFormat.Where(s => !string.IsNullOrEmpty(s))); } - private static int? GetEstimatedAudioBitrate(string codec, int? channels) + internal static int? GetEstimatedAudioBitrate(string codec, string profile, int? channels) { - if (!channels.HasValue) + if (!channels.HasValue || channels.Value < 1 || string.IsNullOrEmpty(codec)) { return null; } - var channelsValue = channels.Value; + // Rough typical bitrates used only as a fallback when ffprobe doesn't report a stream bitrate. + var channelCount = channels.Value; + var isMultichannel = channelCount > 2; - if (string.Equals(codec, "aac", StringComparison.OrdinalIgnoreCase) - || string.Equals(codec, "mp3", StringComparison.OrdinalIgnoreCase)) + return codec.ToLowerInvariant() switch { - switch (channelsValue) - { - case <= 2: - return 192000; - case >= 5: - return 320000; - } - } - - if (string.Equals(codec, "ac3", StringComparison.OrdinalIgnoreCase) - || string.Equals(codec, "eac3", StringComparison.OrdinalIgnoreCase)) - { - switch (channelsValue) - { - case <= 2: - return 192000; - case >= 5: - return 640000; - } - } - - if (string.Equals(codec, "flac", StringComparison.OrdinalIgnoreCase) - || string.Equals(codec, "alac", StringComparison.OrdinalIgnoreCase)) - { - switch (channelsValue) - { - case <= 2: - return 960000; - case >= 5: - return 2880000; - } - } - - return null; + "aac" or "mp3" or "mp2" => isMultichannel ? 320000 : 192000, + "ac3" or "eac3" => isMultichannel ? 640000 : 192000, + "dts" or "dca" => IsDtsLossless(profile) ? channelCount * 700000 : (isMultichannel ? 1509000 : 768000), + "opus" => isMultichannel ? 256000 : 128000, + "vorbis" => isMultichannel ? 320000 : 160000, + "wmav1" or "wmav2" or "wmapro" => isMultichannel ? 384000 : 192000, + "flac" or "alac" => channelCount * 480000, + "truehd" or "mlp" => channelCount * 700000, + _ => null + }; } + private static bool IsDtsLossless(string profile) + => profile is not null && profile.Contains("HD MA", StringComparison.OrdinalIgnoreCase); + private void FetchFromItunesInfo(string xml, MediaInfo info) { // Make things simpler and strip out the dtd @@ -730,9 +732,10 @@ namespace MediaBrowser.MediaEncoding.Probing stream.LocalizedDefault = _localization.GetLocalizedString("Default"); stream.LocalizedExternal = _localization.GetLocalizedString("External"); stream.LocalizedOriginal = _localization.GetLocalizedString("Original"); - stream.LocalizedLanguage = string.IsNullOrEmpty(stream.Language) - ? null - : _localization.FindLanguageInfo(stream.Language)?.DisplayName; + if (!string.IsNullOrEmpty(stream.Language)) + { + stream.LocalizedLanguage = _localization.GetLanguageDisplayName(stream.Language); + } stream.Channels = streamInfo.Channels; @@ -754,11 +757,17 @@ namespace MediaBrowser.MediaEncoding.Probing if (string.IsNullOrEmpty(stream.Title)) { - // mp4 missing track title workaround: fall back to handler_name if populated and not the default "SoundHandler" - string handlerName = GetDictionaryValue(streamInfo.Tags, "handler_name"); - if (!string.IsNullOrEmpty(handlerName) && !string.Equals(handlerName, "SoundHandler", StringComparison.OrdinalIgnoreCase)) + // FFprobe exposes MP4 track names via the name tag rather than title + stream.Title = GetDictionaryValue(streamInfo.Tags, "name"); + + if (string.IsNullOrEmpty(stream.Title)) { - stream.Title = handlerName; + // fall back to handler_name if populated and not the default "SoundHandler" + string handlerName = GetDictionaryValue(streamInfo.Tags, "handler_name"); + if (!string.IsNullOrEmpty(handlerName) && !string.Equals(handlerName, "SoundHandler", StringComparison.OrdinalIgnoreCase)) + { + stream.Title = handlerName; + } } } } @@ -771,17 +780,24 @@ namespace MediaBrowser.MediaEncoding.Probing stream.LocalizedForced = _localization.GetLocalizedString("Forced"); stream.LocalizedExternal = _localization.GetLocalizedString("External"); stream.LocalizedHearingImpaired = _localization.GetLocalizedString("HearingImpaired"); - stream.LocalizedLanguage = string.IsNullOrEmpty(stream.Language) - ? null - : _localization.FindLanguageInfo(stream.Language)?.DisplayName; + if (!string.IsNullOrEmpty(stream.Language)) + { + stream.LocalizedLanguage = _localization.GetLanguageDisplayName(stream.Language); + } if (string.IsNullOrEmpty(stream.Title)) { - // mp4 missing track title workaround: fall back to handler_name if populated and not the default "SubtitleHandler" - string handlerName = GetDictionaryValue(streamInfo.Tags, "handler_name"); - if (!string.IsNullOrEmpty(handlerName) && !string.Equals(handlerName, "SubtitleHandler", StringComparison.OrdinalIgnoreCase)) + // FFprobe exposes MP4 track names via the name tag rather than title + stream.Title = GetDictionaryValue(streamInfo.Tags, "name"); + + if (string.IsNullOrEmpty(stream.Title)) { - stream.Title = handlerName; + // fall back to handler_name if populated and not the default "SubtitleHandler" + string handlerName = GetDictionaryValue(streamInfo.Tags, "handler_name"); + if (!string.IsNullOrEmpty(handlerName) && !string.Equals(handlerName, "SubtitleHandler", StringComparison.OrdinalIgnoreCase)) + { + stream.Title = handlerName; + } } } } @@ -972,10 +988,12 @@ namespace MediaBrowser.MediaEncoding.Probing bitrate = value; } - // The bitrate info of FLAC musics and some videos is included in formatInfo. + // The bitrate info of FLAC audio is included in formatInfo. + // Don't do this for video streams: formatInfo.BitRate is the overall container + // bitrate (video + audio + subtitles + overhead), not the video bitrate. if (bitrate == 0 && formatInfo is not null - && (stream.Type == MediaStreamType.Video || (isAudio && stream.Type == MediaStreamType.Audio))) + && isAudio && stream.Type == MediaStreamType.Audio) { // If the stream info doesn't have a bitrate get the value from the media format info if (int.TryParse(formatInfo.BitRate, CultureInfo.InvariantCulture, out value)) @@ -1260,9 +1278,16 @@ namespace MediaBrowser.MediaEncoding.Probing } var duration = GetDictionaryValue(streamInfo.Tags, "DURATION-eng") ?? GetDictionaryValue(streamInfo.Tags, "DURATION"); - if (TimeSpan.TryParse(duration, out var parsedDuration)) + if (!string.IsNullOrEmpty(duration)) { - return parsedDuration.TotalSeconds; + // Matroska DURATION tags use nanosecond precision (e.g. "00:00:05.023000000"), but + // TimeSpan only supports up to 7 fractional digits (ticks). Trim the surplus digits so + // these durations parse instead of being silently dropped. + duration = DurationOverPrecisionRegex().Replace(duration, "$1"); + if (TimeSpan.TryParse(duration, CultureInfo.InvariantCulture, out var parsedDuration)) + { + return parsedDuration.TotalSeconds; + } } return null; @@ -1630,7 +1655,7 @@ namespace MediaBrowser.MediaEncoding.Probing // Credit to MCEBuddy: https://mcebuddy2x.codeplex.com/ // DateTime is reported along with timezone info (typically Z i.e. UTC hence assume None) - if (tags.TryGetValue("WM/MediaOriginalBroadcastDateTime", out var premiereDateString) && DateTime.TryParse(year, null, DateTimeStyles.AdjustToUniversal, out var parsedDate)) + if (tags.TryGetValue("WM/MediaOriginalBroadcastDateTime", out var premiereDateString) && DateTime.TryParse(year, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out var parsedDate)) { video.PremiereDate = parsedDate; } @@ -1764,5 +1789,8 @@ namespace MediaBrowser.MediaEncoding.Probing [GeneratedRegex("(?.*) \\((?.*)\\)")] private static partial Regex PerformerRegex(); + + [GeneratedRegex(@"(\.\d{7})\d+")] + private static partial Regex DurationOverPrecisionRegex(); } } diff --git a/MediaBrowser.MediaEncoding/Subtitles/ISubtitleParser.cs b/MediaBrowser.MediaEncoding/Subtitles/ISubtitleParser.cs index bd13437fb6..7566616f70 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/ISubtitleParser.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/ISubtitleParser.cs @@ -1,7 +1,7 @@ #pragma warning disable CS1591 using System.IO; -using MediaBrowser.Model.MediaInfo; +using Nikse.SubtitleEdit.Core.Common; namespace MediaBrowser.MediaEncoding.Subtitles { @@ -12,8 +12,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles /// /// The stream. /// The file extension. - /// SubtitleTrackInfo. - SubtitleTrackInfo Parse(Stream stream, string fileExtension); + /// The parsed subtitle. + Subtitle Parse(Stream stream, string fileExtension); /// /// Determines whether the file extension is supported by the parser. diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEditParser.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEditParser.cs index d060b247da..d75eea5904 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEditParser.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEditParser.cs @@ -1,10 +1,8 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.IO; using System.Linq; using Jellyfin.Extensions; -using MediaBrowser.Model.MediaInfo; using Microsoft.Extensions.Logging; using Nikse.SubtitleEdit.Core.Common; using SubtitleFormat = Nikse.SubtitleEdit.Core.SubtitleFormats.SubtitleFormat; @@ -30,7 +28,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles } /// - public SubtitleTrackInfo Parse(Stream stream, string fileExtension) + public Subtitle Parse(Stream stream, string fileExtension) { var subtitle = new Subtitle(); var lines = stream.ReadAllLines().ToList(); @@ -76,21 +74,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles throw new ArgumentException("Unsupported format: " + fileExtension); } - var trackInfo = new SubtitleTrackInfo(); - int len = subtitle.Paragraphs.Count; - var trackEvents = new SubtitleTrackEvent[len]; - for (int i = 0; i < len; i++) - { - var p = subtitle.Paragraphs[i]; - trackEvents[i] = new SubtitleTrackEvent(p.Number.ToString(CultureInfo.InvariantCulture), p.Text) - { - StartPositionTicks = p.StartTime.TimeSpan.Ticks, - EndPositionTicks = p.EndTime.TimeSpan.Ticks - }; - } - - trackInfo.TrackEvents = trackEvents; - return trackInfo; + return subtitle; } /// diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs index 67e323177b..bd516f0a9f 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs @@ -73,7 +73,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles _serverConfigurationManager = serverConfigurationManager; } - private MemoryStream ConvertSubtitles( + internal MemoryStream ConvertSubtitles( Stream stream, SubtitleInfo inputInfo, string outputFormat, @@ -81,7 +81,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles long endTimeTicks, bool preserveOriginalTimestamps) { - var subtitle = Subtitle.Parse(stream, Path.GetExtension(inputInfo.Path)); + var subtitle = _subtitleParser.Parse(stream, inputInfo.Format); FilterEvents(subtitle, startTimeTicks, endTimeTicks, preserveOriginalTimestamps); @@ -163,28 +163,36 @@ namespace MediaBrowser.MediaEncoding.Subtitles return (stream, fileInfo); } - private async Task GetSubtitleStream(SubtitleInfo fileInfo, CancellationToken cancellationToken) + internal async Task GetSubtitleStream(SubtitleInfo fileInfo, CancellationToken cancellationToken) { - if (fileInfo.Protocol == MediaProtocol.Http) + if (fileInfo.IsExternal && MediaStream.IsTextFormat(fileInfo.Format)) { - var result = await DetectCharset(fileInfo.Path, fileInfo.Protocol, cancellationToken).ConfigureAwait(false); + var result = await DetectCharset(fileInfo.Path, cancellationToken).ConfigureAwait(false); var detected = result.Detected; - if (detected is not null) - { - _logger.LogDebug("charset {CharSet} detected for {Path}", detected.EncodingName, fileInfo.Path); - - using var stream = await _httpClientFactory.CreateClient(NamedClient.Default) + var stream = fileInfo.Protocol == MediaProtocol.Http + ? await _httpClientFactory.CreateClient(NamedClient.Default) .GetStreamAsync(new Uri(fileInfo.Path), cancellationToken) - .ConfigureAwait(false); + .ConfigureAwait(false) + : AsyncFile.OpenRead(fileInfo.Path); - await using (stream.ConfigureAwait(false)) - { - using var reader = new StreamReader(stream, detected.Encoding); - var text = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); + // Short-circuit when the file is already UTF-8/ASCII. + if (detected is null + || string.Equals(detected.EncodingName, "utf-8", StringComparison.OrdinalIgnoreCase) + || string.Equals(detected.EncodingName, "ascii", StringComparison.OrdinalIgnoreCase) + || string.Equals(detected.EncodingName, "us-ascii", StringComparison.OrdinalIgnoreCase)) + { + return stream; + } - return new MemoryStream(Encoding.UTF8.GetBytes(text)); - } + _logger.LogDebug("charset {CharSet} detected for {Path}", detected.EncodingName, fileInfo.Path); + + await using (stream.ConfigureAwait(false)) + { + using var reader = new StreamReader(stream, detected.Encoding); + var text = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); + + return new MemoryStream(Encoding.UTF8.GetBytes(text)); } } @@ -445,98 +453,15 @@ namespace MediaBrowser.MediaEncoding.Subtitles encodingParam = " -sub_charenc " + encodingParam; } - int exitCode; + var args = string.Format(CultureInfo.InvariantCulture, "-y {0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, inputPath, outputPath); - using (var process = new Process - { - StartInfo = new ProcessStartInfo - { - CreateNoWindow = true, - UseShellExecute = false, - FileName = _mediaEncoder.EncoderPath, - Arguments = string.Format(CultureInfo.InvariantCulture, "-y {0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, inputPath, outputPath), - WindowStyle = ProcessWindowStyle.Hidden, - ErrorDialog = false - }, - EnableRaisingEvents = true - }) - { - _logger.LogInformation("{0} {1}", process.StartInfo.FileName, process.StartInfo.Arguments); - - try - { - process.Start(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error starting ffmpeg"); - - throw; - } - - try - { - var timeoutMinutes = _serverConfigurationManager.GetEncodingOptions().SubtitleExtractionTimeoutMinutes; - await process.WaitForExitAsync(TimeSpan.FromMinutes(timeoutMinutes)).ConfigureAwait(false); - exitCode = process.ExitCode; - } - catch (OperationCanceledException) - { - process.Kill(true); - exitCode = -1; - } - } - - var failed = false; - - if (exitCode == -1) - { - failed = true; - - if (File.Exists(outputPath)) - { - try - { - _logger.LogInformation("Deleting converted subtitle due to failure: {Path}", outputPath); - _fileSystem.DeleteFile(outputPath); - } - catch (IOException ex) - { - _logger.LogError(ex, "Error deleting converted subtitle {Path}", outputPath); - } - } - } - else if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0) - { - failed = true; - - try - { - _logger.LogWarning("Deleting converted subtitle due to failure: {Path}", outputPath); - _fileSystem.DeleteFile(outputPath); - } - catch (FileNotFoundException) - { - } - catch (IOException ex) - { - _logger.LogError(ex, "Error deleting converted subtitle {Path}", outputPath); - } - } - - if (failed) - { - _logger.LogError("ffmpeg subtitle conversion failed for {Path}", inputPath); - - throw new FfmpegException( - string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle conversion failed for {0}", inputPath)); - } - - await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false); + await ExtractSubtitlesForFile( + inputPath, + args, + [outputPath], + cancellationToken).ConfigureAwait(false); WriteCacheMeta(outputPath, inputPath); - - _logger.LogInformation("ffmpeg subtitle conversion succeeded for {Path}", inputPath); } private string GetExtractableSubtitleFormat(MediaStream subtitleStream) @@ -727,7 +652,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles var outputPaths = new List(); var args = string.Format( CultureInfo.InvariantCulture, - "-i {0}", + "-y -i {0}", inputPath); foreach (var subtitleStream in subtitleStreams) @@ -781,50 +706,10 @@ namespace MediaBrowser.MediaEncoding.Subtitles private async Task ExtractSubtitlesForFile( string inputPath, string args, - List outputPaths, + IReadOnlyList outputPaths, CancellationToken cancellationToken) { - int exitCode; - - using (var process = new Process - { - StartInfo = new ProcessStartInfo - { - CreateNoWindow = true, - UseShellExecute = false, - FileName = _mediaEncoder.EncoderPath, - Arguments = args, - WindowStyle = ProcessWindowStyle.Hidden, - ErrorDialog = false - }, - EnableRaisingEvents = true - }) - { - _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments); - - try - { - process.Start(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error starting ffmpeg"); - - throw; - } - - try - { - var timeoutMinutes = _serverConfigurationManager.GetEncodingOptions().SubtitleExtractionTimeoutMinutes; - await process.WaitForExitAsync(TimeSpan.FromMinutes(timeoutMinutes)).ConfigureAwait(false); - exitCode = process.ExitCode; - } - catch (OperationCanceledException) - { - process.Kill(true); - exitCode = -1; - } - } + var (exitCode, ffmpegError) = await RunSubtitleExtractionProcess(args, cancellationToken).ConfigureAwait(false); var failed = false; @@ -884,6 +769,13 @@ namespace MediaBrowser.MediaEncoding.Subtitles if (failed) { + cancellationToken.ThrowIfCancellationRequested(); + + if (!string.IsNullOrWhiteSpace(ffmpegError)) + { + _logger.LogError("ffmpeg subtitle extraction failed for {InputPath}: {FfmpegOutput}", inputPath, ffmpegError); + } + throw new FfmpegException( string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle extraction failed for {0}", inputPath)); } @@ -941,16 +833,38 @@ namespace MediaBrowser.MediaEncoding.Subtitles ArgumentException.ThrowIfNullOrEmpty(outputPath); Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath))); - var processArgs = string.Format( CultureInfo.InvariantCulture, - "-i {0} -copyts -map 0:{1} -an -vn -c:s {2} \"{3}\"", + "-y -i {0} -copyts -map 0:{1} -an -vn -c:s {2} \"{3}\"", inputPath, subtitleStreamIndex, outputCodec, outputPath); + await ExtractSubtitlesForFile( + inputPath, + processArgs, + [outputPath], + cancellationToken).ConfigureAwait(false); + } + + /// + /// Runs ffmpeg to extract or convert subtitles, capturing its exit code and stderr output. + /// + /// + /// stdin is redirected and closed, and -nostdin is prepended to the arguments, so ffmpeg can never + /// block reading an inherited stdin handle (which happens when Jellyfin runs as a service, e.g. under NSSM, + /// and stalls subtitle extraction until the timeout). stderr is redirected and drained so a full pipe buffer + /// cannot deadlock ffmpeg and so its output can be surfaced on failure; stdout is left un-redirected as it is + /// unused for subtitle extraction. + /// + /// The ffmpeg command line arguments. + /// The cancellation token. + /// The ffmpeg exit code (-1 on timeout) and its captured stderr output. + private async Task<(int ExitCode, string StandardError)> RunSubtitleExtractionProcess(string arguments, CancellationToken cancellationToken) + { int exitCode; + var standardError = string.Empty; using (var process = new Process { @@ -958,8 +872,10 @@ namespace MediaBrowser.MediaEncoding.Subtitles { CreateNoWindow = true, UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardError = true, FileName = _mediaEncoder.EncoderPath, - Arguments = processArgs, + Arguments = "-nostdin " + arguments, WindowStyle = ProcessWindowStyle.Hidden, ErrorDialog = false }, @@ -975,14 +891,21 @@ namespace MediaBrowser.MediaEncoding.Subtitles catch (Exception ex) { _logger.LogError(ex, "Error starting ffmpeg"); - throw; } + // Close stdin so ffmpeg observes EOF instead of blocking on an inherited handle. + process.StandardInput.Close(); + + // Begin draining stderr before waiting for exit; a full stderr pipe buffer would otherwise deadlock ffmpeg. + var standardErrorTask = process.StandardError.ReadToEndAsync(CancellationToken.None); + var timeoutMinutes = _serverConfigurationManager.GetEncodingOptions().SubtitleExtractionTimeoutMinutes; + using var waitSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + waitSource.CancelAfter(TimeSpan.FromMinutes(timeoutMinutes)); + try { - var timeoutMinutes = _serverConfigurationManager.GetEncodingOptions().SubtitleExtractionTimeoutMinutes; - await process.WaitForExitAsync(TimeSpan.FromMinutes(timeoutMinutes)).ConfigureAwait(false); + await process.WaitForExitAsync(waitSource.Token).ConfigureAwait(false); exitCode = process.ExitCode; } catch (OperationCanceledException) @@ -990,59 +913,18 @@ namespace MediaBrowser.MediaEncoding.Subtitles process.Kill(true); exitCode = -1; } - } - - var failed = false; - - if (exitCode == -1) - { - failed = true; try { - _logger.LogWarning("Deleting extracted subtitle due to failure: {Path}", outputPath); - _fileSystem.DeleteFile(outputPath); + standardError = await standardErrorTask.ConfigureAwait(false); } - catch (FileNotFoundException) + catch (OperationCanceledException) { - } - catch (IOException ex) - { - _logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath); - } - } - else if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0) - { - failed = true; - - try - { - _logger.LogWarning("Deleting extracted subtitle due to failure: {Path}", outputPath); - _fileSystem.DeleteFile(outputPath); - } - catch (FileNotFoundException) - { - } - catch (IOException ex) - { - _logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath); + // Reading ffmpeg output was cancelled; nothing more to capture. } } - if (failed) - { - _logger.LogError("ffmpeg subtitle extraction failed for {InputPath} to {OutputPath}", inputPath, outputPath); - - throw new FfmpegException( - string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle extraction failed for {0} to {1}", inputPath, outputPath)); - } - - _logger.LogInformation("ffmpeg subtitle extraction completed for {InputPath} to {OutputPath}", inputPath, outputPath); - - if (string.Equals(outputCodec, "ass", StringComparison.OrdinalIgnoreCase)) - { - await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false); - } + return (exitCode, standardError); } /// @@ -1104,7 +986,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles } } - var result = await DetectCharset(path, mediaSource.Protocol, cancellationToken).ConfigureAwait(false); + var result = await DetectCharset(path, cancellationToken).ConfigureAwait(false); var charset = result.Detected?.EncodingName ?? string.Empty; // UTF16 is automatically converted to UTF8 by FFmpeg, do not specify a character encoding @@ -1120,8 +1002,9 @@ namespace MediaBrowser.MediaEncoding.Subtitles return charset; } - private async Task DetectCharset(string path, MediaProtocol protocol, CancellationToken cancellationToken) + private async Task DetectCharset(string path, CancellationToken cancellationToken) { + var protocol = _mediaSourceManager.GetPathProtocol(path); switch (protocol) { case MediaProtocol.Http: @@ -1141,7 +1024,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles } default: - throw new ArgumentOutOfRangeException(nameof(protocol), protocol, "Unsupported protocol"); + throw new NotSupportedException($"Unsupported protocol: {protocol}"); } } diff --git a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs index defd855ec0..78bb881ec2 100644 --- a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs +++ b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs @@ -424,6 +424,7 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable // Must consume both stdout and stderr or deadlocks may occur // RedirectStandardOutput = true, + StandardErrorEncoding = Encoding.UTF8, RedirectStandardError = true, RedirectStandardInput = true, FileName = _mediaEncoder.EncoderPath, diff --git a/MediaBrowser.Model/Configuration/ImageSavingConvention.cs b/MediaBrowser.Model/Configuration/ImageSavingConvention.cs index 485a4d2f80..c67f379fde 100644 --- a/MediaBrowser.Model/Configuration/ImageSavingConvention.cs +++ b/MediaBrowser.Model/Configuration/ImageSavingConvention.cs @@ -1,10 +1,18 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Model.Configuration { + /// + /// The convention used for naming saved images. + /// public enum ImageSavingConvention { + /// + /// The legacy naming convention. + /// Legacy, + + /// + /// The naming convention compatible with other media servers and metadata managers. + /// Compatible } } diff --git a/MediaBrowser.Model/Dlna/CodecType.cs b/MediaBrowser.Model/Dlna/CodecType.cs index c9f090e4cc..12730a76fa 100644 --- a/MediaBrowser.Model/Dlna/CodecType.cs +++ b/MediaBrowser.Model/Dlna/CodecType.cs @@ -1,11 +1,23 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Model.Dlna { + /// + /// The codec type of a codec profile. + /// public enum CodecType { + /// + /// The profile applies to a video codec. + /// Video = 0, + + /// + /// The profile applies to the audio codec of a video stream. + /// VideoAudio = 1, + + /// + /// The profile applies to an audio codec. + /// Audio = 2 } } diff --git a/MediaBrowser.Model/Dlna/EncodingContext.cs b/MediaBrowser.Model/Dlna/EncodingContext.cs index 79ca6366d7..1408333d2e 100644 --- a/MediaBrowser.Model/Dlna/EncodingContext.cs +++ b/MediaBrowser.Model/Dlna/EncodingContext.cs @@ -1,10 +1,18 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Model.Dlna { + /// + /// The encoding context. + /// public enum EncodingContext { + /// + /// The media is transcoded on the fly and delivered as a stream. + /// Streaming = 0, + + /// + /// The media is transcoded to a static file. + /// Static = 1 } } diff --git a/MediaBrowser.Model/Dlna/PlaybackErrorCode.cs b/MediaBrowser.Model/Dlna/PlaybackErrorCode.cs index 300fab5c50..a28f422a2b 100644 --- a/MediaBrowser.Model/Dlna/PlaybackErrorCode.cs +++ b/MediaBrowser.Model/Dlna/PlaybackErrorCode.cs @@ -1,11 +1,23 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Model.Dlna { + /// + /// The playback error code. + /// public enum PlaybackErrorCode { + /// + /// Playback of the item is not allowed. + /// NotAllowed = 0, + + /// + /// No stream compatible with the device profile was found. + /// NoCompatibleStream = 1, + + /// + /// The rate limit has been exceeded. + /// RateLimitExceeded = 2 } } diff --git a/MediaBrowser.Model/Dlna/ResolutionOptions.cs b/MediaBrowser.Model/Dlna/ResolutionOptions.cs index 774592abc7..b161b4a1e4 100644 --- a/MediaBrowser.Model/Dlna/ResolutionOptions.cs +++ b/MediaBrowser.Model/Dlna/ResolutionOptions.cs @@ -1,11 +1,18 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Model.Dlna { + /// + /// The resolution constraints. + /// public class ResolutionOptions { + /// + /// Gets or sets the maximum width. + /// public int? MaxWidth { get; set; } + /// + /// Gets or sets the maximum height. + /// public int? MaxHeight { get; set; } } } diff --git a/MediaBrowser.Model/Dlna/StreamBuilder.cs b/MediaBrowser.Model/Dlna/StreamBuilder.cs index d875bbe8ed..a9ab7d6db0 100644 --- a/MediaBrowser.Model/Dlna/StreamBuilder.cs +++ b/MediaBrowser.Model/Dlna/StreamBuilder.cs @@ -576,11 +576,8 @@ namespace MediaBrowser.Model.Dlna foreach (var profile in subtitleProfiles) { if (profile.Method == SubtitleDeliveryMethod.External - && (string.Equals(profile.Format, stream.Codec, StringComparison.OrdinalIgnoreCase) - // FFmpeg cannot mux VobSub back into an .idx/.sub pair, so extracted VobSub streams are exposed as .mks. - || (string.Equals(profile.Format, "mks", StringComparison.OrdinalIgnoreCase) - && stream.IsVobSubSubtitleStream - && (!stream.IsExternal || stream.Path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))))) + && (IsVobSubMksProfile(profile, stream) + || (!IsVobSubMksDeliveryProfile(profile) && string.Equals(profile.Format, stream.Codec, StringComparison.OrdinalIgnoreCase)))) { return stream.Index; } @@ -951,6 +948,10 @@ namespace MediaBrowser.Model.Dlna } playlistItem.VideoCodecs = videoCodecs; + if (videoStream is not null && !ContainerHelper.ContainsContainer(videoCodecs, false, videoStream.Codec)) + { + playlistItem.TranscodeReasons |= TranscodeReason.VideoCodecNotSupported; + } // Copy video codec options as a starting point, this applies to transcode and direct-stream playlistItem.MaxFramerate = videoStream?.ReferenceFrameRate; @@ -999,6 +1000,10 @@ namespace MediaBrowser.Model.Dlna var directAudioFailures = audioStreamWithSupportedCodec is null ? default : GetCompatibilityAudioCodec(options, item, container ?? string.Empty, audioStreamWithSupportedCodec, null, true, false); playlistItem.TranscodeReasons |= directAudioFailures; + if (audioStream is not null && audioStreamWithSupportedCodec is null) + { + playlistItem.TranscodeReasons |= TranscodeReason.AudioCodecNotSupported; + } var directAudioStreamSatisfied = audioStreamWithSupportedCodec is not null && !channelsExceedsLimit && directAudioFailures == 0; @@ -1582,13 +1587,11 @@ namespace MediaBrowser.Model.Dlna continue; } - // FFmpeg cannot mux VobSub back into an .idx/.sub pair, so extracted VobSub streams are matched against external .mks delivery profiles. - bool isVobSubMksProfile = string.Equals(profile.Format, "mks", StringComparison.OrdinalIgnoreCase) - && subtitleStream.IsVobSubSubtitleStream - && (!subtitleStream.IsExternal || subtitleStream.Path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase)); + bool isVobSubMksProfile = IsVobSubMksProfile(profile, subtitleStream); if ((profile.Method == SubtitleDeliveryMethod.External - && (isVobSubMksProfile || subtitleStream.IsTextSubtitleStream == MediaStream.IsTextFormat(profile.Format))) || + && (isVobSubMksProfile + || (!IsVobSubMksDeliveryProfile(profile) && subtitleStream.IsTextSubtitleStream == MediaStream.IsTextFormat(profile.Format)))) || (profile.Method == SubtitleDeliveryMethod.Hls && subtitleStream.IsTextSubtitleStream)) { bool requiresConversion = !isVobSubMksProfile @@ -1620,6 +1623,21 @@ namespace MediaBrowser.Model.Dlna return null; } + private static bool IsVobSubMksDeliveryProfile(SubtitleProfile profile) + { + return MediaStream.IsVobSubFormat(profile.Format) + && !string.IsNullOrWhiteSpace(profile.Container) + && ContainerHelper.ContainsContainer(profile.Container, "mks"); + } + + private static bool IsVobSubMksProfile(SubtitleProfile profile, MediaStream subtitleStream) + { + // FFmpeg cannot mux VobSub back into an .idx/.sub pair, so extracted VobSub streams are exposed as .mks. + return IsVobSubMksDeliveryProfile(profile) + && subtitleStream.IsVobSubSubtitleStream + && (!subtitleStream.IsExternal || subtitleStream.Path?.EndsWith(".mks", StringComparison.OrdinalIgnoreCase) == true); + } + private bool IsBitrateLimitExceeded(MediaSourceInfo item, long maxBitrate) { // Don't restrict bitrate if item is remote. diff --git a/MediaBrowser.Model/Dlna/TranscodeSeekInfo.cs b/MediaBrowser.Model/Dlna/TranscodeSeekInfo.cs index cc0c6069bf..1563ffd17a 100644 --- a/MediaBrowser.Model/Dlna/TranscodeSeekInfo.cs +++ b/MediaBrowser.Model/Dlna/TranscodeSeekInfo.cs @@ -1,10 +1,18 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Model.Dlna { + /// + /// The transcode seek info. + /// public enum TranscodeSeekInfo { + /// + /// The seek method is chosen automatically. + /// Auto = 0, + + /// + /// Seeking is performed by byte position. + /// Bytes = 1 } } diff --git a/MediaBrowser.Model/Dto/IHasServerId.cs b/MediaBrowser.Model/Dto/IHasServerId.cs index c754d276c5..49452d736a 100644 --- a/MediaBrowser.Model/Dto/IHasServerId.cs +++ b/MediaBrowser.Model/Dto/IHasServerId.cs @@ -1,10 +1,15 @@ #nullable disable -#pragma warning disable CS1591 namespace MediaBrowser.Model.Dto { + /// + /// Interface for DTOs that reference the id of the server they originate from. + /// public interface IHasServerId { + /// + /// Gets the server id. + /// string ServerId { get; } } } diff --git a/MediaBrowser.Model/Dto/MediaSourceType.cs b/MediaBrowser.Model/Dto/MediaSourceType.cs index 42314d5198..ca6649e64b 100644 --- a/MediaBrowser.Model/Dto/MediaSourceType.cs +++ b/MediaBrowser.Model/Dto/MediaSourceType.cs @@ -1,11 +1,23 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Model.Dto { + /// + /// The type of a media source. + /// public enum MediaSourceType { + /// + /// A default media source. + /// Default = 0, + + /// + /// A grouping of media sources. + /// Grouping = 1, + + /// + /// A placeholder media source, for example a disc that has to be inserted. + /// Placeholder = 2 } } diff --git a/MediaBrowser.Model/Dto/RatingType.cs b/MediaBrowser.Model/Dto/RatingType.cs index 033776f9c6..2c2b7b705d 100644 --- a/MediaBrowser.Model/Dto/RatingType.cs +++ b/MediaBrowser.Model/Dto/RatingType.cs @@ -1,10 +1,18 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Model.Dto { + /// + /// The type of a community rating. + /// public enum RatingType { + /// + /// The rating is a numeric score. + /// Score, + + /// + /// The rating is based on likes. + /// Likes } } diff --git a/MediaBrowser.Model/Globalization/ILocalizationManager.cs b/MediaBrowser.Model/Globalization/ILocalizationManager.cs index 7ad240abfb..0fff70c4e0 100644 --- a/MediaBrowser.Model/Globalization/ILocalizationManager.cs +++ b/MediaBrowser.Model/Globalization/ILocalizationManager.cs @@ -72,6 +72,14 @@ public interface ILocalizationManager /// The correct for the given language. CultureDto? FindLanguageInfo(string language); + /// + /// Gets a human-readable display name for the given language code. + /// Truncates at the first semicolon or comma to avoid cluttered ISO-639-2 names. + /// + /// An ISO language code. + /// The display name, or null if not found. + string? GetLanguageDisplayName(string language); + /// /// Returns the language in ISO 639-2/T when the input is ISO 639-2/B. /// diff --git a/MediaBrowser.Model/Library/PlayAccess.cs b/MediaBrowser.Model/Library/PlayAccess.cs index a2f263ce54..22daaf7254 100644 --- a/MediaBrowser.Model/Library/PlayAccess.cs +++ b/MediaBrowser.Model/Library/PlayAccess.cs @@ -1,10 +1,18 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Model.Library { + /// + /// The play access of an item. + /// public enum PlayAccess { + /// + /// The item can be played. + /// Full = 0, + + /// + /// The item cannot be played. + /// None = 1 } } diff --git a/MediaBrowser.Model/LiveTv/DayPattern.cs b/MediaBrowser.Model/LiveTv/DayPattern.cs index 17efe39088..dab69e8974 100644 --- a/MediaBrowser.Model/LiveTv/DayPattern.cs +++ b/MediaBrowser.Model/LiveTv/DayPattern.cs @@ -1,11 +1,23 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Model.LiveTv { + /// + /// The day pattern of a recurring timer. + /// public enum DayPattern { + /// + /// Every day. + /// Daily, + + /// + /// Monday through Friday. + /// Weekdays, + + /// + /// Saturday and Sunday. + /// Weekends } } diff --git a/MediaBrowser.Model/LiveTv/LiveTvServiceStatus.cs b/MediaBrowser.Model/LiveTv/LiveTvServiceStatus.cs index 72a0e2d7bf..a3df1dc411 100644 --- a/MediaBrowser.Model/LiveTv/LiveTvServiceStatus.cs +++ b/MediaBrowser.Model/LiveTv/LiveTvServiceStatus.cs @@ -1,10 +1,18 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Model.LiveTv { + /// + /// The status of a live TV service. + /// public enum LiveTvServiceStatus { + /// + /// The service is available. + /// Ok = 0, + + /// + /// The service is unavailable. + /// Unavailable = 1 } } diff --git a/MediaBrowser.Model/MediaInfo/TransportStreamTimestamp.cs b/MediaBrowser.Model/MediaInfo/TransportStreamTimestamp.cs index b7ee5747ab..1988dd8078 100644 --- a/MediaBrowser.Model/MediaInfo/TransportStreamTimestamp.cs +++ b/MediaBrowser.Model/MediaInfo/TransportStreamTimestamp.cs @@ -1,11 +1,23 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Model.MediaInfo { + /// + /// The type of timestamps used in a transport stream. + /// public enum TransportStreamTimestamp { + /// + /// The stream contains no timestamps. + /// None, + + /// + /// The stream contains zero-value timestamps. + /// Zero, + + /// + /// The stream contains valid timestamps. + /// Valid } } diff --git a/MediaBrowser.Model/Session/MessageCommand.cs b/MediaBrowser.Model/Session/MessageCommand.cs index cc9db8e6c5..e041a9cccd 100644 --- a/MediaBrowser.Model/Session/MessageCommand.cs +++ b/MediaBrowser.Model/Session/MessageCommand.cs @@ -1,17 +1,28 @@ #nullable disable -#pragma warning disable CS1591 using System.ComponentModel.DataAnnotations; namespace MediaBrowser.Model.Session { + /// + /// A command to display a message on a client. + /// public class MessageCommand { + /// + /// Gets or sets the message header. + /// public string Header { get; set; } + /// + /// Gets or sets the message text. + /// [Required(AllowEmptyStrings = false)] public string Text { get; set; } + /// + /// Gets or sets the timeout in milliseconds after which the message should be dismissed. + /// public long? TimeoutMs { get; set; } } } diff --git a/MediaBrowser.Model/Session/PlayMethod.cs b/MediaBrowser.Model/Session/PlayMethod.cs index 8067627843..2bd11cc91a 100644 --- a/MediaBrowser.Model/Session/PlayMethod.cs +++ b/MediaBrowser.Model/Session/PlayMethod.cs @@ -1,11 +1,23 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Model.Session { + /// + /// The play method. + /// public enum PlayMethod { + /// + /// The media is transcoded before it is sent to the client. + /// Transcode = 0, + + /// + /// The media is remuxed into a compatible container but the streams are not re-encoded. + /// DirectStream = 1, + + /// + /// The media is sent to the client as-is. + /// DirectPlay = 2 } } diff --git a/MediaBrowser.Model/Session/PlaystateRequest.cs b/MediaBrowser.Model/Session/PlaystateRequest.cs index ba2c024b76..040affa144 100644 --- a/MediaBrowser.Model/Session/PlaystateRequest.cs +++ b/MediaBrowser.Model/Session/PlaystateRequest.cs @@ -1,11 +1,18 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Model.Session { + /// + /// A request to change the playstate of a session. + /// public class PlaystateRequest { + /// + /// Gets or sets the playstate command. + /// public PlaystateCommand Command { get; set; } + /// + /// Gets or sets the seek position in ticks. + /// public long? SeekPositionTicks { get; set; } /// diff --git a/MediaBrowser.Model/Session/QueueItem.cs b/MediaBrowser.Model/Session/QueueItem.cs index 43920a8464..b9f3181da0 100644 --- a/MediaBrowser.Model/Session/QueueItem.cs +++ b/MediaBrowser.Model/Session/QueueItem.cs @@ -1,13 +1,21 @@ #nullable disable -#pragma warning disable CS1591 using System; namespace MediaBrowser.Model.Session; +/// +/// An item in a play queue. +/// public record QueueItem { + /// + /// Gets or sets the item id. + /// public Guid Id { get; set; } + /// + /// Gets or sets the playlist item id. + /// public string PlaylistItemId { get; set; } } diff --git a/MediaBrowser.Model/Session/RepeatMode.cs b/MediaBrowser.Model/Session/RepeatMode.cs index c6e173d6b8..c6c657d220 100644 --- a/MediaBrowser.Model/Session/RepeatMode.cs +++ b/MediaBrowser.Model/Session/RepeatMode.cs @@ -1,11 +1,23 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Model.Session { + /// + /// The repeat mode of a play queue. + /// public enum RepeatMode { + /// + /// Nothing is repeated. + /// RepeatNone = 0, + + /// + /// The whole queue is repeated. + /// RepeatAll = 1, + + /// + /// The current item is repeated. + /// RepeatOne = 2 } } diff --git a/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs b/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs new file mode 100644 index 0000000000..2bd2676ceb --- /dev/null +++ b/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs @@ -0,0 +1,238 @@ +using System; +using System.Globalization; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Extensions.Json; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.IO; +using MediaBrowser.Providers.Books.ComicBookInfo.Models; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Providers.Books.ComicBookInfo; + +/// +/// ComicBookInfo provider. +/// +public class ComicBookInfoProvider : IComicProvider +{ + private readonly ILogger _logger; + private readonly IFileSystem _fileSystem; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// Instance of the interface. + public ComicBookInfoProvider(IFileSystem fileSystem, ILogger logger) + { + _fileSystem = fileSystem; + _logger = logger; + } + + /// + public async ValueTask> ReadMetadata(ItemInfo info, IDirectoryService directoryService, CancellationToken cancellationToken) + { + var path = GetComicBookFile(info.Path)?.FullName; + + if (path is null) + { + _logger.LogDebug("could not load comic: {Path}", info.Path); + return new MetadataResult { HasMetadata = false }; + } + + try + { + Stream stream = AsyncFile.OpenRead(path); + await using (stream.ConfigureAwait(false)) + { + var archive = await ZipArchive.CreateAsync(stream, ZipArchiveMode.Read, false, null, cancellationToken).ConfigureAwait(false); + await using (archive.ConfigureAwait(false)) + { + if (archive.Comment is null) + { + _logger.LogInformation("missing ComicBookInfo in archive comment: {Path}", info.Path); + return new MetadataResult { HasMetadata = false }; + } + + var comicBookMetadata = JsonSerializer.Deserialize(archive.Comment, JsonDefaults.Options); + if (comicBookMetadata is null) + { + _logger.LogError("ComicBookInfo deserialization failure: {Path}", info.Path); + return new MetadataResult { HasMetadata = false }; + } + + return SaveMetadata(comicBookMetadata); + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "failed to load ComicBookInfo metadata: {Path}", info.Path); + return new MetadataResult { HasMetadata = false }; + } + } + + /// + public bool HasItemChanged(BaseItem item) + { + var file = GetComicBookFile(item.Path); + + if (file is null) + { + return false; + } + + return file.Exists && _fileSystem.GetLastWriteTimeUtc(file) > item.DateLastSaved; + } + + private MetadataResult SaveMetadata(ComicBookInfoFormat comic) + { + if (comic.Metadata is null) + { + return new MetadataResult { HasMetadata = false }; + } + + var book = ReadComicBookMetadata(comic.Metadata); + + if (book is null) + { + return new MetadataResult { HasMetadata = false }; + } + + var metadataResult = new MetadataResult { Item = book, HasMetadata = true }; + + if (comic.Metadata.Language is not null) + { + metadataResult.ResultLanguage = ReadCultureInfoInto(comic.Metadata.Language); + } + + if (comic.Metadata.Credits.Count > 0) + { + ReadPeopleMetadata(comic.Metadata, metadataResult); + } + + return metadataResult; + } + + private FileSystemMetadata? GetComicBookFile(string path) + { + var fileInfo = _fileSystem.GetFileSystemInfo(path); + + if (fileInfo.IsDirectory) + { + return null; + } + + // only parse files that are known to have ComicBookInfo metadata + return fileInfo.Extension.Equals(".cbz", StringComparison.OrdinalIgnoreCase) ? fileInfo : null; + } + + private static Book? ReadComicBookMetadata(ComicBookInfoMetadata comic) + { + var book = new Book(); + var hasFoundMetadata = false; + + hasFoundMetadata |= ReadStringInto(comic.Title, title => book.Name = title); + hasFoundMetadata |= ReadStringInto(comic.Series, series => book.SeriesName = series); + hasFoundMetadata |= ReadStringInto(comic.Genre, genre => book.AddGenre(genre)); + hasFoundMetadata |= ReadStringInto(comic.Comments, overview => book.Overview = overview); + hasFoundMetadata |= ReadStringInto(comic.Publisher, publisher => book.SetStudios([publisher])); + + if (comic.PublicationYear is not null) + { + book.ProductionYear = comic.PublicationYear; + hasFoundMetadata = true; + } + + if (comic.Issue is not null) + { + book.IndexNumber = comic.Issue; + hasFoundMetadata = true; + } + + if (comic.Tags.Count > 0) + { + book.Tags = comic.Tags.ToArray(); + hasFoundMetadata = true; + } + + if (comic.PublicationYear is not null && comic.PublicationMonth is not null) + { + book.PremiereDate = ReadTwoPartDateInto(comic.PublicationYear.Value, comic.PublicationMonth.Value); + hasFoundMetadata = true; + } + + return hasFoundMetadata ? book : null; + } + + private static void ReadPeopleMetadata(ComicBookInfoMetadata comic, MetadataResult metadataResult) + { + foreach (var person in comic.Credits) + { + if (person.Person is null || person.Role is null) + { + continue; + } + + if (person.Person.Contains(',', StringComparison.InvariantCultureIgnoreCase)) + { + var name = person.Person.Split(','); + person.Person = name[1].Trim(' ') + " " + name[0].Trim(' '); + } + + if (!Enum.TryParse(person.Role, out PersonKind personKind)) + { + personKind = PersonKind.Unknown; + } + + if (string.Equals("Colorer", person.Role, StringComparison.OrdinalIgnoreCase)) + { + personKind = PersonKind.Colorist; + } + + metadataResult.AddPerson(new PersonInfo { Name = person.Person, Type = personKind }); + } + } + + private static string? ReadCultureInfoInto(string language) + { + try + { + return CultureInfo.GetCultureInfo(language).DisplayName; + } + catch (CultureNotFoundException) + { + return null; + } + } + + private static bool ReadStringInto(string? data, Action commitResult) + { + if (!string.IsNullOrWhiteSpace(data)) + { + commitResult(data); + return true; + } + + return false; + } + + private static DateTime? ReadTwoPartDateInto(int year, int month) + { + try + { + // use first day of the month because this format doesn't include a day + return new DateTime(year, month, 1, 0, 0, 0, DateTimeKind.Unspecified); + } + catch (ArgumentOutOfRangeException) + { + return null; + } + } +} diff --git a/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoCredit.cs b/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoCredit.cs new file mode 100644 index 0000000000..fe7aa40456 --- /dev/null +++ b/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoCredit.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace MediaBrowser.Providers.Books.ComicBookInfo.Models; + +/// +/// ComicBookInfo credit. +/// +public class ComicBookInfoCredit +{ + /// + /// Gets or sets the person name. + /// + [JsonPropertyName("person")] + public string? Person { get; set; } + + /// + /// Gets or sets the role. + /// + [JsonPropertyName("role")] + public string? Role { get; set; } +} diff --git a/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoFormat.cs b/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoFormat.cs new file mode 100644 index 0000000000..5c4e3d948f --- /dev/null +++ b/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoFormat.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; + +namespace MediaBrowser.Providers.Books.ComicBookInfo.Models; + +/// +/// ComicBookInfo format. +/// +public class ComicBookInfoFormat +{ + /// + /// Gets or sets the app ID. + /// + [JsonPropertyName("appID")] + public string? AppId { get; set; } + + /// + /// Gets or sets the last modified timestamp. + /// + [JsonPropertyName("lastModified")] + public string? LastModified { get; set; } + + /// + /// Gets or sets the metadata. + /// + [JsonPropertyName("ComicBookInfo/1.0")] + public ComicBookInfoMetadata? Metadata { get; set; } +} diff --git a/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoMetadata.cs b/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoMetadata.cs new file mode 100644 index 0000000000..42e1b3d4f6 --- /dev/null +++ b/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoMetadata.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace MediaBrowser.Providers.Books.ComicBookInfo.Models; + +/// +/// ComicBookInfo metadata. +/// +public class ComicBookInfoMetadata +{ + /// + /// Gets or sets the series. + /// + [JsonPropertyName("series")] + public string? Series { get; set; } + + /// + /// Gets or sets the title. + /// + [JsonPropertyName("title")] + public string? Title { get; set; } + + /// + /// Gets or sets the publisher. + /// + [JsonPropertyName("publisher")] + public string? Publisher { get; set; } + + /// + /// Gets or sets the publication month. + /// + [JsonPropertyName("publicationMonth")] + public int? PublicationMonth { get; set; } + + /// + /// Gets or sets the publication year. + /// + [JsonPropertyName("publicationYear")] + public int? PublicationYear { get; set; } + + /// + /// Gets or sets the issue number. + /// + [JsonPropertyName("issue")] + public int? Issue { get; set; } + + /// + /// Gets or sets the number of issues. + /// + [JsonPropertyName("numberOfIssues")] + public int? NumberOfIssues { get; set; } + + /// + /// Gets or sets the volume number. + /// + [JsonPropertyName("volume")] + public int? Volume { get; set; } + + /// + /// Gets or sets the number of volumes. + /// + [JsonPropertyName("numberOfVolumes")] + public int? NumberOfVolumes { get; set; } + + /// + /// Gets or sets the rating. + /// + [JsonPropertyName("rating")] + public int? Rating { get; set; } + + /// + /// Gets or sets the genre. + /// + [JsonPropertyName("genre")] + public string? Genre { get; set; } + + /// + /// Gets or sets the language. + /// + [JsonPropertyName("language")] + public string? Language { get; set; } + + /// + /// Gets or sets the country. + /// + [JsonPropertyName("country")] + public string? Country { get; set; } + + /// + /// Gets or sets the list of credits. + /// + [JsonPropertyName("credits")] + public IReadOnlyList Credits { get; set; } = Array.Empty(); + + /// + /// Gets or sets the list of tags. + /// + [JsonPropertyName("tags")] + public IReadOnlyList Tags { get; set; } = Array.Empty(); + + /// + /// Gets or sets the comments. + /// + [JsonPropertyName("comments")] + public string? Comments { get; set; } +} diff --git a/MediaBrowser.Providers/Books/ComicImageProvider.cs b/MediaBrowser.Providers/Books/ComicImageProvider.cs new file mode 100644 index 0000000000..34936cff13 --- /dev/null +++ b/MediaBrowser.Providers/Books/ComicImageProvider.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Extensions; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Drawing; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.Logging; +using SharpCompress.Archives; + +namespace MediaBrowser.Providers.Books; + +/// +/// The ComicImageProvider tries to find either an image named "cover" or, in case that +/// fails, just takes the first image inside the archive, hoping that it is the cover. +/// +public class ComicImageProvider : IDynamicImageProvider +{ + private readonly string[] _comicBookExtensions = [".cb7", ".cbr", ".cbt", ".cbz"]; + private readonly string[] _coverExtensions = [".png", ".jpeg", ".jpg", ".webp", ".bmp", ".gif"]; + + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + public ComicImageProvider(ILogger logger) + { + _logger = logger; + } + + /// + public string Name => "Comic Book Archive Cover Extractor"; + + /// + public async Task GetImage(BaseItem item, ImageType type, CancellationToken cancellationToken) + { + var extension = Path.GetExtension(item.Path); + + if (_comicBookExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase)) + { + return await LoadCoverAsync(item, cancellationToken).ConfigureAwait(false); + } + + return new DynamicImageResponse { HasImage = false }; + } + + /// + public IEnumerable GetSupportedImages(BaseItem item) + { + yield return ImageType.Primary; + } + + /// + public bool Supports(BaseItem item) + { + return item is Book; + } + + /// + /// Tries to load a cover from the CBZ archive. Returns a response + /// with no image if nothing is found. + /// + /// Item to check for covers. + /// The cancellation token. + private async Task LoadCoverAsync(BaseItem item, CancellationToken cancellationToken) + { + var memoryStream = new MemoryStream(); + + try + { + ImageFormat imageFormat; + + using (Stream stream = AsyncFile.OpenRead(item.Path)) + { + var archive = await ArchiveFactory.OpenAsyncArchive(stream, cancellationToken: cancellationToken).ConfigureAwait(false); + await using (archive.ConfigureAwait(false)) + { + // throw exception to log results if no cover is found + (var cover, imageFormat) = await FindCoverEntryInArchiveAsync(archive).ConfigureAwait(false) + ?? throw new InvalidOperationException("no supported cover found"); + + // copy the cover to memory stream + var coverStream = await cover.OpenEntryStreamAsync(cancellationToken).ConfigureAwait(false); + await using (coverStream.ConfigureAwait(false)) + { + await coverStream.CopyToAsync(memoryStream, cancellationToken).ConfigureAwait(false); + } + } + } + + // reset stream position after copying + memoryStream.Position = 0; + + return new DynamicImageResponse { HasImage = true, Stream = memoryStream, Format = imageFormat }; + } + catch (Exception e) + { + _logger.LogError(e, "failed to load cover from {Path}", item.Path); + return new DynamicImageResponse { HasImage = false }; + } + } + + /// + /// Tries to find the entry containing the cover. + /// + /// The archive to search. + /// The search result. + private async ValueTask<(IArchiveEntry CoverEntry, ImageFormat ImageFormat)?> FindCoverEntryInArchiveAsync(IAsyncArchive archive) + { + IArchiveEntry? cover; + + // only some comics will explicitly name their cover file + // in many cases the cover will simply be the first image in the archive + foreach (var extension in _coverExtensions) + { + cover = await archive.EntriesAsync.FirstOrDefaultAsync(e => e.Key == "cover" + extension).ConfigureAwait(false); + + if (cover is not null) + { + var imageFormat = GetImageFormat(extension); + + return (cover, imageFormat); + } + } + + cover = await archive.EntriesAsync.OrderBy(x => x.Key) + .FirstOrDefaultAsync(x => _coverExtensions.Contains(Path.GetExtension(x.Key), StringComparison.OrdinalIgnoreCase)) + .ConfigureAwait(false); + + if (cover is not null) + { + var imageFormat = GetImageFormat(Path.GetExtension(cover.Key ?? string.Empty)); + + return (cover, imageFormat); + } + + return null; + } + + private static ImageFormat GetImageFormat(string extension) => extension.ToLowerInvariant() switch + { + ".jpg" => ImageFormat.Jpg, + ".jpeg" => ImageFormat.Jpg, + ".png" => ImageFormat.Png, + ".webp" => ImageFormat.Webp, + ".bmp" => ImageFormat.Bmp, + ".gif" => ImageFormat.Gif, + ".svg" => ImageFormat.Svg, + _ => throw new ArgumentException($"unsupported extension: {extension}"), + }; +} diff --git a/MediaBrowser.Providers/Books/ComicInfo/ComicInfoReader.cs b/MediaBrowser.Providers/Books/ComicInfo/ComicInfoReader.cs new file mode 100644 index 0000000000..4e8dc405ec --- /dev/null +++ b/MediaBrowser.Providers/Books/ComicInfo/ComicInfoReader.cs @@ -0,0 +1,235 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Xml.Linq; +using System.Xml.XPath; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; + +namespace MediaBrowser.Providers.Books.ComicInfo; + +/// +/// ComicInfo reader. +/// +public static class ComicInfoReader +{ + /// + /// Filename to check for comic metadata either next to the comic file or inside the archive. + /// + public const string ComicRackMetaFile = "ComicInfo.xml"; + + /// + /// Read comic book metadata. + /// + /// The XDocument to read for comic metadata. + /// The resulting book. + public static Book? ReadComicBookMetadata(XDocument xml) + { + var book = new Book(); + var hasFoundMetadata = false; + + // this value is only used internally since Jellyfin has no manga flag + var isManga = false; + + hasFoundMetadata |= ReadStringInto(xml, "ComicInfo/Title", title => book.Name = title); + hasFoundMetadata |= ReadStringInto(xml, "ComicInfo/Manga", manga => isManga = manga.Equals("Yes", StringComparison.OrdinalIgnoreCase)); + hasFoundMetadata |= ReadStringInto(xml, "ComicInfo/Series", series => book.SeriesName = series); + hasFoundMetadata |= ReadIntInto(xml, "ComicInfo/Number", issue => book.IndexNumber = issue); + hasFoundMetadata |= ReadStringInto(xml, "ComicInfo/Summary", summary => book.Overview = summary); + hasFoundMetadata |= ReadIntInto(xml, "ComicInfo/Year", year => book.ProductionYear = year); + hasFoundMetadata |= ReadThreePartDateInto(xml, "ComicInfo/Year", "ComicInfo/Month", "ComicInfo/Day", dateTime => book.PremiereDate = dateTime); + hasFoundMetadata |= ReadCommaSeparatedStringsInto(xml, "ComicInfo/Genre", genres => + { + foreach (var genre in genres) + { + book.AddGenre(genre); + } + }); + hasFoundMetadata |= ReadStringInto(xml, "ComicInfo/Publisher", publisher => book.SetStudios([publisher])); + + hasFoundMetadata |= ReadStringInto(xml, "ComicInfo/AlternateSeries", title => + { + if (isManga) + { + // Software like ComicTagger (https://github.com/comictagger/comictagger) will use + // this field for the series name in the original language when tagging manga. + book.OriginalTitle = title; + } + else + { + // Some US comics can be part of cross-over story arcs. This field is then used to + // specify an alternate series. + } + }); + + return hasFoundMetadata ? book : null; + } + + /// + /// Read people metadata. + /// + /// The XDocument to read for people metadata. + /// The metadata result to update. + public static void ReadPeopleMetadata(XDocument xml, MetadataResult metadataResult) + { + ReadCommaSeparatedStringsInto(xml, "ComicInfo/Writer", authors => + { + foreach (var p in authors) + { + metadataResult.AddPerson(new PersonInfo { Name = p, Type = PersonKind.Author }); + } + }); + + ReadCommaSeparatedStringsInto(xml, "ComicInfo/Penciller", pencillers => + { + foreach (var p in pencillers) + { + metadataResult.AddPerson(new PersonInfo { Name = p, Type = PersonKind.Penciller }); + } + }); + + ReadCommaSeparatedStringsInto(xml, "ComicInfo/Inker", inkers => + { + foreach (var p in inkers) + { + metadataResult.AddPerson(new PersonInfo { Name = p, Type = PersonKind.Inker }); + } + }); + + ReadCommaSeparatedStringsInto(xml, "ComicInfo/Letterer", letterers => + { + foreach (var p in letterers) + { + metadataResult.AddPerson(new PersonInfo { Name = p, Type = PersonKind.Letterer }); + } + }); + + ReadCommaSeparatedStringsInto(xml, "ComicInfo/CoverArtist", artists => + { + foreach (var p in artists) + { + metadataResult.AddPerson(new PersonInfo { Name = p, Type = PersonKind.CoverArtist }); + } + }); + + ReadCommaSeparatedStringsInto(xml, "ComicInfo/Colourist", colorists => + { + foreach (var p in colorists) + { + metadataResult.AddPerson(new PersonInfo { Name = p, Type = PersonKind.Colorist }); + } + }); + } + + /// + /// Read culture information. + /// + /// the XDocument to read for metadata. + /// The path to search. + /// The action to take after parsing all metadata. + public static void ReadCultureInfoInto(XDocument xml, string xPath, Action commitResult) + { + string? culture = null; + + if (!ReadStringInto(xml, xPath, value => culture = value)) + { + return; + } + + // culture cannot be null here as the method would have returned earlier + commitResult(new CultureInfo(culture!)); + } + + private static bool ReadStringInto(XDocument xml, string xPath, Action commitResult) + { + var resultElement = xml.XPathSelectElement(xPath); + + if (resultElement is not null && !string.IsNullOrWhiteSpace(resultElement.Value)) + { + commitResult(resultElement.Value); + return true; + } + + return false; + } + + private static bool ReadCommaSeparatedStringsInto(XDocument xml, string xPath, Action> commitResult) + { + var resultElement = xml.XPathSelectElement(xPath); + + if (resultElement is null || string.IsNullOrWhiteSpace(resultElement.Value)) + { + return false; + } + + try + { + var splits = resultElement.Value.Split(",").Select(p => p.Trim()).ToArray(); + if (splits.Length < 1) + { + return false; + } + + commitResult(splits); + return true; + } + catch (ArgumentNullException) + { + return false; + } + } + + private static bool ReadIntInto(XDocument xml, string xPath, Action commitResult) + { + var resultElement = xml.XPathSelectElement(xPath); + + if (resultElement is not null && !string.IsNullOrWhiteSpace(resultElement.Value)) + { + return ParseInt(resultElement.Value, commitResult); + } + + return false; + } + + private static bool ReadThreePartDateInto(XDocument xml, string yearXPath, string monthXPath, string dayXPath, Action commitResult) + { + int year = 0; + int month = 0; + int day = 0; + var parsed = false; + + parsed |= ReadIntInto(xml, yearXPath, num => year = num); + parsed |= ReadIntInto(xml, monthXPath, num => month = num); + parsed |= ReadIntInto(xml, dayXPath, num => day = num); + + if (!parsed) + { + return false; + } + + try + { + var dateTime = new DateTime(year, month, day, 0, 0, 0, DateTimeKind.Unspecified); + + commitResult(dateTime); + return true; + } + catch (ArgumentOutOfRangeException) + { + return false; + } + } + + private static bool ParseInt(string input, Action commitResult) + { + if (int.TryParse(input, out var parsed)) + { + commitResult(parsed); + return true; + } + + return false; + } +} diff --git a/MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs b/MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs new file mode 100644 index 0000000000..cfd22a850e --- /dev/null +++ b/MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs @@ -0,0 +1,98 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using System.Xml; +using System.Xml.Linq; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Providers.Books.ComicInfo; + +/// +/// Handles metadata for comics which is saved as an XML document. This XML document is not part +/// of the comic itself but an external file. +/// +public class ExternalComicInfoProvider : IComicProvider +{ + private readonly IFileSystem _fileSystem; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// Instance of the interface. + public ExternalComicInfoProvider(IFileSystem fileSystem, ILogger logger) + { + _logger = logger; + _fileSystem = fileSystem; + } + + /// + public async ValueTask> ReadMetadata(ItemInfo info, IDirectoryService directoryService, CancellationToken cancellationToken) + { + var comicInfoXml = await LoadXml(info, cancellationToken).ConfigureAwait(false); + + if (comicInfoXml is null) + { + _logger.LogDebug("No external ComicInfo metadata found for {Path}.", info.Path); + return new MetadataResult { HasMetadata = false }; + } + + var book = ComicInfoReader.ReadComicBookMetadata(comicInfoXml); + + if (book is null) + { + return new MetadataResult { HasMetadata = false }; + } + + var metadataResult = new MetadataResult { Item = book, HasMetadata = true }; + + ComicInfoReader.ReadPeopleMetadata(comicInfoXml, metadataResult); + ComicInfoReader.ReadCultureInfoInto(comicInfoXml, "ComicInfo/LanguageISO", cultureInfo => metadataResult.ResultLanguage = cultureInfo.ThreeLetterISOLanguageName); + + return metadataResult; + } + + /// + public bool HasItemChanged(BaseItem item) + { + var file = GetXmlFilePath(item.Path); + + return file.Exists && _fileSystem.GetLastWriteTimeUtc(file) > item.DateLastSaved; + } + + private async Task LoadXml(ItemInfo info, CancellationToken cancellationToken) + { + var file = GetXmlFilePath(info.Path); + if (!file.Exists) + { + return null; + } + + try + { + using var reader = XmlReader.Create(file.FullName, new XmlReaderSettings { Async = true }); + var comicInfoXml = XDocument.LoadAsync(reader, LoadOptions.None, cancellationToken); + + return await comicInfoXml.ConfigureAwait(false); + } + catch (Exception e) + { + _logger.LogWarning(e, "Could not load external ComicInfo XML from {Path}.", file.FullName); + return null; + } + } + + private FileSystemMetadata GetXmlFilePath(string path) + { + var fileInfo = _fileSystem.GetFileSystemInfo(path); + var directoryInfo = fileInfo.IsDirectory ? fileInfo : _fileSystem.GetDirectoryInfo(Path.GetDirectoryName(path)!); + var file = _fileSystem.GetFileInfo(Path.Combine(directoryInfo.FullName, Path.GetFileNameWithoutExtension(path) + ".xml")); + + return file.Exists ? file : _fileSystem.GetFileInfo(Path.Combine(directoryInfo.FullName, ComicInfoReader.ComicRackMetaFile)); + } +} diff --git a/MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs b/MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs new file mode 100644 index 0000000000..19062452b9 --- /dev/null +++ b/MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs @@ -0,0 +1,120 @@ +using System; +using System.IO.Compression; +using System.Threading; +using System.Threading.Tasks; +using System.Xml.Linq; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Providers.Books.ComicInfo; + +/// +/// Handles metadata for comics which is saved as an XML document inside the comic itself. +/// +public class InternalComicInfoProvider : IComicProvider +{ + private readonly IFileSystem _fileSystem; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// Instance of the interface. + public InternalComicInfoProvider(IFileSystem fileSystem, ILogger logger) + { + _logger = logger; + _fileSystem = fileSystem; + } + + /// + public async ValueTask> ReadMetadata(ItemInfo info, IDirectoryService directoryService, CancellationToken cancellationToken) + { + var comicInfoXml = await LoadXml(info, cancellationToken).ConfigureAwait(false); + + if (comicInfoXml is null) + { + _logger.LogDebug("Could not load ComicInfo metadata for {Path} from XML file. No internal XML in comic archive.", info.Path); + return new MetadataResult { HasMetadata = false }; + } + + var book = ComicInfoReader.ReadComicBookMetadata(comicInfoXml); + + if (book is null) + { + return new MetadataResult { HasMetadata = false }; + } + + var metadataResult = new MetadataResult { Item = book, HasMetadata = true }; + + ComicInfoReader.ReadPeopleMetadata(comicInfoXml, metadataResult); + ComicInfoReader.ReadCultureInfoInto(comicInfoXml, "ComicInfo/LanguageISO", cultureInfo => metadataResult.ResultLanguage = cultureInfo.ThreeLetterISOLanguageName); + + return metadataResult; + } + + /// + public bool HasItemChanged(BaseItem item) + { + var file = GetComicBookFile(item.Path); + + if (file is null) + { + return false; + } + + return file.Exists && _fileSystem.GetLastWriteTimeUtc(file) > item.DateLastSaved; + } + + private async Task LoadXml(ItemInfo info, CancellationToken cancellationToken) + { + var path = GetComicBookFile(info.Path)?.FullName; + + if (path is null) + { + return null; + } + + try + { + // open the comic archive and try to get the ComicInfo.xml entry + using var comicBookFile = await ZipFile.OpenReadAsync(path, cancellationToken).ConfigureAwait(false); + var container = comicBookFile.GetEntry(ComicInfoReader.ComicRackMetaFile); + + if (container is null) + { + return null; + } + + using var containerStream = await container.OpenAsync(cancellationToken).ConfigureAwait(false); + var comicInfoXml = XDocument.LoadAsync(containerStream, LoadOptions.None, cancellationToken); + + return await comicInfoXml.ConfigureAwait(false); + } + catch (Exception e) + { + _logger.LogError(e, "could not load internal XML from {Path}", path); + return null; + } + } + + private FileSystemMetadata? GetComicBookFile(string path) + { + var fileInfo = _fileSystem.GetFileSystemInfo(path); + + if (fileInfo.IsDirectory) + { + return null; + } + + // only parse files that are known to have internal metadata + if (!string.Equals(fileInfo.Extension, ".cbz", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + return fileInfo; + } +} diff --git a/MediaBrowser.Providers/Books/ComicProvider.cs b/MediaBrowser.Providers/Books/ComicProvider.cs new file mode 100644 index 0000000000..d59c58c330 --- /dev/null +++ b/MediaBrowser.Providers/Books/ComicProvider.cs @@ -0,0 +1,59 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; + +namespace MediaBrowser.Providers.Books; + +/// +/// Comic provider. +/// +public class ComicProvider : ILocalMetadataProvider, IHasItemChangeMonitor +{ + private readonly IEnumerable _comicProviders; + + /// + /// Initializes a new instance of the class. + /// + /// The list of comic providers. + public ComicProvider(IEnumerable comicProviders) + { + _comicProviders = comicProviders; + } + + /// + public string Name => "Comic Provider"; + + /// + public async Task> GetMetadata(ItemInfo info, IDirectoryService directoryService, CancellationToken cancellationToken) + { + foreach (IComicProvider comicProvider in _comicProviders) + { + var metadata = await comicProvider.ReadMetadata(info, directoryService, cancellationToken).ConfigureAwait(false); + + if (metadata.HasMetadata) + { + return metadata; + } + } + + return new MetadataResult { HasMetadata = false }; + } + + /// + public bool HasChanged(BaseItem item, IDirectoryService directoryService) + { + foreach (IComicProvider iComicFileProvider in _comicProviders) + { + var fileChanged = iComicFileProvider.HasItemChanged(item); + + if (fileChanged) + { + return fileChanged; + } + } + + return false; + } +} diff --git a/MediaBrowser.Providers/Books/IComicProvider.cs b/MediaBrowser.Providers/Books/IComicProvider.cs new file mode 100644 index 0000000000..06c8bd1136 --- /dev/null +++ b/MediaBrowser.Providers/Books/IComicProvider.cs @@ -0,0 +1,28 @@ +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; + +namespace MediaBrowser.Providers.Books; + +/// +/// Comic provider interface. +/// +public interface IComicProvider +{ + /// + /// Read the item metadata. + /// + /// The item information. + /// Instance of the interface. + /// The cancellation token. + /// The metadata result. + ValueTask> ReadMetadata(ItemInfo info, IDirectoryService directoryService, CancellationToken cancellationToken); + + /// + /// Determine whether the item has changed. + /// + /// The item. + /// Item change status. + bool HasItemChanged(BaseItem item); +} diff --git a/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs b/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs index 15ea2ce5ab..6266413dfc 100644 --- a/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs +++ b/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs @@ -2,6 +2,7 @@ using System; using System.Globalization; using System.IO; using System.Linq; +using System.Text.RegularExpressions; using System.Threading; using System.Xml; using Jellyfin.Data.Enums; @@ -17,7 +18,7 @@ namespace MediaBrowser.Providers.Books.OpenPackagingFormat /// Methods used to pull metadata and other information from Open Packaging Format in XML objects. /// /// The type of category. - public class OpfReader + public partial class OpfReader { private const string DcNamespace = @"http://purl.org/dc/elements/1.1/"; private const string OpfNamespace = @"http://www.idpf.org/2007/opf"; @@ -42,6 +43,9 @@ namespace MediaBrowser.Providers.Books.OpenPackagingFormat _namespaceManager.AddNamespace("opf", OpfNamespace); } + [GeneratedRegex(@"(?<=\p{L})\.(?!\s|$)")] + private static partial Regex InitialsRegex(); + /// /// Checks for the existence of a cover image. /// @@ -125,7 +129,7 @@ namespace MediaBrowser.Providers.Books.OpenPackagingFormat ReadStringInto("//dc:date", date => { - if (DateTime.TryParse(date, out var dateValue)) + if (DateTime.TryParse(date, CultureInfo.InvariantCulture, out var dateValue)) { book.PremiereDate = dateValue.Date; book.ProductionYear = dateValue.Date.Year; @@ -229,11 +233,23 @@ namespace MediaBrowser.Providers.Books.OpenPackagingFormat { foreach (XmlElement creator in resultElement) { - var creatorName = creator.InnerText; var role = creator.GetAttribute("opf:role"); - var person = new PersonInfo { Name = creatorName, Type = GetRole(role) }; + var normalizedCreators = creator.InnerText + .Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(fullName => + { + if (fullName.Split(',', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) is [var lastName, var firstName]) + { + fullName = $"{firstName} {lastName}"; + } - book.AddPerson(person); + return InitialsRegex().Replace(fullName, ". "); + }); + + foreach (var fullName in normalizedCreators) + { + book.AddPerson(new PersonInfo { Name = fullName, Type = GetRole(role) }); + } } } } diff --git a/MediaBrowser.Providers/Lyric/LyricManager.cs b/MediaBrowser.Providers/Lyric/LyricManager.cs index 913a104a0d..af31e373ef 100644 --- a/MediaBrowser.Providers/Lyric/LyricManager.cs +++ b/MediaBrowser.Providers/Lyric/LyricManager.cs @@ -398,7 +398,7 @@ public class LyricManager : ILyricManager { var mediaFolderPath = Path.GetFullPath(Path.Combine(audio.ContainingFolderPath, saveFileName)); // TODO: Add some error handling to the API user: return BadRequest("Could not save lyric, bad path."); - if (mediaFolderPath.StartsWith(audio.ContainingFolderPath, StringComparison.Ordinal)) + if (PathHelper.IsContainedIn(audio.ContainingFolderPath, mediaFolderPath)) { savePaths.Add(mediaFolderPath); } @@ -407,7 +407,7 @@ public class LyricManager : ILyricManager var internalPath = Path.GetFullPath(Path.Combine(audio.GetInternalMetadataPath(), saveFileName)); // TODO: Add some error to the user: return BadRequest("Could not save lyric, bad path."); - if (internalPath.StartsWith(audio.GetInternalMetadataPath(), StringComparison.Ordinal)) + if (PathHelper.IsContainedIn(audio.GetInternalMetadataPath(), internalPath)) { savePaths.Add(internalPath); } diff --git a/MediaBrowser.Providers/Manager/ImageSaver.cs b/MediaBrowser.Providers/Manager/ImageSaver.cs index d9a8c044b9..aa4ca5afd8 100644 --- a/MediaBrowser.Providers/Manager/ImageSaver.cs +++ b/MediaBrowser.Providers/Manager/ImageSaver.cs @@ -90,7 +90,7 @@ namespace MediaBrowser.Providers.Manager { ArgumentException.ThrowIfNullOrEmpty(mimeType); - var saveLocally = item.SupportsLocalMetadata && item.IsSaveLocalMetadataEnabled() && !item.ExtraType.HasValue && item is not Audio; + var saveLocally = item.SupportsLocalMetadata && item.IsSaveLocalMetadataEnabled() && !item.ExtraType.HasValue && (item is AudioBook || item is not Audio); if (type != ImageType.Primary && item is Episode) { diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs index c2e523cfaf..a438a94c40 100644 --- a/MediaBrowser.Providers/Manager/MetadataService.cs +++ b/MediaBrowser.Providers/Manager/MetadataService.cs @@ -680,11 +680,18 @@ namespace MediaBrowser.Providers.Manager return providers; } - protected virtual IEnumerable GetNonLocalImageProviders(BaseItem item, IEnumerable allImageProviders, ImageRefreshOptions options) + protected virtual IEnumerable GetNonLocalImageProviders(BaseItem item, IEnumerable allImageProviders, MetadataRefreshOptions options) { // Get providers to refresh var providers = allImageProviders.Where(i => i is not ILocalImageProvider); + // When identifying, run the provider the user picked first so the correct image is used. + if (!string.IsNullOrEmpty(options.SearchResult?.SearchProviderName)) + { + providers = providers + .OrderBy(i => string.Equals(i.Name, options.SearchResult.SearchProviderName, StringComparison.OrdinalIgnoreCase) ? 0 : 1); + } + var dateLastImageRefresh = item.DateLastRefreshed; // Run all if either of these flags are true @@ -831,8 +838,16 @@ namespace MediaBrowser.Providers.Manager var isLocalLocked = temp.Item.IsLocked; if (!isLocalLocked && (options.ReplaceAllMetadata || options.MetadataRefreshMode > MetadataRefreshMode.ValidationOnly)) { - var remoteResult = await ExecuteRemoteProviders(temp, logName, false, id, providers.OfType>(), cancellationToken) - .ConfigureAwait(false); + var remoteProviders = providers.OfType>(); + + // When identifying, run the provider the user picked first so the correct IDs are used. + if (!string.IsNullOrEmpty(options.SearchResult?.SearchProviderName)) + { + remoteProviders = remoteProviders + .OrderBy(i => string.Equals(i.Name, options.SearchResult.SearchProviderName, StringComparison.OrdinalIgnoreCase) ? 0 : 1); + } + + var remoteResult = await ExecuteRemoteProviders(temp, logName, false, id, remoteProviders, cancellationToken).ConfigureAwait(false); refreshResult.UpdateType |= remoteResult.UpdateType; refreshResult.ErrorMessage = remoteResult.ErrorMessage; @@ -1108,7 +1123,7 @@ namespace MediaBrowser.Providers.Manager { if (replaceData || !target.RunTimeTicks.HasValue) { - if (target is not Audio && target is not Video) + if (target is not Audio && target is not Video && target is not Book) { target.RunTimeTicks = source.RunTimeTicks; } diff --git a/MediaBrowser.Providers/MediaBrowser.Providers.csproj b/MediaBrowser.Providers/MediaBrowser.Providers.csproj index 1032582900..2b0f480b1c 100644 --- a/MediaBrowser.Providers/MediaBrowser.Providers.csproj +++ b/MediaBrowser.Providers/MediaBrowser.Providers.csproj @@ -21,7 +21,9 @@ + + diff --git a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs index 0ecbb6f068..b70cba5b3b 100644 --- a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs +++ b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs @@ -549,7 +549,7 @@ namespace MediaBrowser.Providers.MediaInfo var candidateUnsynchronizedLyric = supportedLyrics.FirstOrDefault(l => l.Format is LyricsInfo.LyricsFormat.UNSYNCHRONIZED or LyricsInfo.LyricsFormat.OTHER && l.UnsynchronizedLyrics is not null); var lyrics = candidateSynchronizedLyric is not null ? candidateSynchronizedLyric.FormatSynch() : candidateUnsynchronizedLyric?.UnsynchronizedLyrics; if (!string.IsNullOrWhiteSpace(lyrics) - && tryExtractEmbeddedLyrics) + && (tryExtractEmbeddedLyrics || options.ReplaceAllMetadata)) { await _lyricManager.SaveLyricAsync(audio, "lrc", lyrics).ConfigureAwait(false); } diff --git a/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs b/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs index 789df8f061..221c6bff5e 100644 --- a/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs +++ b/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs @@ -24,6 +24,8 @@ using MediaBrowser.Model.Globalization; using MediaBrowser.Model.IO; using MediaBrowser.Model.MediaInfo; using Microsoft.Extensions.Logging; +using PDFtoImage; +using SharpCompress.Archives; namespace MediaBrowser.Providers.MediaInfo { @@ -37,6 +39,7 @@ namespace MediaBrowser.Providers.MediaInfo ICustomMetadataProvider