diff options
234 files changed, 7606 insertions, 1584 deletions
diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 74e66d3adb..bc574d7054 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "dotnet-ef": { - "version": "10.0.9", + "version": "10.0.10", "commands": [ "dotnet-ef" ] diff --git a/.github/workflows/ci-codeql-analysis.yml b/.github/workflows/ci-codeql-analysis.yml index 06a66bab53..a55a400008 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@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 with: languages: ${{ matrix.language }} queries: +security-extended - name: Autobuild - uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + uses: github/codeql-action/autobuild@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 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/Directory.Packages.props b/Directory.Packages.props index 7ab5b5d53a..15f36872e0 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -26,33 +26,34 @@ <PackageVersion Include="libse" Version="4.0.12" /> <PackageVersion Include="LrcParser" Version="2025.623.0" /> <PackageVersion Include="MetaBrainz.MusicBrainz" Version="8.0.1" /> - <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.9" /> - <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.9" /> - <PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="4.14.0" /> - <PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="5.3.0" /> - <PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.3.0" /> - <PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.3.0" /> - <PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.9" /> - <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9" /> - <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.9" /> - <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.9" /> - <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9" /> - <PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.9" /> - <PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.9" /> - <PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.9" /> - <PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.9" /> - <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" /> - <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.9" /> - <PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.9" /> - <PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.9" /> - <PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.9" /> - <PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.9" /> - <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.6.0" /> + <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.10" /> + <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.10" /> + <PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="5.6.0" /> + <PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="5.6.0" /> + <PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.6.0" /> + <PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.6.0" /> + <PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.10" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.10" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.10" /> + <PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.10" /> + <PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.10" /> + <PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.10" /> + <PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.10" /> + <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.10" /> + <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.10" /> + <PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.10" /> + <PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.10" /> + <PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.10" /> + <PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.10" /> + <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" /> <PackageVersion Include="MimeTypes" Version="2.5.2" /> <PackageVersion Include="Morestachio" Version="5.0.1.670" /> <PackageVersion Include="Moq" Version="4.18.4" /> <PackageVersion Include="NEbml" Version="1.1.0.5" /> <PackageVersion Include="Newtonsoft.Json" Version="13.0.4" /> + <PackageVersion Include="PDFtoImage" Version="5.2.1" /> <PackageVersion Include="PlaylistsNET" Version="1.4.1" /> <PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" /> <PackageVersion Include="prometheus-net.DotNetRuntime" Version="4.4.1" /> @@ -67,17 +68,17 @@ <PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" /> <PackageVersion Include="Serilog.Sinks.Graylog" Version="3.1.1" /> <PackageVersion Include="SerilogAnalyzer" Version="0.15.0" /> - <PackageVersion Include="SharpCompress" Version="0.49.1" /> - <PackageVersion Include="SharpFuzz" Version="2.2.0" /> + <PackageVersion Include="SharpCompress" Version="0.50.0" /> + <PackageVersion Include="SharpFuzz" Version="2.3.0" /> <PackageVersion Include="SkiaSharp" Version="3.119.4" /> <PackageVersion Include="SkiaSharp.HarfBuzz" Version="3.119.4" /> <PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="3.119.4" /> <PackageVersion Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" /> <PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" /> <PackageVersion Include="Svg.Skia" Version="3.7.0" /> - <PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="10.2.1" /> - <PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.1" /> - <PackageVersion Include="System.Text.Json" Version="10.0.9" /> + <PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="10.2.3" /> + <PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.3" /> + <PackageVersion Include="System.Text.Json" Version="10.0.10" /> <PackageVersion Include="TagLibSharp" Version="2.3.0" /> <PackageVersion Include="z440.atl.core" Version="7.15.3" /> <PackageVersion Include="TMDbLib" Version="3.0.0" /> 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 /// <summary> /// Helper class to retrieve basic metadata from a book filename. /// </summary> - 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(@"^(?<seriesName>.+?)((\s\((?<seriesYear>[0-9]{4})\))?)\s#(?<index>[0-9]+)((\s\(of\s(?<count>[0-9]+)\))?)((\s\((?<year>[0-9]{4})\))?)$"), - new Regex(@"^(?<name>.+?)\s\((?<seriesName>.+?),\s#(?<index>[0-9]+)\)((\s\((?<year>[0-9]{4})\))?)$"), - new Regex(@"^(?<index>[0-9]+)\s\-\s(?<name>.+?)((\s\((?<year>[0-9]{4})\))?)$"), + new Regex(@"^(?<seriesName>.+?)((\s\((?<seriesYear>[0-9]{4})\))?)\s#(?<index>[0-9]+)(?:\.0)?((\s\(of\s(?<count>[0-9]+)\))?)((\s\((?<year>[0-9]{4})\))?)$"), + new Regex(@"^(?<name>.+?)\s\((?<seriesName>.+?),\s#(?<index>[0-9]+)\)(?:\.0)?((\s\((?<year>[0-9]{4})\))?)$"), + new Regex(@"^(?<index>[0-9]+)(?:\.0)?\s\-\s(?<name>.+?)((\s\((?<year>[0-9]{4})\))?)$"), new Regex(@"(?<name>.*)\((?<year>[0-9]{4})\)"), // last resort matches the whole string as the name new Regex(@"(?<name>.*)") ]; + [GeneratedRegex(@"^(?<name>.+?)(\sv(?<volume>[0-9]+))?(\sc(?<chapter>[0-9]+))?$")] + private static partial Regex ComicRegex(); + /// <summary> /// Parse a filename name to retrieve the book name, series name, index, and year. /// </summary> @@ -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 { /// <summary> @@ -14,6 +12,7 @@ namespace Emby.Naming.Book { Name = null; Index = null; + ParentIndex = null; Year = null; SeriesName = null; } @@ -29,6 +28,11 @@ namespace Emby.Naming.Book public int? Index { get; set; } /// <summary> + /// Gets or sets the parent index number. + /// </summary> + public int? ParentIndex { get; set; } + + /// <summary> /// Gets or sets the publication year. /// </summary> public int? Year { get; set; } 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 /// </summary> /// <value>The name of the series.</value> public string? Name { get; set; } + + /// <summary> + /// Gets or sets the year of the series. + /// </summary> + /// <value>The year of the series.</value> + 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". /// </summary> - [GeneratedRegex(@"(?<title>.+?)\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..69e23bcb63 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -93,6 +93,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; @@ -496,6 +499,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; + var includeItemTypes = DtoExtensions.GetBaseItemKindsForCollectionType(viewType); + var recursive = viewType != CollectionType.playlists; - BaseItemKind[] includeItemTypes; - - switch (viewType) + if (viewType == CollectionType.music) { - 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; + // Music albums usually don't have dedicated backdrops, so use artist instead + includeItemTypes = [BaseItemKind.MusicArtist]; } - var recursive = viewType != CollectionType.playlists; - 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/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); + // 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 + { + _ when attribute.Equals("tmdbid", StringComparison.OrdinalIgnoreCase) => "tmdb", + _ when attribute.Equals("tvdbid", StringComparison.OrdinalIgnoreCase) => "tvdb", + _ when attribute.Equals("imdbid", StringComparison.OrdinalIgnoreCase) => "imdb", + _ => ReadOnlySpan<char>.Empty + }; - // 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) + for (int strIndex = 0, attributeIndex = 0; attributeIndex > -1;) { - var attributeEnd = attributeIndex + attribute.Length; + // 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] == '-')) + + if (attributeCloser != '\0') { - var closingIndex = str[attributeEnd..].IndexOf(attributeCloser); + if (shortAttr.Length > 0 + && attributeEnd + 1 < subStr.Length + && (subStr[attributeEnd] is 'i' or 'I') + && (subStr[attributeEnd + 1] is 'd' or 'D')) + { + // We were searching for a shortened attribute, but it's followed by "id" - let's skip it. + attributeEnd += 2; + } - // Must be at least 1 character before the closing bracket. - if (closingIndex > 1) + // 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 '-')) { - return str[(attributeEnd + 1)..(attributeEnd + closingIndex)].Trim().ToString(); + 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); + + // 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); + } - item.TrySetProviderId(MetadataProvider.Tmdb, tmdbid); + 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..f5c41e5670 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) - .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) + 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) + { + 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); + } + + 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); - result[item.Id] = userData; - var cacheKey = GetCacheKey(user.InternalId, item.Id); - _cache.AddOrUpdate(cacheKey, userData); + 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,40 @@ 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() + 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 +402,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 +510,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/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..8605a752db 100644 --- a/Emby.Server.Implementations/Localization/Core/fo.json +++ b/Emby.Server.Implementations/Localization/Core/fo.json @@ -1,7 +1,7 @@ { "Artists": "Listafólk", "Collections": "Søvn", - "Default": "Sjálvgildi", + "Default": "Forsett", "External": "Ytri", "Genres": "Greinar", "AppDeviceValues": "App: {0}, Eind: {1}", @@ -12,5 +12,41 @@ "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} var innlagt", + "PluginUninstalledWithName": "{0} var strikað", + "PluginUpdatedWithName": "{0} varð 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" } 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/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/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/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 1098880cf3..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": "網路頻道", 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..e7dd984ec4 100644 --- a/Emby.Server.Implementations/Localization/LocalizationManager.cs +++ b/Emby.Server.Implementations/Localization/LocalizationManager.cs @@ -356,6 +356,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 +385,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 +487,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 +508,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 +606,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..828bdd6859 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) { @@ -726,6 +730,31 @@ namespace Emby.Server.Implementations.Session } /// <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> /// <param name="info">The info.</param> @@ -756,9 +785,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 +920,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 +983,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 +1128,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 +1184,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/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/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..e26264d292 100644 --- a/Jellyfin.Api/Controllers/ItemUpdateController.cs +++ b/Jellyfin.Api/Controllers/ItemUpdateController.cs @@ -279,6 +279,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; @@ -297,6 +302,8 @@ public class ItemUpdateController : BaseJellyfinApiController { foreach (var season in rseries.Children.OfType<Season>()) { + season.SeriesName = rseries.Name; + if (!season.LockedFields.Contains(MetadataField.OfficialRating)) { season.OfficialRating = request.OfficialRating; @@ -314,6 +321,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; 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) - { - // 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 - { - 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(); - } - } + // 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; - var query = new InternalItemsQuery(user) + if (!string.IsNullOrWhiteSpace(searchTerm)) + { + var searchProviderQuery = new SearchProviderQuery { - 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 = []; - } + // if we check for specific subtitles we don't need a separate check for subtitle existence + query.HasSubtitles = null; } - - // 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 - ) + else { - query.IncludeOwnedItems = true; + // if we search for items without subtitles, we don't need to check for subtitles of a specific language + query.SubtitleLanguages = []; } + } - query.ApplyFilters(filters); + // 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; + } - // Filter by Series Status - if (seriesStatus.Length != 0) - { - query.SeriesStatuses = seriesStatus; - } + query.ApplyFilters(filters); - // Exclude Blocked Unrated Items - var blockedUnratedItems = user?.GetPreferenceValues<UnratedItem>(PreferenceKind.BlockUnratedItems); - if (blockedUnratedItems is not null) - { - query.BlockUnratedItems = blockedUnratedItems; - } + // Filter by Series Status + if (seriesStatus.Length != 0) + { + query.SeriesStatuses = seriesStatus; + } - // ExcludeLocationTypes - if (excludeLocationTypes.Any(t => t == LocationType.Virtual)) - { - query.IsVirtualItem = false; - } + // Exclude Blocked Unrated Items + var blockedUnratedItems = user?.GetPreferenceValues<UnratedItem>(PreferenceKind.BlockUnratedItems); + if (blockedUnratedItems is not null) + { + query.BlockUnratedItems = blockedUnratedItems; + } - if (locationTypes.Length > 0 && locationTypes.Length < 4) - { - query.IsVirtualItem = locationTypes.Contains(LocationType.Virtual); - } + // ExcludeLocationTypes + if (excludeLocationTypes.Any(t => t == LocationType.Virtual)) + { + query.IsVirtualItem = false; + } - // Min official rating - if (!string.IsNullOrWhiteSpace(minOfficialRating)) - { - query.MinParentalRating = _localization.GetRatingScore(minOfficialRating); - } + if (locationTypes.Length > 0 && locationTypes.Length < 4) + { + query.IsVirtualItem = locationTypes.Contains(LocationType.Virtual); + } - // Max official rating - if (!string.IsNullOrWhiteSpace(maxOfficialRating)) - { - query.MaxParentalRating = _localization.GetRatingScore(maxOfficialRating); - } + // 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) + // Artists + if (artists.Length != 0) + { + query.ArtistIds = artists.Select(i => { - 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(); - } + 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; - } + // ExcludeArtistIds + if (excludeArtistIds.Length != 0) + { + query.ExcludeArtistIds = excludeArtistIds; + } - if (albumIds.Length != 0) - { - query.AlbumIds = albumIds; - } + if (albumIds.Length != 0) + { + query.AlbumIds = albumIds; + } - // Albums - if (albums.Length != 0) + // Albums + if (albums.Length != 0) + { + query.AlbumIds = albums.SelectMany(i => { - query.AlbumIds = albums.SelectMany(i => - { - return _libraryManager.GetItemIds(new InternalItemsQuery { IncludeItemTypes = [BaseItemKind.MusicAlbum], Name = i, Limit = 1 }); - }).ToArray(); - } + return _libraryManager.GetItemIds(new InternalItemsQuery { IncludeItemTypes = [BaseItemKind.MusicAlbum], Name = i, Limit = 1 }); + }).ToArray(); + } - // Studios - if (studios.Length != 0) + // Studios + if (studios.Length != 0) + { + query.StudioIds = studios.Select(i => { - query.StudioIds = studios.Select(i => + try { - 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) + return _libraryManager.GetStudio(i); + } + catch { - query.OrderBy = [(ItemSortBy.ProductionYear, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Ascending)]; + 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/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; @@ -10,6 +11,37 @@ namespace Jellyfin.Api.Extensions; 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> /// <remarks> 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 - { - using var document = JsonSerializer.SerializeToDocument(item, _serializerSettings); - document.WriteTo(jsonSerializer); - } - catch (Exception ex) + while (true) { - _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.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 3357f874d2..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); - - baseQuery = baseQuery.Where(e => - (e.Type == seriesTypeName && resumableSeriesIds.Contains(e.Id) == isResumable) - || (e.Type != seriesTypeName && resumableItemIds.Contains(e.Id) == isResumable)); + 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 = 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)); } } @@ -741,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) @@ -1059,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); @@ -1069,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/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); + _libraryManager.DeleteItemsUnsafeFast(itemsToDelete); + + deletedSoFar += batch.Count; + _logger.LogInformation("Deleting orphaned extras: {Deleted}/{Total}", deletedSoFar, orphanedItemIds.Count); + } - _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!); - - // 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) + _logger.LogInformation("Deleting {Count} duplicate database entries...", allIdsToDelete.Count); + + // 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)); + + 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); + } - _logger.LogInformation("Removed {Count} duplicate MusicArtist records.", idsToDelete.Count); + 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)); + + 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); + } - _logger.LogInformation("Removed {Count} duplicate Person BaseItems.", idsToDelete.Count); + 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/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; @@ -77,6 +74,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> public bool IsAlive { get; internal set; } @@ -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 </title> <style> + /* Noto Sans (latin), matching the Jellyfin web client font. Embedded so the page renders + identically before the web client is available. */ + @font-face { + font-family: "Noto Sans"; + font-style: normal; + font-display: swap; + font-weight: 400; + src: url(data:font/woff2;base64,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) format("woff2"); + } + + @font-face { + font-family: "Noto Sans"; + font-style: normal; + font-display: swap; + font-weight: 700; + src: url(data:font/woff2;base64,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) format("woff2"); + } + + :root { + --jf-background: #101010; + --jf-paper: #202020; + --jf-text: #fff; + --jf-text-secondary: rgba(255, 255, 255, 0.7); + --jf-primary: #00a4dc; + --jf-primary-dark: #00729a; + --jf-divider: rgba(255, 255, 255, 0.12); + --jf-error: #d15353; + --jf-warn: #f2b01e; + --jf-success: #5cbd5c; + --jf-scrollbar-thumb: #3b3b3b; + } + * { - font-family: sans-serif; + box-sizing: border-box; + scrollbar-width: thin; + scrollbar-color: var(--jf-scrollbar-thumb) var(--jf-paper); + } + + html { + height: 100%; + font-size: 93%; + line-height: 1.35; } - .flex-row { + body { + margin: 0; + padding: 0 0 50px; + min-height: 100%; + background-color: var(--jf-background); + color: var(--jf-text); + font-family: "Noto Sans", "Noto Sans HK", "Noto Sans JP", "Noto Sans KR", "Noto Sans SC", "Noto Sans TC", sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + + ::-webkit-scrollbar { + width: 0.4em; + height: 0.4em; + } + + ::-webkit-scrollbar-track { + background: var(--jf-paper); + } + + ::-webkit-scrollbar-thumb { + background: #888; + border-radius: 2px; + } + + a { + color: var(--jf-primary); + text-decoration: none; + } + + a:hover { + text-decoration: underline; + } + + .page { + max-width: 54em; + margin: 0 auto; + padding: 4.5em 3.3% 0; + } + + .logo { + display: block; + width: 15em; + max-width: 70%; + height: auto; + margin: 0 auto 2.25em; + } + + .status { display: flex; flex-direction: row; - flex-wrap: nowrap; + align-items: center; justify-content: center; + gap: 0.75em; + text-align: center; + margin-bottom: 1.5em; + } + + .status .status-text { + margin: 0; + font-weight: 400; + font-size: 1.25em; + color: var(--jf-text); + } + + .status.is-error .status-text { + color: var(--jf-error); + } + + /* Buttons — matching the web client's emby-button styles. */ + .jf-button { + display: inline-flex; align-items: center; - align-content: normal; + justify-content: center; + box-sizing: border-box; + margin: 0; + padding: 0.9em 1em; + border: 0; + border-radius: 0.2em; + font-family: inherit; + font-size: inherit; + font-weight: 600; + line-height: 1.35; + cursor: pointer; + outline: none; + text-decoration: none; + transition: 0.2s; + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); + } + + .jf-button-primary { + background: var(--jf-primary); + color: rgba(0, 0, 0, 0.87); + } + + .jf-button-primary:hover, + .jf-button-primary:focus { + background: var(--jf-primary-dark); + } + + .jf-button-secondary { + background: #424242; + color: var(--jf-text-secondary); + } + + .jf-button-secondary:hover, + .jf-button-secondary:focus { + background: #616161; } - .flex-col { + /* Redirect countdown shown once the server is ready. */ + .redirect-bar { display: flex; - flex-direction: column; - flex-wrap: nowrap; - justify-content: center; + flex-direction: row; align-items: center; - align-content: normal; + justify-content: center; + flex-wrap: wrap; + gap: 1em; + margin-bottom: 1.5em; + text-align: center; + } + + .redirect-countdown { + color: var(--jf-text-secondary); + } + + /* Material (MDL) spinner — the same one the web client uses while loading. */ + .mdl-spinner { + position: relative; + display: inline-block; + flex: 0 0 auto; + width: 1.95em; + height: 1.95em; + font-size: 0.8em; + animation: mdl-spinner__container-rotate 1568.23529412ms linear infinite; + } + + @keyframes mdl-spinner__container-rotate { + to { + transform: rotate(360deg); + } } - header { - height: 5rem; + .mdl-spinner__layer { + position: absolute; width: 100%; + height: 100%; + opacity: 0; + border-color: var(--jf-primary); } - header svg { - height: 3rem; - width: 9rem; - margin-right: 1rem; + .mdl-spinner__layer-1 { + animation: mdl-spinner__fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both, mdl-spinner__layer-1-fade-in-out 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both; } - /* ol.action-list { - list-style-type: none; - position: relative; - } */ + .mdl-spinner__layer-2 { + animation: mdl-spinner__fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both, mdl-spinner__layer-2-fade-in-out 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both; + } - ol.action-list * { - font-family: monospace; - font-weight: 300; - font-size: clamp(18px, 100vw / var(--width), 20px); - font-feature-settings: 'onum', 'pnum'; - line-height: 1.8; - -webkit-text-size-adjust: none; + .mdl-spinner__layer-3 { + animation: mdl-spinner__fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both, mdl-spinner__layer-3-fade-in-out 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both; } - /* - ol.action-list li { - padding-top: .5rem; + .mdl-spinner__layer-4 { + animation: mdl-spinner__fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both, mdl-spinner__layer-4-fade-in-out 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both; } - ol.action-list li::before { - position: absolute; - left: -0.8em; - font-size: 1.1em; - } */ + @keyframes mdl-spinner__fill-unfill-rotate { + 12.5% { transform: rotate(135deg); } + 25% { transform: rotate(270deg); } + 37.5% { transform: rotate(405deg); } + 50% { transform: rotate(540deg); } + 62.5% { transform: rotate(675deg); } + 75% { transform: rotate(810deg); } + 87.5% { transform: rotate(945deg); } + to { transform: rotate(1080deg); } + } - /* Attribution as heavily inspired by: https://iamkate.com/code/tree-views/ */ - .action-list { - --spacing: 1.4rem; - --radius: 14px; + @keyframes mdl-spinner__layer-1-fade-in-out { + from { opacity: 0.99; } + 25% { opacity: 0.99; } + 26% { opacity: 0; } + 89% { opacity: 0; } + 90% { opacity: 0.99; } + 100% { opacity: 0.99; } } - .action-list li { - display: block; + @keyframes mdl-spinner__layer-2-fade-in-out { + from { opacity: 0; } + 15% { opacity: 0; } + 25% { opacity: 0.99; } + 50% { opacity: 0.99; } + 51% { opacity: 0; } + } + + @keyframes mdl-spinner__layer-3-fade-in-out { + from { opacity: 0; } + 40% { opacity: 0; } + 50% { opacity: 0.99; } + 75% { opacity: 0.99; } + 76% { opacity: 0; } + } + + @keyframes mdl-spinner__layer-4-fade-in-out { + from { opacity: 0; } + 65% { opacity: 0; } + 75% { opacity: 0.99; } + 90% { opacity: 0.99; } + 100% { opacity: 0; } + } + + .mdl-spinner__circle { + box-sizing: border-box; + height: 100%; + border-width: 0.21em; + border-style: solid; + border-color: inherit; + border-bottom-color: transparent !important; + border-radius: 50%; + animation: none; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + } + + .mdl-spinner__circle-clipper { + display: inline-block; position: relative; - padding-left: calc(2 * var(--spacing) - var(--radius) - 1px); + width: 50%; + height: 100%; + overflow: hidden; + border-color: inherit; } - .action-list ul { - margin-left: calc(var(--radius) - var(--spacing)); - padding-left: 0; + .mdl-spinner__circle-clipper .mdl-spinner__circle { + width: 200%; } - .action-list ul li { - border-left: 2px solid #ddd; + .mdl-spinner__circleLeft { + border-right-color: transparent !important; + transform: rotate(129deg); + animation: mdl-spinner__left-spin 1333ms cubic-bezier(0.4, 0, 0.2, 1) infinite both; } - .action-list ul li:last-child { - border-color: transparent; + .mdl-spinner__circleRight { + left: -100%; + border-left-color: transparent !important; + transform: rotate(-129deg); + animation: mdl-spinner__right-spin 1333ms cubic-bezier(0.4, 0, 0.2, 1) infinite both; } - .action-list ul li::before { - content: ''; - display: block; - position: absolute; - top: calc(var(--spacing) / -2); - left: -2px; - width: calc(var(--spacing) + 2px); - height: calc(var(--spacing) + 1px); - border: solid #ddd; - border-width: 0 0 2px 2px; + @keyframes mdl-spinner__left-spin { + from { transform: rotate(130deg); } + 50% { transform: rotate(-5deg); } + to { transform: rotate(130deg); } } - .action-list summary { - display: block; - cursor: pointer; + @keyframes mdl-spinner__right-spin { + from { transform: rotate(-130deg); } + 50% { transform: rotate(5deg); } + to { transform: rotate(-130deg); } } - .action-list summary::marker, - .action-list summary::-webkit-details-marker { - display: none; + /* Startup log — a paperList-style surface. */ + .logs-panel { + background-color: var(--jf-paper); + border-radius: 0.2em; + box-shadow: 0 0.0725em 0.29em 0 rgba(0, 0, 0, 0.37); + padding: calc(0.8em - 4px) 1.2em 1em; } - .action-list summary:focus { - outline: none; + .logs-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1em; + margin-bottom: 0.5em; + padding-bottom: 0.5em; + border-bottom: 1px solid var(--jf-divider); } - .action-list summary:focus-visible { - outline: 1px dotted #000; + .logs-header h2 { + margin: 0; + font-weight: 400; + font-size: 1.17em; } - .action-list li::after, - .action-list summary::before { - content: ''; - display: block; - position: absolute; - top: calc(var(--spacing) / 2 - var(--radius) + 4px); - left: calc(var(--spacing) - var(--radius) - -5px); + .download-logs { + flex: 0 0 auto; + font-size: 0.9em; + font-weight: 600; } - .action-list summary::before { - z-index: 1; - /* background: #696 url('expand-collapse.svg') 0 0; */ + .logs-scroll { + overflow-y: auto; } - .action-list details[open]>summary::before { - background-position: calc(-2 * var(--radius)) 0; + .action-list, + .action-list ul { + list-style: none; + margin: 0; + padding: 0; } - .action-list li.danger-item::after, - .action-list li.danger-strong-item::after { - content: '❌'; + .action-list { + font-size: 0.92em; } - ol.action-list li span.danger-strong-item { - text-decoration-style: solid; - text-decoration-color: red; - text-decoration-line: underline; + .action-list li { + position: relative; + padding: 0.18em 0; } - ol.action-list li.warn-item::after { - content: '⚠️'; + .action-list summary, + .action-list li > span { + display: block; + color: var(--jf-text); } - ol.action-list li.success-item::after { - content: '✅'; + .action-list summary { + cursor: pointer; + list-style: none; + } + + .action-list summary::-webkit-details-marker { + display: none; } - ol.action-list li.info-item::after { - content: '🔹'; + /* Severity dot, colored from the theme palette. */ + .action-list summary::before, + .action-list li > span::before { + content: ""; + display: inline-block; + flex: 0 0 auto; + width: 0.5em; + height: 0.5em; + margin-right: 0.7em; + border-radius: 50%; + background: var(--dot, var(--jf-text-secondary)); } - /* End Attribution */ + .action-list li.success-item { --dot: var(--jf-success); } + .action-list li.info-item { --dot: var(--jf-primary); } + .action-list li.warn-item { --dot: var(--jf-warn); } + .action-list li.danger-item, + .action-list li.danger-strong-item { --dot: var(--jf-error); } + + .action-list li.warn-item > span, + .action-list li.warn-item > details > summary { color: var(--jf-warn); } + + .action-list li.danger-item > span, + .action-list li.danger-item > details > summary, + .action-list li.danger-strong-item > span, + .action-list li.danger-strong-item > details > summary { color: var(--jf-error); } + + .action-list li.danger-strong-item > span { font-weight: 700; } + + /* Nested groups: indent with a divider guide line. */ + .action-list ul { + margin-left: 0.25em; + padding-left: 1.1em; + border-left: 1px solid var(--jf-divider); + } </style> </head> <body> - <div> - <header class="flex-row"> - - {{^IF isInReportingMode}} - <p>Jellyfin Server {{version.ToString(2)}} still starting. Please wait.</p> - {{#ELSE}} - <p>Jellyfin Server {{version.ToString(2)}} has encountered an error and was not able to start.</p> - {{/ELSE}} - {{/IF}} - - {{#IF localNetworkRequest}} - <p style="margin-left: 1rem;">You can download the current log file <a href='/startup/logger' - target="_blank">here</a>.</p> - {{/IF}} - </header> + <div class="page"> + <svg class="logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 251 72" fill="none" role="img" aria-label="Jellyfin"> + <g clip-path="url(#a)"> + <path fill="url(#b)" d="M24.212 49.158C22.66 46.042 32.838 27.588 36 27.588c3.167.002 13.323 18.488 11.788 21.57-1.534 3.082-22.025 3.116-23.576 0" /> + <path fill="url(#c)" fill-rule="evenodd" d="M.482 64.995C-4.195 55.605 26.477 0 36 0c9.533 0 40.153 55.713 35.527 64.995s-66.368 9.39-71.045 0m12.254-8.148c3.064 6.152 43.518 6.084 46.548 0 3.03-6.086-17.032-42.586-23.275-42.586S9.671 50.694 12.736 56.847" clip-rule="evenodd" /> + <path fill="#fff" d="M225.22 56c-.28 0-.42 0-.527-.055a.5.5 0 0 1-.219-.218c-.054-.107-.054-.247-.054-.527V26.8c0-.28 0-.42.054-.527a.5.5 0 0 1 .219-.219c.107-.054.247-.054.527-.054h5.183c.28 0 .42 0 .527.054a.5.5 0 0 1 .218.219c.055.107.055.247.055.527v2.895a7.9 7.9 0 0 1 3.419-3.254q2.261-1.103 5.074-1.103 3.308 0 5.845 1.434a10.1 10.1 0 0 1 4.026 4.026q1.434 2.536 1.434 5.9V55.2c0 .28 0 .42-.055.527a.5.5 0 0 1-.218.218c-.107.055-.247.055-.527.055h-5.625c-.28 0-.42 0-.527-.055a.5.5 0 0 1-.218-.218c-.055-.107-.055-.247-.055-.527V38.408q0-2.978-1.709-4.688-1.654-1.764-4.357-1.764-2.702 0-4.412 1.764-1.654 1.766-1.654 4.688V55.2c0 .28 0 .42-.054.527a.5.5 0 0 1-.219.218c-.107.055-.247.055-.527.055zm-11.54-33.363c-.28 0-.42 0-.527-.055a.5.5 0 0 1-.218-.218c-.055-.107-.055-.247-.055-.527v-6.121c0-.28 0-.42.055-.527a.5.5 0 0 1 .218-.219c.107-.054.247-.054.527-.054h5.624c.28 0 .42 0 .527.054a.5.5 0 0 1 .219.219c.054.107.054.247.054.527v6.12c0 .28 0 .42-.054.528a.5.5 0 0 1-.219.218c-.107.055-.247.055-.527.055zm0 33.363c-.28 0-.42 0-.527-.054a.5.5 0 0 1-.218-.219c-.055-.107-.055-.247-.055-.527V26.8c0-.28 0-.42.055-.527a.5.5 0 0 1 .218-.218c.107-.055.247-.055.527-.055h5.624c.28 0 .42 0 .527.055a.5.5 0 0 1 .219.218c.054.107.054.247.054.527v28.4c0 .28 0 .42-.054.527a.5.5 0 0 1-.219.219c-.107.054-.247.054-.527.054zm-16.712-.054c.107.054.247.054.527.054h5.625c.28 0 .42 0 .526-.054a.5.5 0 0 0 .219-.219c.055-.107.055-.247.055-.527V32.452h5.872c.28 0 .42 0 .527-.054a.5.5 0 0 0 .219-.219c.054-.107.054-.247.054-.527V26.8c0-.28 0-.42-.054-.527a.5.5 0 0 0-.219-.218c-.107-.055-.247-.055-.527-.055h-5.872v-.992q0-2.261 1.323-3.31 1.379-1.102 3.75-1.102.454 0 .939.044c.345.031.518.047.634-.004a.48.48 0 0 0 .241-.22c.061-.111.061-.274.061-.6V15.39c0-.304 0-.457-.061-.589a.7.7 0 0 0-.248-.284c-.122-.078-.261-.097-.537-.136a14.5 14.5 0 0 0-1.966-.126q-5.184 0-8.273 2.812t-3.088 7.942V26H186.53c-.3 0-.451 0-.58.05a.75.75 0 0 0-.296.205c-.091.104-.143.244-.248.526l-7.43 19.9-7.483-19.903c-.105-.28-.158-.42-.249-.524a.75.75 0 0 0-.296-.205c-.129-.049-.279-.049-.578-.049h-5.769c-.394 0-.591 0-.717.083a.5.5 0 0 0-.213.314c-.031.147.041.33.186.697L174.281 56l-.661 1.6q-.883 1.874-2.041 3.033-1.103 1.158-3.584 1.158-.883 0-1.875-.166a13 13 0 0 1-.73-.1c-.389-.066-.584-.099-.709-.053a.47.47 0 0 0-.26.22c-.066.116-.066.298-.066.663v4.329c0 .243 0 .365.045.481a.7.7 0 0 0 .189.266c.095.081.194.116.392.185q.684.24 1.47.351 1.158.22 2.371.22 4.246 0 7.059-2.426 2.867-2.37 4.577-6.728l10.517-26.58h5.72V55.2c0 .28 0 .42.055.527a.5.5 0 0 0 .218.219M154.363 56c-.28 0-.42 0-.527-.054a.5.5 0 0 1-.219-.219c-.054-.107-.054-.247-.054-.527V15.054c0-.28 0-.42.054-.527a.5.5 0 0 1 .219-.219c.107-.054.247-.054.527-.054h5.624c.28 0 .42 0 .527.054a.5.5 0 0 1 .218.219c.055.107.055.247.055.527V55.2c0 .28 0 .42-.055.527a.5.5 0 0 1-.218.219c-.107.054-.247.054-.527.054zm-11.621 0c-.28 0-.42 0-.527-.054a.5.5 0 0 1-.219-.219c-.054-.107-.054-.247-.054-.527V15.054c0-.28 0-.42.054-.527a.5.5 0 0 1 .219-.219c.107-.054.247-.054.527-.054h5.624c.28 0 .42 0 .527.054a.5.5 0 0 1 .219.219c.054.107.054.247.054.527V55.2c0 .28 0 .42-.054.527a.5.5 0 0 1-.219.219c-.107.054-.247.054-.527.054zm-18.132.662q-4.632-.001-8.107-2.096a14.6 14.6 0 0 1-5.404-5.68q-1.93-3.585-1.93-7.942 0-4.522 1.93-7.996 1.985-3.53 5.349-5.57 3.42-2.04 7.61-2.04 4.688 0 7.942 2.04 3.253 1.986 4.963 5.294 1.71 3.309 1.709 7.335 0 .828-.11 1.654-.031.45-.12.841c-.037.165-.055.247-.115.33a.55.55 0 0 1-.208.168c-.095.04-.194.04-.393.04h-21.057q.33 3.309 2.537 5.294 2.205 1.986 5.459 1.985 2.482 0 4.191-1.047a8.2 8.2 0 0 0 2.206-1.986c.241-.316.362-.474.484-.542a.6.6 0 0 1 .352-.083c.139.006.296.083.608.236l4.269 2.094c.239.118.359.176.431.275a.52.52 0 0 1 .098.298c0 .122-.058.231-.172.45q-1.432 2.742-4.526 4.607-3.419 2.04-7.996 2.04m-.552-25.368q-2.702 0-4.687 1.654-1.93 1.6-2.537 4.577h14.118q-.22-2.757-2.151-4.466-1.875-1.765-4.743-1.765M90.801 56c-.28 0-.42 0-.527-.054a.5.5 0 0 1-.218-.218C90 55.62 90 55.48 90 55.2v-5.294c0-.28 0-.42.055-.527a.5.5 0 0 1 .218-.218c.107-.055.247-.055.527-.055h1.572q2.646 0 4.19-1.489 1.6-1.545 1.6-4.08V15.715c0-.28 0-.42.055-.527a.5.5 0 0 1 .218-.219c.107-.054.247-.054.527-.054h5.956c.28 0 .42 0 .527.054a.5.5 0 0 1 .218.219c.055.107.055.247.055.527v27.546q0 3.804-1.655 6.672-1.599 2.868-4.632 4.467-2.979 1.6-7.06 1.6z" /> + </g> + <defs> + <linearGradient id="b" x1="12" x2="71.999" y1="30.001" y2="63.002" gradientUnits="userSpaceOnUse"> + <stop stop-color="#aa5cc3" /> + <stop offset="1" stop-color="#00a4dc" /> + </linearGradient> + <linearGradient id="c" x1="12" x2="71.999" y1="29.999" y2="63.001" gradientUnits="userSpaceOnUse"> + <stop stop-color="#aa5cc3" /> + <stop offset="1" stop-color="#00a4dc" /> + </linearGradient> + <clipPath id="a"> + <path fill="#fff" d="M0 0h251v72H0z" /> + </clipPath> + </defs> + </svg> + + {{^IF isInReportingMode}} + <div class="status"> + <div class="mdl-spinner" dir="ltr" aria-hidden="true"> + <div class="mdl-spinner__layer mdl-spinner__layer-1"><div class="mdl-spinner__circle-clipper mdl-spinner__left"><div class="mdl-spinner__circle mdl-spinner__circleLeft"></div></div><div class="mdl-spinner__circle-clipper mdl-spinner__right"><div class="mdl-spinner__circle mdl-spinner__circleRight"></div></div></div> + <div class="mdl-spinner__layer mdl-spinner__layer-2"><div class="mdl-spinner__circle-clipper mdl-spinner__left"><div class="mdl-spinner__circle mdl-spinner__circleLeft"></div></div><div class="mdl-spinner__circle-clipper mdl-spinner__right"><div class="mdl-spinner__circle mdl-spinner__circleRight"></div></div></div> + <div class="mdl-spinner__layer mdl-spinner__layer-3"><div class="mdl-spinner__circle-clipper mdl-spinner__left"><div class="mdl-spinner__circle mdl-spinner__circleLeft"></div></div><div class="mdl-spinner__circle-clipper mdl-spinner__right"><div class="mdl-spinner__circle mdl-spinner__circleRight"></div></div></div> + <div class="mdl-spinner__layer mdl-spinner__layer-4"><div class="mdl-spinner__circle-clipper mdl-spinner__left"><div class="mdl-spinner__circle mdl-spinner__circleLeft"></div></div><div class="mdl-spinner__circle-clipper mdl-spinner__right"><div class="mdl-spinner__circle mdl-spinner__circleRight"></div></div></div> + </div> + <p class="status-text">Jellyfin is still starting. Please wait… {{currentActivity}}</p> + </div> + {{#ELSE}} + <div class="status is-error"> + <p class="status-text">Jellyfin has encountered an error and was not able to start.</p> + </div> + {{/ELSE}} + {{/IF}} {{#DECLARE LogEntry |--}} {{#LET children = Children}} @@ -192,7 +472,7 @@ <details open> <summary>{{DateOfCreation}} - {{Content}}</summary> <ul class="action-list"> - {{--| #EACH children.Reverse() |-}} + {{--| #EACH children |-}} {{#IMPORT 'LogEntry'}} {{--| /EACH |-}} </ul> @@ -205,31 +485,175 @@ {{--| /DECLARE}} {{#IF localNetworkRequest}} - <div class="flex-col"> - <ol class="action-list"> - {{#FOREACH log IN logs.Reverse()}} - {{#IMPORT 'LogEntry' #WITH log}} - {{/FOREACH}} - </ol> + <div class="logs-panel"> + <div class="logs-header"> + <h2>Startup log</h2> + <a class="download-logs" href='/startup/logger' target="_blank">Download logs</a> + </div> + <div class="logs-scroll" id="logs-scroll"> + <ol class="action-list"> + {{#FOREACH log IN logs}} + {{#IMPORT 'LogEntry' #WITH log}} + {{/FOREACH}} + </ol> + </div> </div> - {{#ELSE}} - {{#IF networkManagerReady}} - <p>Please visit this page from your local network to view detailed startup logs.</p> - {{#ELSE}} - <p>Initializing network settings. Please wait.</p> - {{/ELSE}} - {{/IF}} - {{/ELSE}} {{/IF}} </div> + <script> + (function () { + var reporting = false; + {{#IF isInReportingMode}} + reporting = true; + {{/IF}} + var intervalMs = {{ retryValue.TotalMilliseconds }}; + + var box = document.getElementById('logs-scroll'); + var panel = box ? box.closest('.logs-panel') : null; + + function px(el, prop) { + return el ? (parseFloat(getComputedStyle(el)[prop]) || 0) : 0; + } + + // Size the log viewport so the panel never runs past the bottom of the window + // (keeping the body's bottom padding as the margin). Recomputed on resize. + function fit() { + if (!box) { + return; + } + var topFromDoc = box.getBoundingClientRect().top + window.scrollY; + var below = px(panel, 'paddingBottom') + px(document.body, 'paddingBottom'); + box.style.maxHeight = Math.max(window.innerHeight - topFromDoc - below, 120) + 'px'; + } + + function nearBottom(el) { + return el.scrollHeight - el.scrollTop - el.clientHeight < 24; + } + + fit(); + window.addEventListener('resize', fit); + window.addEventListener('load', fit); + if (box) { + box.scrollTop = box.scrollHeight; // start pinned to the newest entry + } + + // In the terminal error state the page is static, so stop here. + if (reporting) { + return; + } + + // Soft refresh: pull the page in the background and swap the log list + activity line in + // place, so polling never disturbs where the user has scrolled. Only follow to the bottom + // when the user is already there. A real reload happens only on the final transition. + function poll() { + fetch(window.location.href, { cache: 'no-store' }).then(function (resp) { + if (resp.ok) { + // The real server is now answering (HTTP 200) -> offer to continue to the app. + onServerReady(); + return null; + } + return resp.text(); + }).then(function (html) { + if (!html) { + return; + } + var doc = new DOMParser().parseFromString(html, 'text/html'); + + // Startup failed and the page switched to the error view -> reload to render it. + if (doc.querySelector('.status.is-error')) { + window.location.reload(); + return; + } + + var newStatus = doc.querySelector('.status-text'); + var curStatus = document.querySelector('.status-text'); + if (newStatus && curStatus) { + curStatus.innerHTML = newStatus.innerHTML; + } + + if (box) { + var newList = doc.querySelector('#logs-scroll .action-list'); + var curList = box.querySelector('.action-list'); + if (newList && curList) { + var stick = nearBottom(box); + var prevTop = box.scrollTop; + curList.replaceWith(document.importNode(newList, true)); + fit(); + box.scrollTop = stick ? box.scrollHeight : prevTop; + } + } + }).catch(function () { + // Server is mid-transition (port rebinding); just try again on the next tick. + }); + } + + // The server finished starting. Stop polling and present a cancelable countdown so the + // user can either ride the redirect into the app or stay to review the startup output. + function onServerReady() { + clearInterval(pollTimer); + + var status = document.querySelector('.status'); + var statusText = document.querySelector('.status-text'); + var spinner = document.querySelector('.mdl-spinner'); + if (spinner) { + spinner.style.display = 'none'; + } + if (status) { + status.classList.add('is-success'); + } + if (statusText) { + statusText.textContent = 'Jellyfin started successfully.'; + } + if (!status) { + window.location.reload(); + return; + } + + var bar = document.createElement('div'); + bar.className = 'redirect-bar'; + var countdownText = document.createElement('span'); + countdownText.className = 'redirect-countdown'; + var cancelButton = document.createElement('button'); + cancelButton.type = 'button'; + cancelButton.className = 'jf-button jf-button-secondary'; + cancelButton.textContent = 'Cancel'; + bar.appendChild(countdownText); + bar.appendChild(cancelButton); + status.insertAdjacentElement('afterend', bar); + + var remaining = 5; + function renderCountdown() { + countdownText.textContent = 'Redirecting in ' + remaining + '…'; + } + renderCountdown(); + var countdown = setInterval(function () { + remaining -= 1; + if (remaining <= 0) { + clearInterval(countdown); + window.location.reload(); + return; + } + renderCountdown(); + }, 1000); + + // Cancel stops both the redirect and the refreshing, and offers a manual continue. + cancelButton.addEventListener('click', function () { + clearInterval(countdown); + bar.innerHTML = ''; + var continueButton = document.createElement('button'); + continueButton.type = 'button'; + continueButton.className = 'jf-button jf-button-primary'; + continueButton.textContent = 'Continue to Jellyfin'; + continueButton.addEventListener('click', function () { + window.location.reload(); + }); + bar.appendChild(continueButton); + }); + } + + var pollTimer = setInterval(poll, intervalMs); + })(); + </script> </body> -{{^IF isInReportingMode}} -<script> - setTimeout(() => { - window.location.reload(); - }, {{ retryValue.TotalMilliseconds }}); -</script> -{{/IF}} - </html> 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 { + /// <summary> + /// The result of a channel item query. + /// </summary> public class ChannelItemResult { + /// <summary> + /// Initializes a new instance of the <see cref="ChannelItemResult"/> class. + /// </summary> public ChannelItemResult() { Items = Array.Empty<ChannelItemInfo>(); } + /// <summary> + /// Gets or sets the items. + /// </summary> public IReadOnlyList<ChannelItemInfo> Items { get; set; } + /// <summary> + /// Gets or sets the total record count. + /// </summary> 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 { + /// <summary> + /// The type of a channel item. + /// </summary> public enum ChannelItemType { + /// <summary> + /// The item is a media item. + /// </summary> Media = 0, + /// <summary> + /// The item is a folder. + /// </summary> 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 { + /// <summary> + /// The request for a latest media search in a channel. + /// </summary> public class ChannelLatestMediaSearch { + /// <summary> + /// Gets or sets the user id. + /// </summary> 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 { + /// <summary> + /// The parental rating of a channel. + /// </summary> public enum ChannelParentalRating { + /// <summary> + /// Suitable for a general audience. + /// </summary> GeneralAudience = 0, + /// <summary> + /// Parental guidance suggested (US PG). + /// </summary> UsPG = 1, + /// <summary> + /// Parents strongly cautioned (US PG-13). + /// </summary> UsPG13 = 2, + /// <summary> + /// Restricted (US R). + /// </summary> UsR = 3, + /// <summary> + /// Suitable for adults only. + /// </summary> 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 { + /// <summary> + /// The request for a search in a channel. + /// </summary> public class ChannelSearchInfo { + /// <summary> + /// Gets or sets the search term. + /// </summary> public string SearchTerm { get; set; } + /// <summary> + /// Gets or sets the user id. + /// </summary> 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 { + /// <summary> + /// Interface for channels that provide a cache key. + /// </summary> public interface IHasCacheKey { /// <summary> /// Gets the cache key. /// </summary> /// <param name="userId">The user identifier.</param> - /// <returns>System.String.</returns> + /// <returns>The cache key.</returns> 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 { + /// <summary> + /// Interface for channels that support deleting items. + /// </summary> public interface ISupportsDelete { + /// <summary> + /// Gets a value indicating whether the item can be deleted. + /// </summary> + /// <param name="item">The item.</param> + /// <returns><c>true</c> if the item can be deleted, <c>false</c> otherwise.</returns> bool CanDelete(BaseItem item); + /// <summary> + /// Deletes the item with the provided id. + /// </summary> + /// <param name="id">The item id.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>A task representing the deletion of the item.</returns> 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 { + /// <summary> + /// Interface for channels that support retrieving the latest media. + /// </summary> public interface ISupportsLatestMedia { /// <summary> 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 /// <inheritdoc /> public async Task<string> 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 @@ -82,13 +82,6 @@ namespace MediaBrowser.Controller.Dto public bool AddCurrentProgram { get; set; } /// <summary> - /// 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". - /// </summary> - public bool PreferEpisodeParentPoster { get; set; } - - /// <summary> /// Gets a value indicating whether the specified field is populated. /// </summary> /// <param name="field">The field to check.</param> diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index 21304768bd..49a4ed4bf6 100644 --- a/MediaBrowser.Controller/Entities/BaseItem.cs +++ b/MediaBrowser.Controller/Entities/BaseItem.cs @@ -87,6 +87,8 @@ namespace MediaBrowser.Controller.Entities Model.Entities.ExtraType.Short }; + private static readonly char[] VersionDelimiters = ['-', '_', '.']; + private string _sortName; private string _forcedSortName; @@ -1099,8 +1101,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 +1113,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 +1129,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 +1142,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 +1221,7 @@ namespace MediaBrowser.Controller.Entities return info; } - internal string GetMediaSourceName(BaseItem item) + internal string GetMediaSourceName(BaseItem item, string commonPrefix = null) { var terms = new List<string>(); @@ -1228,12 +1229,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 +1310,98 @@ namespace MediaBrowser.Controller.Entities return string.Join('/', terms); } + /// <summary> + /// 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. + /// </summary> + /// <param name="items">The media source items.</param> + /// <returns>The shared prefix, or null when no useful prefix exists.</returns> + private static string GetCommonNamePrefix(IReadOnlyList<(BaseItem Item, MediaSourceType MediaSourceType)> items) + { + var fileNames = new List<string>(); + 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; + } + + /// <summary> + /// 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). + /// </summary> + /// <param name="fileNames">The version file names without extension; must contain at least one entry.</param> + /// <returns>The shared prefix retreated to a separator boundary, or an empty string when none is shared.</returns> + internal static string GetCommonVersionPrefix(IReadOnlyList<string> 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 +2123,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); + } + + /// <summary> + /// Clears the played state on the supplied user data. + /// </summary> + /// <param name="data">The user data to reset.</param> + 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); } /// <summary> @@ -2732,6 +2855,15 @@ namespace MediaBrowser.Controller.Entities } /// <summary> + /// Gets the ids of the items whose owned extras belong to this item. + /// </summary> + /// <returns>An array containing the owner ids.</returns> + protected virtual Guid[] GetExtraOwnerIds() + { + return [Id]; + } + + /// <summary> /// Get all extras associated with this item, sorted by <see cref="SortName"/>. /// </summary> /// <param name="user">The user to apply parental restrictions for, or <c>null</c> to skip restriction checks.</param> @@ -2740,7 +2872,7 @@ namespace MediaBrowser.Controller.Entities { return LibraryManager.GetItemList(new InternalItemsQuery(user) { - OwnerIds = [Id], + OwnerIds = GetExtraOwnerIds(), OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending)] }); } @@ -2755,7 +2887,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<BookInfo>, 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<BaseItem>(); + var accessibleChildren = new List<BaseItem>(); 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<Folder>().ToList(), directoryService, innerProgress, cancellationToken).ConfigureAwait(false); + await ValidateSubFolders(accessibleChildren.OfType<Folder>().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 { + /// <summary> + /// Interface for items that have special features. + /// </summary> public interface IHasSpecialFeatures { /// <summary> 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 { + /// <summary> + /// Interface for items that have a start date. + /// </summary> public interface IHasStartDate { + /// <summary> + /// Gets or sets the start date. + /// </summary> 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 { /// <summary> - /// Marker interface. + /// Marker interface for items that represent a name, like a genre or a studio. /// </summary> public interface IItemByName { + /// <summary> + /// Gets the items tagged with this name. + /// </summary> + /// <param name="query">The query.</param> + /// <returns>The tagged items.</returns> IReadOnlyList<BaseItem> GetTaggedItems(InternalItemsQuery query); } + /// <summary> + /// Interface for by-name items that can also be accessed as a regular library item. + /// </summary> public interface IHasDualAccess : IItemByName { + /// <summary> + /// Gets a value indicating whether the item is accessed by name. + /// </summary> 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 { + /// <summary> + /// Interface for items that can be placeholders. + /// </summary> public interface ISupportsPlaceHolders { /// <summary> 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 } } + /// <summary> + /// 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. + /// </summary> + 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; } + /// <inheritdoc /> + /// <remarks> + /// 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. + /// </remarks> + 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 { + /// <summary> + /// The source of an item. + /// </summary> public enum SourceType { + /// <summary> + /// The item comes from a library. + /// </summary> Library = 0, + + /// <summary> + /// The item comes from a channel. + /// </summary> Channel = 1, + + /// <summary> + /// The item comes from live TV. + /// </summary> 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<BaseItem> 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<BaseItem> 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<BaseItem> 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<string>(); - LocalAlternateVersions = Array.Empty<string>(); - SubtitleFiles = Array.Empty<string>(); - AudioFiles = Array.Empty<string>(); - LinkedAlternateVersions = Array.Empty<LinkedChild>(); + AdditionalParts = []; + LocalAlternateVersions = []; + SubtitleFiles = []; + AudioFiles = []; + LinkedAlternateVersions = []; } [JsonIgnore] @@ -254,7 +254,7 @@ namespace MediaBrowser.Controller.Entities private int GetMediaSourceCount(HashSet<Guid> callstack = null) { - callstack ??= new(); + callstack ??= []; if (PrimaryVersionId.HasValue) { var item = LibraryManager.GetItemById(PrimaryVersionId.Value); @@ -335,6 +335,102 @@ namespace MediaBrowser.Controller.Entities PresentationUniqueKey = CreatePresentationUniqueKey(); } + /// <summary> + /// Marks the played status of this video and propagates it to its alternate versions. + /// </summary> + /// <param name="user">The user.</param> + /// <param name="datePlayed">The date played.</param> + /// <param name="resetPosition">if set to <c>true</c> [reset position].</param> + public override void MarkPlayed(User user, DateTime? datePlayed, bool resetPosition) + { + base.MarkPlayed(user, datePlayed, resetPosition); + PropagatePlayedState(user, true, resetPosition); + } + + /// <summary> + /// Marks this video unplayed and propagates the change to its alternate versions. + /// </summary> + /// <param name="user">The user.</param> + 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); + } + + /// <summary> + /// Propagates the played status to every alternate version of this video. + /// </summary> + /// <param name="user">The user.</param> + /// <param name="played">The played status to apply to the alternate versions.</param> + /// <param name="resetPosition">When marking played, controls whether each version's resume point + /// is also reset (<c>true</c>) or left untouched (<c>false</c>). Ignored when marking unplayed, + /// which always fully resets every version.</param> + 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); + } + } + } + + /// <summary> + /// 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. + /// </summary> + /// <returns>This video and every alternate version of it.</returns> + public IReadOnlyList<Video> GetAllVersions() + { + return GetAllItemsForMediaSources() + .Select(i => i.Item) + .OfType<Video>() + .ToList(); + } + + /// <summary> + /// Gets the alternate version of this video that matches the supplied item id. + /// </summary> + /// <param name="itemId">The version item id (the playback media source id).</param> + /// <returns>The matching version, or <c>null</c> when the id is not a version of this video.</returns> + public Video GetAlternateVersion(Guid itemId) + { + return GetAllVersions().FirstOrDefault(i => i.Id.Equals(itemId)); + } + public override string CreatePresentationUniqueKey() { if (PrimaryVersionId.HasValue) @@ -396,8 +492,8 @@ namespace MediaBrowser.Controller.Entities public IOrderedEnumerable<Video> GetAdditionalParts(User user = null) { return GetAdditionalPartIds() - .Select(i => LibraryManager.GetItemById<Video>(i, user)) - .Where(i => i is not null) + .Select(i => LibraryManager.GetItemById<Video>(i)) + .Where(i => i is not null && (user is null || i.IsVisible(user))) .OrderBy(i => i.SortName); } @@ -642,39 +738,58 @@ namespace MediaBrowser.Controller.Entities }).FirstOrDefault(); } - protected override IEnumerable<(BaseItem Item, MediaSourceType MediaSourceType)> GetAllItemsForMediaSources() + /// <summary> + /// Gets the ids of the items whose owned extras belong to this item. + /// Extras are linked to a single version but need tp be surfaced for all versions. + /// </summary> + /// <returns>An array containing the owner ids.</returns> + protected override Guid[] GetExtraOwnerIds() { - var list = new List<(BaseItem, MediaSourceType)> - { - (this, MediaSourceType.Default) - }; - - list.AddRange( - LibraryManager.GetLinkedAlternateVersions(this) - .Select(i => ((BaseItem)i, MediaSourceType.Grouping))); + return GetAllItemsForMediaSources() + .Select(i => i.Item.Id) + .Distinct() + .ToArray(); + } - if (PrimaryVersionId.HasValue) - { - if (LibraryManager.GetItemById(PrimaryVersionId.Value) is Video primary) - { - var existingIds = list.Select(i => i.Item1.Id).ToList(); - list.Add((primary, MediaSourceType.Grouping)); - list.AddRange(LibraryManager.GetLinkedAlternateVersions(primary).Where(i => !existingIds.Contains(i.Id)).Select(i => ((BaseItem)i, MediaSourceType.Grouping))); - } - } + protected override IEnumerable<(BaseItem Item, MediaSourceType MediaSourceType)> GetAllItemsForMediaSources() + { + var primary = PrimaryVersionId.HasValue + ? LibraryManager.GetItemById(PrimaryVersionId.Value) as Video + : null; + + var primaryLinked = primary is null + ? [] + : LibraryManager.GetLinkedAlternateVersions(primary).ToList(); + + // Grouping marks user-merged (splittable) sources. The primary is only such a source when + // this video is linked onto it; for local (file-based) alternates the primary is just + // another default source. + var primaryType = primaryLinked.Any(i => i.Id.Equals(Id)) + ? MediaSourceType.Grouping + : MediaSourceType.Default; + + // This video and its linked alternates, when this is itself an alternate, the primary and the primary's linked alternates. + var grouped = new[] { ((BaseItem)this, MediaSourceType.Default) } + .Concat(LibraryManager.GetLinkedAlternateVersions(this).Select(i => ((BaseItem)i, MediaSourceType.Grouping))) + .Concat(primary is null + ? [] + : primaryLinked.Select(i => ((BaseItem)i, MediaSourceType.Grouping)).Prepend(((BaseItem)primary, primaryType))) + .ToList(); - var localAlternates = list - .SelectMany(i => - { - return i.Item1 is Video video ? LibraryManager.GetLocalAlternateVersionIds(video) : Enumerable.Empty<Guid>(); - }) + // The local (file-based) alternate versions of every grouped item. + var localAlternates = grouped + .Select(i => i.Item1) + .OfType<Video>() + .SelectMany(LibraryManager.GetLocalAlternateVersionIds) .Select(LibraryManager.GetItemById) .Where(i => i is not null) - .ToList(); - - list.AddRange(localAlternates.Select(i => (i, MediaSourceType.Default))); + .Select(i => (i, MediaSourceType.Default)); - return list; + // Deduplicate + return grouped + .Concat(localAlternates) + .DistinctBy(i => i.Item1.Id) + .ToList(); } } } diff --git a/MediaBrowser.Controller/Library/IUserDataManager.cs b/MediaBrowser.Controller/Library/IUserDataManager.cs index 798812bf1f..2ee8845346 100644 --- a/MediaBrowser.Controller/Library/IUserDataManager.cs +++ b/MediaBrowser.Controller/Library/IUserDataManager.cs @@ -63,6 +63,24 @@ namespace MediaBrowser.Controller.Library Dictionary<Guid, UserItemData> GetUserDataBatch(IReadOnlyList<BaseItem> items, User user); /// <summary> + /// Gets the user data that should drive resume for a multi-version item: the data of the most + /// recently played alternate version (including the item itself) that has a resume point. + /// </summary> + /// <param name="user">The user.</param> + /// <param name="item">The item.</param> + /// <returns>The resume version's data, or <c>null</c> when the item has no versions or none has a resume point.</returns> + VersionResumeData? GetResumeUserData(User user, BaseItem item); + + /// <summary> + /// Gets the resume-driving user data for multiple items in a single batch operation. + /// See <see cref="GetResumeUserData(User, BaseItem)"/>. + /// </summary> + /// <param name="items">The items to get resume data for.</param> + /// <param name="user">The user.</param> + /// <returns>A dictionary mapping item ids to their resume version's data; items without one are omitted.</returns> + IReadOnlyDictionary<Guid, VersionResumeData> GetResumeUserDataBatch(IReadOnlyList<BaseItem> items, User user); + + /// <summary> /// Gets the user data dto. /// </summary> /// <param name="item">Item to use.</param> @@ -80,5 +98,13 @@ namespace MediaBrowser.Controller.Library /// <param name="reportedPositionTicks">New playstate.</param> /// <returns>True if playstate was updated.</returns> bool UpdatePlayState(BaseItem item, UserItemData data, long? reportedPositionTicks); + + /// <summary> + /// Clears any stored audio and subtitle stream selections for the given user/item pair. + /// Used when the user has opted out of remembering selections. + /// </summary> + /// <param name="user">The user.</param> + /// <param name="item">The item.</param> + void ResetPlaybackStreamSelections(User user, BaseItem item); } } diff --git a/MediaBrowser.Controller/Library/VersionPlaybackSelector.cs b/MediaBrowser.Controller/Library/VersionPlaybackSelector.cs new file mode 100644 index 0000000000..1766c50141 --- /dev/null +++ b/MediaBrowser.Controller/Library/VersionPlaybackSelector.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using MediaBrowser.Controller.Entities; + +namespace MediaBrowser.Controller.Library +{ + /// <summary> + /// Single definition of "which alternate version was most recently played" shared by the resume tile + /// (<see cref="IUserDataManager.GetResumeUserData"/>), the media-source default ordering and Next Up. + /// Each call site declares its own eligibility rule so the intentional differences (resumable-only vs. + /// resumable-or-completed) are visible in one place instead of being re-implemented divergently. + /// The SQL resume query keeps its own translation of the same rule. + /// </summary> + public static class VersionPlaybackSelector + { + /// <summary> + /// Selects the entry whose user data has the greatest <see cref="UserItemData.LastPlayedDate"/>, + /// considering only entries that satisfy <paramref name="isEligible"/>. On an exact tie the first + /// encountered entry wins. + /// </summary> + /// <typeparam name="T">The candidate type (e.g. a version item or a media source).</typeparam> + /// <param name="items">The candidates to choose from.</param> + /// <param name="dataSelector">Resolves the user data for a candidate, or <c>null</c> when it has none.</param> + /// <param name="isEligible">Whether a candidate's user data makes it a valid winner.</param> + /// <returns>The most recently played eligible candidate, or <c>default</c> when none qualify.</returns> + public static T? SelectMostRecentlyPlayed<T>( + IEnumerable<T> items, + Func<T, UserItemData?> dataSelector, + Func<UserItemData, bool> isEligible) + { + ArgumentNullException.ThrowIfNull(items); + ArgumentNullException.ThrowIfNull(dataSelector); + ArgumentNullException.ThrowIfNull(isEligible); + + T? winner = default; + var winnerDate = DateTime.MinValue; + var hasWinner = false; + + foreach (var item in items) + { + var data = dataSelector(item); + if (data is null || !isEligible(data)) + { + continue; + } + + var date = data.LastPlayedDate ?? DateTime.MinValue; + if (!hasWinner || date > winnerDate) + { + winner = item; + winnerDate = date; + hasWinner = true; + } + } + + return winner; + } + } +} diff --git a/MediaBrowser.Controller/Library/VersionResumeData.cs b/MediaBrowser.Controller/Library/VersionResumeData.cs new file mode 100644 index 0000000000..772e2bf3a7 --- /dev/null +++ b/MediaBrowser.Controller/Library/VersionResumeData.cs @@ -0,0 +1,41 @@ +using System; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Dto; + +namespace MediaBrowser.Controller.Library +{ + /// <summary> + /// The user data of the most recently played alternate version that should drive the completion state of a multi-version item. + /// </summary> + /// <param name="VersionId">The id of the version that owns <paramref name="UserData"/>.</param> + /// <param name="UserData">The resume version's user data.</param> + public record VersionResumeData(Guid VersionId, UserItemData UserData) + { + /// <summary> + /// Merges the most recently played version's completion state into the supplied user data dto. + /// Completion (played) propagates to the primary. An in-progress resume position stays on the version + /// that owns it, which is surfaced directly (e.g. in resume queries) so that playback always targets + /// the correct version rather than resuming the primary at another version's offset. When the movie was + /// finished on a different version, the primary's own stale resume position is cleared so it does not + /// render as "watched and resumable" at the same time. + /// </summary> + /// <param name="dto">The user data dto to update.</param> + public void ApplyTo(UserItemDataDto dto) + { + dto.Played = dto.Played || UserData.Played; + + if ((UserData.LastPlayedDate ?? DateTime.MinValue) > (dto.LastPlayedDate ?? DateTime.MinValue)) + { + dto.LastPlayedDate = UserData.LastPlayedDate; + } + + // A different version was finished (played, no resume position of its own) and is the most + // recently played: the whole movie is watched. + if (!VersionId.Equals(dto.ItemId) && UserData.Played && UserData.PlaybackPositionTicks <= 0) + { + dto.PlaybackPositionTicks = 0; + dto.PlayedPercentage = null; + } + } + } +} diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs index 320e65231c..1b0bbe9ea0 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs @@ -25,6 +25,7 @@ using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.MediaInfo; +using MediaBrowser.Model.Session; using Microsoft.Extensions.Configuration; using IConfigurationManager = MediaBrowser.Common.Configuration.IConfigurationManager; @@ -2611,56 +2612,66 @@ namespace MediaBrowser.Controller.MediaEncoding } public bool CanStreamCopyAudio(EncodingJobInfo state, MediaStream audioStream, IEnumerable<string> supportedAudioCodecs) + => CanStreamCopyAudio(state, audioStream, supportedAudioCodecs, out _); + + /// <summary> + /// Determines whether the given audio stream can be stream-copied and, regardless of the outcome, + /// reports the codec/parameter incompatibilities that would force a re-encode via <paramref name="failureReasons"/>. + /// </summary> + /// <param name="state">The encoding job state.</param> + /// <param name="audioStream">The source audio stream.</param> + /// <param name="supportedAudioCodecs">The audio codecs the target supports.</param> + /// <param name="failureReasons">The codec/parameter incompatibilities preventing a copy, or <c>0</c> if the stream is copy-compatible.</param> + /// <returns><c>true</c> if the audio stream can be stream-copied; otherwise, <c>false</c>.</returns> + public bool CanStreamCopyAudio(EncodingJobInfo state, MediaStream audioStream, IEnumerable<string> supportedAudioCodecs, out TranscodeReason failureReasons) { var request = state.BaseRequest; - if (!request.AllowAudioStreamCopy) - { - return false; - } + // Policy-independent compatibility check, so the reasons are reported even when a policy gate is what ultimately prevents the copy. + failureReasons = GetAudioStreamCopyFailureReasons(state, audioStream, supportedAudioCodecs); + + return request.AllowAudioStreamCopy + && request.EnableAutoStreamCopy + && failureReasons == 0; + } + + private static TranscodeReason GetAudioStreamCopyFailureReasons(EncodingJobInfo state, MediaStream audioStream, IEnumerable<string> supportedAudioCodecs) + { + var request = state.BaseRequest; + TranscodeReason reasons = 0; var maxBitDepth = state.GetRequestedAudioBitDepth(audioStream.Codec); if (maxBitDepth.HasValue && audioStream.BitDepth.HasValue && audioStream.BitDepth.Value > maxBitDepth.Value) { - return false; + reasons |= TranscodeReason.AudioBitDepthNotSupported; } // Source and target codecs must match if (string.IsNullOrEmpty(audioStream.Codec) || !supportedAudioCodecs.Contains(audioStream.Codec, StringComparison.OrdinalIgnoreCase)) { - return false; + reasons |= TranscodeReason.AudioCodecNotSupported; } // Channels must fall within requested value var channels = state.GetRequestedAudioChannels(audioStream.Codec); - if (channels.HasValue) + if (channels.HasValue + && (!audioStream.Channels.HasValue + || audioStream.Channels.Value <= 0 + || audioStream.Channels.Value > channels.Value)) { - if (!audioStream.Channels.HasValue || audioStream.Channels.Value <= 0) - { - return false; - } - - if (audioStream.Channels.Value > channels.Value) - { - return false; - } + reasons |= TranscodeReason.AudioChannelsNotSupported; } // Sample rate must fall within requested value - if (request.AudioSampleRate.HasValue) + if (request.AudioSampleRate.HasValue + && (!audioStream.SampleRate.HasValue + || audioStream.SampleRate.Value <= 0 + || audioStream.SampleRate.Value > request.AudioSampleRate.Value)) { - if (!audioStream.SampleRate.HasValue || audioStream.SampleRate.Value <= 0) - { - return false; - } - - if (audioStream.SampleRate.Value > request.AudioSampleRate.Value) - { - return false; - } + reasons |= TranscodeReason.AudioSampleRateNotSupported; } // Audio bitrate must fall within requested value @@ -2668,10 +2679,10 @@ namespace MediaBrowser.Controller.MediaEncoding && audioStream.BitRate.HasValue && audioStream.BitRate.Value > request.AudioBitRate.Value) { - return false; + reasons |= TranscodeReason.AudioBitrateNotSupported; } - return request.EnableAutoStreamCopy; + return reasons; } public int GetVideoBitrateParamValue(BaseEncodingJobOptions request, MediaStream videoStream, string outputVideoCodec) @@ -4043,7 +4054,7 @@ namespace MediaBrowser.Controller.MediaEncoding mainFilters.Add(swDeintFilter); } - var outFormat = doCuTonemap ? "yuv420p10le" : "yuv420p"; + var outFormat = doCuTonemap ? "p010le" : "yuv420p"; var swScaleFilter = GetSwScaleFilter(state, options, vidEncoder, swpInW, swpInH, threeDFormat, reqW, reqH, reqMaxW, reqMaxH); // sw scale mainFilters.Add(swScaleFilter); @@ -7217,8 +7228,9 @@ namespace MediaBrowser.Controller.MediaEncoding && !IsCopyCodec(state.OutputVideoCodec) && options.HlsAudioSeekStrategy is HlsAudioSeekStrategy.TranscodeAudio; + TranscodeReason audioCopyFailureReasons = 0; if (state.AudioStream is not null - && CanStreamCopyAudio(state, state.AudioStream, state.SupportedAudioCodecs) + && CanStreamCopyAudio(state, state.AudioStream, state.SupportedAudioCodecs, out audioCopyFailureReasons) && !preventHlsAudioCopy) { state.OutputAudioCodec = "copy"; @@ -7232,6 +7244,13 @@ namespace MediaBrowser.Controller.MediaEncoding { state.OutputAudioCodec = "copy"; } + else if (state.AudioStream is not null && !IsCopyCodec(state.OutputAudioCodec)) + { + // Audio is actually being re-encoded although the playback determination may have considered the source copyable. + // Only carry the primary "cannot be passed through" cause - the codec mismatch. + // Bitrate/channels/sample-rate/bit-depth copy refusals are consequences of the chosen transcode target. + state.AddTranscodeReason(audioCopyFailureReasons & TranscodeReason.AudioCodecNotSupported); + } } } @@ -7851,13 +7870,14 @@ namespace MediaBrowser.Controller.MediaEncoding audioTranscodeParams.Add("-ar " + state.BaseRequest.AudioBitRate); } - if (!string.Equals(outputCodec, "opus", StringComparison.OrdinalIgnoreCase)) + var sampleRate = state.OutputAudioSampleRate; + if (sampleRate.HasValue) { - // opus only supports specific sampling rates - var sampleRate = state.OutputAudioSampleRate; - if (sampleRate.HasValue) + var sampleRateValue = sampleRate.Value; + if (string.Equals(outputCodec, "opus", StringComparison.OrdinalIgnoreCase)) { - var sampleRateValue = sampleRate.Value switch + // opus only supports specific sampling rates + sampleRateValue = sampleRate.Value switch { <= 8000 => 8000, <= 12000 => 12000, @@ -7865,9 +7885,9 @@ namespace MediaBrowser.Controller.MediaEncoding <= 24000 => 24000, _ => 48000 }; - - audioTranscodeParams.Add("-ar " + sampleRateValue.ToString(CultureInfo.InvariantCulture)); } + + audioTranscodeParams.Add("-ar " + sampleRateValue.ToString(CultureInfo.InvariantCulture)); } // Copy the movflags from GetProgressiveVideoFullCommandLine diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs index 3a1897a244..314cd32903 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs @@ -515,6 +515,15 @@ namespace MediaBrowser.Controller.MediaEncoding public int HlsListSize => 0; + /// <summary> + /// Adds the specified reason(s) to <see cref="TranscodeReasons"/>. + /// </summary> + /// <param name="reason">The transcode reason(s) to add.</param> + public void AddTranscodeReason(TranscodeReason reason) + { + _transcodeReasons = TranscodeReasons | reason; + } + private int? GetMediaStreamCount(MediaStreamType type, int limit) { var count = MediaSource.GetStreamCount(type); diff --git a/MediaBrowser.Controller/Providers/BookInfo.cs b/MediaBrowser.Controller/Providers/BookInfo.cs index 3055c5d871..7f8151e534 100644 --- a/MediaBrowser.Controller/Providers/BookInfo.cs +++ b/MediaBrowser.Controller/Providers/BookInfo.cs @@ -1,11 +1,15 @@ #nullable disable -#pragma warning disable CS1591 - namespace MediaBrowser.Controller.Providers { + /// <summary> + /// The lookup info for books. + /// </summary> public class BookInfo : ItemLookupInfo { + /// <summary> + /// Gets or sets the name of the series the book belongs to. + /// </summary> public string SeriesName { get; set; } } } diff --git a/MediaBrowser.Controller/Providers/BoxSetInfo.cs b/MediaBrowser.Controller/Providers/BoxSetInfo.cs index f43ea67178..22dbdb959e 100644 --- a/MediaBrowser.Controller/Providers/BoxSetInfo.cs +++ b/MediaBrowser.Controller/Providers/BoxSetInfo.cs @@ -1,7 +1,8 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Controller.Providers { + /// <summary> + /// The lookup info for box sets. + /// </summary> public class BoxSetInfo : ItemLookupInfo { } diff --git a/MediaBrowser.Controller/Providers/IHasLookupInfo.cs b/MediaBrowser.Controller/Providers/IHasLookupInfo.cs index 42cb523713..834e173ca3 100644 --- a/MediaBrowser.Controller/Providers/IHasLookupInfo.cs +++ b/MediaBrowser.Controller/Providers/IHasLookupInfo.cs @@ -1,10 +1,16 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Controller.Providers { + /// <summary> + /// Interface for items that provide lookup info for metadata providers. + /// </summary> + /// <typeparam name="TLookupInfoType">The type of the lookup info.</typeparam> public interface IHasLookupInfo<out TLookupInfoType> where TLookupInfoType : ItemLookupInfo, new() { + /// <summary> + /// Gets the lookup info. + /// </summary> + /// <returns>The lookup info.</returns> TLookupInfoType GetLookupInfo(); } } diff --git a/MediaBrowser.Controller/Providers/IPreRefreshProvider.cs b/MediaBrowser.Controller/Providers/IPreRefreshProvider.cs index 6d98af33e4..668160759f 100644 --- a/MediaBrowser.Controller/Providers/IPreRefreshProvider.cs +++ b/MediaBrowser.Controller/Providers/IPreRefreshProvider.cs @@ -1,7 +1,8 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Controller.Providers { + /// <summary> + /// Marker interface for custom metadata providers that run before the regular metadata refresh. + /// </summary> public interface IPreRefreshProvider : ICustomMetadataProvider { } diff --git a/MediaBrowser.Controller/Providers/MovieInfo.cs b/MediaBrowser.Controller/Providers/MovieInfo.cs index 20e6b697ad..a33f8bbfe2 100644 --- a/MediaBrowser.Controller/Providers/MovieInfo.cs +++ b/MediaBrowser.Controller/Providers/MovieInfo.cs @@ -1,7 +1,8 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Controller.Providers { + /// <summary> + /// The lookup info for movies. + /// </summary> public class MovieInfo : ItemLookupInfo { } diff --git a/MediaBrowser.Controller/Providers/PersonLookupInfo.cs b/MediaBrowser.Controller/Providers/PersonLookupInfo.cs index 11cb71f902..d0eb5cb825 100644 --- a/MediaBrowser.Controller/Providers/PersonLookupInfo.cs +++ b/MediaBrowser.Controller/Providers/PersonLookupInfo.cs @@ -1,7 +1,8 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Controller.Providers { + /// <summary> + /// The lookup info for persons. + /// </summary> public class PersonLookupInfo : ItemLookupInfo { } diff --git a/MediaBrowser.Controller/Providers/SeriesInfo.cs b/MediaBrowser.Controller/Providers/SeriesInfo.cs index 976fa175ad..5ca5f0a534 100644 --- a/MediaBrowser.Controller/Providers/SeriesInfo.cs +++ b/MediaBrowser.Controller/Providers/SeriesInfo.cs @@ -1,7 +1,8 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Controller.Providers { + /// <summary> + /// The lookup info for series. + /// </summary> public class SeriesInfo : ItemLookupInfo { } diff --git a/MediaBrowser.Controller/Providers/TrailerInfo.cs b/MediaBrowser.Controller/Providers/TrailerInfo.cs index 630850f9db..c30468db6c 100644 --- a/MediaBrowser.Controller/Providers/TrailerInfo.cs +++ b/MediaBrowser.Controller/Providers/TrailerInfo.cs @@ -1,7 +1,8 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Controller.Providers { + /// <summary> + /// The lookup info for trailers. + /// </summary> public class TrailerInfo : ItemLookupInfo { } diff --git a/MediaBrowser.LocalMetadata/Images/LocalImageProvider.cs b/MediaBrowser.LocalMetadata/Images/LocalImageProvider.cs index f56bc71d0d..599c6cd64f 100644 --- a/MediaBrowser.LocalMetadata/Images/LocalImageProvider.cs +++ b/MediaBrowser.LocalMetadata/Images/LocalImageProvider.cs @@ -84,7 +84,7 @@ namespace MediaBrowser.LocalMetadata.Images if (item.SupportsLocalMetadata) { // Episode has its own provider - if (item is Episode || item is Audio || item is Photo) + if (item is Episode || (item is Audio && item is not AudioBook) || item is Photo) { return false; } diff --git a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs index 9dd3dcecba..12a5ab877c 100644 --- a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs +++ b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs @@ -8,6 +8,7 @@ using System.Text; using System.Threading; using System.Threading.Tasks; using AsyncKeyedLock; +using Jellyfin.Extensions; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.IO; @@ -101,7 +102,7 @@ namespace MediaBrowser.MediaEncoding.Attachments CancellationToken cancellationToken) { var shouldExtractOneByOne = mediaSource.MediaAttachments.Any(a => !string.IsNullOrEmpty(a.FileName) - && (a.FileName.Contains('/', StringComparison.OrdinalIgnoreCase) || a.FileName.Contains('\\', StringComparison.OrdinalIgnoreCase))); + && !string.Equals(PathHelper.GetSafeLeafFileName(a.FileName), a.FileName, StringComparison.Ordinal)); if (shouldExtractOneByOne && !inputFile.EndsWith(".mks", StringComparison.OrdinalIgnoreCase)) { await ExtractAllAttachmentsIndividuallyInternal( @@ -387,7 +388,9 @@ namespace MediaBrowser.MediaEncoding.Attachments using (await _semaphoreLocks.LockAsync(attachmentFolderPath, cancellationToken).ConfigureAwait(false)) { - var attachmentPath = _pathManager.GetAttachmentPath(mediaSource.Id, mediaAttachment.FileName ?? mediaAttachment.Index.ToString(CultureInfo.InvariantCulture))!; + var indexName = mediaAttachment.Index.ToString(CultureInfo.InvariantCulture); + var attachmentPath = _pathManager.GetAttachmentPath(mediaSource.Id, mediaAttachment.FileName ?? indexName) + ?? _pathManager.GetAttachmentPath(mediaSource.Id, indexName)!; if (!File.Exists(attachmentPath)) { await ExtractAttachmentInternal( diff --git a/MediaBrowser.MediaEncoding/Encoder/ApplePlatformHelper.cs b/MediaBrowser.MediaEncoding/Encoder/ApplePlatformHelper.cs index a8ff58b091..baea0df8cc 100644 --- a/MediaBrowser.MediaEncoding/Encoder/ApplePlatformHelper.cs +++ b/MediaBrowser.MediaEncoding/Encoder/ApplePlatformHelper.cs @@ -1,9 +1,11 @@ #pragma warning disable CA1031 using System; +using System.Buffers; using System.Linq; using System.Runtime.InteropServices; using System.Runtime.Versioning; +using System.Text; using Microsoft.Extensions.Logging; namespace MediaBrowser.MediaEncoding.Encoder; @@ -12,43 +14,43 @@ namespace MediaBrowser.MediaEncoding.Encoder; /// Helper class for Apple platform specific operations. /// </summary> [SupportedOSPlatform("macos")] -public static class ApplePlatformHelper +public static partial class ApplePlatformHelper { private static readonly string[] _av1DecodeBlacklistedCpuClass = ["M1", "M2"]; - private static string GetSysctlValue(ReadOnlySpan<byte> 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<byte>.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<byte>.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<byte> data = buffer.AsSpan()[..(int)(length - 1)]; + return Encoding.UTF8.GetString(data); } finally { - Marshal.FreeHGlobal(buffer); + ArrayPool<byte>.Shared.Return(buffer); } } - private static int SysctlByName(ReadOnlySpan<byte> name, IntPtr oldp, ref IntPtr oldlenp, IntPtr newp, uint newlen) - { - return NativeMethods.SysctlByName(name.ToArray(), oldp, ref oldlenp, newp, newlen); - } - /// <summary> /// Check if the current system has hardware acceleration for AV1 decoding. /// </summary> @@ -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<byte> oldp, ref nuint oldlenp, IntPtr newp, nuint newlen); } diff --git a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs index 68d6d215b2..1f84b46a2b 100644 --- a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs +++ b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs @@ -6,6 +6,7 @@ using System.Diagnostics; using System.Globalization; using System.Linq; using System.Runtime.Versioning; +using System.Text; using System.Text.RegularExpressions; using MediaBrowser.Controller.MediaEncoding; using Microsoft.Extensions.Logging; @@ -184,8 +185,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 +645,9 @@ namespace MediaBrowser.MediaEncoding.Encoder WindowStyle = ProcessWindowStyle.Hidden, ErrorDialog = false, RedirectStandardInput = redirectStandardIn, + StandardOutputEncoding = Encoding.UTF8, RedirectStandardOutput = true, + StandardErrorEncoding = Encoding.UTF8, RedirectStandardError = true } }) 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 @@ <TargetFramework>net10.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> + <AllowUnsafeBlocks>true</AllowUnsafeBlocks> </PropertyGroup> <ItemGroup> diff --git a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs index 06060988e2..203e565ac7 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; - - if (string.Equals(codec, "aac", StringComparison.OrdinalIgnoreCase) - || string.Equals(codec, "mp3", StringComparison.OrdinalIgnoreCase)) - { - 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; - } - } + // 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, "flac", StringComparison.OrdinalIgnoreCase) - || string.Equals(codec, "alac", StringComparison.OrdinalIgnoreCase)) + return codec.ToLowerInvariant() switch { - 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 @@ -972,10 +974,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 +1264,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 +1641,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 +1775,8 @@ namespace MediaBrowser.MediaEncoding.Probing [GeneratedRegex("(?<name>.*) \\((?<instrument>.*)\\)")] 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 /// </summary> /// <param name="stream">The stream.</param> /// <param name="fileExtension">The file extension.</param> - /// <returns>SubtitleTrackInfo.</returns> - SubtitleTrackInfo Parse(Stream stream, string fileExtension); + /// <returns>The parsed subtitle.</returns> + Subtitle Parse(Stream stream, string fileExtension); /// <summary> /// 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 } /// <inheritdoc /> - 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; } /// <inheritdoc /> 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<Stream> GetSubtitleStream(SubtitleInfo fileInfo, CancellationToken cancellationToken) + internal async Task<Stream> 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) + var stream = fileInfo.Protocol == MediaProtocol.Http + ? await _httpClientFactory.CreateClient(NamedClient.Default) + .GetStreamAsync(new Uri(fileInfo.Path), cancellationToken) + .ConfigureAwait(false) + : AsyncFile.OpenRead(fileInfo.Path); + + // 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)) { - _logger.LogDebug("charset {CharSet} detected for {Path}", detected.EncodingName, fileInfo.Path); + return stream; + } - using var stream = await _httpClientFactory.CreateClient(NamedClient.Default) - .GetStreamAsync(new Uri(fileInfo.Path), cancellationToken) - .ConfigureAwait(false); + _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); + 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)); - } + return new MemoryStream(Encoding.UTF8.GetBytes(text)); } } @@ -445,98 +453,15 @@ namespace MediaBrowser.MediaEncoding.Subtitles encodingParam = " -sub_charenc " + encodingParam; } - int exitCode; - - 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; + var args = string.Format(CultureInfo.InvariantCulture, "-y {0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, inputPath, outputPath); - 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<string>(); 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<string> outputPaths, + IReadOnlyList<string> 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); + } + + /// <summary> + /// Runs ffmpeg to extract or convert subtitles, capturing its exit code and stderr output. + /// </summary> + /// <remarks> + /// stdin is redirected and closed, and <c>-nostdin</c> 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. + /// </remarks> + /// <param name="arguments">The ffmpeg command line arguments.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>The ffmpeg exit code (-1 on timeout) and its captured stderr output.</returns> + 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); - } - catch (FileNotFoundException) - { - } - 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) - { + standardError = await standardErrorTask.ConfigureAwait(false); } - catch (IOException ex) + catch (OperationCanceledException) { - _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); } /// <summary> @@ -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<DetectionResult> DetectCharset(string path, MediaProtocol protocol, CancellationToken cancellationToken) + private async Task<DetectionResult> 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 { + /// <summary> + /// The convention used for naming saved images. + /// </summary> public enum ImageSavingConvention { + /// <summary> + /// The legacy naming convention. + /// </summary> Legacy, + + /// <summary> + /// The naming convention compatible with other media servers and metadata managers. + /// </summary> 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 { + /// <summary> + /// The codec type of a codec profile. + /// </summary> public enum CodecType { + /// <summary> + /// The profile applies to a video codec. + /// </summary> Video = 0, + + /// <summary> + /// The profile applies to the audio codec of a video stream. + /// </summary> VideoAudio = 1, + + /// <summary> + /// The profile applies to an audio codec. + /// </summary> 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 { + /// <summary> + /// The encoding context. + /// </summary> public enum EncodingContext { + /// <summary> + /// The media is transcoded on the fly and delivered as a stream. + /// </summary> Streaming = 0, + + /// <summary> + /// The media is transcoded to a static file. + /// </summary> 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 { + /// <summary> + /// The playback error code. + /// </summary> public enum PlaybackErrorCode { + /// <summary> + /// Playback of the item is not allowed. + /// </summary> NotAllowed = 0, + + /// <summary> + /// No stream compatible with the device profile was found. + /// </summary> NoCompatibleStream = 1, + + /// <summary> + /// The rate limit has been exceeded. + /// </summary> 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 { + /// <summary> + /// The resolution constraints. + /// </summary> public class ResolutionOptions { + /// <summary> + /// Gets or sets the maximum width. + /// </summary> public int? MaxWidth { get; set; } + /// <summary> + /// Gets or sets the maximum height. + /// </summary> public int? MaxHeight { get; set; } } } diff --git a/MediaBrowser.Model/Dlna/StreamBuilder.cs b/MediaBrowser.Model/Dlna/StreamBuilder.cs index d875bbe8ed..59f97d8c7c 100644 --- a/MediaBrowser.Model/Dlna/StreamBuilder.cs +++ b/MediaBrowser.Model/Dlna/StreamBuilder.cs @@ -951,6 +951,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 +1003,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; 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 { + /// <summary> + /// The transcode seek info. + /// </summary> public enum TranscodeSeekInfo { + /// <summary> + /// The seek method is chosen automatically. + /// </summary> Auto = 0, + + /// <summary> + /// Seeking is performed by byte position. + /// </summary> 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 { + /// <summary> + /// Interface for DTOs that reference the id of the server they originate from. + /// </summary> public interface IHasServerId { + /// <summary> + /// Gets the server id. + /// </summary> 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 { + /// <summary> + /// The type of a media source. + /// </summary> public enum MediaSourceType { + /// <summary> + /// A default media source. + /// </summary> Default = 0, + + /// <summary> + /// A grouping of media sources. + /// </summary> Grouping = 1, + + /// <summary> + /// A placeholder media source, for example a disc that has to be inserted. + /// </summary> 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 { + /// <summary> + /// The type of a community rating. + /// </summary> public enum RatingType { + /// <summary> + /// The rating is a numeric score. + /// </summary> Score, + + /// <summary> + /// The rating is based on likes. + /// </summary> Likes } } 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 { + /// <summary> + /// The play access of an item. + /// </summary> public enum PlayAccess { + /// <summary> + /// The item can be played. + /// </summary> Full = 0, + + /// <summary> + /// The item cannot be played. + /// </summary> 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 { + /// <summary> + /// The day pattern of a recurring timer. + /// </summary> public enum DayPattern { + /// <summary> + /// Every day. + /// </summary> Daily, + + /// <summary> + /// Monday through Friday. + /// </summary> Weekdays, + + /// <summary> + /// Saturday and Sunday. + /// </summary> 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 { + /// <summary> + /// The status of a live TV service. + /// </summary> public enum LiveTvServiceStatus { + /// <summary> + /// The service is available. + /// </summary> Ok = 0, + + /// <summary> + /// The service is unavailable. + /// </summary> 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 { + /// <summary> + /// The type of timestamps used in a transport stream. + /// </summary> public enum TransportStreamTimestamp { + /// <summary> + /// The stream contains no timestamps. + /// </summary> None, + + /// <summary> + /// The stream contains zero-value timestamps. + /// </summary> Zero, + + /// <summary> + /// The stream contains valid timestamps. + /// </summary> 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 { + /// <summary> + /// A command to display a message on a client. + /// </summary> public class MessageCommand { + /// <summary> + /// Gets or sets the message header. + /// </summary> public string Header { get; set; } + /// <summary> + /// Gets or sets the message text. + /// </summary> [Required(AllowEmptyStrings = false)] public string Text { get; set; } + /// <summary> + /// Gets or sets the timeout in milliseconds after which the message should be dismissed. + /// </summary> 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 { + /// <summary> + /// The play method. + /// </summary> public enum PlayMethod { + /// <summary> + /// The media is transcoded before it is sent to the client. + /// </summary> Transcode = 0, + + /// <summary> + /// The media is remuxed into a compatible container but the streams are not re-encoded. + /// </summary> DirectStream = 1, + + /// <summary> + /// The media is sent to the client as-is. + /// </summary> 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 { + /// <summary> + /// A request to change the playstate of a session. + /// </summary> public class PlaystateRequest { + /// <summary> + /// Gets or sets the playstate command. + /// </summary> public PlaystateCommand Command { get; set; } + /// <summary> + /// Gets or sets the seek position in ticks. + /// </summary> public long? SeekPositionTicks { get; set; } /// <summary> 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; +/// <summary> +/// An item in a play queue. +/// </summary> public record QueueItem { + /// <summary> + /// Gets or sets the item id. + /// </summary> public Guid Id { get; set; } + /// <summary> + /// Gets or sets the playlist item id. + /// </summary> 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 { + /// <summary> + /// The repeat mode of a play queue. + /// </summary> public enum RepeatMode { + /// <summary> + /// Nothing is repeated. + /// </summary> RepeatNone = 0, + + /// <summary> + /// The whole queue is repeated. + /// </summary> RepeatAll = 1, + + /// <summary> + /// The current item is repeated. + /// </summary> RepeatOne = 2 } } diff --git a/MediaBrowser.Providers/Books/ComicServiceRegistrator.cs b/MediaBrowser.Providers/Books/ComicServiceRegistrator.cs deleted file mode 100644 index 0d096241d6..0000000000 --- a/MediaBrowser.Providers/Books/ComicServiceRegistrator.cs +++ /dev/null @@ -1,23 +0,0 @@ -using MediaBrowser.Controller; -using MediaBrowser.Controller.Plugins; -using MediaBrowser.Providers.Books.ComicBookInfo; -using MediaBrowser.Providers.Books.ComicInfo; -using Microsoft.Extensions.DependencyInjection; - -namespace MediaBrowser.Providers.Books; - -/// <inheritdoc /> -public class ComicServiceRegistrator : IPluginServiceRegistrator -{ - /// <inheritdoc /> - public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost) - { - // 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>(); - } -} 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. /// </summary> /// <typeparam name="TCategoryName">The type of category.</typeparam> - public class OpfReader<TCategoryName> + public partial class OpfReader<TCategoryName> { 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(); + /// <summary> /// Checks for the existence of a cover image. /// </summary> @@ -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) }; - - book.AddPerson(person); + 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}"; + } + + 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<IImageProvider> GetNonLocalImageProviders(BaseItem item, IEnumerable<IImageProvider> allImageProviders, ImageRefreshOptions options) + protected virtual IEnumerable<IImageProvider> GetNonLocalImageProviders(BaseItem item, IEnumerable<IImageProvider> 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<IRemoteMetadataProvider<TItemType, TIdType>>(), cancellationToken) - .ConfigureAwait(false); + var remoteProviders = providers.OfType<IRemoteMetadataProvider<TItemType, TIdType>>(); + + // 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 df51dd8421..2b0f480b1c 100644 --- a/MediaBrowser.Providers/MediaBrowser.Providers.csproj +++ b/MediaBrowser.Providers/MediaBrowser.Providers.csproj @@ -21,6 +21,7 @@ <PackageReference Include="Microsoft.Extensions.Caching.Abstractions" /> <PackageReference Include="Microsoft.Extensions.Http" /> <PackageReference Include="Newtonsoft.Json" /> + <PackageReference Include="PDFtoImage" /> <PackageReference Include="PlaylistsNET" /> <PackageReference Include="SharpCompress" /> <PackageReference Include="z440.atl.core" /> 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<Video>, ICustomMetadataProvider<Audio>, ICustomMetadataProvider<AudioBook>, + ICustomMetadataProvider<Book>, IHasOrder, IForcedProvider, IPreRefreshProvider, @@ -214,6 +217,57 @@ namespace MediaBrowser.Providers.MediaInfo return FetchAudioInfo(item, options, cancellationToken); } + /// <inheritdoc /> + public Task<ItemUpdateType> FetchAsync(Book item, MetadataRefreshOptions options, CancellationToken cancellationToken) + { + if (item.IsVirtualItem || !item.IsFileProtocol) + { + return _cachedTask; + } + + long pageCount; + switch (Path.GetExtension(item.Path).ToLowerInvariant()) + { + case ".cb7": + case ".cbr": + case ".cbt": + case ".cbz": + using (var stream = File.OpenRead(item.Path)) + using (var archive = ArchiveFactory.OpenArchive(stream)) + { + pageCount = archive.Entries.Count(e => !e.IsDirectory); + } + + break; + +#pragma warning disable CA1416 + case ".pdf": + using (var stream = File.OpenRead(item.Path)) + { + pageCount = Conversion.GetPageCount(stream); + } + + break; +#pragma warning restore CA1416 + + case ".epub": + // TODO process CFI and store as a string when multiple progress types are supported + // current progress value is percentage stored as a proportion of one second worth of ticks + item.RunTimeTicks = TimeSpan.TicksPerSecond; + + return Task.FromResult(ItemUpdateType.MetadataImport); + + default: + return _cachedTask; + } + + // TODO use page count without modification when multiple progress types are supported + // book players report page count and the web client multiplies that value by 10000 to convert the expected milliseconds into ticks + item.RunTimeTicks = pageCount * 10000; + + return Task.FromResult(ItemUpdateType.MetadataImport); + } + /// <summary> /// Fetches video information for an item. /// </summary> diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs index f0e159f098..21b822c97c 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs @@ -181,7 +181,9 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV ParentIndexNumber = seasonNumber, IndexNumberEnd = info.IndexNumberEnd, Name = episodeResult.Name, - PremiereDate = episodeResult.AirDate, + PremiereDate = episodeResult.AirDate.HasValue + ? DateTime.SpecifyKind(episodeResult.AirDate.Value, DateTimeKind.Local).ToUniversalTime() + : null, ProductionYear = episodeResult.AirDate?.Year, Overview = episodeResult.Overview, CommunityRating = Convert.ToSingle(episodeResult.VoteAverage) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs index 4229a1dc82..9bb15ca479 100644..100755 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs @@ -256,11 +256,20 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV series.Overview = seriesResult.Overview; + var studios = Enumerable.Empty<string>(); + if (seriesResult.Networks is not null) { - series.Studios = seriesResult.Networks.Select(i => i.Name).ToArray(); + studios = studios.Concat(seriesResult.Networks.Select(i => i.Name).OfType<string>()); + } + + if (seriesResult.ProductionCompanies is not null) + { + studios = studios.Concat(seriesResult.ProductionCompanies.Select(i => i.Name).OfType<string>()); } + series.SetStudios(studios); + if (seriesResult.Genres is not null) { series.Genres = seriesResult.Genres.Select(i => i.Name).ToArray(); @@ -430,6 +439,31 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV yield return personInfo; } } + + if (seriesResult.CreatedBy is not null) + { + foreach (var person in seriesResult.CreatedBy) + { + if (string.IsNullOrWhiteSpace(person.Name)) + { + continue; + } + + var personInfo = new PersonInfo + { + Name = person.Name.Trim(), + Type = PersonKind.Creator, + ImageUrl = _tmdbClientManager.GetProfileUrl(person.ProfilePath) + }; + + if (person.Id > 0) + { + personInfo.SetProviderId(MetadataProvider.Tmdb, person.Id.ToString(CultureInfo.InvariantCulture)); + } + + yield return personInfo; + } + } } /// <inheritdoc /> diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs index 39c0497bed..7e6b9beee9 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Frozen; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Text.RegularExpressions; @@ -48,6 +49,16 @@ namespace MediaBrowser.Providers.Plugins.Tmdb PersonKind.Producer }; + /// <summary> + /// Writing jobs to keep. + /// </summary> + private static readonly FrozenSet<string> _writerJobs = new[] + { + "writer", + "screenplay", + "novel" + }.ToFrozenSet(StringComparer.OrdinalIgnoreCase); + [GeneratedRegex(@"[\W_-[·]]+")] private static partial Regex NonWordRegex(); @@ -82,7 +93,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb } if (string.Equals(crew.Department, "writing", StringComparison.OrdinalIgnoreCase) - && (string.Equals(crew.Job, "writer", StringComparison.OrdinalIgnoreCase) || string.Equals(crew.Job, "screenplay", StringComparison.OrdinalIgnoreCase))) + && crew.Job is not null && _writerJobs.Contains(crew.Job)) { return PersonKind.Writer; } diff --git a/MediaBrowser.Providers/TV/SeriesMetadataService.cs b/MediaBrowser.Providers/TV/SeriesMetadataService.cs index 02040653d1..b350f482c3 100644 --- a/MediaBrowser.Providers/TV/SeriesMetadataService.cs +++ b/MediaBrowser.Providers/TV/SeriesMetadataService.cs @@ -78,11 +78,73 @@ public class SeriesMetadataService : MetadataService<Series, SeriesInfo> { await base.AfterMetadataRefresh(item, refreshOptions, cancellationToken).ConfigureAwait(false); + // Note that this only updates the children's SeriesPresentationUniqueKey and SeasonId, not the ParentIndexNumber + if (LibraryManager.GetLibraryOptions(item).EnableAutomaticSeriesGrouping) + { + await UpdateSeriesChildrenInfoAsync(item, cancellationToken).ConfigureAwait(false); + } + RemoveObsoleteEpisodes(item); RemoveObsoleteSeasons(item); await CreateSeasonsAsync(item, cancellationToken).ConfigureAwait(false); } + /// <summary> + /// Reconciles seasons and episodes with the series' finalized state. + /// </summary> + /// <remarks> + /// The series' presentation unique key can change during a refresh once provider ids become + /// available - notably with <c>EnableAutomaticSeriesGrouping</c>, where the key is derived from + /// the provider id and the owning libraries instead of the (immutable) item id. Seasons and + /// episodes cache this value in <see cref="IHasSeries.SeriesPresentationUniqueKey"/> and are + /// matched to (and displayed under) the series by it, so any child left with a stale key - or an + /// episode not yet linked to a freshly created season - stays hidden until a later scan. Syncing + /// them against the series here lets everything appear within a single scan. + /// </remarks> + /// <param name="series">The series.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>The async task.</returns> + private async Task UpdateSeriesChildrenInfoAsync(Series series, CancellationToken cancellationToken) + { + // Reload children so episode numbers / seasons persisted earlier in the refresh are seen. + series.Children = null; + var seriesKey = series.GetPresentationUniqueKey(); + var children = series.GetRecursiveChildren(i => i is Season || i is Episode); + var seasons = children.OfType<Season>().ToList(); + + foreach (var child in children) + { + var updateType = ItemUpdateType.None; + + if (child is IHasSeries hasSeries + && !string.Equals(hasSeries.SeriesPresentationUniqueKey, seriesKey, StringComparison.Ordinal)) + { + hasSeries.SeriesPresentationUniqueKey = seriesKey; + updateType |= ItemUpdateType.MetadataImport; + } + + if (child is Episode episode) + { + var seasonId = episode.FindSeasonId(); + if (seasonId.IsEmpty() && episode.ParentIndexNumber.HasValue) + { + seasonId = seasons.Find(s => s.IndexNumber == episode.ParentIndexNumber)?.Id ?? Guid.Empty; + } + + if (!seasonId.IsEmpty() && !episode.SeasonId.Equals(seasonId)) + { + episode.SeasonId = seasonId; + updateType |= ItemUpdateType.MetadataImport; + } + } + + if (updateType > ItemUpdateType.None) + { + await child.UpdateToRepositoryAsync(updateType, cancellationToken).ConfigureAwait(false); + } + } + } + /// <inheritdoc /> protected override void MergeData(MetadataResult<Series> source, MetadataResult<Series> target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings) { @@ -235,6 +297,28 @@ public class SeriesMetadataService : MetadataService<Series, SeriesInfo> private async Task CreateSeasonsAsync(Series series, CancellationToken cancellationToken) { var seriesChildren = series.GetRecursiveChildren(i => i is Episode || i is Season); + + // CreateSeasonsAsync can run before the episodes themselves have been refreshed during an + // initial scan, so their ParentIndexNumber may still be unset. Resolve the season number + // from the path first to avoid creating a premature "Season Unknown" instead of the real + // season for episodes that live directly in a flat series folder. + foreach (var episode in seriesChildren.OfType<Episode>()) + { + if (episode.ParentIndexNumber.HasValue) + { + continue; + } + + try + { + LibraryManager.FillMissingEpisodeNumbersFromPath(episode, false); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error resolving season number from path for {Path}", episode.Path); + } + } + var seasons = seriesChildren.OfType<Season>().ToList(); var episodes = seriesChildren.OfType<Episode>().ToList(); @@ -7,34 +7,16 @@ <img alt="Logo Banner" src="https://raw.githubusercontent.com/jellyfin/jellyfin-ux/master/branding/SVG/banner-logo-solid.svg?sanitize=true"/> <br/> <br/> -<a href="https://github.com/jellyfin/jellyfin"> -<img alt="GPL 2.0 License" src="https://img.shields.io/github/license/jellyfin/jellyfin.svg"/> -</a> -<a href="https://github.com/jellyfin/jellyfin/releases"> -<img alt="Current Release" src="https://img.shields.io/github/release/jellyfin/jellyfin.svg"/> -</a> -<a href="https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/?utm_source=widget"> -<img alt="Translation Status" src="https://translate.jellyfin.org/widgets/jellyfin/-/jellyfin-core/svg-badge.svg"/> -</a> -<a href="https://hub.docker.com/r/jellyfin/jellyfin"> -<img alt="Docker Pull Count" src="https://img.shields.io/docker/pulls/jellyfin/jellyfin.svg"/> -</a> +<a href="https://github.com/jellyfin/jellyfin"><img alt="GPL 2.0 License" src="https://img.shields.io/github/license/jellyfin/jellyfin.svg"/></a> +<a href="https://github.com/jellyfin/jellyfin/releases"><img alt="Current Release" src="https://img.shields.io/github/release/jellyfin/jellyfin.svg"/></a> +<a href="https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/?utm_source=widget"><img alt="Translation Status" src="https://translate.jellyfin.org/widgets/jellyfin/-/jellyfin-core/svg-badge.svg"/></a> +<a href="https://hub.docker.com/r/jellyfin/jellyfin"><img alt="Docker Pull Count" src="https://img.shields.io/docker/pulls/jellyfin/jellyfin.svg"/></a> <br/> -<a href="https://opencollective.com/jellyfin"> -<img alt="Donate" src="https://img.shields.io/opencollective/all/jellyfin.svg?label=backers"/> -</a> -<a href="https://features.jellyfin.org"> -<img alt="Submit Feature Requests" src="https://img.shields.io/badge/fider-vote%20on%20features-success.svg"/> -</a> -<a href="https://matrix.to/#/#jellyfinorg:matrix.org"> -<img alt="Chat on Matrix" src="https://img.shields.io/matrix/jellyfinorg:matrix.org.svg?logo=matrix"/> -</a> -<a href="https://github.com/jellyfin/jellyfin/releases.atom"> -<img alt="Release RSS Feed" src="https://img.shields.io/badge/rss-releases-ffa500?logo=rss" /> -</a> -<a href="https://github.com/jellyfin/jellyfin/commits/master.atom"> -<img alt="Master Commits RSS Feed" src="https://img.shields.io/badge/rss-commits-ffa500?logo=rss" /> -</a> +<a href="https://opencollective.com/jellyfin"><img alt="Donate" src="https://img.shields.io/opencollective/all/jellyfin.svg?label=backers"/></a> +<a href="https://features.jellyfin.org"><img alt="Submit Feature Requests" src="https://img.shields.io/badge/fider-vote%20on%20features-success.svg"/></a> +<a href="https://matrix.to/#/#jellyfinorg:matrix.org"><img alt="Chat on Matrix" src="https://img.shields.io/matrix/jellyfinorg:matrix.org.svg?logo=matrix"/></a> +<a href="https://github.com/jellyfin/jellyfin/releases.atom"><img alt="Release RSS Feed" src="https://img.shields.io/badge/rss-releases-ffa500?logo=rss" /></a> +<a href="https://github.com/jellyfin/jellyfin/commits/master.atom"><img alt="Master Commits RSS Feed" src="https://img.shields.io/badge/rss-commits-ffa500?logo=rss" /></a> </p> --- diff --git a/src/Jellyfin.Extensions/PathHelper.cs b/src/Jellyfin.Extensions/PathHelper.cs new file mode 100644 index 0000000000..ab74a7749d --- /dev/null +++ b/src/Jellyfin.Extensions/PathHelper.cs @@ -0,0 +1,77 @@ +using System; +using System.IO; + +namespace Jellyfin.Extensions; + +/// <summary> +/// Helpers for safely composing filesystem paths from untrusted input. +/// </summary> +/// <remarks> +/// <see cref="Path.Combine(string, string)"/> has two issues that matter in +/// any code that joins a trusted directory with an externally-supplied name: +/// it neither normalises <c>..</c> nor rejects a rooted second argument +/// (a rooted second arg silently discards the first). Use the helpers below +/// any time the name comes from media metadata, request input, archive +/// entries, or any other channel that can be influenced by a third party. +/// </remarks> +public static class PathHelper +{ + /// <summary> + /// Reduces a possibly-untrusted file name to a safe leaf-only name with no + /// directory components. + /// </summary> + /// <param name="fileName">The candidate file name.</param> + /// <returns> + /// The leaf component of <paramref name="fileName"/>, or <c>null</c> if + /// the input has no usable leaf (empty, <c>.</c>, or <c>..</c>). + /// </returns> + public static string? GetSafeLeafFileName(string? fileName) + { + if (string.IsNullOrEmpty(fileName)) + { + return null; + } + + var leaf = Path.GetFileName(fileName); + if (string.IsNullOrEmpty(leaf) || string.Equals(leaf, ".", StringComparison.Ordinal) || string.Equals(leaf, "..", StringComparison.Ordinal)) + { + return null; + } + + return leaf; + } + + /// <summary> + /// Returns whether <paramref name="candidate"/> resolves to a path that + /// equals or is contained inside <paramref name="root"/>. + /// </summary> + /// <param name="root">The directory the candidate must remain inside.</param> + /// <param name="candidate">The candidate absolute or relative path.</param> + /// <returns><c>true</c> if the candidate is inside or equal to root; otherwise <c>false</c>.</returns> + /// <remarks> + /// Both arguments are resolved via <see cref="Path.GetFullPath(string)"/> + /// so <c>..</c> segments are collapsed before the comparison. The root is + /// compared with a trailing directory separator to prevent prefix + /// collisions (e.g. <c>/var/data</c> must not be accepted as a parent of + /// <c>/var/dataset</c>). + /// </remarks> + public static bool IsContainedIn(string root, string candidate) + { + ArgumentException.ThrowIfNullOrEmpty(root); + ArgumentException.ThrowIfNullOrEmpty(candidate); + + var fullRoot = Path.GetFullPath(root); + var fullCandidate = Path.GetFullPath(candidate); + + if (string.Equals(fullCandidate, fullRoot, StringComparison.Ordinal)) + { + return true; + } + + var rootWithSep = fullRoot.EndsWith(Path.DirectorySeparatorChar) + ? fullRoot + : fullRoot + Path.DirectorySeparatorChar; + + return fullCandidate.StartsWith(rootWithSep, StringComparison.Ordinal); + } +} diff --git a/src/Jellyfin.LiveTv/Channels/ChannelManager.cs b/src/Jellyfin.LiveTv/Channels/ChannelManager.cs index ed02fe6a1d..e421601092 100644 --- a/src/Jellyfin.LiveTv/Channels/ChannelManager.cs +++ b/src/Jellyfin.LiveTv/Channels/ChannelManager.cs @@ -14,6 +14,7 @@ using Jellyfin.Database.Implementations.Entities; using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using Jellyfin.Extensions.Json; +using Jellyfin.LiveTv; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Configuration; @@ -1109,9 +1110,8 @@ namespace Jellyfin.LiveTv.Channels item.Path = mediaSource?.Path; } - if (!string.IsNullOrEmpty(info.ImageUrl) && !item.HasImage(ImageType.Primary)) + if (LiveTvChannelImageHelper.UpdateChannelImageIfNeeded(item, null, info.ImageUrl)) { - item.SetImagePath(ImageType.Primary, info.ImageUrl); _logger.LogDebug("Forcing update due to ImageUrl {0}", item.Name); forceUpdate = true; } diff --git a/src/Jellyfin.LiveTv/Guide/GuideManager.cs b/src/Jellyfin.LiveTv/Guide/GuideManager.cs index c3cc70381e..4e1b62cdf9 100644 --- a/src/Jellyfin.LiveTv/Guide/GuideManager.cs +++ b/src/Jellyfin.LiveTv/Guide/GuideManager.cs @@ -5,7 +5,9 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; using Jellyfin.Extensions; +using Jellyfin.LiveTv; using Jellyfin.LiveTv.Configuration; +using Jellyfin.LiveTv.Listings; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -448,23 +450,9 @@ public class GuideManager : IGuideManager item.Name = channelInfo.Name; - var currentPrimary = item.GetImageInfo(ImageType.Primary, 0); - var imageUrlIsNull = string.IsNullOrWhiteSpace(channelInfo.ImageUrl); - - // Update channel image if image URL has changed - if (currentPrimary is null - || (!imageUrlIsNull && !string.Equals(currentPrimary.Path, channelInfo.ImageUrl, StringComparison.Ordinal))) + if (LiveTvChannelImageHelper.UpdateChannelImageIfNeeded(item, channelInfo.ImagePath, channelInfo.ImageUrl)) { - if (!string.IsNullOrWhiteSpace(channelInfo.ImagePath)) - { - item.SetImagePath(ImageType.Primary, channelInfo.ImagePath); - forceUpdate = true; - } - else if (!imageUrlIsNull) - { - item.SetImagePath(ImageType.Primary, channelInfo.ImageUrl); - forceUpdate = true; - } + forceUpdate = true; } if (isNew) @@ -499,8 +487,13 @@ public class GuideManager : IGuideManager DateCreated = DateTime.UtcNow, DateModified = DateTime.UtcNow }; - - item.TrySetProviderId(EtagKey, info.Etag); + } + else if (XmlTvProgramEtag.MatchesStored(info.Etag, item.GetProviderId(EtagKey))) + { + // XMLTV ETags are generated from the final ProgramInfo fields Jellyfin consumes, + // so an exact match means nothing relevant changed. Other providers stay on the + // field-by-field update path. + return (item, false, false); } if (!string.Equals(info.ShowId, item.ShowId, StringComparison.OrdinalIgnoreCase)) @@ -626,13 +619,9 @@ public class GuideManager : IGuideManager forceUpdate |= UpdateImages(item, info); - if (isNew) - { - item.OnMetadataChanged(); - - return (item, true, false); - } - + // Restore the etag wiped by `item.ProviderIds = info.ProviderIds` above and + // persist it on new items so they join the fast path on the next refresh + // instead of taking an extra full processing cycle. var isUpdated = forceUpdate; var etag = info.Etag; if (string.IsNullOrWhiteSpace(etag)) @@ -645,6 +634,13 @@ public class GuideManager : IGuideManager isUpdated = true; } + if (isNew) + { + item.OnMetadataChanged(); + + return (item, true, false); + } + if (isUpdated) { item.OnMetadataChanged(); diff --git a/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs b/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs index d877a0d124..19c4514766 100644 --- a/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs +++ b/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs @@ -83,6 +83,7 @@ namespace Jellyfin.LiveTv.IO CreateNoWindow = true, UseShellExecute = false, + StandardErrorEncoding = Encoding.UTF8, RedirectStandardError = true, RedirectStandardInput = true, diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs index c1ccb24bf4..c93d1f039c 100644 --- a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs @@ -491,6 +491,12 @@ namespace Jellyfin.LiveTv.Listings var results = new List<ShowImagesDto>(); for (int i = 0; i < programIds.Count; i += BatchSize) { + // The daily image limit may be surfaced mid-batch. + if (IsImageDailyLimitActive()) + { + break; + } + var batch = programIds.Skip(i).Take(BatchSize); using var message = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/metadata/programs/"); @@ -511,6 +517,18 @@ namespace Jellyfin.LiveTv.Listings entry.ProgramId, entry.Code, entry.Message); + + // The image download limit can be reported per-entry inside an + // otherwise successful (HTTP 200) response when the limit is hit + // mid-batch. Back off so we stop requesting images until SD resets. + if (entry.Code is (int)SdErrorCode.MaxImageDownloads or (int)SdErrorCode.MaxImageDownloadsTrial) + { + _logger.LogError( + "Schedules Direct image download limit hit (code {Code}). Disabling image acquisition until SD reset.", + entry.Code); + SetImageLimitHit(); + } + continue; } @@ -748,9 +766,7 @@ namespace Jellyfin.LiveTv.Listings #pragma warning disable CA5350 // SchedulesDirect is always SHA1. var hashedPasswordBytes = SHA1.HashData(Encoding.ASCII.GetBytes(password)); #pragma warning restore CA5350 - // TODO: remove ToLower when Convert.ToHexString supports lowercase - // Schedules Direct requires the hex to be lowercase - string hashedPassword = Convert.ToHexString(hashedPasswordBytes).ToLowerInvariant(); + string hashedPassword = Convert.ToHexStringLower(hashedPasswordBytes); options.Content = new StringContent("{\"username\":\"" + username + "\",\"password\":\"" + hashedPassword + "\"}", Encoding.UTF8, MediaTypeNames.Application.Json); var root = await Request<TokenDto>(options, false, null, cancellationToken).ConfigureAwait(false); diff --git a/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs b/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs index 7088d1f0bf..0aeb7ad05d 100644 --- a/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs +++ b/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs @@ -173,7 +173,29 @@ namespace Jellyfin.LiveTv.Listings var reader = new XmlTvReader(path, GetLanguage(info)); return reader.GetProgrammes(channelId, startDateUtc, endDateUtc, cancellationToken) - .Select(p => GetProgramInfo(p, info)); + .Select(p => GetProgramInfoWithEtag(p, info)); + } + + private ProgramInfo GetProgramInfoWithEtag(XmlTvProgram program, ListingsProviderInfo info) + { + var programInfo = GetProgramInfo(program, info); + + if (XmlTvProgramEtag.TryCreate(programInfo, out var etag, out var reason)) + { + programInfo.Etag = etag; + } + else + { + _logger.LogDebug( + "Unable to create XMLTV program ETag for program {ProgramId} on channel {ChannelId} from {StartDate} to {EndDate}: {Reason}. The program will be treated as updated on each guide refresh.", + programInfo.Id, + programInfo.ChannelId, + programInfo.StartDate, + programInfo.EndDate, + reason); + } + + return programInfo; } private static ProgramInfo GetProgramInfo(XmlTvProgram program, ListingsProviderInfo info) diff --git a/src/Jellyfin.LiveTv/Listings/XmlTvProgramEtag.cs b/src/Jellyfin.LiveTv/Listings/XmlTvProgramEtag.cs new file mode 100644 index 0000000000..b5ddb1530f --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/XmlTvProgramEtag.cs @@ -0,0 +1,184 @@ +#pragma warning disable CS1591 + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using MediaBrowser.Controller.LiveTv; + +namespace Jellyfin.LiveTv.Listings +{ + internal static class XmlTvProgramEtag + { + internal const string Prefix = "xmltv-sha256-v1:"; + + internal static bool IsXmlTvEtag(string? etag) + => !string.IsNullOrWhiteSpace(etag) + && etag.StartsWith(Prefix, StringComparison.Ordinal); + + // Returns true only when the incoming etag is XMLTV-style AND equals the stored value. + // The IsXmlTvEtag gate keeps other providers (e.g. Schedules Direct) on the + // field-by-field update path even if their etag strings happen to match. + internal static bool MatchesStored(string? incomingEtag, string? storedEtag) + => IsXmlTvEtag(incomingEtag) + && string.Equals(incomingEtag, storedEtag, StringComparison.OrdinalIgnoreCase); + + internal static bool TryCreate(ProgramInfo programInfo, out string? etag, out string? reason) + { + etag = null; + + if (string.IsNullOrWhiteSpace(programInfo.Id)) + { + reason = "program id is empty"; + return false; + } + + if (string.IsNullOrWhiteSpace(programInfo.ChannelId)) + { + reason = "channel id is empty"; + return false; + } + + if (programInfo.StartDate == default) + { + reason = "start date is empty"; + return false; + } + + if (programInfo.EndDate == default) + { + reason = "end date is empty"; + return false; + } + + if (programInfo.EndDate <= programInfo.StartDate) + { + reason = "end date is not after start date"; + return false; + } + + var builder = new StringBuilder(1024); + + // Keep this list aligned with the ProgramInfo fields consumed by GuideManager. + AppendValue(builder, "schema", "xmltv-programinfo-v1"); + AppendValue(builder, nameof(programInfo.Id), programInfo.Id); + AppendValue(builder, nameof(programInfo.ChannelId), programInfo.ChannelId); + AppendValue(builder, nameof(programInfo.Name), programInfo.Name); + AppendValue(builder, nameof(programInfo.OfficialRating), programInfo.OfficialRating); + AppendValue(builder, nameof(programInfo.Overview), programInfo.Overview); + AppendValue(builder, nameof(programInfo.StartDate), programInfo.StartDate); + AppendValue(builder, nameof(programInfo.EndDate), programInfo.EndDate); + AppendList(builder, nameof(programInfo.Genres), programInfo.Genres); + AppendValue(builder, nameof(programInfo.OriginalAirDate), programInfo.OriginalAirDate); + AppendValue(builder, nameof(programInfo.IsHD), programInfo.IsHD); + AppendValue(builder, nameof(programInfo.Audio), programInfo.Audio?.ToString()); + AppendValue(builder, nameof(programInfo.CommunityRating), programInfo.CommunityRating); + AppendValue(builder, nameof(programInfo.IsRepeat), programInfo.IsRepeat); + AppendValue(builder, nameof(programInfo.EpisodeTitle), programInfo.EpisodeTitle); + AppendValue(builder, nameof(programInfo.ImagePath), programInfo.ImagePath); + AppendValue(builder, nameof(programInfo.ImageUrl), programInfo.ImageUrl); + AppendValue(builder, nameof(programInfo.ThumbImageUrl), programInfo.ThumbImageUrl); + AppendValue(builder, nameof(programInfo.LogoImageUrl), programInfo.LogoImageUrl); + AppendValue(builder, nameof(programInfo.BackdropImageUrl), programInfo.BackdropImageUrl); + AppendValue(builder, nameof(programInfo.IsMovie), programInfo.IsMovie); + AppendValue(builder, nameof(programInfo.IsSports), programInfo.IsSports); + AppendValue(builder, nameof(programInfo.IsSeries), programInfo.IsSeries); + AppendValue(builder, nameof(programInfo.IsLive), programInfo.IsLive); + AppendValue(builder, nameof(programInfo.IsNews), programInfo.IsNews); + AppendValue(builder, nameof(programInfo.IsKids), programInfo.IsKids); + AppendValue(builder, nameof(programInfo.IsPremiere), programInfo.IsPremiere); + AppendValue(builder, nameof(programInfo.ProductionYear), programInfo.ProductionYear); + AppendValue(builder, nameof(programInfo.SeriesId), programInfo.SeriesId); + AppendValue(builder, nameof(programInfo.ShowId), programInfo.ShowId); + AppendValue(builder, nameof(programInfo.SeasonNumber), programInfo.SeasonNumber); + AppendValue(builder, nameof(programInfo.EpisodeNumber), programInfo.EpisodeNumber); + AppendDictionary(builder, nameof(programInfo.ProviderIds), programInfo.ProviderIds); + AppendDictionary(builder, nameof(programInfo.SeriesProviderIds), programInfo.SeriesProviderIds); + + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString())); + etag = Prefix + Convert.ToHexString(hash); + reason = null; + return true; + } + + private static void AppendValue(StringBuilder builder, string name, string? value) + { + builder.Append(name).Append('|'); + if (value is null) + { + builder.Append('N').Append("|0|"); + } + else + { + builder.Append('S') + .Append('|') + .Append(value.Length.ToString(CultureInfo.InvariantCulture)) + .Append('|') + .Append(value); + } + + builder.Append('\n'); + } + + private static void AppendValue(StringBuilder builder, string name, DateTime value) + => AppendValue(builder, name, FormatDateTime(value)); + + private static void AppendValue(StringBuilder builder, string name, DateTime? value) + => AppendValue(builder, name, value.HasValue ? FormatDateTime(value.Value) : null); + + private static void AppendValue(StringBuilder builder, string name, bool value) + => AppendValue(builder, name, value ? "true" : "false"); + + private static void AppendValue(StringBuilder builder, string name, bool? value) + => AppendValue(builder, name, value switch { true => "true", false => "false", null => null }); + + private static void AppendValue(StringBuilder builder, string name, int? value) + => AppendValue(builder, name, value?.ToString(CultureInfo.InvariantCulture)); + + private static void AppendValue(StringBuilder builder, string name, float? value) + => AppendValue(builder, name, value?.ToString("R", CultureInfo.InvariantCulture)); + + // Treat Unspecified as UTC so the etag does not vary with the server's local timezone. + private static string FormatDateTime(DateTime value) + { + var utc = value.Kind switch + { + DateTimeKind.Utc => value, + DateTimeKind.Unspecified => DateTime.SpecifyKind(value, DateTimeKind.Utc), + _ => value.ToUniversalTime(), + }; + + return utc.ToString("O", CultureInfo.InvariantCulture); + } + + private static void AppendList(StringBuilder builder, string name, IReadOnlyList<string> values) + { + AppendValue(builder, name + ".Count", values.Count.ToString(CultureInfo.InvariantCulture)); + for (var i = 0; i < values.Count; i++) + { + AppendValue(builder, $"{name}[{i}]", values[i]); + } + } + + private static void AppendDictionary(StringBuilder builder, string name, IReadOnlyDictionary<string, string?> values) + { + AppendValue(builder, name + ".Count", values.Count.ToString(CultureInfo.InvariantCulture)); + if (values.Count == 0) + { + return; + } + + var index = 0; + foreach (var (key, value) in values + .OrderBy(i => i.Key, StringComparer.OrdinalIgnoreCase) + .ThenBy(i => i.Key, StringComparer.Ordinal)) + { + AppendValue(builder, $"{name}[{index}].Key", key); + AppendValue(builder, $"{name}[{index}].Value", value); + index++; + } + } + } +} diff --git a/src/Jellyfin.LiveTv/LiveTvChannelImageHelper.cs b/src/Jellyfin.LiveTv/LiveTvChannelImageHelper.cs new file mode 100644 index 0000000000..a590193b5f --- /dev/null +++ b/src/Jellyfin.LiveTv/LiveTvChannelImageHelper.cs @@ -0,0 +1,33 @@ +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Entities; + +namespace Jellyfin.LiveTv; + +/// <summary> +/// Helpers for keeping Live TV channel icons in sync with guide data. +/// </summary> +internal static class LiveTvChannelImageHelper +{ + /// <summary> + /// Applies the channel icon from guide or tuner metadata. + /// Called on each guide refresh so remote icons are re-downloaded even when the URL is unchanged. + /// </summary> + /// <param name="item">The channel item.</param> + /// <param name="imagePath">The local image path from the tuner, if any.</param> + /// <param name="imageUrl">The remote image URL from the guide provider, if any.</param> + /// <returns><c>true</c> when the item image metadata was updated.</returns> + internal static bool UpdateChannelImageIfNeeded(BaseItem item, string? imagePath, string? imageUrl) + { + var newImageSource = !string.IsNullOrWhiteSpace(imagePath) + ? imagePath + : imageUrl; + + if (string.IsNullOrWhiteSpace(newImageSource)) + { + return false; + } + + item.SetImagePath(ImageType.Primary, newImageSource); + return true; + } +} diff --git a/src/Jellyfin.MediaEncoding.Keyframes/FfProbe/FfProbeKeyframeExtractor.cs b/src/Jellyfin.MediaEncoding.Keyframes/FfProbe/FfProbeKeyframeExtractor.cs index cbe97a8210..af868e4bd6 100644 --- a/src/Jellyfin.MediaEncoding.Keyframes/FfProbe/FfProbeKeyframeExtractor.cs +++ b/src/Jellyfin.MediaEncoding.Keyframes/FfProbe/FfProbeKeyframeExtractor.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.IO; +using System.Text; namespace Jellyfin.MediaEncoding.Keyframes.FfProbe; @@ -31,6 +32,7 @@ public static class FfProbeKeyframeExtractor CreateNoWindow = true, UseShellExecute = false, + StandardOutputEncoding = Encoding.UTF8, RedirectStandardOutput = true, WindowStyle = ProcessWindowStyle.Hidden, diff --git a/tests/Jellyfin.Api.Tests/Controllers/HlsSegmentControllerTests.cs b/tests/Jellyfin.Api.Tests/Controllers/HlsSegmentControllerTests.cs new file mode 100644 index 0000000000..a248664928 --- /dev/null +++ b/tests/Jellyfin.Api.Tests/Controllers/HlsSegmentControllerTests.cs @@ -0,0 +1,161 @@ +using System; +using System.IO; +using Jellyfin.Api.Controllers; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.IO; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using Xunit; + +namespace Jellyfin.Api.Tests.Controllers; + +// The legacy HLS endpoints build a file path from caller-supplied route values, and the audio +// and video segment endpoints are not authenticated. These tests pin down that requests escaping +// the transcode directory are rejected while legitimate ones still serve a file. +public sealed class HlsSegmentControllerTests +{ + private readonly Mock<IFileSystem> _fileSystem = new(); + private readonly Mock<IServerConfigurationManager> _config = new(); + private readonly Mock<ITranscodeManager> _transcodeManager = new(); + private readonly string _transcodePath; + + public HlsSegmentControllerTests() + { + _transcodePath = Path.Combine(Path.GetTempPath(), "jellyfin-hls-segment-tests"); + Directory.CreateDirectory(_transcodePath); + + _config.Setup(c => c.GetConfiguration("encoding")) + .Returns(new EncodingOptions { TranscodingTempPath = _transcodePath }); + _config.SetupGet(c => c.CommonApplicationPaths).Returns(Mock.Of<IApplicationPaths>()); + } + + private HlsSegmentController CreateController(string requestPath) + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.Path = requestPath; + + return new HlsSegmentController(_fileSystem.Object, _config.Object, _transcodeManager.Object) + { + ControllerContext = new ControllerContext { HttpContext = httpContext } + }; + } + + [Fact] + public void GetHlsAudioSegmentLegacy_SegmentInsideTranscodePath_ReturnsFile() + { + var controller = CreateController("/Audio/abc/hls/segment/stream.mp3"); + + var result = controller.GetHlsAudioSegmentLegacy("abc", "segment"); + + Assert.IsType<PhysicalFileResult>(result); + } + + [Theory] + [InlineData("../../../../etc/passwd")] + [InlineData("subdir/../../../../etc/passwd")] + public void GetHlsAudioSegmentLegacy_TraversalOutsideTranscodePath_ReturnsBadRequest(string segmentId) + { + var controller = CreateController("/Audio/abc/hls/segment/stream.mp3"); + + var result = controller.GetHlsAudioSegmentLegacy("abc", segmentId); + + Assert.IsType<BadRequestObjectResult>(result); + } + + [Fact] + public void GetHlsAudioSegmentLegacy_AbsoluteRootedPath_ReturnsBadRequest() + { + var controller = CreateController("/Audio/abc/hls/segment/stream.mp3"); + + // A rooted segment id makes Path.GetFullPath discard the transcode base. + var rooted = OperatingSystem.IsWindows() ? "C:\\Windows\\win.ini" : "/etc/passwd"; + var result = controller.GetHlsAudioSegmentLegacy("abc", rooted); + + Assert.IsType<BadRequestObjectResult>(result); + } + + [Fact] + public void GetHlsAudioSegmentLegacy_SiblingPrefixDirectory_ReturnsBadRequest() + { + var controller = CreateController("/Audio/abc/hls/segment/stream.mp3"); + + // Resolves to "<transcodePath>-evil/passwd", which shares the transcode path as a string prefix. + var result = controller.GetHlsAudioSegmentLegacy("abc", "../jellyfin-hls-segment-tests-evil/passwd"); + + Assert.IsType<BadRequestObjectResult>(result); + } + + [Fact] + public void GetHlsPlaylistLegacy_M3u8InsideTranscodePath_ReturnsFile() + { + var controller = CreateController("/Videos/abc/hls/list/stream.m3u8"); + + var result = controller.GetHlsPlaylistLegacy("abc", "list"); + + Assert.IsType<PhysicalFileResult>(result); + } + + [Fact] + public void GetHlsPlaylistLegacy_NonPlaylistExtension_ReturnsBadRequest() + { + // Playlist endpoint serves only .m3u8, even for a path inside the transcode dir. + var controller = CreateController("/Videos/abc/hls/list/stream.mp4"); + + var result = controller.GetHlsPlaylistLegacy("abc", "list"); + + Assert.IsType<BadRequestObjectResult>(result); + } + + [Theory] + [InlineData("../../../../etc/passwd")] + public void GetHlsPlaylistLegacy_TraversalOutsideTranscodePath_ReturnsBadRequest(string playlistId) + { + var controller = CreateController("/Videos/abc/hls/list/stream.m3u8"); + + var result = controller.GetHlsPlaylistLegacy("abc", playlistId); + + Assert.IsType<BadRequestObjectResult>(result); + } + + [Fact] + public void GetHlsVideoSegmentLegacy_SegmentInsideTranscodePath_ReturnsFile() + { + _fileSystem.Setup(f => f.GetFilePaths(_transcodePath, false)) + .Returns(new[] { Path.Combine(_transcodePath, "playlist123.ts") }); + + var controller = CreateController("/Videos/abc/hls/playlist123/seg1.ts"); + + var result = controller.GetHlsVideoSegmentLegacy("abc", "playlist123", "seg1", "ts"); + + Assert.IsType<PhysicalFileResult>(result); + } + + [Fact] + public void GetHlsVideoSegmentLegacy_NoMatchingPlaylist_ReturnsNotFound() + { + _fileSystem.Setup(f => f.GetFilePaths(_transcodePath, false)) + .Returns(Array.Empty<string>()); + + var controller = CreateController("/Videos/abc/hls/playlist123/seg1.ts"); + + var result = controller.GetHlsVideoSegmentLegacy("abc", "playlist123", "seg1", "ts"); + + Assert.IsType<NotFoundObjectResult>(result); + } + + [Theory] + [InlineData("../../../../etc/passwd")] + public void GetHlsVideoSegmentLegacy_TraversalOutsideTranscodePath_ReturnsBadRequest(string segmentId) + { + var controller = CreateController("/Videos/abc/hls/playlist123/seg1.ts"); + + var result = controller.GetHlsVideoSegmentLegacy("abc", "playlist123", segmentId, "ts"); + + Assert.IsType<BadRequestObjectResult>(result); + _fileSystem.Verify(f => f.GetFilePaths(It.IsAny<string>(), It.IsAny<bool>()), Times.Never); + } +} diff --git a/tests/Jellyfin.Api.Tests/Controllers/PluginsControllerTests.cs b/tests/Jellyfin.Api.Tests/Controllers/PluginsControllerTests.cs new file mode 100644 index 0000000000..f040a328bb --- /dev/null +++ b/tests/Jellyfin.Api.Tests/Controllers/PluginsControllerTests.cs @@ -0,0 +1,129 @@ +using System; +using System.IO; +using Jellyfin.Api.Controllers; +using MediaBrowser.Common.Plugins; +using MediaBrowser.Common.Updates; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using Xunit; + +namespace Jellyfin.Api.Tests.Controllers; + +// Covers the path-traversal validation in GetPluginImage: a plugin's manifest ImagePath +// must resolve to a file inside the plugin's own directory. +public sealed class PluginsControllerTests +{ + private readonly Mock<IPluginManager> _pluginManager = new(); + private readonly string _pluginPath; + + public PluginsControllerTests() + { + _pluginPath = Path.Combine(Path.GetTempPath(), "jellyfin-plugin-image-tests"); + Directory.CreateDirectory(_pluginPath); + } + + private PluginsController CreateController() => + new PluginsController(Mock.Of<IInstallationManager>(), _pluginManager.Object) + { + ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } + }; + + private void SetupPlugin(Guid id, Version version, string? imagePath) + { + var manifest = new PluginManifest { Id = id, Name = "Test", Version = version.ToString(), ImagePath = imagePath }; + _pluginManager.Setup(p => p.GetPlugin(id, version)) + .Returns(new LocalPlugin(_pluginPath, true, manifest)); + } + + [Fact] + public void GetPluginImage_UnknownPlugin_ReturnsNotFound() + { + var result = CreateController().GetPluginImage(Guid.NewGuid(), new Version(1, 0)); + + Assert.IsType<NotFoundResult>(result); + } + + [Fact] + public void GetPluginImage_ImageInsidePluginPath_ReturnsFile() + { + var id = Guid.NewGuid(); + var version = new Version(1, 0); + File.WriteAllBytes(Path.Combine(_pluginPath, "logo.png"), Array.Empty<byte>()); + SetupPlugin(id, version, "logo.png"); + + var result = CreateController().GetPluginImage(id, version); + + Assert.IsType<PhysicalFileResult>(result); + } + + [Fact] + public void GetPluginImage_ImageInsidePluginPathButMissing_ReturnsNotFound() + { + var id = Guid.NewGuid(); + var version = new Version(1, 0); + SetupPlugin(id, version, "does-not-exist.png"); + + var result = CreateController().GetPluginImage(id, version); + + Assert.IsType<NotFoundResult>(result); + } + + [Theory] + [InlineData("../../../../etc/passwd")] + [InlineData("subdir/../../../../etc/passwd")] + public void GetPluginImage_TraversalOutsidePluginPath_ReturnsNotFound(string imagePath) + { + var id = Guid.NewGuid(); + var version = new Version(1, 0); + SetupPlugin(id, version, imagePath); + + var result = CreateController().GetPluginImage(id, version); + + Assert.IsType<NotFoundResult>(result); + } + + [Fact] + public void GetPluginImage_SiblingPrefixDirectory_ReturnsNotFound() + { + var id = Guid.NewGuid(); + var version = new Version(1, 0); + // Resolves to "<pluginPath>-evil/logo.png", which shares the plugin path as a string prefix. + // The file is created so the check fails on the boundary, not on File.Exists. + var siblingDir = _pluginPath + "-evil"; + Directory.CreateDirectory(siblingDir); + File.WriteAllBytes(Path.Combine(siblingDir, "logo.png"), Array.Empty<byte>()); + SetupPlugin(id, version, "../jellyfin-plugin-image-tests-evil/logo.png"); + + var result = CreateController().GetPluginImage(id, version); + + Assert.IsType<NotFoundResult>(result); + } + + [Fact] + public void GetPluginImage_AbsoluteImagePath_ReturnsNotFound() + { + var id = Guid.NewGuid(); + var version = new Version(1, 0); + SetupPlugin(id, version, OperatingSystem.IsWindows() ? "C:\\Windows\\win.ini" : "/etc/passwd"); + + var result = CreateController().GetPluginImage(id, version); + + Assert.IsType<NotFoundResult>(result); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void GetPluginImage_NoImagePathOrResource_ReturnsNotFound(string? imagePath) + { + var id = Guid.NewGuid(); + var version = new Version(1, 0); + SetupPlugin(id, version, imagePath); + + var result = CreateController().GetPluginImage(id, version); + + Assert.IsType<NotFoundResult>(result); + } +} diff --git a/tests/Jellyfin.Controller.Tests/ClientEventLoggerTests.cs b/tests/Jellyfin.Controller.Tests/ClientEventLoggerTests.cs new file mode 100644 index 0000000000..5132e529dd --- /dev/null +++ b/tests/Jellyfin.Controller.Tests/ClientEventLoggerTests.cs @@ -0,0 +1,44 @@ +using System; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using MediaBrowser.Controller; +using MediaBrowser.Controller.ClientEvent; +using Moq; +using Xunit; + +namespace Jellyfin.Controller.Tests +{ + public class ClientEventLoggerTests + { + [Theory] + [InlineData("../../../../etc/passwd", "1.0")] + [InlineData("..\\..\\windows\\system32", "1.0")] + [InlineData("normal-client", "../../../etc/passwd")] + [InlineData("/absolute/path", "1.0")] + public async Task WriteDocumentAsync_TraversalInput_StaysInsideLogDirectory(string clientName, string clientVersion) + { + var logDir = Path.Combine(Path.GetTempPath(), "jellyfin-clientlog-test-" + Path.GetRandomFileName()); + Directory.CreateDirectory(logDir); + try + { + var paths = new Mock<IServerApplicationPaths>(); + paths.Setup(p => p.LogDirectoryPath).Returns(logDir); + + var logger = new ClientEventLogger(paths.Object); + using var contents = new MemoryStream(Encoding.UTF8.GetBytes("payload")); + + var fileName = await logger.WriteDocumentAsync(clientName, clientVersion, contents); + + var resolved = Path.GetFullPath(Path.Combine(logDir, fileName)); + var rootWithSep = Path.GetFullPath(logDir) + Path.DirectorySeparatorChar; + Assert.StartsWith(rootWithSep, resolved, StringComparison.Ordinal); + Assert.True(File.Exists(resolved)); + } + finally + { + Directory.Delete(logDir, recursive: true); + } + } + } +} diff --git a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs index 5c187da413..c0a2b0ecca 100644 --- a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs +++ b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs @@ -1,6 +1,15 @@ using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.MediaSegments; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.MediaInfo; using Moq; using Xunit; @@ -46,4 +55,284 @@ public class BaseItemTests Assert.Equal(name, video.GetMediaSourceName(video)); Assert.Equal(altName, video.GetMediaSourceName(videoAlt)); } + + [Theory] + // Episode versions share a season folder; the common prefix (not the folder name) yields the label. + // Both files carry a suffix (no bare base name), so the shared "- " must be stripped too. + [InlineData( + "Spider-Noir - S01E02 - Wo ist Flint - Greyscale", + "Spider-Noir - S01E02 - Wo ist Flint - Colorized", + "Greyscale", + "Colorized")] + // One version is the bare base name; the other is suffixed. + [InlineData( + "Spider-Noir - S01E02 - Wo ist Flint", + "Spider-Noir - S01E02 - Wo ist Flint - Greyscale", + "Spider-Noir - S01E02 - Wo ist Flint", + "Greyscale")] + // Suffixes share a leading word ("Grey"); the prefix must retreat to the separator, not split it. + [InlineData( + "Demo - S01E01 - Greyscale", + "Demo - S01E01 - Greyish", + "Greyscale", + "Greyish")] + // Underscore separator. + [InlineData("Movie (2020)_4K", "Movie (2020)_1080p", "4K", "1080p")] + // Dot separator. + [InlineData("Movie (2020).UHD", "Movie (2020).1080p", "UHD", "1080p")] + // Resolution variants that share leading digits must retreat to the separator, not yield "p"/"i". + [InlineData("Movie - 1080p", "Movie - 1080i", "1080p", "1080i")] + // A token shared by the descriptors but separated only by spaces (the resolution) must stay in the + // label: retreat to the '-' delimiter, not the interior space, so the resolution is kept. + [InlineData( + "movie (2020) - 2160p Extended", + "movie (2020) - 2160p Original", + "2160p Extended", + "2160p Original")] + // Bracketed version labels: the opening bracket is kept in the label. + [InlineData( + "Blade Runner (1982) [Final Cut] [1080p HEVC AAC]", + "Blade Runner (1982) [EE by ADM] [480p HEVC AAC]", + "[Final Cut] [1080p HEVC AAC]", + "[EE by ADM] [480p HEVC AAC]")] + public void GetMediaSourceName_CommonPrefix_Valid(string primaryName, string altName, string expectedPrimary, string expectedAlt) + { + var primaryPath = "/Shows/Demo/Season 01/" + primaryName + ".mkv"; + var altPath = "/Shows/Demo/Season 01/" + altName + ".mkv"; + var commonPrefix = BaseItem.GetCommonVersionPrefix([primaryName, altName]); + + var video = new Video() + { + Path = primaryPath + }; + + var videoAlt = new Video() + { + Path = altPath, + }; + + var mediaSourceManager = new Mock<IMediaSourceManager>(); + mediaSourceManager.Setup(x => x.GetPathProtocol(It.IsAny<string>())) + .Returns((string x) => MediaProtocol.File); + var libraryManager = new Mock<ILibraryManager>(); + // No local alternate versions: these are linked (separate items), so the folder fallback is unavailable. + libraryManager.Setup(x => x.GetLocalAlternateVersionIds(It.IsAny<Video>())) + .Returns(Array.Empty<Guid>()); + BaseItem.MediaSourceManager = mediaSourceManager.Object; + BaseItem.LibraryManager = libraryManager.Object; + + Assert.Equal(expectedPrimary, video.GetMediaSourceName(video, commonPrefix)); + Assert.Equal(expectedAlt, videoAlt.GetMediaSourceName(videoAlt, commonPrefix)); + } + + [Fact] + public void GetAlternateVersion_ReturnsMatchingLocalVersion() + { + var (primary, alt1, alt2) = SetupVersionGroup(); + + Assert.Same(alt1, primary.GetAlternateVersion(alt1.Id)); + Assert.Same(alt2, primary.GetAlternateVersion(alt2.Id)); + Assert.Same(primary, primary.GetAlternateVersion(primary.Id)); + Assert.Null(primary.GetAlternateVersion(Guid.NewGuid())); + } + + [Fact] + public void GetAllVersions_FromAnyVersion_ReturnsEveryVersionOnce() + { + var (primary, alt1, alt2) = SetupVersionGroup(); + + foreach (var source in new[] { primary, alt1, alt2 }) + { + var versions = source.GetAllVersions(); + + Assert.Equal(3, versions.Count); + Assert.Contains(versions, v => v.Id.Equals(primary.Id)); + Assert.Contains(versions, v => v.Id.Equals(alt1.Id)); + Assert.Contains(versions, v => v.Id.Equals(alt2.Id)); + } + } + + [Fact] + public void PropagatePlayedState_MarksAlternateVersions_AndResetsPositionByDefault() + { + var (primary, alt1, alt2) = SetupVersionGroup(); + + var saved = CaptureSaves(); + + var user = new User("test", "default", "default"); + primary.PropagatePlayedState(user, true); + + // Both alternate versions are marked played, the primary (self) is not, and the position is + // reset so a watched version does not linger in "Continue Watching". + Assert.Equal(2, saved.Count); + Assert.DoesNotContain(saved, e => e.ItemId.Equals(primary.Id)); + Assert.Contains(saved, e => e.ItemId.Equals(alt1.Id)); + Assert.Contains(saved, e => e.ItemId.Equals(alt2.Id)); + Assert.All(saved, e => + { + Assert.True(e.Dto.Played.GetValueOrDefault()); + Assert.Equal(0, e.Dto.PlaybackPositionTicks); + }); + } + + [Fact] + public void PropagatePlayedState_WithoutReset_LeavesPositionUntouched() + { + var (primary, _, _) = SetupVersionGroup(); + + var saved = CaptureSaves(); + + primary.PropagatePlayedState(new User("test", "default", "default"), true, resetPosition: false); + + Assert.Equal(2, saved.Count); + Assert.All(saved, e => + { + Assert.True(e.Dto.Played.GetValueOrDefault()); + Assert.Null(e.Dto.PlaybackPositionTicks); + }); + } + + [Fact] + public void PropagatePlayedState_Unwatched_ClearsAllWatchedStateOnVersions() + { + var (primary, alt1, alt2) = SetupVersionGroup(); + + // Each alternate starts out watched, with a play count, resume point and last-played date. + var existing = new Dictionary<Guid, UserItemData> + { + [alt1.Id] = new UserItemData { Key = "alt1", Played = true, PlayCount = 3, PlaybackPositionTicks = 1000, LastPlayedDate = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc) }, + [alt2.Id] = new UserItemData { Key = "alt2", Played = true, PlayCount = 1, PlaybackPositionTicks = 500, LastPlayedDate = new DateTime(2021, 2, 2, 0, 0, 0, DateTimeKind.Utc) }, + }; + + var saved = new List<UserItemData>(); + var userDataManager = new Mock<IUserDataManager>(); + userDataManager.Setup(x => x.GetUserData(It.IsAny<User>(), It.IsAny<BaseItem>())) + .Returns((User _, BaseItem item) => existing.GetValueOrDefault(item.Id)); + userDataManager + .Setup(x => x.SaveUserData(It.IsAny<User>(), It.IsAny<BaseItem>(), It.IsAny<UserItemData>(), It.IsAny<UserDataSaveReason>(), It.IsAny<CancellationToken>())) + .Callback<User, BaseItem, UserItemData, UserDataSaveReason, CancellationToken>((_, _, data, _, _) => saved.Add(data)); + BaseItem.UserDataManager = userDataManager.Object; + + primary.PropagatePlayedState(new User("test", "default", "default"), false); + + // Every alternate is fully reset to an unwatched state, mirroring MarkUnplayed: the played flag, + // play count, resume point and last-played date are all cleared so no watched state lingers. + Assert.Equal(2, saved.Count); + Assert.All(saved, d => + { + Assert.False(d.Played); + Assert.Equal(0, d.PlayCount); + Assert.Equal(0, d.PlaybackPositionTicks); + Assert.Null(d.LastPlayedDate); + }); + } + + private static List<(Guid ItemId, UpdateUserItemDataDto Dto)> CaptureSaves() + { + var saved = new List<(Guid ItemId, UpdateUserItemDataDto Dto)>(); + var userDataManager = new Mock<IUserDataManager>(); + userDataManager + .Setup(x => x.SaveUserData(It.IsAny<User>(), It.IsAny<BaseItem>(), It.IsAny<UpdateUserItemDataDto>(), It.IsAny<UserDataSaveReason>())) + .Callback<User, BaseItem, UpdateUserItemDataDto, UserDataSaveReason>((_, item, dto, _) => saved.Add((item.Id, dto))); + BaseItem.UserDataManager = userDataManager.Object; + return saved; + } + + [Fact] + public void PropagatePlayedState_SingleVersion_DoesNothing() + { + var solo = new Video { Id = Guid.NewGuid(), Path = "/Movies/Solo/Solo.mkv" }; + + var mediaSourceManager = new Mock<IMediaSourceManager>(); + mediaSourceManager.Setup(x => x.GetPathProtocol(It.IsAny<string>())).Returns(MediaProtocol.File); + var libraryManager = new Mock<ILibraryManager>(); + libraryManager.Setup(x => x.GetLocalAlternateVersionIds(It.IsAny<Video>())).Returns(Array.Empty<Guid>()); + libraryManager.Setup(x => x.GetLinkedAlternateVersions(It.IsAny<Video>())).Returns(Array.Empty<Video>()); + BaseItem.MediaSourceManager = mediaSourceManager.Object; + BaseItem.LibraryManager = libraryManager.Object; + + var userDataManager = new Mock<IUserDataManager>(); + BaseItem.UserDataManager = userDataManager.Object; + + solo.PropagatePlayedState(new User("test", "default", "default"), true); + + userDataManager.Verify( + x => x.SaveUserData(It.IsAny<User>(), It.IsAny<BaseItem>(), It.IsAny<UpdateUserItemDataDto>(), It.IsAny<UserDataSaveReason>()), + Times.Never); + } + + private static (Video Primary, Video Alt1, Video Alt2) SetupVersionGroup() + { + var primary = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie.mkv" }; + var alt1 = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie - 1080p.mkv", PrimaryVersionId = primary.Id }; + var alt2 = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie - 4K.mkv", PrimaryVersionId = primary.Id }; + + // 2160p primary, 1080p alternates: width is only the ordering tiebreaker, set so it would place + // the primary first — letting the tests confirm the queried version's own source still wins. + var widths = new Dictionary<Guid, int> { [primary.Id] = 3840, [alt1.Id] = 1920, [alt2.Id] = 1920 }; + var mediaSourceManager = new Mock<IMediaSourceManager>(); + mediaSourceManager.Setup(x => x.GetPathProtocol(It.IsAny<string>())).Returns(MediaProtocol.File); + mediaSourceManager.Setup(x => x.GetMediaStreams(It.IsAny<Guid>())) + .Returns((Guid id) => new List<MediaStream> { new MediaStream { Type = MediaStreamType.Video, Width = widths.GetValueOrDefault(id) } }); + mediaSourceManager.Setup(x => x.GetMediaAttachments(It.IsAny<Guid>())).Returns(new List<MediaAttachment>()); + + var segmentManager = new Mock<IMediaSegmentManager>(); + segmentManager.Setup(x => x.IsTypeSupported(It.IsAny<BaseItem>())).Returns(false); + BaseItem.MediaSegmentManager = segmentManager.Object; + + var libraryManager = new Mock<ILibraryManager>(); + libraryManager.Setup(x => x.GetLinkedAlternateVersions(It.IsAny<Video>())).Returns(Array.Empty<Video>()); + libraryManager.Setup(x => x.GetLocalAlternateVersionIds(primary)).Returns(new[] { alt1.Id, alt2.Id }); + libraryManager.Setup(x => x.GetLocalAlternateVersionIds(alt1)).Returns(Array.Empty<Guid>()); + libraryManager.Setup(x => x.GetLocalAlternateVersionIds(alt2)).Returns(Array.Empty<Guid>()); + libraryManager.Setup(x => x.GetItemById(alt1.Id)).Returns(alt1); + libraryManager.Setup(x => x.GetItemById(alt2.Id)).Returns(alt2); + libraryManager.Setup(x => x.GetItemById(primary.Id)).Returns(primary); + + var recordingsManager = new Mock<IRecordingsManager>(); + recordingsManager.Setup(x => x.GetActiveRecordingInfo(It.IsAny<string>())).Returns((ActiveRecordingInfo?)null); + Video.RecordingsManager = recordingsManager.Object; + + BaseItem.MediaSourceManager = mediaSourceManager.Object; + BaseItem.LibraryManager = libraryManager.Object; + + return (primary, alt1, alt2); + } + + [Fact] + public void GetMediaSources_DefaultsToTheQueriedVersionsOwnSource() + { + var (primary, alt1, _) = SetupVersionGroup(); + + // Resuming the 1080p alternate must default to the 1080p source, not the higher-resolution + // 2160p primary that the width ordering would otherwise place first. + Assert.Equal(alt1.Id.ToString("N"), alt1.GetMediaSources(false)[0].Id); + + // Opening the primary still defaults to the primary's own (here highest-resolution) source. + Assert.Equal(primary.Id.ToString("N"), primary.GetMediaSources(false)[0].Id); + } + + [Fact] + public void GetAllItemsForMediaSources_FromAnyVersion_HasNoDuplicates() + { + var (primary, alt1, alt2) = SetupVersionGroup(); + + var method = typeof(Video).GetMethod("GetAllItemsForMediaSources", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(method); + + // Each version must surface exactly once, regardless of which member the list is built from. + // Building from an alternate previously re-added that alternate as a "local alternate" of the + // primary, producing a duplicate entry in the version dropdown. + foreach (var source in new[] { primary, alt1, alt2 }) + { + var items = (IEnumerable<(BaseItem Item, MediaSourceType MediaSourceType)>)method!.Invoke(source, null)!; + var ids = items.Select(i => i.Item.Id).ToList(); + + Assert.Equal(3, ids.Count); + Assert.Equal(ids.Count, ids.Distinct().Count()); + Assert.Contains(primary.Id, ids); + Assert.Contains(alt1.Id, ids); + Assert.Contains(alt2.Id, ids); + } + } } diff --git a/tests/Jellyfin.Controller.Tests/Library/VersionResumeDataTests.cs b/tests/Jellyfin.Controller.Tests/Library/VersionResumeDataTests.cs new file mode 100644 index 0000000000..7d87d5ee92 --- /dev/null +++ b/tests/Jellyfin.Controller.Tests/Library/VersionResumeDataTests.cs @@ -0,0 +1,89 @@ +using System; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Dto; +using Xunit; + +namespace Jellyfin.Controller.Tests.Library; + +public class VersionResumeDataTests +{ + [Fact] + public void ApplyTo_CompletedOtherVersion_PropagatesCompletionAndClearsStaleResume() + { + var lastPlayed = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc); + var resume = new VersionResumeData( + Guid.NewGuid(), + new UserItemData { Key = "version", PlaybackPositionTicks = 0, Played = true, LastPlayedDate = lastPlayed }); + + var dto = new UserItemDataDto { ItemId = Guid.NewGuid(), Key = "primary", PlaybackPositionTicks = 1, Played = false, PlayedPercentage = 50 }; + + resume.ApplyTo(dto); + + // Completion state propagates to the primary... + Assert.True(dto.Played); + Assert.Equal(lastPlayed, dto.LastPlayedDate); + + // ...and because the movie was finished on a different version, the primary's own stale resume bar is cleared. + Assert.Equal(0, dto.PlaybackPositionTicks); + Assert.Null(dto.PlayedPercentage); + } + + [Fact] + public void ApplyTo_PrimaryOwnProgress_KeepsResumePosition() + { + var primaryId = Guid.NewGuid(); + var lastPlayed = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc); + + // The winning version is the primary itself (e.g. rewatching): its resume bar must survive. + var resume = new VersionResumeData( + primaryId, + new UserItemData { Key = "primary", PlaybackPositionTicks = 5, Played = true, LastPlayedDate = lastPlayed }); + + var dto = new UserItemDataDto { ItemId = primaryId, Key = "primary", PlaybackPositionTicks = 5, Played = true, PlayedPercentage = 20 }; + + resume.ApplyTo(dto); + + Assert.True(dto.Played); + Assert.Equal(5, dto.PlaybackPositionTicks); + Assert.Equal(20, dto.PlayedPercentage); + } + + [Fact] + public void ApplyTo_InProgressOtherVersion_KeepsPrimaryResumePosition() + { + var lastPlayed = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc); + + // A different version that is in-progress (not finished) must not clear the primary's position. + var resume = new VersionResumeData( + Guid.NewGuid(), + new UserItemData { Key = "version", PlaybackPositionTicks = 25, Played = false, LastPlayedDate = lastPlayed }); + + var dto = new UserItemDataDto { ItemId = Guid.NewGuid(), Key = "primary", PlaybackPositionTicks = 1, Played = false, PlayedPercentage = 50 }; + + resume.ApplyTo(dto); + + Assert.False(dto.Played); + Assert.Equal(1, dto.PlaybackPositionTicks); + Assert.Equal(50, dto.PlayedPercentage); + } + + [Fact] + public void ApplyTo_DoesNotUnsetExistingPlayedOrRegressLastPlayed() + { + var primaryLastPlayed = new DateTime(2026, 1, 5, 0, 0, 0, DateTimeKind.Utc); + var versionLastPlayed = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc); + var resume = new VersionResumeData( + Guid.NewGuid(), + new UserItemData { Key = "version", Played = false, LastPlayedDate = versionLastPlayed }); + + var dto = new UserItemDataDto { ItemId = Guid.NewGuid(), Key = "primary", Played = true, LastPlayedDate = primaryLastPlayed }; + + resume.ApplyTo(dto); + + // A not-yet-completed version must not clear the primary's own completion, and the more recent + // LastPlayedDate is kept. + Assert.True(dto.Played); + Assert.Equal(primaryLastPlayed, dto.LastPlayedDate); + } +} diff --git a/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs index d7ae6a8a18..71b6551d0f 100644 --- a/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs +++ b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs @@ -11,6 +11,7 @@ using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; +using MediaBrowser.Model.MediaInfo; using Moq; using Xunit; @@ -203,6 +204,50 @@ public class EncodingHelperTests } } + [Theory] + [InlineData("aac", 44100, 44100)] // non-opus: requested rate must be preserved (issue #17026) + [InlineData("aac", 48000, 48000)] + [InlineData("mp3", 22050, 22050)] + [InlineData("flac", 96000, 96000)] + [InlineData("opus", 44100, 48000)] // opus: must snap to a libopus-supported rate + [InlineData("opus", 22050, 24000)] + [InlineData("opus", 8000, 8000)] + public void GetProgressiveAudioFullCommandLine_SampleRate_OnlyClampedForOpus( + string audioCodec, + int requestedSampleRate, + int expectedSampleRate) + { + var state = BuildAudioState(audioCodec, requestedSampleRate); + var args = CreateHelper().GetProgressiveAudioFullCommandLine(state, new EncodingOptions(), "/tmp/out"); + + Assert.Contains("-ar " + expectedSampleRate, args, StringComparison.Ordinal); + } + + private static EncodingJobInfo BuildAudioState(string audioCodec, int requestedSampleRate) + { + var audio = new MediaStream { Index = 0, Type = MediaStreamType.Audio, Codec = "flac", SampleRate = 96000 }; + + return new EncodingJobInfo(TranscodingJobType.Progressive) + { + MediaSource = new MediaSourceInfo + { + Container = "flac", + MediaStreams = new List<MediaStream> { audio }, + Path = "/media/track.flac", + Protocol = MediaProtocol.File, + }, + AudioStream = audio, + OutputAudioCodec = audioCodec, + BaseRequest = new VideoRequestDto + { + AudioCodec = audioCodec, + AudioSampleRate = requestedSampleRate, + }, + IsVideoRequest = false, + IsInputVideo = false, + }; + } + private static EncodingJobInfo BuildState( MediaStream? subtitle, SubtitleDeliveryMethod? deliveryMethod, diff --git a/tests/Jellyfin.Extensions.Tests/PathHelperTests.cs b/tests/Jellyfin.Extensions.Tests/PathHelperTests.cs new file mode 100644 index 0000000000..71fd853ba2 --- /dev/null +++ b/tests/Jellyfin.Extensions.Tests/PathHelperTests.cs @@ -0,0 +1,60 @@ +using System.IO; +using Jellyfin.Extensions; +using Xunit; + +namespace Jellyfin.Extensions.Tests +{ + public static class PathHelperTests + { + [Theory] + [InlineData("file.txt", "file.txt")] + [InlineData("sub/file.txt", "file.txt")] + [InlineData("../../etc/passwd", "passwd")] + public static void GetSafeLeafFileName_ReducesToLeaf(string input, string expected) + { + Assert.Equal(expected, PathHelper.GetSafeLeafFileName(input)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(".")] + [InlineData("..")] + public static void GetSafeLeafFileName_RejectsUnusableLeaf(string? input) + { + Assert.Null(PathHelper.GetSafeLeafFileName(input)); + } + + [Fact] + public static void IsContainedIn_ChildPath_ReturnsTrue() + { + var root = Path.Combine(Path.GetTempPath(), "root"); + var child = Path.Combine(root, "sub", "file.txt"); + Assert.True(PathHelper.IsContainedIn(root, child)); + } + + [Fact] + public static void IsContainedIn_RootItself_ReturnsTrue() + { + var root = Path.Combine(Path.GetTempPath(), "root"); + Assert.True(PathHelper.IsContainedIn(root, root)); + } + + [Fact] + public static void IsContainedIn_TraversalEscape_ReturnsFalse() + { + var root = Path.Combine(Path.GetTempPath(), "root"); + var escape = Path.Combine(root, "..", "..", "etc", "passwd"); + Assert.False(PathHelper.IsContainedIn(root, escape)); + } + + [Fact] + public static void IsContainedIn_SiblingPrefixCollision_ReturnsFalse() + { + // "/var/data" must not be accepted as a parent of "/var/dataset". + var root = Path.Combine(Path.GetTempPath(), "data"); + var sibling = Path.Combine(Path.GetTempPath(), "dataset", "file.txt"); + Assert.False(PathHelper.IsContainedIn(root, sibling)); + } + } +} diff --git a/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs index b71dc15201..f698edc637 100644 --- a/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs +++ b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using AutoFixture; using AutoFixture.AutoMoq; using Jellyfin.LiveTv.Listings; +using MediaBrowser.Controller.LiveTv; using MediaBrowser.Model.LiveTv; using Moq; using Moq.Protected; @@ -66,6 +67,7 @@ public class XmlTvListingsProviderTests Assert.True(program.HasImage); Assert.Equal("https://domain.tld/image.png", program.ImageUrl); Assert.Equal("3297", program.ChannelId); + AssertXmlTvEtag(program.Etag); } [Theory] @@ -85,5 +87,60 @@ public class XmlTvListingsProviderTests var program = programsList[0]; Assert.DoesNotContain(program.Genres, g => string.IsNullOrEmpty(g)); Assert.Equal("3297", program.ChannelId); + AssertXmlTvEtag(program.Etag); + } + + [Fact] + public async Task GetProgramsAsync_Etag_SameContentIsStable() + { + var first = await GetSingleProgramAsync("Test Data/LiveTv/Listings/XmlTv/etag-base.xml"); + var second = await GetSingleProgramAsync("Test Data/LiveTv/Listings/XmlTv/etag-base.xml"); + + Assert.Equal(first.Etag, second.Etag); + } + + [Theory] + [InlineData("Test Data/LiveTv/Listings/XmlTv/etag-title-change.xml")] + [InlineData("Test Data/LiveTv/Listings/XmlTv/etag-description-change.xml")] + [InlineData("Test Data/LiveTv/Listings/XmlTv/etag-icon-change.xml")] + [InlineData("Test Data/LiveTv/Listings/XmlTv/etag-category-change.xml")] + [InlineData("Test Data/LiveTv/Listings/XmlTv/etag-progid-change.xml")] + public async Task GetProgramsAsync_Etag_ChangesWhenMappedContentChanges(string changedPath) + { + var original = await GetSingleProgramAsync("Test Data/LiveTv/Listings/XmlTv/etag-base.xml"); + var changed = await GetSingleProgramAsync(changedPath); + + Assert.NotEqual(original.Etag, changed.Etag); + } + + [Theory] + [InlineData("Test Data/LiveTv/Listings/XmlTv/etag-reordered.xml")] + [InlineData("Test Data/LiveTv/Listings/XmlTv/etag-unknown-field.xml")] + public async Task GetProgramsAsync_Etag_DoesNotChangeWhenMappedContentIsEquivalent(string equivalentPath) + { + var original = await GetSingleProgramAsync("Test Data/LiveTv/Listings/XmlTv/etag-base.xml"); + var equivalent = await GetSingleProgramAsync(equivalentPath); + + Assert.Equal(original.Etag, equivalent.Etag); + } + + private async Task<ProgramInfo> GetSingleProgramAsync(string path) + { + var info = new ListingsProviderInfo() + { + Id = Path.GetFileNameWithoutExtension(path), + Path = path + }; + + var startDate = new DateTime(2022, 11, 4, 0, 0, 0, DateTimeKind.Utc); + var programs = await _xmlTvListingsProvider.GetProgramsAsync(info, "3297", startDate, startDate.AddDays(1), CancellationToken.None); + + return Assert.Single(programs.ToList()); + } + + private static void AssertXmlTvEtag(string? etag) + { + Assert.NotNull(etag); + Assert.StartsWith("xmltv-sha256-v1:", etag!, StringComparison.Ordinal); } } diff --git a/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvProgramEtagTests.cs b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvProgramEtagTests.cs new file mode 100644 index 0000000000..b8d1c60e1a --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvProgramEtagTests.cs @@ -0,0 +1,59 @@ +using System; +using Jellyfin.LiveTv.Listings; +using MediaBrowser.Controller.LiveTv; +using Xunit; + +namespace Jellyfin.LiveTv.Tests.Listings; + +public class XmlTvProgramEtagTests +{ + [Fact] + public void TryCreate_GenreOrderIsSignificant() + { + // GuideManager assigns item.Genres = info.Genres.ToArray() preserving order, + // so the same genres in a different order is a real mapped-content change. + var first = NewProgram(); + first.Genres = new() { "Drama", "Action" }; + + var second = NewProgram(); + second.Genres = new() { "Action", "Drama" }; + + Assert.True(XmlTvProgramEtag.TryCreate(first, out var firstEtag, out _)); + Assert.True(XmlTvProgramEtag.TryCreate(second, out var secondEtag, out _)); + Assert.NotEqual(firstEtag, secondEtag); + } + + [Fact] + public void MatchesStored_EqualXmlTvEtags_ReturnsTrue() + { + const string Etag = XmlTvProgramEtag.Prefix + "ABCDEF0123456789"; + Assert.True(XmlTvProgramEtag.MatchesStored(Etag, Etag)); + } + + [Fact] + public void MatchesStored_DifferentXmlTvEtags_ReturnsFalse() + { + Assert.False(XmlTvProgramEtag.MatchesStored( + XmlTvProgramEtag.Prefix + "AAAA", + XmlTvProgramEtag.Prefix + "BBBB")); + } + + [Fact] + public void MatchesStored_EqualNonXmlTvEtags_ReturnsFalse() + { + // Other providers (e.g. Schedules Direct) use their own etag schemes. + // The IsXmlTvEtag gate must keep them on the field-by-field update path + // even when their incoming and stored values happen to match exactly. + const string Etag = "sd-abc123"; + Assert.False(XmlTvProgramEtag.MatchesStored(Etag, Etag)); + } + + private static ProgramInfo NewProgram() => new() + { + Id = "program-id", + ChannelId = "channel-id", + Name = "Program Name", + StartDate = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc), + EndDate = new DateTime(2026, 1, 1, 13, 0, 0, DateTimeKind.Utc), + }; +} diff --git a/tests/Jellyfin.LiveTv.Tests/LiveTvChannelImageHelperTests.cs b/tests/Jellyfin.LiveTv.Tests/LiveTvChannelImageHelperTests.cs new file mode 100644 index 0000000000..f44cb88834 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/LiveTvChannelImageHelperTests.cs @@ -0,0 +1,51 @@ +using Jellyfin.LiveTv; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Entities; +using Xunit; + +namespace Jellyfin.LiveTv.Tests; + +public class LiveTvChannelImageHelperTests +{ + [Fact] + public void UpdateChannelImageIfNeeded_NoSource_DoesNotUpdate() + { + var channel = new LiveTvChannel { Name = "Test Channel" }; + + var updated = LiveTvChannelImageHelper.UpdateChannelImageIfNeeded(channel, null, null); + + Assert.False(updated); + Assert.False(channel.HasImage(ImageType.Primary)); + } + + [Fact] + public void UpdateChannelImageIfNeeded_WithUrl_AppliesUrl() + { + var channel = new LiveTvChannel { Name = "Test Channel" }; + + var updated = LiveTvChannelImageHelper.UpdateChannelImageIfNeeded( + channel, + null, + "https://example.com/icon.png"); + + Assert.True(updated); + Assert.True(channel.HasImage(ImageType.Primary)); + Assert.Equal("https://example.com/icon.png", channel.GetImagePath(ImageType.Primary)); + } + + [Fact] + public void UpdateChannelImageIfNeeded_SameUrl_StillUpdates() + { + var channel = new LiveTvChannel { Name = "Test Channel" }; + LiveTvChannelImageHelper.UpdateChannelImageIfNeeded(channel, null, "https://example.com/icon.png"); + + var updated = LiveTvChannelImageHelper.UpdateChannelImageIfNeeded( + channel, + null, + "https://example.com/icon.png"); + + Assert.True(updated); + Assert.Equal("https://example.com/icon.png", channel.GetImagePath(ImageType.Primary)); + } +} diff --git a/tests/Jellyfin.LiveTv.Tests/SchedulesDirect/SchedulesDirectDeserializeTests.cs b/tests/Jellyfin.LiveTv.Tests/SchedulesDirect/SchedulesDirectDeserializeTests.cs index 59cd42c05b..1bc42d5fe5 100644 --- a/tests/Jellyfin.LiveTv.Tests/SchedulesDirect/SchedulesDirectDeserializeTests.cs +++ b/tests/Jellyfin.LiveTv.Tests/SchedulesDirect/SchedulesDirectDeserializeTests.cs @@ -176,6 +176,30 @@ namespace Jellyfin.LiveTv.Tests.SchedulesDirect } /// <summary> + /// /metadata/programs response where the daily image limit is hit mid-batch, + /// so individual entries carry an error code inside an otherwise successful response. + /// </summary> + [Fact] + public void Deserialize_Metadata_Programs_Image_Limit_Response_Success() + { + var bytes = File.ReadAllBytes("Test Data/SchedulesDirect/metadata_programs_image_limit_response.json"); + var showImagesDtos = JsonSerializer.Deserialize<IReadOnlyList<ShowImagesDto>>(bytes, _jsonOptions); + + Assert.NotNull(showImagesDtos); + Assert.Equal(2, showImagesDtos!.Count); + + // First entry is a normal result with image data and no error code. + Assert.Equal("SH00712240", showImagesDtos[0].ProgramId); + Assert.Null(showImagesDtos[0].Code); + Assert.Single(showImagesDtos[0].Data); + + // Second entry is a per-entry trial image download limit error (SD code 5003). + Assert.Equal("SH00712241", showImagesDtos[1].ProgramId); + Assert.Equal((int)SdErrorCode.MaxImageDownloadsTrial, showImagesDtos[1].Code); + Assert.Empty(showImagesDtos[1].Data); + } + + /// <summary> /// /headends response. /// </summary> [Fact] diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-base.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-base.xml new file mode 100644 index 0000000000..15f85f57e6 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-base.xml @@ -0,0 +1,17 @@ +<tv date="20221104"> + <programme channel="3297" start="20221104130000 +0000" stop="20221104140000 +0000"> + <title lang="en">Base Program</title> + <sub-title lang="en">Base Episode</sub-title> + <desc lang="en">Base description.</desc> + <category lang="en">series</category> + <episode-num system="xmltv_ns">0 . 1 . </episode-num> + <episode-num system="dd_progid">EP123456789012</episode-num> + <rating system="VCHIP"> + <value>TV-G</value> + </rating> + <star-rating> + <value>3/5</value> + </star-rating> + <icon src="https://domain.tld/base.png"/> + </programme> +</tv> diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-category-change.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-category-change.xml new file mode 100644 index 0000000000..2b49c3bccd --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-category-change.xml @@ -0,0 +1,17 @@ +<tv date="20221104"> + <programme channel="3297" start="20221104130000 +0000" stop="20221104140000 +0000"> + <title lang="en">Base Program</title> + <sub-title lang="en">Base Episode</sub-title> + <desc lang="en">Base description.</desc> + <category lang="en">sports</category> + <episode-num system="xmltv_ns">0 . 1 . </episode-num> + <episode-num system="dd_progid">EP123456789012</episode-num> + <rating system="VCHIP"> + <value>TV-G</value> + </rating> + <star-rating> + <value>3/5</value> + </star-rating> + <icon src="https://domain.tld/base.png"/> + </programme> +</tv> diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-description-change.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-description-change.xml new file mode 100644 index 0000000000..090273ac98 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-description-change.xml @@ -0,0 +1,17 @@ +<tv date="20221104"> + <programme channel="3297" start="20221104130000 +0000" stop="20221104140000 +0000"> + <title lang="en">Base Program</title> + <sub-title lang="en">Base Episode</sub-title> + <desc lang="en">Changed description.</desc> + <category lang="en">series</category> + <episode-num system="xmltv_ns">0 . 1 . </episode-num> + <episode-num system="dd_progid">EP123456789012</episode-num> + <rating system="VCHIP"> + <value>TV-G</value> + </rating> + <star-rating> + <value>3/5</value> + </star-rating> + <icon src="https://domain.tld/base.png"/> + </programme> +</tv> diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-icon-change.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-icon-change.xml new file mode 100644 index 0000000000..532b91da20 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-icon-change.xml @@ -0,0 +1,17 @@ +<tv date="20221104"> + <programme channel="3297" start="20221104130000 +0000" stop="20221104140000 +0000"> + <title lang="en">Base Program</title> + <sub-title lang="en">Base Episode</sub-title> + <desc lang="en">Base description.</desc> + <category lang="en">series</category> + <episode-num system="xmltv_ns">0 . 1 . </episode-num> + <episode-num system="dd_progid">EP123456789012</episode-num> + <rating system="VCHIP"> + <value>TV-G</value> + </rating> + <star-rating> + <value>3/5</value> + </star-rating> + <icon src="https://domain.tld/changed.png"/> + </programme> +</tv> diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-progid-change.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-progid-change.xml new file mode 100644 index 0000000000..db0d5e86de --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-progid-change.xml @@ -0,0 +1,17 @@ +<tv date="20221104"> + <programme channel="3297" start="20221104130000 +0000" stop="20221104140000 +0000"> + <title lang="en">Base Program</title> + <sub-title lang="en">Base Episode</sub-title> + <desc lang="en">Base description.</desc> + <category lang="en">series</category> + <episode-num system="xmltv_ns">0 . 1 . </episode-num> + <episode-num system="dd_progid">EP123456789013</episode-num> + <rating system="VCHIP"> + <value>TV-G</value> + </rating> + <star-rating> + <value>3/5</value> + </star-rating> + <icon src="https://domain.tld/base.png"/> + </programme> +</tv> diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-reordered.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-reordered.xml new file mode 100644 index 0000000000..168c0a643b --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-reordered.xml @@ -0,0 +1,17 @@ +<tv date="20221104"> + <programme channel="3297" stop="20221104140000 +0000" start="20221104130000 +0000"> + <icon src="https://domain.tld/base.png"/> + <star-rating> + <value>3/5</value> + </star-rating> + <rating system="VCHIP"> + <value>TV-G</value> + </rating> + <episode-num system="xmltv_ns">0 . 1 . </episode-num> + <episode-num system="dd_progid">EP123456789012</episode-num> + <category lang="en">series</category> + <desc lang="en">Base description.</desc> + <sub-title lang="en">Base Episode</sub-title> + <title lang="en">Base Program</title> + </programme> +</tv> diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-title-change.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-title-change.xml new file mode 100644 index 0000000000..73288e7c57 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-title-change.xml @@ -0,0 +1,17 @@ +<tv date="20221104"> + <programme channel="3297" start="20221104130000 +0000" stop="20221104140000 +0000"> + <title lang="en">Changed Program</title> + <sub-title lang="en">Base Episode</sub-title> + <desc lang="en">Base description.</desc> + <category lang="en">series</category> + <episode-num system="xmltv_ns">0 . 1 . </episode-num> + <episode-num system="dd_progid">EP123456789012</episode-num> + <rating system="VCHIP"> + <value>TV-G</value> + </rating> + <star-rating> + <value>3/5</value> + </star-rating> + <icon src="https://domain.tld/base.png"/> + </programme> +</tv> diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-unknown-field.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-unknown-field.xml new file mode 100644 index 0000000000..d0ff1b82f5 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-unknown-field.xml @@ -0,0 +1,18 @@ +<tv date="20221104"> + <programme channel="3297" start="20221104130000 +0000" stop="20221104140000 +0000"> + <title lang="en">Base Program</title> + <sub-title lang="en">Base Episode</sub-title> + <desc lang="en">Base description.</desc> + <category lang="en">series</category> + <episode-num system="xmltv_ns">0 . 1 . </episode-num> + <episode-num system="dd_progid">EP123456789012</episode-num> + <rating system="VCHIP"> + <value>TV-G</value> + </rating> + <star-rating> + <value>3/5</value> + </star-rating> + <previously-unknown-field>Ignored by Jellyfin XMLTV mapping.</previously-unknown-field> + <icon src="https://domain.tld/base.png"/> + </programme> +</tv> diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/metadata_programs_image_limit_response.json b/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/metadata_programs_image_limit_response.json new file mode 100644 index 0000000000..34931aa769 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/metadata_programs_image_limit_response.json @@ -0,0 +1 @@ +[{"programID":"SH00712240","data":[{"width":"135","height":"180","uri":"assets/p282288_b_v2_aa.jpg","size":"Sm","aspect":"3x4","category":"Banner-L3","text":"yes","primary":"true","tier":"Series"}]},{"programID":"SH00712241","code":5003,"message":"Image download limit exceeded. Try again tomorrow."}] diff --git a/tests/Jellyfin.MediaEncoding.Tests/Encoder/ApplePlatformHelperTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Encoder/ApplePlatformHelperTests.cs new file mode 100644 index 0000000000..9847acbb0a --- /dev/null +++ b/tests/Jellyfin.MediaEncoding.Tests/Encoder/ApplePlatformHelperTests.cs @@ -0,0 +1,22 @@ +using System; +using System.Runtime.Versioning; +using MediaBrowser.MediaEncoding.Encoder; +using Xunit; + +namespace Jellyfin.MediaEncoding.Tests; + +[SupportedOSPlatform("macos")] +public class ApplePlatformHelperTests +{ + [Fact] + public void GetSysctlValue_CpuBrand_NotEmpty() + { + Assert.SkipUnless(OperatingSystem.IsMacOS(), "macOS-only test"); + + var value = ApplePlatformHelper.GetSysctlValue("machdep.cpu.brand_string"); + Assert.NotEmpty(value); + + // Make sure we don't include the null terminator + Assert.DoesNotContain("\0", value, StringComparison.Ordinal); + } +} diff --git a/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTests.cs b/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTests.cs index 988073074b..bfe6ade1fe 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTests.cs +++ b/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTests.cs @@ -24,6 +24,7 @@ namespace Jellyfin.MediaEncoding.Tests [InlineData(EncoderValidatorTestsData.FFmpegV44Output, true)] [InlineData(EncoderValidatorTestsData.FFmpegV432Output, false)] [InlineData(EncoderValidatorTestsData.FFmpegGitUnknownOutput2, true)] + [InlineData(EncoderValidatorTestsData.FFmpegGitWithoutLibpostprocOutput, true)] [InlineData(EncoderValidatorTestsData.FFmpegGitUnknownOutput, false)] public void ValidateVersionInternalTest(string versionOutput, bool valid) { @@ -41,6 +42,7 @@ namespace Jellyfin.MediaEncoding.Tests Add(EncoderValidatorTestsData.FFmpegV44Output, new Version(4, 4)); Add(EncoderValidatorTestsData.FFmpegV432Output, new Version(4, 3, 2)); Add(EncoderValidatorTestsData.FFmpegGitUnknownOutput2, new Version(4, 4)); + Add(EncoderValidatorTestsData.FFmpegGitWithoutLibpostprocOutput, new Version(4, 4)); Add(EncoderValidatorTestsData.FFmpegGitUnknownOutput, null); } } diff --git a/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTestsData.cs b/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTestsData.cs index 1f2d618aa4..604b862fbe 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTestsData.cs +++ b/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTestsData.cs @@ -86,6 +86,15 @@ libswscale 5. 9.100 / 5. 9.100 libswresample 3. 9.100 / 3. 9.100 libpostproc 55. 9.100 / 55. 9.100"; + public const string FFmpegGitWithoutLibpostprocOutput = @"ffmpeg version N-122128-gdeadbeef Copyright (c) 2000-2026 the FFmpeg developers +libavutil 60. 26.102 / 60. 26.102 +libavcodec 62. 28.102 / 62. 28.102 +libavformat 62. 12.102 / 62. 12.102 +libavdevice 62. 3.102 / 62. 3.102 +libavfilter 11. 14.102 / 11. 14.102 +libswscale 9. 5.102 / 9. 5.102 +libswresample 6. 3.102 / 6. 3.102"; + public const string FFmpegGitUnknownOutput = @"ffmpeg version N-45325-gb173e0353-static https://johnvansickle.com/ffmpeg/ Copyright (c) 2000-2018 the FFmpeg developers built with gcc 6.3.0 (Debian 6.3.0-18+deb9u1) 20170516 configuration: --enable-gpl --enable-version3 --enable-static --disable-debug --disable-ffplay --disable-indev=sndio --disable-outdev=sndio --cc=gcc-6 --enable-fontconfig --enable-frei0r --enable-gnutls --enable-gray --enable-libfribidi --enable-libass --enable-libfreetype --enable-libmp3lame --enable-libopencore-amrnb --enable-libopencore-amrwb --enable-libopenjpeg --enable-librubberband --enable-libsoxr --enable-libspeex --enable-libvorbis --enable-libopus --enable-libtheora --enable-libvidstab --enable-libvo-amrwbenc --enable-libvpx --enable-libwebp --enable-libx264 --enable-libx265 --enable-libxvid --enable-libzimg diff --git a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs index 198cdaa4fc..b723fc7208 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs +++ b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs @@ -1,6 +1,7 @@ using System; using System.Globalization; using System.IO; +using System.Linq; using System.Text.Json; using Jellyfin.Data.Enums; using Jellyfin.Extensions.Json; @@ -56,6 +57,43 @@ namespace Jellyfin.MediaEncoding.Tests.Probing public void IsNearSquarePixelSar_DetectsCorrectly(string? sar, bool expected) => Assert.Equal(expected, ProbeResultNormalizer.IsNearSquarePixelSar(sar)); + [Theory] + // Lossy codecs, mono/stereo and multichannel. + [InlineData("aac", null, 2, 192000)] + [InlineData("mp3", null, 2, 192000)] + [InlineData("mp2", null, 2, 192000)] + [InlineData("aac", null, 6, 320000)] + [InlineData("ac3", null, 2, 192000)] + [InlineData("eac3", null, 6, 640000)] + [InlineData("opus", null, 2, 128000)] + [InlineData("vorbis", null, 6, 320000)] + [InlineData("wmav2", null, 2, 192000)] + // DTS: the lossy core (any non-MA profile, or none) is flat and caps at 5.1... + [InlineData("dts", null, 2, 768000)] + [InlineData("dts", "DTS", 6, 1509000)] + [InlineData("dts", "DTS-HD HRA", 8, 1509000)] + // ...while lossless DTS-HD MA scales per channel like other lossless codecs. + [InlineData("dts", "DTS-HD MA", 6, 4200000)] + [InlineData("dts", "DTS-HD MA + DTS:X", 8, 5600000)] + // Lossless codecs scale per channel. + [InlineData("flac", null, 2, 960000)] + [InlineData("flac", null, 6, 2880000)] + [InlineData("flac", null, 8, 3840000)] + [InlineData("alac", null, 6, 2880000)] + [InlineData("truehd", null, 2, 1400000)] + [InlineData("truehd", null, 6, 4200000)] + [InlineData("truehd", "Dolby TrueHD + Dolby Atmos", 8, 5600000)] + // 3-4 channel audio must use the multichannel estimate, not return null. + [InlineData("aac", null, 3, 320000)] + [InlineData("ac3", null, 4, 640000)] + // Codec matching is case-insensitive. + [InlineData("AAC", null, 2, 192000)] + // Unknown codec or unknown channel count cannot be estimated. + [InlineData("pcm_s16le", null, 2, null)] + [InlineData("aac", null, null, null)] + public void GetEstimatedAudioBitrate_ReturnsExpected(string codec, string? profile, int? channels, int? expected) + => Assert.Equal(expected, ProbeResultNormalizer.GetEstimatedAudioBitrate(codec, profile, channels)); + [Fact] public void GetMediaInfo_MetaData_Success() { @@ -71,7 +109,10 @@ namespace Jellyfin.MediaEncoding.Tests.Probing Assert.Equal("4:3", res.VideoStream.AspectRatio); Assert.Equal(25f, res.VideoStream.AverageFrameRate); Assert.Equal(8, res.VideoStream.BitDepth); - Assert.Equal(69432, res.VideoStream.BitRate); + // ffprobe reports no per-stream video bitrate here. The container bitrate must not be + // misreported as the video bitrate, and the other streams' bitrates exceed the container + // bitrate in this sample, so no sensible video bitrate can be inferred (see #16248). + Assert.Null(res.VideoStream.BitRate); Assert.Equal("h264", res.VideoStream.Codec); Assert.Equal("1/50", res.VideoStream.CodecTimeBase); Assert.Equal(240, res.VideoStream.Height); @@ -322,6 +363,73 @@ namespace Jellyfin.MediaEncoding.Tests.Probing } [Fact] + public void GetMediaInfo_MissingVideoBitrate_EstimatedFromContainer() + { + // ffprobe did not report a per-stream video bitrate. The video bitrate must be estimated + // as the container bitrate minus the other (audio) stream bitrates, not reported as the + // whole container bitrate (see #16248). + var bytes = File.ReadAllBytes("Test Data/Probing/video_missing_video_bitrate.json"); + + var internalMediaInfoResult = JsonSerializer.Deserialize<InternalMediaInfoResult>(bytes, _jsonOptions); + MediaInfo res = _probeResultNormalizer.GetMediaInfo(internalMediaInfoResult, VideoType.VideoFile, false, "Test Data/Probing/video_missing_video_bitrate.mp4", MediaProtocol.File); + + Assert.Equal(2, res.MediaStreams.Count); + + Assert.NotNull(res.VideoStream); + Assert.Equal(MediaStreamType.Video, res.VideoStream.Type); + + var audioStream = res.MediaStreams.First(i => i.Type == MediaStreamType.Audio); + Assert.Equal(128000, audioStream.BitRate); + + // Container bitrate (5128000) minus the audio bitrate (128000). + Assert.Equal(5000000, res.VideoStream.BitRate); + + // The container bitrate itself must remain the overall container bitrate. + Assert.Equal(5128000, res.Bitrate); + } + + [Fact] + public void GetMediaInfo_NanosecondDurationTag_BitrateComputedFromBytes() + { + // The stream carries NUMBER_OF_BYTES and a nanosecond-precision DURATION tag but no + // bitrate. TimeSpan only supports 7 fractional digits, so the 9-digit DURATION must be + // trimmed for the duration to parse and the bitrate to be computed (bytes * 8 / seconds). + var bytes = File.ReadAllBytes("Test Data/Probing/video_nanosecond_duration_bitrate.json"); + + var internalMediaInfoResult = JsonSerializer.Deserialize<InternalMediaInfoResult>(bytes, _jsonOptions); + MediaInfo res = _probeResultNormalizer.GetMediaInfo(internalMediaInfoResult, VideoType.VideoFile, false, "Test Data/Probing/video_nanosecond_duration_bitrate.mkv", MediaProtocol.File); + + Assert.NotNull(res.VideoStream); + + // 10000000 bytes * 8 / 100 seconds. + Assert.Equal(800000, res.VideoStream.BitRate); + } + + [Fact] + public void GetMediaInfo_MissingVideoBitrate_UnknownAudioBitrate_NotEstimated() + { + // ffprobe reported no per-stream video bitrate and the audio bitrate cannot be estimated + // (the audio stream has no channel count, so GetEstimatedAudioBitrate returns null). The + // video bitrate must be left unset rather than wrongly absorbing the unaccounted audio + // bitrate (see #16248). + var bytes = File.ReadAllBytes("Test Data/Probing/video_missing_video_bitrate_unknown_audio.json"); + + var internalMediaInfoResult = JsonSerializer.Deserialize<InternalMediaInfoResult>(bytes, _jsonOptions); + MediaInfo res = _probeResultNormalizer.GetMediaInfo(internalMediaInfoResult, VideoType.VideoFile, false, "Test Data/Probing/video_missing_video_bitrate_unknown_audio.mp4", MediaProtocol.File); + + Assert.Equal(2, res.MediaStreams.Count); + + Assert.NotNull(res.VideoStream); + Assert.Null(res.VideoStream.BitRate); + + var audioStream = res.MediaStreams.First(i => i.Type == MediaStreamType.Audio); + Assert.Null(audioStream.BitRate); + + // The overall container bitrate is still reported. + Assert.Equal(5128000, res.Bitrate); + } + + [Fact] public void GetMediaInfo_VideoWithSingleFrameMjpeg_Success() { var bytes = File.ReadAllBytes("Test Data/Probing/video_single_frame_mjpeg.json"); diff --git a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/AssParserTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/AssParserTests.cs index 1f908d7e0e..b03651e5e9 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/AssParserTests.cs +++ b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/AssParserTests.cs @@ -15,13 +15,13 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests using var stream = File.OpenRead("Test Data/example.ass"); var parsed = new SubtitleEditParser(new NullLogger<SubtitleEditParser>()).Parse(stream, "ass"); - Assert.Single(parsed.TrackEvents); - var trackEvent = parsed.TrackEvents[0]; + Assert.Single(parsed.Paragraphs); + var paragraph = parsed.Paragraphs[0]; - Assert.Equal("1", trackEvent.Id); - Assert.Equal(TimeSpan.Parse("00:00:01.18", CultureInfo.InvariantCulture).Ticks, trackEvent.StartPositionTicks); - Assert.Equal(TimeSpan.Parse("00:00:06.85", CultureInfo.InvariantCulture).Ticks, trackEvent.EndPositionTicks); - Assert.Equal("{\\pos(400,570)}Like an Angel with pity on nobody" + Environment.NewLine + "The second line in subtitle", trackEvent.Text); + Assert.Equal(1, paragraph.Number); + Assert.Equal(TimeSpan.Parse("00:00:01.18", CultureInfo.InvariantCulture).Ticks, paragraph.StartTime.TimeSpan.Ticks); + Assert.Equal(TimeSpan.Parse("00:00:06.85", CultureInfo.InvariantCulture).Ticks, paragraph.EndTime.TimeSpan.Ticks); + Assert.Equal("{\\pos(400,570)}Like an Angel with pity on nobody" + Environment.NewLine + "The second line in subtitle", paragraph.Text); } } } diff --git a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SrtParserTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SrtParserTests.cs index b7152961cd..01a35e6cb0 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SrtParserTests.cs +++ b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SrtParserTests.cs @@ -15,19 +15,19 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests using var stream = File.OpenRead("Test Data/example.srt"); var parsed = new SubtitleEditParser(new NullLogger<SubtitleEditParser>()).Parse(stream, "srt"); - Assert.Equal(2, parsed.TrackEvents.Count); - - var trackEvent1 = parsed.TrackEvents[0]; - Assert.Equal("1", trackEvent1.Id); - Assert.Equal(TimeSpan.Parse("00:02:17.440", CultureInfo.InvariantCulture).Ticks, trackEvent1.StartPositionTicks); - Assert.Equal(TimeSpan.Parse("00:02:20.375", CultureInfo.InvariantCulture).Ticks, trackEvent1.EndPositionTicks); - Assert.Equal("Senator, we're making" + Environment.NewLine + "our final approach into Coruscant.", trackEvent1.Text); - - var trackEvent2 = parsed.TrackEvents[1]; - Assert.Equal("2", trackEvent2.Id); - Assert.Equal(TimeSpan.Parse("00:02:20.476", CultureInfo.InvariantCulture).Ticks, trackEvent2.StartPositionTicks); - Assert.Equal(TimeSpan.Parse("00:02:22.501", CultureInfo.InvariantCulture).Ticks, trackEvent2.EndPositionTicks); - Assert.Equal("Very good, Lieutenant.", trackEvent2.Text); + Assert.Equal(2, parsed.Paragraphs.Count); + + var paragraph1 = parsed.Paragraphs[0]; + Assert.Equal(1, paragraph1.Number); + Assert.Equal(TimeSpan.Parse("00:02:17.440", CultureInfo.InvariantCulture).Ticks, paragraph1.StartTime.TimeSpan.Ticks); + Assert.Equal(TimeSpan.Parse("00:02:20.375", CultureInfo.InvariantCulture).Ticks, paragraph1.EndTime.TimeSpan.Ticks); + Assert.Equal("Senator, we're making" + Environment.NewLine + "our final approach into Coruscant.", paragraph1.Text); + + var paragraph2 = parsed.Paragraphs[1]; + Assert.Equal(2, paragraph2.Number); + Assert.Equal(TimeSpan.Parse("00:02:20.476", CultureInfo.InvariantCulture).Ticks, paragraph2.StartTime.TimeSpan.Ticks); + Assert.Equal(TimeSpan.Parse("00:02:22.501", CultureInfo.InvariantCulture).Ticks, paragraph2.EndTime.TimeSpan.Ticks); + Assert.Equal("Very good, Lieutenant.", paragraph2.Text); } [Fact] @@ -36,19 +36,19 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests using var stream = File.OpenRead("Test Data/example2.srt"); var parsed = new SubtitleEditParser(new NullLogger<SubtitleEditParser>()).Parse(stream, "srt"); - Assert.Equal(2, parsed.TrackEvents.Count); - - var trackEvent1 = parsed.TrackEvents[0]; - Assert.Equal("311", trackEvent1.Id); - Assert.Equal(TimeSpan.Parse("00:16:46.465", CultureInfo.InvariantCulture).Ticks, trackEvent1.StartPositionTicks); - Assert.Equal(TimeSpan.Parse("00:16:49.009", CultureInfo.InvariantCulture).Ticks, trackEvent1.EndPositionTicks); - Assert.Equal("Una vez que la gente se entere" + Environment.NewLine + Environment.NewLine + "de que ustedes están aquí,", trackEvent1.Text); - - var trackEvent2 = parsed.TrackEvents[1]; - Assert.Equal("312", trackEvent2.Id); - Assert.Equal(TimeSpan.Parse("00:16:49.092", CultureInfo.InvariantCulture).Ticks, trackEvent2.StartPositionTicks); - Assert.Equal(TimeSpan.Parse("00:16:51.470", CultureInfo.InvariantCulture).Ticks, trackEvent2.EndPositionTicks); - Assert.Equal("este lugar se convertirá" + Environment.NewLine + Environment.NewLine + "en un maldito zoológico.", trackEvent2.Text); + Assert.Equal(2, parsed.Paragraphs.Count); + + var paragraph1 = parsed.Paragraphs[0]; + Assert.Equal(311, paragraph1.Number); + Assert.Equal(TimeSpan.Parse("00:16:46.465", CultureInfo.InvariantCulture).Ticks, paragraph1.StartTime.TimeSpan.Ticks); + Assert.Equal(TimeSpan.Parse("00:16:49.009", CultureInfo.InvariantCulture).Ticks, paragraph1.EndTime.TimeSpan.Ticks); + Assert.Equal("Una vez que la gente se entere" + Environment.NewLine + Environment.NewLine + "de que ustedes están aquí,", paragraph1.Text); + + var paragraph2 = parsed.Paragraphs[1]; + Assert.Equal(312, paragraph2.Number); + Assert.Equal(TimeSpan.Parse("00:16:49.092", CultureInfo.InvariantCulture).Ticks, paragraph2.StartTime.TimeSpan.Ticks); + Assert.Equal(TimeSpan.Parse("00:16:51.470", CultureInfo.InvariantCulture).Ticks, paragraph2.EndTime.TimeSpan.Ticks); + Assert.Equal("este lugar se convertirá" + Environment.NewLine + Environment.NewLine + "en un maldito zoológico.", paragraph2.Text); } } } diff --git a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SsaParserTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SsaParserTests.cs index 5b7aa7eaa9..d814088593 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SsaParserTests.cs +++ b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SsaParserTests.cs @@ -20,19 +20,19 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests { using Stream stream = new MemoryStream(Encoding.UTF8.GetBytes(ssa)); - SubtitleTrackInfo subtitleTrackInfo = _parser.Parse(stream, "ssa"); + var subtitle = _parser.Parse(stream, "ssa"); - Assert.Equal(expectedSubtitleTrackEvents.Count, subtitleTrackInfo.TrackEvents.Count); + Assert.Equal(expectedSubtitleTrackEvents.Count, subtitle.Paragraphs.Count); for (int i = 0; i < expectedSubtitleTrackEvents.Count; ++i) { SubtitleTrackEvent expected = expectedSubtitleTrackEvents[i]; - SubtitleTrackEvent actual = subtitleTrackInfo.TrackEvents[i]; + var actual = subtitle.Paragraphs[i]; - Assert.Equal(expected.Id, actual.Id); + Assert.Equal(expected.Id, actual.Number.ToString(CultureInfo.InvariantCulture)); Assert.Equal(expected.Text, actual.Text); - Assert.Equal(expected.StartPositionTicks, actual.StartPositionTicks); - Assert.Equal(expected.EndPositionTicks, actual.EndPositionTicks); + Assert.Equal(expected.StartPositionTicks, actual.StartTime.TimeSpan.Ticks); + Assert.Equal(expected.EndPositionTicks, actual.EndTime.TimeSpan.Ticks); } } @@ -75,13 +75,13 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests using var stream = File.OpenRead("Test Data/example.ssa"); var parsed = _parser.Parse(stream, "ssa"); - Assert.Single(parsed.TrackEvents); - var trackEvent = parsed.TrackEvents[0]; + Assert.Single(parsed.Paragraphs); + var paragraph = parsed.Paragraphs[0]; - Assert.Equal("1", trackEvent.Id); - Assert.Equal(TimeSpan.Parse("00:00:01.18", CultureInfo.InvariantCulture).Ticks, trackEvent.StartPositionTicks); - Assert.Equal(TimeSpan.Parse("00:00:06.85", CultureInfo.InvariantCulture).Ticks, trackEvent.EndPositionTicks); - Assert.Equal("{\\pos(400,570)}Like an angel with pity on nobody", trackEvent.Text); + Assert.Equal(1, paragraph.Number); + Assert.Equal(TimeSpan.Parse("00:00:01.18", CultureInfo.InvariantCulture).Ticks, paragraph.StartTime.TimeSpan.Ticks); + Assert.Equal(TimeSpan.Parse("00:00:06.85", CultureInfo.InvariantCulture).Ticks, paragraph.EndTime.TimeSpan.Ticks); + Assert.Equal("{\\pos(400,570)}Like an angel with pity on nobody", paragraph.Text); } } } diff --git a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs index ce1f005f40..48850b2f67 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs +++ b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs @@ -1,3 +1,8 @@ +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; using System.Threading; using System.Threading.Tasks; using AutoFixture; @@ -6,87 +11,120 @@ using MediaBrowser.MediaEncoding.Subtitles; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.MediaInfo; +using Microsoft.Extensions.Logging.Abstractions; using Xunit; namespace Jellyfin.MediaEncoding.Subtitles.Tests { public class SubtitleEncoderTests { - public static TheoryData<MediaSourceInfo, MediaStream, SubtitleEncoder.SubtitleInfo> GetReadableFile_Valid_TestData() + private const int StreamCount = 8; + private const int CueCount = 500; + + // A Greek line that requires a non-UTF-8 legacy encoding to reproduce the bug. The accented + // characters (ά, έ, ή, ί, ό, ύ, ώ) share the same code points in windows-1253 and iso-8859-7, + // so a Greek-vs-Greek charset misdetection still round-trips correctly. + private const string GreekText = "Καλημέρα κόσμε, αυτό είναι ένας υπότιτλος."; + + static SubtitleEncoderTests() { - var data = new TheoryData<MediaSourceInfo, MediaStream, SubtitleEncoder.SubtitleInfo>(); + // Mirrors Jellyfin.Server startup so legacy code pages (e.g. Greek windows-1253) are available. + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + } - data.Add( - new MediaSourceInfo() - { - Protocol = MediaProtocol.File - }, - new MediaStream() - { - Path = "/media/sub.ass", - IsExternal = true - }, - new SubtitleEncoder.SubtitleInfo() - { - Path = "/media/sub.ass", - Protocol = MediaProtocol.File, - Format = "ass", - IsExternal = true - }); + // Enough Greek text to give the charset detector a strong, unambiguous signal. + private static string BuildGreekSrt() + { + var builder = new StringBuilder(); + for (var i = 1; i <= 8; i++) + { + builder.Append(i.ToString(CultureInfo.InvariantCulture)).Append('\n'); + builder.Append("00:00:0").Append(i.ToString(CultureInfo.InvariantCulture)) + .Append(",000 --> 00:00:0").Append((i + 1).ToString(CultureInfo.InvariantCulture)).Append(",000\n"); + builder.Append(GreekText).Append('\n'); + builder.Append("Η γρήγορη καφέ αλεπού πηδάει πάνω από το τεμπέλικο σκυλί.\n\n"); + } - data.Add( - new MediaSourceInfo() - { - Protocol = MediaProtocol.File - }, - new MediaStream() - { - Path = "/media/sub.ssa", - IsExternal = true - }, - new SubtitleEncoder.SubtitleInfo() - { - Path = "/media/sub.ssa", - Protocol = MediaProtocol.File, - Format = "ssa", - IsExternal = true - }); + return builder.ToString(); + } - data.Add( - new MediaSourceInfo() - { - Protocol = MediaProtocol.File - }, - new MediaStream() + public static TheoryData<MediaSourceInfo, MediaStream, SubtitleEncoder.SubtitleInfo> GetReadableFile_Valid_TestData() + { + var data = new TheoryData<MediaSourceInfo, MediaStream, SubtitleEncoder.SubtitleInfo> + { { - Path = "/media/sub.srt", - IsExternal = true + new MediaSourceInfo() + { + Protocol = MediaProtocol.File + }, + new MediaStream() + { + Path = "/media/sub.ass", + IsExternal = true + }, + new SubtitleEncoder.SubtitleInfo() + { + Path = "/media/sub.ass", + Protocol = MediaProtocol.File, + Format = "ass", + IsExternal = true + } }, - new SubtitleEncoder.SubtitleInfo() - { - Path = "/media/sub.srt", - Protocol = MediaProtocol.File, - Format = "srt", - IsExternal = true - }); - - data.Add( - new MediaSourceInfo() { - Protocol = MediaProtocol.Http + new MediaSourceInfo() + { + Protocol = MediaProtocol.File + }, + new MediaStream() + { + Path = "/media/sub.ssa", + IsExternal = true + }, + new SubtitleEncoder.SubtitleInfo() + { + Path = "/media/sub.ssa", + Protocol = MediaProtocol.File, + Format = "ssa", + IsExternal = true + } }, - new MediaStream() { - Path = "/media/sub.ass", - IsExternal = true + new MediaSourceInfo() + { + Protocol = MediaProtocol.File + }, + new MediaStream() + { + Path = "/media/sub.srt", + IsExternal = true + }, + new SubtitleEncoder.SubtitleInfo() + { + Path = "/media/sub.srt", + Protocol = MediaProtocol.File, + Format = "srt", + IsExternal = true + } }, - new SubtitleEncoder.SubtitleInfo() { - Path = "/media/sub.ass", - Protocol = MediaProtocol.File, - Format = "ass", - IsExternal = true - }); + new MediaSourceInfo() + { + Protocol = MediaProtocol.Http + }, + new MediaStream() + { + Path = "/media/sub.ass", + IsExternal = true + }, + new SubtitleEncoder.SubtitleInfo() + { + Path = "/media/sub.ass", + Protocol = MediaProtocol.File, + Format = "ass", + IsExternal = true + } + } + }; return data; } @@ -103,5 +141,177 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests Assert.Equal(subtitleInfo.Format, result.Format); Assert.Equal(subtitleInfo.IsExternal, result.IsExternal); } + + public static TheoryData<Encoding> GetSubtitleStream_NonUtf8LocalFile_TestData() + { + return + [ + // Greek legacy encodings – the exact scenario reported in issue #17267. + Encoding.GetEncoding("windows-1253"), + Encoding.GetEncoding("iso-8859-7"), + // Wide encoding with a BOM. + new UnicodeEncoding(bigEndian: false, byteOrderMark: true), + ]; + } + + [Theory] + [MemberData(nameof(GetSubtitleStream_NonUtf8LocalFile_TestData))] + public async Task GetSubtitleStream_NonUtf8LocalFile_ConvertedToUtf8(Encoding sourceEncoding) + { + var cancellationToken = TestContext.Current.CancellationToken; + var srt = BuildGreekSrt(); + var path = Path.GetTempFileName(); + try + { + await File.WriteAllTextAsync(path, srt, sourceEncoding, cancellationToken); + + var fixture = new Fixture().Customize(new AutoMoqCustomization { ConfigureMembers = true }); + var subtitleEncoder = fixture.Create<SubtitleEncoder>(); + + var fileInfo = new SubtitleEncoder.SubtitleInfo + { + Path = path, + Protocol = MediaProtocol.File, + Format = "srt", + IsExternal = true + }; + + using var stream = await subtitleEncoder.GetSubtitleStream(fileInfo, cancellationToken); + using var reader = new StreamReader(stream, new UTF8Encoding(false)); + var text = await reader.ReadToEndAsync(cancellationToken); + + // The Greek text must survive round-trip and contain no replacement characters. + Assert.Contains(GreekText, text, StringComparison.Ordinal); + Assert.DoesNotContain('�', text); + Assert.DoesNotContain('?', text); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void ConvertSubtitles_SequentialCalls_AreDeterministic() + { + using var encoder = CreateEncoder(); + var sources = GenerateSources(); + + var first = ConvertAllSequential(encoder, sources); + var second = ConvertAllSequential(encoder, sources); + + for (var i = 0; i < StreamCount; i++) + { + Assert.Contains($"S{i}C{CueCount - 1}", first[i], StringComparison.Ordinal); + Assert.Equal(first[i], second[i]); + } + } + + [Fact] + public async Task GetSubtitleStream_Utf8LocalFile_PreservesContent() + { + var cancellationToken = TestContext.Current.CancellationToken; + var srt = BuildGreekSrt(); + var path = Path.GetTempFileName(); + try + { + await File.WriteAllTextAsync(path, srt, new UTF8Encoding(false), cancellationToken); + + var fixture = new Fixture().Customize(new AutoMoqCustomization { ConfigureMembers = true }); + var subtitleEncoder = fixture.Create<SubtitleEncoder>(); + + var fileInfo = new SubtitleEncoder.SubtitleInfo + { + Path = path, + Protocol = MediaProtocol.File, + Format = "srt", + IsExternal = true + }; + + using var stream = await subtitleEncoder.GetSubtitleStream(fileInfo, cancellationToken); + + // An already-UTF-8 file must be short-circuited and served directly from disk, + // not read into memory and re-encoded (which would produce a MemoryStream). + Assert.IsNotType<MemoryStream>(stream); + + using var reader = new StreamReader(stream, new UTF8Encoding(false)); + var text = await reader.ReadToEndAsync(cancellationToken); + + Assert.Contains(GreekText, text, StringComparison.Ordinal); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public async Task ConvertSubtitles_ConcurrentCalls_MatchSequentialBaseline() + { + const int Iterations = 10; + + using var encoder = CreateEncoder(); + var sources = GenerateSources(); + var baseline = ConvertAllSequential(encoder, sources); + + for (var iteration = 0; iteration < Iterations; iteration++) + { + var results = await Task.WhenAll(Enumerable.Range(0, StreamCount) + .Select(i => Task.Run(() => Convert(encoder, sources[i], i))) + .ToArray()); + + for (var i = 0; i < StreamCount; i++) + { + Assert.True( + string.Equals(baseline[i], results[i], StringComparison.Ordinal), + $"Iteration {iteration}: stream {i} returned corrupted content ({results[i].Length} chars vs {baseline[i].Length} baseline)"); + } + } + } + + private static SubtitleEncoder CreateEncoder() + { + var fixture = new Fixture().Customize(new AutoMoqCustomization { ConfigureMembers = true }); + fixture.Inject<ISubtitleParser>(new SubtitleEditParser(NullLogger<SubtitleEditParser>.Instance)); + return fixture.Create<SubtitleEncoder>(); + } + + private static byte[][] GenerateSources() + { + return Enumerable.Range(0, StreamCount) + .Select(i => Encoding.UTF8.GetBytes(GenerateSrt(i, CueCount))) + .ToArray(); + } + + private static string Convert(SubtitleEncoder encoder, byte[] source, int streamIndex) + { + using var input = new MemoryStream(source); + var info = new SubtitleEncoder.SubtitleInfo { Path = $"track{streamIndex}.srt", Format = "srt" }; + using var output = encoder.ConvertSubtitles(input, info, "vtt", 0, 0, false); + return Encoding.UTF8.GetString(output.ToArray()); + } + + private static string[] ConvertAllSequential(SubtitleEncoder encoder, byte[][] sources) + { + return sources.Select((source, i) => Convert(encoder, source, i)).ToArray(); + } + + private static string GenerateSrt(int streamIndex, int cueCount) + { + var builder = new StringBuilder(); + for (var i = 0; i < cueCount; i++) + { + var start = TimeSpan.FromSeconds(i * 4); + var end = start + TimeSpan.FromSeconds(2); + builder.Append(i + 1).AppendLine() + .Append(start.ToString(@"hh\:mm\:ss\,fff", CultureInfo.InvariantCulture)) + .Append(" --> ") + .AppendLine(end.ToString(@"hh\:mm\:ss\,fff", CultureInfo.InvariantCulture)) + .Append('S').Append(streamIndex).Append('C').Append(i).AppendLine() + .AppendLine(); + } + + return builder.ToString(); + } } } diff --git a/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_missing_video_bitrate.json b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_missing_video_bitrate.json new file mode 100644 index 0000000000..803a3a7e5f --- /dev/null +++ b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_missing_video_bitrate.json @@ -0,0 +1,113 @@ +{ + "streams": [ + { + "index": 0, + "codec_name": "h264", + "codec_long_name": "H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10", + "profile": "High", + "codec_type": "video", + "codec_time_base": "1/48", + "codec_tag_string": "avc1", + "codec_tag": "0x31637661", + "width": 1920, + "height": 1080, + "coded_width": 1920, + "coded_height": 1080, + "closed_captions": 0, + "has_b_frames": 2, + "pix_fmt": "yuv420p", + "level": 40, + "chroma_location": "left", + "refs": 1, + "is_avc": "true", + "nal_length_size": "4", + "r_frame_rate": "24/1", + "avg_frame_rate": "24/1", + "time_base": "1/12288", + "start_pts": 0, + "start_time": "0.000000", + "duration_ts": 3686400, + "duration": "300.000000", + "bits_per_raw_sample": "8", + "nb_frames": "7200", + "disposition": { + "default": 1, + "dub": 0, + "original": 0, + "comment": 0, + "lyrics": 0, + "karaoke": 0, + "forced": 0, + "hearing_impaired": 0, + "visual_impaired": 0, + "clean_effects": 0, + "attached_pic": 0, + "timed_thumbnails": 0 + }, + "tags": { + "language": "und", + "handler_name": "VideoHandler" + } + }, + { + "index": 1, + "codec_name": "aac", + "codec_long_name": "AAC (Advanced Audio Coding)", + "profile": "LC", + "codec_type": "audio", + "codec_time_base": "1/48000", + "codec_tag_string": "mp4a", + "codec_tag": "0x6134706d", + "sample_fmt": "fltp", + "sample_rate": "48000", + "channels": 2, + "channel_layout": "stereo", + "bits_per_sample": 0, + "r_frame_rate": "0/0", + "avg_frame_rate": "0/0", + "time_base": "1/48000", + "start_pts": 0, + "start_time": "0.000000", + "duration_ts": 14400000, + "duration": "300.000000", + "bit_rate": "128000", + "nb_frames": "14063", + "disposition": { + "default": 1, + "dub": 0, + "original": 0, + "comment": 0, + "lyrics": 0, + "karaoke": 0, + "forced": 0, + "hearing_impaired": 0, + "visual_impaired": 0, + "clean_effects": 0, + "attached_pic": 0, + "timed_thumbnails": 0 + }, + "tags": { + "language": "eng", + "handler_name": "SoundHandler" + } + } + ], + "format": { + "filename": "test.1080p.mp4", + "nb_streams": 2, + "nb_programs": 0, + "format_name": "mov,mp4,m4a,3gp,3g2,mj2", + "format_long_name": "QuickTime / MOV", + "start_time": "0.000000", + "duration": "300.000000", + "size": "192000000", + "bit_rate": "5128000", + "probe_score": 100, + "tags": { + "major_brand": "isom", + "minor_version": "512", + "compatible_brands": "isomiso2avc1mp41", + "encoder": "Lavf58.20.100" + } + } +} diff --git a/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_missing_video_bitrate_unknown_audio.json b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_missing_video_bitrate_unknown_audio.json new file mode 100644 index 0000000000..ff6dc51f27 --- /dev/null +++ b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_missing_video_bitrate_unknown_audio.json @@ -0,0 +1,110 @@ +{ + "streams": [ + { + "index": 0, + "codec_name": "h264", + "codec_long_name": "H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10", + "profile": "High", + "codec_type": "video", + "codec_time_base": "1/48", + "codec_tag_string": "avc1", + "codec_tag": "0x31637661", + "width": 1920, + "height": 1080, + "coded_width": 1920, + "coded_height": 1080, + "closed_captions": 0, + "has_b_frames": 2, + "pix_fmt": "yuv420p", + "level": 40, + "chroma_location": "left", + "refs": 1, + "is_avc": "true", + "nal_length_size": "4", + "r_frame_rate": "24/1", + "avg_frame_rate": "24/1", + "time_base": "1/12288", + "start_pts": 0, + "start_time": "0.000000", + "duration_ts": 3686400, + "duration": "300.000000", + "bits_per_raw_sample": "8", + "nb_frames": "7200", + "disposition": { + "default": 1, + "dub": 0, + "original": 0, + "comment": 0, + "lyrics": 0, + "karaoke": 0, + "forced": 0, + "hearing_impaired": 0, + "visual_impaired": 0, + "clean_effects": 0, + "attached_pic": 0, + "timed_thumbnails": 0 + }, + "tags": { + "language": "und", + "handler_name": "VideoHandler" + } + }, + { + "index": 1, + "codec_name": "dts", + "codec_long_name": "DCA (DTS Coherent Acoustics)", + "profile": "DTS-HD MA", + "codec_type": "audio", + "codec_time_base": "1/48000", + "codec_tag_string": "[0][0][0][0]", + "codec_tag": "0x0000", + "sample_fmt": "s32p", + "sample_rate": "48000", + "bits_per_sample": 0, + "r_frame_rate": "0/0", + "avg_frame_rate": "0/0", + "time_base": "1/48000", + "start_pts": 0, + "start_time": "0.000000", + "duration_ts": 14400000, + "duration": "300.000000", + "nb_frames": "14063", + "disposition": { + "default": 1, + "dub": 0, + "original": 0, + "comment": 0, + "lyrics": 0, + "karaoke": 0, + "forced": 0, + "hearing_impaired": 0, + "visual_impaired": 0, + "clean_effects": 0, + "attached_pic": 0, + "timed_thumbnails": 0 + }, + "tags": { + "language": "eng", + "handler_name": "SoundHandler" + } + } + ], + "format": { + "filename": "test.1080p.mp4", + "nb_streams": 2, + "nb_programs": 0, + "format_name": "mov,mp4,m4a,3gp,3g2,mj2", + "format_long_name": "QuickTime / MOV", + "start_time": "0.000000", + "duration": "300.000000", + "size": "192000000", + "bit_rate": "5128000", + "probe_score": 100, + "tags": { + "major_brand": "isom", + "minor_version": "512", + "compatible_brands": "isomiso2avc1mp41", + "encoder": "Lavf58.20.100" + } + } +} diff --git a/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_nanosecond_duration_bitrate.json b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_nanosecond_duration_bitrate.json new file mode 100644 index 0000000000..ff8b2ca80a --- /dev/null +++ b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_nanosecond_duration_bitrate.json @@ -0,0 +1,49 @@ +{ + "streams": [ + { + "index": 0, + "codec_name": "h264", + "codec_long_name": "H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10", + "profile": "High", + "codec_type": "video", + "codec_tag_string": "[0][0][0][0]", + "codec_tag": "0x0000", + "width": 1920, + "height": 1080, + "coded_width": 1920, + "coded_height": 1080, + "has_b_frames": 2, + "pix_fmt": "yuv420p", + "level": 40, + "r_frame_rate": "24/1", + "avg_frame_rate": "24/1", + "time_base": "1/1000", + "start_pts": 0, + "start_time": "0.000000", + "disposition": { + "default": 1 + }, + "tags": { + "language": "eng", + "BPS-eng": "", + "DURATION-eng": "00:01:40.000000000", + "NUMBER_OF_FRAMES-eng": "2400", + "NUMBER_OF_BYTES-eng": "10000000" + } + } + ], + "format": { + "filename": "video_nanosecond_duration_bitrate.mkv", + "nb_streams": 1, + "nb_programs": 0, + "format_name": "matroska,webm", + "format_long_name": "Matroska / WebM", + "start_time": "0.000000", + "duration": "100.000000", + "size": "10001000", + "probe_score": 100, + "tags": { + "encoder": "libebml v1.4.2 + libmatroska v1.6.4" + } + } +} diff --git a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs index 16c586bcda..d94d56bc20 100644 --- a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs +++ b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs @@ -81,25 +81,25 @@ namespace Jellyfin.Model.Tests [InlineData("AndroidPixel", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectPlay)] // #6450 [InlineData("AndroidPixel", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.DirectPlay)] // #6450 [InlineData("AndroidPixel", "mp4-h264-ac3-srt-2600k", PlayMethod.DirectPlay)] // #6450 - [InlineData("AndroidPixel", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")] - [InlineData("AndroidPixel", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")] + [InlineData("AndroidPixel", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")] + [InlineData("AndroidPixel", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")] // Yatse [InlineData("Yatse", "mp4-h264-aac-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 [InlineData("Yatse", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)] // #6450 [InlineData("Yatse", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.Transcode, TranscodeReason.SecondaryAudioNotSupported, "Remux")] // #6450 [InlineData("Yatse", "mp4-h264-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)] [InlineData("Yatse", "mp4-hevc-aac-srt-15200k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 - [InlineData("Yatse", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc - [InlineData("Yatse", "mp4-hevc-ac3-aacDef-srt-15200k", PlayMethod.Transcode, TranscodeReason.SecondaryAudioNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc + [InlineData("Yatse", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc + [InlineData("Yatse", "mp4-hevc-ac3-aacDef-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.SecondaryAudioNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc // RokuSSPlus [InlineData("RokuSSPlus", "mp4-h264-aac-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 [InlineData("RokuSSPlus", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)] // #6450 [InlineData("RokuSSPlus", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 should be DirectPlay [InlineData("RokuSSPlus", "mp4-h264-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)] // #6450 [InlineData("RokuSSPlus", "mp4-hevc-aac-srt-15200k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 - [InlineData("RokuSSPlus", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc + [InlineData("RokuSSPlus", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc [InlineData("RokuSSPlus", "mp4-hevc-ac3-aacDef-srt-15200k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 - [InlineData("RokuSSPlus", "mp4-hevc-ac3-srt-15200k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc + [InlineData("RokuSSPlus", "mp4-hevc-ac3-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc // JellyfinMediaPlayer [InlineData("JellyfinMediaPlayer", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 [InlineData("JellyfinMediaPlayer", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 @@ -118,21 +118,21 @@ namespace Jellyfin.Model.Tests [InlineData("Chrome-NoHLS", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Transcode", "http")] [InlineData("Chrome-NoHLS", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode", "http")] [InlineData("Chrome-NoHLS", "mp4-hevc-ac3-aacDef-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.SecondaryAudioNotSupported, "Transcode", "http")] - [InlineData("Chrome-NoHLS", "mkv-vp9-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.ContainerNotSupported, "DirectStream", "http")] // webm requested, aac not supported + [InlineData("Chrome-NoHLS", "mkv-vp9-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.ContainerNotSupported | TranscodeReason.AudioCodecNotSupported, "DirectStream", "http")] // webm requested, aac not supported [InlineData("Chrome-NoHLS", "mkv-vp9-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.ContainerNotSupported | TranscodeReason.AudioCodecNotSupported, "DirectStream", "http")] // #6450 [InlineData("Chrome-NoHLS", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux", "http")] // #6450 // TranscodeMedia [InlineData("TranscodeMedia", "mp4-h264-aac-vtt-2600k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "Remux", "HLS.mp4")] - [InlineData("TranscodeMedia", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "DirectStream", "HLS.mp4")] + [InlineData("TranscodeMedia", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported | TranscodeReason.DirectPlayError, "DirectStream", "HLS.mp4")] [InlineData("TranscodeMedia", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "Remux", "HLS.mp4")] - [InlineData("TranscodeMedia", "mp4-h264-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "DirectStream", "HLS.mp4")] + [InlineData("TranscodeMedia", "mp4-h264-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported | TranscodeReason.DirectPlayError, "DirectStream", "HLS.mp4")] [InlineData("TranscodeMedia", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "Remux", "HLS.mp4")] - [InlineData("TranscodeMedia", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "DirectStream", "HLS.mp4")] + [InlineData("TranscodeMedia", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported | TranscodeReason.DirectPlayError, "DirectStream", "HLS.mp4")] [InlineData("TranscodeMedia", "mp4-hevc-ac3-aacDef-srt-15200k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "Remux", "HLS.mp4")] - [InlineData("TranscodeMedia", "mkv-av1-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "DirectStream", "http")] + [InlineData("TranscodeMedia", "mkv-av1-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported | TranscodeReason.DirectPlayError, "DirectStream", "http")] [InlineData("TranscodeMedia", "mkv-av1-vorbis-srt-2600k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "Remux", "http")] - [InlineData("TranscodeMedia", "mkv-vp9-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "DirectStream", "http")] - [InlineData("TranscodeMedia", "mkv-vp9-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "DirectStream", "http")] + [InlineData("TranscodeMedia", "mkv-vp9-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported | TranscodeReason.DirectPlayError, "DirectStream", "http")] + [InlineData("TranscodeMedia", "mkv-vp9-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported | TranscodeReason.DirectPlayError, "DirectStream", "http")] [InlineData("TranscodeMedia", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "Remux", "http")] // DirectMedia [InlineData("DirectMedia", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] @@ -150,9 +150,9 @@ namespace Jellyfin.Model.Tests [InlineData("LowBandwidth", "mp4-h264-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")] [InlineData("LowBandwidth", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")] [InlineData("LowBandwidth", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")] - [InlineData("LowBandwidth", "mkv-vp9-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")] - [InlineData("LowBandwidth", "mkv-vp9-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")] - [InlineData("LowBandwidth", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.Transcode, TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")] + [InlineData("LowBandwidth", "mkv-vp9-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")] + [InlineData("LowBandwidth", "mkv-vp9-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")] + [InlineData("LowBandwidth", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported | TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")] // Null [InlineData("Null", "mp4-h264-aac-vtt-2600k", null, TranscodeReason.ContainerBitrateExceedsLimit)] [InlineData("Null", "mp4-h264-ac3-aac-srt-2600k", null, TranscodeReason.ContainerBitrateExceedsLimit)] @@ -170,10 +170,10 @@ namespace Jellyfin.Model.Tests [InlineData("AndroidTVExoPlayer", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.DirectPlay)] [InlineData("AndroidTVExoPlayer", "mkv-vp9-aac-srt-2600k", PlayMethod.DirectPlay)] [InlineData("AndroidTVExoPlayer", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectPlay)] - [InlineData("AndroidTVExoPlayer", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow vp9 + [InlineData("AndroidTVExoPlayer", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow vp9 [InlineData("AndroidTVExoPlayer", "mp4-hevc-aac-4000k-r180", PlayMethod.DirectPlay)] // #13712 // AndroidTV NoHevcRotation - [InlineData("AndroidTVExoPlayer-NoHevcRotation", "mp4-hevc-aac-4000k-r180", PlayMethod.Transcode, TranscodeReason.VideoRotationNotSupported, "Transcode")] // #13712 + [InlineData("AndroidTVExoPlayer-NoHevcRotation", "mp4-hevc-aac-4000k-r180", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.VideoRotationNotSupported, "Transcode")] // #13712 // Tizen 3 Stereo [InlineData("Tizen3-stereo", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay)] [InlineData("Tizen3-stereo", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectPlay)] @@ -255,23 +255,23 @@ namespace Jellyfin.Model.Tests [InlineData("AndroidPixel", "mp4-h264-aac-srt-2600k", PlayMethod.DirectPlay)] // #6450 [InlineData("AndroidPixel", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.DirectPlay)] // #6450 [InlineData("AndroidPixel", "mp4-h264-ac3-srt-2600k", PlayMethod.DirectPlay)] // #6450 - [InlineData("AndroidPixel", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")] - [InlineData("AndroidPixel", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")] + [InlineData("AndroidPixel", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")] + [InlineData("AndroidPixel", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")] // Yatse [InlineData("Yatse", "mp4-h264-aac-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 [InlineData("Yatse", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)] // #6450 [InlineData("Yatse", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)] // #6450 [InlineData("Yatse", "mp4-h264-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)] [InlineData("Yatse", "mp4-hevc-aac-srt-15200k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 - [InlineData("Yatse", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc + [InlineData("Yatse", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc // RokuSSPlus [InlineData("RokuSSPlus", "mp4-h264-aac-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 [InlineData("RokuSSPlus", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)] // #6450 should be DirectPlay [InlineData("RokuSSPlus", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)] // #6450 [InlineData("RokuSSPlus", "mp4-h264-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)] // #6450 [InlineData("RokuSSPlus", "mp4-hevc-aac-srt-15200k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 - [InlineData("RokuSSPlus", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc - [InlineData("RokuSSPlus", "mp4-hevc-ac3-srt-15200k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc + [InlineData("RokuSSPlus", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc + [InlineData("RokuSSPlus", "mp4-hevc-ac3-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc // JellyfinMediaPlayer [InlineData("JellyfinMediaPlayer", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 [InlineData("JellyfinMediaPlayer", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 @@ -289,7 +289,7 @@ namespace Jellyfin.Model.Tests [InlineData("AndroidTVExoPlayer", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.DirectPlay)] [InlineData("AndroidTVExoPlayer", "mkv-vp9-aac-srt-2600k", PlayMethod.DirectPlay)] [InlineData("AndroidTVExoPlayer", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectPlay)] - [InlineData("AndroidTVExoPlayer", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow vp9 + [InlineData("AndroidTVExoPlayer", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow vp9 // Tizen 3 Stereo [InlineData("Tizen3-stereo", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay)] [InlineData("Tizen3-stereo", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectPlay)] @@ -336,7 +336,7 @@ namespace Jellyfin.Model.Tests // Yatse [InlineData("Yatse", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.SecondaryAudioNotSupported, "Remux")] // #6450 [InlineData("Yatse", "mp4-h264-ac3-aac-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.SecondaryAudioNotSupported, "Remux")] - [InlineData("Yatse", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.SecondaryAudioNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc + [InlineData("Yatse", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.SecondaryAudioNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc // RokuSSPlus [InlineData("RokuSSPlus", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 [InlineData("RokuSSPlus", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 diff --git a/tests/Jellyfin.Naming.Tests/Book/BookResolverTests.cs b/tests/Jellyfin.Naming.Tests/Book/BookResolverTests.cs new file mode 100644 index 0000000000..19ee13cd75 --- /dev/null +++ b/tests/Jellyfin.Naming.Tests/Book/BookResolverTests.cs @@ -0,0 +1,58 @@ +using Emby.Naming.Book; +using Xunit; + +namespace Jellyfin.Naming.Tests.Book; + +public class BookResolverTests +{ + [Theory] + // seriesName (seriesYear?) #index (of count?) (year?) + [InlineData("Sherlock Holmes (1887) #1 (of 4) (1887)", null, "Sherlock Holmes", 1, 1887)] + [InlineData("Sherlock Holmes #2", null, "Sherlock Holmes", 2, null)] + [InlineData("Sherlock Holmes (1887) #1", null, "Sherlock Holmes", 1, null)] + [InlineData("Sherlock Holmes #2 (1890)", null, "Sherlock Holmes", 2, 1890)] + // name (seriesName, #index) (year?) + [InlineData("A Study in Scarlet (Sherlock Holmes, #1) (1887)", "A Study in Scarlet", "Sherlock Holmes", 1, 1887)] + [InlineData("The Adventures of Sherlock Holmes (Sherlock Holmes, #5)", "The Adventures of Sherlock Holmes", "Sherlock Holmes", 5, null)] + // name (year) + [InlineData("The Sign of the Four (1890)", "The Sign of the Four", null, null, 1890)] + [InlineData("The Valley of Fear (1915)", "The Valley of Fear", null, null, 1915)] + // index - name (year?) + [InlineData("2 - The Sign of the Four (1890)", "The Sign of the Four", null, 2, 1890)] + [InlineData("4 - The Valley of Fear", "The Valley of Fear", null, 4, null)] + // parse entire string as book name + [InlineData("A Study in Scarlet", "A Study in Scarlet", null, null, null)] + [InlineData("The Adventures of Sherlock Holmes", "The Adventures of Sherlock Holmes", null, null, null)] + // leading zeros on index number + [InlineData("00 - Dracula's Guest (1914)", "Dracula's Guest", null, 0, 1914)] + [InlineData("01 - Dracula (1897)", "Dracula", null, 1, 1897)] + // basic decimal support for prequels and novellas + [InlineData("2.0 - Twenty Thousand Leagues Under the Sea", "Twenty Thousand Leagues Under the Sea", null, 2, null)] + // TODO decide how to process non-zero decimals + [InlineData("2.1 - The Blockade Runners", "2.1 - The Blockade Runners", null, null, null)] + public void Resolve_Books(string input, string? name, string? series, int? index, int? year) + { + var result = BookFileNameParser.Parse(input); + + Assert.Equal(name, result.Name); + Assert.Equal(series, result.SeriesName); + Assert.Equal(index, result.Index); + Assert.Equal(year, result.Year); + } + + [Theory] + // name volume? chapter? (year?) + [InlineData("Captain Marvel Adventures v01 (1941)", "Captain Marvel Adventures v01", null, null, 1, 1941)] + [InlineData("Captain Marvel Adventures c120", "Captain Marvel Adventures c120", null, 120, null, null)] + [InlineData("Captain Marvel Adventures v01 c120", "Captain Marvel Adventures v01 c120", null, 120, 1, null)] + public void Resolve_Comics(string input, string? name, string? series, int? chapter, int? volume, int? year) + { + var result = BookFileNameParser.Parse(input); + + Assert.Equal(name, result.Name); + Assert.Equal(series, result.SeriesName); + Assert.Equal(chapter, result.Index); + Assert.Equal(volume, result.ParentIndex); + Assert.Equal(year, result.Year); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs index a5de0a4416..9c247d54b9 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs @@ -11,6 +11,7 @@ using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Trickplay; using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Querying; using Microsoft.Extensions.Logging.Abstractions; using Moq; using Xunit; @@ -56,57 +57,63 @@ public class DtoServiceTests } [Fact] - public void GetBaseItemDto_PreferEpisodeParentPoster_PrefersSeasonPosterOverEpisodeAndSeries() + public void GetBaseItemDto_Episode_AttachesSeasonPosterAsParentPrimaryImage() { - var (episode, season, series) = BuildEpisode(seasonHasPoster: true); - var options = new DtoOptions(false) { PreferEpisodeParentPoster = true }; + var (episode, season, _) = BuildEpisode(seasonHasPoster: true); + var options = new DtoOptions(false) { Fields = [ItemFields.PrimaryImageAspectRatio] }; var dto = _dtoService.GetBaseItemDto(episode, options); - // The episode's own 16:9 primary is dropped in favor of the season's portrait poster. - Assert.False(dto.ImageTags is not null && dto.ImageTags.ContainsKey(ImageType.Primary)); - Assert.Null(dto.SeriesPrimaryImageTag); + // The season poster is attached additively; the episode keeps its own primary and 16:9 ratio, + // and clients decide per view whether to prefer the parent/series poster over the episode still. + Assert.NotNull(dto.ImageTags); + Assert.True(dto.ImageTags.ContainsKey(ImageType.Primary)); + Assert.NotNull(dto.SeriesPrimaryImageTag); Assert.Equal(season.Id, dto.ParentPrimaryImageItemId); Assert.Equal("tag:" + season.GetImageInfo(ImageType.Primary, 0)!.Path, dto.ParentPrimaryImageTag); - // Aspect ratio follows the (portrait) poster, not the episode's 16:9 image. - Assert.Equal(season.GetDefaultPrimaryImageAspectRatio(), dto.PrimaryImageAspectRatio); + // Aspect ratio stays the episode's own image, not the poster's. + Assert.Equal(episode.GetDefaultPrimaryImageAspectRatio(), dto.PrimaryImageAspectRatio); } [Fact] - public void GetBaseItemDto_PreferEpisodeParentPoster_FallsBackToSeriesWhenSeasonHasNoPoster() + public void GetBaseItemDto_Episode_ParentPrimaryImageFallsBackToSeriesWhenSeasonHasNoPoster() { var (episode, _, series) = BuildEpisode(seasonHasPoster: false); - var options = new DtoOptions(false) { PreferEpisodeParentPoster = true }; + var options = new DtoOptions(false); var dto = _dtoService.GetBaseItemDto(episode, options); - Assert.False(dto.ImageTags is not null && dto.ImageTags.ContainsKey(ImageType.Primary)); - Assert.Null(dto.SeriesPrimaryImageTag); + // Episode image is retained; ParentPrimaryImage falls back to the series poster. + Assert.NotNull(dto.ImageTags); + Assert.True(dto.ImageTags.ContainsKey(ImageType.Primary)); + Assert.NotNull(dto.SeriesPrimaryImageTag); Assert.Equal(series.Id, dto.ParentPrimaryImageItemId); Assert.Equal("tag:" + series.GetImageInfo(ImageType.Primary, 0)!.Path, dto.ParentPrimaryImageTag); } [Fact] - public void GetBaseItemDto_WithoutPreferEpisodeParentPoster_KeepsEpisodePrimary() + public void GetBaseItemDto_Episode_WithoutParentPosters_KeepsOnlyEpisodePrimary() { - var (episode, _, _) = BuildEpisode(seasonHasPoster: true); + var (episode, _, _) = BuildEpisode(seasonHasPoster: false, seriesHasPoster: false); var options = new DtoOptions(false); var dto = _dtoService.GetBaseItemDto(episode, options); - // Default behavior: the episode keeps its own primary and exposes the series poster as a tag. + // With no season or series poster there is nothing to attach; the episode keeps its own primary. Assert.NotNull(dto.ImageTags); Assert.True(dto.ImageTags.ContainsKey(ImageType.Primary)); - Assert.NotNull(dto.SeriesPrimaryImageTag); Assert.Null(dto.ParentPrimaryImageItemId); } - private (Episode Episode, Season Season, Series Series) BuildEpisode(bool seasonHasPoster) + private (Episode Episode, Season Season, Series Series) BuildEpisode(bool seasonHasPoster, bool seriesHasPoster = true) { // Non-local (http) paths keep aspect-ratio resolution off the image processor and on the // item's default ratio, which is portrait (2/3) for Season/Series and 16:9 for Episode. var series = new Series { Id = Guid.NewGuid(), Name = "Series" }; - series.SetImage(new ItemImageInfo { Type = ImageType.Primary, Path = "http://test/series.jpg" }, 0); + if (seriesHasPoster) + { + series.SetImage(new ItemImageInfo { Type = ImageType.Primary, Path = "http://test/series.jpg" }, 0); + } var season = new Season { Id = Guid.NewGuid(), Name = "Season", SeriesId = series.Id }; if (seasonHasPoster) diff --git a/tests/Jellyfin.Server.Implementations.Tests/FullSystemBackup/BackupServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/FullSystemBackup/BackupServiceTests.cs new file mode 100644 index 0000000000..66c392a6ad --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/FullSystemBackup/BackupServiceTests.cs @@ -0,0 +1,182 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Locking; +using Jellyfin.Database.Providers.Sqlite; +using Jellyfin.Server.Implementations.FullSystemBackup; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.SystemBackupService; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; +using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind; + +namespace Jellyfin.Server.Implementations.Tests.FullSystemBackup; + +/// <summary> +/// Tests for <see cref="BackupService"/>, in particular that a single row of corrupt +/// <see cref="KeyframeData"/> (e.g. malformed <c>KeyframeTicks</c> JSON) does not abort +/// an otherwise healthy backup. See https://github.com/jellyfin/jellyfin/issues/17216. +/// </summary> +public sealed class BackupServiceTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly DbContextOptions<JellyfinDbContext> _dbOptions; + private readonly string _testRoot; + private readonly string _backupPath; + private readonly string _configurationDirectoryPath; + + public BackupServiceTests() + { + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + + _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>() + .UseSqlite(_connection) + .Options; + + using (var ctx = CreateDbContext()) + { + ctx.Database.EnsureCreated(); + } + + // Use the test assembly's own output directory instead of Path.GetTempPath(). On GitHub-hosted + // windows-latest runners, the system temp directory lives on the constrained C: drive, which can have + // less than the 5GiB BackupService requires free, causing spurious failures. AppContext.BaseDirectory + // is under the repo checkout (the much larger D: drive on Windows runners) on all platforms. + _testRoot = Path.Combine(AppContext.BaseDirectory, "jellyfin-backup-service-tests-" + Guid.NewGuid().ToString("N")); + _backupPath = Path.Combine(_testRoot, "Backup"); + _configurationDirectoryPath = Path.Combine(_testRoot, "Config"); + Directory.CreateDirectory(_backupPath); + Directory.CreateDirectory(_configurationDirectoryPath); + } + + public void Dispose() + { + _connection.Dispose(); + + if (Directory.Exists(_testRoot)) + { + Directory.Delete(_testRoot, true); + } + } + + [Fact] + public async Task CreateBackupAsync_WithCorruptKeyframeDataRow_SkipsRowAndCompletesBackup() + { + var cancellationToken = TestContext.Current.CancellationToken; + var validItemId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + var corruptItemId = Guid.Parse("22222222-2222-2222-2222-222222222222"); + + await using (var ctx = CreateDbContext()) + { + // A healthy item + keyframe row, written the normal way. + ctx.BaseItems.Add(CreateMovieEntity(validItemId, "Good Movie")); + ctx.BaseItems.Add(CreateMovieEntity(corruptItemId, "Corrupt Movie")); + await ctx.SaveChangesAsync(cancellationToken).ConfigureAwait(true); + + ctx.KeyframeData.Add(new KeyframeData + { + ItemId = validItemId, + TotalDuration = 60_000, + KeyframeTicks = [0, 1000, 2000] + }); + await ctx.SaveChangesAsync(cancellationToken).ConfigureAwait(true); + + // Simulate a corrupted database row: truncated JSON array for KeyframeTicks, + // written directly via SQL to bypass EF's normal (well-formed) write path. + await ctx.Database.ExecuteSqlInterpolatedAsync( + $"INSERT INTO KeyframeData (ItemId, TotalDuration, KeyframeTicks) VALUES ({corruptItemId.ToString()}, {5000L}, {"[1,2,3"})", + cancellationToken).ConfigureAwait(true); + } + + var backupService = CreateBackupService(); + + var manifest = await backupService.CreateBackupAsync(new BackupOptionsDto()).ConfigureAwait(true); + + Assert.True(File.Exists(manifest.Path)); + + using var archive = await ZipFile.OpenReadAsync(manifest.Path, cancellationToken).ConfigureAwait(true); + var keyframeEntry = archive.GetEntry("Database/KeyframeData.json"); + Assert.NotNull(keyframeEntry); + + await using var entryStream = await keyframeEntry!.OpenAsync(cancellationToken).ConfigureAwait(true); + using var document = await JsonDocument.ParseAsync(entryStream, cancellationToken: cancellationToken).ConfigureAwait(true); + + var rows = document.RootElement.EnumerateArray().ToList(); + + // The corrupt row must be skipped, but the valid row must still make it into the backup. + var singleRow = Assert.Single(rows); + Assert.Equal(validItemId, singleRow.GetProperty("ItemId").GetGuid()); + } + + private BackupService CreateBackupService() + { + var factory = new Mock<IDbContextFactory<JellyfinDbContext>>(); + factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext); + factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>())).ReturnsAsync(CreateDbContext); + + var applicationHost = new Mock<IServerApplicationHost>(); + applicationHost.Setup(a => a.ApplicationVersion).Returns(new Version(10, 11, 0)); + + var applicationPaths = new Mock<IServerApplicationPaths>(); + applicationPaths.Setup(a => a.BackupPath).Returns(_backupPath); + applicationPaths.Setup(a => a.ConfigurationDirectoryPath).Returns(_configurationDirectoryPath); + applicationPaths.Setup(a => a.DataPath).Returns(Path.Combine(_testRoot, "Data")); + applicationPaths.Setup(a => a.RootFolderPath).Returns(Path.Combine(_testRoot, "Root")); + applicationPaths.Setup(a => a.InternalMetadataPath).Returns(Path.Combine(_testRoot, "Metadata")); + applicationPaths.Setup(a => a.DefaultInternalMetadataPath).Returns(Path.Combine(_testRoot, "MetadataDefault")); + + var jellyfinDatabaseProvider = new Mock<IJellyfinDatabaseProvider>(); + jellyfinDatabaseProvider.Setup(p => p.RunScheduledOptimisation(It.IsAny<CancellationToken>())).Returns(Task.CompletedTask); + jellyfinDatabaseProvider.Setup(p => p.PurgeDatabase(It.IsAny<JellyfinDbContext>(), It.IsAny<System.Collections.Generic.IEnumerable<string>>())).Returns(Task.CompletedTask); + + var applicationLifetime = new Mock<IHostApplicationLifetime>(); + + var libraryManager = new Mock<ILibraryManager>(); + libraryManager.Setup(l => l.IsScanRunning).Returns(false); + + return new BackupService( + NullLogger<BackupService>.Instance, + factory.Object, + applicationHost.Object, + applicationPaths.Object, + jellyfinDatabaseProvider.Object, + applicationLifetime.Object, + libraryManager.Object); + } + + private static BaseItemEntity CreateMovieEntity(Guid id, string name) + { + return new BaseItemEntity + { + Id = id, + Type = "Movie", + Name = name, + PresentationUniqueKey = id.ToString("N"), + MediaType = "Video", + IsMovie = true, + IsFolder = false, + IsVirtualItem = false + }; + } + + private JellyfinDbContext CreateDbContext() + { + return new JellyfinDbContext( + _dbOptions, + NullLogger<JellyfinDbContext>.Instance, + new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance), + new NoLockBehavior(NullLogger<NoLockBehavior>.Instance)); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs b/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs index c06279af2d..6cadfacce8 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs @@ -10,7 +10,7 @@ using Xunit; namespace Jellyfin.Server.Implementations.Tests.IO; -public class ManagedFileSystemTests +public partial class ManagedFileSystemTests { private readonly IFixture _fixture; private readonly ManagedFileSystem _sut; @@ -117,7 +117,7 @@ public class ManagedFileSystemTests } [SuppressMessage("Naming Rules", "SA1300:ElementMustBeginWithUpperCaseLetter", Justification = "Have to")] - [DllImport("libc", SetLastError = true, CharSet = CharSet.Ansi)] + [LibraryImport("libc", SetLastError = true)] [DefaultDllImportSearchPaths(DllImportSearchPath.UserDirectories)] - private static extern int symlink(string target, string linkpath); + private static partial int symlink([MarshalAs(UnmanagedType.LPStr)] string target, [MarshalAs(UnmanagedType.LPStr)] string linkpath); } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs new file mode 100644 index 0000000000..b7fca74310 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs @@ -0,0 +1,237 @@ +#pragma warning disable RS0030 // Do not use banned APIs: Guid == is required inside EF expression trees to mirror the production query shapes. + +using System; +using System.Linq; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Locking; +using Jellyfin.Database.Providers.Sqlite; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +/// <summary> +/// Verifies that the alternate-version-aware query shapes used by the resume filter +/// (BaseItemRepository.TranslateQuery) and the DatePlayed ordering (OrderMapper) translate +/// and evaluate correctly on the SQLite provider. +/// </summary> +public sealed class AlternateVersionQueryTranslationTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly DbContextOptions<JellyfinDbContext> _dbOptions; + + public AlternateVersionQueryTranslationTests() + { + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + + _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>() + .UseSqlite(_connection) + .Options; + + using var ctx = CreateDbContext(); + ctx.Database.EnsureCreated(); + } + + [Fact] + public void ResumeFilter_VersionProgress_SurfacesPlayedVersion() + { + Guid userId, primaryId, versionId, otherId; + + using (var ctx = CreateDbContext()) + { + (userId, primaryId, versionId, otherId) = Seed(ctx); + } + + using (var ctx = CreateDbContext()) + { + var inProgress = ctx.UserData + .Where(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0); + + // Scope to the seeded items; EnsureCreated also seeds a placeholder row. + var seededIds = new[] { primaryId, versionId, otherId }; + + // Mirrors the resumable=true filter in BaseItemRepository.TranslateQuery. + var inProgressIds = inProgress.Select(ud => ud.ItemId); + var resumable = ctx.BaseItems + .Where(e => seededIds.Contains(e.Id)) + .Where(e => inProgressIds.Contains(e.Id)) + .Where(e => !ctx.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))) + .Select(e => e.Id) + .ToList(); + + Assert.Equal([versionId], resumable); + + // The not-resumable direction keeps primaries only. + var resumableMovieIds = inProgress + .Join(ctx.BaseItems, ud => ud.ItemId, bi => bi.Id, (ud, bi) => bi.PrimaryVersionId ?? bi.Id); + var notResumable = ctx.BaseItems + .Where(e => seededIds.Contains(e.Id) && e.PrimaryVersionId == null) + .Where(e => !resumableMovieIds.Contains(e.Id)) + .Select(e => e.Id) + .ToList(); + + Assert.Equal([otherId], notResumable); + } + } + + [Fact] + public void ResumeFilter_TiedLastPlayedDate_KeepsSingleVersion() + { + Guid userId, primaryId, versionAId, versionBId; + + using (var ctx = CreateDbContext()) + { + (userId, primaryId, versionAId, versionBId) = SeedTiedVersions(ctx); + } + + using (var ctx = CreateDbContext()) + { + var inProgress = ctx.UserData + .Where(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0); + + var seededIds = new[] { primaryId, versionAId, versionBId }; + var inProgressIds = inProgress.Select(ud => ud.ItemId); + + // The exact production dedup, including the Guid.CompareTo tie-break. This asserts the + // expression translates on SQLite and that two versions sharing an identical LastPlayedDate + // collapse to a single row instead of double-listing the item in Continue Watching. + var resumable = ctx.BaseItems + .Where(e => seededIds.Contains(e.Id)) + .Where(e => inProgressIds.Contains(e.Id)) + .Where(e => !ctx.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))) + .Select(e => e.Id) + .ToList(); + + var survivor = Assert.Single(resumable); + Assert.Contains(survivor, new[] { versionAId, versionBId }); + } + } + + [Fact] + public void DatePlayedOrdering_VersionProgress_SortsPrimaryByVersionDate() + { + Guid userId, primaryId, otherId; + + using (var ctx = CreateDbContext()) + { + (userId, primaryId, _, otherId) = Seed(ctx); + } + + using (var ctx = CreateDbContext()) + { + // Scope to the seeded items; EnsureCreated also seeds a placeholder row. + var seededIds = new[] { primaryId, otherId }; + + // Mirrors the DatePlayed mapping in OrderMapper. + var ordered = ctx.BaseItems + .Where(e => seededIds.Contains(e.Id) && e.PrimaryVersionId == null) + .OrderByDescending(e => ctx.UserData + .Where(w => w.UserId == userId && (w.ItemId == e.Id || w.Item!.PrimaryVersionId == e.Id)) + .Max(f => f.LastPlayedDate)) + .Select(e => e.Id) + .ToList(); + + // The movie whose only progress is on its alternate version sorts before the unplayed one. + Assert.Equal([primaryId, otherId], ordered); + } + } + + private static (Guid UserId, Guid PrimaryId, Guid VersionId, Guid OtherId) Seed(JellyfinDbContext ctx) + { + var user = new User("test", "auth-provider", "reset-provider"); + ctx.Users.Add(user); + + var primary = new BaseItemEntity { Id = Guid.NewGuid(), Type = "MediaBrowser.Controller.Entities.Movies.Movie" }; + var version = new BaseItemEntity { Id = Guid.NewGuid(), Type = "MediaBrowser.Controller.Entities.Movies.Movie", PrimaryVersionId = primary.Id }; + var other = new BaseItemEntity { Id = Guid.NewGuid(), Type = "MediaBrowser.Controller.Entities.Movies.Movie" }; + ctx.BaseItems.AddRange(primary, version, other); + + // Progress only on the alternate version. + ctx.UserData.Add(new UserData + { + ItemId = version.Id, + Item = version, + UserId = user.Id, + User = user, + CustomDataKey = version.Id.ToString("N"), + PlaybackPositionTicks = 1000, + LastPlayedDate = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc) + }); + + ctx.SaveChanges(); + return (user.Id, primary.Id, version.Id, other.Id); + } + + private static (Guid UserId, Guid PrimaryId, Guid VersionAId, Guid VersionBId) SeedTiedVersions(JellyfinDbContext ctx) + { + var user = new User("test", "auth-provider", "reset-provider"); + ctx.Users.Add(user); + + var primary = new BaseItemEntity { Id = Guid.NewGuid(), Type = "MediaBrowser.Controller.Entities.Movies.Movie" }; + var versionA = new BaseItemEntity { Id = Guid.NewGuid(), Type = "MediaBrowser.Controller.Entities.Movies.Movie", PrimaryVersionId = primary.Id }; + var versionB = new BaseItemEntity { Id = Guid.NewGuid(), Type = "MediaBrowser.Controller.Entities.Movies.Movie", PrimaryVersionId = primary.Id }; + ctx.BaseItems.AddRange(primary, versionA, versionB); + + // Both versions in progress with the exact same LastPlayedDate - the tie that a strict '>' cannot break. + var tied = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc); + ctx.UserData.Add(new UserData + { + ItemId = versionA.Id, + Item = versionA, + UserId = user.Id, + User = user, + CustomDataKey = versionA.Id.ToString("N"), + PlaybackPositionTicks = 1000, + LastPlayedDate = tied + }); + ctx.UserData.Add(new UserData + { + ItemId = versionB.Id, + Item = versionB, + UserId = user.Id, + User = user, + CustomDataKey = versionB.Id.ToString("N"), + PlaybackPositionTicks = 2000, + LastPlayedDate = tied + }); + + ctx.SaveChanges(); + return (user.Id, primary.Id, versionA.Id, versionB.Id); + } + + private JellyfinDbContext CreateDbContext() + { + return new JellyfinDbContext( + _dbOptions, + NullLogger<JellyfinDbContext>.Instance, + new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance), + new NoLockBehavior(NullLogger<NoLockBehavior>.Instance)); + } + + public void Dispose() + { + _connection.Dispose(); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs new file mode 100644 index 0000000000..083f725db9 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs @@ -0,0 +1,144 @@ +using System; +using System.Linq; +using Emby.Server.Implementations.Data; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Locking; +using Jellyfin.Database.Providers.Sqlite; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Entities; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; +using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +public sealed class BaseItemRepositoryGroupingTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly DbContextOptions<JellyfinDbContext> _dbOptions; + private readonly BaseItemRepository _repository; + private readonly string _movieTypeName; + + public BaseItemRepositoryGroupingTests() + { + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + + _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>() + .UseSqlite(_connection) + .Options; + + using (var ctx = CreateDbContext()) + { + ctx.Database.EnsureCreated(); + } + + var factory = new Mock<IDbContextFactory<JellyfinDbContext>>(); + factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext); + + var itemTypeLookup = new ItemTypeLookup(); + _movieTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie]; + + var serverConfigurationManager = new Mock<IServerConfigurationManager>(); + serverConfigurationManager.Setup(c => c.Configuration).Returns(new ServerConfiguration()); + + _repository = new BaseItemRepository( + factory.Object, + new Mock<IServerApplicationHost>().Object, + itemTypeLookup, + serverConfigurationManager.Object, + NullLogger<BaseItemRepository>.Instance); + } + + public void Dispose() + { + _connection.Dispose(); + } + + [Fact] + public void GetItemList_VersionGroup_ReturnsPrimaryVersion() + { + // The alternate version sorts before the primary by id, so a plain Min(Id) per + // presentation key would wrongly pick the alternate as the group representative. + var primaryId = Guid.Parse("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"); + var alternateId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + var presentationKey = primaryId.ToString("N"); + + using (var ctx = CreateDbContext()) + { + ctx.BaseItems.Add(CreateMovieEntity(primaryId, "Movie", presentationKey, null)); + ctx.BaseItems.Add(CreateMovieEntity(alternateId, "Movie - 1080p", presentationKey, primaryId)); + ctx.SaveChanges(); + } + + var result = _repository.GetItemList(CreateQuery()); + + var item = Assert.Single(result); + Assert.Equal(primaryId, item.Id); + } + + [Fact] + public void GetItemList_GroupWithoutPrimary_FallsBackToMinId() + { + var firstId = Guid.Parse("22222222-2222-2222-2222-222222222222"); + var secondId = Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd"); + var otherPrimaryId = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"); + var presentationKey = otherPrimaryId.ToString("N"); + + using (var ctx = CreateDbContext()) + { + ctx.BaseItems.Add(CreateMovieEntity(firstId, "Movie", presentationKey, otherPrimaryId)); + ctx.BaseItems.Add(CreateMovieEntity(secondId, "Movie - 4K", presentationKey, otherPrimaryId)); + ctx.SaveChanges(); + } + + var result = _repository.GetItemList(CreateQuery()); + + var item = Assert.Single(result); + Assert.Equal(firstId, item.Id); + } + + private static InternalItemsQuery CreateQuery() + { + // IncludeOwnedItems keeps the alternate version rows in the query so the + // grouping collapse is what picks the group representative. + return new InternalItemsQuery(new Database.Implementations.Entities.User("test", "auth", "reset")) + { + IncludeItemTypes = [BaseItemKind.Movie], + IncludeOwnedItems = true + }; + } + + private BaseItemEntity CreateMovieEntity(Guid id, string name, string presentationKey, Guid? primaryVersionId) + { + return new BaseItemEntity + { + Id = id, + Type = _movieTypeName, + Name = name, + PresentationUniqueKey = presentationKey, + PrimaryVersionId = primaryVersionId, + MediaType = "Video", + IsMovie = true, + IsFolder = false, + IsVirtualItem = false + }; + } + + private JellyfinDbContext CreateDbContext() + { + return new JellyfinDbContext( + _dbOptions, + NullLogger<JellyfinDbContext>.Instance, + new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance), + new NoLockBehavior(NullLogger<NoLockBehavior>.Instance)); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj b/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj index 958ffb8b6e..29de52a2ba 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj +++ b/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj @@ -4,6 +4,7 @@ <PropertyGroup> <ProjectGuid>{2E3A1B4B-4225-4AAA-8B29-0181A84E7AEE}</ProjectGuid> <OutputType>Exe</OutputType> + <AllowUnsafeBlocks>true</AllowUnsafeBlocks> </PropertyGroup> <ItemGroup> diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs index facdb2bc2e..c80f899498 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Linq; using AutoFixture; using AutoFixture.AutoMoq; using Castle.Components.DictionaryAdapter; @@ -7,6 +9,8 @@ using Emby.Server.Implementations.Library; using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.MediaSegments; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; @@ -144,5 +148,105 @@ namespace Jellyfin.Server.Implementations.Tests.Library _mediaSourceManager.SetDefaultAudioAndSubtitleStreamIndices(_item, mediaInfo, _user); Assert.Equal(expectedIndex, mediaInfo.DefaultAudioStreamIndex); } + + [Fact] + public void GetStaticMediaSources_PrimaryQueried_DefaultsToMostRecentlyPlayedVersion() + { + var (primary, alt1, alt2) = SetupVersionGroup(); + SetupUserDataBatch(new Dictionary<Guid, UserItemData> + { + [alt1.Id] = new UserItemData { Key = "alt1", PlaybackPositionTicks = 10, LastPlayedDate = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc) }, + [alt2.Id] = new UserItemData { Key = "alt2", PlaybackPositionTicks = 20, LastPlayedDate = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc) } + }); + + var sources = _mediaSourceManager.GetStaticMediaSources(primary, false, _user); + + // The most recently played version is the default source, so resuming plays the right file. + // Per-user positions live in each version's UserData, not on the source. + Assert.Equal(alt2.Id.ToString("N"), sources[0].Id); + } + + [Fact] + public void GetStaticMediaSources_AlternateQueried_KeepsOwnSourceFirst() + { + var (primary, alt1, alt2) = SetupVersionGroup(); + SetupUserDataBatch(new Dictionary<Guid, UserItemData> + { + [alt2.Id] = new UserItemData { Key = "alt2", PlaybackPositionTicks = 20, LastPlayedDate = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc) } + }); + + var sources = _mediaSourceManager.GetStaticMediaSources(alt1, false, _user); + + // An explicitly opened version keeps its own source first, even when a sibling was + // played more recently. + Assert.Equal(alt1.Id.ToString("N"), sources[0].Id); + Assert.Equal(3, sources.Count); + } + + [Fact] + public void GetStaticMediaSources_NoProgress_KeepsQueriedItemFirst() + { + var (primary, _, _) = SetupVersionGroup(); + SetupUserDataBatch([]); + + var sources = _mediaSourceManager.GetStaticMediaSources(primary, false, _user); + + Assert.Equal(primary.Id.ToString("N"), sources[0].Id); + } + + [Fact] + public void GetStaticMediaSources_NoUser_DoesNotTouchUserData() + { + var (primary, _, _) = SetupVersionGroup(); + + var sources = _mediaSourceManager.GetStaticMediaSources(primary, false); + + Assert.Equal(primary.Id.ToString("N"), sources[0].Id); + _mockUserDataManager.Verify(x => x.GetUserDataBatch(It.IsAny<IReadOnlyList<BaseItem>>(), It.IsAny<User>()), Times.Never); + } + + private void SetupUserDataBatch(Dictionary<Guid, UserItemData> userData) + { + _mockUserDataManager + .Setup(x => x.GetUserDataBatch(It.IsAny<IReadOnlyList<BaseItem>>(), It.IsAny<User>())) + .Returns((IReadOnlyList<BaseItem> items, User _) => items + .Where(i => userData.ContainsKey(i.Id)) + .ToDictionary(i => i.Id, i => userData[i.Id])); + } + + private static (Video Primary, Video Alt1, Video Alt2) SetupVersionGroup() + { + var primary = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie.mkv" }; + var alt1 = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie - 1080p.mkv", PrimaryVersionId = primary.Id }; + var alt2 = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie - 4K.mkv", PrimaryVersionId = primary.Id }; + + // BaseItem.GetMediaSources runs against the static service locators. + var mediaSourceManager = new Mock<IMediaSourceManager>(); + mediaSourceManager.Setup(x => x.GetPathProtocol(It.IsAny<string>())).Returns(MediaProtocol.File); + mediaSourceManager.Setup(x => x.GetMediaStreams(It.IsAny<Guid>())).Returns(new List<MediaStream>()); + mediaSourceManager.Setup(x => x.GetMediaAttachments(It.IsAny<Guid>())).Returns(new List<MediaAttachment>()); + + var segmentManager = new Mock<IMediaSegmentManager>(); + segmentManager.Setup(x => x.IsTypeSupported(It.IsAny<BaseItem>())).Returns(false); + + var libraryManager = new Mock<ILibraryManager>(); + libraryManager.Setup(x => x.GetLinkedAlternateVersions(It.IsAny<Video>())).Returns(Array.Empty<Video>()); + libraryManager.Setup(x => x.GetLocalAlternateVersionIds(primary)).Returns(new[] { alt1.Id, alt2.Id }); + libraryManager.Setup(x => x.GetLocalAlternateVersionIds(alt1)).Returns(Array.Empty<Guid>()); + libraryManager.Setup(x => x.GetLocalAlternateVersionIds(alt2)).Returns(Array.Empty<Guid>()); + libraryManager.Setup(x => x.GetItemById(primary.Id)).Returns(primary); + libraryManager.Setup(x => x.GetItemById(alt1.Id)).Returns(alt1); + libraryManager.Setup(x => x.GetItemById(alt2.Id)).Returns(alt2); + + var recordingsManager = new Mock<IRecordingsManager>(); + recordingsManager.Setup(x => x.GetActiveRecordingInfo(It.IsAny<string>())).Returns((ActiveRecordingInfo?)null); + + BaseItem.MediaSegmentManager = segmentManager.Object; + BaseItem.MediaSourceManager = mediaSourceManager.Object; + BaseItem.LibraryManager = libraryManager.Object; + Video.RecordingsManager = recordingsManager.Object; + + return (primary, alt1, alt2); + } } } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs index 650d67b195..e65bc1d31f 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs @@ -9,44 +9,105 @@ namespace Jellyfin.Server.Implementations.Tests.Library { [Theory] [InlineData("Superman: Red Son [imdbid=tt10985510]", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son [imdb=tt10985510]", "imdbid", "tt10985510")] [InlineData("Superman: Red Son [imdbid-tt10985510]", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son [imdb-tt10985510]", "imdbid", "tt10985510")] [InlineData("Superman: Red Son - tt10985510", "imdbid", "tt10985510")] [InlineData("Superman: Red Son {imdbid=tt10985510}", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son {imdb=tt10985510}", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son {imdbid-tt10985510}", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son {imdb-tt10985510}", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son (imdbid=tt10985510)", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son (imdb=tt10985510)", "imdbid", "tt10985510")] [InlineData("Superman: Red Son (imdbid-tt10985510)", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son (imdb-tt10985510)", "imdbid", "tt10985510")] [InlineData("Superman: Red Son", "imdbid", null)] [InlineData("Superman: Red Son [imdbid1=tt11111111][imdbid=tt10985510]", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son [imdbid1=tt11111111][imdb=tt10985510]", "imdbid", "tt10985510")] [InlineData("Superman: Red Son {imdbid1=tt11111111}(imdbid=tt10985510)", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son {imdbid1=tt11111111}(imdb=tt10985510)", "imdbid", "tt10985510")] [InlineData("Superman: Red Son (imdbid1-tt11111111)[imdbid=tt10985510]", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son (imdbid1-tt11111111)[imdb=tt10985510]", "imdbid", "tt10985510")] [InlineData("Superman: Red Son [tmdbid=618355][imdbid=tt10985510]", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son [tmdbid=618355][imdb=tt10985510]", "imdbid", "tt10985510")] [InlineData("Superman: Red Son [tmdbid-618355]{imdbid-tt10985510}", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son [tmdbid-618355]{imdb-tt10985510}", "imdbid", "tt10985510")] [InlineData("Superman: Red Son (tmdbid-618355)[imdbid-tt10985510]", "tmdbid", "618355")] + [InlineData("Superman: Red Son (tmdbid-618355)[imdb-tt10985510]", "tmdbid", "618355")] [InlineData("Superman: Red Son [providera-id=1]", "providera-id", "1")] [InlineData("Superman: Red Son [providerb-id=2]", "providerb-id", "2")] [InlineData("Superman: Red Son [providera id=4]", "providera id", "4")] [InlineData("Superman: Red Son [providerb id=5]", "providerb id", "5")] + [InlineData("Superman: Red Son [provider=99][providerid=5]", "providerid", "5")] [InlineData("Superman: Red Son [tmdbid=3]", "tmdbid", "3")] - [InlineData("Superman: Red Son [tvdbid-6]", "tvdbid", "6")] + [InlineData("Superman: Red Son [tmdb=3]", "tmdbid", "3")] + [InlineData("Superman: Red Son [tmdbid-3]", "tmdbid", "3")] + [InlineData("Superman: Red Son [tmdb-3]", "tmdbid", "3")] [InlineData("Superman: Red Son {tmdbid=3}", "tmdbid", "3")] + [InlineData("Superman: Red Son {tmdb=3}", "tmdbid", "3")] + [InlineData("Superman: Red Son {tmdbid-3}", "tmdbid", "3")] + [InlineData("Superman: Red Son {tmdb-3}", "tmdbid", "3")] + [InlineData("Superman: Red Son (tmdbid=6)", "tmdbid", "6")] + [InlineData("Superman: Red Son (tmdb=6)", "tmdbid", "6")] + [InlineData("Superman: Red Son (tmdbid-6)", "tmdbid", "6")] + [InlineData("Superman: Red Son (tmdb-6)", "tmdbid", "6")] + [InlineData("Superman: Red Son [tvdbid=6]", "tvdbid", "6")] + [InlineData("Superman: Red Son [tvdb=6]", "tvdbid", "6")] + [InlineData("Superman: Red Son [tvdbid-6]", "tvdbid", "6")] + [InlineData("Superman: Red Son [tvdb-6]", "tvdbid", "6")] + [InlineData("Superman: Red Son {tvdbid=3}", "tvdbid", "3")] + [InlineData("Superman: Red Son {tvdb=3}", "tvdbid", "3")] + [InlineData("Superman: Red Son {tvdbid-3}", "tvdbid", "3")] + [InlineData("Superman: Red Son {tvdb-3}", "tvdbid", "3")] + [InlineData("Superman: Red Son (tvdbid=6)", "tvdbid", "6")] + [InlineData("Superman: Red Son (tvdb=6)", "tvdbid", "6")] [InlineData("Superman: Red Son (tvdbid-6)", "tvdbid", "6")] + [InlineData("Superman: Red Son (tvdb-6)", "tvdbid", "6")] [InlineData("[tmdbid=618355]", "tmdbid", "618355")] + [InlineData("[tmdb=618355]", "tmdbid", "618355")] [InlineData("{tmdbid=618355}", "tmdbid", "618355")] + [InlineData("{tmdb=618355}", "tmdbid", "618355")] [InlineData("(tmdbid=618355)", "tmdbid", "618355")] + [InlineData("(tmdb=618355)", "tmdbid", "618355")] [InlineData("[tmdbid-618355]", "tmdbid", "618355")] + [InlineData("[tmdb-618355]", "tmdbid", "618355")] [InlineData("{tmdbid-618355)", "tmdbid", null)] + [InlineData("{tmdb-618355)", "tmdbid", null)] [InlineData("[tmdbid-618355}", "tmdbid", null)] + [InlineData("[tmdb-618355}", "tmdbid", null)] [InlineData("tmdbid=111111][tmdbid=618355]", "tmdbid", "618355")] + [InlineData("tmdbid=111111][tmdb=618355]", "tmdbid", "618355")] [InlineData("[tmdbid=618355]tmdbid=111111]", "tmdbid", "618355")] + [InlineData("[tmdb=618355]tmdbid=111111]", "tmdbid", "618355")] [InlineData("tmdbid=618355]", "tmdbid", null)] + [InlineData("tmdb=618355]", "tmdbid", null)] [InlineData("[tmdbid=618355", "tmdbid", null)] + [InlineData("[tmdb=618355", "tmdbid", null)] [InlineData("tmdbid=618355", "tmdbid", null)] + [InlineData("tmdb=618355", "tmdbid", null)] [InlineData("tmdbid=", "tmdbid", null)] + [InlineData("tmdb=", "tmdbid", null)] [InlineData("tmdbid", "tmdbid", null)] + [InlineData("tmdb", "tmdbid", null)] + [InlineData("[tmdbid= ][tmdbid=223344]", "tmdbid", "223344")] + [InlineData("[tmdb= ][tmdb=223344]", "tmdbid", "223344")] + [InlineData("[tmdbid= ][tmdb=223344]", "tmdbid", "223344")] + [InlineData("[tmdb= ][tmdbid=223344]", "tmdbid", "223344")] [InlineData("[tmdbid=][imdbid=tt10985510]", "tmdbid", null)] + [InlineData("[tmdb=][imdbid=tt10985510]", "tmdbid", null)] [InlineData("[tmdbid-][imdbid-tt10985510]", "tmdbid", null)] + [InlineData("[tmdb-][imdbid-tt10985510]", "tmdbid", null)] [InlineData("Superman: Red Son [tmdbid-618355][tmdbid=1234567]", "tmdbid", "618355")] + [InlineData("Superman: Red Son [tmdb-618355][tmdbid=1234567]", "tmdbid", "618355")] [InlineData("{tmdbid=}{imdbid=tt10985510}", "tmdbid", null)] + [InlineData("{tmdb=}{imdbid=tt10985510}", "tmdbid", null)] [InlineData("(tmdbid-)(imdbid-tt10985510)", "tmdbid", null)] + [InlineData("(tmdb-)(imdbid-tt10985510)", "tmdbid", null)] [InlineData("Superman: Red Son {tmdbid-618355}{tmdbid=1234567}", "tmdbid", "618355")] + [InlineData("Superman: Red Son {tmdb-618355}{tmdbid=1234567}", "tmdbid", "618355")] + [InlineData("Superman: Red Son - tt10985510 [imdbid1=tt11]", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son [tmdb=618355][tmdbid1=1]", "tmdbid", "618355")] + [InlineData("Superman: Red Son [tmdb=618355][tmdbid=12345]", "tmdbid", "618355")] public void GetAttributeValue_ValidArgs_Correct(string input, string attribute, string? expectedResult) { Assert.Equal(expectedResult, PathExtensions.GetAttributeValue(input, attribute)); diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs new file mode 100644 index 0000000000..bd14ca008d --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs @@ -0,0 +1,209 @@ +using System; +using System.Collections.Generic; +using Emby.Server.Implementations.Library; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Locking; +using Jellyfin.Database.Providers.Sqlite; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Configuration; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; +using AudioBook = MediaBrowser.Controller.Entities.AudioBook; + +namespace Jellyfin.Server.Implementations.Tests.Library; + +public sealed class UserDataManagerTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly DbContextOptions<JellyfinDbContext> _dbOptions; + private readonly UserDataManager _userDataManager; + private readonly User _user; + + public UserDataManagerTests() + { + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + + _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>() + .UseSqlite(_connection) + .Options; + + using (var ctx = CreateDbContext()) + { + ctx.Database.EnsureCreated(); + } + + var factory = new Mock<IDbContextFactory<JellyfinDbContext>>(); + factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext); + + var config = new Mock<IServerConfigurationManager>(); + config.SetupGet(c => c.Configuration).Returns(new ServerConfiguration()); + + _userDataManager = new UserDataManager(config.Object, factory.Object); + _user = new User("user", "auth-provider", "reset-provider") + { + Id = Guid.NewGuid() + }; + } + + public void Dispose() + { + _connection.Dispose(); + } + + private JellyfinDbContext CreateDbContext() + { + return new JellyfinDbContext( + _dbOptions, + NullLogger<JellyfinDbContext>.Instance, + new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance), + new NoLockBehavior(NullLogger<NoLockBehavior>.Instance)); + } + + private AudioBook CreateAudioBook() + { + // GetUserDataKeys(): ["Author-Series-0001Book Title", "<item id N>"] + return new AudioBook + { + Id = Guid.NewGuid(), + Name = "Book Title", + Album = "Series", + AlbumArtists = new[] { "Author" }, + IndexNumber = 1 + }; + } + + private UserData CreateUserDataRow(AudioBook item, string key, long positionTicks) + { + return new UserData + { + ItemId = item.Id, + Item = null, + UserId = _user.Id, + User = null, + CustomDataKey = key, + PlaybackPositionTicks = positionTicks + }; + } + + [Fact] + public void GetUserData_RowsUnderCurrentAndRetiredKeys_PrefersCurrentKeyRow() + { + var item = CreateAudioBook(); + var currentKey = item.GetUserDataKeys()[0]; + + // the retired-key row comes first to ensure selection is by key, not row order + item.UserData = new List<UserData> + { + CreateUserDataRow(item, "Author-Old Album-0001Old File Name", 111), + CreateUserDataRow(item, currentKey, 222) + }; + + var userData = _userDataManager.GetUserData(_user, item); + + Assert.NotNull(userData); + Assert.Equal(currentKey, userData.Key); + Assert.Equal(222, userData.PlaybackPositionTicks); + } + + [Fact] + public void GetUserData_NoPrimaryKeyRow_UsesNextCurrentKeyRow() + { + var item = CreateAudioBook(); + var idKey = item.GetUserDataKeys()[1]; + + item.UserData = new List<UserData> + { + CreateUserDataRow(item, "Author-Old Album-0001Old File Name", 111), + CreateUserDataRow(item, idKey, 333) + }; + + var userData = _userDataManager.GetUserData(_user, item); + + Assert.NotNull(userData); + Assert.Equal(idKey, userData.Key); + Assert.Equal(333, userData.PlaybackPositionTicks); + } + + [Fact] + public void GetUserData_OnlyRetiredKeyRows_ReturnsRetiredKeyRow() + { + var item = CreateAudioBook(); + + item.UserData = new List<UserData> + { + CreateUserDataRow(item, "Author-Old Album-0001Old File Name", 111) + }; + + var userData = _userDataManager.GetUserData(_user, item); + + Assert.NotNull(userData); + Assert.Equal(111, userData.PlaybackPositionTicks); + } + + [Fact] + public void GetUserData_NoRows_ReturnsDefaultWithPrimaryKey() + { + var item = CreateAudioBook(); + item.UserData = new List<UserData>(); + + var userData = _userDataManager.GetUserData(_user, item); + + Assert.NotNull(userData); + Assert.Equal(item.GetUserDataKeys()[0], userData.Key); + Assert.Equal(0, userData.PlaybackPositionTicks); + } + + [Fact] + public void GetUserData_RowsForOtherUsers_AreIgnored() + { + var item = CreateAudioBook(); + var currentKey = item.GetUserDataKeys()[0]; + + var otherUserRow = CreateUserDataRow(item, currentKey, 999); + otherUserRow.UserId = Guid.NewGuid(); + + item.UserData = new List<UserData> + { + otherUserRow, + CreateUserDataRow(item, currentKey, 222) + }; + + var userData = _userDataManager.GetUserData(_user, item); + + Assert.NotNull(userData); + Assert.Equal(222, userData.PlaybackPositionTicks); + } + + [Fact] + public void GetUserDataBatch_DatabaseFallback_ResolvesRowsByKeyOrder() + { + // no preloaded navigation data, so the batch takes the database fallback + var fossilItem = CreateAudioBook(); + var retiredItem = CreateAudioBook(); + + using (var ctx = CreateDbContext()) + { + ctx.Users.Add(_user); + ctx.BaseItems.Add(new BaseItemEntity { Id = fossilItem.Id, Type = typeof(AudioBook).FullName! }); + ctx.BaseItems.Add(new BaseItemEntity { Id = retiredItem.Id, Type = typeof(AudioBook).FullName! }); + + // the stale id-key row is inserted first so selection by row order would return it + ctx.UserData.AddRange( + CreateUserDataRow(fossilItem, fossilItem.GetUserDataKeys()[1], 111), + CreateUserDataRow(fossilItem, fossilItem.GetUserDataKeys()[0], 222), + CreateUserDataRow(retiredItem, "Author-Old Album-0001Old File Name", 333)); + ctx.SaveChanges(); + } + + var result = _userDataManager.GetUserDataBatch([fossilItem, retiredItem], _user); + + Assert.Equal(222, result[fossilItem.Id].PlaybackPositionTicks); + Assert.Equal(333, result[retiredItem.Id].PlaybackPositionTicks); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs index 3b8fe5ca60..bdb726f06d 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs @@ -345,6 +345,20 @@ namespace Jellyfin.Server.Implementations.Tests.Localization } [Fact] + public void GetLocalizedString_WithBcp47NormalizationToUppercaseRegion_ReturnsTranslation() + { + var localizationManager = Setup(new ServerConfiguration + { + UICulture = "en-US" + }); + + // he-IL normalizes to the underscore resource he_IL. The resource lookup is case-sensitive, + // so the region casing has to be preserved or the file is not found and we fall back to en-US. + var translated = localizationManager.GetLocalizedString("Books", "he-IL"); + Assert.Equal("ספרים", translated); + } + + [Fact] public void GetServerLocalizedString_UsesServerCulture() { var localizationManager = Setup(new ServerConfiguration diff --git a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/GroupTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/GroupTests.cs new file mode 100644 index 0000000000..bd9e680cd9 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/GroupTests.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using Emby.Server.Implementations.SyncPlay; +using Jellyfin.Database.Implementations.Entities; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Session; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.SyncPlay; + +public class GroupTests +{ + public GroupTests() + { + var mockLogger = new Mock<ILogger<Emby.Server.Implementations.SyncPlay.Group>>(); + MockLoggerFactory = new Mock<ILoggerFactory>(); + MockLoggerFactory.Setup(x => x.CreateLogger(It.IsAny<string>())).Returns(mockLogger.Object); + + MockUserManager = new Mock<IUserManager>(); + MockSessionManager = new Mock<ISessionManager>(); + MockLibraryManager = new Mock<ILibraryManager>(); + MockItem = new Mock<BaseItem>(); + MockItem.Setup(i => i.IsVisibleStandalone(It.IsAny<User>())).Returns(true); + } + + private Mock<ILoggerFactory> MockLoggerFactory { get; } + + private Mock<IUserManager> MockUserManager { get; } + + private Mock<ISessionManager> MockSessionManager { get; } + + private Mock<ILibraryManager> MockLibraryManager { get; } + + private Mock<BaseItem> MockItem { get; } + + [Fact] + public void HasAccessToPlayQueue_ReturnsTrue_WhenItemsAreVisible() + { + MockLibraryManager.Setup(m => m.GetItemById(It.IsAny<Guid>())).Returns(MockItem.Object); + + var group = new Emby.Server.Implementations.SyncPlay.Group(MockLoggerFactory.Object, MockUserManager.Object, MockSessionManager.Object, MockLibraryManager.Object); + var itemId = Guid.NewGuid(); + var playlist = new List<Guid> { itemId }; + group.PlayQueue.Reset(); + group.PlayQueue.SetPlaylist(playlist); + + Assert.Single(group.PlayQueue.GetPlaylist()); + Assert.Equal(itemId, group.PlayQueue.GetPlaylist()[0].ItemId); + + var user = new User("test-user", "auth-provider", "pwdreset-provider"); + var result = group.HasAccessToPlayQueue(user); + + Assert.True(result); + } + + [Fact] + public void HasAccessToPlayQueue_ReturnsFalse_WhenLibraryReturnsNullForItem() + { + MockLibraryManager.Setup(m => m.GetItemById(It.IsAny<Guid>())).Returns((BaseItem?)null); + + Assert.Null(MockLibraryManager.Object.GetItemById(Guid.NewGuid())); + + var group = new Emby.Server.Implementations.SyncPlay.Group(MockLoggerFactory.Object, MockUserManager.Object, MockSessionManager.Object, MockLibraryManager.Object); + var itemId = Guid.NewGuid(); + var playlist = new List<Guid> { itemId }; + group.PlayQueue.Reset(); + group.PlayQueue.SetPlaylist(playlist); + + Assert.Single(group.PlayQueue.GetPlaylist()); + Assert.Equal(itemId, group.PlayQueue.GetPlaylist()[0].ItemId); + + var user = new User("test-user", "auth-provider", "pwdreset-provider"); + var result = group.HasAccessToPlayQueue(user); + + Assert.False(result); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs index 92e10c9f92..4a10b2f607 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs @@ -109,5 +109,29 @@ namespace Jellyfin.Server.Implementations.Tests.Updates var ex = await Record.ExceptionAsync(() => _installationManager.InstallPackage(packageInfo, CancellationToken.None)); Assert.Null(ex); } + + [Theory] + [InlineData("../evil")] + [InlineData("..\\evil")] + [InlineData("../../escape_attempt")] + [InlineData("..")] + [InlineData(".")] + [InlineData("")] + [InlineData(" ")] + [InlineData("foo/bar")] + [InlineData("foo\\bar")] + [InlineData("/absolute")] + [InlineData("foo\0bar")] + public async Task InstallPackage_InvalidName_ThrowsInvalidDataException(string name) + { + var packageInfo = new InstallationInfo() + { + Name = name, + SourceUrl = "https://repo.jellyfin.org/releases/plugin/empty/empty.zip", + Checksum = "11b5b2f1a9ebc4f66d6ef19018543361" + }; + + await Assert.ThrowsAsync<InvalidDataException>(() => _installationManager.InstallPackage(packageInfo, CancellationToken.None)); + } } } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerProfileImageTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerProfileImageTests.cs new file mode 100644 index 0000000000..cb714a4014 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerProfileImageTests.cs @@ -0,0 +1,142 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Locking; +using Jellyfin.Database.Providers.Sqlite; +using Jellyfin.Server.Implementations.Users; +using MediaBrowser.Common; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Authentication; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Events; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Cryptography; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Users +{ + public sealed class UserManagerProfileImageTests : IDisposable + { + private readonly SqliteConnection _connection; + private readonly DbContextOptions<JellyfinDbContext> _dbOptions; + private readonly UserManager _userManager; + + public UserManagerProfileImageTests() + { + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + + _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>() + .UseSqlite(_connection) + .Options; + + // Create the schema + using var ctx = CreateDbContext(); + ctx.Database.EnsureCreated(); + + var factory = new Mock<IDbContextFactory<JellyfinDbContext>>(); + factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext); + factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>())) + .ReturnsAsync(CreateDbContext); + + var cryptoProvider = new Mock<ICryptoProvider>(); + var configManager = new Mock<IServerConfigurationManager>(); + var appPaths = new Mock<IServerApplicationPaths>(); + appPaths.Setup(x => x.ProgramDataPath).Returns(Path.GetTempPath()); + configManager.Setup(x => x.ApplicationPaths).Returns(appPaths.Object); + + var appHost = new Mock<IApplicationHost>(); + + var defaultAuthProvider = new DefaultAuthenticationProvider( + NullLogger<DefaultAuthenticationProvider>.Instance, + cryptoProvider.Object); + var invalidAuthProvider = new InvalidAuthProvider(); + var defaultPasswordResetProvider = new DefaultPasswordResetProvider( + configManager.Object, + appHost.Object); + + _userManager = new UserManager( + factory.Object, + new NoopEventManager(), + new Mock<INetworkManager>().Object, + appHost.Object, + new Mock<IImageProcessor>().Object, + NullLogger<UserManager>.Instance, + configManager.Object, + new IPasswordResetProvider[] { defaultPasswordResetProvider }, + new IAuthenticationProvider[] { defaultAuthProvider, invalidAuthProvider }); + } + + public void Dispose() + { + _userManager.Dispose(); + _connection.Dispose(); + } + + private JellyfinDbContext CreateDbContext() + { + return new JellyfinDbContext( + _dbOptions, + NullLogger<JellyfinDbContext>.Instance, + new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance), + new NoLockBehavior(NullLogger<NoLockBehavior>.Instance)); + } + + [Fact] + public async Task ClearProfileImageAsync_WhenInMemoryImageHasTemporaryKey_RemovesPersistedImage() + { + var user = await _userManager.CreateUserAsync("profileimageuser"); + + // Assign a profile image the same way the image endpoint does and persist it. + // UpdateUserAsync creates the persisted ImageInfo on a separately loaded db entity, + // so the in-memory instance below is never assigned the database generated key. + user.ProfileImage = new ImageInfo(Path.Combine(Path.GetTempPath(), "profile.png")); + await _userManager.UpdateUserAsync(user); + + // Precondition reproducing the bug: the in-memory image still carries the default, + // never-persisted (temporary) key, while a real image row exists in the database. + Assert.Equal(0, user.ProfileImage.Id); + Assert.NotNull(_userManager.GetUserById(user.Id)!.ProfileImage); + + // This used to throw InvalidOperationException: + // "The property 'ImageInfo.Id' has a temporary value while attempting to change the entity's state to 'Deleted'." + var exception = await Record.ExceptionAsync(() => _userManager.ClearProfileImageAsync(user)); + + Assert.Null(exception); + Assert.Null(user.ProfileImage); + Assert.Null(_userManager.GetUserById(user.Id)!.ProfileImage); + } + + [Fact] + public async Task ClearProfileImageAsync_WhenNoProfileImage_DoesNothing() + { + var user = await _userManager.CreateUserAsync("noprofileimageuser"); + + var exception = await Record.ExceptionAsync(() => _userManager.ClearProfileImageAsync(user)); + + Assert.Null(exception); + Assert.Null(user.ProfileImage); + } + + private sealed class NoopEventManager : IEventManager + { + public void Publish<T>(T eventArgs) + where T : EventArgs + { + } + + public Task PublishAsync<T>(T eventArgs) + where T : EventArgs + => Task.CompletedTask; + } + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerTests.cs index 4cea53bd3d..2bf1d1d05b 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerTests.cs @@ -27,6 +27,8 @@ namespace Jellyfin.Server.Implementations.Tests.Users [InlineData(" thishasaspaceatthestart")] [InlineData(" thishasaspaceatbothends ")] [InlineData(" this has a space at both ends and inbetween ")] + [InlineData(".")] + [InlineData("..")] public void ThrowIfInvalidUsername_WhenInvalidUsername_ThrowsArgumentException(string username) { Assert.Throws<ArgumentException>(() => UserManager.ThrowIfInvalidUsername(username)); diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/UserControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/UserControllerTests.cs index 7ea56be731..4e01282dcf 100644 --- a/tests/Jellyfin.Server.Integration.Tests/Controllers/UserControllerTests.cs +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/UserControllerTests.cs @@ -1,6 +1,5 @@ using System; using System.Globalization; -using System.Linq; using System.Net; using System.Net.Http; using System.Net.Http.Json; @@ -164,5 +163,33 @@ namespace Jellyfin.Server.Integration.Tests.Controllers using var response = await UpdateUserPassword(client, _testUserId, createRequest); Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); } + + [Fact] + [Priority(2)] + public async Task UpdateUser_UsernameCaseDifference_Success() + { + var client = _factory.CreateClient(); + + client.DefaultRequestHeaders.AddAuthHeader(_accessToken!); + + using var response = await client.GetAsync("Users/" + _testUserId, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var userDto = await response.Content.ReadFromJsonAsync<UserDto>(JsonDefaults.Options, TestContext.Current.CancellationToken); + Assert.NotNull(userDto); + + userDto.Name = userDto.Name.ToLowerInvariant(); + + using var response2 = await client.PostAsJsonAsync($"Users?userId={_testUserId}", userDto, _jsonOptions, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NoContent, response2.StatusCode); + + using var response3 = await client.GetAsync("Users/" + _testUserId, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var newUserDto = await response3.Content.ReadFromJsonAsync<UserDto>(JsonDefaults.Options, TestContext.Current.CancellationToken); + Assert.NotNull(newUserDto); + Assert.Equal(userDto.Name, newUserDto.Name); + + // Sanity check, make sure we're testing something + Assert.NotEqual(TestUsername, userDto.Name); + } } } diff --git a/tests/Jellyfin.Server.Integration.Tests/SyncPlayLostWebSocketTests.cs b/tests/Jellyfin.Server.Integration.Tests/SyncPlayLostWebSocketTests.cs new file mode 100644 index 0000000000..fa15b33af6 --- /dev/null +++ b/tests/Jellyfin.Server.Integration.Tests/SyncPlayLostWebSocketTests.cs @@ -0,0 +1,140 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using System.Net.WebSockets; +using System.Reflection; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Emby.Server.Implementations.Session; +using Jellyfin.Api.Models.SyncPlayDtos; +using Jellyfin.Extensions.Json; +using MediaBrowser.Controller.Net; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Jellyfin.Server.Integration.Tests; + +public sealed class SyncPlayLostWebSocketTests : IClassFixture<JellyfinApplicationFactory> +{ + private readonly JellyfinApplicationFactory _factory; + + public SyncPlayLostWebSocketTests(JellyfinApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task LostWebSocket_EndsSession_And_RemovesEmptySyncPlayGroup() + { + var cancellationToken = TestContext.Current.CancellationToken; + var client = _factory.CreateClient(); + var accessToken = await AuthHelper.CompleteStartupAsync(client); + client.DefaultRequestHeaders.AddAuthHeader(accessToken); + + var wsClient = _factory.Server.CreateWebSocketClient(); + wsClient.ConfigureRequest = request => + request.Headers.Authorization = AuthHelper.DummyAuthHeader + $", Token={accessToken}"; + + var webSocket = await wsClient.ConnectAsync( + new UriBuilder(_factory.Server.BaseAddress) + { + Scheme = "ws", + Path = "websocket" + }.Uri, + cancellationToken); + + _ = DrainAsync(webSocket, cancellationToken); + + var watched = await WaitForWatchedWebSocketsAsync(TimeSpan.FromSeconds(10), cancellationToken); + var connection = Assert.Single(watched); + + using var createResponse = await client.PostAsync( + "SyncPlay/New", + JsonContent.Create(new NewGroupRequestDto { GroupName = "ZombieGroupRepro" }, options: JsonDefaults.Options), + cancellationToken); + Assert.Equal(HttpStatusCode.OK, createResponse.StatusCode); + Assert.Equal(1, await WaitForGroupCountAsync(client, 1, TimeSpan.FromSeconds(10), cancellationToken)); + + connection.LastKeepAliveDate = DateTime.UtcNow - TimeSpan.FromSeconds(180); + + var groupCount = await WaitForGroupCountAsync(client, 0, TimeSpan.FromSeconds(45), cancellationToken); + Assert.True( + groupCount == 0, + $"SyncPlay group still listed {groupCount} group(s) after the WebSocket was lost: " + + "the keep-alive watchdog removed the socket from its watchlist without closing " + + "the session, leaving a zombie participant in the group (SessionWebSocketListener)."); + } + + private static async Task DrainAsync(WebSocket webSocket, CancellationToken cancellationToken) + { + var buffer = new byte[4096]; + try + { + while (webSocket.State == WebSocketState.Open) + { + await webSocket.ReceiveAsync(buffer, cancellationToken); + } + } + catch + { + // The server tears the connection down once the watchdog gives up on it. + } + } + + private async Task<IReadOnlyList<IWebSocketConnection>> WaitForWatchedWebSocketsAsync(TimeSpan timeout, CancellationToken cancellationToken) + { + var listener = _factory.Services.GetRequiredService<IEnumerable<IWebSocketListener>>() + .OfType<SessionWebSocketListener>() + .Single(); + var watchlistField = typeof(SessionWebSocketListener) + .GetField("_webSockets", BindingFlags.NonPublic | BindingFlags.Instance); + Assert.NotNull(watchlistField); + var watchlist = (IEnumerable<IWebSocketConnection>)watchlistField.GetValue(listener)!; + + var stopwatch = Stopwatch.StartNew(); + while (true) + { + try + { + var snapshot = watchlist.ToArray(); + if (snapshot.Length > 0 || stopwatch.Elapsed >= timeout) + { + return snapshot; + } + } + catch (InvalidOperationException) + { + // The watchdog mutated the set during enumeration; retry. + } + + await Task.Delay(100, cancellationToken); + } + } + + private static async Task<int> WaitForGroupCountAsync(HttpClient client, int expected, TimeSpan timeout, CancellationToken cancellationToken) + { + var stopwatch = Stopwatch.StartNew(); + var count = -1; + while (stopwatch.Elapsed < timeout) + { + using var response = await client.GetAsync("SyncPlay/List", cancellationToken); + response.EnsureSuccessStatusCode(); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); + count = document.RootElement.GetArrayLength(); + if (count == expected) + { + return count; + } + + await Task.Delay(500, cancellationToken); + } + + return count; + } +} |
