diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index a3847dcdfb..76e6bce7b3 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "dotnet-ef": { - "version": "8.0.8", + "version": "9.0.4", "commands": [ "dotnet-ef" ] diff --git a/.devcontainer/Dev - Server Ffmpeg/devcontainer.json b/.devcontainer/Dev - Server Ffmpeg/devcontainer.json deleted file mode 100644 index 0b848d9f3c..0000000000 --- a/.devcontainer/Dev - Server Ffmpeg/devcontainer.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "Development Jellyfin Server - FFmpeg", - "image":"mcr.microsoft.com/devcontainers/dotnet:8.0-jammy", - // restores nuget packages, installs the dotnet workloads and installs the dev https certificate - "postStartCommand": "dotnet restore; dotnet workload update; dotnet dev-certs https --trust; sudo bash \"./.devcontainer/Dev - Server Ffmpeg/install-ffmpeg.sh\"", - // reads the extensions list and installs them - "postAttachCommand": "cat .vscode/extensions.json | jq -r .recommendations[] | xargs -n 1 code --install-extension", - "features": { - "ghcr.io/devcontainers/features/dotnet:2": { - "version": "none", - "dotnetRuntimeVersions": "8.0", - "aspNetCoreRuntimeVersions": "8.0" - }, - "ghcr.io/devcontainers-contrib/features/apt-packages:1": { - "preserve_apt_list": false, - "packages": ["libfontconfig1"] - }, - "ghcr.io/devcontainers/features/docker-in-docker:2": { - "dockerDashComposeVersion": "v2" - }, - "ghcr.io/devcontainers/features/github-cli:1": {}, - "ghcr.io/eitsupi/devcontainer-features/jq-likes:2": {} - }, - "hostRequirements": { - "memory": "8gb", - "cpus": 4 - } -} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 063901c800..c2127ba5c3 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,19 +1,23 @@ { "name": "Development Jellyfin Server", - "image":"mcr.microsoft.com/devcontainers/dotnet:8.0-jammy", + "image": "mcr.microsoft.com/devcontainers/dotnet:9.0-bookworm", + "service": "app", + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", // restores nuget packages, installs the dotnet workloads and installs the dev https certificate - "postStartCommand": "dotnet restore; dotnet workload update; dotnet dev-certs https --trust", + "postStartCommand": "sudo dotnet restore; sudo dotnet workload update; sudo dotnet dev-certs https --trust; sudo bash \"./.devcontainer/install-ffmpeg.sh\"", // reads the extensions list and installs them "postAttachCommand": "cat .vscode/extensions.json | jq -r .recommendations[] | xargs -n 1 code --install-extension", "features": { "ghcr.io/devcontainers/features/dotnet:2": { "version": "none", - "dotnetRuntimeVersions": "8.0", - "aspNetCoreRuntimeVersions": "8.0" + "dotnetRuntimeVersions": "9.0", + "aspNetCoreRuntimeVersions": "9.0" }, "ghcr.io/devcontainers-contrib/features/apt-packages:1": { "preserve_apt_list": false, - "packages": ["libfontconfig1"] + "packages": [ + "libfontconfig1" + ] }, "ghcr.io/devcontainers/features/docker-in-docker:2": { "dockerDashComposeVersion": "v2" diff --git a/.devcontainer/Dev - Server Ffmpeg/install-ffmpeg.sh b/.devcontainer/install-ffmpeg.sh similarity index 89% rename from .devcontainer/Dev - Server Ffmpeg/install-ffmpeg.sh rename to .devcontainer/install-ffmpeg.sh index c867ef538c..1e58e6ef44 100644 --- a/.devcontainer/Dev - Server Ffmpeg/install-ffmpeg.sh +++ b/.devcontainer/install-ffmpeg.sh @@ -1,6 +1,6 @@ #!/bin/bash -## configure the following for a manuall install of a specific version from the repo +## configure the following for a manual install of a specific version from the repo # wget https://repo.jellyfin.org/releases/server/ubuntu/versions/jellyfin-ffmpeg/6.0.1-1/jellyfin-ffmpeg6_6.0.1-1-jammy_amd64.deb -O ffmpeg.deb @@ -29,4 +29,4 @@ Signed-By: /etc/apt/keyrings/jellyfin.gpg EOF sudo apt update -y -sudo apt install jellyfin-ffmpeg6 -y +sudo apt install jellyfin-ffmpeg7 -y diff --git a/.editorconfig b/.editorconfig index b84e563efa..ab5d3d9dd1 100644 --- a/.editorconfig +++ b/.editorconfig @@ -192,3 +192,341 @@ csharp_space_between_method_call_empty_parameter_list_parentheses = false # Wrapping preferences csharp_preserve_single_line_statements = true csharp_preserve_single_line_blocks = true + +############################### +# C# Analyzer Rules # +############################### +### ERROR # +########### +# error on SA1000: The keyword 'new' should be followed by a space +dotnet_diagnostic.SA1000.severity = error + +# error on SA1001: Commas should not be preceded by whitespace +dotnet_diagnostic.SA1001.severity = error + +# error on SA1106: Code should not contain empty statements +dotnet_diagnostic.SA1106.severity = error + +# error on SA1107: Code should not contain multiple statements on one line +dotnet_diagnostic.SA1107.severity = error + +# error on SA1028: Code should not contain trailing whitespace +dotnet_diagnostic.SA1028.severity = error + +# error on SA1117: The parameters should all be placed on the same line or each parameter should be placed on its own line +dotnet_diagnostic.SA1117.severity = error + +# error on SA1137: Elements should have the same indentation +dotnet_diagnostic.SA1137.severity = error + +# error on SA1142: Refer to tuple fields by name +dotnet_diagnostic.SA1142.severity = error + +# error on SA1210: Using directives should be ordered alphabetically by the namespaces +dotnet_diagnostic.SA1210.severity = error + +# error on SA1316: Tuple element names should use correct casing +dotnet_diagnostic.SA1316.severity = error + +# error on SA1414: Tuple types in signatures should have element names +dotnet_diagnostic.SA1414.severity = error + +# disable warning SA1513: Closing brace should be followed by blank line +dotnet_diagnostic.SA1513.severity = error + +# error on SA1518: File is required to end with a single newline character +dotnet_diagnostic.SA1518.severity = error + +# error on SA1629: Documentation text should end with a period +dotnet_diagnostic.SA1629.severity = error + +# error on CA1001: Types that own disposable fields should be disposable +dotnet_diagnostic.CA1001.severity = error + +# error on CA1012: Abstract types should not have public constructors +dotnet_diagnostic.CA1012.severity = error + +# error on CA1063: Implement IDisposable correctly +dotnet_diagnostic.CA1063.severity = error + +# error on CA1305: Specify IFormatProvider +dotnet_diagnostic.CA1305.severity = error + +# error on CA1307: Specify StringComparison for clarity +dotnet_diagnostic.CA1307.severity = error + +# error on CA1309: Use ordinal StringComparison +dotnet_diagnostic.CA1309.severity = error + +# error on CA1310: Specify StringComparison for correctness +dotnet_diagnostic.CA1310.severity = error + +# error on CA1513: Use 'ObjectDisposedException.ThrowIf' instead of explicitly throwing a new exception instance +dotnet_diagnostic.CA1513.severity = error + +# error on CA1725: Parameter names should match base declaration +dotnet_diagnostic.CA1725.severity = error + +# error on CA1725: Call async methods when in an async method +dotnet_diagnostic.CA1727.severity = error + +# error on CA1813: Avoid unsealed attributes +dotnet_diagnostic.CA1813.severity = error + +# error on CA1834: Use 'StringBuilder.Append(char)' instead of 'StringBuilder.Append(string)' when the input is a constant unit string +dotnet_diagnostic.CA1834.severity = error + +# error on CA1843: Do not use 'WaitAll' with a single task +dotnet_diagnostic.CA1843.severity = error + +# error on CA1845: Use span-based 'string.Concat' +dotnet_diagnostic.CA1845.severity = error + +# error on CA1849: Call async methods when in an async method +dotnet_diagnostic.CA1849.severity = error + +# error on CA1851: Possible multiple enumerations of IEnumerable collection +dotnet_diagnostic.CA1851.severity = error + +# error on CA1854: Prefer a 'TryGetValue' call over a Dictionary indexer access guarded by a 'ContainsKey' check to avoid double lookup +dotnet_diagnostic.CA1854.severity = error + +# error on CA1860: Avoid using 'Enumerable.Any()' extension method +dotnet_diagnostic.CA1860.severity = error + +# error on CA1862: Use the 'StringComparison' method overloads to perform case-insensitive string comparisons +dotnet_diagnostic.CA1862.severity = error + +# error on CA1863: Use 'CompositeFormat' +dotnet_diagnostic.CA1863.severity = error + +# error on CA1864: Prefer the 'IDictionary.TryAdd(TKey, TValue)' method +dotnet_diagnostic.CA1864.severity = error + +# error on CA1865-CA1867: Use 'string.Method(char)' instead of 'string.Method(string)' for string with single char +dotnet_diagnostic.CA1865.severity = error +dotnet_diagnostic.CA1866.severity = error +dotnet_diagnostic.CA1867.severity = error + +# error on CA1868: Unnecessary call to 'Contains' for sets +dotnet_diagnostic.CA1868.severity = error + +# error on CA1869: Cache and reuse 'JsonSerializerOptions' instances +dotnet_diagnostic.CA1869.severity = error + +# error on CA1870: Use a cached 'SearchValues' instance +dotnet_diagnostic.CA1870.severity = error + +# error on CA1871: Do not pass a nullable struct to 'ArgumentNullException.ThrowIfNull' +dotnet_diagnostic.CA1871.severity = error + +# error on CA1872: Prefer 'Convert.ToHexString' and 'Convert.ToHexStringLower' over call chains based on 'BitConverter.ToString' +dotnet_diagnostic.CA1872.severity = error + +# error on CA2016: Forward the CancellationToken parameter to methods that take one +# or pass in 'CancellationToken.None' explicitly to indicate intentionally not propagating the token +dotnet_diagnostic.CA2016.severity = error + +# error on CA2201: Exception type System.Exception is not sufficiently specific +dotnet_diagnostic.CA2201.severity = error + +# error on CA2215: Dispose methods should call base class dispose +dotnet_diagnostic.CA2215.severity = error + +# error on CA2249: Use 'string.Contains' instead of 'string.IndexOf' to improve readability +dotnet_diagnostic.CA2249.severity = error + +# error on CA2254: Template should be a static expression +dotnet_diagnostic.CA2254.severity = error + +################ +### SUGGESTION # +################ +# disable warning CA1014: Mark assemblies with CLSCompliantAttribute +dotnet_diagnostic.CA1014.severity = suggestion + +# disable warning CA1024: Use properties where appropriate +dotnet_diagnostic.CA1024.severity = suggestion + +# disable warning CA1031: Do not catch general exception types +dotnet_diagnostic.CA1031.severity = suggestion + +# disable warning CA1032: Implement standard exception constructors +dotnet_diagnostic.CA1032.severity = suggestion + +# disable warning CA1040: Avoid empty interfaces +dotnet_diagnostic.CA1040.severity = suggestion + +# disable warning CA1062: Validate arguments of public methods +dotnet_diagnostic.CA1062.severity = suggestion + +# TODO: enable when false positives are fixed +# disable warning CA1508: Avoid dead conditional code +dotnet_diagnostic.CA1508.severity = suggestion + +# disable warning CA1515: Consider making public types internal +dotnet_diagnostic.CA1515.severity = suggestion + +# disable warning CA1716: Identifiers should not match keywords +dotnet_diagnostic.CA1716.severity = suggestion + +# disable warning CA1720: Identifiers should not contain type names +dotnet_diagnostic.CA1720.severity = suggestion + +# disable warning CA1724: Type names should not match namespaces +dotnet_diagnostic.CA1724.severity = suggestion + +# disable warning CA1805: Do not initialize unnecessarily +dotnet_diagnostic.CA1805.severity = suggestion + +# disable warning CA1812: internal class that is apparently never instantiated. +# If so, remove the code from the assembly. +# If this class is intended to contain only static members, make it static +dotnet_diagnostic.CA1812.severity = suggestion + +# disable warning CA1822: Member does not access instance data and can be marked as static +dotnet_diagnostic.CA1822.severity = suggestion + +# CA1859: Use concrete types when possible for improved performance +dotnet_diagnostic.CA1859.severity = suggestion + +# TODO: Enable +# CA1861: Prefer 'static readonly' fields over constant array arguments if the called method is called repeatedly and is not mutating the passed array +dotnet_diagnostic.CA1861.severity = suggestion + +# disable warning CA2000: Dispose objects before losing scope +dotnet_diagnostic.CA2000.severity = suggestion + +# disable warning CA2253: Named placeholders should not be numeric values +dotnet_diagnostic.CA2253.severity = suggestion + +# disable warning CA5394: Do not use insecure randomness +dotnet_diagnostic.CA5394.severity = suggestion + +# error on CA3003: Review code for file path injection vulnerabilities +dotnet_diagnostic.CA3003.severity = suggestion + +# error on CA3006: Review code for process command injection vulnerabilities +dotnet_diagnostic.CA3006.severity = suggestion + +############### +### DISABLED # +############### +# disable warning SA1009: Closing parenthesis should be followed by a space. +dotnet_diagnostic.SA1009.severity = none + +# disable warning SA1011: Closing square bracket should be followed by a space. +dotnet_diagnostic.SA1011.severity = none + +# disable warning SA1101: Prefix local calls with 'this.' +dotnet_diagnostic.SA1101.severity = none + +# disable warning SA1108: Block statements should not contain embedded comments +dotnet_diagnostic.SA1108.severity = none + +# disable warning SA1118: Parameter must not span multiple lines. +dotnet_diagnostic.SA1118.severity = none + +# disable warning SA1128:: Put constructor initializers on their own line +dotnet_diagnostic.SA1128.severity = none + +# disable warning SA1130: Use lambda syntax +dotnet_diagnostic.SA1130.severity = none + +# disable warning SA1200: 'using' directive must appear within a namespace declaration +dotnet_diagnostic.SA1200.severity = none + +# disable warning SA1202: 'public' members must come before 'private' members +dotnet_diagnostic.SA1202.severity = none + +# disable warning SA1204: Static members must appear before non-static members +dotnet_diagnostic.SA1204.severity = none + +# disable warning SA1309: Fields must not begin with an underscore +dotnet_diagnostic.SA1309.severity = none + +# disable warning SA1311: Static readonly fields should begin with upper-case letter +dotnet_diagnostic.SA1311.severity = none + +# disable warning SA1413: Use trailing comma in multi-line initializers +dotnet_diagnostic.SA1413.severity = none + +# disable warning SA1512: Single-line comments must not be followed by blank line +dotnet_diagnostic.SA1512.severity = none + +# disable warning SA1515: Single-line comment should be preceded by blank line +dotnet_diagnostic.SA1515.severity = none + +# disable warning SA1600: Elements should be documented +dotnet_diagnostic.SA1600.severity = none + +# disable warning SA1601: Partial elements should be documented +dotnet_diagnostic.SA1601.severity = none + +# disable warning SA1602: Enumeration items should be documented +dotnet_diagnostic.SA1602.severity = none + +# disable warning SA1633: The file header is missing or not located at the top of the file +dotnet_diagnostic.SA1633.severity = none + +# disable warning CA1054: Change the type of parameter url from string to System.Uri +dotnet_diagnostic.CA1054.severity = none + +# disable warning CA1055: URI return values should not be strings +dotnet_diagnostic.CA1055.severity = none + +# disable warning CA1056: URI properties should not be strings +dotnet_diagnostic.CA1056.severity = none + +# disable warning CA1303: Do not pass literals as localized parameters +dotnet_diagnostic.CA1303.severity = none + +# disable warning CA1308: Normalize strings to uppercase +dotnet_diagnostic.CA1308.severity = none + +# disable warning CA1848: Use the LoggerMessage delegates +dotnet_diagnostic.CA1848.severity = none + +# disable warning CA2101: Specify marshaling for P/Invoke string arguments +dotnet_diagnostic.CA2101.severity = none + +# disable warning CA2234: Pass System.Uri objects instead of strings +dotnet_diagnostic.CA2234.severity = none + +# error on RS0030: Do not used banned APIs +dotnet_diagnostic.RS0030.severity = error + +# disable warning IDISP001: Dispose created +dotnet_diagnostic.IDISP001.severity = suggestion + +# TODO: Enable when false positives are fixed +# disable warning IDISP003: Dispose previous before re-assigning +dotnet_diagnostic.IDISP003.severity = suggestion + +# disable warning IDISP004: Don't ignore created IDisposable +dotnet_diagnostic.IDISP004.severity = suggestion + +# disable warning IDISP007: Don't dispose injected +dotnet_diagnostic.IDISP007.severity = suggestion + +# disable warning IDISP008: Don't assign member with injected and created disposables +dotnet_diagnostic.IDISP008.severity = suggestion + +[tests/**.{cs,vb}] +# disable warning SA0001: XML comment analysis is disabled due to project configuration +dotnet_diagnostic.SA0001.severity = none + +# disable warning CA1707: Identifiers should not contain underscores +dotnet_diagnostic.CA1707.severity = none + +# disable warning CA2007: Consider calling ConfigureAwait on the awaited task +dotnet_diagnostic.CA2007.severity = none + +# disable warning CA2234: Pass system uri objects instead of strings +dotnet_diagnostic.CA2234.severity = suggestion + +# disable warning xUnit1028: Test methods must have a supported return type. +dotnet_diagnostic.xUnit1028.severity = none + +# CA1826: Do not use Enumerable methods on indexable collections +dotnet_diagnostic.CA1826.severity = suggestion diff --git a/.github/ISSUE_TEMPLATE/issue report.yml b/.github/ISSUE_TEMPLATE/issue report.yml index b522412088..4f58c5bc50 100644 --- a/.github/ISSUE_TEMPLATE/issue report.yml +++ b/.github/ISSUE_TEMPLATE/issue report.yml @@ -14,7 +14,7 @@ body: label: "This issue respects the following points:" description: All conditions are **required**. Failure to comply with any of these conditions may cause your issue to be closed without comment. options: - - label: This is a **bug**, not a question or a configuration issue; Please visit our forum or chat rooms first to troubleshoot with volunteers, before creating a report. The links can be found [here](https://jellyfin.org/contact/). + - label: This is a **bug**, not a question or a configuration issue; Please visit our [forum or chat rooms](https://jellyfin.org/contact/) first to troubleshoot with volunteers, before creating a report. required: true - label: This issue is **not** already reported on [GitHub](https://github.com/jellyfin/jellyfin/issues?q=is%3Aopen+is%3Aissue) _(I've searched it)_. required: true @@ -86,7 +86,7 @@ body: label: Jellyfin Server version description: What version of Jellyfin are you using? options: - - 10.9.11+ + - 10.10.0+ - Master - Unstable - Older* diff --git a/.github/workflows/ci-codeql-analysis.yml b/.github/workflows/ci-codeql-analysis.yml index 1c55437a45..1eced1913b 100644 --- a/.github/workflows/ci-codeql-analysis.yml +++ b/.github/workflows/ci-codeql-analysis.yml @@ -22,16 +22,16 @@ jobs: - name: Checkout repository uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup .NET - uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0 + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 with: - dotnet-version: '8.0.x' + dotnet-version: '9.0.x' - name: Initialize CodeQL - uses: github/codeql-action/init@662472033e021d55d94146f66f6058822b0b39fd # v3.27.0 + uses: github/codeql-action/init@45775bd8235c68ba998cffa5171334d58593da47 # v3.28.15 with: languages: ${{ matrix.language }} queries: +security-extended - name: Autobuild - uses: github/codeql-action/autobuild@662472033e021d55d94146f66f6058822b0b39fd # v3.27.0 + uses: github/codeql-action/autobuild@45775bd8235c68ba998cffa5171334d58593da47 # v3.28.15 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@662472033e021d55d94146f66f6058822b0b39fd # v3.27.0 + uses: github/codeql-action/analyze@45775bd8235c68ba998cffa5171334d58593da47 # v3.28.15 diff --git a/.github/workflows/ci-compat.yml b/.github/workflows/ci-compat.yml index c9d9f84493..13b029e52c 100644 --- a/.github/workflows/ci-compat.yml +++ b/.github/workflows/ci-compat.yml @@ -16,12 +16,17 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} repository: ${{ github.event.pull_request.head.repo.full_name }} + - name: Setup .NET + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 + with: + dotnet-version: '9.0.x' + - name: Build run: | dotnet build Jellyfin.Server -o ./out - name: Upload Head - uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: abi-head retention-days: 14 @@ -41,6 +46,11 @@ jobs: repository: ${{ github.event.pull_request.head.repo.full_name }} fetch-depth: 0 + - name: Setup .NET + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 + with: + dotnet-version: '9.0.x' + - name: Checkout common ancestor env: HEAD_REF: ${{ github.head_ref }} @@ -55,7 +65,7 @@ jobs: dotnet build Jellyfin.Server -o ./out - name: Upload Head - uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: abi-base retention-days: 14 @@ -75,13 +85,13 @@ jobs: steps: - name: Download abi-head - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1 with: name: abi-head path: abi-head - name: Download abi-base - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1 with: name: abi-base path: abi-base diff --git a/.github/workflows/ci-openapi.yml b/.github/workflows/ci-openapi.yml index 4633461ad7..95e090f9b6 100644 --- a/.github/workflows/ci-openapi.yml +++ b/.github/workflows/ci-openapi.yml @@ -21,18 +21,18 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} repository: ${{ github.event.pull_request.head.repo.full_name }} - name: Setup .NET - uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0 + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 with: - dotnet-version: '8.0.x' + dotnet-version: '9.0.x' - name: Generate openapi.json run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests" - name: Upload openapi.json - uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: openapi-head retention-days: 14 if-no-files-found: error - path: tests/Jellyfin.Server.Integration.Tests/bin/Release/net8.0/openapi.json + path: tests/Jellyfin.Server.Integration.Tests/bin/Release/net9.0/openapi.json openapi-base: name: OpenAPI - BASE @@ -55,18 +55,18 @@ jobs: ANCESTOR_REF=$(git merge-base upstream/${{ github.base_ref }} origin/$HEAD_REF) git checkout --progress --force $ANCESTOR_REF - name: Setup .NET - uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0 + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 with: - dotnet-version: '8.0.x' + dotnet-version: '9.0.x' - name: Generate openapi.json run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests" - name: Upload openapi.json - uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: openapi-base retention-days: 14 if-no-files-found: error - path: tests/Jellyfin.Server.Integration.Tests/bin/Release/net8.0/openapi.json + path: tests/Jellyfin.Server.Integration.Tests/bin/Release/net9.0/openapi.json openapi-diff: permissions: @@ -80,12 +80,12 @@ jobs: - openapi-base steps: - name: Download openapi-head - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1 with: name: openapi-head path: openapi-head - name: Download openapi-base - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1 with: name: openapi-base path: openapi-base @@ -158,7 +158,7 @@ jobs: run: |- echo "JELLYFIN_VERSION=$(date +'%Y%m%d%H%M%S')" >> $GITHUB_ENV - name: Download openapi-head - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1 with: name: openapi-head path: openapi-head @@ -172,7 +172,7 @@ jobs: strip_components: 1 target: "/srv/incoming/openapi/unstable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}" - name: Move openapi.json (unstable) into place - uses: appleboy/ssh-action@25ce8cbbcb08177468c7ff7ec5cbfa236f9341e1 # v1.1.0 + uses: appleboy/ssh-action@2ead5e36573f08b82fbfce1504f1a4b05a647c6f # v1.2.2 with: host: "${{ secrets.REPO_HOST }}" username: "${{ secrets.REPO_USER }}" @@ -220,7 +220,7 @@ jobs: run: |- echo "JELLYFIN_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV - name: Download openapi-head - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1 with: name: openapi-head path: openapi-head @@ -234,7 +234,7 @@ jobs: strip_components: 1 target: "/srv/incoming/openapi/stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}" - name: Move openapi.json (stable) into place - uses: appleboy/ssh-action@25ce8cbbcb08177468c7ff7ec5cbfa236f9341e1 # v1.1.0 + uses: appleboy/ssh-action@2ead5e36573f08b82fbfce1504f1a4b05a647c6f # v1.2.2 with: host: "${{ secrets.REPO_HOST }}" username: "${{ secrets.REPO_USER }}" diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 54fb762e6b..be4192a446 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -9,19 +9,20 @@ on: pull_request: env: - SDK_VERSION: "8.0.x" + SDK_VERSION: "9.0.x" jobs: run-tests: strategy: matrix: os: ["ubuntu-latest", "macos-latest", "windows-latest"] + fail-fast: false runs-on: "${{ matrix.os }}" steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0 + - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 with: dotnet-version: ${{ env.SDK_VERSION }} @@ -34,7 +35,7 @@ jobs: --verbosity minimal - name: Merge code coverage results - uses: danielpalme/ReportGenerator-GitHub-Action@62f9e70ab348d56eee76d446b4db903a85ab0ea8 # v5.3.11 + uses: danielpalme/ReportGenerator-GitHub-Action@25b1e0261a9f68d7874dbbace168300558ef68f7 # v5.4.5 with: reports: "**/coverage.cobertura.xml" targetdir: "merged/" diff --git a/.github/workflows/commands.yml b/.github/workflows/commands.yml index 26b98f973d..5ec4d164a5 100644 --- a/.github/workflows/commands.yml +++ b/.github/workflows/commands.yml @@ -34,94 +34,6 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.JF_BOT_TOKEN }} - check-backport: - permissions: - contents: read - - name: Check Backport - if: ${{ ( github.event.issue.pull_request && contains(github.event.comment.body, '@jellyfin-bot check backport') ) || github.event.label.name == 'stable backport' || contains(github.event.pull_request.labels.*.name, 'stable backport' ) }} - runs-on: ubuntu-latest - steps: - - name: Notify as seen - uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0 - if: ${{ github.event.comment != null }} - with: - token: ${{ secrets.JF_BOT_TOKEN }} - comment-id: ${{ github.event.comment.id }} - reactions: eyes - - - name: Checkout the latest code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - token: ${{ secrets.JF_BOT_TOKEN }} - fetch-depth: 0 - - - name: Notify as running - id: comment_running - uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0 - if: ${{ github.event.comment != null }} - with: - token: ${{ secrets.JF_BOT_TOKEN }} - issue-number: ${{ github.event.issue.number }} - body: | - Running backport tests... - - - name: Perform test backport - id: run_tests - run: | - set +o errexit - git config --global user.name "Jellyfin Bot" - git config --global user.email "team@jellyfin.org" - CURRENT_BRANCH="origin/${GITHUB_HEAD_REF}" - git checkout master - git merge --no-ff ${CURRENT_BRANCH} - MERGE_COMMIT_HASH=$( git log -q -1 | head -1 | awk '{ print $2 }' ) - git fetch --all - CURRENT_STABLE=$( git branch -r | grep 'origin/release' | sort -rV | head -1 | awk -F '/' '{ print $NF }' ) - stable_branch="Current stable release branch: ${CURRENT_STABLE}" - echo ${stable_branch} - echo ::set-output name=branch::${stable_branch} - git checkout -t origin/${CURRENT_STABLE} -b ${CURRENT_STABLE} - git cherry-pick -sx -m1 ${MERGE_COMMIT_HASH} &>output.txt - retcode=$? - cat output.txt | grep -v 'hint:' - output="$( grep -v 'hint:' output.txt )" - output="${output//'%'/'%25'}" - output="${output//$'\n'/'%0A'}" - output="${output//$'\r'/'%0D'}" - echo ::set-output name=output::$output - exit ${retcode} - - - name: Notify with result success - uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0 - if: ${{ github.event.comment != null && success() }} - with: - token: ${{ secrets.JF_BOT_TOKEN }} - comment-id: ${{ steps.comment_running.outputs.comment-id }} - body: | - ${{ steps.run_tests.outputs.branch }} - Output from `git cherry-pick`: - - --- - - ${{ steps.run_tests.outputs.output }} - reactions: hooray - - - name: Notify with result failure - uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0 - if: ${{ github.event.comment != null && failure() }} - with: - token: ${{ secrets.JF_BOT_TOKEN }} - comment-id: ${{ steps.comment_running.outputs.comment-id }} - body: | - ${{ steps.run_tests.outputs.branch }} - Output from `git cherry-pick`: - - --- - - ${{ steps.run_tests.outputs.output }} - reactions: confused - rename: name: Rename if: contains(github.event.comment.body, '@jellyfin-bot rename') && github.event.comment.author_association == 'MEMBER' @@ -132,9 +44,9 @@ jobs: with: repository: jellyfin/jellyfin-triage-script - name: install python - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 + uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5.5.0 with: - python-version: '3.12' + python-version: '3.13' cache: 'pip' - name: install python packages run: pip install -r rename/requirements.txt diff --git a/.github/workflows/issue-stale.yml b/.github/workflows/issue-stale.yml index 5a1ca9f7a2..624ea564fb 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@28ca1036281a5e5922ead5184a1bbf96e5fc984e # v9.0.0 + - uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.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 b72e552af0..8a21ab0151 100644 --- a/.github/workflows/issue-template-check.yml +++ b/.github/workflows/issue-template-check.yml @@ -14,9 +14,9 @@ jobs: with: repository: jellyfin/jellyfin-triage-script - name: install python - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 + uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5.5.0 with: - python-version: '3.12' + python-version: '3.13' cache: 'pip' - name: install python packages run: pip install -r main-repo-triage/requirements.txt diff --git a/.github/workflows/pull-request-conflict.yml b/.github/workflows/pull-request-conflict.yml index 5d342b7f84..411ebf8290 100644 --- a/.github/workflows/pull-request-conflict.yml +++ b/.github/workflows/pull-request-conflict.yml @@ -15,7 +15,7 @@ jobs: if: ${{ github.repository == 'jellyfin/jellyfin' }} steps: - name: Apply label - uses: eps1lon/actions-label-merge-conflict@1b1b1fcde06a9b3d089f3464c96417961dde1168 # v3.0.2 + uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3 if: ${{ github.event_name == 'push' || github.event_name == 'pull_request_target'}} with: dirtyLabel: 'merge conflict' diff --git a/.github/workflows/pull-request-stale.yaml b/.github/workflows/pull-request-stale.yaml index d01b3f4a1f..7ce5b0fa61 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@28ca1036281a5e5922ead5184a1bbf96e5fc984e # v9.0.0 + - uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0 with: repo-token: ${{ secrets.JF_BOT_TOKEN }} ascending: true diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 3be946e446..e4205ce0b1 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,12 +1,13 @@ { - "recommendations": [ + "recommendations": [ "ms-dotnettools.csharp", "editorconfig.editorconfig", "github.vscode-github-actions", "ms-dotnettools.vscode-dotnet-runtime", - "ms-dotnettools.csdevkit" - ], - "unwantedRecommendations": [ + "ms-dotnettools.csdevkit", + "alexcvzz.vscode-sqlite" + ], + "unwantedRecommendations": [ - ] + ] } diff --git a/.vscode/launch.json b/.vscode/launch.json index 7e50d4f0a4..d97d8de843 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -6,7 +6,7 @@ "type": "coreclr", "request": "launch", "preLaunchTask": "build", - "program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net8.0/jellyfin.dll", + "program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net9.0/jellyfin.dll", "args": [], "cwd": "${workspaceFolder}/Jellyfin.Server", "console": "internalConsole", @@ -22,7 +22,7 @@ "type": "coreclr", "request": "launch", "preLaunchTask": "build", - "program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net8.0/jellyfin.dll", + "program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net9.0/jellyfin.dll", "args": ["--nowebclient"], "cwd": "${workspaceFolder}/Jellyfin.Server", "console": "internalConsole", @@ -34,7 +34,7 @@ "type": "coreclr", "request": "launch", "preLaunchTask": "build", - "program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net8.0/jellyfin.dll", + "program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net9.0/jellyfin.dll", "args": ["--nowebclient", "--ffmpeg", "/usr/lib/jellyfin-ffmpeg/ffmpeg"], "cwd": "${workspaceFolder}/Jellyfin.Server", "console": "internalConsole", diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000000..6733d59aca --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "dotnet.preferVisualStudioCodeFileSystemWatcher": true +} diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index a9deb1c4a2..0dcce1ea18 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -192,6 +192,9 @@ - [jaina heartles](https://github.com/heartles) - [oxixes](https://github.com/oxixes) - [elfalem](https://github.com/elfalem) + - [Kenneth Cochran](https://github.com/kennethcochran) + - [benedikt257](https://github.com/benedikt257) + - [revam](https://github.com/revam) # Emby Contributors @@ -265,3 +268,5 @@ - [0x25CBFC4F](https://github.com/0x25CBFC4F) - [Robert Lützner](https://github.com/rluetzner) - [Nathan McCrina](https://github.com/nfmccrina) + - [Martin Reuter](https://github.com/reuterma24) + - [Michael McElroy](https://github.com/mcmcelro) diff --git a/Directory.Build.props b/Directory.Build.props index 44a60ffb5c..31ae8bfbe4 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,11 +3,11 @@ enable - $(MSBuildThisFileDirectory)/jellyfin.ruleset true + NU1902;NU1903 diff --git a/Directory.Packages.props b/Directory.Packages.props index 5aadeb2541..cdce608d9c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,88 +4,88 @@ - + - - + + + - + - - + + - - + + - - + + - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + - + - + - + - - - - + + + + - + - - - + + + - + - - + + \ No newline at end of file diff --git a/Emby.Naming/Common/NamingOptions.cs b/Emby.Naming/Common/NamingOptions.cs index 333d237a24..6a662aaf55 100644 --- a/Emby.Naming/Common/NamingOptions.cs +++ b/Emby.Naming/Common/NamingOptions.cs @@ -238,6 +238,7 @@ namespace Emby.Naming.Common ".dsp", ".dts", ".dvf", + ".eac3", ".far", ".flac", ".gdm", @@ -467,6 +468,14 @@ namespace Emby.Naming.Common { IsNamed = true }, + + // Anime style expression + // "[Group][Series Name][21][1080p][FLAC][HASH]" + // "[Group] Series Name [04][BDRIP]" + new EpisodeExpression(@"(?:\[(?:[^\]]+)\]\s*)?(?\[[^\]]+\]|[^[\]]+)\s*\[(?[0-9]+)\]") + { + IsNamed = true + }, }; VideoExtraRules = new[] diff --git a/Emby.Naming/Emby.Naming.csproj b/Emby.Naming/Emby.Naming.csproj index 7eb131575d..20b32f3a62 100644 --- a/Emby.Naming/Emby.Naming.csproj +++ b/Emby.Naming/Emby.Naming.csproj @@ -6,7 +6,7 @@ - net8.0 + net9.0 false true true @@ -36,7 +36,7 @@ Jellyfin Contributors Jellyfin.Naming - 10.10.0 + 10.11.0 https://github.com/jellyfin/jellyfin GPL-3.0-only diff --git a/Emby.Naming/TV/SeasonPathParser.cs b/Emby.Naming/TV/SeasonPathParser.cs index 45b91971bf..98ee1e4b8f 100644 --- a/Emby.Naming/TV/SeasonPathParser.cs +++ b/Emby.Naming/TV/SeasonPathParser.cs @@ -1,43 +1,35 @@ using System; using System.Globalization; using System.IO; +using System.Text.RegularExpressions; namespace Emby.Naming.TV { /// /// Class to parse season paths. /// - public static class SeasonPathParser + public static partial class SeasonPathParser { - /// - /// A season folder must contain one of these somewhere in the name. - /// - private static readonly string[] _seasonFolderNames = - { - "season", - "sæson", - "temporada", - "saison", - "staffel", - "series", - "сезон", - "stagione" - }; + [GeneratedRegex(@"^\s*((?(?>\d+))(?:st|nd|rd|th|\.)*(?!\s*[Ee]\d+))\s*(?:[[시즌]*|[シーズン]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?.*)$")] + private static partial Regex ProcessPre(); - private static readonly char[] _splitChars = ['.', '_', ' ', '-']; + [GeneratedRegex(@"^\s*(?:[[시즌]*|[シーズン]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?(?>\d+)(?!\s*[Ee]\d+))(?.*)$")] + private static partial Regex ProcessPost(); /// /// Attempts to parse season number from path. /// /// Path to season. + /// Folder name of the parent. /// Support special aliases when parsing. /// Support numeric season folders when parsing. /// Returns object. - public static SeasonPathParserResult Parse(string path, bool supportSpecialAliases, bool supportNumericSeasonFolders) + public static SeasonPathParserResult Parse(string path, string? parentPath, bool supportSpecialAliases, bool supportNumericSeasonFolders) { var result = new SeasonPathParserResult(); + var parentFolderName = parentPath is null ? null : new DirectoryInfo(parentPath).Name; - var (seasonNumber, isSeasonFolder) = GetSeasonNumberFromPath(path, supportSpecialAliases, supportNumericSeasonFolders); + var (seasonNumber, isSeasonFolder) = GetSeasonNumberFromPath(path, parentFolderName, supportSpecialAliases, supportNumericSeasonFolders); result.SeasonNumber = seasonNumber; @@ -54,15 +46,24 @@ namespace Emby.Naming.TV /// Gets the season number from path. /// /// The path. + /// The parent folder name. /// if set to true [support special aliases]. /// if set to true [support numeric season folders]. /// System.Nullable{System.Int32}. private static (int? SeasonNumber, bool IsSeasonFolder) GetSeasonNumberFromPath( string path, + string? parentFolderName, bool supportSpecialAliases, bool supportNumericSeasonFolders) { string filename = Path.GetFileName(path); + filename = Regex.Replace(filename, "[ ._-]", string.Empty); + + if (parentFolderName is not null) + { + parentFolderName = Regex.Replace(parentFolderName, "[ ._-]", string.Empty); + filename = filename.Replace(parentFolderName, string.Empty, StringComparison.OrdinalIgnoreCase); + } if (supportSpecialAliases) { @@ -85,53 +86,38 @@ namespace Emby.Naming.TV } } - if (TryGetSeasonNumberFromPart(filename, out int seasonNumber)) + if (filename.StartsWith('s')) { + var testFilename = filename.AsSpan()[1..]; + + if (int.TryParse(testFilename, NumberStyles.Integer, CultureInfo.InvariantCulture, out var val)) + { + return (val, true); + } + } + + var preMatch = ProcessPre().Match(filename); + if (preMatch.Success) + { + return CheckMatch(preMatch); + } + else + { + var postMatch = ProcessPost().Match(filename); + return CheckMatch(postMatch); + } + } + + private static (int? SeasonNumber, bool IsSeasonFolder) CheckMatch(Match match) + { + var numberString = match.Groups["seasonnumber"]; + if (numberString.Success) + { + var seasonNumber = int.Parse(numberString.Value, CultureInfo.InvariantCulture); return (seasonNumber, true); } - // Look for one of the season folder names - foreach (var name in _seasonFolderNames) - { - if (filename.Contains(name, StringComparison.OrdinalIgnoreCase)) - { - var result = GetSeasonNumberFromPathSubstring(filename.Replace(name, " ", StringComparison.OrdinalIgnoreCase)); - if (result.SeasonNumber.HasValue) - { - return result; - } - - break; - } - } - - var parts = filename.Split(_splitChars, StringSplitOptions.RemoveEmptyEntries); - foreach (var part in parts) - { - if (TryGetSeasonNumberFromPart(part, out seasonNumber)) - { - return (seasonNumber, true); - } - } - - return (null, true); - } - - private static bool TryGetSeasonNumberFromPart(ReadOnlySpan part, out int seasonNumber) - { - seasonNumber = 0; - if (part.Length < 2 || !part.StartsWith("s", StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - if (int.TryParse(part.Slice(1), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)) - { - seasonNumber = value; - return true; - } - - return false; + return (null, false); } /// diff --git a/Emby.Naming/TV/SeriesResolver.cs b/Emby.Naming/TV/SeriesResolver.cs index d8fa417436..c955b8a0db 100644 --- a/Emby.Naming/TV/SeriesResolver.cs +++ b/Emby.Naming/TV/SeriesResolver.cs @@ -12,7 +12,7 @@ namespace Emby.Naming.TV /// /// Regex that matches strings of at least 2 characters separated by a dot or underscore. /// Used for removing separators between words, i.e turns "The_show" into "The show" while - /// preserving namings like "S.H.O.W". + /// preserving names like "S.H.O.W". /// [GeneratedRegex(@"((?[^\._]{2,})[\._]*)|([\._](?[^\._]{2,}))")] private static partial Regex SeriesNameRegex(); diff --git a/Emby.Naming/Video/ExtraRuleResolver.cs b/Emby.Naming/Video/ExtraRuleResolver.cs index 3219472eff..5289065898 100644 --- a/Emby.Naming/Video/ExtraRuleResolver.cs +++ b/Emby.Naming/Video/ExtraRuleResolver.cs @@ -18,8 +18,9 @@ namespace Emby.Naming.Video /// /// Path to file. /// The naming options. + /// Top-level folder for the containing library. /// Returns object. - public static ExtraResult GetExtraInfo(string path, NamingOptions namingOptions) + public static ExtraResult GetExtraInfo(string path, NamingOptions namingOptions, string? libraryRoot = "") { var result = new ExtraResult(); @@ -69,7 +70,9 @@ namespace Emby.Naming.Video else if (rule.RuleType == ExtraRuleType.DirectoryName) { var directoryName = Path.GetFileName(Path.GetDirectoryName(pathSpan)); - if (directoryName.Equals(rule.Token, StringComparison.OrdinalIgnoreCase)) + string fullDirectory = Path.GetDirectoryName(pathSpan).ToString(); + if (directoryName.Equals(rule.Token, StringComparison.OrdinalIgnoreCase) + && !string.Equals(fullDirectory, libraryRoot, StringComparison.OrdinalIgnoreCase)) { result.ExtraType = rule.ExtraType; result.Rule = rule; diff --git a/Emby.Naming/Video/VideoListResolver.cs b/Emby.Naming/Video/VideoListResolver.cs index 12bc22a6ac..a3134f3f68 100644 --- a/Emby.Naming/Video/VideoListResolver.cs +++ b/Emby.Naming/Video/VideoListResolver.cs @@ -27,8 +27,9 @@ namespace Emby.Naming.Video /// The naming options. /// Indication we should consider multi-versions of content. /// Whether to parse the name or use the filename. + /// Top-level folder for the containing library. /// Returns enumerable of which groups files together when related. - public static IReadOnlyList Resolve(IReadOnlyList videoInfos, NamingOptions namingOptions, bool supportMultiVersion = true, bool parseName = true) + public static IReadOnlyList Resolve(IReadOnlyList videoInfos, NamingOptions namingOptions, bool supportMultiVersion = true, bool parseName = true, string? libraryRoot = "") { // Filter out all extras, otherwise they could cause stacks to not be resolved // See the unit test TestStackedWithTrailer @@ -65,7 +66,7 @@ namespace Emby.Naming.Video { var info = new VideoInfo(stack.Name) { - Files = stack.Files.Select(i => VideoResolver.Resolve(i, stack.IsDirectoryStack, namingOptions, parseName)) + Files = stack.Files.Select(i => VideoResolver.Resolve(i, stack.IsDirectoryStack, namingOptions, parseName, libraryRoot)) .OfType() .ToList() }; diff --git a/Emby.Naming/Video/VideoResolver.cs b/Emby.Naming/Video/VideoResolver.cs index db5bfdbf94..afbf6f8fae 100644 --- a/Emby.Naming/Video/VideoResolver.cs +++ b/Emby.Naming/Video/VideoResolver.cs @@ -17,10 +17,11 @@ namespace Emby.Naming.Video /// The path. /// The naming options. /// Whether to parse the name or use the filename. + /// Top-level folder for the containing library. /// VideoFileInfo. - public static VideoFileInfo? ResolveDirectory(string? path, NamingOptions namingOptions, bool parseName = true) + public static VideoFileInfo? ResolveDirectory(string? path, NamingOptions namingOptions, bool parseName = true, string? libraryRoot = "") { - return Resolve(path, true, namingOptions, parseName); + return Resolve(path, true, namingOptions, parseName, libraryRoot); } /// @@ -28,10 +29,11 @@ namespace Emby.Naming.Video /// /// The path. /// The naming options. + /// Top-level folder for the containing library. /// VideoFileInfo. - public static VideoFileInfo? ResolveFile(string? path, NamingOptions namingOptions) + public static VideoFileInfo? ResolveFile(string? path, NamingOptions namingOptions, string? libraryRoot = "") { - return Resolve(path, false, namingOptions); + return Resolve(path, false, namingOptions, libraryRoot: libraryRoot); } /// @@ -41,9 +43,10 @@ namespace Emby.Naming.Video /// if set to true [is folder]. /// The naming options. /// Whether or not the name should be parsed for info. + /// Top-level folder for the containing library. /// VideoFileInfo. /// path is null. - public static VideoFileInfo? Resolve(string? path, bool isDirectory, NamingOptions namingOptions, bool parseName = true) + public static VideoFileInfo? Resolve(string? path, bool isDirectory, NamingOptions namingOptions, bool parseName = true, string? libraryRoot = "") { if (string.IsNullOrEmpty(path)) { @@ -75,7 +78,7 @@ namespace Emby.Naming.Video var format3DResult = Format3DParser.Parse(path, namingOptions); - var extraResult = ExtraRuleResolver.GetExtraInfo(path, namingOptions); + var extraResult = ExtraRuleResolver.GetExtraInfo(path, namingOptions, libraryRoot); var name = Path.GetFileNameWithoutExtension(path); diff --git a/Emby.Photos/Emby.Photos.csproj b/Emby.Photos/Emby.Photos.csproj index 55dbe393c7..645a74aea4 100644 --- a/Emby.Photos/Emby.Photos.csproj +++ b/Emby.Photos/Emby.Photos.csproj @@ -19,7 +19,7 @@ - net8.0 + net9.0 false true diff --git a/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs b/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs index dc845b2d7e..f0cca9efd0 100644 --- a/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs +++ b/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs @@ -34,76 +34,46 @@ namespace Emby.Server.Implementations.AppBase DataPath = Directory.CreateDirectory(Path.Combine(ProgramDataPath, "data")).FullName; } - /// - /// Gets the path to the program data folder. - /// - /// The program data path. + /// public string ProgramDataPath { get; } /// public string WebPath { get; } - /// - /// Gets the path to the system folder. - /// - /// The path to the system folder. + /// public string ProgramSystemPath { get; } = AppContext.BaseDirectory; - /// - /// Gets the folder path to the data directory. - /// - /// The data directory. + /// public string DataPath { get; } /// public string VirtualDataPath => "%AppDataPath%"; - /// - /// Gets the image cache path. - /// - /// The image cache path. + /// public string ImageCachePath => Path.Combine(CachePath, "images"); - /// - /// Gets the path to the plugin directory. - /// - /// The plugins path. + /// public string PluginsPath => Path.Combine(ProgramDataPath, "plugins"); - /// - /// Gets the path to the plugin configurations directory. - /// - /// The plugin configurations path. + /// public string PluginConfigurationsPath => Path.Combine(PluginsPath, "configurations"); - /// - /// Gets the path to the log directory. - /// - /// The log directory path. + /// public string LogDirectoryPath { get; } - /// - /// Gets the path to the application configuration root directory. - /// - /// The configuration directory path. + /// public string ConfigurationDirectoryPath { get; } - /// - /// Gets the path to the system configuration file. - /// - /// The system configuration file path. + /// public string SystemConfigurationFilePath => Path.Combine(ConfigurationDirectoryPath, "system.xml"); - /// - /// Gets or sets the folder path to the cache directory. - /// - /// The cache directory. + /// public string CachePath { get; set; } - /// - /// Gets the folder path to the temp directory within the cache folder. - /// - /// The temp directory. + /// public string TempDirectory => Path.Join(Path.GetTempPath(), "jellyfin"); + + /// + public string TrickplayPath => Path.Combine(DataPath, "trickplay"); } } diff --git a/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs b/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs index 9e98d5ce09..9bc3a0204b 100644 --- a/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs +++ b/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; +using System.Threading; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Events; using MediaBrowser.Common.Extensions; @@ -19,7 +20,7 @@ namespace Emby.Server.Implementations.AppBase public abstract class BaseConfigurationManager : IConfigurationManager { private readonly ConcurrentDictionary _configurations = new(); - private readonly object _configurationSyncLock = new(); + private readonly Lock _configurationSyncLock = new(); private ConfigurationStore[] _configurationStores = Array.Empty(); private IConfigurationFactory[] _configurationFactories = Array.Empty(); diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index 5292003f09..5bb75e2b95 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -35,11 +35,12 @@ using Emby.Server.Implementations.SyncPlay; using Emby.Server.Implementations.TV; using Emby.Server.Implementations.Updates; using Jellyfin.Api.Helpers; +using Jellyfin.Database.Implementations; using Jellyfin.Drawing; using Jellyfin.MediaEncoding.Hls.Playlist; using Jellyfin.Networking.Manager; using Jellyfin.Networking.Udp; -using Jellyfin.Server.Implementations; +using Jellyfin.Server.Implementations.Item; using Jellyfin.Server.Implementations.MediaSegments; using MediaBrowser.Common; using MediaBrowser.Common.Configuration; @@ -56,6 +57,7 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.Lyrics; @@ -83,7 +85,6 @@ using MediaBrowser.Model.Net; using MediaBrowser.Model.Serialization; using MediaBrowser.Model.System; using MediaBrowser.Model.Tasks; -using MediaBrowser.Providers.Chapters; using MediaBrowser.Providers.Lyric; using MediaBrowser.Providers.Manager; using MediaBrowser.Providers.Plugins.Tmdb; @@ -268,6 +269,11 @@ namespace Emby.Server.Implementations public string ExpandVirtualPath(string path) { + if (path is null) + { + return null; + } + var appPaths = ApplicationPaths; return path.Replace(appPaths.VirtualDataPath, appPaths.DataPath, StringComparison.OrdinalIgnoreCase) @@ -492,13 +498,19 @@ namespace Emby.Server.Implementations serviceCollection.AddSingleton(); - serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); - serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); // TODO: Refactor to eliminate the circular dependencies here so that Lazy isn't required serviceCollection.AddTransient(provider => new Lazy(provider.GetRequiredService)); @@ -540,8 +552,6 @@ namespace Emby.Server.Implementations serviceCollection.AddSingleton(); - serviceCollection.AddSingleton(); - serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); @@ -565,10 +575,15 @@ namespace Emby.Server.Implementations /// /// Create services registered with the service container that need to be initialized at application startup. /// + /// The configuration used to initialise the application. /// A task representing the service initialization operation. - public async Task InitializeServices() + public async Task InitializeServices(IConfiguration startupConfig) { - var jellyfinDb = await Resolve>().CreateDbContextAsync().ConfigureAwait(false); + var factory = Resolve>(); + var provider = Resolve(); + provider.DbContextFactory = factory; + + var jellyfinDb = await factory.CreateDbContextAsync().ConfigureAwait(false); await using (jellyfinDb.ConfigureAwait(false)) { if ((await jellyfinDb.Database.GetPendingMigrationsAsync().ConfigureAwait(false)).Any()) @@ -579,9 +594,6 @@ namespace Emby.Server.Implementations } } - ((SqliteItemRepository)Resolve()).Initialize(); - ((SqliteUserDataRepository)Resolve()).Initialize(); - var localizationManager = (LocalizationManager)Resolve(); await localizationManager.LoadAll().ConfigureAwait(false); @@ -607,7 +619,7 @@ namespace Emby.Server.Implementations // Don't use an empty string password password = string.IsNullOrWhiteSpace(password) ? null : password; - var localCert = new X509Certificate2(path, password, X509KeyStorageFlags.UserKeySet); + var localCert = X509CertificateLoader.LoadPkcs12FromFile(path, password, X509KeyStorageFlags.UserKeySet); if (!localCert.HasPrivateKey) { Logger.LogError("No private key included in SSL cert {CertificateLocation}.", path); @@ -635,6 +647,7 @@ namespace Emby.Server.Implementations BaseItem.ProviderManager = Resolve(); BaseItem.LocalizationManager = Resolve(); BaseItem.ItemRepository = Resolve(); + BaseItem.ChapterRepository = Resolve(); BaseItem.FileSystem = Resolve(); BaseItem.UserDataManager = Resolve(); BaseItem.ChannelManager = Resolve(); diff --git a/Emby.Server.Implementations/Collections/CollectionManager.cs b/Emby.Server.Implementations/Collections/CollectionManager.cs index e414792ba0..60f515f24d 100644 --- a/Emby.Server.Implementations/Collections/CollectionManager.cs +++ b/Emby.Server.Implementations/Collections/CollectionManager.cs @@ -4,7 +4,7 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Data.Entities; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Collections; using MediaBrowser.Controller.Entities; @@ -204,7 +204,7 @@ namespace Emby.Server.Implementations.Collections { if (_libraryManager.GetItemById(collectionId) is not BoxSet collection) { - throw new ArgumentException("No collection exists with the supplied Id"); + throw new ArgumentException("No collection exists with the supplied collectionId " + collectionId); } List? itemList = null; @@ -218,7 +218,7 @@ namespace Emby.Server.Implementations.Collections if (item is null) { - throw new ArgumentException("No item exists with the supplied Id"); + throw new ArgumentException("No item exists with the supplied Id " + id); } if (!currentLinkedChildrenIds.Contains(id)) diff --git a/Emby.Server.Implementations/ConfigurationOptions.cs b/Emby.Server.Implementations/ConfigurationOptions.cs index 91791a1c82..a06f6e7fe9 100644 --- a/Emby.Server.Implementations/ConfigurationOptions.cs +++ b/Emby.Server.Implementations/ConfigurationOptions.cs @@ -17,7 +17,6 @@ namespace Emby.Server.Implementations { DefaultRedirectKey, "web/" }, { FfmpegProbeSizeKey, "1G" }, { FfmpegAnalyzeDurationKey, "200M" }, - { PlaylistsAllowDuplicatesKey, bool.FalseString }, { BindToUnixSocketKey, bool.FalseString }, { SqliteCacheSizeKey, "20000" }, { FfmpegSkipValidationKey, bool.FalseString }, diff --git a/Emby.Server.Implementations/Data/BaseSqliteRepository.cs b/Emby.Server.Implementations/Data/BaseSqliteRepository.cs deleted file mode 100644 index 8ed72c2082..0000000000 --- a/Emby.Server.Implementations/Data/BaseSqliteRepository.cs +++ /dev/null @@ -1,269 +0,0 @@ -#nullable disable - -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Threading; -using Jellyfin.Extensions; -using Microsoft.Data.Sqlite; -using Microsoft.Extensions.Logging; - -namespace Emby.Server.Implementations.Data -{ - public abstract class BaseSqliteRepository : IDisposable - { - private bool _disposed = false; - private SemaphoreSlim _writeLock = new SemaphoreSlim(1, 1); - private SqliteConnection _writeConnection; - - /// - /// Initializes a new instance of the class. - /// - /// The logger. - protected BaseSqliteRepository(ILogger logger) - { - Logger = logger; - } - - /// - /// Gets or sets the path to the DB file. - /// - protected string DbFilePath { get; set; } - - /// - /// Gets the logger. - /// - /// The logger. - protected ILogger Logger { get; } - - /// - /// Gets the cache size. - /// - /// The cache size or null. - protected virtual int? CacheSize => null; - - /// - /// Gets the locking mode. . - /// - protected virtual string LockingMode => "NORMAL"; - - /// - /// Gets the journal mode. . - /// - /// The journal mode. - protected virtual string JournalMode => "WAL"; - - /// - /// Gets the journal size limit. . - /// The default (-1) is overridden to prevent unconstrained WAL size, as reported by users. - /// - /// The journal size limit. - protected virtual int? JournalSizeLimit => 134_217_728; // 128MiB - - /// - /// Gets the page size. - /// - /// The page size or null. - protected virtual int? PageSize => null; - - /// - /// Gets the temp store mode. - /// - /// The temp store mode. - /// - protected virtual TempStoreMode TempStore => TempStoreMode.Memory; - - /// - /// Gets the synchronous mode. - /// - /// The synchronous mode or null. - /// - protected virtual SynchronousMode? Synchronous => SynchronousMode.Normal; - - public virtual void Initialize() - { - // Configuration and pragmas can affect VACUUM so it needs to be last. - using (var connection = GetConnection()) - { - connection.Execute("VACUUM"); - } - } - - protected ManagedConnection GetConnection(bool readOnly = false) - { - if (!readOnly) - { - _writeLock.Wait(); - if (_writeConnection is not null) - { - return new ManagedConnection(_writeConnection, _writeLock); - } - - var writeConnection = new SqliteConnection($"Filename={DbFilePath};Pooling=False"); - writeConnection.Open(); - - if (CacheSize.HasValue) - { - writeConnection.Execute("PRAGMA cache_size=" + CacheSize.Value); - } - - if (!string.IsNullOrWhiteSpace(LockingMode)) - { - writeConnection.Execute("PRAGMA locking_mode=" + LockingMode); - } - - if (!string.IsNullOrWhiteSpace(JournalMode)) - { - writeConnection.Execute("PRAGMA journal_mode=" + JournalMode); - } - - if (JournalSizeLimit.HasValue) - { - writeConnection.Execute("PRAGMA journal_size_limit=" + JournalSizeLimit.Value); - } - - if (Synchronous.HasValue) - { - writeConnection.Execute("PRAGMA synchronous=" + (int)Synchronous.Value); - } - - if (PageSize.HasValue) - { - writeConnection.Execute("PRAGMA page_size=" + PageSize.Value); - } - - writeConnection.Execute("PRAGMA temp_store=" + (int)TempStore); - - return new ManagedConnection(_writeConnection = writeConnection, _writeLock); - } - - var connection = new SqliteConnection($"Filename={DbFilePath};Mode=ReadOnly"); - connection.Open(); - - if (CacheSize.HasValue) - { - connection.Execute("PRAGMA cache_size=" + CacheSize.Value); - } - - if (!string.IsNullOrWhiteSpace(LockingMode)) - { - connection.Execute("PRAGMA locking_mode=" + LockingMode); - } - - if (!string.IsNullOrWhiteSpace(JournalMode)) - { - connection.Execute("PRAGMA journal_mode=" + JournalMode); - } - - if (JournalSizeLimit.HasValue) - { - connection.Execute("PRAGMA journal_size_limit=" + JournalSizeLimit.Value); - } - - if (Synchronous.HasValue) - { - connection.Execute("PRAGMA synchronous=" + (int)Synchronous.Value); - } - - if (PageSize.HasValue) - { - connection.Execute("PRAGMA page_size=" + PageSize.Value); - } - - connection.Execute("PRAGMA temp_store=" + (int)TempStore); - - return new ManagedConnection(connection, null); - } - - public SqliteCommand PrepareStatement(ManagedConnection connection, string sql) - { - var command = connection.CreateCommand(); - command.CommandText = sql; - return command; - } - - protected bool TableExists(ManagedConnection connection, string name) - { - using var statement = PrepareStatement(connection, "select DISTINCT tbl_name from sqlite_master"); - foreach (var row in statement.ExecuteQuery()) - { - if (string.Equals(name, row.GetString(0), StringComparison.OrdinalIgnoreCase)) - { - return true; - } - } - - return false; - } - - protected List GetColumnNames(ManagedConnection connection, string table) - { - var columnNames = new List(); - - foreach (var row in connection.Query("PRAGMA table_info(" + table + ")")) - { - if (row.TryGetString(1, out var columnName)) - { - columnNames.Add(columnName); - } - } - - return columnNames; - } - - protected void AddColumn(ManagedConnection connection, string table, string columnName, string type, List existingColumnNames) - { - if (existingColumnNames.Contains(columnName, StringComparison.OrdinalIgnoreCase)) - { - return; - } - - connection.Execute("alter table " + table + " add column " + columnName + " " + type + " NULL"); - } - - protected void CheckDisposed() - { - ObjectDisposedException.ThrowIf(_disposed, this); - } - - /// - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// - /// Releases unmanaged and - optionally - managed resources. - /// - /// true to release both managed and unmanaged resources; false to release only unmanaged resources. - protected virtual void Dispose(bool dispose) - { - if (_disposed) - { - return; - } - - if (dispose) - { - _writeLock.Wait(); - try - { - _writeConnection.Dispose(); - } - finally - { - _writeLock.Release(); - } - - _writeLock.Dispose(); - } - - _writeConnection = null; - _writeLock = null; - - _disposed = true; - } - } -} diff --git a/Emby.Server.Implementations/Data/CleanDatabaseScheduledTask.cs b/Emby.Server.Implementations/Data/CleanDatabaseScheduledTask.cs index 4516b89dc2..9a80eafe50 100644 --- a/Emby.Server.Implementations/Data/CleanDatabaseScheduledTask.cs +++ b/Emby.Server.Implementations/Data/CleanDatabaseScheduledTask.cs @@ -1,66 +1,119 @@ #pragma warning disable CS1591 using System; +using System.IO; +using System.Linq; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Database.Implementations; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -namespace Emby.Server.Implementations.Data +namespace Emby.Server.Implementations.Data; + +public class CleanDatabaseScheduledTask : ILibraryPostScanTask { - public class CleanDatabaseScheduledTask : ILibraryPostScanTask + private readonly ILibraryManager _libraryManager; + private readonly ILogger _logger; + private readonly IDbContextFactory _dbProvider; + private readonly IPathManager _pathManager; + + public CleanDatabaseScheduledTask( + ILibraryManager libraryManager, + ILogger logger, + IDbContextFactory dbProvider, + IPathManager pathManager) { - private readonly ILibraryManager _libraryManager; - private readonly ILogger _logger; + _libraryManager = libraryManager; + _logger = logger; + _dbProvider = dbProvider; + _pathManager = pathManager; + } - public CleanDatabaseScheduledTask(ILibraryManager libraryManager, ILogger logger) - { - _libraryManager = libraryManager; - _logger = logger; - } + public async Task Run(IProgress progress, CancellationToken cancellationToken) + { + await CleanDeadItems(cancellationToken, progress).ConfigureAwait(false); + } - public Task Run(IProgress progress, CancellationToken cancellationToken) + private async Task CleanDeadItems(CancellationToken cancellationToken, IProgress progress) + { + var itemIds = _libraryManager.GetItemIds(new InternalItemsQuery { - CleanDeadItems(cancellationToken, progress); - return Task.CompletedTask; - } + HasDeadParentId = true + }); - private void CleanDeadItems(CancellationToken cancellationToken, IProgress progress) + var numComplete = 0; + var numItems = itemIds.Count + 1; + + _logger.LogDebug("Cleaning {Number} items with dead parent links", numItems); + + foreach (var itemId in itemIds) { - var itemIds = _libraryManager.GetItemIds(new InternalItemsQuery + cancellationToken.ThrowIfCancellationRequested(); + + var item = _libraryManager.GetItemById(itemId); + if (item is not null) { - HasDeadParentId = true - }); + _logger.LogInformation("Cleaning item {Item} type: {Type} path: {Path}", item.Name, item.GetType().Name, item.Path ?? string.Empty); - var numComplete = 0; - var numItems = itemIds.Count; - - _logger.LogDebug("Cleaning {0} items with dead parent links", numItems); - - foreach (var itemId in itemIds) - { - cancellationToken.ThrowIfCancellationRequested(); - - var item = _libraryManager.GetItemById(itemId); - - if (item is not null) + foreach (var mediaSource in item.GetMediaSources(false)) { - _logger.LogInformation("Cleaning item {0} type: {1} path: {2}", item.Name, item.GetType().Name, item.Path ?? string.Empty); - - _libraryManager.DeleteItem(item, new DeleteOptions + // Delete extracted subtitles + try { - DeleteFileLocation = false - }); + var subtitleFolder = _pathManager.GetSubtitleFolderPath(mediaSource.Id); + if (Directory.Exists(subtitleFolder)) + { + Directory.Delete(subtitleFolder, true); + } + } + catch (Exception e) + { + _logger.LogWarning("Failed to remove subtitle cache folder for {Item}: {Exception}", item.Id, e.Message); + } + + // Delete extracted attachments + try + { + var attachmentFolder = _pathManager.GetAttachmentFolderPath(mediaSource.Id); + if (Directory.Exists(attachmentFolder)) + { + Directory.Delete(attachmentFolder, true); + } + } + catch (Exception e) + { + _logger.LogWarning("Failed to remove attachment cache folder for {Item}: {Exception}", item.Id, e.Message); + } } - numComplete++; - double percent = numComplete; - percent /= numItems; - progress.Report(percent * 100); + // Delete item + _libraryManager.DeleteItem(item, new DeleteOptions + { + DeleteFileLocation = false + }); } - progress.Report(100); + numComplete++; + double percent = numComplete; + percent /= numItems; + progress.Report(percent * 100); } + + var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (context.ConfigureAwait(false)) + { + var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); + await using (transaction.ConfigureAwait(false)) + { + await context.ItemValues.Where(e => e.BaseItemsMap!.Count == 0).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); + await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + } + } + + progress.Report(100); } } diff --git a/Emby.Server.Implementations/Data/ItemTypeLookup.cs b/Emby.Server.Implementations/Data/ItemTypeLookup.cs new file mode 100644 index 0000000000..82c0a8b6c5 --- /dev/null +++ b/Emby.Server.Implementations/Data/ItemTypeLookup.cs @@ -0,0 +1,64 @@ +using System.Collections.Frozen; +using System.Collections.Generic; +using System.Threading.Channels; +using Emby.Server.Implementations.Playlists; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Controller.Playlists; + +namespace Emby.Server.Implementations.Data; + +/// +public class ItemTypeLookup : IItemTypeLookup +{ + /// + public IReadOnlyList MusicGenreTypes { get; } = [ + typeof(Audio).FullName!, + typeof(MusicVideo).FullName!, + typeof(MusicAlbum).FullName!, + typeof(MusicArtist).FullName!, + ]; + + /// + public IReadOnlyDictionary BaseItemKindNames { get; } = new Dictionary() + { + { BaseItemKind.AggregateFolder, typeof(AggregateFolder).FullName! }, + { BaseItemKind.Audio, typeof(Audio).FullName! }, + { BaseItemKind.AudioBook, typeof(AudioBook).FullName! }, + { BaseItemKind.BasePluginFolder, typeof(BasePluginFolder).FullName! }, + { BaseItemKind.Book, typeof(Book).FullName! }, + { BaseItemKind.BoxSet, typeof(BoxSet).FullName! }, + { BaseItemKind.Channel, typeof(Channel).FullName! }, + { BaseItemKind.CollectionFolder, typeof(CollectionFolder).FullName! }, + { BaseItemKind.Episode, typeof(Episode).FullName! }, + { BaseItemKind.Folder, typeof(Folder).FullName! }, + { BaseItemKind.Genre, typeof(Genre).FullName! }, + { BaseItemKind.Movie, typeof(Movie).FullName! }, + { BaseItemKind.LiveTvChannel, typeof(LiveTvChannel).FullName! }, + { BaseItemKind.LiveTvProgram, typeof(LiveTvProgram).FullName! }, + { BaseItemKind.MusicAlbum, typeof(MusicAlbum).FullName! }, + { BaseItemKind.MusicArtist, typeof(MusicArtist).FullName! }, + { BaseItemKind.MusicGenre, typeof(MusicGenre).FullName! }, + { BaseItemKind.MusicVideo, typeof(MusicVideo).FullName! }, + { BaseItemKind.Person, typeof(Person).FullName! }, + { BaseItemKind.Photo, typeof(Photo).FullName! }, + { BaseItemKind.PhotoAlbum, typeof(PhotoAlbum).FullName! }, + { BaseItemKind.Playlist, typeof(Playlist).FullName! }, + { BaseItemKind.PlaylistsFolder, typeof(PlaylistsFolder).FullName! }, + { BaseItemKind.Season, typeof(Season).FullName! }, + { BaseItemKind.Series, typeof(Series).FullName! }, + { BaseItemKind.Studio, typeof(Studio).FullName! }, + { BaseItemKind.Trailer, typeof(Trailer).FullName! }, + { BaseItemKind.TvChannel, typeof(LiveTvChannel).FullName! }, + { BaseItemKind.TvProgram, typeof(LiveTvProgram).FullName! }, + { BaseItemKind.UserRootFolder, typeof(UserRootFolder).FullName! }, + { BaseItemKind.UserView, typeof(UserView).FullName! }, + { BaseItemKind.Video, typeof(Video).FullName! }, + { BaseItemKind.Year, typeof(Year).FullName! } + }.ToFrozenDictionary(); +} diff --git a/Emby.Server.Implementations/Data/ManagedConnection.cs b/Emby.Server.Implementations/Data/ManagedConnection.cs deleted file mode 100644 index 860950b303..0000000000 --- a/Emby.Server.Implementations/Data/ManagedConnection.cs +++ /dev/null @@ -1,62 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Threading; -using Microsoft.Data.Sqlite; - -namespace Emby.Server.Implementations.Data; - -public sealed class ManagedConnection : IDisposable -{ - private readonly SemaphoreSlim? _writeLock; - - private SqliteConnection _db; - - private bool _disposed = false; - - public ManagedConnection(SqliteConnection db, SemaphoreSlim? writeLock) - { - _db = db; - _writeLock = writeLock; - } - - public SqliteTransaction BeginTransaction() - => _db.BeginTransaction(); - - public SqliteCommand CreateCommand() - => _db.CreateCommand(); - - public void Execute(string commandText) - => _db.Execute(commandText); - - public SqliteCommand PrepareStatement(string sql) - => _db.PrepareStatement(sql); - - public IEnumerable Query(string commandText) - => _db.Query(commandText); - - public void Dispose() - { - if (_disposed) - { - return; - } - - if (_writeLock is null) - { - // Read connections are managed with an internal pool - _db.Dispose(); - } - else - { - // Write lock is managed by BaseSqliteRepository - // Don't dispose here - _writeLock.Release(); - } - - _db = null!; - - _disposed = true; - } -} diff --git a/Emby.Server.Implementations/Data/SqliteExtensions.cs b/Emby.Server.Implementations/Data/SqliteExtensions.cs index 25ef57d271..0efef4dedc 100644 --- a/Emby.Server.Implementations/Data/SqliteExtensions.cs +++ b/Emby.Server.Implementations/Data/SqliteExtensions.cs @@ -127,8 +127,16 @@ namespace Emby.Server.Implementations.Data return false; } - result = reader.GetGuid(index); - return true; + try + { + result = reader.GetGuid(index); + return true; + } + catch + { + result = Guid.Empty; + return false; + } } public static bool TryGetString(this SqliteDataReader reader, int index, out string result) diff --git a/Emby.Server.Implementations/Data/SqliteItemRepository.cs b/Emby.Server.Implementations/Data/SqliteItemRepository.cs deleted file mode 100644 index 3477194cf7..0000000000 --- a/Emby.Server.Implementations/Data/SqliteItemRepository.cs +++ /dev/null @@ -1,5971 +0,0 @@ -#nullable disable - -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Runtime.CompilerServices; -using System.Text; -using System.Text.Json; -using System.Threading; -using Emby.Server.Implementations.Playlists; -using Jellyfin.Data.Enums; -using Jellyfin.Extensions; -using Jellyfin.Extensions.Json; -using MediaBrowser.Controller; -using MediaBrowser.Controller.Channels; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Drawing; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Entities.Audio; -using MediaBrowser.Controller.Entities.Movies; -using MediaBrowser.Controller.Entities.TV; -using MediaBrowser.Controller.Extensions; -using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Controller.Persistence; -using MediaBrowser.Controller.Playlists; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Globalization; -using MediaBrowser.Model.LiveTv; -using MediaBrowser.Model.Querying; -using Microsoft.Data.Sqlite; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; - -namespace Emby.Server.Implementations.Data -{ - /// - /// Class SQLiteItemRepository. - /// - public class SqliteItemRepository : BaseSqliteRepository, IItemRepository - { - private const string FromText = " from TypedBaseItems A"; - private const string ChaptersTableName = "Chapters2"; - - private const string SaveItemCommandText = - @"replace into TypedBaseItems - (guid,type,data,Path,StartDate,EndDate,ChannelId,IsMovie,IsSeries,EpisodeTitle,IsRepeat,CommunityRating,CustomRating,IndexNumber,IsLocked,Name,OfficialRating,MediaType,Overview,ParentIndexNumber,PremiereDate,ProductionYear,ParentId,Genres,InheritedParentalRatingValue,SortName,ForcedSortName,RunTimeTicks,Size,DateCreated,DateModified,PreferredMetadataLanguage,PreferredMetadataCountryCode,Width,Height,DateLastRefreshed,DateLastSaved,IsInMixedFolder,LockedFields,Studios,Audio,ExternalServiceId,Tags,IsFolder,UnratedType,TopParentId,TrailerTypes,CriticRating,CleanName,PresentationUniqueKey,OriginalTitle,PrimaryVersionId,DateLastMediaAdded,Album,LUFS,NormalizationGain,IsVirtualItem,SeriesName,UserDataKey,SeasonName,SeasonId,SeriesId,ExternalSeriesId,Tagline,ProviderIds,Images,ProductionLocations,ExtraIds,TotalBitrate,ExtraType,Artists,AlbumArtists,ExternalId,SeriesPresentationUniqueKey,ShowId,OwnerId) - values (@guid,@type,@data,@Path,@StartDate,@EndDate,@ChannelId,@IsMovie,@IsSeries,@EpisodeTitle,@IsRepeat,@CommunityRating,@CustomRating,@IndexNumber,@IsLocked,@Name,@OfficialRating,@MediaType,@Overview,@ParentIndexNumber,@PremiereDate,@ProductionYear,@ParentId,@Genres,@InheritedParentalRatingValue,@SortName,@ForcedSortName,@RunTimeTicks,@Size,@DateCreated,@DateModified,@PreferredMetadataLanguage,@PreferredMetadataCountryCode,@Width,@Height,@DateLastRefreshed,@DateLastSaved,@IsInMixedFolder,@LockedFields,@Studios,@Audio,@ExternalServiceId,@Tags,@IsFolder,@UnratedType,@TopParentId,@TrailerTypes,@CriticRating,@CleanName,@PresentationUniqueKey,@OriginalTitle,@PrimaryVersionId,@DateLastMediaAdded,@Album,@LUFS,@NormalizationGain,@IsVirtualItem,@SeriesName,@UserDataKey,@SeasonName,@SeasonId,@SeriesId,@ExternalSeriesId,@Tagline,@ProviderIds,@Images,@ProductionLocations,@ExtraIds,@TotalBitrate,@ExtraType,@Artists,@AlbumArtists,@ExternalId,@SeriesPresentationUniqueKey,@ShowId,@OwnerId)"; - - private readonly IServerConfigurationManager _config; - private readonly IServerApplicationHost _appHost; - private readonly ILocalizationManager _localization; - // TODO: Remove this dependency. GetImageCacheTag() is the only method used and it can be converted to a static helper method - private readonly IImageProcessor _imageProcessor; - - private readonly TypeMapper _typeMapper; - private readonly JsonSerializerOptions _jsonOptions; - - private readonly ItemFields[] _allItemFields = Enum.GetValues(); - - private static readonly string[] _retrieveItemColumns = - { - "type", - "data", - "StartDate", - "EndDate", - "ChannelId", - "IsMovie", - "IsSeries", - "EpisodeTitle", - "IsRepeat", - "CommunityRating", - "CustomRating", - "IndexNumber", - "IsLocked", - "PreferredMetadataLanguage", - "PreferredMetadataCountryCode", - "Width", - "Height", - "DateLastRefreshed", - "Name", - "Path", - "PremiereDate", - "Overview", - "ParentIndexNumber", - "ProductionYear", - "OfficialRating", - "ForcedSortName", - "RunTimeTicks", - "Size", - "DateCreated", - "DateModified", - "guid", - "Genres", - "ParentId", - "Audio", - "ExternalServiceId", - "IsInMixedFolder", - "DateLastSaved", - "LockedFields", - "Studios", - "Tags", - "TrailerTypes", - "OriginalTitle", - "PrimaryVersionId", - "DateLastMediaAdded", - "Album", - "LUFS", - "NormalizationGain", - "CriticRating", - "IsVirtualItem", - "SeriesName", - "SeasonName", - "SeasonId", - "SeriesId", - "PresentationUniqueKey", - "InheritedParentalRatingValue", - "ExternalSeriesId", - "Tagline", - "ProviderIds", - "Images", - "ProductionLocations", - "ExtraIds", - "TotalBitrate", - "ExtraType", - "Artists", - "AlbumArtists", - "ExternalId", - "SeriesPresentationUniqueKey", - "ShowId", - "OwnerId" - }; - - private static readonly string _retrieveItemColumnsSelectQuery = $"select {string.Join(',', _retrieveItemColumns)} from TypedBaseItems where guid = @guid"; - - private static readonly string[] _mediaStreamSaveColumns = - { - "ItemId", - "StreamIndex", - "StreamType", - "Codec", - "Language", - "ChannelLayout", - "Profile", - "AspectRatio", - "Path", - "IsInterlaced", - "BitRate", - "Channels", - "SampleRate", - "IsDefault", - "IsForced", - "IsExternal", - "Height", - "Width", - "AverageFrameRate", - "RealFrameRate", - "Level", - "PixelFormat", - "BitDepth", - "IsAnamorphic", - "RefFrames", - "CodecTag", - "Comment", - "NalLengthSize", - "IsAvc", - "Title", - "TimeBase", - "CodecTimeBase", - "ColorPrimaries", - "ColorSpace", - "ColorTransfer", - "DvVersionMajor", - "DvVersionMinor", - "DvProfile", - "DvLevel", - "RpuPresentFlag", - "ElPresentFlag", - "BlPresentFlag", - "DvBlSignalCompatibilityId", - "IsHearingImpaired", - "Rotation" - }; - - private static readonly string _mediaStreamSaveColumnsInsertQuery = - $"insert into mediastreams ({string.Join(',', _mediaStreamSaveColumns)}) values "; - - private static readonly string _mediaStreamSaveColumnsSelectQuery = - $"select {string.Join(',', _mediaStreamSaveColumns)} from mediastreams where ItemId=@ItemId"; - - private static readonly string[] _mediaAttachmentSaveColumns = - { - "ItemId", - "AttachmentIndex", - "Codec", - "CodecTag", - "Comment", - "Filename", - "MIMEType" - }; - - private static readonly string _mediaAttachmentSaveColumnsSelectQuery = - $"select {string.Join(',', _mediaAttachmentSaveColumns)} from mediaattachments where ItemId=@ItemId"; - - private static readonly string _mediaAttachmentInsertPrefix = BuildMediaAttachmentInsertPrefix(); - - private static readonly BaseItemKind[] _programTypes = new[] - { - BaseItemKind.Program, - BaseItemKind.TvChannel, - BaseItemKind.LiveTvProgram, - BaseItemKind.LiveTvChannel - }; - - private static readonly BaseItemKind[] _programExcludeParentTypes = new[] - { - BaseItemKind.Series, - BaseItemKind.Season, - BaseItemKind.MusicAlbum, - BaseItemKind.MusicArtist, - BaseItemKind.PhotoAlbum - }; - - private static readonly BaseItemKind[] _serviceTypes = new[] - { - BaseItemKind.TvChannel, - BaseItemKind.LiveTvChannel - }; - - private static readonly BaseItemKind[] _startDateTypes = new[] - { - BaseItemKind.Program, - BaseItemKind.LiveTvProgram - }; - - private static readonly BaseItemKind[] _seriesTypes = new[] - { - BaseItemKind.Book, - BaseItemKind.AudioBook, - BaseItemKind.Episode, - BaseItemKind.Season - }; - - private static readonly BaseItemKind[] _artistExcludeParentTypes = new[] - { - BaseItemKind.Series, - BaseItemKind.Season, - BaseItemKind.PhotoAlbum - }; - - private static readonly BaseItemKind[] _artistsTypes = new[] - { - BaseItemKind.Audio, - BaseItemKind.MusicAlbum, - BaseItemKind.MusicVideo, - BaseItemKind.AudioBook - }; - - private static readonly Dictionary _baseItemKindNames = new() - { - { BaseItemKind.AggregateFolder, typeof(AggregateFolder).FullName }, - { BaseItemKind.Audio, typeof(Audio).FullName }, - { BaseItemKind.AudioBook, typeof(AudioBook).FullName }, - { BaseItemKind.BasePluginFolder, typeof(BasePluginFolder).FullName }, - { BaseItemKind.Book, typeof(Book).FullName }, - { BaseItemKind.BoxSet, typeof(BoxSet).FullName }, - { BaseItemKind.Channel, typeof(Channel).FullName }, - { BaseItemKind.CollectionFolder, typeof(CollectionFolder).FullName }, - { BaseItemKind.Episode, typeof(Episode).FullName }, - { BaseItemKind.Folder, typeof(Folder).FullName }, - { BaseItemKind.Genre, typeof(Genre).FullName }, - { BaseItemKind.Movie, typeof(Movie).FullName }, - { BaseItemKind.LiveTvChannel, typeof(LiveTvChannel).FullName }, - { BaseItemKind.LiveTvProgram, typeof(LiveTvProgram).FullName }, - { BaseItemKind.MusicAlbum, typeof(MusicAlbum).FullName }, - { BaseItemKind.MusicArtist, typeof(MusicArtist).FullName }, - { BaseItemKind.MusicGenre, typeof(MusicGenre).FullName }, - { BaseItemKind.MusicVideo, typeof(MusicVideo).FullName }, - { BaseItemKind.Person, typeof(Person).FullName }, - { BaseItemKind.Photo, typeof(Photo).FullName }, - { BaseItemKind.PhotoAlbum, typeof(PhotoAlbum).FullName }, - { BaseItemKind.Playlist, typeof(Playlist).FullName }, - { BaseItemKind.PlaylistsFolder, typeof(PlaylistsFolder).FullName }, - { BaseItemKind.Season, typeof(Season).FullName }, - { BaseItemKind.Series, typeof(Series).FullName }, - { BaseItemKind.Studio, typeof(Studio).FullName }, - { BaseItemKind.Trailer, typeof(Trailer).FullName }, - { BaseItemKind.TvChannel, typeof(LiveTvChannel).FullName }, - { BaseItemKind.TvProgram, typeof(LiveTvProgram).FullName }, - { BaseItemKind.UserRootFolder, typeof(UserRootFolder).FullName }, - { BaseItemKind.UserView, typeof(UserView).FullName }, - { BaseItemKind.Video, typeof(Video).FullName }, - { BaseItemKind.Year, typeof(Year).FullName } - }; - - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// config is null. - public SqliteItemRepository( - IServerConfigurationManager config, - IServerApplicationHost appHost, - ILogger logger, - ILocalizationManager localization, - IImageProcessor imageProcessor, - IConfiguration configuration) - : base(logger) - { - _config = config; - _appHost = appHost; - _localization = localization; - _imageProcessor = imageProcessor; - - _typeMapper = new TypeMapper(); - _jsonOptions = JsonDefaults.Options; - - DbFilePath = Path.Combine(_config.ApplicationPaths.DataPath, "library.db"); - - CacheSize = configuration.GetSqliteCacheSize(); - } - - /// - protected override int? CacheSize { get; } - - /// - protected override TempStoreMode TempStore => TempStoreMode.Memory; - - /// - /// Opens the connection to the database. - /// - public override void Initialize() - { - base.Initialize(); - - const string CreateMediaStreamsTableCommand - = "create table if not exists mediastreams (ItemId GUID, StreamIndex INT, StreamType TEXT, Codec TEXT, Language TEXT, ChannelLayout TEXT, Profile TEXT, AspectRatio TEXT, Path TEXT, IsInterlaced BIT, BitRate INT NULL, Channels INT NULL, SampleRate INT NULL, IsDefault BIT, IsForced BIT, IsExternal BIT, Height INT NULL, Width INT NULL, AverageFrameRate FLOAT NULL, RealFrameRate FLOAT NULL, Level FLOAT NULL, PixelFormat TEXT, BitDepth INT NULL, IsAnamorphic BIT NULL, RefFrames INT NULL, CodecTag TEXT NULL, Comment TEXT NULL, NalLengthSize TEXT NULL, IsAvc BIT NULL, Title TEXT NULL, TimeBase TEXT NULL, CodecTimeBase TEXT NULL, ColorPrimaries TEXT NULL, ColorSpace TEXT NULL, ColorTransfer TEXT NULL, DvVersionMajor INT NULL, DvVersionMinor INT NULL, DvProfile INT NULL, DvLevel INT NULL, RpuPresentFlag INT NULL, ElPresentFlag INT NULL, BlPresentFlag INT NULL, DvBlSignalCompatibilityId INT NULL, IsHearingImpaired BIT NULL, Rotation INT NULL, PRIMARY KEY (ItemId, StreamIndex))"; - - const string CreateMediaAttachmentsTableCommand - = "create table if not exists mediaattachments (ItemId GUID, AttachmentIndex INT, Codec TEXT, CodecTag TEXT NULL, Comment TEXT NULL, Filename TEXT NULL, MIMEType TEXT NULL, PRIMARY KEY (ItemId, AttachmentIndex))"; - - string[] queries = - { - "create table if not exists TypedBaseItems (guid GUID primary key NOT NULL, type TEXT NOT NULL, data BLOB NULL, ParentId GUID NULL, Path TEXT NULL)", - - "create table if not exists AncestorIds (ItemId GUID NOT NULL, AncestorId GUID NOT NULL, AncestorIdText TEXT NOT NULL, PRIMARY KEY (ItemId, AncestorId))", - "create index if not exists idx_AncestorIds1 on AncestorIds(AncestorId)", - "create index if not exists idx_AncestorIds5 on AncestorIds(AncestorIdText,ItemId)", - - "create table if not exists ItemValues (ItemId GUID NOT NULL, Type INT NOT NULL, Value TEXT NOT NULL, CleanValue TEXT NOT NULL)", - - "create table if not exists People (ItemId GUID, Name TEXT NOT NULL, Role TEXT, PersonType TEXT, SortOrder int, ListOrder int)", - - "drop index if exists idxPeopleItemId", - "create index if not exists idxPeopleItemId1 on People(ItemId,ListOrder)", - "create index if not exists idxPeopleName on People(Name)", - - "create table if not exists " + ChaptersTableName + " (ItemId GUID, ChapterIndex INT NOT NULL, StartPositionTicks BIGINT NOT NULL, Name TEXT, ImagePath TEXT, PRIMARY KEY (ItemId, ChapterIndex))", - - CreateMediaStreamsTableCommand, - CreateMediaAttachmentsTableCommand, - - "pragma shrink_memory" - }; - - string[] postQueries = - { - "create index if not exists idx_PathTypedBaseItems on TypedBaseItems(Path)", - "create index if not exists idx_ParentIdTypedBaseItems on TypedBaseItems(ParentId)", - - "create index if not exists idx_PresentationUniqueKey on TypedBaseItems(PresentationUniqueKey)", - "create index if not exists idx_GuidTypeIsFolderIsVirtualItem on TypedBaseItems(Guid,Type,IsFolder,IsVirtualItem)", - "create index if not exists idx_CleanNameType on TypedBaseItems(CleanName,Type)", - - // covering index - "create index if not exists idx_TopParentIdGuid on TypedBaseItems(TopParentId,Guid)", - - // series - "create index if not exists idx_TypeSeriesPresentationUniqueKey1 on TypedBaseItems(Type,SeriesPresentationUniqueKey,PresentationUniqueKey,SortName)", - - // series counts - // seriesdateplayed sort order - "create index if not exists idx_TypeSeriesPresentationUniqueKey3 on TypedBaseItems(SeriesPresentationUniqueKey,Type,IsFolder,IsVirtualItem)", - - // live tv programs - "create index if not exists idx_TypeTopParentIdStartDate on TypedBaseItems(Type,TopParentId,StartDate)", - - // covering index for getitemvalues - "create index if not exists idx_TypeTopParentIdGuid on TypedBaseItems(Type,TopParentId,Guid)", - - // used by movie suggestions - "create index if not exists idx_TypeTopParentIdGroup on TypedBaseItems(Type,TopParentId,PresentationUniqueKey)", - "create index if not exists idx_TypeTopParentId5 on TypedBaseItems(TopParentId,IsVirtualItem)", - - // latest items - "create index if not exists idx_TypeTopParentId9 on TypedBaseItems(TopParentId,Type,IsVirtualItem,PresentationUniqueKey,DateCreated)", - "create index if not exists idx_TypeTopParentId8 on TypedBaseItems(TopParentId,IsFolder,IsVirtualItem,PresentationUniqueKey,DateCreated)", - - // resume - "create index if not exists idx_TypeTopParentId7 on TypedBaseItems(TopParentId,MediaType,IsVirtualItem,PresentationUniqueKey)", - - // items by name - "create index if not exists idx_ItemValues6 on ItemValues(ItemId,Type,CleanValue)", - "create index if not exists idx_ItemValues7 on ItemValues(Type,CleanValue,ItemId)", - - // Used to update inherited tags - "create index if not exists idx_ItemValues8 on ItemValues(Type, ItemId, Value)", - - "CREATE INDEX IF NOT EXISTS idx_TypedBaseItemsUserDataKeyType ON TypedBaseItems(UserDataKey, Type)", - "CREATE INDEX IF NOT EXISTS idx_PeopleNameListOrder ON People(Name, ListOrder)" - }; - - using (var connection = GetConnection()) - using (var transaction = connection.BeginTransaction()) - { - connection.Execute(string.Join(';', queries)); - - var existingColumnNames = GetColumnNames(connection, "AncestorIds"); - AddColumn(connection, "AncestorIds", "AncestorIdText", "Text", existingColumnNames); - - existingColumnNames = GetColumnNames(connection, "TypedBaseItems"); - - AddColumn(connection, "TypedBaseItems", "Path", "Text", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "StartDate", "DATETIME", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "EndDate", "DATETIME", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "ChannelId", "Text", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "IsMovie", "BIT", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "CommunityRating", "Float", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "CustomRating", "Text", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "IndexNumber", "INT", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "IsLocked", "BIT", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "Name", "Text", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "OfficialRating", "Text", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "MediaType", "Text", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "Overview", "Text", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "ParentIndexNumber", "INT", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "PremiereDate", "DATETIME", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "ProductionYear", "INT", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "ParentId", "GUID", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "Genres", "Text", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "SortName", "Text", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "ForcedSortName", "Text", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "RunTimeTicks", "BIGINT", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "DateCreated", "DATETIME", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "DateModified", "DATETIME", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "IsSeries", "BIT", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "EpisodeTitle", "Text", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "IsRepeat", "BIT", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "PreferredMetadataLanguage", "Text", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "PreferredMetadataCountryCode", "Text", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "DateLastRefreshed", "DATETIME", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "DateLastSaved", "DATETIME", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "IsInMixedFolder", "BIT", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "LockedFields", "Text", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "Studios", "Text", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "Audio", "Text", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "ExternalServiceId", "Text", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "Tags", "Text", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "IsFolder", "BIT", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "InheritedParentalRatingValue", "INT", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "UnratedType", "Text", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "TopParentId", "Text", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "TrailerTypes", "Text", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "CriticRating", "Float", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "CleanName", "Text", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "PresentationUniqueKey", "Text", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "OriginalTitle", "Text", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "PrimaryVersionId", "Text", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "DateLastMediaAdded", "DATETIME", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "Album", "Text", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "LUFS", "Float", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "NormalizationGain", "Float", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "IsVirtualItem", "BIT", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "SeriesName", "Text", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "UserDataKey", "Text", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "SeasonName", "Text", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "SeasonId", "GUID", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "SeriesId", "GUID", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "ExternalSeriesId", "Text", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "Tagline", "Text", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "ProviderIds", "Text", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "Images", "Text", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "ProductionLocations", "Text", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "ExtraIds", "Text", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "TotalBitrate", "INT", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "ExtraType", "Text", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "Artists", "Text", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "AlbumArtists", "Text", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "ExternalId", "Text", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "SeriesPresentationUniqueKey", "Text", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "ShowId", "Text", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "OwnerId", "Text", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "Width", "INT", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "Height", "INT", existingColumnNames); - AddColumn(connection, "TypedBaseItems", "Size", "BIGINT", existingColumnNames); - - existingColumnNames = GetColumnNames(connection, "ItemValues"); - AddColumn(connection, "ItemValues", "CleanValue", "Text", existingColumnNames); - - existingColumnNames = GetColumnNames(connection, ChaptersTableName); - AddColumn(connection, ChaptersTableName, "ImageDateModified", "DATETIME", existingColumnNames); - - existingColumnNames = GetColumnNames(connection, "MediaStreams"); - AddColumn(connection, "MediaStreams", "IsAvc", "BIT", existingColumnNames); - AddColumn(connection, "MediaStreams", "TimeBase", "TEXT", existingColumnNames); - AddColumn(connection, "MediaStreams", "CodecTimeBase", "TEXT", existingColumnNames); - AddColumn(connection, "MediaStreams", "Title", "TEXT", existingColumnNames); - AddColumn(connection, "MediaStreams", "NalLengthSize", "TEXT", existingColumnNames); - AddColumn(connection, "MediaStreams", "Comment", "TEXT", existingColumnNames); - AddColumn(connection, "MediaStreams", "CodecTag", "TEXT", existingColumnNames); - AddColumn(connection, "MediaStreams", "PixelFormat", "TEXT", existingColumnNames); - AddColumn(connection, "MediaStreams", "BitDepth", "INT", existingColumnNames); - AddColumn(connection, "MediaStreams", "RefFrames", "INT", existingColumnNames); - AddColumn(connection, "MediaStreams", "KeyFrames", "TEXT", existingColumnNames); - AddColumn(connection, "MediaStreams", "IsAnamorphic", "BIT", existingColumnNames); - - AddColumn(connection, "MediaStreams", "ColorPrimaries", "TEXT", existingColumnNames); - AddColumn(connection, "MediaStreams", "ColorSpace", "TEXT", existingColumnNames); - AddColumn(connection, "MediaStreams", "ColorTransfer", "TEXT", existingColumnNames); - - AddColumn(connection, "MediaStreams", "DvVersionMajor", "INT", existingColumnNames); - AddColumn(connection, "MediaStreams", "DvVersionMinor", "INT", existingColumnNames); - AddColumn(connection, "MediaStreams", "DvProfile", "INT", existingColumnNames); - AddColumn(connection, "MediaStreams", "DvLevel", "INT", existingColumnNames); - AddColumn(connection, "MediaStreams", "RpuPresentFlag", "INT", existingColumnNames); - AddColumn(connection, "MediaStreams", "ElPresentFlag", "INT", existingColumnNames); - AddColumn(connection, "MediaStreams", "BlPresentFlag", "INT", existingColumnNames); - AddColumn(connection, "MediaStreams", "DvBlSignalCompatibilityId", "INT", existingColumnNames); - - AddColumn(connection, "MediaStreams", "IsHearingImpaired", "BIT", existingColumnNames); - - AddColumn(connection, "MediaStreams", "Rotation", "INT", existingColumnNames); - - connection.Execute(string.Join(';', postQueries)); - - transaction.Commit(); - } - } - - /// - public void SaveImages(BaseItem item) - { - ArgumentNullException.ThrowIfNull(item); - - CheckDisposed(); - - var images = SerializeImages(item.ImageInfos); - using var connection = GetConnection(); - using var transaction = connection.BeginTransaction(); - using var saveImagesStatement = PrepareStatement(connection, "Update TypedBaseItems set Images=@Images where guid=@Id"); - saveImagesStatement.TryBind("@Id", item.Id); - saveImagesStatement.TryBind("@Images", images); - - saveImagesStatement.ExecuteNonQuery(); - transaction.Commit(); - } - - /// - /// Saves the items. - /// - /// The items. - /// The cancellation token. - /// - /// or is null. - /// - public void SaveItems(IReadOnlyList items, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(items); - - cancellationToken.ThrowIfCancellationRequested(); - - CheckDisposed(); - - var itemsLen = items.Count; - var tuples = new ValueTuple, BaseItem, string, List>[itemsLen]; - for (int i = 0; i < itemsLen; i++) - { - var item = items[i]; - var ancestorIds = item.SupportsAncestors ? - item.GetAncestorIds().Distinct().ToList() : - null; - - var topParent = item.GetTopParent(); - - var userdataKey = item.GetUserDataKeys().FirstOrDefault(); - var inheritedTags = item.GetInheritedTags(); - - tuples[i] = (item, ancestorIds, topParent, userdataKey, inheritedTags); - } - - using var connection = GetConnection(); - using var transaction = connection.BeginTransaction(); - SaveItemsInTransaction(connection, tuples); - transaction.Commit(); - } - - private void SaveItemsInTransaction(ManagedConnection db, IEnumerable<(BaseItem Item, List AncestorIds, BaseItem TopParent, string UserDataKey, List InheritedTags)> tuples) - { - using (var saveItemStatement = PrepareStatement(db, SaveItemCommandText)) - using (var deleteAncestorsStatement = PrepareStatement(db, "delete from AncestorIds where ItemId=@ItemId")) - { - var requiresReset = false; - foreach (var tuple in tuples) - { - if (requiresReset) - { - saveItemStatement.Parameters.Clear(); - deleteAncestorsStatement.Parameters.Clear(); - } - - var item = tuple.Item; - var topParent = tuple.TopParent; - var userDataKey = tuple.UserDataKey; - - SaveItem(item, topParent, userDataKey, saveItemStatement); - - var inheritedTags = tuple.InheritedTags; - - if (item.SupportsAncestors) - { - UpdateAncestors(item.Id, tuple.AncestorIds, db, deleteAncestorsStatement); - } - - UpdateItemValues(item.Id, GetItemValuesToSave(item, inheritedTags), db); - - requiresReset = true; - } - } - } - - private string GetPathToSave(string path) - { - if (path is null) - { - return null; - } - - return _appHost.ReverseVirtualPath(path); - } - - private string RestorePath(string path) - { - return _appHost.ExpandVirtualPath(path); - } - - private void SaveItem(BaseItem item, BaseItem topParent, string userDataKey, SqliteCommand saveItemStatement) - { - Type type = item.GetType(); - - saveItemStatement.TryBind("@guid", item.Id); - saveItemStatement.TryBind("@type", type.FullName); - - if (TypeRequiresDeserialization(type)) - { - saveItemStatement.TryBind("@data", JsonSerializer.SerializeToUtf8Bytes(item, type, _jsonOptions), true); - } - else - { - saveItemStatement.TryBindNull("@data"); - } - - saveItemStatement.TryBind("@Path", GetPathToSave(item.Path)); - - if (item is IHasStartDate hasStartDate) - { - saveItemStatement.TryBind("@StartDate", hasStartDate.StartDate); - } - else - { - saveItemStatement.TryBindNull("@StartDate"); - } - - if (item.EndDate.HasValue) - { - saveItemStatement.TryBind("@EndDate", item.EndDate.Value); - } - else - { - saveItemStatement.TryBindNull("@EndDate"); - } - - saveItemStatement.TryBind("@ChannelId", item.ChannelId.IsEmpty() ? null : item.ChannelId.ToString("N", CultureInfo.InvariantCulture)); - - if (item is IHasProgramAttributes hasProgramAttributes) - { - saveItemStatement.TryBind("@IsMovie", hasProgramAttributes.IsMovie); - saveItemStatement.TryBind("@IsSeries", hasProgramAttributes.IsSeries); - saveItemStatement.TryBind("@EpisodeTitle", hasProgramAttributes.EpisodeTitle); - saveItemStatement.TryBind("@IsRepeat", hasProgramAttributes.IsRepeat); - } - else - { - saveItemStatement.TryBindNull("@IsMovie"); - saveItemStatement.TryBindNull("@IsSeries"); - saveItemStatement.TryBindNull("@EpisodeTitle"); - saveItemStatement.TryBindNull("@IsRepeat"); - } - - saveItemStatement.TryBind("@CommunityRating", item.CommunityRating); - saveItemStatement.TryBind("@CustomRating", item.CustomRating); - saveItemStatement.TryBind("@IndexNumber", item.IndexNumber); - saveItemStatement.TryBind("@IsLocked", item.IsLocked); - saveItemStatement.TryBind("@Name", item.Name); - saveItemStatement.TryBind("@OfficialRating", item.OfficialRating); - saveItemStatement.TryBind("@MediaType", item.MediaType.ToString()); - saveItemStatement.TryBind("@Overview", item.Overview); - saveItemStatement.TryBind("@ParentIndexNumber", item.ParentIndexNumber); - saveItemStatement.TryBind("@PremiereDate", item.PremiereDate); - saveItemStatement.TryBind("@ProductionYear", item.ProductionYear); - - var parentId = item.ParentId; - if (parentId.IsEmpty()) - { - saveItemStatement.TryBindNull("@ParentId"); - } - else - { - saveItemStatement.TryBind("@ParentId", parentId); - } - - if (item.Genres.Length > 0) - { - saveItemStatement.TryBind("@Genres", string.Join('|', item.Genres)); - } - else - { - saveItemStatement.TryBindNull("@Genres"); - } - - saveItemStatement.TryBind("@InheritedParentalRatingValue", item.InheritedParentalRatingValue); - - saveItemStatement.TryBind("@SortName", item.SortName); - - saveItemStatement.TryBind("@ForcedSortName", item.ForcedSortName); - - saveItemStatement.TryBind("@RunTimeTicks", item.RunTimeTicks); - saveItemStatement.TryBind("@Size", item.Size); - - saveItemStatement.TryBind("@DateCreated", item.DateCreated); - saveItemStatement.TryBind("@DateModified", item.DateModified); - - saveItemStatement.TryBind("@PreferredMetadataLanguage", item.PreferredMetadataLanguage); - saveItemStatement.TryBind("@PreferredMetadataCountryCode", item.PreferredMetadataCountryCode); - - if (item.Width > 0) - { - saveItemStatement.TryBind("@Width", item.Width); - } - else - { - saveItemStatement.TryBindNull("@Width"); - } - - if (item.Height > 0) - { - saveItemStatement.TryBind("@Height", item.Height); - } - else - { - saveItemStatement.TryBindNull("@Height"); - } - - if (item.DateLastRefreshed != default(DateTime)) - { - saveItemStatement.TryBind("@DateLastRefreshed", item.DateLastRefreshed); - } - else - { - saveItemStatement.TryBindNull("@DateLastRefreshed"); - } - - if (item.DateLastSaved != default(DateTime)) - { - saveItemStatement.TryBind("@DateLastSaved", item.DateLastSaved); - } - else - { - saveItemStatement.TryBindNull("@DateLastSaved"); - } - - saveItemStatement.TryBind("@IsInMixedFolder", item.IsInMixedFolder); - - if (item.LockedFields.Length > 0) - { - saveItemStatement.TryBind("@LockedFields", string.Join('|', item.LockedFields)); - } - else - { - saveItemStatement.TryBindNull("@LockedFields"); - } - - if (item.Studios.Length > 0) - { - saveItemStatement.TryBind("@Studios", string.Join('|', item.Studios)); - } - else - { - saveItemStatement.TryBindNull("@Studios"); - } - - if (item.Audio.HasValue) - { - saveItemStatement.TryBind("@Audio", item.Audio.Value.ToString()); - } - else - { - saveItemStatement.TryBindNull("@Audio"); - } - - if (item is LiveTvChannel liveTvChannel) - { - saveItemStatement.TryBind("@ExternalServiceId", liveTvChannel.ServiceName); - } - else - { - saveItemStatement.TryBindNull("@ExternalServiceId"); - } - - if (item.Tags.Length > 0) - { - saveItemStatement.TryBind("@Tags", string.Join('|', item.Tags)); - } - else - { - saveItemStatement.TryBindNull("@Tags"); - } - - saveItemStatement.TryBind("@IsFolder", item.IsFolder); - - saveItemStatement.TryBind("@UnratedType", item.GetBlockUnratedType().ToString()); - - if (topParent is null) - { - saveItemStatement.TryBindNull("@TopParentId"); - } - else - { - saveItemStatement.TryBind("@TopParentId", topParent.Id.ToString("N", CultureInfo.InvariantCulture)); - } - - if (item is Trailer trailer && trailer.TrailerTypes.Length > 0) - { - saveItemStatement.TryBind("@TrailerTypes", string.Join('|', trailer.TrailerTypes)); - } - else - { - saveItemStatement.TryBindNull("@TrailerTypes"); - } - - saveItemStatement.TryBind("@CriticRating", item.CriticRating); - - if (string.IsNullOrWhiteSpace(item.Name)) - { - saveItemStatement.TryBindNull("@CleanName"); - } - else - { - saveItemStatement.TryBind("@CleanName", GetCleanValue(item.Name)); - } - - saveItemStatement.TryBind("@PresentationUniqueKey", item.PresentationUniqueKey); - saveItemStatement.TryBind("@OriginalTitle", item.OriginalTitle); - - if (item is Video video) - { - saveItemStatement.TryBind("@PrimaryVersionId", video.PrimaryVersionId); - } - else - { - saveItemStatement.TryBindNull("@PrimaryVersionId"); - } - - if (item is Folder folder && folder.DateLastMediaAdded.HasValue) - { - saveItemStatement.TryBind("@DateLastMediaAdded", folder.DateLastMediaAdded.Value); - } - else - { - saveItemStatement.TryBindNull("@DateLastMediaAdded"); - } - - saveItemStatement.TryBind("@Album", item.Album); - saveItemStatement.TryBind("@LUFS", item.LUFS); - saveItemStatement.TryBind("@NormalizationGain", item.NormalizationGain); - saveItemStatement.TryBind("@IsVirtualItem", item.IsVirtualItem); - - if (item is IHasSeries hasSeriesName) - { - saveItemStatement.TryBind("@SeriesName", hasSeriesName.SeriesName); - } - else - { - saveItemStatement.TryBindNull("@SeriesName"); - } - - if (string.IsNullOrWhiteSpace(userDataKey)) - { - saveItemStatement.TryBindNull("@UserDataKey"); - } - else - { - saveItemStatement.TryBind("@UserDataKey", userDataKey); - } - - if (item is Episode episode) - { - saveItemStatement.TryBind("@SeasonName", episode.SeasonName); - - var nullableSeasonId = episode.SeasonId.IsEmpty() ? (Guid?)null : episode.SeasonId; - - saveItemStatement.TryBind("@SeasonId", nullableSeasonId); - } - else - { - saveItemStatement.TryBindNull("@SeasonName"); - saveItemStatement.TryBindNull("@SeasonId"); - } - - if (item is IHasSeries hasSeries) - { - var nullableSeriesId = hasSeries.SeriesId.IsEmpty() ? (Guid?)null : hasSeries.SeriesId; - - saveItemStatement.TryBind("@SeriesId", nullableSeriesId); - saveItemStatement.TryBind("@SeriesPresentationUniqueKey", hasSeries.SeriesPresentationUniqueKey); - } - else - { - saveItemStatement.TryBindNull("@SeriesId"); - saveItemStatement.TryBindNull("@SeriesPresentationUniqueKey"); - } - - saveItemStatement.TryBind("@ExternalSeriesId", item.ExternalSeriesId); - saveItemStatement.TryBind("@Tagline", item.Tagline); - - saveItemStatement.TryBind("@ProviderIds", SerializeProviderIds(item.ProviderIds)); - saveItemStatement.TryBind("@Images", SerializeImages(item.ImageInfos)); - - if (item.ProductionLocations.Length > 0) - { - saveItemStatement.TryBind("@ProductionLocations", string.Join('|', item.ProductionLocations)); - } - else - { - saveItemStatement.TryBindNull("@ProductionLocations"); - } - - if (item.ExtraIds.Length > 0) - { - saveItemStatement.TryBind("@ExtraIds", string.Join('|', item.ExtraIds)); - } - else - { - saveItemStatement.TryBindNull("@ExtraIds"); - } - - saveItemStatement.TryBind("@TotalBitrate", item.TotalBitrate); - if (item.ExtraType.HasValue) - { - saveItemStatement.TryBind("@ExtraType", item.ExtraType.Value.ToString()); - } - else - { - saveItemStatement.TryBindNull("@ExtraType"); - } - - string artists = null; - if (item is IHasArtist hasArtists && hasArtists.Artists.Count > 0) - { - artists = string.Join('|', hasArtists.Artists); - } - - saveItemStatement.TryBind("@Artists", artists); - - string albumArtists = null; - if (item is IHasAlbumArtist hasAlbumArtists - && hasAlbumArtists.AlbumArtists.Count > 0) - { - albumArtists = string.Join('|', hasAlbumArtists.AlbumArtists); - } - - saveItemStatement.TryBind("@AlbumArtists", albumArtists); - saveItemStatement.TryBind("@ExternalId", item.ExternalId); - - if (item is LiveTvProgram program) - { - saveItemStatement.TryBind("@ShowId", program.ShowId); - } - else - { - saveItemStatement.TryBindNull("@ShowId"); - } - - Guid ownerId = item.OwnerId; - if (ownerId.IsEmpty()) - { - saveItemStatement.TryBindNull("@OwnerId"); - } - else - { - saveItemStatement.TryBind("@OwnerId", ownerId); - } - - saveItemStatement.ExecuteNonQuery(); - } - - internal static string SerializeProviderIds(Dictionary providerIds) - { - StringBuilder str = new StringBuilder(); - foreach (var i in providerIds) - { - // Ideally we shouldn't need this IsNullOrWhiteSpace check, - // but we're seeing some cases of bad data slip through - if (string.IsNullOrWhiteSpace(i.Value)) - { - continue; - } - - str.Append(i.Key) - .Append('=') - .Append(i.Value) - .Append('|'); - } - - if (str.Length == 0) - { - return null; - } - - str.Length -= 1; // Remove last | - return str.ToString(); - } - - internal static void DeserializeProviderIds(string value, IHasProviderIds item) - { - if (string.IsNullOrWhiteSpace(value)) - { - return; - } - - foreach (var part in value.SpanSplit('|')) - { - var providerDelimiterIndex = part.IndexOf('='); - // Don't let empty values through - if (providerDelimiterIndex != -1 && part.Length != providerDelimiterIndex + 1) - { - item.SetProviderId(part[..providerDelimiterIndex].ToString(), part[(providerDelimiterIndex + 1)..].ToString()); - } - } - } - - internal string SerializeImages(ItemImageInfo[] images) - { - if (images.Length == 0) - { - return null; - } - - StringBuilder str = new StringBuilder(); - foreach (var i in images) - { - if (string.IsNullOrWhiteSpace(i.Path)) - { - continue; - } - - AppendItemImageInfo(str, i); - str.Append('|'); - } - - str.Length -= 1; // Remove last | - return str.ToString(); - } - - internal ItemImageInfo[] DeserializeImages(string value) - { - if (string.IsNullOrWhiteSpace(value)) - { - return Array.Empty(); - } - - // TODO The following is an ugly performance optimization, but it's extremely unlikely that the data in the database would be malformed - var valueSpan = value.AsSpan(); - var count = valueSpan.Count('|') + 1; - - var position = 0; - var result = new ItemImageInfo[count]; - foreach (var part in valueSpan.Split('|')) - { - var image = ItemImageInfoFromValueString(part); - - if (image is not null) - { - result[position++] = image; - } - } - - if (position == count) - { - return result; - } - - if (position == 0) - { - return Array.Empty(); - } - - // Extremely unlikely, but somehow one or more of the image strings were malformed. Cut the array. - return result[..position]; - } - - private void AppendItemImageInfo(StringBuilder bldr, ItemImageInfo image) - { - const char Delimiter = '*'; - - var path = image.Path ?? string.Empty; - - bldr.Append(GetPathToSave(path)) - .Append(Delimiter) - .Append(image.DateModified.Ticks) - .Append(Delimiter) - .Append(image.Type) - .Append(Delimiter) - .Append(image.Width) - .Append(Delimiter) - .Append(image.Height); - - var hash = image.BlurHash; - if (!string.IsNullOrEmpty(hash)) - { - bldr.Append(Delimiter) - // Replace delimiters with other characters. - // This can be removed when we migrate to a proper DB. - .Append(hash.Replace(Delimiter, '/').Replace('|', '\\')); - } - } - - internal ItemImageInfo ItemImageInfoFromValueString(ReadOnlySpan value) - { - const char Delimiter = '*'; - - var nextSegment = value.IndexOf(Delimiter); - if (nextSegment == -1) - { - return null; - } - - ReadOnlySpan path = value[..nextSegment]; - value = value[(nextSegment + 1)..]; - nextSegment = value.IndexOf(Delimiter); - if (nextSegment == -1) - { - return null; - } - - ReadOnlySpan dateModified = value[..nextSegment]; - value = value[(nextSegment + 1)..]; - nextSegment = value.IndexOf(Delimiter); - if (nextSegment == -1) - { - nextSegment = value.Length; - } - - ReadOnlySpan imageType = value[..nextSegment]; - - var image = new ItemImageInfo - { - Path = RestorePath(path.ToString()) - }; - - if (long.TryParse(dateModified, CultureInfo.InvariantCulture, out var ticks) - && ticks >= DateTime.MinValue.Ticks - && ticks <= DateTime.MaxValue.Ticks) - { - image.DateModified = new DateTime(ticks, DateTimeKind.Utc); - } - else - { - return null; - } - - if (Enum.TryParse(imageType, true, out ImageType type)) - { - image.Type = type; - } - else - { - return null; - } - - // Optional parameters: width*height*blurhash - if (nextSegment + 1 < value.Length - 1) - { - value = value[(nextSegment + 1)..]; - nextSegment = value.IndexOf(Delimiter); - if (nextSegment == -1 || nextSegment == value.Length) - { - return image; - } - - ReadOnlySpan widthSpan = value[..nextSegment]; - - value = value[(nextSegment + 1)..]; - nextSegment = value.IndexOf(Delimiter); - if (nextSegment == -1) - { - nextSegment = value.Length; - } - - ReadOnlySpan heightSpan = value[..nextSegment]; - - if (int.TryParse(widthSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var width) - && int.TryParse(heightSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var height)) - { - image.Width = width; - image.Height = height; - } - - if (nextSegment < value.Length - 1) - { - value = value[(nextSegment + 1)..]; - var length = value.Length; - - Span blurHashSpan = stackalloc char[length]; - for (int i = 0; i < length; i++) - { - var c = value[i]; - blurHashSpan[i] = c switch - { - '/' => Delimiter, - '\\' => '|', - _ => c - }; - } - - image.BlurHash = new string(blurHashSpan); - } - } - - return image; - } - - /// - /// Internal retrieve from items or users table. - /// - /// The id. - /// BaseItem. - /// is null. - /// is . - public BaseItem RetrieveItem(Guid id) - { - if (id.IsEmpty()) - { - throw new ArgumentException("Guid can't be empty", nameof(id)); - } - - CheckDisposed(); - - using (var connection = GetConnection(true)) - using (var statement = PrepareStatement(connection, _retrieveItemColumnsSelectQuery)) - { - statement.TryBind("@guid", id); - - foreach (var row in statement.ExecuteQuery()) - { - return GetItem(row, new InternalItemsQuery()); - } - } - - return null; - } - - private bool TypeRequiresDeserialization(Type type) - { - if (_config.Configuration.SkipDeserializationForBasicTypes) - { - if (type == typeof(Channel) - || type == typeof(UserRootFolder)) - { - return false; - } - } - - return type != typeof(Season) - && type != typeof(MusicArtist) - && type != typeof(Person) - && type != typeof(MusicGenre) - && type != typeof(Genre) - && type != typeof(Studio) - && type != typeof(PlaylistsFolder) - && type != typeof(PhotoAlbum) - && type != typeof(Year) - && type != typeof(Book) - && type != typeof(LiveTvProgram) - && type != typeof(AudioBook) - && type != typeof(MusicAlbum); - } - - private BaseItem GetItem(SqliteDataReader reader, InternalItemsQuery query) - { - return GetItem(reader, query, HasProgramAttributes(query), HasEpisodeAttributes(query), HasServiceName(query), HasStartDate(query), HasTrailerTypes(query), HasArtistFields(query), HasSeriesFields(query), false); - } - - private BaseItem GetItem(SqliteDataReader reader, InternalItemsQuery query, bool enableProgramAttributes, bool hasEpisodeAttributes, bool hasServiceName, bool queryHasStartDate, bool hasTrailerTypes, bool hasArtistFields, bool hasSeriesFields, bool skipDeserialization) - { - var typeString = reader.GetString(0); - - var type = _typeMapper.GetType(typeString); - - if (type is null) - { - return null; - } - - BaseItem item = null; - - if (TypeRequiresDeserialization(type) && !skipDeserialization) - { - try - { - item = JsonSerializer.Deserialize(reader.GetStream(1), type, _jsonOptions) as BaseItem; - } - catch (JsonException ex) - { - Logger.LogError(ex, "Error deserializing item with JSON: {Data}", reader.GetString(1)); - } - } - - if (item is null) - { - try - { - item = Activator.CreateInstance(type) as BaseItem; - } - catch - { - } - } - - if (item is null) - { - return null; - } - - var index = 2; - - if (queryHasStartDate) - { - if (item is IHasStartDate hasStartDate && reader.TryReadDateTime(index, out var startDate)) - { - hasStartDate.StartDate = startDate; - } - - index++; - } - - if (reader.TryReadDateTime(index++, out var endDate)) - { - item.EndDate = endDate; - } - - if (reader.TryGetGuid(index, out var guid)) - { - item.ChannelId = guid; - } - - index++; - - if (enableProgramAttributes) - { - if (item is IHasProgramAttributes hasProgramAttributes) - { - if (reader.TryGetBoolean(index++, out var isMovie)) - { - hasProgramAttributes.IsMovie = isMovie; - } - - if (reader.TryGetBoolean(index++, out var isSeries)) - { - hasProgramAttributes.IsSeries = isSeries; - } - - if (reader.TryGetString(index++, out var episodeTitle)) - { - hasProgramAttributes.EpisodeTitle = episodeTitle; - } - - if (reader.TryGetBoolean(index++, out var isRepeat)) - { - hasProgramAttributes.IsRepeat = isRepeat; - } - } - else - { - index += 4; - } - } - - if (reader.TryGetSingle(index++, out var communityRating)) - { - item.CommunityRating = communityRating; - } - - if (HasField(query, ItemFields.CustomRating)) - { - if (reader.TryGetString(index++, out var customRating)) - { - item.CustomRating = customRating; - } - } - - if (reader.TryGetInt32(index++, out var indexNumber)) - { - item.IndexNumber = indexNumber; - } - - if (HasField(query, ItemFields.Settings)) - { - if (reader.TryGetBoolean(index++, out var isLocked)) - { - item.IsLocked = isLocked; - } - - if (reader.TryGetString(index++, out var preferredMetadataLanguage)) - { - item.PreferredMetadataLanguage = preferredMetadataLanguage; - } - - if (reader.TryGetString(index++, out var preferredMetadataCountryCode)) - { - item.PreferredMetadataCountryCode = preferredMetadataCountryCode; - } - } - - if (HasField(query, ItemFields.Width)) - { - if (reader.TryGetInt32(index++, out var width)) - { - item.Width = width; - } - } - - if (HasField(query, ItemFields.Height)) - { - if (reader.TryGetInt32(index++, out var height)) - { - item.Height = height; - } - } - - if (HasField(query, ItemFields.DateLastRefreshed)) - { - if (reader.TryReadDateTime(index++, out var dateLastRefreshed)) - { - item.DateLastRefreshed = dateLastRefreshed; - } - } - - if (reader.TryGetString(index++, out var name)) - { - item.Name = name; - } - - if (reader.TryGetString(index++, out var restorePath)) - { - item.Path = RestorePath(restorePath); - } - - if (reader.TryReadDateTime(index++, out var premiereDate)) - { - item.PremiereDate = premiereDate; - } - - if (HasField(query, ItemFields.Overview)) - { - if (reader.TryGetString(index++, out var overview)) - { - item.Overview = overview; - } - } - - if (reader.TryGetInt32(index++, out var parentIndexNumber)) - { - item.ParentIndexNumber = parentIndexNumber; - } - - if (reader.TryGetInt32(index++, out var productionYear)) - { - item.ProductionYear = productionYear; - } - - if (reader.TryGetString(index++, out var officialRating)) - { - item.OfficialRating = officialRating; - } - - if (HasField(query, ItemFields.SortName)) - { - if (reader.TryGetString(index++, out var forcedSortName)) - { - item.ForcedSortName = forcedSortName; - } - } - - if (reader.TryGetInt64(index++, out var runTimeTicks)) - { - item.RunTimeTicks = runTimeTicks; - } - - if (reader.TryGetInt64(index++, out var size)) - { - item.Size = size; - } - - if (HasField(query, ItemFields.DateCreated)) - { - if (reader.TryReadDateTime(index++, out var dateCreated)) - { - item.DateCreated = dateCreated; - } - } - - if (reader.TryReadDateTime(index++, out var dateModified)) - { - item.DateModified = dateModified; - } - - item.Id = reader.GetGuid(index++); - - if (HasField(query, ItemFields.Genres)) - { - if (reader.TryGetString(index++, out var genres)) - { - item.Genres = genres.Split('|', StringSplitOptions.RemoveEmptyEntries); - } - } - - if (reader.TryGetGuid(index++, out var parentId)) - { - item.ParentId = parentId; - } - - if (reader.TryGetString(index++, out var audioString)) - { - if (Enum.TryParse(audioString, true, out ProgramAudio audio)) - { - item.Audio = audio; - } - } - - // TODO: Even if not needed by apps, the server needs it internally - // But get this excluded from contexts where it is not needed - if (hasServiceName) - { - if (item is LiveTvChannel liveTvChannel) - { - if (reader.TryGetString(index, out var serviceName)) - { - liveTvChannel.ServiceName = serviceName; - } - } - - index++; - } - - if (reader.TryGetBoolean(index++, out var isInMixedFolder)) - { - item.IsInMixedFolder = isInMixedFolder; - } - - if (HasField(query, ItemFields.DateLastSaved)) - { - if (reader.TryReadDateTime(index++, out var dateLastSaved)) - { - item.DateLastSaved = dateLastSaved; - } - } - - if (HasField(query, ItemFields.Settings)) - { - if (reader.TryGetString(index++, out var lockedFields)) - { - List fields = null; - foreach (var i in lockedFields.AsSpan().Split('|')) - { - if (Enum.TryParse(i, true, out MetadataField parsedValue)) - { - (fields ??= new List()).Add(parsedValue); - } - } - - item.LockedFields = fields?.ToArray() ?? Array.Empty(); - } - } - - if (HasField(query, ItemFields.Studios)) - { - if (reader.TryGetString(index++, out var studios)) - { - item.Studios = studios.Split('|', StringSplitOptions.RemoveEmptyEntries); - } - } - - if (HasField(query, ItemFields.Tags)) - { - if (reader.TryGetString(index++, out var tags)) - { - item.Tags = tags.Split('|', StringSplitOptions.RemoveEmptyEntries); - } - } - - if (hasTrailerTypes) - { - if (item is Trailer trailer) - { - if (reader.TryGetString(index, out var trailerTypes)) - { - List types = null; - foreach (var i in trailerTypes.AsSpan().Split('|')) - { - if (Enum.TryParse(i, true, out TrailerType parsedValue)) - { - (types ??= new List()).Add(parsedValue); - } - } - - trailer.TrailerTypes = types?.ToArray() ?? Array.Empty(); - } - } - - index++; - } - - if (HasField(query, ItemFields.OriginalTitle)) - { - if (reader.TryGetString(index++, out var originalTitle)) - { - item.OriginalTitle = originalTitle; - } - } - - if (item is Video video) - { - if (reader.TryGetString(index, out var primaryVersionId)) - { - video.PrimaryVersionId = primaryVersionId; - } - } - - index++; - - if (HasField(query, ItemFields.DateLastMediaAdded)) - { - if (item is Folder folder && reader.TryReadDateTime(index, out var dateLastMediaAdded)) - { - folder.DateLastMediaAdded = dateLastMediaAdded; - } - - index++; - } - - if (reader.TryGetString(index++, out var album)) - { - item.Album = album; - } - - if (reader.TryGetSingle(index++, out var lUFS)) - { - item.LUFS = lUFS; - } - - if (reader.TryGetSingle(index++, out var normalizationGain)) - { - item.NormalizationGain = normalizationGain; - } - - if (reader.TryGetSingle(index++, out var criticRating)) - { - item.CriticRating = criticRating; - } - - if (reader.TryGetBoolean(index++, out var isVirtualItem)) - { - item.IsVirtualItem = isVirtualItem; - } - - if (item is IHasSeries hasSeriesName) - { - if (reader.TryGetString(index, out var seriesName)) - { - hasSeriesName.SeriesName = seriesName; - } - } - - index++; - - if (hasEpisodeAttributes) - { - if (item is Episode episode) - { - if (reader.TryGetString(index, out var seasonName)) - { - episode.SeasonName = seasonName; - } - - index++; - if (reader.TryGetGuid(index, out var seasonId)) - { - episode.SeasonId = seasonId; - } - } - else - { - index++; - } - - index++; - } - - var hasSeries = item as IHasSeries; - if (hasSeriesFields) - { - if (hasSeries is not null) - { - if (reader.TryGetGuid(index, out var seriesId)) - { - hasSeries.SeriesId = seriesId; - } - } - - index++; - } - - if (HasField(query, ItemFields.PresentationUniqueKey)) - { - if (reader.TryGetString(index++, out var presentationUniqueKey)) - { - item.PresentationUniqueKey = presentationUniqueKey; - } - } - - if (HasField(query, ItemFields.InheritedParentalRatingValue)) - { - if (reader.TryGetInt32(index++, out var parentalRating)) - { - item.InheritedParentalRatingValue = parentalRating; - } - } - - if (HasField(query, ItemFields.ExternalSeriesId)) - { - if (reader.TryGetString(index++, out var externalSeriesId)) - { - item.ExternalSeriesId = externalSeriesId; - } - } - - if (HasField(query, ItemFields.Taglines)) - { - if (reader.TryGetString(index++, out var tagLine)) - { - item.Tagline = tagLine; - } - } - - if (item.ProviderIds.Count == 0 && reader.TryGetString(index, out var providerIds)) - { - DeserializeProviderIds(providerIds, item); - } - - index++; - - if (query.DtoOptions.EnableImages) - { - if (item.ImageInfos.Length == 0 && reader.TryGetString(index, out var imageInfos)) - { - item.ImageInfos = DeserializeImages(imageInfos); - } - - index++; - } - - if (HasField(query, ItemFields.ProductionLocations)) - { - if (reader.TryGetString(index++, out var productionLocations)) - { - item.ProductionLocations = productionLocations.Split('|', StringSplitOptions.RemoveEmptyEntries); - } - } - - if (HasField(query, ItemFields.ExtraIds)) - { - if (reader.TryGetString(index++, out var extraIds)) - { - item.ExtraIds = SplitToGuids(extraIds); - } - } - - if (reader.TryGetInt32(index++, out var totalBitrate)) - { - item.TotalBitrate = totalBitrate; - } - - if (reader.TryGetString(index++, out var extraTypeString)) - { - if (Enum.TryParse(extraTypeString, true, out ExtraType extraType)) - { - item.ExtraType = extraType; - } - } - - if (hasArtistFields) - { - if (item is IHasArtist hasArtists && reader.TryGetString(index, out var artists)) - { - hasArtists.Artists = artists.Split('|', StringSplitOptions.RemoveEmptyEntries); - } - - index++; - - if (item is IHasAlbumArtist hasAlbumArtists && reader.TryGetString(index, out var albumArtists)) - { - hasAlbumArtists.AlbumArtists = albumArtists.Split('|', StringSplitOptions.RemoveEmptyEntries); - } - - index++; - } - - if (reader.TryGetString(index++, out var externalId)) - { - item.ExternalId = externalId; - } - - if (HasField(query, ItemFields.SeriesPresentationUniqueKey)) - { - if (hasSeries is not null) - { - if (reader.TryGetString(index, out var seriesPresentationUniqueKey)) - { - hasSeries.SeriesPresentationUniqueKey = seriesPresentationUniqueKey; - } - } - - index++; - } - - if (enableProgramAttributes) - { - if (item is LiveTvProgram program && reader.TryGetString(index, out var showId)) - { - program.ShowId = showId; - } - - index++; - } - - if (reader.TryGetGuid(index, out var ownerId)) - { - item.OwnerId = ownerId; - } - - return item; - } - - private static Guid[] SplitToGuids(string value) - { - var ids = value.Split('|'); - - var result = new Guid[ids.Length]; - - for (var i = 0; i < result.Length; i++) - { - result[i] = new Guid(ids[i]); - } - - return result; - } - - /// - public List GetChapters(BaseItem item) - { - CheckDisposed(); - - var chapters = new List(); - using (var connection = GetConnection(true)) - using (var statement = PrepareStatement(connection, "select StartPositionTicks,Name,ImagePath,ImageDateModified from " + ChaptersTableName + " where ItemId = @ItemId order by ChapterIndex asc")) - { - statement.TryBind("@ItemId", item.Id); - - foreach (var row in statement.ExecuteQuery()) - { - chapters.Add(GetChapter(row, item)); - } - } - - return chapters; - } - - /// - public ChapterInfo GetChapter(BaseItem item, int index) - { - CheckDisposed(); - - using (var connection = GetConnection(true)) - using (var statement = PrepareStatement(connection, "select StartPositionTicks,Name,ImagePath,ImageDateModified from " + ChaptersTableName + " where ItemId = @ItemId and ChapterIndex=@ChapterIndex")) - { - statement.TryBind("@ItemId", item.Id); - statement.TryBind("@ChapterIndex", index); - - foreach (var row in statement.ExecuteQuery()) - { - return GetChapter(row, item); - } - } - - return null; - } - - /// - /// Gets the chapter. - /// - /// The reader. - /// The item. - /// ChapterInfo. - private ChapterInfo GetChapter(SqliteDataReader reader, BaseItem item) - { - var chapter = new ChapterInfo - { - StartPositionTicks = reader.GetInt64(0) - }; - - if (reader.TryGetString(1, out var chapterName)) - { - chapter.Name = chapterName; - } - - if (reader.TryGetString(2, out var imagePath)) - { - chapter.ImagePath = imagePath; - chapter.ImageTag = _imageProcessor.GetImageCacheTag(item, chapter); - } - - if (reader.TryReadDateTime(3, out var imageDateModified)) - { - chapter.ImageDateModified = imageDateModified; - } - - return chapter; - } - - /// - /// Saves the chapters. - /// - /// The item id. - /// The chapters. - public void SaveChapters(Guid id, IReadOnlyList chapters) - { - CheckDisposed(); - - if (id.IsEmpty()) - { - throw new ArgumentNullException(nameof(id)); - } - - ArgumentNullException.ThrowIfNull(chapters); - - using var connection = GetConnection(); - using var transaction = connection.BeginTransaction(); - // First delete chapters - using var command = connection.PrepareStatement($"delete from {ChaptersTableName} where ItemId=@ItemId"); - command.TryBind("@ItemId", id); - command.ExecuteNonQuery(); - - InsertChapters(id, chapters, connection); - transaction.Commit(); - } - - private void InsertChapters(Guid idBlob, IReadOnlyList chapters, ManagedConnection db) - { - var startIndex = 0; - var limit = 100; - var chapterIndex = 0; - - const string StartInsertText = "insert into " + ChaptersTableName + " (ItemId, ChapterIndex, StartPositionTicks, Name, ImagePath, ImageDateModified) values "; - var insertText = new StringBuilder(StartInsertText, 256); - - while (startIndex < chapters.Count) - { - var endIndex = Math.Min(chapters.Count, startIndex + limit); - - for (var i = startIndex; i < endIndex; i++) - { - insertText.AppendFormat(CultureInfo.InvariantCulture, "(@ItemId, @ChapterIndex{0}, @StartPositionTicks{0}, @Name{0}, @ImagePath{0}, @ImageDateModified{0}),", i.ToString(CultureInfo.InvariantCulture)); - } - - insertText.Length -= 1; // Remove trailing comma - - using (var statement = PrepareStatement(db, insertText.ToString())) - { - statement.TryBind("@ItemId", idBlob); - - for (var i = startIndex; i < endIndex; i++) - { - var index = i.ToString(CultureInfo.InvariantCulture); - - var chapter = chapters[i]; - - statement.TryBind("@ChapterIndex" + index, chapterIndex); - statement.TryBind("@StartPositionTicks" + index, chapter.StartPositionTicks); - statement.TryBind("@Name" + index, chapter.Name); - statement.TryBind("@ImagePath" + index, chapter.ImagePath); - statement.TryBind("@ImageDateModified" + index, chapter.ImageDateModified); - - chapterIndex++; - } - - statement.ExecuteNonQuery(); - } - - startIndex += limit; - insertText.Length = StartInsertText.Length; - } - } - - private static bool EnableJoinUserData(InternalItemsQuery query) - { - if (query.User is null) - { - return false; - } - - var sortingFields = new HashSet(query.OrderBy.Select(i => i.OrderBy)); - - return sortingFields.Contains(ItemSortBy.IsFavoriteOrLiked) - || sortingFields.Contains(ItemSortBy.IsPlayed) - || sortingFields.Contains(ItemSortBy.IsUnplayed) - || sortingFields.Contains(ItemSortBy.PlayCount) - || sortingFields.Contains(ItemSortBy.DatePlayed) - || sortingFields.Contains(ItemSortBy.SeriesDatePlayed) - || query.IsFavoriteOrLiked.HasValue - || query.IsFavorite.HasValue - || query.IsResumable.HasValue - || query.IsPlayed.HasValue - || query.IsLiked.HasValue; - } - - private bool HasField(InternalItemsQuery query, ItemFields name) - { - switch (name) - { - case ItemFields.Tags: - return query.DtoOptions.ContainsField(name) || HasProgramAttributes(query); - case ItemFields.CustomRating: - case ItemFields.ProductionLocations: - case ItemFields.Settings: - case ItemFields.OriginalTitle: - case ItemFields.Taglines: - case ItemFields.SortName: - case ItemFields.Studios: - case ItemFields.ExtraIds: - case ItemFields.DateCreated: - case ItemFields.Overview: - case ItemFields.Genres: - case ItemFields.DateLastMediaAdded: - case ItemFields.PresentationUniqueKey: - case ItemFields.InheritedParentalRatingValue: - case ItemFields.ExternalSeriesId: - case ItemFields.SeriesPresentationUniqueKey: - case ItemFields.DateLastRefreshed: - case ItemFields.DateLastSaved: - return query.DtoOptions.ContainsField(name); - case ItemFields.ServiceName: - return HasServiceName(query); - default: - return true; - } - } - - private bool HasProgramAttributes(InternalItemsQuery query) - { - if (query.ParentType is not null && _programExcludeParentTypes.Contains(query.ParentType.Value)) - { - return false; - } - - if (query.IncludeItemTypes.Length == 0) - { - return true; - } - - return query.IncludeItemTypes.Any(x => _programTypes.Contains(x)); - } - - private bool HasServiceName(InternalItemsQuery query) - { - if (query.ParentType is not null && _programExcludeParentTypes.Contains(query.ParentType.Value)) - { - return false; - } - - if (query.IncludeItemTypes.Length == 0) - { - return true; - } - - return query.IncludeItemTypes.Any(x => _serviceTypes.Contains(x)); - } - - private bool HasStartDate(InternalItemsQuery query) - { - if (query.ParentType is not null && _programExcludeParentTypes.Contains(query.ParentType.Value)) - { - return false; - } - - if (query.IncludeItemTypes.Length == 0) - { - return true; - } - - return query.IncludeItemTypes.Any(x => _startDateTypes.Contains(x)); - } - - private bool HasEpisodeAttributes(InternalItemsQuery query) - { - if (query.IncludeItemTypes.Length == 0) - { - return true; - } - - return query.IncludeItemTypes.Contains(BaseItemKind.Episode); - } - - private bool HasTrailerTypes(InternalItemsQuery query) - { - if (query.IncludeItemTypes.Length == 0) - { - return true; - } - - return query.IncludeItemTypes.Contains(BaseItemKind.Trailer); - } - - private bool HasArtistFields(InternalItemsQuery query) - { - if (query.ParentType is not null && _artistExcludeParentTypes.Contains(query.ParentType.Value)) - { - return false; - } - - if (query.IncludeItemTypes.Length == 0) - { - return true; - } - - return query.IncludeItemTypes.Any(x => _artistsTypes.Contains(x)); - } - - private bool HasSeriesFields(InternalItemsQuery query) - { - if (query.ParentType == BaseItemKind.PhotoAlbum) - { - return false; - } - - if (query.IncludeItemTypes.Length == 0) - { - return true; - } - - return query.IncludeItemTypes.Any(x => _seriesTypes.Contains(x)); - } - - private void SetFinalColumnsToSelect(InternalItemsQuery query, List columns) - { - foreach (var field in _allItemFields) - { - if (!HasField(query, field)) - { - switch (field) - { - case ItemFields.Settings: - columns.Remove("IsLocked"); - columns.Remove("PreferredMetadataCountryCode"); - columns.Remove("PreferredMetadataLanguage"); - columns.Remove("LockedFields"); - break; - case ItemFields.ServiceName: - columns.Remove("ExternalServiceId"); - break; - case ItemFields.SortName: - columns.Remove("ForcedSortName"); - break; - case ItemFields.Taglines: - columns.Remove("Tagline"); - break; - case ItemFields.Tags: - columns.Remove("Tags"); - break; - case ItemFields.IsHD: - // do nothing - break; - default: - columns.Remove(field.ToString()); - break; - } - } - } - - if (!HasProgramAttributes(query)) - { - columns.Remove("IsMovie"); - columns.Remove("IsSeries"); - columns.Remove("EpisodeTitle"); - columns.Remove("IsRepeat"); - columns.Remove("ShowId"); - } - - if (!HasEpisodeAttributes(query)) - { - columns.Remove("SeasonName"); - columns.Remove("SeasonId"); - } - - if (!HasStartDate(query)) - { - columns.Remove("StartDate"); - } - - if (!HasTrailerTypes(query)) - { - columns.Remove("TrailerTypes"); - } - - if (!HasArtistFields(query)) - { - columns.Remove("AlbumArtists"); - columns.Remove("Artists"); - } - - if (!HasSeriesFields(query)) - { - columns.Remove("SeriesId"); - } - - if (!HasEpisodeAttributes(query)) - { - columns.Remove("SeasonName"); - columns.Remove("SeasonId"); - } - - if (!query.DtoOptions.EnableImages) - { - columns.Remove("Images"); - } - - if (EnableJoinUserData(query)) - { - columns.Add("UserDatas.UserId"); - columns.Add("UserDatas.lastPlayedDate"); - columns.Add("UserDatas.playbackPositionTicks"); - columns.Add("UserDatas.playcount"); - columns.Add("UserDatas.isFavorite"); - columns.Add("UserDatas.played"); - columns.Add("UserDatas.rating"); - } - - if (query.SimilarTo is not null) - { - var item = query.SimilarTo; - - var builder = new StringBuilder(); - builder.Append('('); - - if (item.InheritedParentalRatingValue == 0) - { - builder.Append("((InheritedParentalRatingValue=0) * 10)"); - } - else - { - builder.Append( - @"(SELECT CASE WHEN COALESCE(InheritedParentalRatingValue, 0)=0 - THEN 0 - ELSE 10.0 / (1.0 + ABS(InheritedParentalRatingValue - @InheritedParentalRatingValue)) - END)"); - } - - if (item.ProductionYear.HasValue) - { - builder.Append("+(Select Case When Abs(COALESCE(ProductionYear, 0) - @ItemProductionYear) < 10 Then 10 Else 0 End )"); - builder.Append("+(Select Case When Abs(COALESCE(ProductionYear, 0) - @ItemProductionYear) < 5 Then 5 Else 0 End )"); - } - - // genres, tags, studios, person, year? - builder.Append("+ (Select count(1) * 10 from ItemValues where ItemId=Guid and CleanValue in (select CleanValue from ItemValues where ItemId=@SimilarItemId))"); - builder.Append("+ (Select count(1) * 10 from People where ItemId=Guid and Name in (select Name from People where ItemId=@SimilarItemId))"); - - if (item is MusicArtist) - { - // Match albums where the artist is AlbumArtist against other albums. - // It is assumed that similar albums => similar artists. - builder.Append( - @"+ (WITH artistValues AS ( - SELECT DISTINCT albumValues.CleanValue - FROM ItemValues albumValues - INNER JOIN ItemValues artistAlbums ON albumValues.ItemId = artistAlbums.ItemId - INNER JOIN TypedBaseItems artistItem ON artistAlbums.CleanValue = artistItem.CleanName AND artistAlbums.TYPE = 1 AND artistItem.Guid = @SimilarItemId - ), similarArtist AS ( - SELECT albumValues.ItemId - FROM ItemValues albumValues - INNER JOIN ItemValues artistAlbums ON albumValues.ItemId = artistAlbums.ItemId - INNER JOIN TypedBaseItems artistItem ON artistAlbums.CleanValue = artistItem.CleanName AND artistAlbums.TYPE = 1 AND artistItem.Guid = A.Guid - ) SELECT COUNT(DISTINCT(CleanValue)) * 10 FROM ItemValues WHERE ItemId IN (SELECT ItemId FROM similarArtist) AND CleanValue IN (SELECT CleanValue FROM artistValues))"); - } - - builder.Append(") as SimilarityScore"); - - columns.Add(builder.ToString()); - - query.ExcludeItemIds = [.. query.ExcludeItemIds, item.Id, .. item.ExtraIds]; - query.ExcludeProviderIds = item.ProviderIds; - } - - if (!string.IsNullOrEmpty(query.SearchTerm)) - { - var builder = new StringBuilder(); - builder.Append('('); - - builder.Append("((CleanName like @SearchTermStartsWith or (OriginalTitle not null and OriginalTitle like @SearchTermStartsWith)) * 10)"); - builder.Append("+ ((CleanName = @SearchTermStartsWith COLLATE NOCASE or (OriginalTitle not null and OriginalTitle = @SearchTermStartsWith COLLATE NOCASE)) * 10)"); - - if (query.SearchTerm.Length > 1) - { - builder.Append("+ ((CleanName like @SearchTermContains or (OriginalTitle not null and OriginalTitle like @SearchTermContains)) * 10)"); - } - - builder.Append(") as SearchScore"); - - columns.Add(builder.ToString()); - } - } - - private void BindSearchParams(InternalItemsQuery query, SqliteCommand statement) - { - var searchTerm = query.SearchTerm; - - if (string.IsNullOrEmpty(searchTerm)) - { - return; - } - - searchTerm = FixUnicodeChars(searchTerm); - searchTerm = GetCleanValue(searchTerm); - - var commandText = statement.CommandText; - if (commandText.Contains("@SearchTermStartsWith", StringComparison.OrdinalIgnoreCase)) - { - statement.TryBind("@SearchTermStartsWith", searchTerm + "%"); - } - - if (commandText.Contains("@SearchTermContains", StringComparison.OrdinalIgnoreCase)) - { - statement.TryBind("@SearchTermContains", "%" + searchTerm + "%"); - } - } - - private void BindSimilarParams(InternalItemsQuery query, SqliteCommand statement) - { - var item = query.SimilarTo; - - if (item is null) - { - return; - } - - var commandText = statement.CommandText; - - if (commandText.Contains("@ItemOfficialRating", StringComparison.OrdinalIgnoreCase)) - { - statement.TryBind("@ItemOfficialRating", item.OfficialRating); - } - - if (commandText.Contains("@ItemProductionYear", StringComparison.OrdinalIgnoreCase)) - { - statement.TryBind("@ItemProductionYear", item.ProductionYear ?? 0); - } - - if (commandText.Contains("@SimilarItemId", StringComparison.OrdinalIgnoreCase)) - { - statement.TryBind("@SimilarItemId", item.Id); - } - - if (commandText.Contains("@InheritedParentalRatingValue", StringComparison.OrdinalIgnoreCase)) - { - statement.TryBind("@InheritedParentalRatingValue", item.InheritedParentalRatingValue); - } - } - - private string GetJoinUserDataText(InternalItemsQuery query) - { - if (!EnableJoinUserData(query)) - { - return string.Empty; - } - - return " left join UserDatas on UserDataKey=UserDatas.Key And (UserId=@UserId)"; - } - - private string GetGroupBy(InternalItemsQuery query) - { - var enableGroupByPresentationUniqueKey = EnableGroupByPresentationUniqueKey(query); - if (enableGroupByPresentationUniqueKey && query.GroupBySeriesPresentationUniqueKey) - { - return " Group by PresentationUniqueKey, SeriesPresentationUniqueKey"; - } - - if (enableGroupByPresentationUniqueKey) - { - return " Group by PresentationUniqueKey"; - } - - if (query.GroupBySeriesPresentationUniqueKey) - { - return " Group by SeriesPresentationUniqueKey"; - } - - return string.Empty; - } - - /// - public int GetCount(InternalItemsQuery query) - { - ArgumentNullException.ThrowIfNull(query); - - CheckDisposed(); - - // Hack for right now since we currently don't support filtering out these duplicates within a query - if (query.Limit.HasValue && query.EnableGroupByMetadataKey) - { - query.Limit = query.Limit.Value + 4; - } - - var columns = new List { "count(distinct PresentationUniqueKey)" }; - SetFinalColumnsToSelect(query, columns); - var commandTextBuilder = new StringBuilder("select ", 256) - .AppendJoin(',', columns) - .Append(FromText) - .Append(GetJoinUserDataText(query)); - - var whereClauses = GetWhereClauses(query, null); - if (whereClauses.Count != 0) - { - commandTextBuilder.Append(" where ") - .AppendJoin(" AND ", whereClauses); - } - - var commandText = commandTextBuilder.ToString(); - - using (new QueryTimeLogger(Logger, commandText)) - using (var connection = GetConnection(true)) - using (var statement = PrepareStatement(connection, commandText)) - { - if (EnableJoinUserData(query)) - { - statement.TryBind("@UserId", query.User.InternalId); - } - - BindSimilarParams(query, statement); - BindSearchParams(query, statement); - - // Running this again will bind the params - GetWhereClauses(query, statement); - - return statement.SelectScalarInt(); - } - } - - /// - public List GetItemList(InternalItemsQuery query) - { - ArgumentNullException.ThrowIfNull(query); - - CheckDisposed(); - - // Hack for right now since we currently don't support filtering out these duplicates within a query - if (query.Limit.HasValue && query.EnableGroupByMetadataKey) - { - query.Limit = query.Limit.Value + 4; - } - - var columns = _retrieveItemColumns.ToList(); - SetFinalColumnsToSelect(query, columns); - var commandTextBuilder = new StringBuilder("select ", 1024) - .AppendJoin(',', columns) - .Append(FromText) - .Append(GetJoinUserDataText(query)); - - var whereClauses = GetWhereClauses(query, null); - - if (whereClauses.Count != 0) - { - commandTextBuilder.Append(" where ") - .AppendJoin(" AND ", whereClauses); - } - - commandTextBuilder.Append(GetGroupBy(query)) - .Append(GetOrderByText(query)); - - if (query.Limit.HasValue || query.StartIndex.HasValue) - { - var offset = query.StartIndex ?? 0; - - if (query.Limit.HasValue || offset > 0) - { - commandTextBuilder.Append(" LIMIT ") - .Append(query.Limit ?? int.MaxValue); - } - - if (offset > 0) - { - commandTextBuilder.Append(" OFFSET ") - .Append(offset); - } - } - - var commandText = commandTextBuilder.ToString(); - var items = new List(); - using (new QueryTimeLogger(Logger, commandText)) - using (var connection = GetConnection(true)) - using (var statement = PrepareStatement(connection, commandText)) - { - if (EnableJoinUserData(query)) - { - statement.TryBind("@UserId", query.User.InternalId); - } - - BindSimilarParams(query, statement); - BindSearchParams(query, statement); - - // Running this again will bind the params - GetWhereClauses(query, statement); - - var hasEpisodeAttributes = HasEpisodeAttributes(query); - var hasServiceName = HasServiceName(query); - var hasProgramAttributes = HasProgramAttributes(query); - var hasStartDate = HasStartDate(query); - var hasTrailerTypes = HasTrailerTypes(query); - var hasArtistFields = HasArtistFields(query); - var hasSeriesFields = HasSeriesFields(query); - - foreach (var row in statement.ExecuteQuery()) - { - var item = GetItem(row, query, hasProgramAttributes, hasEpisodeAttributes, hasServiceName, hasStartDate, hasTrailerTypes, hasArtistFields, hasSeriesFields, query.SkipDeserialization); - if (item is not null) - { - items.Add(item); - } - } - } - - // Hack for right now since we currently don't support filtering out these duplicates within a query - if (query.EnableGroupByMetadataKey) - { - var limit = query.Limit ?? int.MaxValue; - limit -= 4; - var newList = new List(); - - foreach (var item in items) - { - AddItem(newList, item); - - if (newList.Count >= limit) - { - break; - } - } - - items = newList; - } - - return items; - } - - private string FixUnicodeChars(string buffer) - { - buffer = buffer.Replace('\u2013', '-'); // en dash - buffer = buffer.Replace('\u2014', '-'); // em dash - buffer = buffer.Replace('\u2015', '-'); // horizontal bar - buffer = buffer.Replace('\u2017', '_'); // double low line - buffer = buffer.Replace('\u2018', '\''); // left single quotation mark - buffer = buffer.Replace('\u2019', '\''); // right single quotation mark - buffer = buffer.Replace('\u201a', ','); // single low-9 quotation mark - buffer = buffer.Replace('\u201b', '\''); // single high-reversed-9 quotation mark - buffer = buffer.Replace('\u201c', '\"'); // left double quotation mark - buffer = buffer.Replace('\u201d', '\"'); // right double quotation mark - buffer = buffer.Replace('\u201e', '\"'); // double low-9 quotation mark - buffer = buffer.Replace("\u2026", "...", StringComparison.Ordinal); // horizontal ellipsis - buffer = buffer.Replace('\u2032', '\''); // prime - buffer = buffer.Replace('\u2033', '\"'); // double prime - buffer = buffer.Replace('\u0060', '\''); // grave accent - return buffer.Replace('\u00B4', '\''); // acute accent - } - - private void AddItem(List items, BaseItem newItem) - { - for (var i = 0; i < items.Count; i++) - { - var item = items[i]; - - foreach (var providerId in newItem.ProviderIds) - { - if (string.Equals(providerId.Key, nameof(MetadataProvider.TmdbCollection), StringComparison.Ordinal)) - { - continue; - } - - if (string.Equals(item.GetProviderId(providerId.Key), providerId.Value, StringComparison.Ordinal)) - { - if (newItem.SourceType == SourceType.Library) - { - items[i] = newItem; - } - - return; - } - } - } - - items.Add(newItem); - } - - /// - public QueryResult GetItems(InternalItemsQuery query) - { - ArgumentNullException.ThrowIfNull(query); - - CheckDisposed(); - - if (!query.EnableTotalRecordCount || (!query.Limit.HasValue && (query.StartIndex ?? 0) == 0)) - { - var returnList = GetItemList(query); - return new QueryResult( - query.StartIndex, - returnList.Count, - returnList); - } - - // Hack for right now since we currently don't support filtering out these duplicates within a query - if (query.Limit.HasValue && query.EnableGroupByMetadataKey) - { - query.Limit = query.Limit.Value + 4; - } - - var columns = _retrieveItemColumns.ToList(); - SetFinalColumnsToSelect(query, columns); - var commandTextBuilder = new StringBuilder("select ", 512) - .AppendJoin(',', columns) - .Append(FromText) - .Append(GetJoinUserDataText(query)); - - var whereClauses = GetWhereClauses(query, null); - - var whereText = whereClauses.Count == 0 ? - string.Empty : - string.Join(" AND ", whereClauses); - - if (!string.IsNullOrEmpty(whereText)) - { - commandTextBuilder.Append(" where ") - .Append(whereText); - } - - commandTextBuilder.Append(GetGroupBy(query)) - .Append(GetOrderByText(query)); - - if (query.Limit.HasValue || query.StartIndex.HasValue) - { - var offset = query.StartIndex ?? 0; - - if (query.Limit.HasValue || offset > 0) - { - commandTextBuilder.Append(" LIMIT ") - .Append(query.Limit ?? int.MaxValue); - } - - if (offset > 0) - { - commandTextBuilder.Append(" OFFSET ") - .Append(offset); - } - } - - var isReturningZeroItems = query.Limit.HasValue && query.Limit <= 0; - - var itemQuery = string.Empty; - var totalRecordCountQuery = string.Empty; - if (!isReturningZeroItems) - { - itemQuery = commandTextBuilder.ToString(); - } - - if (query.EnableTotalRecordCount) - { - commandTextBuilder.Clear(); - - commandTextBuilder.Append(" select "); - - List columnsToSelect; - if (EnableGroupByPresentationUniqueKey(query)) - { - columnsToSelect = new List { "count (distinct PresentationUniqueKey)" }; - } - else if (query.GroupBySeriesPresentationUniqueKey) - { - columnsToSelect = new List { "count (distinct SeriesPresentationUniqueKey)" }; - } - else - { - columnsToSelect = new List { "count (guid)" }; - } - - SetFinalColumnsToSelect(query, columnsToSelect); - - commandTextBuilder.AppendJoin(',', columnsToSelect) - .Append(FromText) - .Append(GetJoinUserDataText(query)); - if (!string.IsNullOrEmpty(whereText)) - { - commandTextBuilder.Append(" where ") - .Append(whereText); - } - - totalRecordCountQuery = commandTextBuilder.ToString(); - } - - var list = new List(); - var result = new QueryResult(); - using var connection = GetConnection(true); - using var transaction = connection.BeginTransaction(); - if (!isReturningZeroItems) - { - using (new QueryTimeLogger(Logger, itemQuery, "GetItems.ItemQuery")) - using (var statement = PrepareStatement(connection, itemQuery)) - { - if (EnableJoinUserData(query)) - { - statement.TryBind("@UserId", query.User.InternalId); - } - - BindSimilarParams(query, statement); - BindSearchParams(query, statement); - - // Running this again will bind the params - GetWhereClauses(query, statement); - - var hasEpisodeAttributes = HasEpisodeAttributes(query); - var hasServiceName = HasServiceName(query); - var hasProgramAttributes = HasProgramAttributes(query); - var hasStartDate = HasStartDate(query); - var hasTrailerTypes = HasTrailerTypes(query); - var hasArtistFields = HasArtistFields(query); - var hasSeriesFields = HasSeriesFields(query); - - foreach (var row in statement.ExecuteQuery()) - { - var item = GetItem(row, query, hasProgramAttributes, hasEpisodeAttributes, hasServiceName, hasStartDate, hasTrailerTypes, hasArtistFields, hasSeriesFields, false); - if (item is not null) - { - list.Add(item); - } - } - } - } - - if (query.EnableTotalRecordCount) - { - using (new QueryTimeLogger(Logger, totalRecordCountQuery, "GetItems.TotalRecordCount")) - using (var statement = PrepareStatement(connection, totalRecordCountQuery)) - { - if (EnableJoinUserData(query)) - { - statement.TryBind("@UserId", query.User.InternalId); - } - - BindSimilarParams(query, statement); - BindSearchParams(query, statement); - - // Running this again will bind the params - GetWhereClauses(query, statement); - - result.TotalRecordCount = statement.SelectScalarInt(); - } - } - - transaction.Commit(); - - result.StartIndex = query.StartIndex ?? 0; - result.Items = list; - return result; - } - - private string GetOrderByText(InternalItemsQuery query) - { - var orderBy = query.OrderBy; - bool hasSimilar = query.SimilarTo is not null; - bool hasSearch = !string.IsNullOrEmpty(query.SearchTerm); - - if (hasSimilar || hasSearch) - { - List<(ItemSortBy, SortOrder)> prepend = new List<(ItemSortBy, SortOrder)>(4); - if (hasSearch) - { - prepend.Add((ItemSortBy.SearchScore, SortOrder.Descending)); - prepend.Add((ItemSortBy.SortName, SortOrder.Ascending)); - } - - if (hasSimilar) - { - prepend.Add((ItemSortBy.SimilarityScore, SortOrder.Descending)); - prepend.Add((ItemSortBy.Random, SortOrder.Ascending)); - } - - orderBy = query.OrderBy = [.. prepend, .. orderBy]; - } - else if (orderBy.Count == 0) - { - return string.Empty; - } - - return " ORDER BY " + string.Join(',', orderBy.Select(i => - { - var sortBy = MapOrderByField(i.OrderBy, query); - var sortOrder = i.SortOrder == SortOrder.Ascending ? "ASC" : "DESC"; - return sortBy + " " + sortOrder; - })); - } - - private string MapOrderByField(ItemSortBy sortBy, InternalItemsQuery query) - { - return sortBy switch - { - ItemSortBy.AirTime => "SortName", // TODO - ItemSortBy.Runtime => "RuntimeTicks", - ItemSortBy.Random => "RANDOM()", - ItemSortBy.DatePlayed when query.GroupBySeriesPresentationUniqueKey => "MAX(LastPlayedDate)", - ItemSortBy.DatePlayed => "LastPlayedDate", - ItemSortBy.PlayCount => "PlayCount", - ItemSortBy.IsFavoriteOrLiked => "(Select Case When IsFavorite is null Then 0 Else IsFavorite End )", - ItemSortBy.IsFolder => "IsFolder", - ItemSortBy.IsPlayed => "played", - ItemSortBy.IsUnplayed => "played", - ItemSortBy.DateLastContentAdded => "DateLastMediaAdded", - ItemSortBy.Artist => "(select CleanValue from ItemValues where ItemId=Guid and Type=0 LIMIT 1)", - ItemSortBy.AlbumArtist => "(select CleanValue from ItemValues where ItemId=Guid and Type=1 LIMIT 1)", - ItemSortBy.OfficialRating => "InheritedParentalRatingValue", - ItemSortBy.Studio => "(select CleanValue from ItemValues where ItemId=Guid and Type=3 LIMIT 1)", - ItemSortBy.SeriesDatePlayed => "(Select MAX(LastPlayedDate) from TypedBaseItems B" + GetJoinUserDataText(query) + " where Played=1 and B.SeriesPresentationUniqueKey=A.PresentationUniqueKey)", - ItemSortBy.SeriesSortName => "SeriesName", - ItemSortBy.AiredEpisodeOrder => "AiredEpisodeOrder", - ItemSortBy.Album => "Album", - ItemSortBy.DateCreated => "DateCreated", - ItemSortBy.PremiereDate => "PremiereDate", - ItemSortBy.StartDate => "StartDate", - ItemSortBy.Name => "Name", - ItemSortBy.CommunityRating => "CommunityRating", - ItemSortBy.ProductionYear => "ProductionYear", - ItemSortBy.CriticRating => "CriticRating", - ItemSortBy.VideoBitRate => "VideoBitRate", - ItemSortBy.ParentIndexNumber => "ParentIndexNumber", - ItemSortBy.IndexNumber => "IndexNumber", - ItemSortBy.SimilarityScore => "SimilarityScore", - ItemSortBy.SearchScore => "SearchScore", - _ => "SortName" - }; - } - - /// - public List GetItemIdsList(InternalItemsQuery query) - { - ArgumentNullException.ThrowIfNull(query); - - CheckDisposed(); - - var columns = new List { "guid" }; - SetFinalColumnsToSelect(query, columns); - var commandTextBuilder = new StringBuilder("select ", 256) - .AppendJoin(',', columns) - .Append(FromText) - .Append(GetJoinUserDataText(query)); - - var whereClauses = GetWhereClauses(query, null); - if (whereClauses.Count != 0) - { - commandTextBuilder.Append(" where ") - .AppendJoin(" AND ", whereClauses); - } - - commandTextBuilder.Append(GetGroupBy(query)) - .Append(GetOrderByText(query)); - - if (query.Limit.HasValue || query.StartIndex.HasValue) - { - var offset = query.StartIndex ?? 0; - - if (query.Limit.HasValue || offset > 0) - { - commandTextBuilder.Append(" LIMIT ") - .Append(query.Limit ?? int.MaxValue); - } - - if (offset > 0) - { - commandTextBuilder.Append(" OFFSET ") - .Append(offset); - } - } - - var commandText = commandTextBuilder.ToString(); - var list = new List(); - using (new QueryTimeLogger(Logger, commandText)) - using (var connection = GetConnection(true)) - using (var statement = PrepareStatement(connection, commandText)) - { - if (EnableJoinUserData(query)) - { - statement.TryBind("@UserId", query.User.InternalId); - } - - BindSimilarParams(query, statement); - BindSearchParams(query, statement); - - // Running this again will bind the params - GetWhereClauses(query, statement); - - foreach (var row in statement.ExecuteQuery()) - { - list.Add(row.GetGuid(0)); - } - } - - return list; - } - - private bool IsAlphaNumeric(string str) - { - if (string.IsNullOrWhiteSpace(str)) - { - return false; - } - - for (int i = 0; i < str.Length; i++) - { - if (!char.IsLetter(str[i]) && !char.IsNumber(str[i])) - { - return false; - } - } - - return true; - } - - private bool IsValidPersonType(string value) - { - return IsAlphaNumeric(value); - } - -#nullable enable - private List GetWhereClauses(InternalItemsQuery query, SqliteCommand? statement) - { - if (query.IsResumable ?? false) - { - query.IsVirtualItem = false; - } - - var minWidth = query.MinWidth; - var maxWidth = query.MaxWidth; - - if (query.IsHD.HasValue) - { - const int Threshold = 1200; - if (query.IsHD.Value) - { - minWidth = Threshold; - } - else - { - maxWidth = Threshold - 1; - } - } - - if (query.Is4K.HasValue) - { - const int Threshold = 3800; - if (query.Is4K.Value) - { - minWidth = Threshold; - } - else - { - maxWidth = Threshold - 1; - } - } - - var whereClauses = new List(); - - if (minWidth.HasValue) - { - whereClauses.Add("Width>=@MinWidth"); - statement?.TryBind("@MinWidth", minWidth); - } - - if (query.MinHeight.HasValue) - { - whereClauses.Add("Height>=@MinHeight"); - statement?.TryBind("@MinHeight", query.MinHeight); - } - - if (maxWidth.HasValue) - { - whereClauses.Add("Width<=@MaxWidth"); - statement?.TryBind("@MaxWidth", maxWidth); - } - - if (query.MaxHeight.HasValue) - { - whereClauses.Add("Height<=@MaxHeight"); - statement?.TryBind("@MaxHeight", query.MaxHeight); - } - - if (query.IsLocked.HasValue) - { - whereClauses.Add("IsLocked=@IsLocked"); - statement?.TryBind("@IsLocked", query.IsLocked); - } - - var tags = query.Tags.ToList(); - var excludeTags = query.ExcludeTags.ToList(); - - if (query.IsMovie == true) - { - if (query.IncludeItemTypes.Length == 0 - || query.IncludeItemTypes.Contains(BaseItemKind.Movie) - || query.IncludeItemTypes.Contains(BaseItemKind.Trailer)) - { - whereClauses.Add("(IsMovie is null OR IsMovie=@IsMovie)"); - } - else - { - whereClauses.Add("IsMovie=@IsMovie"); - } - - statement?.TryBind("@IsMovie", true); - } - else if (query.IsMovie.HasValue) - { - whereClauses.Add("IsMovie=@IsMovie"); - statement?.TryBind("@IsMovie", query.IsMovie); - } - - if (query.IsSeries.HasValue) - { - whereClauses.Add("IsSeries=@IsSeries"); - statement?.TryBind("@IsSeries", query.IsSeries); - } - - if (query.IsSports.HasValue) - { - if (query.IsSports.Value) - { - tags.Add("Sports"); - } - else - { - excludeTags.Add("Sports"); - } - } - - if (query.IsNews.HasValue) - { - if (query.IsNews.Value) - { - tags.Add("News"); - } - else - { - excludeTags.Add("News"); - } - } - - if (query.IsKids.HasValue) - { - if (query.IsKids.Value) - { - tags.Add("Kids"); - } - else - { - excludeTags.Add("Kids"); - } - } - - if (query.SimilarTo is not null && query.MinSimilarityScore > 0) - { - whereClauses.Add("SimilarityScore > " + (query.MinSimilarityScore - 1).ToString(CultureInfo.InvariantCulture)); - } - - if (!string.IsNullOrEmpty(query.SearchTerm)) - { - whereClauses.Add("SearchScore > 0"); - } - - if (query.IsFolder.HasValue) - { - whereClauses.Add("IsFolder=@IsFolder"); - statement?.TryBind("@IsFolder", query.IsFolder); - } - - var includeTypes = query.IncludeItemTypes; - // Only specify excluded types if no included types are specified - if (query.IncludeItemTypes.Length == 0) - { - var excludeTypes = query.ExcludeItemTypes; - if (excludeTypes.Length == 1) - { - if (_baseItemKindNames.TryGetValue(excludeTypes[0], out var excludeTypeName)) - { - whereClauses.Add("type<>@type"); - statement?.TryBind("@type", excludeTypeName); - } - else - { - Logger.LogWarning("Undefined BaseItemKind to Type mapping: {BaseItemKind}", excludeTypes[0]); - } - } - else if (excludeTypes.Length > 1) - { - var whereBuilder = new StringBuilder("type not in ("); - foreach (var excludeType in excludeTypes) - { - if (_baseItemKindNames.TryGetValue(excludeType, out var baseItemKindName)) - { - whereBuilder - .Append('\'') - .Append(baseItemKindName) - .Append("',"); - } - else - { - Logger.LogWarning("Undefined BaseItemKind to Type mapping: {BaseItemKind}", excludeType); - } - } - - // Remove trailing comma. - whereBuilder.Length--; - whereBuilder.Append(')'); - whereClauses.Add(whereBuilder.ToString()); - } - } - else if (includeTypes.Length == 1) - { - if (_baseItemKindNames.TryGetValue(includeTypes[0], out var includeTypeName)) - { - whereClauses.Add("type=@type"); - statement?.TryBind("@type", includeTypeName); - } - else - { - Logger.LogWarning("Undefined BaseItemKind to Type mapping: {BaseItemKind}", includeTypes[0]); - } - } - else if (includeTypes.Length > 1) - { - var whereBuilder = new StringBuilder("type in ("); - foreach (var includeType in includeTypes) - { - if (_baseItemKindNames.TryGetValue(includeType, out var baseItemKindName)) - { - whereBuilder - .Append('\'') - .Append(baseItemKindName) - .Append("',"); - } - else - { - Logger.LogWarning("Undefined BaseItemKind to Type mapping: {BaseItemKind}", includeType); - } - } - - // Remove trailing comma. - whereBuilder.Length--; - whereBuilder.Append(')'); - whereClauses.Add(whereBuilder.ToString()); - } - - if (query.ChannelIds.Count == 1) - { - whereClauses.Add("ChannelId=@ChannelId"); - statement?.TryBind("@ChannelId", query.ChannelIds[0].ToString("N", CultureInfo.InvariantCulture)); - } - else if (query.ChannelIds.Count > 1) - { - var inClause = string.Join(',', query.ChannelIds.Select(i => "'" + i.ToString("N", CultureInfo.InvariantCulture) + "'")); - whereClauses.Add($"ChannelId in ({inClause})"); - } - - if (!query.ParentId.IsEmpty()) - { - whereClauses.Add("ParentId=@ParentId"); - statement?.TryBind("@ParentId", query.ParentId); - } - - if (!string.IsNullOrWhiteSpace(query.Path)) - { - whereClauses.Add("Path=@Path"); - statement?.TryBind("@Path", GetPathToSave(query.Path)); - } - - if (!string.IsNullOrWhiteSpace(query.PresentationUniqueKey)) - { - whereClauses.Add("PresentationUniqueKey=@PresentationUniqueKey"); - statement?.TryBind("@PresentationUniqueKey", query.PresentationUniqueKey); - } - - if (query.MinCommunityRating.HasValue) - { - whereClauses.Add("CommunityRating>=@MinCommunityRating"); - statement?.TryBind("@MinCommunityRating", query.MinCommunityRating.Value); - } - - if (query.MinIndexNumber.HasValue) - { - whereClauses.Add("IndexNumber>=@MinIndexNumber"); - statement?.TryBind("@MinIndexNumber", query.MinIndexNumber.Value); - } - - if (query.MinParentAndIndexNumber.HasValue) - { - whereClauses.Add("((ParentIndexNumber=@MinParentAndIndexNumberParent and IndexNumber>=@MinParentAndIndexNumberIndex) or ParentIndexNumber>@MinParentAndIndexNumberParent)"); - statement?.TryBind("@MinParentAndIndexNumberParent", query.MinParentAndIndexNumber.Value.ParentIndexNumber); - statement?.TryBind("@MinParentAndIndexNumberIndex", query.MinParentAndIndexNumber.Value.IndexNumber); - } - - if (query.MinDateCreated.HasValue) - { - whereClauses.Add("DateCreated>=@MinDateCreated"); - statement?.TryBind("@MinDateCreated", query.MinDateCreated.Value); - } - - if (query.MinDateLastSaved.HasValue) - { - whereClauses.Add("(DateLastSaved not null and DateLastSaved>=@MinDateLastSavedForUser)"); - statement?.TryBind("@MinDateLastSaved", query.MinDateLastSaved.Value); - } - - if (query.MinDateLastSavedForUser.HasValue) - { - whereClauses.Add("(DateLastSaved not null and DateLastSaved>=@MinDateLastSavedForUser)"); - statement?.TryBind("@MinDateLastSavedForUser", query.MinDateLastSavedForUser.Value); - } - - if (query.IndexNumber.HasValue) - { - whereClauses.Add("IndexNumber=@IndexNumber"); - statement?.TryBind("@IndexNumber", query.IndexNumber.Value); - } - - if (query.ParentIndexNumber.HasValue) - { - whereClauses.Add("ParentIndexNumber=@ParentIndexNumber"); - statement?.TryBind("@ParentIndexNumber", query.ParentIndexNumber.Value); - } - - if (query.ParentIndexNumberNotEquals.HasValue) - { - whereClauses.Add("(ParentIndexNumber<>@ParentIndexNumberNotEquals or ParentIndexNumber is null)"); - statement?.TryBind("@ParentIndexNumberNotEquals", query.ParentIndexNumberNotEquals.Value); - } - - var minEndDate = query.MinEndDate; - var maxEndDate = query.MaxEndDate; - - if (query.HasAired.HasValue) - { - if (query.HasAired.Value) - { - maxEndDate = DateTime.UtcNow; - } - else - { - minEndDate = DateTime.UtcNow; - } - } - - if (minEndDate.HasValue) - { - whereClauses.Add("EndDate>=@MinEndDate"); - statement?.TryBind("@MinEndDate", minEndDate.Value); - } - - if (maxEndDate.HasValue) - { - whereClauses.Add("EndDate<=@MaxEndDate"); - statement?.TryBind("@MaxEndDate", maxEndDate.Value); - } - - if (query.MinStartDate.HasValue) - { - whereClauses.Add("StartDate>=@MinStartDate"); - statement?.TryBind("@MinStartDate", query.MinStartDate.Value); - } - - if (query.MaxStartDate.HasValue) - { - whereClauses.Add("StartDate<=@MaxStartDate"); - statement?.TryBind("@MaxStartDate", query.MaxStartDate.Value); - } - - if (query.MinPremiereDate.HasValue) - { - whereClauses.Add("PremiereDate>=@MinPremiereDate"); - statement?.TryBind("@MinPremiereDate", query.MinPremiereDate.Value); - } - - if (query.MaxPremiereDate.HasValue) - { - whereClauses.Add("PremiereDate<=@MaxPremiereDate"); - statement?.TryBind("@MaxPremiereDate", query.MaxPremiereDate.Value); - } - - StringBuilder clauseBuilder = new StringBuilder(); - const string Or = " OR "; - - var trailerTypes = query.TrailerTypes; - int trailerTypesLen = trailerTypes.Length; - if (trailerTypesLen > 0) - { - clauseBuilder.Append('('); - - for (int i = 0; i < trailerTypesLen; i++) - { - var paramName = "@TrailerTypes" + i; - clauseBuilder.Append("TrailerTypes like ") - .Append(paramName) - .Append(Or); - statement?.TryBind(paramName, "%" + trailerTypes[i] + "%"); - } - - clauseBuilder.Length -= Or.Length; - clauseBuilder.Append(')'); - - whereClauses.Add(clauseBuilder.ToString()); - - clauseBuilder.Length = 0; - } - - if (query.IsAiring.HasValue) - { - if (query.IsAiring.Value) - { - whereClauses.Add("StartDate<=@MaxStartDate"); - statement?.TryBind("@MaxStartDate", DateTime.UtcNow); - - whereClauses.Add("EndDate>=@MinEndDate"); - statement?.TryBind("@MinEndDate", DateTime.UtcNow); - } - else - { - whereClauses.Add("(StartDate>@IsAiringDate OR EndDate < @IsAiringDate)"); - statement?.TryBind("@IsAiringDate", DateTime.UtcNow); - } - } - - int personIdsLen = query.PersonIds.Length; - if (personIdsLen > 0) - { - // TODO: Should this query with CleanName ? - - clauseBuilder.Append('('); - - Span idBytes = stackalloc byte[16]; - for (int i = 0; i < personIdsLen; i++) - { - string paramName = "@PersonId" + i; - clauseBuilder.Append("(guid in (select itemid from People where Name = (select Name from TypedBaseItems where guid=") - .Append(paramName) - .Append("))) OR "); - - statement?.TryBind(paramName, query.PersonIds[i]); - } - - clauseBuilder.Length -= Or.Length; - clauseBuilder.Append(')'); - - whereClauses.Add(clauseBuilder.ToString()); - - clauseBuilder.Length = 0; - } - - if (!string.IsNullOrWhiteSpace(query.Person)) - { - whereClauses.Add("Guid in (select ItemId from People where Name=@PersonName)"); - statement?.TryBind("@PersonName", query.Person); - } - - if (!string.IsNullOrWhiteSpace(query.MinSortName)) - { - whereClauses.Add("SortName>=@MinSortName"); - statement?.TryBind("@MinSortName", query.MinSortName); - } - - if (!string.IsNullOrWhiteSpace(query.ExternalSeriesId)) - { - whereClauses.Add("ExternalSeriesId=@ExternalSeriesId"); - statement?.TryBind("@ExternalSeriesId", query.ExternalSeriesId); - } - - if (!string.IsNullOrWhiteSpace(query.ExternalId)) - { - whereClauses.Add("ExternalId=@ExternalId"); - statement?.TryBind("@ExternalId", query.ExternalId); - } - - if (!string.IsNullOrWhiteSpace(query.Name)) - { - whereClauses.Add("CleanName=@Name"); - statement?.TryBind("@Name", GetCleanValue(query.Name)); - } - - // These are the same, for now - var nameContains = query.NameContains; - if (!string.IsNullOrWhiteSpace(nameContains)) - { - whereClauses.Add("(CleanName like @NameContains or OriginalTitle like @NameContains)"); - if (statement is not null) - { - nameContains = FixUnicodeChars(nameContains); - statement.TryBind("@NameContains", "%" + GetCleanValue(nameContains) + "%"); - } - } - - if (!string.IsNullOrWhiteSpace(query.NameStartsWith)) - { - whereClauses.Add("SortName like @NameStartsWith"); - statement?.TryBind("@NameStartsWith", query.NameStartsWith + "%"); - } - - if (!string.IsNullOrWhiteSpace(query.NameStartsWithOrGreater)) - { - whereClauses.Add("SortName >= @NameStartsWithOrGreater"); - // lowercase this because SortName is stored as lowercase - statement?.TryBind("@NameStartsWithOrGreater", query.NameStartsWithOrGreater.ToLowerInvariant()); - } - - if (!string.IsNullOrWhiteSpace(query.NameLessThan)) - { - whereClauses.Add("SortName < @NameLessThan"); - // lowercase this because SortName is stored as lowercase - statement?.TryBind("@NameLessThan", query.NameLessThan.ToLowerInvariant()); - } - - if (query.ImageTypes.Length > 0) - { - foreach (var requiredImage in query.ImageTypes) - { - whereClauses.Add("Images like '%" + requiredImage + "%'"); - } - } - - if (query.IsLiked.HasValue) - { - if (query.IsLiked.Value) - { - whereClauses.Add("rating>=@UserRating"); - statement?.TryBind("@UserRating", UserItemData.MinLikeValue); - } - else - { - whereClauses.Add("(rating is null or rating<@UserRating)"); - statement?.TryBind("@UserRating", UserItemData.MinLikeValue); - } - } - - if (query.IsFavoriteOrLiked.HasValue) - { - if (query.IsFavoriteOrLiked.Value) - { - whereClauses.Add("IsFavorite=@IsFavoriteOrLiked"); - } - else - { - whereClauses.Add("(IsFavorite is null or IsFavorite=@IsFavoriteOrLiked)"); - } - - statement?.TryBind("@IsFavoriteOrLiked", query.IsFavoriteOrLiked.Value); - } - - if (query.IsFavorite.HasValue) - { - if (query.IsFavorite.Value) - { - whereClauses.Add("IsFavorite=@IsFavorite"); - } - else - { - whereClauses.Add("(IsFavorite is null or IsFavorite=@IsFavorite)"); - } - - statement?.TryBind("@IsFavorite", query.IsFavorite.Value); - } - - if (EnableJoinUserData(query)) - { - if (query.IsPlayed.HasValue) - { - // We should probably figure this out for all folders, but for right now, this is the only place where we need it - if (query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes[0] == BaseItemKind.Series) - { - if (query.IsPlayed.Value) - { - whereClauses.Add("PresentationUniqueKey not in (select S.SeriesPresentationUniqueKey from TypedBaseitems S left join UserDatas UD on S.UserDataKey=UD.Key And UD.UserId=@UserId where Coalesce(UD.Played, 0)=0 and S.IsFolder=0 and S.IsVirtualItem=0 and S.SeriesPresentationUniqueKey not null)"); - } - else - { - whereClauses.Add("PresentationUniqueKey in (select S.SeriesPresentationUniqueKey from TypedBaseitems S left join UserDatas UD on S.UserDataKey=UD.Key And UD.UserId=@UserId where Coalesce(UD.Played, 0)=0 and S.IsFolder=0 and S.IsVirtualItem=0 and S.SeriesPresentationUniqueKey not null)"); - } - } - else - { - if (query.IsPlayed.Value) - { - whereClauses.Add("(played=@IsPlayed)"); - } - else - { - whereClauses.Add("(played is null or played=@IsPlayed)"); - } - - statement?.TryBind("@IsPlayed", query.IsPlayed.Value); - } - } - } - - if (query.IsResumable.HasValue) - { - if (query.IsResumable.Value) - { - whereClauses.Add("playbackPositionTicks > 0"); - } - else - { - whereClauses.Add("(playbackPositionTicks is null or playbackPositionTicks = 0)"); - } - } - - if (query.ArtistIds.Length > 0) - { - clauseBuilder.Append('('); - for (var i = 0; i < query.ArtistIds.Length; i++) - { - clauseBuilder.Append("(guid in (select itemid from ItemValues where CleanValue = (select CleanName from TypedBaseItems where guid=@ArtistIds") - .Append(i) - .Append(") and Type<=1)) OR "); - statement?.TryBind("@ArtistIds" + i, query.ArtistIds[i]); - } - - clauseBuilder.Length -= Or.Length; - whereClauses.Add(clauseBuilder.Append(')').ToString()); - clauseBuilder.Length = 0; - } - - if (query.AlbumArtistIds.Length > 0) - { - clauseBuilder.Append('('); - for (var i = 0; i < query.AlbumArtistIds.Length; i++) - { - clauseBuilder.Append("(guid in (select itemid from ItemValues where CleanValue = (select CleanName from TypedBaseItems where guid=@ArtistIds") - .Append(i) - .Append(") and Type=1)) OR "); - statement?.TryBind("@ArtistIds" + i, query.AlbumArtistIds[i]); - } - - clauseBuilder.Length -= Or.Length; - whereClauses.Add(clauseBuilder.Append(')').ToString()); - clauseBuilder.Length = 0; - } - - if (query.ContributingArtistIds.Length > 0) - { - clauseBuilder.Append('('); - for (var i = 0; i < query.ContributingArtistIds.Length; i++) - { - clauseBuilder.Append("((select CleanName from TypedBaseItems where guid=@ArtistIds") - .Append(i) - .Append(") in (select CleanValue from ItemValues where ItemId=Guid and Type=0) AND (select CleanName from TypedBaseItems where guid=@ArtistIds") - .Append(i) - .Append(") not in (select CleanValue from ItemValues where ItemId=Guid and Type=1)) OR "); - statement?.TryBind("@ArtistIds" + i, query.ContributingArtistIds[i]); - } - - clauseBuilder.Length -= Or.Length; - whereClauses.Add(clauseBuilder.Append(')').ToString()); - clauseBuilder.Length = 0; - } - - if (query.AlbumIds.Length > 0) - { - clauseBuilder.Append('('); - for (var i = 0; i < query.AlbumIds.Length; i++) - { - clauseBuilder.Append("Album in (select Name from typedbaseitems where guid=@AlbumIds") - .Append(i) - .Append(") OR "); - statement?.TryBind("@AlbumIds" + i, query.AlbumIds[i]); - } - - clauseBuilder.Length -= Or.Length; - whereClauses.Add(clauseBuilder.Append(')').ToString()); - clauseBuilder.Length = 0; - } - - if (query.ExcludeArtistIds.Length > 0) - { - clauseBuilder.Append('('); - for (var i = 0; i < query.ExcludeArtistIds.Length; i++) - { - clauseBuilder.Append("(guid not in (select itemid from ItemValues where CleanValue = (select CleanName from TypedBaseItems where guid=@ExcludeArtistId") - .Append(i) - .Append(") and Type<=1)) OR "); - statement?.TryBind("@ExcludeArtistId" + i, query.ExcludeArtistIds[i]); - } - - clauseBuilder.Length -= Or.Length; - whereClauses.Add(clauseBuilder.Append(')').ToString()); - clauseBuilder.Length = 0; - } - - if (query.GenreIds.Count > 0) - { - clauseBuilder.Append('('); - for (var i = 0; i < query.GenreIds.Count; i++) - { - clauseBuilder.Append("(guid in (select itemid from ItemValues where CleanValue = (select CleanName from TypedBaseItems where guid=@GenreId") - .Append(i) - .Append(") and Type=2)) OR "); - statement?.TryBind("@GenreId" + i, query.GenreIds[i]); - } - - clauseBuilder.Length -= Or.Length; - whereClauses.Add(clauseBuilder.Append(')').ToString()); - clauseBuilder.Length = 0; - } - - if (query.Genres.Count > 0) - { - clauseBuilder.Append('('); - for (var i = 0; i < query.Genres.Count; i++) - { - clauseBuilder.Append("@Genre") - .Append(i) - .Append(" in (select CleanValue from ItemValues where ItemId=Guid and Type=2) OR "); - statement?.TryBind("@Genre" + i, GetCleanValue(query.Genres[i])); - } - - clauseBuilder.Length -= Or.Length; - whereClauses.Add(clauseBuilder.Append(')').ToString()); - clauseBuilder.Length = 0; - } - - if (tags.Count > 0) - { - clauseBuilder.Append('('); - for (var i = 0; i < tags.Count; i++) - { - clauseBuilder.Append("@Tag") - .Append(i) - .Append(" in (select CleanValue from ItemValues where ItemId=Guid and Type=4) OR "); - statement?.TryBind("@Tag" + i, GetCleanValue(tags[i])); - } - - clauseBuilder.Length -= Or.Length; - whereClauses.Add(clauseBuilder.Append(')').ToString()); - clauseBuilder.Length = 0; - } - - if (excludeTags.Count > 0) - { - clauseBuilder.Append('('); - for (var i = 0; i < excludeTags.Count; i++) - { - clauseBuilder.Append("@ExcludeTag") - .Append(i) - .Append(" not in (select CleanValue from ItemValues where ItemId=Guid and Type=4) OR "); - statement?.TryBind("@ExcludeTag" + i, GetCleanValue(excludeTags[i])); - } - - clauseBuilder.Length -= Or.Length; - whereClauses.Add(clauseBuilder.Append(')').ToString()); - clauseBuilder.Length = 0; - } - - if (query.StudioIds.Length > 0) - { - clauseBuilder.Append('('); - for (var i = 0; i < query.StudioIds.Length; i++) - { - clauseBuilder.Append("(guid in (select itemid from ItemValues where CleanValue = (select CleanName from TypedBaseItems where guid=@StudioId") - .Append(i) - .Append(") and Type=3)) OR "); - statement?.TryBind("@StudioId" + i, query.StudioIds[i]); - } - - clauseBuilder.Length -= Or.Length; - whereClauses.Add(clauseBuilder.Append(')').ToString()); - clauseBuilder.Length = 0; - } - - if (query.OfficialRatings.Length > 0) - { - clauseBuilder.Append('('); - for (var i = 0; i < query.OfficialRatings.Length; i++) - { - clauseBuilder.Append("OfficialRating=@OfficialRating").Append(i).Append(Or); - statement?.TryBind("@OfficialRating" + i, query.OfficialRatings[i]); - } - - clauseBuilder.Length -= Or.Length; - whereClauses.Add(clauseBuilder.Append(')').ToString()); - clauseBuilder.Length = 0; - } - - clauseBuilder.Append('('); - if (query.HasParentalRating ?? false) - { - clauseBuilder.Append("InheritedParentalRatingValue not null"); - if (query.MinParentalRating.HasValue) - { - clauseBuilder.Append(" AND InheritedParentalRatingValue >= @MinParentalRating"); - statement?.TryBind("@MinParentalRating", query.MinParentalRating.Value); - } - - if (query.MaxParentalRating.HasValue) - { - clauseBuilder.Append(" AND InheritedParentalRatingValue <= @MaxParentalRating"); - statement?.TryBind("@MaxParentalRating", query.MaxParentalRating.Value); - } - } - else if (query.BlockUnratedItems.Length > 0) - { - const string ParamName = "@UnratedType"; - clauseBuilder.Append("(InheritedParentalRatingValue is null AND UnratedType not in ("); - - for (int i = 0; i < query.BlockUnratedItems.Length; i++) - { - clauseBuilder.Append(ParamName).Append(i).Append(','); - statement?.TryBind(ParamName + i, query.BlockUnratedItems[i].ToString()); - } - - // Remove trailing comma - clauseBuilder.Length--; - clauseBuilder.Append("))"); - - if (query.MinParentalRating.HasValue || query.MaxParentalRating.HasValue) - { - clauseBuilder.Append(" OR ("); - } - - if (query.MinParentalRating.HasValue) - { - clauseBuilder.Append("InheritedParentalRatingValue >= @MinParentalRating"); - statement?.TryBind("@MinParentalRating", query.MinParentalRating.Value); - } - - if (query.MaxParentalRating.HasValue) - { - if (query.MinParentalRating.HasValue) - { - clauseBuilder.Append(" AND "); - } - - clauseBuilder.Append("InheritedParentalRatingValue <= @MaxParentalRating"); - statement?.TryBind("@MaxParentalRating", query.MaxParentalRating.Value); - } - - if (query.MinParentalRating.HasValue || query.MaxParentalRating.HasValue) - { - clauseBuilder.Append(')'); - } - - if (!(query.MinParentalRating.HasValue || query.MaxParentalRating.HasValue)) - { - clauseBuilder.Append(" OR InheritedParentalRatingValue not null"); - } - } - else if (query.MinParentalRating.HasValue) - { - clauseBuilder.Append("InheritedParentalRatingValue is null OR (InheritedParentalRatingValue >= @MinParentalRating"); - statement?.TryBind("@MinParentalRating", query.MinParentalRating.Value); - - if (query.MaxParentalRating.HasValue) - { - clauseBuilder.Append(" AND InheritedParentalRatingValue <= @MaxParentalRating"); - statement?.TryBind("@MaxParentalRating", query.MaxParentalRating.Value); - } - - clauseBuilder.Append(')'); - } - else if (query.MaxParentalRating.HasValue) - { - clauseBuilder.Append("InheritedParentalRatingValue is null OR InheritedParentalRatingValue <= @MaxParentalRating"); - statement?.TryBind("@MaxParentalRating", query.MaxParentalRating.Value); - } - else if (!query.HasParentalRating ?? false) - { - clauseBuilder.Append("InheritedParentalRatingValue is null"); - } - - if (clauseBuilder.Length > 1) - { - whereClauses.Add(clauseBuilder.Append(')').ToString()); - clauseBuilder.Length = 0; - } - - if (query.HasOfficialRating.HasValue) - { - if (query.HasOfficialRating.Value) - { - whereClauses.Add("(OfficialRating not null AND OfficialRating<>'')"); - } - else - { - whereClauses.Add("(OfficialRating is null OR OfficialRating='')"); - } - } - - if (query.HasOverview.HasValue) - { - if (query.HasOverview.Value) - { - whereClauses.Add("(Overview not null AND Overview<>'')"); - } - else - { - whereClauses.Add("(Overview is null OR Overview='')"); - } - } - - if (query.HasOwnerId.HasValue) - { - if (query.HasOwnerId.Value) - { - whereClauses.Add("OwnerId not null"); - } - else - { - whereClauses.Add("OwnerId is null"); - } - } - - if (!string.IsNullOrWhiteSpace(query.HasNoAudioTrackWithLanguage)) - { - whereClauses.Add("((select language from MediaStreams where MediaStreams.ItemId=A.Guid and MediaStreams.StreamType='Audio' and MediaStreams.Language=@HasNoAudioTrackWithLanguage limit 1) is null)"); - statement?.TryBind("@HasNoAudioTrackWithLanguage", query.HasNoAudioTrackWithLanguage); - } - - if (!string.IsNullOrWhiteSpace(query.HasNoInternalSubtitleTrackWithLanguage)) - { - whereClauses.Add("((select language from MediaStreams where MediaStreams.ItemId=A.Guid and MediaStreams.StreamType='Subtitle' and MediaStreams.IsExternal=0 and MediaStreams.Language=@HasNoInternalSubtitleTrackWithLanguage limit 1) is null)"); - statement?.TryBind("@HasNoInternalSubtitleTrackWithLanguage", query.HasNoInternalSubtitleTrackWithLanguage); - } - - if (!string.IsNullOrWhiteSpace(query.HasNoExternalSubtitleTrackWithLanguage)) - { - whereClauses.Add("((select language from MediaStreams where MediaStreams.ItemId=A.Guid and MediaStreams.StreamType='Subtitle' and MediaStreams.IsExternal=1 and MediaStreams.Language=@HasNoExternalSubtitleTrackWithLanguage limit 1) is null)"); - statement?.TryBind("@HasNoExternalSubtitleTrackWithLanguage", query.HasNoExternalSubtitleTrackWithLanguage); - } - - if (!string.IsNullOrWhiteSpace(query.HasNoSubtitleTrackWithLanguage)) - { - whereClauses.Add("((select language from MediaStreams where MediaStreams.ItemId=A.Guid and MediaStreams.StreamType='Subtitle' and MediaStreams.Language=@HasNoSubtitleTrackWithLanguage limit 1) is null)"); - statement?.TryBind("@HasNoSubtitleTrackWithLanguage", query.HasNoSubtitleTrackWithLanguage); - } - - if (query.HasSubtitles.HasValue) - { - if (query.HasSubtitles.Value) - { - whereClauses.Add("((select type from MediaStreams where MediaStreams.ItemId=A.Guid and MediaStreams.StreamType='Subtitle' limit 1) not null)"); - } - else - { - whereClauses.Add("((select type from MediaStreams where MediaStreams.ItemId=A.Guid and MediaStreams.StreamType='Subtitle' limit 1) is null)"); - } - } - - if (query.HasChapterImages.HasValue) - { - if (query.HasChapterImages.Value) - { - whereClauses.Add("((select imagepath from Chapters2 where Chapters2.ItemId=A.Guid and imagepath not null limit 1) not null)"); - } - else - { - whereClauses.Add("((select imagepath from Chapters2 where Chapters2.ItemId=A.Guid and imagepath not null limit 1) is null)"); - } - } - - if (query.HasDeadParentId.HasValue && query.HasDeadParentId.Value) - { - whereClauses.Add("ParentId NOT NULL AND ParentId NOT IN (select guid from TypedBaseItems)"); - } - - if (query.IsDeadArtist.HasValue && query.IsDeadArtist.Value) - { - whereClauses.Add("CleanName not in (Select CleanValue From ItemValues where Type in (0,1))"); - } - - if (query.IsDeadStudio.HasValue && query.IsDeadStudio.Value) - { - whereClauses.Add("CleanName not in (Select CleanValue From ItemValues where Type = 3)"); - } - - if (query.IsDeadPerson.HasValue && query.IsDeadPerson.Value) - { - whereClauses.Add("Name not in (Select Name From People)"); - } - - if (query.Years.Length == 1) - { - whereClauses.Add("ProductionYear=@Years"); - statement?.TryBind("@Years", query.Years[0].ToString(CultureInfo.InvariantCulture)); - } - else if (query.Years.Length > 1) - { - var val = string.Join(',', query.Years); - whereClauses.Add("ProductionYear in (" + val + ")"); - } - - var isVirtualItem = query.IsVirtualItem ?? query.IsMissing; - if (isVirtualItem.HasValue) - { - whereClauses.Add("IsVirtualItem=@IsVirtualItem"); - statement?.TryBind("@IsVirtualItem", isVirtualItem.Value); - } - - if (query.IsSpecialSeason.HasValue) - { - if (query.IsSpecialSeason.Value) - { - whereClauses.Add("IndexNumber = 0"); - } - else - { - whereClauses.Add("IndexNumber <> 0"); - } - } - - if (query.IsUnaired.HasValue) - { - if (query.IsUnaired.Value) - { - whereClauses.Add("PremiereDate >= DATETIME('now')"); - } - else - { - whereClauses.Add("PremiereDate < DATETIME('now')"); - } - } - - if (query.MediaTypes.Length == 1) - { - whereClauses.Add("MediaType=@MediaTypes"); - statement?.TryBind("@MediaTypes", query.MediaTypes[0].ToString()); - } - else if (query.MediaTypes.Length > 1) - { - var val = string.Join(',', query.MediaTypes.Select(i => $"'{i}'")); - whereClauses.Add("MediaType in (" + val + ")"); - } - - if (query.ItemIds.Length > 0) - { - var includeIds = new List(); - var index = 0; - foreach (var id in query.ItemIds) - { - includeIds.Add("Guid = @IncludeId" + index); - statement?.TryBind("@IncludeId" + index, id); - index++; - } - - whereClauses.Add("(" + string.Join(" OR ", includeIds) + ")"); - } - - if (query.ExcludeItemIds.Length > 0) - { - var excludeIds = new List(); - var index = 0; - foreach (var id in query.ExcludeItemIds) - { - excludeIds.Add("Guid <> @ExcludeId" + index); - statement?.TryBind("@ExcludeId" + index, id); - index++; - } - - whereClauses.Add(string.Join(" AND ", excludeIds)); - } - - if (query.ExcludeProviderIds is not null && query.ExcludeProviderIds.Count > 0) - { - var excludeIds = new List(); - - var index = 0; - foreach (var pair in query.ExcludeProviderIds) - { - if (string.Equals(pair.Key, nameof(MetadataProvider.TmdbCollection), StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - var paramName = "@ExcludeProviderId" + index; - excludeIds.Add("(ProviderIds is null or ProviderIds not like " + paramName + ")"); - statement?.TryBind(paramName, "%" + pair.Key + "=" + pair.Value + "%"); - index++; - - break; - } - - if (excludeIds.Count > 0) - { - whereClauses.Add(string.Join(" AND ", excludeIds)); - } - } - - if (query.HasAnyProviderId is not null && query.HasAnyProviderId.Count > 0) - { - var hasProviderIds = new List(); - - var index = 0; - foreach (var pair in query.HasAnyProviderId) - { - if (string.Equals(pair.Key, nameof(MetadataProvider.TmdbCollection), StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - // TODO this seems to be an idea for a better schema where ProviderIds are their own table - // but this is not implemented - // hasProviderIds.Add("(COALESCE((select value from ProviderIds where ItemId=Guid and Name = '" + pair.Key + "'), '') <> " + paramName + ")"); - - // TODO this is a really BAD way to do it since the pair: - // Tmdb, 1234 matches Tmdb=1234 but also Tmdb=1234567 - // and maybe even NotTmdb=1234. - - // this is a placeholder for this specific pair to correlate it in the bigger query - var paramName = "@HasAnyProviderId" + index; - - // this is a search for the placeholder - hasProviderIds.Add("ProviderIds like " + paramName); - - // this replaces the placeholder with a value, here: %key=val% - statement?.TryBind(paramName, "%" + pair.Key + "=" + pair.Value + "%"); - index++; - - break; - } - - if (hasProviderIds.Count > 0) - { - whereClauses.Add("(" + string.Join(" OR ", hasProviderIds) + ")"); - } - } - - if (query.HasImdbId.HasValue) - { - whereClauses.Add(GetProviderIdClause(query.HasImdbId.Value, "imdb")); - } - - if (query.HasTmdbId.HasValue) - { - whereClauses.Add(GetProviderIdClause(query.HasTmdbId.Value, "tmdb")); - } - - if (query.HasTvdbId.HasValue) - { - whereClauses.Add(GetProviderIdClause(query.HasTvdbId.Value, "tvdb")); - } - - var queryTopParentIds = query.TopParentIds; - - if (queryTopParentIds.Length > 0) - { - var includedItemByNameTypes = GetItemByNameTypesInQuery(query); - var enableItemsByName = (query.IncludeItemsByName ?? false) && includedItemByNameTypes.Count > 0; - - if (queryTopParentIds.Length == 1) - { - if (enableItemsByName && includedItemByNameTypes.Count == 1) - { - whereClauses.Add("(TopParentId=@TopParentId or Type=@IncludedItemByNameType)"); - statement?.TryBind("@IncludedItemByNameType", includedItemByNameTypes[0]); - } - else if (enableItemsByName && includedItemByNameTypes.Count > 1) - { - var itemByNameTypeVal = string.Join(',', includedItemByNameTypes.Select(i => "'" + i + "'")); - whereClauses.Add("(TopParentId=@TopParentId or Type in (" + itemByNameTypeVal + "))"); - } - else - { - whereClauses.Add("(TopParentId=@TopParentId)"); - } - - statement?.TryBind("@TopParentId", queryTopParentIds[0].ToString("N", CultureInfo.InvariantCulture)); - } - else if (queryTopParentIds.Length > 1) - { - var val = string.Join(',', queryTopParentIds.Select(i => "'" + i.ToString("N", CultureInfo.InvariantCulture) + "'")); - - if (enableItemsByName && includedItemByNameTypes.Count == 1) - { - whereClauses.Add("(Type=@IncludedItemByNameType or TopParentId in (" + val + "))"); - statement?.TryBind("@IncludedItemByNameType", includedItemByNameTypes[0]); - } - else if (enableItemsByName && includedItemByNameTypes.Count > 1) - { - var itemByNameTypeVal = string.Join(',', includedItemByNameTypes.Select(i => "'" + i + "'")); - whereClauses.Add("(Type in (" + itemByNameTypeVal + ") or TopParentId in (" + val + "))"); - } - else - { - whereClauses.Add("TopParentId in (" + val + ")"); - } - } - } - - if (query.AncestorIds.Length == 1) - { - whereClauses.Add("Guid in (select itemId from AncestorIds where AncestorId=@AncestorId)"); - statement?.TryBind("@AncestorId", query.AncestorIds[0]); - } - - if (query.AncestorIds.Length > 1) - { - var inClause = string.Join(',', query.AncestorIds.Select(i => "'" + i.ToString("N", CultureInfo.InvariantCulture) + "'")); - whereClauses.Add(string.Format(CultureInfo.InvariantCulture, "Guid in (select itemId from AncestorIds where AncestorIdText in ({0}))", inClause)); - } - - if (!string.IsNullOrWhiteSpace(query.AncestorWithPresentationUniqueKey)) - { - var inClause = "select guid from TypedBaseItems where PresentationUniqueKey=@AncestorWithPresentationUniqueKey"; - whereClauses.Add(string.Format(CultureInfo.InvariantCulture, "Guid in (select itemId from AncestorIds where AncestorId in ({0}))", inClause)); - statement?.TryBind("@AncestorWithPresentationUniqueKey", query.AncestorWithPresentationUniqueKey); - } - - if (!string.IsNullOrWhiteSpace(query.SeriesPresentationUniqueKey)) - { - whereClauses.Add("SeriesPresentationUniqueKey=@SeriesPresentationUniqueKey"); - statement?.TryBind("@SeriesPresentationUniqueKey", query.SeriesPresentationUniqueKey); - } - - if (query.ExcludeInheritedTags.Length > 0) - { - var paramName = "@ExcludeInheritedTags"; - if (statement is null) - { - int index = 0; - string excludedTags = string.Join(',', query.ExcludeInheritedTags.Select(_ => paramName + index++)); - whereClauses.Add("((select CleanValue from ItemValues where ItemId=Guid and Type=6 and cleanvalue in (" + excludedTags + ")) is null)"); - } - else - { - for (int index = 0; index < query.ExcludeInheritedTags.Length; index++) - { - statement.TryBind(paramName + index, GetCleanValue(query.ExcludeInheritedTags[index])); - } - } - } - - if (query.IncludeInheritedTags.Length > 0) - { - var paramName = "@IncludeInheritedTags"; - if (statement is null) - { - int index = 0; - string includedTags = string.Join(',', query.IncludeInheritedTags.Select(_ => paramName + index++)); - // Episodes do not store inherit tags from their parents in the database, and the tag may be still required by the client. - // In addtion to the tags for the episodes themselves, we need to manually query its parent (the season)'s tags as well. - if (includeTypes.Length == 1 && includeTypes.FirstOrDefault() is BaseItemKind.Episode) - { - whereClauses.Add($""" - ((select CleanValue from ItemValues where ItemId=Guid and Type=6 and CleanValue in ({includedTags})) is not null - OR (select CleanValue from ItemValues where ItemId=ParentId and Type=6 and CleanValue in ({includedTags})) is not null) - """); - } - - // A playlist should be accessible to its owner regardless of allowed tags. - else if (includeTypes.Length == 1 && includeTypes.FirstOrDefault() is BaseItemKind.Playlist) - { - whereClauses.Add($""" - ((select CleanValue from ItemValues where ItemId=Guid and Type=6 and CleanValue in ({includedTags})) is not null - OR data like @PlaylistOwnerUserId) - """); - } - else - { - whereClauses.Add("((select CleanValue from ItemValues where ItemId=Guid and Type=6 and cleanvalue in (" + includedTags + ")) is not null)"); - } - } - else - { - for (int index = 0; index < query.IncludeInheritedTags.Length; index++) - { - statement.TryBind(paramName + index, GetCleanValue(query.IncludeInheritedTags[index])); - } - - if (query.User is not null) - { - statement.TryBind("@PlaylistOwnerUserId", $"""%"OwnerUserId":"{query.User.Id.ToString("N")}"%"""); - } - } - } - - if (query.SeriesStatuses.Length > 0) - { - var statuses = new List(); - - foreach (var seriesStatus in query.SeriesStatuses) - { - statuses.Add("data like '%" + seriesStatus + "%'"); - } - - whereClauses.Add("(" + string.Join(" OR ", statuses) + ")"); - } - - if (query.BoxSetLibraryFolders.Length > 0) - { - var folderIdQueries = new List(); - - foreach (var folderId in query.BoxSetLibraryFolders) - { - folderIdQueries.Add("data like '%" + folderId.ToString("N", CultureInfo.InvariantCulture) + "%'"); - } - - whereClauses.Add("(" + string.Join(" OR ", folderIdQueries) + ")"); - } - - if (query.VideoTypes.Length > 0) - { - var videoTypes = new List(); - - foreach (var videoType in query.VideoTypes) - { - videoTypes.Add("data like '%\"VideoType\":\"" + videoType + "\"%'"); - } - - whereClauses.Add("(" + string.Join(" OR ", videoTypes) + ")"); - } - - if (query.Is3D.HasValue) - { - if (query.Is3D.Value) - { - whereClauses.Add("data like '%Video3DFormat%'"); - } - else - { - whereClauses.Add("data not like '%Video3DFormat%'"); - } - } - - if (query.IsPlaceHolder.HasValue) - { - if (query.IsPlaceHolder.Value) - { - whereClauses.Add("data like '%\"IsPlaceHolder\":true%'"); - } - else - { - whereClauses.Add("(data is null or data not like '%\"IsPlaceHolder\":true%')"); - } - } - - if (query.HasSpecialFeature.HasValue) - { - if (query.HasSpecialFeature.Value) - { - whereClauses.Add("ExtraIds not null"); - } - else - { - whereClauses.Add("ExtraIds is null"); - } - } - - if (query.HasTrailer.HasValue) - { - if (query.HasTrailer.Value) - { - whereClauses.Add("ExtraIds not null"); - } - else - { - whereClauses.Add("ExtraIds is null"); - } - } - - if (query.HasThemeSong.HasValue) - { - if (query.HasThemeSong.Value) - { - whereClauses.Add("ExtraIds not null"); - } - else - { - whereClauses.Add("ExtraIds is null"); - } - } - - if (query.HasThemeVideo.HasValue) - { - if (query.HasThemeVideo.Value) - { - whereClauses.Add("ExtraIds not null"); - } - else - { - whereClauses.Add("ExtraIds is null"); - } - } - - return whereClauses; - } - - /// - /// Formats a where clause for the specified provider. - /// - /// Whether or not to include items with this provider's ids. - /// Provider name. - /// Formatted SQL clause. - private string GetProviderIdClause(bool includeResults, string provider) - { - return string.Format( - CultureInfo.InvariantCulture, - "ProviderIds {0} like '%{1}=%'", - includeResults ? string.Empty : "not", - provider); - } - -#nullable disable - private List GetItemByNameTypesInQuery(InternalItemsQuery query) - { - var list = new List(); - - if (IsTypeInQuery(BaseItemKind.Person, query)) - { - list.Add(typeof(Person).FullName); - } - - if (IsTypeInQuery(BaseItemKind.Genre, query)) - { - list.Add(typeof(Genre).FullName); - } - - if (IsTypeInQuery(BaseItemKind.MusicGenre, query)) - { - list.Add(typeof(MusicGenre).FullName); - } - - if (IsTypeInQuery(BaseItemKind.MusicArtist, query)) - { - list.Add(typeof(MusicArtist).FullName); - } - - if (IsTypeInQuery(BaseItemKind.Studio, query)) - { - list.Add(typeof(Studio).FullName); - } - - return list; - } - - private bool IsTypeInQuery(BaseItemKind type, InternalItemsQuery query) - { - if (query.ExcludeItemTypes.Contains(type)) - { - return false; - } - - return query.IncludeItemTypes.Length == 0 || query.IncludeItemTypes.Contains(type); - } - - private string GetCleanValue(string value) - { - if (string.IsNullOrWhiteSpace(value)) - { - return value; - } - - return value.RemoveDiacritics().ToLowerInvariant(); - } - - private bool EnableGroupByPresentationUniqueKey(InternalItemsQuery query) - { - if (!query.GroupByPresentationUniqueKey) - { - return false; - } - - if (query.GroupBySeriesPresentationUniqueKey) - { - return false; - } - - if (!string.IsNullOrWhiteSpace(query.PresentationUniqueKey)) - { - return false; - } - - if (query.User is null) - { - return false; - } - - if (query.IncludeItemTypes.Length == 0) - { - return true; - } - - return query.IncludeItemTypes.Contains(BaseItemKind.Episode) - || query.IncludeItemTypes.Contains(BaseItemKind.Video) - || query.IncludeItemTypes.Contains(BaseItemKind.Movie) - || query.IncludeItemTypes.Contains(BaseItemKind.MusicVideo) - || query.IncludeItemTypes.Contains(BaseItemKind.Series) - || query.IncludeItemTypes.Contains(BaseItemKind.Season); - } - - /// - public void UpdateInheritedValues() - { - const string Statements = """ -delete from ItemValues where type = 6; -insert into ItemValues (ItemId, Type, Value, CleanValue) select ItemId, 6, Value, CleanValue from ItemValues where Type=4; -insert into ItemValues (ItemId, Type, Value, CleanValue) select AncestorIds.itemid, 6, ItemValues.Value, ItemValues.CleanValue -FROM AncestorIds -LEFT JOIN ItemValues ON (AncestorIds.AncestorId = ItemValues.ItemId) -where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type = 4; -"""; - using var connection = GetConnection(); - using var transaction = connection.BeginTransaction(); - connection.Execute(Statements); - transaction.Commit(); - } - - /// - public void DeleteItem(Guid id) - { - if (id.IsEmpty()) - { - throw new ArgumentNullException(nameof(id)); - } - - CheckDisposed(); - - using var connection = GetConnection(); - using var transaction = connection.BeginTransaction(); - // Delete people - ExecuteWithSingleParam(connection, "delete from People where ItemId=@Id", id); - - // Delete chapters - ExecuteWithSingleParam(connection, "delete from " + ChaptersTableName + " where ItemId=@Id", id); - - // Delete media streams - ExecuteWithSingleParam(connection, "delete from mediastreams where ItemId=@Id", id); - - // Delete ancestors - ExecuteWithSingleParam(connection, "delete from AncestorIds where ItemId=@Id", id); - - // Delete item values - ExecuteWithSingleParam(connection, "delete from ItemValues where ItemId=@Id", id); - - // Delete the item - ExecuteWithSingleParam(connection, "delete from TypedBaseItems where guid=@Id", id); - - transaction.Commit(); - } - - private void ExecuteWithSingleParam(ManagedConnection db, string query, Guid value) - { - using (var statement = PrepareStatement(db, query)) - { - statement.TryBind("@Id", value); - - statement.ExecuteNonQuery(); - } - } - - /// - public List GetPeopleNames(InternalPeopleQuery query) - { - ArgumentNullException.ThrowIfNull(query); - - CheckDisposed(); - - var commandText = new StringBuilder("select Distinct p.Name from People p"); - - var whereClauses = GetPeopleWhereClauses(query, null); - - if (whereClauses.Count != 0) - { - commandText.Append(" where ").AppendJoin(" AND ", whereClauses); - } - - commandText.Append(" order by ListOrder"); - - if (query.Limit > 0) - { - commandText.Append(" LIMIT ").Append(query.Limit); - } - - var list = new List(); - using (var connection = GetConnection(true)) - using (var statement = PrepareStatement(connection, commandText.ToString())) - { - // Run this again to bind the params - GetPeopleWhereClauses(query, statement); - - foreach (var row in statement.ExecuteQuery()) - { - list.Add(row.GetString(0)); - } - } - - return list; - } - - /// - public List GetPeople(InternalPeopleQuery query) - { - ArgumentNullException.ThrowIfNull(query); - - CheckDisposed(); - - StringBuilder commandText = new StringBuilder("select ItemId, Name, Role, PersonType, SortOrder from People p"); - - var whereClauses = GetPeopleWhereClauses(query, null); - - if (whereClauses.Count != 0) - { - commandText.Append(" where ").AppendJoin(" AND ", whereClauses); - } - - commandText.Append(" order by ListOrder"); - - if (query.Limit > 0) - { - commandText.Append(" LIMIT ").Append(query.Limit); - } - - var list = new List(); - using (var connection = GetConnection(true)) - using (var statement = PrepareStatement(connection, commandText.ToString())) - { - // Run this again to bind the params - GetPeopleWhereClauses(query, statement); - - foreach (var row in statement.ExecuteQuery()) - { - list.Add(GetPerson(row)); - } - } - - return list; - } - - private List GetPeopleWhereClauses(InternalPeopleQuery query, SqliteCommand statement) - { - var whereClauses = new List(); - - if (query.User is not null && query.IsFavorite.HasValue) - { - whereClauses.Add(@"p.Name IN ( -SELECT Name FROM TypedBaseItems WHERE UserDataKey IN ( -SELECT key FROM UserDatas WHERE isFavorite=@IsFavorite AND userId=@UserId) -AND Type = @InternalPersonType)"); - statement?.TryBind("@IsFavorite", query.IsFavorite.Value); - statement?.TryBind("@InternalPersonType", typeof(Person).FullName); - statement?.TryBind("@UserId", query.User.InternalId); - } - - if (!query.ItemId.IsEmpty()) - { - whereClauses.Add("ItemId=@ItemId"); - statement?.TryBind("@ItemId", query.ItemId); - } - - if (!query.AppearsInItemId.IsEmpty()) - { - whereClauses.Add("p.Name in (Select Name from People where ItemId=@AppearsInItemId)"); - statement?.TryBind("@AppearsInItemId", query.AppearsInItemId); - } - - var queryPersonTypes = query.PersonTypes.Where(IsValidPersonType).ToList(); - - if (queryPersonTypes.Count == 1) - { - whereClauses.Add("PersonType=@PersonType"); - statement?.TryBind("@PersonType", queryPersonTypes[0]); - } - else if (queryPersonTypes.Count > 1) - { - var val = string.Join(',', queryPersonTypes.Select(i => "'" + i + "'")); - - whereClauses.Add("PersonType in (" + val + ")"); - } - - var queryExcludePersonTypes = query.ExcludePersonTypes.Where(IsValidPersonType).ToList(); - - if (queryExcludePersonTypes.Count == 1) - { - whereClauses.Add("PersonType<>@PersonType"); - statement?.TryBind("@PersonType", queryExcludePersonTypes[0]); - } - else if (queryExcludePersonTypes.Count > 1) - { - var val = string.Join(',', queryExcludePersonTypes.Select(i => "'" + i + "'")); - - whereClauses.Add("PersonType not in (" + val + ")"); - } - - if (query.MaxListOrder.HasValue) - { - whereClauses.Add("ListOrder<=@MaxListOrder"); - statement?.TryBind("@MaxListOrder", query.MaxListOrder.Value); - } - - if (!string.IsNullOrWhiteSpace(query.NameContains)) - { - whereClauses.Add("p.Name like @NameContains"); - statement?.TryBind("@NameContains", "%" + query.NameContains + "%"); - } - - return whereClauses; - } - - private void UpdateAncestors(Guid itemId, List ancestorIds, ManagedConnection db, SqliteCommand deleteAncestorsStatement) - { - if (itemId.IsEmpty()) - { - throw new ArgumentNullException(nameof(itemId)); - } - - ArgumentNullException.ThrowIfNull(ancestorIds); - - CheckDisposed(); - - // First delete - deleteAncestorsStatement.TryBind("@ItemId", itemId); - deleteAncestorsStatement.ExecuteNonQuery(); - - if (ancestorIds.Count == 0) - { - return; - } - - var insertText = new StringBuilder("insert into AncestorIds (ItemId, AncestorId, AncestorIdText) values "); - - for (var i = 0; i < ancestorIds.Count; i++) - { - insertText.AppendFormat( - CultureInfo.InvariantCulture, - "(@ItemId, @AncestorId{0}, @AncestorIdText{0}),", - i.ToString(CultureInfo.InvariantCulture)); - } - - // Remove trailing comma - insertText.Length--; - - using (var statement = PrepareStatement(db, insertText.ToString())) - { - statement.TryBind("@ItemId", itemId); - - for (var i = 0; i < ancestorIds.Count; i++) - { - var index = i.ToString(CultureInfo.InvariantCulture); - - var ancestorId = ancestorIds[i]; - - statement.TryBind("@AncestorId" + index, ancestorId); - statement.TryBind("@AncestorIdText" + index, ancestorId.ToString("N", CultureInfo.InvariantCulture)); - } - - statement.ExecuteNonQuery(); - } - } - - /// - public QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetAllArtists(InternalItemsQuery query) - { - return GetItemValues(query, new[] { 0, 1 }, typeof(MusicArtist).FullName); - } - - /// - public QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetArtists(InternalItemsQuery query) - { - return GetItemValues(query, new[] { 0 }, typeof(MusicArtist).FullName); - } - - /// - public QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetAlbumArtists(InternalItemsQuery query) - { - return GetItemValues(query, new[] { 1 }, typeof(MusicArtist).FullName); - } - - /// - public QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetStudios(InternalItemsQuery query) - { - return GetItemValues(query, new[] { 3 }, typeof(Studio).FullName); - } - - /// - public QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetGenres(InternalItemsQuery query) - { - return GetItemValues(query, new[] { 2 }, typeof(Genre).FullName); - } - - /// - public QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetMusicGenres(InternalItemsQuery query) - { - return GetItemValues(query, new[] { 2 }, typeof(MusicGenre).FullName); - } - - /// - public List GetStudioNames() - { - return GetItemValueNames(new[] { 3 }, Array.Empty(), Array.Empty()); - } - - /// - public List GetAllArtistNames() - { - return GetItemValueNames(new[] { 0, 1 }, Array.Empty(), Array.Empty()); - } - - /// - public List GetMusicGenreNames() - { - return GetItemValueNames( - new[] { 2 }, - new string[] - { - typeof(Audio).FullName, - typeof(MusicVideo).FullName, - typeof(MusicAlbum).FullName, - typeof(MusicArtist).FullName - }, - Array.Empty()); - } - - /// - public List GetGenreNames() - { - return GetItemValueNames( - new[] { 2 }, - Array.Empty(), - new string[] - { - typeof(Audio).FullName, - typeof(MusicVideo).FullName, - typeof(MusicAlbum).FullName, - typeof(MusicArtist).FullName - }); - } - - private List GetItemValueNames(int[] itemValueTypes, IReadOnlyList withItemTypes, IReadOnlyList excludeItemTypes) - { - CheckDisposed(); - - var stringBuilder = new StringBuilder("Select Value From ItemValues where Type", 128); - if (itemValueTypes.Length == 1) - { - stringBuilder.Append('=') - .Append(itemValueTypes[0]); - } - else - { - stringBuilder.Append(" in (") - .AppendJoin(',', itemValueTypes) - .Append(')'); - } - - if (withItemTypes.Count > 0) - { - stringBuilder.Append(" AND ItemId In (select guid from typedbaseitems where type in (") - .AppendJoinInSingleQuotes(',', withItemTypes) - .Append("))"); - } - - if (excludeItemTypes.Count > 0) - { - stringBuilder.Append(" AND ItemId not In (select guid from typedbaseitems where type in (") - .AppendJoinInSingleQuotes(',', excludeItemTypes) - .Append("))"); - } - - stringBuilder.Append(" Group By CleanValue"); - var commandText = stringBuilder.ToString(); - - var list = new List(); - using (new QueryTimeLogger(Logger, commandText)) - using (var connection = GetConnection(true)) - using (var statement = PrepareStatement(connection, commandText)) - { - foreach (var row in statement.ExecuteQuery()) - { - if (row.TryGetString(0, out var result)) - { - list.Add(result); - } - } - } - - return list; - } - - private QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetItemValues(InternalItemsQuery query, int[] itemValueTypes, string returnType) - { - ArgumentNullException.ThrowIfNull(query); - - if (!query.Limit.HasValue) - { - query.EnableTotalRecordCount = false; - } - - CheckDisposed(); - - var typeClause = itemValueTypes.Length == 1 ? - ("Type=" + itemValueTypes[0]) : - ("Type in (" + string.Join(',', itemValueTypes) + ")"); - - InternalItemsQuery typeSubQuery = null; - - string itemCountColumns = null; - - var stringBuilder = new StringBuilder(1024); - var typesToCount = query.IncludeItemTypes; - - if (typesToCount.Length > 0) - { - stringBuilder.Append("(select group_concat(type, '|') from TypedBaseItems B"); - - typeSubQuery = new InternalItemsQuery(query.User) - { - ExcludeItemTypes = query.ExcludeItemTypes, - IncludeItemTypes = query.IncludeItemTypes, - MediaTypes = query.MediaTypes, - AncestorIds = query.AncestorIds, - ExcludeItemIds = query.ExcludeItemIds, - ItemIds = query.ItemIds, - TopParentIds = query.TopParentIds, - ParentId = query.ParentId, - IsPlayed = query.IsPlayed - }; - var whereClauses = GetWhereClauses(typeSubQuery, null); - - stringBuilder.Append(" where ") - .AppendJoin(" AND ", whereClauses) - .Append(" AND ") - .Append("guid in (select ItemId from ItemValues where ItemValues.CleanValue=A.CleanName AND ") - .Append(typeClause) - .Append(")) as itemTypes"); - - itemCountColumns = stringBuilder.ToString(); - stringBuilder.Clear(); - } - - List columns = _retrieveItemColumns.ToList(); - // Unfortunately we need to add it to columns to ensure the order of the columns in the select - if (!string.IsNullOrEmpty(itemCountColumns)) - { - columns.Add(itemCountColumns); - } - - // do this first before calling GetFinalColumnsToSelect, otherwise ExcludeItemIds will be set by SimilarTo - var innerQuery = new InternalItemsQuery(query.User) - { - ExcludeItemTypes = query.ExcludeItemTypes, - IncludeItemTypes = query.IncludeItemTypes, - MediaTypes = query.MediaTypes, - AncestorIds = query.AncestorIds, - ItemIds = query.ItemIds, - TopParentIds = query.TopParentIds, - ParentId = query.ParentId, - IsAiring = query.IsAiring, - IsMovie = query.IsMovie, - IsSports = query.IsSports, - IsKids = query.IsKids, - IsNews = query.IsNews, - IsSeries = query.IsSeries - }; - - SetFinalColumnsToSelect(query, columns); - - var innerWhereClauses = GetWhereClauses(innerQuery, null); - - stringBuilder.Append(" where Type=@SelectType And CleanName In (Select CleanValue from ItemValues where ") - .Append(typeClause) - .Append(" AND ItemId in (select guid from TypedBaseItems"); - if (innerWhereClauses.Count > 0) - { - stringBuilder.Append(" where ") - .AppendJoin(" AND ", innerWhereClauses); - } - - stringBuilder.Append("))"); - - var outerQuery = new InternalItemsQuery(query.User) - { - IsPlayed = query.IsPlayed, - IsFavorite = query.IsFavorite, - IsFavoriteOrLiked = query.IsFavoriteOrLiked, - IsLiked = query.IsLiked, - IsLocked = query.IsLocked, - NameLessThan = query.NameLessThan, - NameStartsWith = query.NameStartsWith, - NameStartsWithOrGreater = query.NameStartsWithOrGreater, - Tags = query.Tags, - OfficialRatings = query.OfficialRatings, - StudioIds = query.StudioIds, - GenreIds = query.GenreIds, - Genres = query.Genres, - Years = query.Years, - NameContains = query.NameContains, - SearchTerm = query.SearchTerm, - SimilarTo = query.SimilarTo, - ExcludeItemIds = query.ExcludeItemIds - }; - - var outerWhereClauses = GetWhereClauses(outerQuery, null); - if (outerWhereClauses.Count != 0) - { - stringBuilder.Append(" AND ") - .AppendJoin(" AND ", outerWhereClauses); - } - - var whereText = stringBuilder.ToString(); - stringBuilder.Clear(); - - stringBuilder.Append("select ") - .AppendJoin(',', columns) - .Append(FromText) - .Append(GetJoinUserDataText(query)) - .Append(whereText) - .Append(" group by PresentationUniqueKey"); - - if (query.OrderBy.Count != 0 - || query.SimilarTo is not null - || !string.IsNullOrEmpty(query.SearchTerm)) - { - stringBuilder.Append(GetOrderByText(query)); - } - else - { - stringBuilder.Append(" order by SortName"); - } - - if (query.Limit.HasValue || query.StartIndex.HasValue) - { - var offset = query.StartIndex ?? 0; - - if (query.Limit.HasValue || offset > 0) - { - stringBuilder.Append(" LIMIT ") - .Append(query.Limit ?? int.MaxValue); - } - - if (offset > 0) - { - stringBuilder.Append(" OFFSET ") - .Append(offset); - } - } - - var isReturningZeroItems = query.Limit.HasValue && query.Limit <= 0; - - string commandText = string.Empty; - - if (!isReturningZeroItems) - { - commandText = stringBuilder.ToString(); - } - - string countText = string.Empty; - if (query.EnableTotalRecordCount) - { - stringBuilder.Clear(); - var columnsToSelect = new List { "count (distinct PresentationUniqueKey)" }; - SetFinalColumnsToSelect(query, columnsToSelect); - stringBuilder.Append("select ") - .AppendJoin(',', columnsToSelect) - .Append(FromText) - .Append(GetJoinUserDataText(query)) - .Append(whereText); - - countText = stringBuilder.ToString(); - } - - var list = new List<(BaseItem, ItemCounts)>(); - var result = new QueryResult<(BaseItem, ItemCounts)>(); - using (new QueryTimeLogger(Logger, commandText)) - using (var connection = GetConnection(true)) - using (var transaction = connection.BeginTransaction()) - { - if (!isReturningZeroItems) - { - using (var statement = PrepareStatement(connection, commandText)) - { - statement.TryBind("@SelectType", returnType); - if (EnableJoinUserData(query)) - { - statement.TryBind("@UserId", query.User.InternalId); - } - - if (typeSubQuery is not null) - { - GetWhereClauses(typeSubQuery, null); - } - - BindSimilarParams(query, statement); - BindSearchParams(query, statement); - GetWhereClauses(innerQuery, statement); - GetWhereClauses(outerQuery, statement); - - var hasEpisodeAttributes = HasEpisodeAttributes(query); - var hasProgramAttributes = HasProgramAttributes(query); - var hasServiceName = HasServiceName(query); - var hasStartDate = HasStartDate(query); - var hasTrailerTypes = HasTrailerTypes(query); - var hasArtistFields = HasArtistFields(query); - var hasSeriesFields = HasSeriesFields(query); - - foreach (var row in statement.ExecuteQuery()) - { - var item = GetItem(row, query, hasProgramAttributes, hasEpisodeAttributes, hasServiceName, hasStartDate, hasTrailerTypes, hasArtistFields, hasSeriesFields, false); - if (item is not null) - { - var countStartColumn = columns.Count - 1; - - list.Add((item, GetItemCounts(row, countStartColumn, typesToCount))); - } - } - } - } - - if (query.EnableTotalRecordCount) - { - using (var statement = PrepareStatement(connection, countText)) - { - statement.TryBind("@SelectType", returnType); - if (EnableJoinUserData(query)) - { - statement.TryBind("@UserId", query.User.InternalId); - } - - if (typeSubQuery is not null) - { - GetWhereClauses(typeSubQuery, null); - } - - BindSimilarParams(query, statement); - BindSearchParams(query, statement); - GetWhereClauses(innerQuery, statement); - GetWhereClauses(outerQuery, statement); - - result.TotalRecordCount = statement.SelectScalarInt(); - } - } - - transaction.Commit(); - } - - if (result.TotalRecordCount == 0) - { - result.TotalRecordCount = list.Count; - } - - result.StartIndex = query.StartIndex ?? 0; - result.Items = list; - - return result; - } - - private static ItemCounts GetItemCounts(SqliteDataReader reader, int countStartColumn, BaseItemKind[] typesToCount) - { - var counts = new ItemCounts(); - - if (typesToCount.Length == 0) - { - return counts; - } - - if (!reader.TryGetString(countStartColumn, out var typeString)) - { - return counts; - } - - foreach (var typeName in typeString.AsSpan().Split('|')) - { - if (typeName.Equals(typeof(Series).FullName, StringComparison.OrdinalIgnoreCase)) - { - counts.SeriesCount++; - } - else if (typeName.Equals(typeof(Episode).FullName, StringComparison.OrdinalIgnoreCase)) - { - counts.EpisodeCount++; - } - else if (typeName.Equals(typeof(Movie).FullName, StringComparison.OrdinalIgnoreCase)) - { - counts.MovieCount++; - } - else if (typeName.Equals(typeof(MusicAlbum).FullName, StringComparison.OrdinalIgnoreCase)) - { - counts.AlbumCount++; - } - else if (typeName.Equals(typeof(MusicArtist).FullName, StringComparison.OrdinalIgnoreCase)) - { - counts.ArtistCount++; - } - else if (typeName.Equals(typeof(Audio).FullName, StringComparison.OrdinalIgnoreCase)) - { - counts.SongCount++; - } - else if (typeName.Equals(typeof(Trailer).FullName, StringComparison.OrdinalIgnoreCase)) - { - counts.TrailerCount++; - } - - counts.ItemCount++; - } - - return counts; - } - - private List<(int MagicNumber, string Value)> GetItemValuesToSave(BaseItem item, List inheritedTags) - { - var list = new List<(int, string)>(); - - if (item is IHasArtist hasArtist) - { - list.AddRange(hasArtist.Artists.Select(i => (0, i))); - } - - if (item is IHasAlbumArtist hasAlbumArtist) - { - list.AddRange(hasAlbumArtist.AlbumArtists.Select(i => (1, i))); - } - - list.AddRange(item.Genres.Select(i => (2, i))); - list.AddRange(item.Studios.Select(i => (3, i))); - list.AddRange(item.Tags.Select(i => (4, i))); - - // keywords was 5 - - list.AddRange(inheritedTags.Select(i => (6, i))); - - // Remove all invalid values. - list.RemoveAll(i => string.IsNullOrWhiteSpace(i.Item2)); - - return list; - } - - private void UpdateItemValues(Guid itemId, List<(int MagicNumber, string Value)> values, ManagedConnection db) - { - if (itemId.IsEmpty()) - { - throw new ArgumentNullException(nameof(itemId)); - } - - ArgumentNullException.ThrowIfNull(values); - - CheckDisposed(); - - // First delete - using var command = db.PrepareStatement("delete from ItemValues where ItemId=@Id"); - command.TryBind("@Id", itemId); - command.ExecuteNonQuery(); - - InsertItemValues(itemId, values, db); - } - - private void InsertItemValues(Guid id, List<(int MagicNumber, string Value)> values, ManagedConnection db) - { - const int Limit = 100; - var startIndex = 0; - - const string StartInsertText = "insert into ItemValues (ItemId, Type, Value, CleanValue) values "; - var insertText = new StringBuilder(StartInsertText); - while (startIndex < values.Count) - { - var endIndex = Math.Min(values.Count, startIndex + Limit); - - for (var i = startIndex; i < endIndex; i++) - { - insertText.AppendFormat( - CultureInfo.InvariantCulture, - "(@ItemId, @Type{0}, @Value{0}, @CleanValue{0}),", - i); - } - - // Remove trailing comma - insertText.Length--; - - using (var statement = PrepareStatement(db, insertText.ToString())) - { - statement.TryBind("@ItemId", id); - - for (var i = startIndex; i < endIndex; i++) - { - var index = i.ToString(CultureInfo.InvariantCulture); - - var currentValueInfo = values[i]; - - var itemValue = currentValueInfo.Value; - - statement.TryBind("@Type" + index, currentValueInfo.MagicNumber); - statement.TryBind("@Value" + index, itemValue); - statement.TryBind("@CleanValue" + index, GetCleanValue(itemValue)); - } - - statement.ExecuteNonQuery(); - } - - startIndex += Limit; - insertText.Length = StartInsertText.Length; - } - } - - /// - public void UpdatePeople(Guid itemId, List people) - { - if (itemId.IsEmpty()) - { - throw new ArgumentNullException(nameof(itemId)); - } - - CheckDisposed(); - - using var connection = GetConnection(); - using var transaction = connection.BeginTransaction(); - // Delete all existing people first - using var command = connection.CreateCommand(); - command.CommandText = "delete from People where ItemId=@ItemId"; - command.TryBind("@ItemId", itemId); - command.ExecuteNonQuery(); - - if (people is not null) - { - InsertPeople(itemId, people, connection); - } - - transaction.Commit(); - } - - private void InsertPeople(Guid id, List people, ManagedConnection db) - { - const int Limit = 100; - var startIndex = 0; - var listIndex = 0; - - const string StartInsertText = "insert into People (ItemId, Name, Role, PersonType, SortOrder, ListOrder) values "; - var insertText = new StringBuilder(StartInsertText); - while (startIndex < people.Count) - { - var endIndex = Math.Min(people.Count, startIndex + Limit); - for (var i = startIndex; i < endIndex; i++) - { - insertText.AppendFormat( - CultureInfo.InvariantCulture, - "(@ItemId, @Name{0}, @Role{0}, @PersonType{0}, @SortOrder{0}, @ListOrder{0}),", - i.ToString(CultureInfo.InvariantCulture)); - } - - // Remove trailing comma - insertText.Length--; - - using (var statement = PrepareStatement(db, insertText.ToString())) - { - statement.TryBind("@ItemId", id); - - for (var i = startIndex; i < endIndex; i++) - { - var index = i.ToString(CultureInfo.InvariantCulture); - - var person = people[i]; - - statement.TryBind("@Name" + index, person.Name); - statement.TryBind("@Role" + index, person.Role); - statement.TryBind("@PersonType" + index, person.Type.ToString()); - statement.TryBind("@SortOrder" + index, person.SortOrder); - statement.TryBind("@ListOrder" + index, listIndex); - - listIndex++; - } - - statement.ExecuteNonQuery(); - } - - startIndex += Limit; - insertText.Length = StartInsertText.Length; - } - } - - private PersonInfo GetPerson(SqliteDataReader reader) - { - var item = new PersonInfo - { - ItemId = reader.GetGuid(0), - Name = reader.GetString(1) - }; - - if (reader.TryGetString(2, out var role)) - { - item.Role = role; - } - - if (reader.TryGetString(3, out var type) - && Enum.TryParse(type, true, out PersonKind personKind)) - { - item.Type = personKind; - } - - if (reader.TryGetInt32(4, out var sortOrder)) - { - item.SortOrder = sortOrder; - } - - return item; - } - - /// - public List GetMediaStreams(MediaStreamQuery query) - { - CheckDisposed(); - - ArgumentNullException.ThrowIfNull(query); - - var cmdText = _mediaStreamSaveColumnsSelectQuery; - - if (query.Type.HasValue) - { - cmdText += " AND StreamType=@StreamType"; - } - - if (query.Index.HasValue) - { - cmdText += " AND StreamIndex=@StreamIndex"; - } - - cmdText += " order by StreamIndex ASC"; - - using (var connection = GetConnection(true)) - { - var list = new List(); - - using (var statement = PrepareStatement(connection, cmdText)) - { - statement.TryBind("@ItemId", query.ItemId); - - if (query.Type.HasValue) - { - statement.TryBind("@StreamType", query.Type.Value.ToString()); - } - - if (query.Index.HasValue) - { - statement.TryBind("@StreamIndex", query.Index.Value); - } - - foreach (var row in statement.ExecuteQuery()) - { - list.Add(GetMediaStream(row)); - } - } - - return list; - } - } - - /// - public void SaveMediaStreams(Guid id, IReadOnlyList streams, CancellationToken cancellationToken) - { - CheckDisposed(); - - if (id.IsEmpty()) - { - throw new ArgumentNullException(nameof(id)); - } - - ArgumentNullException.ThrowIfNull(streams); - - cancellationToken.ThrowIfCancellationRequested(); - - using var connection = GetConnection(); - using var transaction = connection.BeginTransaction(); - // Delete existing mediastreams - using var command = connection.PrepareStatement("delete from mediastreams where ItemId=@ItemId"); - command.TryBind("@ItemId", id); - command.ExecuteNonQuery(); - - InsertMediaStreams(id, streams, connection); - - transaction.Commit(); - } - - private void InsertMediaStreams(Guid id, IReadOnlyList streams, ManagedConnection db) - { - const int Limit = 10; - var startIndex = 0; - - var insertText = new StringBuilder(_mediaStreamSaveColumnsInsertQuery); - while (startIndex < streams.Count) - { - var endIndex = Math.Min(streams.Count, startIndex + Limit); - - for (var i = startIndex; i < endIndex; i++) - { - if (i != startIndex) - { - insertText.Append(','); - } - - var index = i.ToString(CultureInfo.InvariantCulture); - insertText.Append("(@ItemId, "); - - foreach (var column in _mediaStreamSaveColumns.Skip(1)) - { - insertText.Append('@').Append(column).Append(index).Append(','); - } - - insertText.Length -= 1; // Remove the last comma - - insertText.Append(')'); - } - - using (var statement = PrepareStatement(db, insertText.ToString())) - { - statement.TryBind("@ItemId", id); - - for (var i = startIndex; i < endIndex; i++) - { - var index = i.ToString(CultureInfo.InvariantCulture); - - var stream = streams[i]; - - statement.TryBind("@StreamIndex" + index, stream.Index); - statement.TryBind("@StreamType" + index, stream.Type.ToString()); - statement.TryBind("@Codec" + index, stream.Codec); - statement.TryBind("@Language" + index, stream.Language); - statement.TryBind("@ChannelLayout" + index, stream.ChannelLayout); - statement.TryBind("@Profile" + index, stream.Profile); - statement.TryBind("@AspectRatio" + index, stream.AspectRatio); - statement.TryBind("@Path" + index, GetPathToSave(stream.Path)); - - statement.TryBind("@IsInterlaced" + index, stream.IsInterlaced); - statement.TryBind("@BitRate" + index, stream.BitRate); - statement.TryBind("@Channels" + index, stream.Channels); - statement.TryBind("@SampleRate" + index, stream.SampleRate); - - statement.TryBind("@IsDefault" + index, stream.IsDefault); - statement.TryBind("@IsForced" + index, stream.IsForced); - statement.TryBind("@IsExternal" + index, stream.IsExternal); - - // Yes these are backwards due to a mistake - statement.TryBind("@Width" + index, stream.Height); - statement.TryBind("@Height" + index, stream.Width); - - statement.TryBind("@AverageFrameRate" + index, stream.AverageFrameRate); - statement.TryBind("@RealFrameRate" + index, stream.RealFrameRate); - statement.TryBind("@Level" + index, stream.Level); - - statement.TryBind("@PixelFormat" + index, stream.PixelFormat); - statement.TryBind("@BitDepth" + index, stream.BitDepth); - statement.TryBind("@IsAnamorphic" + index, stream.IsAnamorphic); - statement.TryBind("@IsExternal" + index, stream.IsExternal); - statement.TryBind("@RefFrames" + index, stream.RefFrames); - - statement.TryBind("@CodecTag" + index, stream.CodecTag); - statement.TryBind("@Comment" + index, stream.Comment); - statement.TryBind("@NalLengthSize" + index, stream.NalLengthSize); - statement.TryBind("@IsAvc" + index, stream.IsAVC); - statement.TryBind("@Title" + index, stream.Title); - - statement.TryBind("@TimeBase" + index, stream.TimeBase); - statement.TryBind("@CodecTimeBase" + index, stream.CodecTimeBase); - - statement.TryBind("@ColorPrimaries" + index, stream.ColorPrimaries); - statement.TryBind("@ColorSpace" + index, stream.ColorSpace); - statement.TryBind("@ColorTransfer" + index, stream.ColorTransfer); - - statement.TryBind("@DvVersionMajor" + index, stream.DvVersionMajor); - statement.TryBind("@DvVersionMinor" + index, stream.DvVersionMinor); - statement.TryBind("@DvProfile" + index, stream.DvProfile); - statement.TryBind("@DvLevel" + index, stream.DvLevel); - statement.TryBind("@RpuPresentFlag" + index, stream.RpuPresentFlag); - statement.TryBind("@ElPresentFlag" + index, stream.ElPresentFlag); - statement.TryBind("@BlPresentFlag" + index, stream.BlPresentFlag); - statement.TryBind("@DvBlSignalCompatibilityId" + index, stream.DvBlSignalCompatibilityId); - - statement.TryBind("@IsHearingImpaired" + index, stream.IsHearingImpaired); - - statement.TryBind("@Rotation" + index, stream.Rotation); - } - - statement.ExecuteNonQuery(); - } - - startIndex += Limit; - insertText.Length = _mediaStreamSaveColumnsInsertQuery.Length; - } - } - - /// - /// Gets the media stream. - /// - /// The reader. - /// MediaStream. - private MediaStream GetMediaStream(SqliteDataReader reader) - { - var item = new MediaStream - { - Index = reader.GetInt32(1), - Type = Enum.Parse(reader.GetString(2), true) - }; - - if (reader.TryGetString(3, out var codec)) - { - item.Codec = codec; - } - - if (reader.TryGetString(4, out var language)) - { - item.Language = language; - } - - if (reader.TryGetString(5, out var channelLayout)) - { - item.ChannelLayout = channelLayout; - } - - if (reader.TryGetString(6, out var profile)) - { - item.Profile = profile; - } - - if (reader.TryGetString(7, out var aspectRatio)) - { - item.AspectRatio = aspectRatio; - } - - if (reader.TryGetString(8, out var path)) - { - item.Path = RestorePath(path); - } - - item.IsInterlaced = reader.GetBoolean(9); - - if (reader.TryGetInt32(10, out var bitrate)) - { - item.BitRate = bitrate; - } - - if (reader.TryGetInt32(11, out var channels)) - { - item.Channels = channels; - } - - if (reader.TryGetInt32(12, out var sampleRate)) - { - item.SampleRate = sampleRate; - } - - item.IsDefault = reader.GetBoolean(13); - item.IsForced = reader.GetBoolean(14); - item.IsExternal = reader.GetBoolean(15); - - if (reader.TryGetInt32(16, out var width)) - { - item.Width = width; - } - - if (reader.TryGetInt32(17, out var height)) - { - item.Height = height; - } - - if (reader.TryGetSingle(18, out var averageFrameRate)) - { - item.AverageFrameRate = averageFrameRate; - } - - if (reader.TryGetSingle(19, out var realFrameRate)) - { - item.RealFrameRate = realFrameRate; - } - - if (reader.TryGetSingle(20, out var level)) - { - item.Level = level; - } - - if (reader.TryGetString(21, out var pixelFormat)) - { - item.PixelFormat = pixelFormat; - } - - if (reader.TryGetInt32(22, out var bitDepth)) - { - item.BitDepth = bitDepth; - } - - if (reader.TryGetBoolean(23, out var isAnamorphic)) - { - item.IsAnamorphic = isAnamorphic; - } - - if (reader.TryGetInt32(24, out var refFrames)) - { - item.RefFrames = refFrames; - } - - if (reader.TryGetString(25, out var codecTag)) - { - item.CodecTag = codecTag; - } - - if (reader.TryGetString(26, out var comment)) - { - item.Comment = comment; - } - - if (reader.TryGetString(27, out var nalLengthSize)) - { - item.NalLengthSize = nalLengthSize; - } - - if (reader.TryGetBoolean(28, out var isAVC)) - { - item.IsAVC = isAVC; - } - - if (reader.TryGetString(29, out var title)) - { - item.Title = title; - } - - if (reader.TryGetString(30, out var timeBase)) - { - item.TimeBase = timeBase; - } - - if (reader.TryGetString(31, out var codecTimeBase)) - { - item.CodecTimeBase = codecTimeBase; - } - - if (reader.TryGetString(32, out var colorPrimaries)) - { - item.ColorPrimaries = colorPrimaries; - } - - if (reader.TryGetString(33, out var colorSpace)) - { - item.ColorSpace = colorSpace; - } - - if (reader.TryGetString(34, out var colorTransfer)) - { - item.ColorTransfer = colorTransfer; - } - - if (reader.TryGetInt32(35, out var dvVersionMajor)) - { - item.DvVersionMajor = dvVersionMajor; - } - - if (reader.TryGetInt32(36, out var dvVersionMinor)) - { - item.DvVersionMinor = dvVersionMinor; - } - - if (reader.TryGetInt32(37, out var dvProfile)) - { - item.DvProfile = dvProfile; - } - - if (reader.TryGetInt32(38, out var dvLevel)) - { - item.DvLevel = dvLevel; - } - - if (reader.TryGetInt32(39, out var rpuPresentFlag)) - { - item.RpuPresentFlag = rpuPresentFlag; - } - - if (reader.TryGetInt32(40, out var elPresentFlag)) - { - item.ElPresentFlag = elPresentFlag; - } - - if (reader.TryGetInt32(41, out var blPresentFlag)) - { - item.BlPresentFlag = blPresentFlag; - } - - if (reader.TryGetInt32(42, out var dvBlSignalCompatibilityId)) - { - item.DvBlSignalCompatibilityId = dvBlSignalCompatibilityId; - } - - item.IsHearingImpaired = reader.TryGetBoolean(43, out var result) && result; - - if (reader.TryGetInt32(44, out var rotation)) - { - item.Rotation = rotation; - } - - if (item.Type is MediaStreamType.Audio or MediaStreamType.Subtitle) - { - item.LocalizedDefault = _localization.GetLocalizedString("Default"); - item.LocalizedExternal = _localization.GetLocalizedString("External"); - - if (item.Type is MediaStreamType.Subtitle) - { - item.LocalizedUndefined = _localization.GetLocalizedString("Undefined"); - item.LocalizedForced = _localization.GetLocalizedString("Forced"); - item.LocalizedHearingImpaired = _localization.GetLocalizedString("HearingImpaired"); - } - } - - return item; - } - - /// - public List GetMediaAttachments(MediaAttachmentQuery query) - { - CheckDisposed(); - - ArgumentNullException.ThrowIfNull(query); - - var cmdText = _mediaAttachmentSaveColumnsSelectQuery; - - if (query.Index.HasValue) - { - cmdText += " AND AttachmentIndex=@AttachmentIndex"; - } - - cmdText += " order by AttachmentIndex ASC"; - - var list = new List(); - using (var connection = GetConnection(true)) - using (var statement = PrepareStatement(connection, cmdText)) - { - statement.TryBind("@ItemId", query.ItemId); - - if (query.Index.HasValue) - { - statement.TryBind("@AttachmentIndex", query.Index.Value); - } - - foreach (var row in statement.ExecuteQuery()) - { - list.Add(GetMediaAttachment(row)); - } - } - - return list; - } - - /// - public void SaveMediaAttachments( - Guid id, - IReadOnlyList attachments, - CancellationToken cancellationToken) - { - CheckDisposed(); - if (id.IsEmpty()) - { - throw new ArgumentException("Guid can't be empty.", nameof(id)); - } - - ArgumentNullException.ThrowIfNull(attachments); - - cancellationToken.ThrowIfCancellationRequested(); - - using (var connection = GetConnection()) - using (var transaction = connection.BeginTransaction()) - using (var command = connection.PrepareStatement("delete from mediaattachments where ItemId=@ItemId")) - { - command.TryBind("@ItemId", id); - command.ExecuteNonQuery(); - - InsertMediaAttachments(id, attachments, connection, cancellationToken); - - transaction.Commit(); - } - } - - private void InsertMediaAttachments( - Guid id, - IReadOnlyList attachments, - ManagedConnection db, - CancellationToken cancellationToken) - { - const int InsertAtOnce = 10; - - var insertText = new StringBuilder(_mediaAttachmentInsertPrefix); - for (var startIndex = 0; startIndex < attachments.Count; startIndex += InsertAtOnce) - { - var endIndex = Math.Min(attachments.Count, startIndex + InsertAtOnce); - - for (var i = startIndex; i < endIndex; i++) - { - insertText.Append("(@ItemId, "); - - foreach (var column in _mediaAttachmentSaveColumns.Skip(1)) - { - insertText.Append('@') - .Append(column) - .Append(i) - .Append(','); - } - - insertText.Length -= 1; - - insertText.Append("),"); - } - - insertText.Length--; - - cancellationToken.ThrowIfCancellationRequested(); - - using (var statement = PrepareStatement(db, insertText.ToString())) - { - statement.TryBind("@ItemId", id); - - for (var i = startIndex; i < endIndex; i++) - { - var index = i.ToString(CultureInfo.InvariantCulture); - - var attachment = attachments[i]; - - statement.TryBind("@AttachmentIndex" + index, attachment.Index); - statement.TryBind("@Codec" + index, attachment.Codec); - statement.TryBind("@CodecTag" + index, attachment.CodecTag); - statement.TryBind("@Comment" + index, attachment.Comment); - statement.TryBind("@Filename" + index, attachment.FileName); - statement.TryBind("@MIMEType" + index, attachment.MimeType); - } - - statement.ExecuteNonQuery(); - } - - insertText.Length = _mediaAttachmentInsertPrefix.Length; - } - } - - /// - /// Gets the attachment. - /// - /// The reader. - /// MediaAttachment. - private MediaAttachment GetMediaAttachment(SqliteDataReader reader) - { - var item = new MediaAttachment - { - Index = reader.GetInt32(1) - }; - - if (reader.TryGetString(2, out var codec)) - { - item.Codec = codec; - } - - if (reader.TryGetString(3, out var codecTag)) - { - item.CodecTag = codecTag; - } - - if (reader.TryGetString(4, out var comment)) - { - item.Comment = comment; - } - - if (reader.TryGetString(5, out var fileName)) - { - item.FileName = fileName; - } - - if (reader.TryGetString(6, out var mimeType)) - { - item.MimeType = mimeType; - } - - return item; - } - - private static string BuildMediaAttachmentInsertPrefix() - { - var queryPrefixText = new StringBuilder(); - queryPrefixText.Append("insert into mediaattachments ("); - foreach (var column in _mediaAttachmentSaveColumns) - { - queryPrefixText.Append(column) - .Append(','); - } - - queryPrefixText.Length -= 1; - queryPrefixText.Append(") values "); - return queryPrefixText.ToString(); - } - -#nullable enable - - private readonly struct QueryTimeLogger : IDisposable - { - private readonly ILogger _logger; - private readonly string _commandText; - private readonly string _methodName; - private readonly long _startTimestamp; - - public QueryTimeLogger(ILogger logger, string commandText, [CallerMemberName] string methodName = "") - { - _logger = logger; - _commandText = commandText; - _methodName = methodName; - _startTimestamp = logger.IsEnabled(LogLevel.Debug) ? Stopwatch.GetTimestamp() : -1; - } - - public void Dispose() - { - if (_startTimestamp == -1) - { - return; - } - - var elapsedMs = Stopwatch.GetElapsedTime(_startTimestamp).TotalMilliseconds; - -#if DEBUG - const int SlowThreshold = 100; -#else - const int SlowThreshold = 10; -#endif - - if (elapsedMs >= SlowThreshold) - { - _logger.LogDebug( - "{Method} query time (slow): {ElapsedMs}ms. Query: {Query}", - _methodName, - elapsedMs, - _commandText); - } - } - } - } -} diff --git a/Emby.Server.Implementations/Data/SqliteUserDataRepository.cs b/Emby.Server.Implementations/Data/SqliteUserDataRepository.cs deleted file mode 100644 index bfdcc08f42..0000000000 --- a/Emby.Server.Implementations/Data/SqliteUserDataRepository.cs +++ /dev/null @@ -1,369 +0,0 @@ -#nullable disable - -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.IO; -using System.Threading; -using Jellyfin.Data.Entities; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Persistence; -using Microsoft.Data.Sqlite; -using Microsoft.Extensions.Logging; - -namespace Emby.Server.Implementations.Data -{ - public class SqliteUserDataRepository : BaseSqliteRepository, IUserDataRepository - { - private readonly IUserManager _userManager; - - public SqliteUserDataRepository( - ILogger logger, - IServerConfigurationManager config, - IUserManager userManager) - : base(logger) - { - _userManager = userManager; - - DbFilePath = Path.Combine(config.ApplicationPaths.DataPath, "library.db"); - } - - /// - /// Opens the connection to the database. - /// - public override void Initialize() - { - base.Initialize(); - - using (var connection = GetConnection()) - { - var userDatasTableExists = TableExists(connection, "UserDatas"); - var userDataTableExists = TableExists(connection, "userdata"); - - var users = userDatasTableExists ? null : _userManager.Users; - using var transaction = connection.BeginTransaction(); - connection.Execute(string.Join( - ';', - "create table if not exists UserDatas (key nvarchar not null, userId INT not null, rating float null, played bit not null, playCount int not null, isFavorite bit not null, playbackPositionTicks bigint not null, lastPlayedDate datetime null, AudioStreamIndex INT, SubtitleStreamIndex INT)", - "drop index if exists idx_userdata", - "drop index if exists idx_userdata1", - "drop index if exists idx_userdata2", - "drop index if exists userdataindex1", - "drop index if exists userdataindex", - "drop index if exists userdataindex3", - "drop index if exists userdataindex4", - "create unique index if not exists UserDatasIndex1 on UserDatas (key, userId)", - "create index if not exists UserDatasIndex2 on UserDatas (key, userId, played)", - "create index if not exists UserDatasIndex3 on UserDatas (key, userId, playbackPositionTicks)", - "create index if not exists UserDatasIndex4 on UserDatas (key, userId, isFavorite)", - "create index if not exists UserDatasIndex5 on UserDatas (key, userId, lastPlayedDate)")); - - if (!userDataTableExists) - { - transaction.Commit(); - return; - } - - var existingColumnNames = GetColumnNames(connection, "userdata"); - - AddColumn(connection, "userdata", "InternalUserId", "int", existingColumnNames); - AddColumn(connection, "userdata", "AudioStreamIndex", "int", existingColumnNames); - AddColumn(connection, "userdata", "SubtitleStreamIndex", "int", existingColumnNames); - - if (userDatasTableExists) - { - return; - } - - ImportUserIds(connection, users); - - connection.Execute("INSERT INTO UserDatas (key, userId, rating, played, playCount, isFavorite, playbackPositionTicks, lastPlayedDate, AudioStreamIndex, SubtitleStreamIndex) SELECT key, InternalUserId, rating, played, playCount, isFavorite, playbackPositionTicks, lastPlayedDate, AudioStreamIndex, SubtitleStreamIndex from userdata where InternalUserId not null"); - - transaction.Commit(); - } - } - - private void ImportUserIds(ManagedConnection db, IEnumerable users) - { - var userIdsWithUserData = GetAllUserIdsWithUserData(db); - - using (var statement = db.PrepareStatement("update userdata set InternalUserId=@InternalUserId where UserId=@UserId")) - { - foreach (var user in users) - { - if (!userIdsWithUserData.Contains(user.Id)) - { - continue; - } - - statement.TryBind("@UserId", user.Id); - statement.TryBind("@InternalUserId", user.InternalId); - - statement.ExecuteNonQuery(); - } - } - } - - private List GetAllUserIdsWithUserData(ManagedConnection db) - { - var list = new List(); - - using (var statement = PrepareStatement(db, "select DISTINCT UserId from UserData where UserId not null")) - { - foreach (var row in statement.ExecuteQuery()) - { - try - { - list.Add(row.GetGuid(0)); - } - catch (Exception ex) - { - Logger.LogError(ex, "Error while getting user"); - } - } - } - - return list; - } - - /// - public void SaveUserData(long userId, string key, UserItemData userData, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(userData); - - if (userId <= 0) - { - throw new ArgumentNullException(nameof(userId)); - } - - ArgumentException.ThrowIfNullOrEmpty(key); - - PersistUserData(userId, key, userData, cancellationToken); - } - - /// - public void SaveAllUserData(long userId, UserItemData[] userData, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(userData); - - if (userId <= 0) - { - throw new ArgumentNullException(nameof(userId)); - } - - PersistAllUserData(userId, userData, cancellationToken); - } - - /// - /// Persists the user data. - /// - /// The user id. - /// The key. - /// The user data. - /// The cancellation token. - public void PersistUserData(long internalUserId, string key, UserItemData userData, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - - using (var connection = GetConnection()) - using (var transaction = connection.BeginTransaction()) - { - SaveUserData(connection, internalUserId, key, userData); - transaction.Commit(); - } - } - - private static void SaveUserData(ManagedConnection db, long internalUserId, string key, UserItemData userData) - { - using (var statement = db.PrepareStatement("replace into UserDatas (key, userId, rating,played,playCount,isFavorite,playbackPositionTicks,lastPlayedDate,AudioStreamIndex,SubtitleStreamIndex) values (@key, @userId, @rating,@played,@playCount,@isFavorite,@playbackPositionTicks,@lastPlayedDate,@AudioStreamIndex,@SubtitleStreamIndex)")) - { - statement.TryBind("@userId", internalUserId); - statement.TryBind("@key", key); - - if (userData.Rating.HasValue) - { - statement.TryBind("@rating", userData.Rating.Value); - } - else - { - statement.TryBindNull("@rating"); - } - - statement.TryBind("@played", userData.Played); - statement.TryBind("@playCount", userData.PlayCount); - statement.TryBind("@isFavorite", userData.IsFavorite); - statement.TryBind("@playbackPositionTicks", userData.PlaybackPositionTicks); - - if (userData.LastPlayedDate.HasValue) - { - statement.TryBind("@lastPlayedDate", userData.LastPlayedDate.Value.ToDateTimeParamValue()); - } - else - { - statement.TryBindNull("@lastPlayedDate"); - } - - if (userData.AudioStreamIndex.HasValue) - { - statement.TryBind("@AudioStreamIndex", userData.AudioStreamIndex.Value); - } - else - { - statement.TryBindNull("@AudioStreamIndex"); - } - - if (userData.SubtitleStreamIndex.HasValue) - { - statement.TryBind("@SubtitleStreamIndex", userData.SubtitleStreamIndex.Value); - } - else - { - statement.TryBindNull("@SubtitleStreamIndex"); - } - - statement.ExecuteNonQuery(); - } - } - - /// - /// Persist all user data for the specified user. - /// - private void PersistAllUserData(long internalUserId, UserItemData[] userDataList, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - - using (var connection = GetConnection()) - using (var transaction = connection.BeginTransaction()) - { - foreach (var userItemData in userDataList) - { - SaveUserData(connection, internalUserId, userItemData.Key, userItemData); - } - - transaction.Commit(); - } - } - - /// - /// Gets the user data. - /// - /// The user id. - /// The key. - /// Task{UserItemData}. - /// - /// userId - /// or - /// key. - /// - public UserItemData GetUserData(long userId, string key) - { - if (userId <= 0) - { - throw new ArgumentNullException(nameof(userId)); - } - - ArgumentException.ThrowIfNullOrEmpty(key); - - using (var connection = GetConnection(true)) - { - using (var statement = connection.PrepareStatement("select key,userid,rating,played,playCount,isFavorite,playbackPositionTicks,lastPlayedDate,AudioStreamIndex,SubtitleStreamIndex from UserDatas where key =@Key and userId=@UserId")) - { - statement.TryBind("@UserId", userId); - statement.TryBind("@Key", key); - - foreach (var row in statement.ExecuteQuery()) - { - return ReadRow(row); - } - } - - return null; - } - } - - public UserItemData GetUserData(long userId, List keys) - { - ArgumentNullException.ThrowIfNull(keys); - - if (keys.Count == 0) - { - return null; - } - - return GetUserData(userId, keys[0]); - } - - /// - /// Return all user-data associated with the given user. - /// - /// The internal user id. - /// The list of user item data. - public List GetAllUserData(long userId) - { - if (userId <= 0) - { - throw new ArgumentNullException(nameof(userId)); - } - - var list = new List(); - - using (var connection = GetConnection()) - { - using (var statement = connection.PrepareStatement("select key,userid,rating,played,playCount,isFavorite,playbackPositionTicks,lastPlayedDate,AudioStreamIndex,SubtitleStreamIndex from UserDatas where userId=@UserId")) - { - statement.TryBind("@UserId", userId); - - foreach (var row in statement.ExecuteQuery()) - { - list.Add(ReadRow(row)); - } - } - } - - return list; - } - - /// - /// Read a row from the specified reader into the provided userData object. - /// - /// The list of result set values. - /// The user item data. - private UserItemData ReadRow(SqliteDataReader reader) - { - var userData = new UserItemData - { - Key = reader.GetString(0) - }; - - if (reader.TryGetDouble(2, out var rating)) - { - userData.Rating = rating; - } - - userData.Played = reader.GetBoolean(3); - userData.PlayCount = reader.GetInt32(4); - userData.IsFavorite = reader.GetBoolean(5); - userData.PlaybackPositionTicks = reader.GetInt64(6); - - if (reader.TryReadDateTime(7, out var lastPlayedDate)) - { - userData.LastPlayedDate = lastPlayedDate; - } - - if (reader.TryGetInt32(8, out var audioStreamIndex)) - { - userData.AudioStreamIndex = audioStreamIndex; - } - - if (reader.TryGetInt32(9, out var subtitleStreamIndex)) - { - userData.SubtitleStreamIndex = subtitleStreamIndex; - } - - return userData; - } - } -} diff --git a/Emby.Server.Implementations/Data/SynchronousMode.cs b/Emby.Server.Implementations/Data/SynchronousMode.cs deleted file mode 100644 index cde524e2e0..0000000000 --- a/Emby.Server.Implementations/Data/SynchronousMode.cs +++ /dev/null @@ -1,30 +0,0 @@ -namespace Emby.Server.Implementations.Data; - -/// -/// The disk synchronization mode, controls how aggressively SQLite will write data -/// all the way out to physical storage. -/// -public enum SynchronousMode -{ - /// - /// SQLite continues without syncing as soon as it has handed data off to the operating system. - /// - Off = 0, - - /// - /// SQLite database engine will still sync at the most critical moments. - /// - Normal = 1, - - /// - /// SQLite database engine will use the xSync method of the VFS - /// to ensure that all content is safely written to the disk surface prior to continuing. - /// - Full = 2, - - /// - /// EXTRA synchronous is like FULL with the addition that the directory containing a rollback journal - /// is synced after that journal is unlinked to commit a transaction in DELETE mode. - /// - Extra = 3 -} diff --git a/Emby.Server.Implementations/Data/TempStoreMode.cs b/Emby.Server.Implementations/Data/TempStoreMode.cs deleted file mode 100644 index d2427ce478..0000000000 --- a/Emby.Server.Implementations/Data/TempStoreMode.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace Emby.Server.Implementations.Data; - -/// -/// Storage mode used by temporary database files. -/// -public enum TempStoreMode -{ - /// - /// The compile-time C preprocessor macro SQLITE_TEMP_STORE - /// is used to determine where temporary tables and indices are stored. - /// - Default = 0, - - /// - /// Temporary tables and indices are stored in a file. - /// - File = 1, - - /// - /// Temporary tables and indices are kept in as if they were pure in-memory databases memory. - /// - Memory = 2 -} diff --git a/Emby.Server.Implementations/Devices/DeviceId.cs b/Emby.Server.Implementations/Devices/DeviceId.cs index 2459178d81..0b3c3bbd4f 100644 --- a/Emby.Server.Implementations/Devices/DeviceId.cs +++ b/Emby.Server.Implementations/Devices/DeviceId.cs @@ -4,6 +4,7 @@ using System; using System.Globalization; using System.IO; using System.Text; +using System.Threading; using MediaBrowser.Common.Configuration; using Microsoft.Extensions.Logging; @@ -13,7 +14,7 @@ namespace Emby.Server.Implementations.Devices { private readonly IApplicationPaths _appPaths; private readonly ILogger _logger; - private readonly object _syncLock = new object(); + private readonly Lock _syncLock = new(); private string? _id; diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index 0c0ba74533..5b0fc9ef3b 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -5,11 +5,12 @@ using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; -using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; using Jellyfin.Extensions; using MediaBrowser.Common; using MediaBrowser.Controller.Channels; +using MediaBrowser.Controller.Chapters; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -40,7 +41,6 @@ namespace Emby.Server.Implementations.Dto private readonly ILogger _logger; private readonly ILibraryManager _libraryManager; private readonly IUserDataManager _userDataRepository; - private readonly IItemRepository _itemRepo; private readonly IImageProcessor _imageProcessor; private readonly IProviderManager _providerManager; @@ -51,24 +51,24 @@ namespace Emby.Server.Implementations.Dto private readonly Lazy _livetvManagerFactory; private readonly ITrickplayManager _trickplayManager; + private readonly IChapterRepository _chapterRepository; public DtoService( ILogger logger, ILibraryManager libraryManager, IUserDataManager userDataRepository, - IItemRepository itemRepo, IImageProcessor imageProcessor, IProviderManager providerManager, IRecordingsManager recordingsManager, IApplicationHost appHost, IMediaSourceManager mediaSourceManager, Lazy livetvManagerFactory, - ITrickplayManager trickplayManager) + ITrickplayManager trickplayManager, + IChapterRepository chapterRepository) { _logger = logger; _libraryManager = libraryManager; _userDataRepository = userDataRepository; - _itemRepo = itemRepo; _imageProcessor = imageProcessor; _providerManager = providerManager; _recordingsManager = recordingsManager; @@ -76,6 +76,7 @@ namespace Emby.Server.Implementations.Dto _mediaSourceManager = mediaSourceManager; _livetvManagerFactory = livetvManagerFactory; _trickplayManager = trickplayManager; + _chapterRepository = chapterRepository; } private ILiveTvManager LivetvManager => _livetvManagerFactory.Value; @@ -95,11 +96,11 @@ namespace Emby.Server.Implementations.Dto if (item is LiveTvChannel tvChannel) { - (channelTuples ??= new()).Add((dto, tvChannel)); + (channelTuples ??= []).Add((dto, tvChannel)); } else if (item is LiveTvProgram) { - (programTuples ??= new()).Add((item, dto)); + (programTuples ??= []).Add((item, dto)); } if (item is IItemByName byName) @@ -165,7 +166,7 @@ namespace Emby.Server.Implementations.Dto return dto; } - private static IList GetTaggedItems(IItemByName byName, User? user, DtoOptions options) + private static IReadOnlyList GetTaggedItems(IItemByName byName, User? user, DtoOptions options) { return byName.GetTaggedItems( new InternalItemsQuery(user) @@ -327,7 +328,7 @@ namespace Emby.Server.Implementations.Dto return dto; } - private static void SetItemByNameInfo(BaseItem item, BaseItemDto dto, IList taggedItems) + private static void SetItemByNameInfo(BaseItem item, BaseItemDto dto, IReadOnlyList taggedItems) { if (item is MusicArtist) { @@ -586,12 +587,12 @@ namespace Emby.Server.Implementations.Dto if (dto.ImageBlurHashes is not null) { // Only add BlurHash for the person's image. - baseItemPerson.ImageBlurHashes = new Dictionary>(); + baseItemPerson.ImageBlurHashes = []; foreach (var (imageType, blurHash) in dto.ImageBlurHashes) { if (blurHash is not null) { - baseItemPerson.ImageBlurHashes[imageType] = new Dictionary(); + baseItemPerson.ImageBlurHashes[imageType] = []; foreach (var (imageId, blurHashValue) in blurHash) { if (string.Equals(baseItemPerson.PrimaryImageTag, imageId, StringComparison.OrdinalIgnoreCase)) @@ -670,11 +671,11 @@ namespace Emby.Server.Implementations.Dto if (!string.IsNullOrEmpty(image.BlurHash)) { - dto.ImageBlurHashes ??= new Dictionary>(); + dto.ImageBlurHashes ??= []; if (!dto.ImageBlurHashes.TryGetValue(image.Type, out var value)) { - value = new Dictionary(); + value = []; dto.ImageBlurHashes[image.Type] = value; } @@ -705,7 +706,7 @@ namespace Emby.Server.Implementations.Dto if (hashes.Count > 0) { - dto.ImageBlurHashes ??= new Dictionary>(); + dto.ImageBlurHashes ??= []; dto.ImageBlurHashes[imageType] = hashes; } @@ -752,7 +753,7 @@ namespace Emby.Server.Implementations.Dto dto.AspectRatio = hasAspectRatio.AspectRatio; } - dto.ImageBlurHashes = new Dictionary>(); + dto.ImageBlurHashes = []; var backdropLimit = options.GetImageLimit(ImageType.Backdrop); if (backdropLimit > 0) @@ -768,7 +769,7 @@ namespace Emby.Server.Implementations.Dto if (options.EnableImages) { - dto.ImageTags = new Dictionary(); + dto.ImageTags = []; // Prevent implicitly captured closure var currentItem = item; @@ -1060,7 +1061,7 @@ namespace Emby.Server.Implementations.Dto if (options.ContainsField(ItemFields.Chapters)) { - dto.Chapters = _itemRepo.GetChapters(item); + dto.Chapters = _chapterRepository.GetChapters(item.Id).ToList(); } if (options.ContainsField(ItemFields.Trickplay)) diff --git a/Emby.Server.Implementations/Emby.Server.Implementations.csproj b/Emby.Server.Implementations/Emby.Server.Implementations.csproj index 34276355a7..d99923b4fc 100644 --- a/Emby.Server.Implementations/Emby.Server.Implementations.csproj +++ b/Emby.Server.Implementations/Emby.Server.Implementations.csproj @@ -18,9 +18,11 @@ + + @@ -37,7 +39,7 @@ - net8.0 + net9.0 false true @@ -66,6 +68,6 @@ - + diff --git a/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs b/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs index 4c668379c8..933cfc8cbe 100644 --- a/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs +++ b/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs @@ -5,8 +5,8 @@ using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Data.Entities; using Jellyfin.Data.Events; +using Jellyfin.Database.Implementations.Entities; using Jellyfin.Extensions; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Configuration; @@ -34,7 +34,7 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable private readonly IUserManager _userManager; private readonly ILogger _logger; - private readonly object _libraryChangedSyncLock = new(); + private readonly Lock _libraryChangedSyncLock = new(); private readonly List _foldersAddedTo = new(); private readonly List _foldersRemovedFrom = new(); private readonly List _itemsAdded = new(); diff --git a/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs b/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs index aef02ce6bf..fc174b7c14 100644 --- a/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs +++ b/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs @@ -24,7 +24,7 @@ namespace Emby.Server.Implementations.EntryPoints private readonly IUserManager _userManager; private readonly Dictionary> _changedItems = new(); - private readonly object _syncLock = new(); + private readonly Lock _syncLock = new(); private Timer? _updateTimer; @@ -144,9 +144,15 @@ namespace Emby.Server.Implementations.EntryPoints .Select(i => { var dto = _userDataManager.GetUserDataDto(i, user); + if (dto is null) + { + return null!; + } + dto.ItemId = i.Id; return dto; }) + .Where(e => e is not null) .ToArray() }; } diff --git a/Emby.Server.Implementations/HttpServer/Security/AuthService.cs b/Emby.Server.Implementations/HttpServer/Security/AuthService.cs index 1d04f3da37..8a79cdebc1 100644 --- a/Emby.Server.Implementations/HttpServer/Security/AuthService.cs +++ b/Emby.Server.Implementations/HttpServer/Security/AuthService.cs @@ -1,7 +1,8 @@ #pragma warning disable CS1591 using System.Threading.Tasks; -using Jellyfin.Data.Enums; +using Jellyfin.Data; +using Jellyfin.Database.Implementations.Enums; using MediaBrowser.Controller.Net; using Microsoft.AspNetCore.Http; diff --git a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs index cb6f7e1d35..a720c86fb2 100644 --- a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs +++ b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs @@ -82,17 +82,17 @@ namespace Emby.Server.Implementations.HttpServer public WebSocketState State => _socket.State; /// - public Task SendAsync(OutboundWebSocketMessage message, CancellationToken cancellationToken) + public async Task SendAsync(OutboundWebSocketMessage message, CancellationToken cancellationToken) { var json = JsonSerializer.SerializeToUtf8Bytes(message, _jsonOptions); - return _socket.SendAsync(json, WebSocketMessageType.Text, true, cancellationToken); + await _socket.SendAsync(json, WebSocketMessageType.Text, true, cancellationToken).ConfigureAwait(false); } /// - public Task SendAsync(OutboundWebSocketMessage message, CancellationToken cancellationToken) + public async Task SendAsync(OutboundWebSocketMessage message, CancellationToken cancellationToken) { var json = JsonSerializer.SerializeToUtf8Bytes(message, _jsonOptions); - return _socket.SendAsync(json, WebSocketMessageType.Text, true, cancellationToken); + await _socket.SendAsync(json, WebSocketMessageType.Text, true, cancellationToken).ConfigureAwait(false); } /// @@ -224,12 +224,12 @@ namespace Emby.Server.Implementations.HttpServer return ret; } - private Task SendKeepAliveResponse() + private async Task SendKeepAliveResponse() { LastKeepAliveDate = DateTime.UtcNow; - return SendAsync( + await SendAsync( new OutboundKeepAliveMessage(), - CancellationToken.None); + CancellationToken.None).ConfigureAwait(false); } /// diff --git a/Emby.Server.Implementations/HttpServer/WebSocketManager.cs b/Emby.Server.Implementations/HttpServer/WebSocketManager.cs index 774d3563cb..cb5b3993b8 100644 --- a/Emby.Server.Implementations/HttpServer/WebSocketManager.cs +++ b/Emby.Server.Implementations/HttpServer/WebSocketManager.cs @@ -84,7 +84,7 @@ namespace Emby.Server.Implementations.HttpServer /// Processes the web socket message received. /// /// The result. - private Task ProcessWebSocketMessageReceived(WebSocketMessageInfo result) + private async Task ProcessWebSocketMessageReceived(WebSocketMessageInfo result) { var tasks = new Task[_webSocketListeners.Length]; for (var i = 0; i < _webSocketListeners.Length; ++i) @@ -92,7 +92,7 @@ namespace Emby.Server.Implementations.HttpServer tasks[i] = _webSocketListeners[i].ProcessMessageAsync(result); } - return Task.WhenAll(tasks); + await Task.WhenAll(tasks).ConfigureAwait(false); } } } diff --git a/Emby.Server.Implementations/IO/FileRefresher.cs b/Emby.Server.Implementations/IO/FileRefresher.cs index e75cab64c9..7378cf8851 100644 --- a/Emby.Server.Implementations/IO/FileRefresher.cs +++ b/Emby.Server.Implementations/IO/FileRefresher.cs @@ -18,8 +18,8 @@ namespace Emby.Server.Implementations.IO private readonly ILibraryManager _libraryManager; private readonly IServerConfigurationManager _configurationManager; - private readonly List _affectedPaths = new List(); - private readonly object _timerLock = new object(); + private readonly List _affectedPaths = new(); + private readonly Lock _timerLock = new(); private Timer? _timer; private bool _disposed; diff --git a/Emby.Server.Implementations/IO/ManagedFileSystem.cs b/Emby.Server.Implementations/IO/ManagedFileSystem.cs index 4b68f21d55..ac5933a694 100644 --- a/Emby.Server.Implementations/IO/ManagedFileSystem.cs +++ b/Emby.Server.Implementations/IO/ManagedFileSystem.cs @@ -276,6 +276,13 @@ namespace Emby.Server.Implementations.IO { _logger.LogError(ex, "Reading the file at {Path} failed due to a permissions exception.", fileInfo.FullName); } + catch (IOException ex) + { + // IOException generally means the file is not accessible due to filesystem issues + // Catch this exception and mark the file as not exist to ignore it + _logger.LogError(ex, "Reading the file at {Path} failed due to an IO Exception. Marking the file as not existing", fileInfo.FullName); + result.Exists = false; + } } } @@ -534,8 +541,8 @@ namespace Emby.Server.Implementations.IO return DriveInfo.GetDrives() .Where( d => (d.DriveType == DriveType.Fixed || d.DriveType == DriveType.Network || d.DriveType == DriveType.Removable) - && d.IsReady - && d.TotalSize != 0) + && d.IsReady + && d.TotalSize != 0) .Select(d => new FileSystemMetadata { Name = d.Name, @@ -553,22 +560,36 @@ namespace Emby.Server.Implementations.IO /// public virtual IEnumerable GetFiles(string path, bool recursive = false) { - return GetFiles(path, null, false, recursive); + return GetFiles(path, "*", recursive); } /// - public virtual IEnumerable GetFiles(string path, IReadOnlyList? extensions, bool enableCaseSensitiveExtensions, bool recursive = false) + public virtual IEnumerable GetFiles(string path, string searchPattern, bool recursive = false) + { + return GetFiles(path, searchPattern, null, false, recursive); + } + + /// + public virtual IEnumerable GetFiles(string path, IReadOnlyList? extensions, bool enableCaseSensitiveExtensions, bool recursive) + { + return GetFiles(path, "*", extensions, enableCaseSensitiveExtensions, recursive); + } + + /// + public virtual IEnumerable GetFiles(string path, string searchPattern, IReadOnlyList? extensions, bool enableCaseSensitiveExtensions, bool recursive = false) { var enumerationOptions = GetEnumerationOptions(recursive); - // On linux and osx the search pattern is case sensitive + // On linux and macOS the search pattern is case-sensitive // If we're OK with case-sensitivity, and we're only filtering for one extension, then use the native method if ((enableCaseSensitiveExtensions || _isEnvironmentCaseInsensitive) && extensions is not null && extensions.Count == 1) { - return ToMetadata(new DirectoryInfo(path).EnumerateFiles("*" + extensions[0], enumerationOptions)); + searchPattern = searchPattern.EndsWith(extensions[0], StringComparison.Ordinal) ? searchPattern : searchPattern + extensions[0]; + + return ToMetadata(new DirectoryInfo(path).EnumerateFiles(searchPattern, enumerationOptions)); } - var files = new DirectoryInfo(path).EnumerateFiles("*", enumerationOptions); + var files = new DirectoryInfo(path).EnumerateFiles(searchPattern, enumerationOptions); if (extensions is not null && extensions.Count > 0) { @@ -590,6 +611,9 @@ namespace Emby.Server.Implementations.IO /// public virtual IEnumerable GetFileSystemEntries(string path, bool recursive = false) { + // Note: any of unhandled exceptions thrown by this method may cause the caller to believe the whole path is not accessible. + // But what causing the exception may be a single file under that path. This could lead to unexpected behavior. + // For example, the scanner will remove everything in that path due to unhandled errors. var directoryInfo = new DirectoryInfo(path); var enumerationOptions = GetEnumerationOptions(recursive); @@ -618,7 +642,7 @@ namespace Emby.Server.Implementations.IO { var enumerationOptions = GetEnumerationOptions(recursive); - // On linux and osx the search pattern is case sensitive + // On linux and macOS the search pattern is case-sensitive // If we're OK with case-sensitivity, and we're only filtering for one extension, then use the native method if ((enableCaseSensitiveExtensions || _isEnvironmentCaseInsensitive) && extensions is not null && extensions.Length == 1) { diff --git a/Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs b/Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs index 82db7c46b3..8b28691498 100644 --- a/Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs +++ b/Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; +using System.Net.Mime; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Common.Configuration; @@ -116,13 +117,12 @@ namespace Emby.Server.Implementations.Images var mimeType = MimeTypes.GetMimeType(outputPath); - if (string.Equals(mimeType, "application/octet-stream", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(mimeType, MediaTypeNames.Application.Octet, StringComparison.OrdinalIgnoreCase)) { - mimeType = "image/png"; + mimeType = MediaTypeNames.Image.Png; } await ProviderManager.SaveImage(item, outputPath, mimeType, imageType, null, false, cancellationToken).ConfigureAwait(false); - File.Delete(outputPath); return ItemUpdateType.ImageUpdate; } diff --git a/Emby.Server.Implementations/Images/BaseFolderImageProvider.cs b/Emby.Server.Implementations/Images/BaseFolderImageProvider.cs index f9c10ba098..0d63b3af7d 100644 --- a/Emby.Server.Implementations/Images/BaseFolderImageProvider.cs +++ b/Emby.Server.Implementations/Images/BaseFolderImageProvider.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Dto; diff --git a/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs b/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs index 34c722e41d..273d356a39 100644 --- a/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs +++ b/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Generic; using System.IO; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Dto; diff --git a/Emby.Server.Implementations/Images/GenreImageProvider.cs b/Emby.Server.Implementations/Images/GenreImageProvider.cs index c9b41f8193..706de60a90 100644 --- a/Emby.Server.Implementations/Images/GenreImageProvider.cs +++ b/Emby.Server.Implementations/Images/GenreImageProvider.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Dto; diff --git a/Emby.Server.Implementations/Images/MusicGenreImageProvider.cs b/Emby.Server.Implementations/Images/MusicGenreImageProvider.cs index 31f053f065..c472623e67 100644 --- a/Emby.Server.Implementations/Images/MusicGenreImageProvider.cs +++ b/Emby.Server.Implementations/Images/MusicGenreImageProvider.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Dto; diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 28f7ed6598..1303bb3cb2 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -2,7 +2,6 @@ #pragma warning disable CA5394 using System; -using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; using System.IO; @@ -11,6 +10,7 @@ using System.Net; using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using BitFaster.Caching.Lru; using Emby.Naming.Common; using Emby.Naming.TV; using Emby.Server.Implementations.Library.Resolvers; @@ -18,8 +18,10 @@ using Emby.Server.Implementations.Library.Validators; using Emby.Server.Implementations.Playlists; using Emby.Server.Implementations.ScheduledTasks.Tasks; using Emby.Server.Implementations.Sorting; -using Jellyfin.Data.Entities; +using Jellyfin.Data; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller; @@ -62,7 +64,6 @@ namespace Emby.Server.Implementations.Library private const string ShortcutFileExtension = ".mblink"; private readonly ILogger _logger; - private readonly ConcurrentDictionary _cache; private readonly ITaskManager _taskManager; private readonly IUserManager _userManager; private readonly IUserDataManager _userDataRepository; @@ -76,13 +77,16 @@ namespace Emby.Server.Implementations.Library private readonly IItemRepository _itemRepository; private readonly IImageProcessor _imageProcessor; private readonly NamingOptions _namingOptions; + private readonly IPeopleRepository _peopleRepository; private readonly ExtraResolver _extraResolver; + private readonly IPathManager _pathManager; + private readonly FastConcurrentLru _cache; /// /// The _root folder sync lock. /// - private readonly object _rootFolderSyncLock = new object(); - private readonly object _userRootFolderSyncLock = new object(); + private readonly Lock _rootFolderSyncLock = new(); + private readonly Lock _userRootFolderSyncLock = new(); private readonly TimeSpan _viewRefreshInterval = TimeSpan.FromHours(24); @@ -112,6 +116,8 @@ namespace Emby.Server.Implementations.Library /// The image processor. /// The naming options. /// The directory service. + /// The people repository. + /// The path manager. public LibraryManager( IServerApplicationHost appHost, ILoggerFactory loggerFactory, @@ -127,7 +133,9 @@ namespace Emby.Server.Implementations.Library IItemRepository itemRepository, IImageProcessor imageProcessor, NamingOptions namingOptions, - IDirectoryService directoryService) + IDirectoryService directoryService, + IPeopleRepository peopleRepository, + IPathManager pathManager) { _appHost = appHost; _logger = loggerFactory.CreateLogger(); @@ -142,14 +150,17 @@ namespace Emby.Server.Implementations.Library _mediaEncoder = mediaEncoder; _itemRepository = itemRepository; _imageProcessor = imageProcessor; - _cache = new ConcurrentDictionary(); - _namingOptions = namingOptions; + _cache = new FastConcurrentLru(_configurationManager.Configuration.CacheSize); + + _namingOptions = namingOptions; + _peopleRepository = peopleRepository; + _pathManager = pathManager; _extraResolver = new ExtraResolver(loggerFactory.CreateLogger(), namingOptions, directoryService); _configurationManager.ConfigurationUpdated += ConfigurationUpdated; - RecordConfigurationValues(configurationManager.Configuration); + RecordConfigurationValues(_configurationManager.Configuration); } /// @@ -197,33 +208,33 @@ namespace Emby.Server.Implementations.Library /// Gets or sets the postscan tasks. /// /// The postscan tasks. - private ILibraryPostScanTask[] PostscanTasks { get; set; } = Array.Empty(); + private ILibraryPostScanTask[] PostscanTasks { get; set; } = []; /// /// Gets or sets the intro providers. /// /// The intro providers. - private IIntroProvider[] IntroProviders { get; set; } = Array.Empty(); + private IIntroProvider[] IntroProviders { get; set; } = []; /// /// Gets or sets the list of entity resolution ignore rules. /// /// The entity resolution ignore rules. - private IResolverIgnoreRule[] EntityResolutionIgnoreRules { get; set; } = Array.Empty(); + private IResolverIgnoreRule[] EntityResolutionIgnoreRules { get; set; } = []; /// /// Gets or sets the list of currently registered entity resolvers. /// /// The entity resolvers enumerable. - private IItemResolver[] EntityResolvers { get; set; } = Array.Empty(); + private IItemResolver[] EntityResolvers { get; set; } = []; - private IMultiItemResolver[] MultiItemResolvers { get; set; } = Array.Empty(); + private IMultiItemResolver[] MultiItemResolvers { get; set; } = []; /// /// Gets or sets the comparers. /// /// The comparers. - private IBaseItemComparer[] Comparers { get; set; } = Array.Empty(); + private IBaseItemComparer[] Comparers { get; set; } = []; public bool IsScanRunning { get; private set; } @@ -297,7 +308,7 @@ namespace Emby.Server.Implementations.Library } } - _cache[item.Id] = item; + _cache.AddOrUpdate(item.Id, item); } public void DeleteItem(BaseItem item, DeleteOptions options) @@ -356,7 +367,7 @@ namespace Emby.Server.Implementations.Library var children = item.IsFolder ? ((Folder)item).GetRecursiveChildren(false) - : Array.Empty(); + : []; foreach (var metadataPath in GetMetadataPaths(item, children)) { @@ -451,24 +462,55 @@ namespace Emby.Server.Implementations.Library item.SetParent(null); _itemRepository.DeleteItem(item.Id); + _cache.TryRemove(item.Id, out _); foreach (var child in children) { _itemRepository.DeleteItem(child.Id); + _cache.TryRemove(child.Id, out _); } - _cache.TryRemove(item.Id, out _); - ReportItemRemoved(item, parent); } - private static List GetMetadataPaths(BaseItem item, IEnumerable children) + private List GetMetadataPaths(BaseItem item, IEnumerable children) + { + var list = GetInternalMetadataPaths(item); + foreach (var child in children) + { + list.AddRange(GetInternalMetadataPaths(child)); + } + + return list; + } + + private List GetInternalMetadataPaths(BaseItem item) { var list = new List { item.GetInternalMetadataPath() }; - list.AddRange(children.Select(i => i.GetInternalMetadataPath())); + if (item is Video video) + { + // Trickplay + list.Add(_pathManager.GetTrickplayDirectory(video)); + + // Subtitles and attachments + foreach (var mediaSource in item.GetMediaSources(false)) + { + var subtitleFolder = _pathManager.GetSubtitleFolderPath(mediaSource.Id); + if (subtitleFolder is not null) + { + list.Add(subtitleFolder); + } + + var attachmentFolder = _pathManager.GetAttachmentFolderPath(mediaSource.Id); + if (attachmentFolder is not null) + { + list.Add(attachmentFolder); + } + } + } return list; } @@ -589,7 +631,7 @@ namespace Emby.Server.Implementations.Library { _logger.LogError(ex, "Error in GetFilteredFileSystemEntries isPhysicalRoot: {0} IsVf: {1}", isPhysicalRoot, isVf); - files = Array.Empty(); + files = []; } else { @@ -751,14 +793,7 @@ namespace Emby.Server.Implementations.Library if (folder.Id.IsEmpty()) { - if (string.IsNullOrEmpty(folder.Path)) - { - folder.Id = GetNewItemId(folder.GetType().Name, folder.GetType()); - } - else - { - folder.Id = GetNewItemId(folder.Path, folder.GetType()); - } + folder.Id = GetNewItemId(folder.Path, folder.GetType()); } var dbItem = GetItemById(folder.Id) as BasePluginFolder; @@ -1053,9 +1088,17 @@ namespace Emby.Server.Implementations.Library cancellationToken: cancellationToken).ConfigureAwait(false); // Quickly scan CollectionFolders for changes - foreach (var folder in GetUserRootFolder().Children.OfType()) + foreach (var child in GetUserRootFolder().Children.OfType()) { - await folder.RefreshMetadata(cancellationToken).ConfigureAwait(false); + // If the user has somehow deleted the collection directory, remove the metadata from the database. + if (child is CollectionFolder collectionFolder && !Directory.Exists(collectionFolder.Path)) + { + _itemRepository.DeleteItem(collectionFolder.Id); + } + else + { + await child.RefreshMetadata(cancellationToken).ConfigureAwait(false); + } } } @@ -1230,7 +1273,7 @@ namespace Emby.Server.Implementations.Library throw new ArgumentException("Guid can't be empty", nameof(id)); } - if (_cache.TryGetValue(id, out BaseItem? item)) + if (_cache.TryGet(id, out var item)) { return item; } @@ -1247,7 +1290,7 @@ namespace Emby.Server.Implementations.Library /// public T? GetItemById(Guid id) - where T : BaseItem + where T : BaseItem { var item = GetItemById(id); if (item is T typedItem) @@ -1274,7 +1317,7 @@ namespace Emby.Server.Implementations.Library return ItemIsVisible(item, user) ? item : null; } - public List GetItemList(InternalItemsQuery query, bool allowExternalContent) + public IReadOnlyList GetItemList(InternalItemsQuery query, bool allowExternalContent) { if (query.Recursive && !query.ParentId.IsEmpty()) { @@ -1300,7 +1343,7 @@ namespace Emby.Server.Implementations.Library return itemList; } - public List GetItemList(InternalItemsQuery query) + public IReadOnlyList GetItemList(InternalItemsQuery query) { return GetItemList(query, true); } @@ -1324,7 +1367,7 @@ namespace Emby.Server.Implementations.Library return _itemRepository.GetCount(query); } - public List GetItemList(InternalItemsQuery query, List parents) + public IReadOnlyList GetItemList(InternalItemsQuery query, List parents) { SetTopParentIdsOrAncestors(query, parents); @@ -1339,6 +1382,36 @@ namespace Emby.Server.Implementations.Library return _itemRepository.GetItemList(query); } + public IReadOnlyList GetLatestItemList(InternalItemsQuery query, IReadOnlyList parents, CollectionType collectionType) + { + SetTopParentIdsOrAncestors(query, parents); + + if (query.AncestorIds.Length == 0 && query.TopParentIds.Length == 0) + { + if (query.User is not null) + { + AddUserToQuery(query, query.User); + } + } + + return _itemRepository.GetLatestItemList(query, collectionType); + } + + public IReadOnlyList GetNextUpSeriesKeys(InternalItemsQuery query, IReadOnlyCollection parents, DateTime dateCutoff) + { + SetTopParentIdsOrAncestors(query, parents); + + if (query.AncestorIds.Length == 0 && query.TopParentIds.Length == 0) + { + if (query.User is not null) + { + AddUserToQuery(query, query.User); + } + } + + return _itemRepository.GetNextUpSeriesKeys(query, dateCutoff); + } + public QueryResult QueryItems(InternalItemsQuery query) { if (query.User is not null) @@ -1357,7 +1430,7 @@ namespace Emby.Server.Implementations.Library _itemRepository.GetItemList(query)); } - public List GetItemIds(InternalItemsQuery query) + public IReadOnlyList GetItemIds(InternalItemsQuery query) { if (query.User is not null) { @@ -1443,7 +1516,7 @@ namespace Emby.Server.Implementations.Library // Optimize by querying against top level views query.TopParentIds = parents.SelectMany(i => GetTopParentIdsForQuery(i, query.User)).ToArray(); - query.AncestorIds = Array.Empty(); + query.AncestorIds = []; // Prevent searching in all libraries due to empty filter if (query.TopParentIds.Length == 0) @@ -1563,7 +1636,7 @@ namespace Emby.Server.Implementations.Library return GetTopParentIdsForQuery(displayParent, user); } - return Array.Empty(); + return []; } if (!view.ParentId.IsEmpty()) @@ -1574,7 +1647,7 @@ namespace Emby.Server.Implementations.Library return GetTopParentIdsForQuery(displayParent, user); } - return Array.Empty(); + return []; } // Handle grouping @@ -1589,7 +1662,7 @@ namespace Emby.Server.Implementations.Library .SelectMany(i => GetTopParentIdsForQuery(i, user)); } - return Array.Empty(); + return []; } if (item is CollectionFolder collectionFolder) @@ -1603,7 +1676,7 @@ namespace Emby.Server.Implementations.Library return new[] { topParent.Id }; } - return Array.Empty(); + return []; } /// @@ -1647,7 +1720,7 @@ namespace Emby.Server.Implementations.Library { _logger.LogError(ex, "Error getting intros"); - return Enumerable.Empty(); + return []; } } @@ -1955,13 +2028,13 @@ namespace Emby.Server.Implementations.Library /// public async Task UpdateItemsAsync(IReadOnlyList items, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken) { + _itemRepository.SaveItems(items, cancellationToken); + foreach (var item in items) { await RunMetadataSavers(item, updateReason).ConfigureAwait(false); } - _itemRepository.SaveItems(items, cancellationToken); - if (ItemUpdated is not null) { foreach (var item in items) @@ -2474,8 +2547,11 @@ namespace Emby.Server.Implementations.Library } /// - public int? GetSeasonNumberFromPath(string path) - => SeasonPathParser.Parse(path, true, true).SeasonNumber; + public int? GetSeasonNumberFromPath(string path, Guid? parentId) + { + var parentPath = parentId.HasValue ? GetItemById(parentId.Value)?.ContainingFolderPath : null; + return SeasonPathParser.Parse(path, parentPath, true, true).SeasonNumber; + } /// public bool FillMissingEpisodeNumbersFromPath(Episode episode, bool forceRefresh) @@ -2626,15 +2702,6 @@ namespace Emby.Server.Implementations.Library { episode.ParentIndexNumber = season.IndexNumber; } - else - { - /* - Anime series don't generally have a season in their file name, however, - TVDb needs a season to correctly get the metadata. - Hence, a null season needs to be filled with something. */ - // FIXME perhaps this would be better for TVDb parser to ask for season 1 if no season is specified - episode.ParentIndexNumber = 1; - } if (episode.ParentIndexNumber.HasValue) { @@ -2659,7 +2726,7 @@ namespace Emby.Server.Implementations.Library public IEnumerable FindExtras(BaseItem owner, IReadOnlyList fileSystemChildren, IDirectoryService directoryService) { - var ownerVideoInfo = VideoResolver.Resolve(owner.Path, owner.IsFolder, _namingOptions); + var ownerVideoInfo = VideoResolver.Resolve(owner.Path, owner.IsFolder, _namingOptions, libraryRoot: owner.ContainingFolderPath); if (ownerVideoInfo is null) { yield break; @@ -2736,12 +2803,12 @@ namespace Emby.Server.Implementations.Library return path; } - public List GetPeople(InternalPeopleQuery query) + public IReadOnlyList GetPeople(InternalPeopleQuery query) { - return _itemRepository.GetPeople(query); + return _peopleRepository.GetPeople(query); } - public List GetPeople(BaseItem item) + public IReadOnlyList GetPeople(BaseItem item) { if (item.SupportsPeople) { @@ -2756,12 +2823,12 @@ namespace Emby.Server.Implementations.Library } } - return new List(); + return []; } - public List GetPeopleItems(InternalPeopleQuery query) + public IReadOnlyList GetPeopleItems(InternalPeopleQuery query) { - return _itemRepository.GetPeopleNames(query) + return _peopleRepository.GetPeopleNames(query) .Select(i => { try @@ -2779,9 +2846,9 @@ namespace Emby.Server.Implementations.Library .ToList()!; // null values are filtered out } - public List GetPeopleNames(InternalPeopleQuery query) + public IReadOnlyList GetPeopleNames(InternalPeopleQuery query) { - return _itemRepository.GetPeopleNames(query); + return _peopleRepository.GetPeopleNames(query); } public void UpdatePeople(BaseItem item, List people) @@ -2790,16 +2857,17 @@ namespace Emby.Server.Implementations.Library } /// - public async Task UpdatePeopleAsync(BaseItem item, List people, CancellationToken cancellationToken) + public async Task UpdatePeopleAsync(BaseItem item, IReadOnlyList people, CancellationToken cancellationToken) { if (!item.SupportsPeople) { return; } - _itemRepository.UpdatePeople(item.Id, people); if (people is not null) { + people = people.Where(e => e is not null).ToArray(); + _peopleRepository.UpdatePeople(item.Id, people); await SavePeopleMetadataAsync(people, cancellationToken).ConfigureAwait(false); } } @@ -2848,7 +2916,7 @@ namespace Emby.Server.Implementations.Library throw new ArgumentNullException(nameof(name)); } - name = _fileSystem.GetValidFilename(name); + name = _fileSystem.GetValidFilename(name.Trim()); var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath; @@ -2882,7 +2950,7 @@ namespace Emby.Server.Implementations.Library { var path = Path.Combine(virtualFolderPath, collectionType.ToString()!.ToLowerInvariant() + ".collection"); // Can't be null with legal values? - await File.WriteAllBytesAsync(path, Array.Empty()).ConfigureAwait(false); + await File.WriteAllBytesAsync(path, []).ConfigureAwait(false); } CollectionFolder.SaveLibraryOptions(virtualFolderPath, options); @@ -2914,14 +2982,13 @@ namespace Emby.Server.Implementations.Library private async Task SavePeopleMetadataAsync(IEnumerable people, CancellationToken cancellationToken) { - List? personsToSave = null; - foreach (var person in people) { cancellationToken.ThrowIfCancellationRequested(); var itemUpdateType = ItemUpdateType.MetadataDownload; var saveEntity = false; + var createEntity = false; var personEntity = GetPerson(person.Name); if (personEntity is null) @@ -2938,6 +3005,7 @@ namespace Emby.Server.Implementations.Library personEntity.PresentationUniqueKey = personEntity.CreatePresentationUniqueKey(); saveEntity = true; + createEntity = true; } foreach (var id in person.ProviderIds) @@ -2965,14 +3033,14 @@ namespace Emby.Server.Implementations.Library if (saveEntity) { - (personsToSave ??= new()).Add(personEntity); - await RunMetadataSavers(personEntity, itemUpdateType).ConfigureAwait(false); - } - } + if (createEntity) + { + CreateItems([personEntity], null, CancellationToken.None); + } - if (personsToSave is not null) - { - CreateItems(personsToSave, null, CancellationToken.None); + await RunMetadataSavers(personEntity, itemUpdateType).ConfigureAwait(false); + CreateItems([personEntity], null, CancellationToken.None); + } } } @@ -3027,7 +3095,7 @@ namespace Emby.Server.Implementations.Library { var libraryOptions = CollectionFolder.GetLibraryOptions(virtualFolderPath); - libraryOptions.PathInfos = [..libraryOptions.PathInfos, pathInfo]; + libraryOptions.PathInfos = [.. libraryOptions.PathInfos, pathInfo]; SyncLibraryOptionsToLocations(virtualFolderPath, libraryOptions); diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs index 90a01c052c..c6cfd5391a 100644 --- a/Emby.Server.Implementations/Library/MediaSourceManager.cs +++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Collections.Immutable; using System.Globalization; using System.IO; using System.Linq; @@ -12,8 +13,10 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; using AsyncKeyedLock; -using Jellyfin.Data.Entities; +using Jellyfin.Data; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using Jellyfin.Extensions.Json; using MediaBrowser.Common.Configuration; @@ -38,7 +41,7 @@ namespace Emby.Server.Implementations.Library public class MediaSourceManager : IMediaSourceManager, IDisposable { // Do not use a pipe here because Roku http requests to the server will fail, without any explicit error message. - private const char LiveStreamIdDelimeter = '_'; + private const char LiveStreamIdDelimiter = '_'; private readonly IServerApplicationHost _appHost; private readonly IItemRepository _itemRepo; @@ -51,7 +54,8 @@ namespace Emby.Server.Implementations.Library private readonly ILocalizationManager _localizationManager; private readonly IApplicationPaths _appPaths; private readonly IDirectoryService _directoryService; - + private readonly IMediaStreamRepository _mediaStreamRepository; + private readonly IMediaAttachmentRepository _mediaAttachmentRepository; private readonly ConcurrentDictionary _openStreams = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); private readonly AsyncNonKeyedLocker _liveStreamLocker = new(1); private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; @@ -69,7 +73,9 @@ namespace Emby.Server.Implementations.Library IFileSystem fileSystem, IUserDataManager userDataManager, IMediaEncoder mediaEncoder, - IDirectoryService directoryService) + IDirectoryService directoryService, + IMediaStreamRepository mediaStreamRepository, + IMediaAttachmentRepository mediaAttachmentRepository) { _appHost = appHost; _itemRepo = itemRepo; @@ -82,6 +88,8 @@ namespace Emby.Server.Implementations.Library _localizationManager = localizationManager; _appPaths = applicationPaths; _directoryService = directoryService; + _mediaStreamRepository = mediaStreamRepository; + _mediaAttachmentRepository = mediaAttachmentRepository; } public void AddParts(IEnumerable providers) @@ -89,9 +97,9 @@ namespace Emby.Server.Implementations.Library _providers = providers.ToArray(); } - public List GetMediaStreams(MediaStreamQuery query) + public IReadOnlyList GetMediaStreams(MediaStreamQuery query) { - var list = _itemRepo.GetMediaStreams(query); + var list = _mediaStreamRepository.GetMediaStreams(query); foreach (var stream in list) { @@ -121,7 +129,7 @@ namespace Emby.Server.Implementations.Library return false; } - public List GetMediaStreams(Guid itemId) + public IReadOnlyList GetMediaStreams(Guid itemId) { var list = GetMediaStreams(new MediaStreamQuery { @@ -131,7 +139,7 @@ namespace Emby.Server.Implementations.Library return GetMediaStreamsForItem(list); } - private List GetMediaStreamsForItem(List streams) + private IReadOnlyList GetMediaStreamsForItem(IReadOnlyList streams) { foreach (var stream in streams) { @@ -145,13 +153,13 @@ namespace Emby.Server.Implementations.Library } /// - public List GetMediaAttachments(MediaAttachmentQuery query) + public IReadOnlyList GetMediaAttachments(MediaAttachmentQuery query) { - return _itemRepo.GetMediaAttachments(query); + return _mediaAttachmentRepository.GetMediaAttachments(query); } /// - public List GetMediaAttachments(Guid itemId) + public IReadOnlyList GetMediaAttachments(Guid itemId) { return GetMediaAttachments(new MediaAttachmentQuery { @@ -159,7 +167,7 @@ namespace Emby.Server.Implementations.Library }); } - public async Task> GetPlaybackMediaSources(BaseItem item, User user, bool allowMediaProbe, bool enablePathSubstitution, CancellationToken cancellationToken) + public async Task> GetPlaybackMediaSources(BaseItem item, User user, bool allowMediaProbe, bool enablePathSubstitution, CancellationToken cancellationToken) { var mediaSources = GetStaticMediaSources(item, enablePathSubstitution, user); @@ -212,7 +220,7 @@ namespace Emby.Server.Implementations.Library list.Add(source); } - return SortMediaSources(list); + return SortMediaSources(list).ToArray(); } /// > @@ -307,7 +315,7 @@ namespace Emby.Server.Implementations.Library private static void SetKeyProperties(IMediaSourceProvider provider, MediaSourceInfo mediaSource) { - var prefix = provider.GetType().FullName.GetMD5().ToString("N", CultureInfo.InvariantCulture) + LiveStreamIdDelimeter; + var prefix = provider.GetType().FullName.GetMD5().ToString("N", CultureInfo.InvariantCulture) + LiveStreamIdDelimiter; if (!string.IsNullOrEmpty(mediaSource.OpenToken) && !mediaSource.OpenToken.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) { @@ -332,7 +340,7 @@ namespace Emby.Server.Implementations.Library return sources.FirstOrDefault(i => string.Equals(i.Id, mediaSourceId, StringComparison.OrdinalIgnoreCase)); } - public List GetStaticMediaSources(BaseItem item, bool enablePathSubstitution, User user = null) + public IReadOnlyList GetStaticMediaSources(BaseItem item, bool enablePathSubstitution, User user = null) { ArgumentNullException.ThrowIfNull(item); @@ -419,6 +427,7 @@ namespace Emby.Server.Implementations.Library if (source.MediaStreams.Any(i => i.Type == MediaStreamType.Audio && i.Index == index)) { source.DefaultAudioStreamIndex = index; + source.DefaultAudioIndexSource = AudioIndexSource.User; return; } } @@ -426,6 +435,15 @@ namespace Emby.Server.Implementations.Library var preferredAudio = NormalizeLanguage(user.AudioLanguagePreference); source.DefaultAudioStreamIndex = MediaStreamSelector.GetDefaultAudioStreamIndex(source.MediaStreams, preferredAudio, user.PlayDefaultAudioTrack); + if (user.PlayDefaultAudioTrack) + { + source.DefaultAudioIndexSource |= AudioIndexSource.Default; + } + + if (preferredAudio.Count > 0) + { + source.DefaultAudioIndexSource |= AudioIndexSource.Language; + } } public void SetDefaultAudioAndSubtitleStreamIndices(BaseItem item, MediaSourceInfo source, User user) @@ -453,7 +471,7 @@ namespace Emby.Server.Implementations.Library } } - private static List SortMediaSources(IEnumerable sources) + private static IEnumerable SortMediaSources(IEnumerable sources) { return sources.OrderBy(i => { @@ -470,8 +488,7 @@ namespace Emby.Server.Implementations.Library return stream?.Width ?? 0; }) - .Where(i => i.Type != MediaSourceType.Placeholder) - .ToList(); + .Where(i => i.Type != MediaSourceType.Placeholder); } public async Task> OpenLiveStreamInternal(LiveStreamRequest request, CancellationToken cancellationToken) @@ -777,9 +794,13 @@ namespace Emby.Server.Implementations.Library { ArgumentException.ThrowIfNullOrEmpty(id); - // TODO probably shouldn't throw here but it is kept for "backwards compatibility" - var info = GetLiveStreamInfo(id) ?? throw new ResourceNotFoundException(); - return Task.FromResult(new Tuple(info.MediaSource, info as IDirectStreamProvider)); + var info = GetLiveStreamInfo(id); + if (info is null) + { + return Task.FromResult>(new Tuple(null, null)); + } + + return Task.FromResult>(new Tuple(info.MediaSource, info as IDirectStreamProvider)); } public ILiveStream GetLiveStreamInfo(string id) @@ -806,7 +827,7 @@ namespace Emby.Server.Implementations.Library return result.Item1; } - public async Task> GetRecordingStreamMediaSources(ActiveRecordingInfo info, CancellationToken cancellationToken) + public async Task> GetRecordingStreamMediaSources(ActiveRecordingInfo info, CancellationToken cancellationToken) { var stream = new MediaSourceInfo { @@ -829,10 +850,7 @@ namespace Emby.Server.Implementations.Library await new LiveStreamHelper(_mediaEncoder, _logger, _appPaths) .AddMediaInfoWithProbe(stream, false, false, cancellationToken).ConfigureAwait(false); - return new List - { - stream - }; + return [stream]; } public async Task CloseLiveStream(string id) @@ -864,11 +882,11 @@ namespace Emby.Server.Implementations.Library { ArgumentException.ThrowIfNullOrEmpty(key); - var keys = key.Split(LiveStreamIdDelimeter, 2); + var keys = key.Split(LiveStreamIdDelimiter, 2); var provider = _providers.FirstOrDefault(i => string.Equals(i.GetType().FullName.GetMD5().ToString("N", CultureInfo.InvariantCulture), keys[0], StringComparison.OrdinalIgnoreCase)); - var splitIndex = key.IndexOf(LiveStreamIdDelimeter, StringComparison.Ordinal); + var splitIndex = key.IndexOf(LiveStreamIdDelimiter, StringComparison.Ordinal); var keyId = key.Substring(splitIndex + 1); return (provider, keyId); diff --git a/Emby.Server.Implementations/Library/MediaStreamSelector.cs b/Emby.Server.Implementations/Library/MediaStreamSelector.cs index ea223e3ece..631179ffcf 100644 --- a/Emby.Server.Implementations/Library/MediaStreamSelector.cs +++ b/Emby.Server.Implementations/Library/MediaStreamSelector.cs @@ -3,7 +3,7 @@ using System; using System.Collections.Generic; using System.Linq; -using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using MediaBrowser.Model.Entities; @@ -39,46 +39,48 @@ namespace Emby.Server.Implementations.Library return null; } + // Sort in the following order: Default > No tag > Forced var sortedStreams = streams .Where(i => i.Type == MediaStreamType.Subtitle) .OrderByDescending(x => x.IsExternal) - .ThenByDescending(x => x.IsForced && string.Equals(x.Language, audioTrackLanguage, StringComparison.OrdinalIgnoreCase)) - .ThenByDescending(x => x.IsForced) .ThenByDescending(x => x.IsDefault) - .ThenByDescending(x => preferredLanguages.Contains(x.Language, StringComparison.OrdinalIgnoreCase)) + .ThenByDescending(x => !x.IsForced && MatchesPreferredLanguage(x.Language, preferredLanguages)) + .ThenByDescending(x => x.IsForced && MatchesPreferredLanguage(x.Language, preferredLanguages)) + .ThenByDescending(x => x.IsForced && IsLanguageUndefined(x.Language)) + .ThenByDescending(x => x.IsForced) .ToList(); MediaStream? stream = null; + if (mode == SubtitlePlaybackMode.Default) { - // Load subtitles according to external, forced and default flags. - stream = sortedStreams.FirstOrDefault(x => x.IsExternal || x.IsForced || x.IsDefault); + // Load subtitles according to external, default and forced flags. + stream = sortedStreams.FirstOrDefault(x => x.IsExternal || x.IsDefault || x.IsForced); } else if (mode == SubtitlePlaybackMode.Smart) { // Only attempt to load subtitles if the audio language is not one of the user's preferred subtitle languages. - // If no subtitles of preferred language available, use default behaviour. + // If no subtitles of preferred language available, use none. + // If the audio language is one of the user's preferred subtitle languages behave like OnlyForced. if (!preferredLanguages.Contains(audioTrackLanguage, StringComparison.OrdinalIgnoreCase)) { - stream = sortedStreams.FirstOrDefault(x => preferredLanguages.Contains(x.Language, StringComparison.OrdinalIgnoreCase)) ?? - sortedStreams.FirstOrDefault(x => x.IsExternal || x.IsForced || x.IsDefault); + stream = sortedStreams.FirstOrDefault(x => MatchesPreferredLanguage(x.Language, preferredLanguages)); } else { - // Respect forced flag. - stream = sortedStreams.FirstOrDefault(x => x.IsForced); + stream = BehaviorOnlyForced(sortedStreams, preferredLanguages).FirstOrDefault(); } } else if (mode == SubtitlePlaybackMode.Always) { - // Always load (full/non-forced) subtitles of the user's preferred subtitle language if possible, otherwise default behaviour. - stream = sortedStreams.FirstOrDefault(x => !x.IsForced && preferredLanguages.Contains(x.Language, StringComparison.OrdinalIgnoreCase)) ?? - sortedStreams.FirstOrDefault(x => x.IsExternal || x.IsForced || x.IsDefault); + // Always load (full/non-forced) subtitles of the user's preferred subtitle language if possible, otherwise OnlyForced behaviour. + stream = sortedStreams.FirstOrDefault(x => !x.IsForced && MatchesPreferredLanguage(x.Language, preferredLanguages)) ?? + BehaviorOnlyForced(sortedStreams, preferredLanguages).FirstOrDefault(); } else if (mode == SubtitlePlaybackMode.OnlyForced) { - // Only load subtitles that are flagged forced. - stream = sortedStreams.FirstOrDefault(x => x.IsForced); + // Load subtitles that are flagged forced of the user's preferred subtitle language or with an undefined language + stream = BehaviorOnlyForced(sortedStreams, preferredLanguages).FirstOrDefault(); } return stream?.Index; @@ -110,40 +112,72 @@ namespace Emby.Server.Implementations.Library if (mode == SubtitlePlaybackMode.Default) { // Prefer embedded metadata over smart logic - filteredStreams = sortedStreams.Where(s => s.IsForced || s.IsDefault) + // Load subtitles according to external, default, and forced flags. + filteredStreams = sortedStreams.Where(s => s.IsExternal || s.IsDefault || s.IsForced) .ToList(); } else if (mode == SubtitlePlaybackMode.Smart) { // Prefer smart logic over embedded metadata + // Only attempt to load subtitles if the audio language is not one of the user's preferred subtitle languages, otherwise OnlyForced behavior. if (!preferredLanguages.Contains(audioTrackLanguage, StringComparison.OrdinalIgnoreCase)) { - filteredStreams = sortedStreams.Where(s => !s.IsForced && preferredLanguages.Contains(s.Language, StringComparison.OrdinalIgnoreCase)) + filteredStreams = sortedStreams.Where(s => MatchesPreferredLanguage(s.Language, preferredLanguages)) .ToList(); } + else + { + filteredStreams = BehaviorOnlyForced(sortedStreams, preferredLanguages); + } } else if (mode == SubtitlePlaybackMode.Always) { - // Always load the most suitable full subtitles - filteredStreams = sortedStreams.Where(s => !s.IsForced).ToList(); + // Always load (full/non-forced) subtitles of the user's preferred subtitle language if possible, otherwise OnlyForced behavior. + filteredStreams = sortedStreams.Where(s => !s.IsForced && MatchesPreferredLanguage(s.Language, preferredLanguages)) + .ToList() ?? BehaviorOnlyForced(sortedStreams, preferredLanguages); } else if (mode == SubtitlePlaybackMode.OnlyForced) { - // Always load the most suitable full subtitles - filteredStreams = sortedStreams.Where(s => s.IsForced).ToList(); + // Load subtitles that are flagged forced of the user's preferred subtitle language or with an undefined language + filteredStreams = BehaviorOnlyForced(sortedStreams, preferredLanguages); } - // Load forced subs if we have found no suitable full subtitles - var iterStreams = filteredStreams is null || filteredStreams.Count == 0 - ? sortedStreams.Where(s => s.IsForced && string.Equals(s.Language, audioTrackLanguage, StringComparison.OrdinalIgnoreCase)) - : filteredStreams; + // If filteredStreams is null, initialize it as an empty list to avoid null reference errors + filteredStreams ??= new List(); - foreach (var stream in iterStreams) + foreach (var stream in filteredStreams) { stream.Score = GetStreamScore(stream, preferredLanguages); } } + private static bool MatchesPreferredLanguage(string language, IReadOnlyList preferredLanguages) + { + // If preferredLanguages is empty, treat it as "any language" (wildcard) + return preferredLanguages.Count == 0 || + preferredLanguages.Contains(language, StringComparison.OrdinalIgnoreCase); + } + + private static bool IsLanguageUndefined(string language) + { + // Check for null, empty, or known placeholders + return string.IsNullOrEmpty(language) || + language.Equals("und", StringComparison.OrdinalIgnoreCase) || + language.Equals("unknown", StringComparison.OrdinalIgnoreCase) || + language.Equals("undetermined", StringComparison.OrdinalIgnoreCase) || + language.Equals("mul", StringComparison.OrdinalIgnoreCase) || + language.Equals("zxx", StringComparison.OrdinalIgnoreCase); + } + + private static List BehaviorOnlyForced(IEnumerable sortedStreams, IReadOnlyList preferredLanguages) + { + return sortedStreams + .Where(s => s.IsForced && (MatchesPreferredLanguage(s.Language, preferredLanguages) || IsLanguageUndefined(s.Language))) + .OrderByDescending(s => MatchesPreferredLanguage(s.Language, preferredLanguages)) + .ThenByDescending(s => IsLanguageUndefined(s.Language)) + .ToList(); + } + internal static int GetStreamScore(MediaStream stream, IReadOnlyList languagePreferences) { var index = languagePreferences.FindIndex(x => string.Equals(x, stream.Language, StringComparison.OrdinalIgnoreCase)); diff --git a/Emby.Server.Implementations/Library/MusicManager.cs b/Emby.Server.Implementations/Library/MusicManager.cs index a69a0f33f3..28cf695007 100644 --- a/Emby.Server.Implementations/Library/MusicManager.cs +++ b/Emby.Server.Implementations/Library/MusicManager.cs @@ -2,9 +2,11 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Linq; -using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -24,30 +26,23 @@ namespace Emby.Server.Implementations.Library _libraryManager = libraryManager; } - public List GetInstantMixFromSong(Audio item, User? user, DtoOptions dtoOptions) - { - var list = new List - { - item - }; - - list.AddRange(GetInstantMixFromGenres(item.Genres, user, dtoOptions)); - - return list; - } - - /// - public List GetInstantMixFromArtist(MusicArtist artist, User? user, DtoOptions dtoOptions) - { - return GetInstantMixFromGenres(artist.Genres, user, dtoOptions); - } - - public List GetInstantMixFromAlbum(MusicAlbum item, User? user, DtoOptions dtoOptions) + public IReadOnlyList GetInstantMixFromSong(Audio item, User? user, DtoOptions dtoOptions) { return GetInstantMixFromGenres(item.Genres, user, dtoOptions); } - public List GetInstantMixFromFolder(Folder item, User? user, DtoOptions dtoOptions) + /// + public IReadOnlyList GetInstantMixFromArtist(MusicArtist artist, User? user, DtoOptions dtoOptions) + { + return GetInstantMixFromGenres(artist.Genres, user, dtoOptions); + } + + public IReadOnlyList GetInstantMixFromAlbum(MusicAlbum item, User? user, DtoOptions dtoOptions) + { + return GetInstantMixFromGenres(item.Genres, user, dtoOptions); + } + + public IReadOnlyList GetInstantMixFromFolder(Folder item, User? user, DtoOptions dtoOptions) { var genres = item .GetRecursiveChildren(user, new InternalItemsQuery(user) @@ -63,12 +58,12 @@ namespace Emby.Server.Implementations.Library return GetInstantMixFromGenres(genres, user, dtoOptions); } - public List GetInstantMixFromPlaylist(Playlist item, User? user, DtoOptions dtoOptions) + public IReadOnlyList GetInstantMixFromPlaylist(Playlist item, User? user, DtoOptions dtoOptions) { return GetInstantMixFromGenres(item.Genres, user, dtoOptions); } - public List GetInstantMixFromGenres(IEnumerable genres, User? user, DtoOptions dtoOptions) + public IReadOnlyList GetInstantMixFromGenres(IEnumerable genres, User? user, DtoOptions dtoOptions) { var genreIds = genres.DistinctNames().Select(i => { @@ -85,7 +80,7 @@ namespace Emby.Server.Implementations.Library return GetInstantMixFromGenreIds(genreIds, user, dtoOptions); } - public List GetInstantMixFromGenreIds(Guid[] genreIds, User? user, DtoOptions dtoOptions) + public IReadOnlyList GetInstantMixFromGenreIds(Guid[] genreIds, User? user, DtoOptions dtoOptions) { return _libraryManager.GetItemList(new InternalItemsQuery(user) { @@ -97,7 +92,7 @@ namespace Emby.Server.Implementations.Library }); } - public List GetInstantMixFromItem(BaseItem item, User? user, DtoOptions dtoOptions) + public IReadOnlyList GetInstantMixFromItem(BaseItem item, User? user, DtoOptions dtoOptions) { if (item is MusicGenre) { diff --git a/Emby.Server.Implementations/Library/PathManager.cs b/Emby.Server.Implementations/Library/PathManager.cs new file mode 100644 index 0000000000..83a6df9644 --- /dev/null +++ b/Emby.Server.Implementations/Library/PathManager.cs @@ -0,0 +1,73 @@ +using System; +using System.Globalization; +using System.IO; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.IO; + +namespace Emby.Server.Implementations.Library; + +/// +/// IPathManager implementation. +/// +public class PathManager : IPathManager +{ + private readonly IServerConfigurationManager _config; + private readonly IApplicationPaths _appPaths; + + /// + /// Initializes a new instance of the class. + /// + /// The server configuration manager. + /// The application paths. + public PathManager( + IServerConfigurationManager config, + IApplicationPaths appPaths) + { + _config = config; + _appPaths = appPaths; + } + + private string SubtitleCachePath => Path.Combine(_appPaths.DataPath, "subtitles"); + + private string AttachmentCachePath => Path.Combine(_appPaths.DataPath, "attachments"); + + /// + public string GetAttachmentPath(string mediaSourceId, string fileName) + { + return Path.Join(GetAttachmentFolderPath(mediaSourceId), fileName); + } + + /// + public string GetAttachmentFolderPath(string mediaSourceId) + { + var id = Guid.Parse(mediaSourceId).ToString("D", CultureInfo.InvariantCulture).AsSpan(); + + return Path.Join(AttachmentCachePath, id[..2], id); + } + + /// + public string GetSubtitleFolderPath(string mediaSourceId) + { + var id = Guid.Parse(mediaSourceId).ToString("D", CultureInfo.InvariantCulture).AsSpan(); + + return Path.Join(SubtitleCachePath, id[..2], id); + } + + /// + public string GetSubtitlePath(string mediaSourceId, int streamIndex, string extension) + { + return Path.Join(GetSubtitleFolderPath(mediaSourceId), streamIndex.ToString(CultureInfo.InvariantCulture) + extension); + } + + /// + public string GetTrickplayDirectory(BaseItem item, bool saveWithMedia = false) + { + var id = item.Id.ToString("D", CultureInfo.InvariantCulture).AsSpan(); + + return saveWithMedia + ? Path.Combine(item.ContainingFolderPath, Path.ChangeExtension(item.Path, ".trickplay")) + : Path.Join(_config.ApplicationPaths.TrickplayPath, id[..2], id); + } +} diff --git a/Emby.Server.Implementations/Library/Resolvers/ExtraResolver.cs b/Emby.Server.Implementations/Library/Resolvers/ExtraResolver.cs index b4791b9456..b9f9f29723 100644 --- a/Emby.Server.Implementations/Library/Resolvers/ExtraResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/ExtraResolver.cs @@ -54,9 +54,9 @@ namespace Emby.Server.Implementations.Library.Resolvers _ => _videoResolvers }; - public bool TryGetExtraTypeForOwner(string path, VideoFileInfo ownerVideoFileInfo, [NotNullWhen(true)] out ExtraType? extraType) + public bool TryGetExtraTypeForOwner(string path, VideoFileInfo ownerVideoFileInfo, [NotNullWhen(true)] out ExtraType? extraType, string? libraryRoot = "") { - var extraResult = GetExtraInfo(path, _namingOptions); + var extraResult = GetExtraInfo(path, _namingOptions, libraryRoot); if (extraResult.ExtraType is null) { extraType = null; diff --git a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs index 4debe722b9..f1aeb1340a 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs @@ -270,11 +270,11 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies } var videoInfos = files - .Select(i => VideoResolver.Resolve(i.FullName, i.IsDirectory, NamingOptions, parseName)) + .Select(i => VideoResolver.Resolve(i.FullName, i.IsDirectory, NamingOptions, parseName, parent.ContainingFolderPath)) .Where(f => f is not null) .ToList(); - var resolverResult = VideoListResolver.Resolve(videoInfos, NamingOptions, supportMultiEditions, parseName); + var resolverResult = VideoListResolver.Resolve(videoInfos, NamingOptions, supportMultiEditions, parseName, parent.ContainingFolderPath); var result = new MultiItemResolverResult { diff --git a/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs b/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs index a03c1214d6..14798dda65 100644 --- a/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs @@ -28,7 +28,7 @@ namespace Emby.Server.Implementations.Library.Resolvers { if (args.IsDirectory) { - // It's a boxset if the path is a directory with [playlist] in its name + // It's a playlist if the path is a directory with [playlist] in its name var filename = Path.GetFileName(Path.TrimEndingDirectorySeparator(args.Path)); if (string.IsNullOrEmpty(filename)) { diff --git a/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs index abf2d01159..6cb63a28a2 100644 --- a/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs @@ -48,7 +48,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV var path = args.Path; - var seasonParserResult = SeasonPathParser.Parse(path, true, true); + var seasonParserResult = SeasonPathParser.Parse(path, series.ContainingFolderPath, true, true); var season = new Season { diff --git a/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs index fb48d7bf17..c81a0adb89 100644 --- a/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs @@ -118,7 +118,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV { if (child.IsDirectory) { - if (IsSeasonFolder(child.FullName, isTvContentType)) + if (IsSeasonFolder(child.FullName, path, isTvContentType)) { _logger.LogDebug("{Path} is a series because of season folder {Dir}.", path, child.FullName); return true; @@ -155,11 +155,12 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV /// Determines whether [is season folder] [the specified path]. /// /// The path. + /// The parentpath. /// if set to true [is tv content type]. /// true if [is season folder] [the specified path]; otherwise, false. - private static bool IsSeasonFolder(string path, bool isTvContentType) + private static bool IsSeasonFolder(string path, string parentPath, bool isTvContentType) { - var seasonNumber = SeasonPathParser.Parse(path, isTvContentType, isTvContentType).SeasonNumber; + var seasonNumber = SeasonPathParser.Parse(path, parentPath, isTvContentType, isTvContentType).SeasonNumber; return seasonNumber.HasValue; } diff --git a/Emby.Server.Implementations/Library/SearchEngine.cs b/Emby.Server.Implementations/Library/SearchEngine.cs index 7f3f8615e2..9d81b835ce 100644 --- a/Emby.Server.Implementations/Library/SearchEngine.cs +++ b/Emby.Server.Implementations/Library/SearchEngine.cs @@ -3,8 +3,9 @@ using System; using System.Collections.Generic; using System.Linq; -using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -171,7 +172,7 @@ namespace Emby.Server.Implementations.Library } }; - List mediaItems; + IReadOnlyList mediaItems; if (searchQuery.IncludeItemTypes.Length == 1 && searchQuery.IncludeItemTypes[0] == BaseItemKind.MusicArtist) { diff --git a/Emby.Server.Implementations/Library/SplashscreenPostScanTask.cs b/Emby.Server.Implementations/Library/SplashscreenPostScanTask.cs index 320685b1f1..71ce3b6012 100644 --- a/Emby.Server.Implementations/Library/SplashscreenPostScanTask.cs +++ b/Emby.Server.Implementations/Library/SplashscreenPostScanTask.cs @@ -4,13 +4,13 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Persistence; using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Querying; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.Library; @@ -43,14 +43,26 @@ public class SplashscreenPostScanTask : ILibraryPostScanTask /// public Task Run(IProgress progress, CancellationToken cancellationToken) { - var posters = GetItemsWithImageType(ImageType.Primary).Select(x => x.GetImages(ImageType.Primary).First().Path).ToList(); - var backdrops = GetItemsWithImageType(ImageType.Thumb).Select(x => x.GetImages(ImageType.Thumb).First().Path).ToList(); + var posters = GetItemsWithImageType(ImageType.Primary) + .Select(x => x.GetImages(ImageType.Primary).FirstOrDefault()?.Path) + .Where(path => !string.IsNullOrEmpty(path)) + .Select(path => path!) + .ToList(); + var backdrops = GetItemsWithImageType(ImageType.Thumb) + .Select(x => x.GetImages(ImageType.Thumb).FirstOrDefault()?.Path) + .Where(path => !string.IsNullOrEmpty(path)) + .Select(path => path!) + .ToList(); if (backdrops.Count == 0) { // Thumb images fit better because they include the title in the image but are not provided with TMDb. // Using backdrops as a fallback to generate an image at all _logger.LogDebug("No thumb images found. Using backdrops to generate splashscreen"); - backdrops = GetItemsWithImageType(ImageType.Backdrop).Select(x => x.GetImages(ImageType.Backdrop).First().Path).ToList(); + backdrops = GetItemsWithImageType(ImageType.Backdrop) + .Select(x => x.GetImages(ImageType.Backdrop).FirstOrDefault()?.Path) + .Where(path => !string.IsNullOrEmpty(path)) + .Select(path => path!) + .ToList(); } _imageEncoder.CreateSplashscreen(posters, backdrops); @@ -65,15 +77,15 @@ public class SplashscreenPostScanTask : ILibraryPostScanTask CollapseBoxSetItems = false, Recursive = true, DtoOptions = new DtoOptions(false), - ImageTypes = new[] { imageType }, + ImageTypes = [imageType], Limit = 30, // TODO max parental rating configurable - MaxParentalRating = 10, - OrderBy = new[] - { + MaxParentalRating = new(10, null), + OrderBy = + [ (ItemSortBy.Random, SortOrder.Ascending) - }, - IncludeItemTypes = new[] { BaseItemKind.Movie, BaseItemKind.Series } + ], + IncludeItemTypes = [BaseItemKind.Movie, BaseItemKind.Series] }); } } diff --git a/Emby.Server.Implementations/Library/UserDataManager.cs b/Emby.Server.Implementations/Library/UserDataManager.cs index 62d22b23ff..be1d96bf0b 100644 --- a/Emby.Server.Implementations/Library/UserDataManager.cs +++ b/Emby.Server.Implementations/Library/UserDataManager.cs @@ -1,17 +1,20 @@ +#pragma warning disable RS0030 // Do not use banned APIs + using System; -using System.Collections.Concurrent; using System.Collections.Generic; -using System.Diagnostics; using System.Globalization; +using System.Linq; using System.Threading; -using Jellyfin.Data.Entities; +using BitFaster.Caching.Lru; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Persistence; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; +using Microsoft.EntityFrameworkCore; using AudioBook = MediaBrowser.Controller.Entities.AudioBook; using Book = MediaBrowser.Controller.Entities.Book; @@ -22,27 +25,22 @@ namespace Emby.Server.Implementations.Library /// public class UserDataManager : IUserDataManager { - private readonly ConcurrentDictionary _userData = - new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); - private readonly IServerConfigurationManager _config; - private readonly IUserManager _userManager; - private readonly IUserDataRepository _repository; + private readonly IDbContextFactory _repository; + private readonly FastConcurrentLru _cache; /// /// Initializes a new instance of the class. /// /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. + /// Instance of the interface. public UserDataManager( IServerConfigurationManager config, - IUserManager userManager, - IUserDataRepository repository) + IDbContextFactory repository) { _config = config; - _userManager = userManager; _repository = repository; + _cache = new FastConcurrentLru(Environment.ProcessorCount, _config.Configuration.CacheSize, StringComparer.OrdinalIgnoreCase); } /// @@ -59,15 +57,29 @@ namespace Emby.Server.Implementations.Library var keys = item.GetUserDataKeys(); - var userId = user.InternalId; + using var dbContext = _repository.CreateDbContext(); + using var transaction = dbContext.Database.BeginTransaction(); foreach (var key in keys) { - _repository.SaveUserData(userId, key, userData, cancellationToken); + userData.Key = key; + var userDataEntry = Map(userData, user.Id, item.Id); + if (dbContext.UserData.Any(f => f.ItemId == userDataEntry.ItemId && f.UserId == userDataEntry.UserId && f.CustomDataKey == userDataEntry.CustomDataKey)) + { + dbContext.UserData.Attach(userDataEntry).State = EntityState.Modified; + } + else + { + dbContext.UserData.Add(userDataEntry); + } } + dbContext.SaveChanges(); + transaction.Commit(); + + var userId = user.InternalId; var cacheKey = GetCacheKey(userId, item.Id); - _userData.AddOrUpdate(cacheKey, userData, (_, _) => userData); + _cache.AddOrUpdate(cacheKey, userData); UserDataSaved?.Invoke(this, new UserDataSaveEventArgs { @@ -84,10 +96,9 @@ namespace Emby.Server.Implementations.Library { ArgumentNullException.ThrowIfNull(user); ArgumentNullException.ThrowIfNull(item); - ArgumentNullException.ThrowIfNull(reason); ArgumentNullException.ThrowIfNull(userDataDto); - var userData = GetUserData(user, item); + var userData = GetUserData(user, item) ?? throw new InvalidOperationException("UserData should not be null."); if (userDataDto.PlaybackPositionTicks.HasValue) { @@ -127,33 +138,91 @@ namespace Emby.Server.Implementations.Library SaveUserData(user, item, userData, reason, CancellationToken.None); } - private UserItemData GetUserData(User user, Guid itemId, List keys) + private UserData Map(UserItemData dto, Guid userId, Guid itemId) { - var userId = user.InternalId; - - var cacheKey = GetCacheKey(userId, itemId); - - return _userData.GetOrAdd(cacheKey, _ => GetUserDataInternal(userId, keys)); + return new UserData() + { + ItemId = itemId, + CustomDataKey = dto.Key, + Item = null, + User = null, + AudioStreamIndex = dto.AudioStreamIndex, + IsFavorite = dto.IsFavorite, + LastPlayedDate = dto.LastPlayedDate, + Likes = dto.Likes, + PlaybackPositionTicks = dto.PlaybackPositionTicks, + PlayCount = dto.PlayCount, + Played = dto.Played, + Rating = dto.Rating, + UserId = userId, + SubtitleStreamIndex = dto.SubtitleStreamIndex, + }; } - private UserItemData GetUserDataInternal(long internalUserId, List keys) + private UserItemData Map(UserData dto) { - var userData = _repository.GetUserData(internalUserId, keys); - - if (userData is not null) + return new UserItemData() { - return userData; + Key = dto.CustomDataKey!, + AudioStreamIndex = dto.AudioStreamIndex, + IsFavorite = dto.IsFavorite, + LastPlayedDate = dto.LastPlayedDate, + Likes = dto.Likes, + PlaybackPositionTicks = dto.PlaybackPositionTicks, + PlayCount = dto.PlayCount, + Played = dto.Played, + Rating = dto.Rating, + SubtitleStreamIndex = dto.SubtitleStreamIndex, + }; + } + + private UserItemData? GetUserData(User user, Guid itemId, List keys) + { + var cacheKey = GetCacheKey(user.InternalId, itemId); + + if (_cache.TryGet(cacheKey, out var data)) + { + return data; } - if (keys.Count > 0) + data = GetUserDataInternal(user.Id, itemId, keys); + + if (data is null) { - return new UserItemData + return new UserItemData() { - Key = keys[0] + Key = keys[0], }; } - throw new UnreachableException(); + return _cache.GetOrAdd(cacheKey, _ => data); + } + + private UserItemData? GetUserDataInternal(Guid userId, Guid itemId, List keys) + { + if (keys.Count == 0) + { + return null; + } + + using var context = _repository.CreateDbContext(); + var userData = context.UserData.AsNoTracking().Where(e => e.ItemId == itemId && keys.Contains(e.CustomDataKey) && e.UserId.Equals(userId)).ToArray(); + + if (userData.Length > 0) + { + var directDataReference = userData.FirstOrDefault(e => e.CustomDataKey == itemId.ToString("N")); + if (directDataReference is not null) + { + return Map(directDataReference); + } + + return Map(userData.First()); + } + + return new UserItemData + { + Key = keys.Last()! + }; } /// @@ -166,20 +235,25 @@ namespace Emby.Server.Implementations.Library } /// - public UserItemData GetUserData(User user, BaseItem item) + public UserItemData? GetUserData(User user, BaseItem item) { return GetUserData(user, item.Id, item.GetUserDataKeys()); } /// - public UserItemDataDto GetUserDataDto(BaseItem item, User user) + public UserItemDataDto? GetUserDataDto(BaseItem item, User user) => GetUserDataDto(item, null, user, new DtoOptions()); /// - public UserItemDataDto GetUserDataDto(BaseItem item, BaseItemDto? itemDto, User user, DtoOptions options) + public UserItemDataDto? GetUserDataDto(BaseItem item, BaseItemDto? itemDto, User user, DtoOptions options) { var userData = GetUserData(user, item); - var dto = GetUserItemDataDto(userData); + if (userData is null) + { + return null; + } + + var dto = GetUserItemDataDto(userData, item.Id); item.FillUserDataDtoValues(dto, userData, itemDto, user, options); return dto; @@ -189,9 +263,10 @@ namespace Emby.Server.Implementations.Library /// Converts a UserItemData to a DTOUserItemData. /// /// The data. + /// The reference key to an Item. /// DtoUserItemData. /// is null. - private UserItemDataDto GetUserItemDataDto(UserItemData data) + private UserItemDataDto GetUserItemDataDto(UserItemData data, Guid itemId) { ArgumentNullException.ThrowIfNull(data); @@ -204,6 +279,7 @@ namespace Emby.Server.Implementations.Library Rating = data.Rating, Played = data.Played, LastPlayedDate = data.LastPlayedDate, + ItemId = itemId, Key = data.Key }; } diff --git a/Emby.Server.Implementations/Library/UserViewManager.cs b/Emby.Server.Implementations/Library/UserViewManager.cs index e9cf47d462..87214c273b 100644 --- a/Emby.Server.Implementations/Library/UserViewManager.cs +++ b/Emby.Server.Implementations/Library/UserViewManager.cs @@ -6,8 +6,10 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading; -using Jellyfin.Data.Entities; +using Jellyfin.Data; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Configuration; @@ -308,39 +310,40 @@ namespace Emby.Server.Implementations.Library } } - var mediaTypes = new List(); + MediaType[] mediaTypes = []; if (includeItemTypes.Length == 0) { + HashSet tmpMediaTypes = []; foreach (var parent in parents.OfType()) { switch (parent.CollectionType) { case CollectionType.books: - mediaTypes.Add(MediaType.Book); - mediaTypes.Add(MediaType.Audio); + tmpMediaTypes.Add(MediaType.Book); + tmpMediaTypes.Add(MediaType.Audio); break; case CollectionType.music: - mediaTypes.Add(MediaType.Audio); + tmpMediaTypes.Add(MediaType.Audio); break; case CollectionType.photos: - mediaTypes.Add(MediaType.Photo); - mediaTypes.Add(MediaType.Video); + tmpMediaTypes.Add(MediaType.Photo); + tmpMediaTypes.Add(MediaType.Video); break; case CollectionType.homevideos: - mediaTypes.Add(MediaType.Photo); - mediaTypes.Add(MediaType.Video); + tmpMediaTypes.Add(MediaType.Photo); + tmpMediaTypes.Add(MediaType.Video); break; default: - mediaTypes.Add(MediaType.Video); + tmpMediaTypes.Add(MediaType.Video); break; } } - mediaTypes = mediaTypes.Distinct().ToList(); + mediaTypes = tmpMediaTypes.ToArray(); } - var excludeItemTypes = includeItemTypes.Length == 0 && mediaTypes.Count == 0 + var excludeItemTypes = includeItemTypes.Length == 0 && mediaTypes.Length == 0 ? new[] { BaseItemKind.Person, @@ -366,12 +369,22 @@ namespace Emby.Server.Implementations.Library Limit = limit * 5, IsPlayed = isPlayed, DtoOptions = options, - MediaTypes = mediaTypes.ToArray() + MediaTypes = mediaTypes }; - if (parents.Count == 0) + if (request.GroupItems) { - return _libraryManager.GetItemList(query, false); + if (parents.OfType().All(i => i.CollectionType == CollectionType.tvshows)) + { + query.Limit = limit; + return _libraryManager.GetLatestItemList(query, parents, CollectionType.tvshows); + } + + if (parents.OfType().All(i => i.CollectionType == CollectionType.music)) + { + query.Limit = limit; + return _libraryManager.GetLatestItemList(query, parents, CollectionType.music); + } } return _libraryManager.GetItemList(query, parents); diff --git a/Emby.Server.Implementations/Library/Validators/CollectionPostScanTask.cs b/Emby.Server.Implementations/Library/Validators/CollectionPostScanTask.cs index 89f64ee4f0..337b1afdd4 100644 --- a/Emby.Server.Implementations/Library/Validators/CollectionPostScanTask.cs +++ b/Emby.Server.Implementations/Library/Validators/CollectionPostScanTask.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; using MediaBrowser.Controller.Collections; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; diff --git a/Emby.Server.Implementations/Library/Validators/GenresValidator.cs b/Emby.Server.Implementations/Library/Validators/GenresValidator.cs index e59c62e239..364770fcdc 100644 --- a/Emby.Server.Implementations/Library/Validators/GenresValidator.cs +++ b/Emby.Server.Implementations/Library/Validators/GenresValidator.cs @@ -1,6 +1,9 @@ using System; +using System.Globalization; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Persistence; using Microsoft.Extensions.Logging; @@ -75,6 +78,26 @@ namespace Emby.Server.Implementations.Library.Validators progress.Report(percent); } + var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.Genre, BaseItemKind.MusicGenre], + IsDeadGenre = true, + IsLocked = false + }); + + foreach (var item in deadEntities) + { + _logger.LogInformation("Deleting dead {ItemType} {ItemId} {ItemName}", item.GetType().Name, item.Id.ToString("N", CultureInfo.InvariantCulture), item.Name); + + _libraryManager.DeleteItem( + item, + new DeleteOptions + { + DeleteFileLocation = false + }, + false); + } + progress.Report(100); } } diff --git a/Emby.Server.Implementations/Localization/Core/af.json b/Emby.Server.Implementations/Localization/Core/af.json index e89ede10b4..1dce589234 100644 --- a/Emby.Server.Implementations/Localization/Core/af.json +++ b/Emby.Server.Implementations/Localization/Core/af.json @@ -129,5 +129,11 @@ "TaskAudioNormalizationDescription": "Skandeer lêers vir oudio-normaliseringsdata.", "TaskAudioNormalization": "Odio Normalisering", "TaskCleanCollectionsAndPlaylists": "Maak versamelings en snitlyste skoon", - "TaskCleanCollectionsAndPlaylistsDescription": "Verwyder items uit versamelings en snitlyste wat nie meer bestaan nie." + "TaskCleanCollectionsAndPlaylistsDescription": "Verwyder items uit versamelings en snitlyste wat nie meer bestaan nie.", + "TaskDownloadMissingLyrics": "Laai tekorte lirieke af", + "TaskDownloadMissingLyricsDescription": "Laai lirieke af vir liedjies", + "TaskExtractMediaSegments": "Media Segment Skandeer", + "TaskExtractMediaSegmentsDescription": "Onttrek of verkry mediasegmente van MediaSegment-geaktiveerde inproppe.", + "TaskMoveTrickplayImages": "Migreer Trickplay Beeldligging", + "TaskMoveTrickplayImagesDescription": "Skuif ontstaande trickplay lêers volgens die biblioteekinstellings." } diff --git a/Emby.Server.Implementations/Localization/Core/ar.json b/Emby.Server.Implementations/Localization/Core/ar.json index bd45b0b968..2d29eb5bf7 100644 --- a/Emby.Server.Implementations/Localization/Core/ar.json +++ b/Emby.Server.Implementations/Localization/Core/ar.json @@ -16,7 +16,7 @@ "Folders": "المجلدات", "Genres": "التصنيفات", "HeaderAlbumArtists": "فناني الألبوم", - "HeaderContinueWatching": "استئناف المشاهدة", + "HeaderContinueWatching": "إستئناف المشاهدة", "HeaderFavoriteAlbums": "الألبومات المفضلة", "HeaderFavoriteArtists": "الفنانون المفضلون", "HeaderFavoriteEpisodes": "الحلقات المفضلة", @@ -31,7 +31,7 @@ "ItemRemovedWithName": "أُزيل {0} من المكتبة", "LabelIpAddressValue": "عنوان الآي بي: {0}", "LabelRunningTimeValue": "مدة التشغيل: {0}", - "Latest": "أحدث", + "Latest": "الأحدث", "MessageApplicationUpdated": "حُدث خادم Jellyfin", "MessageApplicationUpdatedTo": "حُدث خادم Jellyfin إلى {0}", "MessageNamedServerConfigurationUpdatedWithValue": "حُدثت إعدادات الخادم في قسم {0}", @@ -52,7 +52,7 @@ "NotificationOptionInstallationFailed": "فشل في التثبيت", "NotificationOptionNewLibraryContent": "أُضيف محتوى جديدا", "NotificationOptionPluginError": "فشل في الملحق", - "NotificationOptionPluginInstalled": "ثُبتت المكونات الإضافية", + "NotificationOptionPluginInstalled": "ثُبتت الملحق", "NotificationOptionPluginUninstalled": "تمت إزالة الملحق", "NotificationOptionPluginUpdateInstalled": "تم تثبيت تحديثات الملحق", "NotificationOptionServerRestartRequired": "يجب إعادة تشغيل الخادم", @@ -90,10 +90,10 @@ "UserStartedPlayingItemWithValues": "قام {0} ببدء تشغيل {1} على {2}", "UserStoppedPlayingItemWithValues": "قام {0} بإيقاف تشغيل {1} على {2}", "ValueHasBeenAddedToLibrary": "تمت اضافت {0} إلى مكتبة الوسائط", - "ValueSpecialEpisodeName": "حلقه خاصه - {0}", + "ValueSpecialEpisodeName": "حلقة خاصه - {0}", "VersionNumber": "الإصدار {0}", "TaskCleanCacheDescription": "يحذف الملفات المؤقتة التي لم يعد النظام بحاجة إليها.", - "TaskCleanCache": "احذف ما بمجلد الملفات المؤقتة", + "TaskCleanCache": "حذف الملفات المؤقتة", "TasksChannelsCategory": "قنوات الإنترنت", "TasksLibraryCategory": "مكتبة", "TasksMaintenanceCategory": "صيانة", @@ -129,7 +129,12 @@ "TaskRefreshTrickplayImagesDescription": "يُنشئ معاينات Trickplay لمقاطع الفيديو في المكتبات المُمكّنة.", "TaskCleanCollectionsAndPlaylists": "حذف المجموعات وقوائم التشغيل", "TaskCleanCollectionsAndPlaylistsDescription": "حذف عناصر من المجموعات وقوائم التشغيل التي لم تعد موجودة.", - "TaskAudioNormalization": "تطبيع الصوت", + "TaskAudioNormalization": "تسوية الصوت", "TaskAudioNormalizationDescription": "مسح الملفات لتطبيع بيانات الصوت.", - "TaskDownloadMissingLyrics": "تنزيل عبارات القصيدة" + "TaskDownloadMissingLyrics": "تنزيل عبارات القصيدة", + "TaskDownloadMissingLyricsDescription": "كلمات", + "TaskExtractMediaSegments": "فحص مقاطع الوسائط", + "TaskExtractMediaSegmentsDescription": "يستخرج مقاطع وسائط من إضافات MediaSegment المُفعّلة.", + "TaskMoveTrickplayImages": "تغيير مكان صور المعاينة السريعة", + "TaskMoveTrickplayImagesDescription": "تُنقل ملفات التشغيل السريع الحالية بناءً على إعدادات المكتبة." } diff --git a/Emby.Server.Implementations/Localization/Core/be.json b/Emby.Server.Implementations/Localization/Core/be.json index 97aa0ca58c..d5da04fb9f 100644 --- a/Emby.Server.Implementations/Localization/Core/be.json +++ b/Emby.Server.Implementations/Localization/Core/be.json @@ -1,6 +1,6 @@ { "Sync": "Сінхранізаваць", - "Playlists": "Плэйлісты", + "Playlists": "Спісы прайгравання", "Latest": "Апошні", "LabelIpAddressValue": "IP-адрас: {0}", "ItemAddedWithName": "{0} быў дададзены ў бібліятэку", @@ -16,7 +16,7 @@ "Collections": "Калекцыі", "Default": "Па змаўчанні", "FailedLoginAttemptWithUserName": "Няўдалая спроба ўваходу з {0}", - "Folders": "Папкі", + "Folders": "Тэчкі", "Favorites": "Абранае", "External": "Знешні", "Genres": "Жанры", diff --git a/Emby.Server.Implementations/Localization/Core/bg-BG.json b/Emby.Server.Implementations/Localization/Core/bg-BG.json index 4f95b48803..72f5757531 100644 --- a/Emby.Server.Implementations/Localization/Core/bg-BG.json +++ b/Emby.Server.Implementations/Localization/Core/bg-BG.json @@ -121,7 +121,7 @@ "TaskCleanActivityLog": "Изчисти дневника с активност", "TaskOptimizeDatabaseDescription": "Прави базата данни по-компактна и освобождава място. Пускането на тази задача след сканиране на библиотеката или правене на други промени, свързани с модификации на базата данни, може да подобри производителността.", "TaskOptimizeDatabase": "Оптимизирай базата данни", - "TaskKeyframeExtractorDescription": "Извличат се ключови кадри от видеофайловете ,за да се създаде по точен ХЛС списък . Задачата може да отнеме много време.", + "TaskKeyframeExtractorDescription": "Извличат се ключови кадри от видеофайловете ,за да се създаде по точен HLS списък . Задачата може да отнеме много време.", "TaskKeyframeExtractor": "Извличане на ключови кадри", "External": "Външен", "HearingImpaired": "Увреден слух", @@ -129,8 +129,12 @@ "TaskRefreshTrickplayImagesDescription": "Създава прегледи на Trickplay за видеа в активирани библиотеки.", "TaskDownloadMissingLyrics": "Свали липсващи текстове", "TaskDownloadMissingLyricsDescription": "Свали текстове за песни", - "TaskCleanCollectionsAndPlaylists": "Изчисти колекциите и плейлистовете", + "TaskCleanCollectionsAndPlaylists": "Изчисти колекциите и плейлистите", "TaskCleanCollectionsAndPlaylistsDescription": "Премахни несъществуващи файлове в колекциите и плейлистите.", "TaskAudioNormalization": "Нормализиране на звука", - "TaskAudioNormalizationDescription": "Сканирай файловете за нормализация на звука." + "TaskAudioNormalizationDescription": "Сканирай файловете за нормализация на звука.", + "TaskExtractMediaSegmentsDescription": "Изважда медиини сегменти от MediaSegment плъгини.", + "TaskMoveTrickplayImages": "Мигриране на Локацията за Trickplay изображения", + "TaskMoveTrickplayImagesDescription": "Премества съществуващите trickplay изображения спрямо настройките на библиотеката.", + "TaskExtractMediaSegments": "Сканиране за сегменти" } diff --git a/Emby.Server.Implementations/Localization/Core/bn.json b/Emby.Server.Implementations/Localization/Core/bn.json index 4724bba3b1..268a141ff1 100644 --- a/Emby.Server.Implementations/Localization/Core/bn.json +++ b/Emby.Server.Implementations/Localization/Core/bn.json @@ -125,5 +125,11 @@ "TaskKeyframeExtractor": "কি-ফ্রেম নিষ্কাশক", "TaskKeyframeExtractorDescription": "ভিডিয়ো থেকে কি-ফ্রেম নিষ্কাশনের মাধ্যমে অধিকতর সঠিক HLS প্লে লিস্ট তৈরী করে। এই প্রক্রিয়া দীর্ঘ সময় ধরে চলতে পারে।", "TaskRefreshTrickplayImages": "ট্রিকপ্লে ইমেজ তৈরি করুন", - "TaskRefreshTrickplayImagesDescription": "সক্ষম লাইব্রেরিতে ভিডিওর জন্য ট্রিকপ্লে প্রিভিউ তৈরি করে।" + "TaskRefreshTrickplayImagesDescription": "সক্ষম লাইব্রেরিতে ভিডিওর জন্য ট্রিকপ্লে প্রিভিউ তৈরি করে।", + "TaskDownloadMissingLyricsDescription": "গানের লিরিক্স ডাউনলোড করে", + "TaskCleanCollectionsAndPlaylists": "সংগ্রহ এবং প্লেলিস্ট পরিষ্কার করুন", + "TaskCleanCollectionsAndPlaylistsDescription": "সংগ্রহ এবং প্লেলিস্ট থেকে আইটেমগুলি সরিয়ে দেয় যা আর বিদ্যমান নেই।", + "TaskExtractMediaSegments": "মিডিয়া সেগমেন্ট স্ক্যান", + "TaskExtractMediaSegmentsDescription": "MediaSegment সক্ষম প্লাগইনগুলি থেকে মিডিয়া সেগমেন্টগুলি বের করে বা প্রাপ্ত করে।", + "TaskDownloadMissingLyrics": "অনুপস্থিত গান ডাউনলোড করুন" } diff --git a/Emby.Server.Implementations/Localization/Core/ca.json b/Emby.Server.Implementations/Localization/Core/ca.json index 6b3b78fa12..6cce0e0198 100644 --- a/Emby.Server.Implementations/Localization/Core/ca.json +++ b/Emby.Server.Implementations/Localization/Core/ca.json @@ -16,7 +16,7 @@ "Folders": "Carpetes", "Genres": "Gèneres", "HeaderAlbumArtists": "Artistes de l'àlbum", - "HeaderContinueWatching": "Continuar veient", + "HeaderContinueWatching": "Continua veient", "HeaderFavoriteAlbums": "Àlbums preferits", "HeaderFavoriteArtists": "Artistes preferits", "HeaderFavoriteEpisodes": "Episodis preferits", @@ -24,13 +24,13 @@ "HeaderFavoriteSongs": "Cançons preferides", "HeaderLiveTV": "TV en directe", "HeaderNextUp": "A continuació", - "HeaderRecordingGroups": "Grups d'enregistrament", + "HeaderRecordingGroups": "Grups Musicals", "HomeVideos": "Vídeos domèstics", - "Inherit": "Hereta", - "ItemAddedWithName": "{0} ha sigut afegit a la biblioteca", - "ItemRemovedWithName": "{0} ha sigut eliminat de la biblioteca", + "Inherit": "Heretat", + "ItemAddedWithName": "{0} s'ha afegit a la biblioteca", + "ItemRemovedWithName": "{0} s'ha eliminat de la biblioteca", "LabelIpAddressValue": "Adreça IP: {0}", - "LabelRunningTimeValue": "Temps en funcionament: {0}", + "LabelRunningTimeValue": "Temps en marxa: {0}", "Latest": "Darrers", "MessageApplicationUpdated": "El servidor de Jellyfin ha estat actualitzat", "MessageApplicationUpdatedTo": "El servidor de Jellyfin ha estat actualitzat a {0}", @@ -44,8 +44,8 @@ "NameSeasonNumber": "Temporada {0}", "NameSeasonUnknown": "Temporada desconeguda", "NewVersionIsAvailable": "Una nova versió del servidor de Jellyfin està disponible per a descarregar.", - "NotificationOptionApplicationUpdateAvailable": "Actualització de l'aplicació disponible", - "NotificationOptionApplicationUpdateInstalled": "Actualització de l'aplicació instal·lada", + "NotificationOptionApplicationUpdateAvailable": "Actualització de l'aplicatiu disponible", + "NotificationOptionApplicationUpdateInstalled": "Actualització de l'aplicatiu instal·lada", "NotificationOptionAudioPlayback": "Reproducció d'àudio iniciada", "NotificationOptionAudioPlaybackStopped": "Reproducció d'àudio aturada", "NotificationOptionCameraImageUploaded": "Imatge de càmera pujada", @@ -54,8 +54,8 @@ "NotificationOptionPluginError": "Un complement ha fallat", "NotificationOptionPluginInstalled": "Complement instal·lat", "NotificationOptionPluginUninstalled": "Complement desinstal·lat", - "NotificationOptionPluginUpdateInstalled": "Actualització de complement instal·lada", - "NotificationOptionServerRestartRequired": "Reinici del servidor requerit", + "NotificationOptionPluginUpdateInstalled": "Actualització del complement instal·lada", + "NotificationOptionServerRestartRequired": "El servidor s'ha de reiniciar", "NotificationOptionTaskFailed": "Tasca programada fallida", "NotificationOptionUserLockedOut": "Usuari expulsat", "NotificationOptionVideoPlayback": "Reproducció de vídeo iniciada", @@ -64,15 +64,15 @@ "Playlists": "Llistes de reproducció", "Plugin": "Complement", "PluginInstalledWithName": "{0} ha estat instal·lat", - "PluginUninstalledWithName": "{0} ha estat desinstal·lat", - "PluginUpdatedWithName": "{0} ha estat actualitzat", + "PluginUninstalledWithName": "S'ha instalat {0}", + "PluginUpdatedWithName": "S'ha actualitzat {0}", "ProviderValue": "Proveïdor: {0}", "ScheduledTaskFailedWithName": "{0} ha fallat", - "ScheduledTaskStartedWithName": "{0} s'ha iniciat", - "ServerNameNeedsToBeRestarted": "{0} necessita ser reiniciat", + "ScheduledTaskStartedWithName": "S'ha iniciat {0}", + "ServerNameNeedsToBeRestarted": "S'ha de reiniciar {0}", "Shows": "Sèries", "Songs": "Cançons", - "StartupEmbyServerIsLoading": "El servidor de Jellyfin s'està carregant. Proveu-ho altre cop aviat.", + "StartupEmbyServerIsLoading": "El servidor de Jellyfin s'està carregant. Proveu de nou en una estona.", "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", "SubtitleDownloadFailureFromForItem": "Els subtítols per a {1} no s'han pogut baixar de {0}", "Sync": "Sincronitzar", @@ -80,41 +80,41 @@ "TvShows": "Sèries de TV", "User": "Usuari", "UserCreatedWithName": "S'ha creat l'usuari {0}", - "UserDeletedWithName": "L'usuari {0} ha estat eliminat", + "UserDeletedWithName": "S'ha eliminat l'usuari {0}", "UserDownloadingItemWithValues": "{0} està descarregant {1}", - "UserLockedOutWithName": "L'usuari {0} ha sigut expulsat", + "UserLockedOutWithName": "S'ha expulsat a l'usuari {0}", "UserOfflineFromDevice": "{0} s'ha desconnectat de {1}", "UserOnlineFromDevice": "{0} està connectat des de {1}", - "UserPasswordChangedWithName": "La contrasenya ha estat canviada per a l'usuari {0}", + "UserPasswordChangedWithName": "S'ha canviat la contrasenya per a l'usuari {0}", "UserPolicyUpdatedWithName": "La política d'usuari s'ha actualitzat per a {0}", - "UserStartedPlayingItemWithValues": "{0} ha començat a reproduir {1}", - "UserStoppedPlayingItemWithValues": "{0} ha parat de reproduir {1}", - "ValueHasBeenAddedToLibrary": "{0} ha sigut afegit a la teva biblioteca", + "UserStartedPlayingItemWithValues": "{0} ha començat a reproduir {1} a {2}", + "UserStoppedPlayingItemWithValues": "{0} ha parat de reproduir {1} a {2}", + "ValueHasBeenAddedToLibrary": "S'ha afegit {0} a la teva biblioteca", "ValueSpecialEpisodeName": "Especial - {0}", "VersionNumber": "Versió {0}", "TaskDownloadMissingSubtitlesDescription": "Cerca a internet els subtítols que faltin a partir de la configuració de metadades.", "TaskDownloadMissingSubtitles": "Descarrega els subtítols que faltin", - "TaskRefreshChannelsDescription": "Actualitza la informació dels canals d'internet.", + "TaskRefreshChannelsDescription": "Actualitza la informació dels canals per internet.", "TaskRefreshChannels": "Actualitza els canals", "TaskCleanTranscodeDescription": "Elimina els arxius de transcodificacions que tinguin més d'un dia.", "TaskCleanTranscode": "Neteja les transcodificacions", - "TaskUpdatePluginsDescription": "Actualitza els connectors que estan configurats per a actualitzar-se automàticament.", - "TaskUpdatePlugins": "Actualitza els connectors", - "TaskRefreshPeopleDescription": "Actualitza les metadades dels actors i directors de la teva mediateca.", + "TaskUpdatePluginsDescription": "Actualitza els complements que estan configurats per a actualitzar-se automàticament.", + "TaskUpdatePlugins": "Actualitza els complements", + "TaskRefreshPeopleDescription": "Actualitza les metadades dels actors i directors de la teva biblioteca de mitjans.", "TaskRefreshPeople": "Actualitza les persones", "TaskCleanLogsDescription": "Esborra els logs que tinguin més de {0} dies.", "TaskCleanLogs": "Neteja els registres", - "TaskRefreshLibraryDescription": "Escaneja la mediateca buscant fitxers nous i refresca les metadades.", + "TaskRefreshLibraryDescription": "Escaneja la biblioteca de mitjans buscant fitxers nous i refresca les metadades.", "TaskRefreshLibrary": "Escaneja la biblioteca de mitjans", "TaskRefreshChapterImagesDescription": "Crea les miniatures dels vídeos que tinguin capítols.", "TaskRefreshChapterImages": "Extreure les imatges dels capítols", - "TaskCleanCacheDescription": "Elimina els arxius temporals que ja no són necessaris per al servidor.", - "TaskCleanCache": "Elimina arxius temporals", - "TasksChannelsCategory": "Canals d'internet", - "TasksApplicationCategory": "Aplicació", + "TaskCleanCacheDescription": "Elimina la memòria cau no necessària per al servidor.", + "TaskCleanCache": "Elimina la memòria cau", + "TasksChannelsCategory": "Canals per internet", + "TasksApplicationCategory": "Aplicatiu", "TasksLibraryCategory": "Biblioteca", "TasksMaintenanceCategory": "Manteniment", - "TaskCleanActivityLogDescription": "Eliminat entrades del registre d'activitats mes antigues que l'antiguitat configurada.", + "TaskCleanActivityLogDescription": "Eliminades les entrades del registre d'activitats més antigues que l'antiguitat configurada.", "TaskCleanActivityLog": "Buidar el registre d'activitat", "Undefined": "Indefinit", "Forced": "Forçat", @@ -128,9 +128,13 @@ "TaskRefreshTrickplayImages": "Generar miniatures de línia de temps", "TaskRefreshTrickplayImagesDescription": "Crear miniatures de línia de temps per vídeos en les biblioteques habilitades.", "TaskCleanCollectionsAndPlaylistsDescription": "Esborra elements de col·leccions i llistes de reproducció que ja no existeixen.", - "TaskCleanCollectionsAndPlaylists": "Neteja col·leccions i llistes de reproducció", - "TaskAudioNormalization": "Normalització d'Àudio", - "TaskAudioNormalizationDescription": "Escaneja arxius per dades de normalització d'àudio.", - "TaskDownloadMissingLyricsDescription": "Baixar lletres de les cançons", - "TaskDownloadMissingLyrics": "Baixar lletres que falten" + "TaskCleanCollectionsAndPlaylists": "Neteja les col·leccions i llistes de reproducció", + "TaskAudioNormalization": "Estabilització d'Àudio", + "TaskAudioNormalizationDescription": "Escaneja arxius per dades d'estabilització d'àudio.", + "TaskDownloadMissingLyricsDescription": "Baixar les lletres de les cançons", + "TaskDownloadMissingLyrics": "Baixar les lletres que falten", + "TaskExtractMediaSegments": "Escaneig de segments multimèdia", + "TaskExtractMediaSegmentsDescription": "Extreu o obté segments multimèdia usant els connectors MediaSegment activats.", + "TaskMoveTrickplayImages": "Migra la ubicació de la imatge de Trickplay", + "TaskMoveTrickplayImagesDescription": "Mou els fitxers trickplay existents segons la configuració de la biblioteca." } diff --git a/Emby.Server.Implementations/Localization/Core/da.json b/Emby.Server.Implementations/Localization/Core/da.json index 121a0eba8f..d43d4097f9 100644 --- a/Emby.Server.Implementations/Localization/Core/da.json +++ b/Emby.Server.Implementations/Localization/Core/da.json @@ -1,5 +1,5 @@ { - "Albums": "Album", + "Albums": "Albummer", "AppDeviceValues": "App: {0}, Enhed: {1}", "Application": "Applikation", "Artists": "Kunstnere", @@ -72,7 +72,7 @@ "ServerNameNeedsToBeRestarted": "{0} skal genstartes", "Shows": "Serier", "Songs": "Sange", - "StartupEmbyServerIsLoading": "Jellyfin Server er i gang med at starte. Forsøg igen om et øjeblik.", + "StartupEmbyServerIsLoading": "Jellyfin er i gang med at starte. Prøv igen om et øjeblik.", "SubtitleDownloadFailureForItem": "Fejlet i download af undertekster for {0}", "SubtitleDownloadFailureFromForItem": "Undertekster kunne ikke hentes fra {0} til {1}", "Sync": "Synkroniser", @@ -93,13 +93,13 @@ "ValueSpecialEpisodeName": "Special - {0}", "VersionNumber": "Version {0}", "TaskDownloadMissingSubtitlesDescription": "Søger på internettet efter manglende undertekster baseret på metadata-konfigurationen.", - "TaskDownloadMissingSubtitles": "Hentede medie mangler undertekster", + "TaskDownloadMissingSubtitles": "Hent manglende undertekster", "TaskUpdatePluginsDescription": "Henter og installerer opdateringer for plugins, som er konfigurerede til at blive opdateret automatisk.", - "TaskUpdatePlugins": "Opdater Plugins", + "TaskUpdatePlugins": "Opdater plugins", "TaskCleanLogsDescription": "Sletter log-filer som er mere end {0} dage gamle.", - "TaskCleanLogs": "Ryd Log-mappe", + "TaskCleanLogs": "Ryd log-mappe", "TaskRefreshLibraryDescription": "Scanner dit mediebibliotek for nye filer og opdateret metadata.", - "TaskRefreshLibrary": "Scan Mediebibliotek", + "TaskRefreshLibrary": "Scan mediebibliotek", "TaskCleanCacheDescription": "Sletter cache-filer som systemet ikke længere bruger.", "TaskCleanCache": "Ryd cache-mappe", "TasksChannelsCategory": "Internetkanaler", @@ -108,33 +108,33 @@ "TasksMaintenanceCategory": "Vedligeholdelse", "TaskRefreshChapterImages": "Udtræk kapitelbilleder", "TaskRefreshChapterImagesDescription": "Laver miniaturebilleder for videoer, der har kapitler.", - "TaskRefreshChannelsDescription": "Opdaterer information for internetkanal.", - "TaskRefreshChannels": "Opdater Kanaler", - "TaskCleanTranscodeDescription": "Fjerner transcode-filer, som er mere end 1 dag gammel.", - "TaskCleanTranscode": "Tøm Transcode-mappen", - "TaskRefreshPeople": "Opdater Personer", + "TaskRefreshChannelsDescription": "Opdaterer information for internetkanaler.", + "TaskRefreshChannels": "Opdater kanaler", + "TaskCleanTranscodeDescription": "Fjerner omkodningsfiler, som er mere end 1 dag gamle.", + "TaskCleanTranscode": "Tøm omkodningsmappen", + "TaskRefreshPeople": "Opdater 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", + "TaskCleanActivityLog": "Ryd aktivitetslog", "Undefined": "Udefineret", "Forced": "Tvunget", "Default": "Standard", - "TaskOptimizeDatabaseDescription": "Komprimerer databasen og frigør plads. Denne handling køres efter at have scannet mediebiblioteket, eller efter at have lavet ændringer til databasen, for at højne ydeevnen.", - "TaskOptimizeDatabase": "Optimér database", - "TaskKeyframeExtractorDescription": "Udtrækker billeder fra videofiler for at lave mere præcise HLS-playlister. Denne opgave kan tage lang tid.", - "TaskKeyframeExtractor": "Udtræk af nøglebillede", + "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", + "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", "HearingImpaired": "Hørehæmmet", - "TaskRefreshTrickplayImages": "Generér Trickplay Billeder", - "TaskRefreshTrickplayImagesDescription": "Laver trickplay forhåndsvisninger for videoer i aktiverede biblioteker.", + "TaskRefreshTrickplayImages": "Generer trickplay-billeder", + "TaskRefreshTrickplayImagesDescription": "Laver trickplay-billeder for videoer i aktiverede biblioteker.", "TaskCleanCollectionsAndPlaylists": "Ryd op i samlinger og afspilningslister", "TaskCleanCollectionsAndPlaylistsDescription": "Fjerner elementer fra samlinger og afspilningslister der ikke eksisterer længere.", - "TaskAudioNormalizationDescription": "Skanner filer for data vedrørende audio-normalisering.", - "TaskAudioNormalization": "Audio-normalisering", - "TaskDownloadMissingLyricsDescription": "Hentede sange mangler sangtekster", - "TaskDownloadMissingLyrics": "Hentede medie mangler sangtekster", - "TaskExtractMediaSegments": "Scan mediesegment", - "TaskMoveTrickplayImages": "Migrer billedelokation for Trickplay", - "TaskMoveTrickplayImagesDescription": "Flyt eksisterende trickplay-filer jævnfør biblioteksindstillilnger.", - "TaskExtractMediaSegmentsDescription": "Ekstraherer eller henter mediesegmenter fra plugins som understøtter MediaSegment." + "TaskAudioNormalizationDescription": "Skanner filer for data vedrørende lydnormalisering.", + "TaskAudioNormalization": "Lydnormalisering", + "TaskDownloadMissingLyricsDescription": "Søger på internettet efter manglende sangtekster baseret på metadata-konfigurationen", + "TaskDownloadMissingLyrics": "Hent manglende sangtekster", + "TaskExtractMediaSegments": "Scan for mediesegmenter", + "TaskMoveTrickplayImages": "Migrer billedelokationer for trickplay-billeder", + "TaskMoveTrickplayImagesDescription": "Flyt eksisterende trickplay-billeder jævnfør biblioteksindstillinger.", + "TaskExtractMediaSegmentsDescription": "Udtrækker eller henter mediesegmenter fra plugins som understøtter MediaSegment." } diff --git a/Emby.Server.Implementations/Localization/Core/de.json b/Emby.Server.Implementations/Localization/Core/de.json index 51c9e87d5a..f5ae43bb5b 100644 --- a/Emby.Server.Implementations/Localization/Core/de.json +++ b/Emby.Server.Implementations/Localization/Core/de.json @@ -5,7 +5,7 @@ "Artists": "Interpreten", "AuthenticationSucceededWithUserName": "{0} erfolgreich authentifiziert", "Books": "Bücher", - "CameraImageUploadedFrom": "Ein neues Kamerafoto wurde von {0} hochgeladen", + "CameraImageUploadedFrom": "Ein neues Kamerabild wurde von {0} hochgeladen", "Channels": "Kanäle", "ChapterNameValue": "Kapitel {0}", "Collections": "Sammlungen", @@ -18,7 +18,7 @@ "HeaderAlbumArtists": "Album-Interpreten", "HeaderContinueWatching": "Weiterschauen", "HeaderFavoriteAlbums": "Lieblingsalben", - "HeaderFavoriteArtists": "Lieblings-Interpreten", + "HeaderFavoriteArtists": "Lieblingsinterpreten", "HeaderFavoriteEpisodes": "Lieblingsepisoden", "HeaderFavoriteShows": "Lieblingsserien", "HeaderFavoriteSongs": "Lieblingslieder", @@ -43,7 +43,7 @@ "NameInstallFailed": "Installation von {0} fehlgeschlagen", "NameSeasonNumber": "Staffel {0}", "NameSeasonUnknown": "Staffel unbekannt", - "NewVersionIsAvailable": "Eine neue Version von Jellyfin-Server steht zum Download bereit.", + "NewVersionIsAvailable": "Eine neue Jellyfin-Serverversion steht zum Download bereit.", "NotificationOptionApplicationUpdateAvailable": "Anwendungsaktualisierung verfügbar", "NotificationOptionApplicationUpdateInstalled": "Anwendungsaktualisierung installiert", "NotificationOptionAudioPlayback": "Audiowiedergabe gestartet", @@ -72,12 +72,12 @@ "ServerNameNeedsToBeRestarted": "{0} muss neu gestartet werden", "Shows": "Serien", "Songs": "Lieder", - "StartupEmbyServerIsLoading": "Jellyfin-Server startet, bitte versuche es gleich noch einmal.", + "StartupEmbyServerIsLoading": "Jellyfin-Server lädt. Bitte versuche es gleich noch einmal.", "SubtitleDownloadFailureForItem": "Download der Untertitel fehlgeschlagen für {0}", "SubtitleDownloadFailureFromForItem": "Untertitel von {0} für {1} konnten nicht heruntergeladen werden", "Sync": "Synchronisation", "System": "System", - "TvShows": "TV-Sendungen", + "TvShows": "TV-Serien", "User": "Benutzer", "UserCreatedWithName": "Benutzer {0} wurde erstellt", "UserDeletedWithName": "Benutzer {0} wurde gelöscht", @@ -92,30 +92,30 @@ "ValueHasBeenAddedToLibrary": "{0} wurde deiner Bibliothek hinzugefügt", "ValueSpecialEpisodeName": "Extra - {0}", "VersionNumber": "Version {0}", - "TaskDownloadMissingSubtitlesDescription": "Suche im Internet basierend auf den Metadaten-Einstellungen nach fehlenden Untertiteln.", - "TaskDownloadMissingSubtitles": "Lade fehlende Untertitel herunter", - "TaskRefreshChannelsDescription": "Aktualisiere Internet-Kanal-Informationen.", - "TaskRefreshChannels": "Aktualisiere Kanäle", - "TaskCleanTranscodeDescription": "Löscht Transkodierdateien, die älter als einen Tag sind.", - "TaskCleanTranscode": "Räume Transkodierungs-Verzeichnis auf", + "TaskDownloadMissingSubtitlesDescription": "Sucht im Internet basierend auf den Metadaten-Einstellungen nach fehlenden Untertiteln.", + "TaskDownloadMissingSubtitles": "Fehlende Untertitel herunterladen", + "TaskRefreshChannelsDescription": "Aktualisiert Internet-Kanal-Informationen.", + "TaskRefreshChannels": "Kanäle aktualisieren", + "TaskCleanTranscodeDescription": "Löscht Transkodierungsdateien, die älter als einen Tag sind.", + "TaskCleanTranscode": "Transkodierungs-Verzeichnis aufräumen", "TaskUpdatePluginsDescription": "Lädt Updates für Plugins herunter, welche für automatische Updates konfiguriert sind und installiert diese.", - "TaskUpdatePlugins": "Aktualisiere Plugins", + "TaskUpdatePlugins": "Plugins aktualisieren", "TaskRefreshPeopleDescription": "Aktualisiert Metadaten für Schauspieler und Regisseure in deinen Bibliotheken.", - "TaskRefreshPeople": "Aktualisiere Personen", + "TaskRefreshPeople": "Personen aktualisieren", "TaskCleanLogsDescription": "Lösche Log-Dateien, die älter als {0} Tage sind.", - "TaskCleanLogs": "Räumt Log-Verzeichnis auf", - "TaskRefreshLibraryDescription": "Scannt alle Bibliotheken nach neu hinzugefügten Dateien und aktualisiere Metadaten.", - "TaskRefreshLibrary": "Scanne Medien-Bibliothek", + "TaskCleanLogs": "Log-Verzeichnis aufräumen", + "TaskRefreshLibraryDescription": "Durchsucht alle Bibliotheken nach neu hinzugefügten Dateien und aktualisiert Metadaten.", + "TaskRefreshLibrary": "Medien-Bibliothek scannen", "TaskRefreshChapterImagesDescription": "Erstellt Vorschaubilder für Videos, die Kapitel besitzen.", - "TaskRefreshChapterImages": "Extrahiere Kapitel-Bilder", - "TaskCleanCacheDescription": "Löscht nicht mehr benötigte Zwischenspeicherdateien.", - "TaskCleanCache": "Leere Zwischenspeicher", + "TaskRefreshChapterImages": "Kapitel-Bilder extrahieren", + "TaskCleanCacheDescription": "Löscht vom System nicht mehr benötigte Zwischenspeicherdateien.", + "TaskCleanCache": "Zwischenspeicher-Verzeichnis aufräumen", "TasksChannelsCategory": "Internet-Kanäle", "TasksApplicationCategory": "Anwendung", "TasksLibraryCategory": "Bibliothek", "TasksMaintenanceCategory": "Wartung", "TaskCleanActivityLogDescription": "Löscht Aktivitätsprotokolleinträge, die älter als das konfigurierte Alter sind.", - "TaskCleanActivityLog": "Aktivitätsprotokoll aufräumen", + "TaskCleanActivityLog": "Aktivitätsprotokolle aufräumen", "Undefined": "Undefiniert", "Forced": "Erzwungen", "Default": "Standard", @@ -128,12 +128,12 @@ "TaskRefreshTrickplayImages": "Trickplay-Bilder generieren", "TaskRefreshTrickplayImagesDescription": "Erstellt ein Trickplay-Vorschauen für Videos in aktivierten Bibliotheken.", "TaskCleanCollectionsAndPlaylists": "Sammlungen und Playlisten aufräumen", - "TaskCleanCollectionsAndPlaylistsDescription": "Lösche nicht mehr vorhandene Einträge aus den Sammlungen und Playlisten.", + "TaskCleanCollectionsAndPlaylistsDescription": "Löscht nicht mehr vorhandene Einträge aus den Sammlungen und Playlisten.", "TaskAudioNormalization": "Audio Normalisierung", "TaskAudioNormalizationDescription": "Durchsucht Dateien nach Audionormalisierungsdaten.", "TaskDownloadMissingLyricsDescription": "Lädt Songtexte herunter", "TaskDownloadMissingLyrics": "Fehlende Songtexte herunterladen", - "TaskExtractMediaSegments": "Scanne Mediensegmente", + "TaskExtractMediaSegments": "Mediensegmente scannen", "TaskExtractMediaSegmentsDescription": "Extrahiert oder empfängt Mediensegmente von Plugins die Mediensegmente nutzen.", "TaskMoveTrickplayImages": "Verzeichnis für Trickplay-Bilder migrieren", "TaskMoveTrickplayImagesDescription": "Trickplay-Bilder werden entsprechend der Bibliothekseinstellungen verschoben." diff --git a/Emby.Server.Implementations/Localization/Core/el.json b/Emby.Server.Implementations/Localization/Core/el.json index 056a2e4755..f3195f0ea0 100644 --- a/Emby.Server.Implementations/Localization/Core/el.json +++ b/Emby.Server.Implementations/Localization/Core/el.json @@ -11,7 +11,7 @@ "Collections": "Συλλογές", "DeviceOfflineWithName": "Ο/Η {0} αποσυνδέθηκε", "DeviceOnlineWithName": "Ο/Η {0} συνδέθηκε", - "FailedLoginAttemptWithUserName": "Αποτυχημένη προσπάθεια σύνδεσης από {0}", + "FailedLoginAttemptWithUserName": "Αποτυχία προσπάθειας σύνδεσης από {0}", "Favorites": "Αγαπημένα", "Folders": "Φάκελοι", "Genres": "Είδη", @@ -27,8 +27,8 @@ "HeaderRecordingGroups": "Ομάδες Ηχογράφησης", "HomeVideos": "Προσωπικά Βίντεο", "Inherit": "Κληρονόμηση", - "ItemAddedWithName": "{0} προστέθηκε στη βιβλιοθήκη", - "ItemRemovedWithName": "{0} διαγράφηκε από τη βιβλιοθήκη", + "ItemAddedWithName": "Το {0} προστέθηκε στη βιβλιοθήκη", + "ItemRemovedWithName": "Το {0} διαγράφτηκε από τη βιβλιοθήκη", "LabelIpAddressValue": "Διεύθυνση IP: {0}", "LabelRunningTimeValue": "Διάρκεια: {0}", "Latest": "Πρόσφατα", @@ -40,7 +40,7 @@ "Movies": "Ταινίες", "Music": "Μουσική", "MusicVideos": "Μουσικά Βίντεο", - "NameInstallFailed": "{0} η εγκατάσταση απέτυχε", + "NameInstallFailed": "H εγκατάσταση του {0} απέτυχε", "NameSeasonNumber": "Κύκλος {0}", "NameSeasonUnknown": "Άγνωστος Κύκλος", "NewVersionIsAvailable": "Μια νέα έκδοση του διακομιστή Jellyfin είναι διαθέσιμη για λήψη.", @@ -54,7 +54,7 @@ "NotificationOptionPluginError": "Αποτυχία του πρόσθετου", "NotificationOptionPluginInstalled": "Το πρόσθετο εγκαταστάθηκε", "NotificationOptionPluginUninstalled": "Το πρόσθετο απεγκαταστάθηκε", - "NotificationOptionPluginUpdateInstalled": "Η αναβάθμιση του πρόσθετου εγκαταστάθηκε", + "NotificationOptionPluginUpdateInstalled": "Η ενημέρωση του πρόσθετου εγκαταστάθηκε", "NotificationOptionServerRestartRequired": "Ο διακομιστής χρειάζεται επανεκκίνηση", "NotificationOptionTaskFailed": "Αποτυχία προγραμματισμένης εργασίας", "NotificationOptionUserLockedOut": "Ο χρήστης αποκλείστηκε", @@ -63,9 +63,9 @@ "Photos": "Φωτογραφίες", "Playlists": "Λίστες αναπαραγωγής", "Plugin": "Πρόσθετο", - "PluginInstalledWithName": "{0} εγκαταστήθηκε", - "PluginUninstalledWithName": "{0} έχει απεγκατασταθεί", - "PluginUpdatedWithName": "{0} έχει αναβαθμιστεί", + "PluginInstalledWithName": "Το {0} εγκαταστάθηκε", + "PluginUninstalledWithName": "Το {0} έχει απεγκατασταθεί", + "PluginUpdatedWithName": "Το {0} ενημερώθηκε", "ProviderValue": "Πάροχος: {0}", "ScheduledTaskFailedWithName": "{0} αποτυχία", "ScheduledTaskStartedWithName": "{0} ξεκίνησε", @@ -96,7 +96,7 @@ "TaskCleanLogsDescription": "Διαγράφει αρχεία καταγραφής που είναι πάνω από {0} ημέρες.", "TaskCleanLogs": "Εκκαθάριση Καταλόγου Καταγραφής", "TaskRefreshLibraryDescription": "Σαρώνει την βιβλιοθήκη πολυμέσων σας για νέα αρχεία και ανανεώνει τα μεταδεδομένα.", - "TaskRefreshLibrary": "Βιβλιοθήκη Σάρωσης Πολυμέσων", + "TaskRefreshLibrary": "Σάρωση Βιβλιοθήκης Πολυμέσων", "TaskRefreshChapterImagesDescription": "Δημιουργεί μικρογραφίες για βίντεο που έχουν κεφάλαια.", "TaskRefreshChapterImages": "Εξαγωγή Εικόνων Κεφαλαίου", "TaskCleanCacheDescription": "Διαγράφει αρχεία προσωρινής μνήμης που δεν χρειάζονται πλέον το σύστημα.", @@ -125,10 +125,16 @@ "TaskKeyframeExtractor": "Εξαγωγέας βασικών καρέ βίντεο", "External": "Εξωτερικό", "HearingImpaired": "Με προβλήματα ακοής", - "TaskRefreshTrickplayImages": "Δημιουργήστε εικόνες Trickplay", + "TaskRefreshTrickplayImages": "Δημιουργία εικόνων Trickplay", "TaskRefreshTrickplayImagesDescription": "Δημιουργεί προεπισκοπήσεις trickplay για βίντεο σε ενεργοποιημένες βιβλιοθήκες.", "TaskAudioNormalization": "Ομοιομορφία ήχου", "TaskAudioNormalizationDescription": "Ανίχνευση αρχείων για δεδομένα ομοιομορφίας ήχου.", "TaskCleanCollectionsAndPlaylists": "Καθαρισμός συλλογών και λιστών αναπαραγωγής", - "TaskCleanCollectionsAndPlaylistsDescription": "Αφαιρούνται στοιχεία από τις συλλογές και τις λίστες αναπαραγωγής που δεν υπάρχουν πλέον." + "TaskCleanCollectionsAndPlaylistsDescription": "Αφαιρούνται στοιχεία από τις συλλογές και τις λίστες αναπαραγωγής που δεν υπάρχουν πλέον.", + "TaskMoveTrickplayImages": "Αλλαγή τοποθεσίας εικόνων Trickplay", + "TaskDownloadMissingLyrics": "Λήψη στίχων που λείπουν", + "TaskMoveTrickplayImagesDescription": "Μετακινεί τα υπάρχοντα αρχεία trickplay σύμφωνα με τις ρυθμίσεις της βιβλιοθήκης.", + "TaskDownloadMissingLyricsDescription": "Κατεβάζει στίχους για τραγούδια", + "TaskExtractMediaSegments": "Σάρωση τμημάτων πολυμέσων", + "TaskExtractMediaSegmentsDescription": "Εξάγει ή βρίσκει τμήματα πολυμέσων από επεκτάσεις που χρησιμοποιούν το MediaSegment." } diff --git a/Emby.Server.Implementations/Localization/Core/eo.json b/Emby.Server.Implementations/Localization/Core/eo.json index 0b595c2caf..42cce1096f 100644 --- a/Emby.Server.Implementations/Localization/Core/eo.json +++ b/Emby.Server.Implementations/Localization/Core/eo.json @@ -122,5 +122,9 @@ "AuthenticationSucceededWithUserName": "{0} sukcese aŭtentikigis", "TaskKeyframeExtractorDescription": "Eltiras ĉefkadrojn el videodosieroj por krei pli precizajn HLS-ludlistojn. Ĉi tiu tasko povas funkcii dum longa tempo.", "TaskKeyframeExtractor": "Eltiri Ĉefkadrojn", - "External": "Ekstera" + "External": "Ekstera", + "TaskAudioNormalizationDescription": "Skanas dosierojn por sonnivelaj normaligaj datumoj.", + "TaskRefreshTrickplayImages": "Generi la bildojn por TrickPlay (Antaŭrigardo rapida antaŭen)", + "TaskAudioNormalization": "Normaligo Sonnivela", + "HearingImpaired": "Surda" } diff --git a/Emby.Server.Implementations/Localization/Core/es-AR.json b/Emby.Server.Implementations/Localization/Core/es-AR.json index f2f657b049..cf31960f9f 100644 --- a/Emby.Server.Implementations/Localization/Core/es-AR.json +++ b/Emby.Server.Implementations/Localization/Core/es-AR.json @@ -15,7 +15,7 @@ "Favorites": "Favoritos", "Folders": "Carpetas", "Genres": "Géneros", - "HeaderAlbumArtists": "Artistas de álbum", + "HeaderAlbumArtists": "Artistas del álbum", "HeaderContinueWatching": "Seguir viendo", "HeaderFavoriteAlbums": "Álbumes favoritos", "HeaderFavoriteArtists": "Artistas favoritos", diff --git a/Emby.Server.Implementations/Localization/Core/es_419.json b/Emby.Server.Implementations/Localization/Core/es_419.json index b458ed4230..2534f37c16 100644 --- a/Emby.Server.Implementations/Localization/Core/es_419.json +++ b/Emby.Server.Implementations/Localization/Core/es_419.json @@ -131,5 +131,9 @@ "TaskAudioNormalizationDescription": "Analiza los archivos para normalizar el audio.", "TaskCleanCollectionsAndPlaylists": "Limpieza de colecciones y listas de reproducción", "TaskDownloadMissingLyrics": "Descargar letra faltante", - "TaskDownloadMissingLyricsDescription": "Descarga letras de canciones" + "TaskDownloadMissingLyricsDescription": "Descarga letras de canciones", + "TaskExtractMediaSegmentsDescription": "Extrae u obtiene segmentos de medios de complementos habilitados para MediaSegment.", + "TaskMoveTrickplayImagesDescription": "Mueve archivos de trickplay existentes según la configuración de la biblioteca.", + "TaskExtractMediaSegments": "Escaneo de segmentos de medios", + "TaskMoveTrickplayImages": "Migrar la ubicación de la imagen de Trickplay" } diff --git a/Emby.Server.Implementations/Localization/Core/eu.json b/Emby.Server.Implementations/Localization/Core/eu.json index 114c76c54c..4df4b90d3a 100644 --- a/Emby.Server.Implementations/Localization/Core/eu.json +++ b/Emby.Server.Implementations/Localization/Core/eu.json @@ -19,25 +19,25 @@ "Artists": "Artistak", "Albums": "Albumak", "TaskOptimizeDatabase": "Datu basea optimizatu", - "TaskDownloadMissingSubtitlesDescription": "Metadataren konfigurazioan oinarrituta falta diren azpitituluak bilatzen ditu interneten.", + "TaskDownloadMissingSubtitlesDescription": "Falta diren azpitituluak bilatzen ditu interneten metadatuen konfigurazioaren arabera.", "TaskDownloadMissingSubtitles": "Falta diren azpitituluak deskargatu", "TaskRefreshChannelsDescription": "Internet kanalen informazioa eguneratu.", "TaskRefreshChannels": "Kanalak eguneratu", - "TaskCleanTranscodeDescription": "Egun bat baino zaharragoak diren transcode fitxategiak ezabatzen ditu.", - "TaskCleanTranscode": "Transcode direktorioa garbitu", - "TaskUpdatePluginsDescription": "Automatikoki eguneratzeko konfiguratutako pluginen eguneraketak deskargatu eta instalatzen ditu.", + "TaskCleanTranscodeDescription": "Egun bat baino zaharragoak diren transkodifikazio fitxategiak ezabatzen ditu.", + "TaskCleanTranscode": "Transkodifikazio direktorioa garbitu", + "TaskUpdatePluginsDescription": "Automatikoki deskargatu eta instalatu eguneraketak konfiguratutako pluginetarako.", "TaskUpdatePlugins": "Pluginak eguneratu", - "TaskRefreshPeopleDescription": "Zure liburutegiko aktore eta zuzendarien metadata eguneratzen du.", + "TaskRefreshPeopleDescription": "Zure liburutegiko aktore eta zuzendarien metadatuak eguneratzen ditu.", "TaskRefreshPeople": "Jendea eguneratu", "TaskCleanLogsDescription": "{0} egun baino zaharragoak diren log fitxategiak ezabatzen ditu.", "TaskCleanLogs": "Log direktorioa garbitu", - "TaskRefreshLibraryDescription": "Zure multimedia liburutegia eskaneatzen du fitxategi berriak eta metadatak eguneratzeko.", - "TaskRefreshLibrary": "Multimedia Liburutegia eskaneatu", + "TaskRefreshLibraryDescription": "Zure multimedia liburutegia eskaneatzen du fitxategi berriak eta metadatuak eguneratzeko.", + "TaskRefreshLibrary": "Multimedia liburutegia eskaneatu", "TaskRefreshChapterImagesDescription": "Kapituluak dituzten bideoen miniaturak sortzen ditu.", "TaskRefreshChapterImages": "Kapituluen irudiak erauzi", "TaskCleanCacheDescription": "Sistemak behar ez dituen cache fitxategiak ezabatzen ditu.", - "TaskCleanCache": "Cache Directorioa garbitu", - "TaskCleanActivityLogDescription": "Konfiguratuta data baino zaharragoak diren log-ak ezabatu.", + "TaskCleanCache": "Cache direktorioa garbitu", + "TaskCleanActivityLogDescription": "Konfiguratutako baino zaharragoak diren jarduera-log sarrerak ezabatzen ditu.", "TaskCleanActivityLog": "Erabilera Log-a garbitu", "TasksChannelsCategory": "Internet Kanalak", "TasksApplicationCategory": "Aplikazioa", @@ -45,22 +45,22 @@ "TasksMaintenanceCategory": "Mantenua", "VersionNumber": "Bertsioa {0}", "ValueHasBeenAddedToLibrary": "{0} zure multimedia liburutegian gehitu da", - "UserStoppedPlayingItemWithValues": "{0}-ek {1} ikusteaz bukatu du {2}-(a)n", - "UserStartedPlayingItemWithValues": "{0} {1} ikusten ari da {2}-(a)n", - "UserPolicyUpdatedWithName": "{0} Erabiltzailearen politikak aldatu dira", - "UserPasswordChangedWithName": "{0} Erabiltzailearen pasahitza aldatu da", - "UserOnlineFromDevice": "{0} online dago {1}-tik", - "UserOfflineFromDevice": "{0} {1}-tik deskonektatu da", - "UserLockedOutWithName": "{0} Erabiltzailea blokeatu da", - "UserDownloadingItemWithValues": "{1} {0}-tik deskargatzen", + "UserStoppedPlayingItemWithValues": "{0} {1} ikusten bukatu du {2}-(e)n", + "UserStartedPlayingItemWithValues": "{0} {1} ikusten ari da {2}-(e)n", + "UserPolicyUpdatedWithName": "{0} erabiltzailearen politikak aldatu dira", + "UserPasswordChangedWithName": "{0} erabiltzailearen pasahitza aldatu da", + "UserOnlineFromDevice": "{0} online dago {1}-(e)tik", + "UserOfflineFromDevice": "{0} {1}-(e)tik deskonektatu da", + "UserLockedOutWithName": "{0} erabiltzailea blokeatu da", + "UserDownloadingItemWithValues": "{0} {1} deskargatzen ari da", "UserDeletedWithName": "{0} Erabiltzailea ezabatu da", "UserCreatedWithName": "{0} Erabiltzailea sortu da", "User": "Erabiltzailea", "Undefined": "Ezezaguna", - "TvShows": "TB showak", + "TvShows": "TB serieak", "System": "Sistema", - "SubtitleDownloadFailureFromForItem": "{1}-en azpitutuluak {0} deskargatzean huts egin du", - "StartupEmbyServerIsLoading": "Jellyfin zerbitzaria kargatzen. Saiatu berriro beranduxeago.", + "SubtitleDownloadFailureFromForItem": "{1}-en azpitutuluak {0}-tik deskargatzeak huts egin du", + "StartupEmbyServerIsLoading": "Jellyfin zerbitzaria kargatzen. Saiatu berriro beranduago.", "ServerNameNeedsToBeRestarted": "{0} berrabiarazi behar da", "ScheduledTaskStartedWithName": "{0} hasi da", "ScheduledTaskFailedWithName": "{0} huts egin du", @@ -89,26 +89,26 @@ "NameSeasonNumber": "{0} Denboraldia", "NameInstallFailed": "{0} instalazioak huts egin du", "Music": "Musika", - "MixedContent": "Denetariko edukia", + "MixedContent": "Eduki mistoa", "MessageServerConfigurationUpdated": "Zerbitzariaren konfigurazioa eguneratu da", - "MessageNamedServerConfigurationUpdatedWithValue": "Zerbitzariaren konfigurazio {0} atala eguneratu da", + "MessageNamedServerConfigurationUpdatedWithValue": "Zerbitzariaren {0} konfigurazio atala eguneratu da", "MessageApplicationUpdatedTo": "Jellyfin zerbitzaria {0}-ra eguneratu da", "MessageApplicationUpdated": "Jellyfin zerbitzaria eguneratu da", "Latest": "Azkena", - "LabelRunningTimeValue": "Denbora martxan: {0}", + "LabelRunningTimeValue": "Iraupena: {0}", "LabelIpAddressValue": "IP helbidea: {0}", - "ItemRemovedWithName": "{0} liburutegitik ezabatu da", + "ItemRemovedWithName": "{0} liburutegitik kendu da", "ItemAddedWithName": "{0} liburutegira gehitu da", "HomeVideos": "Etxeko bideoak", - "HeaderNextUp": "Nobedadeak", + "HeaderNextUp": "Hurrengoa", "HeaderLiveTV": "Zuzeneko TB", "HeaderFavoriteSongs": "Gogoko abestiak", - "HeaderFavoriteShows": "Gogoko showak", + "HeaderFavoriteShows": "Gogoko serieak", "HeaderFavoriteEpisodes": "Gogoko atalak", "HeaderFavoriteArtists": "Gogoko artistak", "HeaderFavoriteAlbums": "Gogoko albumak", "Forced": "Behartuta", - "FailedLoginAttemptWithUserName": "Login egiten akatsa, saiatu hemen {0}", + "FailedLoginAttemptWithUserName": "{0}-tik saioa hasteak huts egin du", "External": "Kanpokoa", "DeviceOnlineWithName": "{0} konektatu da", "DeviceOfflineWithName": "{0} deskonektatu da", @@ -117,13 +117,23 @@ "AuthenticationSucceededWithUserName": "{0} ongi autentifikatu da", "Application": "Aplikazioa", "AppDeviceValues": "App: {0}, Gailua: {1}", - "HearingImpaired": "Entzunaldia aldatua", + "HearingImpaired": "Entzumen urritasuna", "ProviderValue": "Hornitzailea: {0}", "TaskKeyframeExtractorDescription": "Bideo fitxategietako fotograma gakoak ateratzen ditu HLS erreprodukzio-zerrenda zehatzagoak sortzeko. Zeregin honek denbora asko iraun dezake.", "HeaderRecordingGroups": "Grabaketa taldeak", "Inherit": "Oinordetu", "TaskOptimizeDatabaseDescription": "Datu-basea trinkotu eta bertatik espazioa askatzen du. Liburutegia eskaneatu ondoren edo datu-basean aldaketak egin ondoren ataza hau exekutatzeak errendimendua hobetu lezake.", "TaskKeyframeExtractor": "Fotograma gakoen erauzgailua", - "TaskRefreshTrickplayImages": "\"Trickplay Irudiak Sortu", - "TaskRefreshTrickplayImagesDescription": "Bideoentzako trickplay aurrebistak sortzen ditu gaitutako liburutegietan." + "TaskRefreshTrickplayImages": "Trickplay irudiak sortu", + "TaskRefreshTrickplayImagesDescription": "Bideoentzako trickplay aurrebistak sortzen ditu gaitutako liburutegietan.", + "TaskAudioNormalization": "Audio normalizazioa", + "TaskDownloadMissingLyrics": "Deskargatu falta diren letrak", + "TaskDownloadMissingLyricsDescription": "Deskargatu abestientzako letrak", + "TaskExtractMediaSegments": "Multimedia segmentuen eskaneoa", + "TaskCleanCollectionsAndPlaylistsDescription": "Jada existitzen ez diren bildumak eta erreprodukzio-zerrendak kentzen ditu.", + "TaskCleanCollectionsAndPlaylists": "Garbitu bildumak eta erreprodukzio-zerrendak", + "TaskExtractMediaSegmentsDescription": "Media segmentuak atera edo lortzen ditu MediaSegment gaituta duten pluginetik.", + "TaskMoveTrickplayImages": "Aldatu Trickplay irudien kokalekua", + "TaskMoveTrickplayImagesDescription": "Lehendik dauden trickplay fitxategiak liburutegiaren ezarpenen arabera mugitzen dira.", + "TaskAudioNormalizationDescription": "Audio normalizazio datuak lortzeko fitxategiak eskaneatzen ditu." } diff --git a/Emby.Server.Implementations/Localization/Core/fi.json b/Emby.Server.Implementations/Localization/Core/fi.json index 8a88cf28e9..c9f580cd5f 100644 --- a/Emby.Server.Implementations/Localization/Core/fi.json +++ b/Emby.Server.Implementations/Localization/Core/fi.json @@ -130,5 +130,10 @@ "TaskCleanCollectionsAndPlaylists": "Puhdista kokoelmat ja soittolistat", "TaskAudioNormalization": "Äänenvoimakkuuden normalisointi", "TaskAudioNormalizationDescription": "Etsii tiedostoista äänenvoimakkuuden normalisointitietoja.", - "TaskDownloadMissingLyrics": "Lataa puuttuva lyriikka" + "TaskDownloadMissingLyrics": "Lataa puuttuva lyriikka", + "TaskExtractMediaSegments": "Mediasegmentin skannaus", + "TaskDownloadMissingLyricsDescription": "Ladataan sanoituksia", + "TaskExtractMediaSegmentsDescription": "Poimii tai hankkii mediasegmenttejä MediaSegment-yhteensopivista laajennuksista.", + "TaskMoveTrickplayImages": "Siirrä Trickplay-kuvien sijainti", + "TaskMoveTrickplayImagesDescription": "Siirtää olemassa olevia trickplay-tiedostoja kirjaston asetusten mukaan." } diff --git a/Emby.Server.Implementations/Localization/Core/fr-CA.json b/Emby.Server.Implementations/Localization/Core/fr-CA.json index 68ab4b617e..a10912f011 100644 --- a/Emby.Server.Implementations/Localization/Core/fr-CA.json +++ b/Emby.Server.Implementations/Localization/Core/fr-CA.json @@ -135,5 +135,6 @@ "TaskDownloadMissingLyricsDescription": "Téléchargement des paroles des chansons", "TaskMoveTrickplayImagesDescription": "Déplace les fichiers trickplay existants en fonction des paramètres de la bibliothèque.", "TaskDownloadMissingLyrics": "Télécharger les paroles des chansons manquantes", - "TaskMoveTrickplayImages": "Changer l'emplacement des images Trickplay" + "TaskMoveTrickplayImages": "Changer l'emplacement des images Trickplay", + "TaskExtractMediaSegmentsDescription": "Extrait ou obtient des segments de média à partir des plugins compatibles avec MediaSegment." } diff --git a/Emby.Server.Implementations/Localization/Core/ga.json b/Emby.Server.Implementations/Localization/Core/ga.json index b511ed6ba9..b8e787c20a 100644 --- a/Emby.Server.Implementations/Localization/Core/ga.json +++ b/Emby.Server.Implementations/Localization/Core/ga.json @@ -1,16 +1,139 @@ { "Albums": "Albaim", - "Artists": "Ealaíontóir", - "AuthenticationSucceededWithUserName": "{0} fíordheimhnithe", - "Books": "leabhair", - "CameraImageUploadedFrom": "Tá íomhá ceamara nua uaslódáilte ó {0}", + "Artists": "Ealaíontóirí", + "AuthenticationSucceededWithUserName": "D'éirigh le fíordheimhniú {0}", + "Books": "Leabhair", + "CameraImageUploadedFrom": "Uaslódáladh íomhá ceamara nua ó {0}", "Channels": "Cainéil", "ChapterNameValue": "Caibidil {0}", "Collections": "Bailiúcháin", - "Default": "Mainneachtain", - "DeviceOfflineWithName": "scoireadh {0}", - "DeviceOnlineWithName": "{0} ceangailte", - "External": "Forimeallach", - "FailedLoginAttemptWithUserName": "Iarracht ar theip ar fhíordheimhniú ó {0}", - "Favorites": "Ceanáin" + "Default": "Réamhshocrú", + "DeviceOfflineWithName": "Tá {0} dícheangailte", + "DeviceOnlineWithName": "Tá {0} nasctha", + "External": "Seachtrach", + "FailedLoginAttemptWithUserName": "Theip ar iarracht logáil isteach ó {0}", + "Favorites": "Ceanáin", + "TaskExtractMediaSegments": "Scanadh Deighleog na Meán", + "TaskMoveTrickplayImages": "Imirce Suíomh Íomhá Trickplay", + "TaskDownloadMissingLyrics": "Íosluchtaigh liricí ar iarraidh", + "TaskKeyframeExtractor": "Keyframe Eastarraingteoir", + "TaskAudioNormalization": "Normalú Fuaime", + "TaskAudioNormalizationDescription": "Scanann comhaid le haghaidh sonraí normalaithe fuaime.", + "TaskRefreshLibraryDescription": "Déanann sé do leabharlann meán a scanadh le haghaidh comhaid nua agus athnuachana meiteashonraí.", + "TaskCleanLogs": "Eolaire Logchomhad Glan", + "TaskCleanLogsDescription": "Scriostar comhaid loga atá níos mó ná {0} lá d'aois.", + "TaskRefreshPeopleDescription": "Nuashonraítear meiteashonraí d’aisteoirí agus stiúrthóirí i do leabharlann meán.", + "TaskRefreshTrickplayImages": "Gin Íomhánna Trickplay", + "TaskRefreshTrickplayImagesDescription": "Cruthaíonn sé réamhamhairc trickplay le haghaidh físeáin i leabharlanna cumasaithe.", + "TaskRefreshChannels": "Cainéil Athnuaigh", + "TaskRefreshChannelsDescription": "Athnuachan eolas faoi chainéil idirlín.", + "TaskOptimizeDatabase": "Bunachar sonraí a bharrfheabhsú", + "TaskKeyframeExtractorDescription": "Baintear eochairfhrámaí as comhaid físe chun seinmliostaí HLS níos cruinne a chruthú. Féadfaidh an tasc seo a bheith ar siúl ar feadh i bhfad.", + "TaskCleanCollectionsAndPlaylistsDescription": "Baintear míreanna as bailiúcháin agus seinmliostaí nach ann dóibh a thuilleadh.", + "TaskDownloadMissingLyricsDescription": "Íosluchtaigh liricí do na hamhráin", + "TaskUpdatePluginsDescription": "Íoslódálann agus suiteálann nuashonruithe do bhreiseáin atá cumraithe le nuashonrú go huathoibríoch.", + "TaskDownloadMissingSubtitlesDescription": "Déanann sé cuardach ar an idirlíon le haghaidh fotheidil atá ar iarraidh bunaithe ar chumraíocht meiteashonraí.", + "TaskExtractMediaSegmentsDescription": "Sliocht nó faigheann codanna meán ó bhreiseáin chumasaithe MediaSegment.", + "TaskCleanCollectionsAndPlaylists": "Glan suas bailiúcháin agus seinmliostaí", + "TaskOptimizeDatabaseDescription": "Comhdhlúthaíonn bunachar sonraí agus gearrtar spás saor in aisce. Má ritheann tú an tasc seo tar éis scanadh a dhéanamh ar an leabharlann nó athruithe eile a dhéanamh a thugann le tuiscint gur cheart go bhfeabhsófaí an fheidhmíocht.", + "TaskMoveTrickplayImagesDescription": "Bogtar comhaid trickplay atá ann cheana de réir socruithe na leabharlainne.", + "AppDeviceValues": "Aip: {0}, Gléas: {1}", + "Application": "Feidhmchlár", + "Folders": "Fillteáin", + "Forced": "Éigean", + "Genres": "Seánraí", + "HeaderAlbumArtists": "Ealaíontóirí albam", + "HeaderContinueWatching": "Leanúint ar aghaidh ag Breathnú", + "HeaderFavoriteAlbums": "Albam is fearr leat", + "HeaderFavoriteArtists": "Ealaíontóirí is Fearr", + "HeaderFavoriteEpisodes": "Eipeasóid is fearr leat", + "HeaderFavoriteShows": "Seónna is Fearr", + "HeaderFavoriteSongs": "Amhráin is fearr leat", + "HeaderLiveTV": "Teilifís beo", + "HeaderNextUp": "Ar Aghaidh Suas", + "HeaderRecordingGroups": "Grúpaí Taifeadta", + "HearingImpaired": "Lag éisteachta", + "HomeVideos": "Físeáin Baile", + "Inherit": "Oidhreacht", + "ItemAddedWithName": "Cuireadh {0} leis an leabharlann", + "ItemRemovedWithName": "Baineadh {0} den leabharlann", + "LabelIpAddressValue": "Seoladh IP: {0}", + "LabelRunningTimeValue": "Am rite: {0}", + "Latest": "Is déanaí", + "MessageApplicationUpdated": "Tá Freastalaí Jellyfin nuashonraithe", + "MessageApplicationUpdatedTo": "Nuashonraíodh Freastalaí Jellyfin go {0}", + "MessageNamedServerConfigurationUpdatedWithValue": "Nuashonraíodh an chuid cumraíochta freastalaí {0}", + "MessageServerConfigurationUpdated": "Nuashonraíodh cumraíocht an fhreastalaí", + "MixedContent": "Ábhar measctha", + "Movies": "Scannáin", + "Music": "Ceol", + "MusicVideos": "Físeáin Ceoil", + "NameInstallFailed": "Theip ar shuiteáil {0}", + "NameSeasonNumber": "Séasúr {0}", + "NameSeasonUnknown": "Séasúr Anaithnid", + "NewVersionIsAvailable": "Tá leagan nua de Jellyfin Server ar fáil le híoslódáil.", + "NotificationOptionApplicationUpdateAvailable": "Nuashonrú feidhmchláir ar fáil", + "NotificationOptionApplicationUpdateInstalled": "Nuashonrú feidhmchláir suiteáilte", + "NotificationOptionAudioPlayback": "Cuireadh tús le hathsheinm fuaime", + "NotificationOptionAudioPlaybackStopped": "Cuireadh deireadh le hathsheinm fuaime", + "NotificationOptionCameraImageUploaded": "Íosluchtaigh grianghraf ceamara", + "NotificationOptionInstallationFailed": "Teip suiteála", + "NotificationOptionNewLibraryContent": "Ábhar nua curtha leis", + "NotificationOptionPluginError": "Teip breiseán", + "NotificationOptionPluginInstalled": "Breiseán suiteáilte", + "NotificationOptionPluginUninstalled": "Breiseán díshuiteáilte", + "NotificationOptionPluginUpdateInstalled": "Nuashonrú breiseán suiteáilte", + "NotificationOptionServerRestartRequired": "Teastaíonn atosú an fhreastalaí", + "NotificationOptionTaskFailed": "Teip tasc sceidealta", + "NotificationOptionUserLockedOut": "Úsáideoir glasáilte amach", + "NotificationOptionVideoPlayback": "Cuireadh tús le hathsheinm físe", + "NotificationOptionVideoPlaybackStopped": "Cuireadh deireadh le hathsheinm físe", + "Photos": "Grianghraif", + "Playlists": "Seinmliostaí", + "Plugin": "Breiseán", + "PluginInstalledWithName": "Suiteáladh {0}", + "PluginUninstalledWithName": "Díshuiteáladh {0}", + "PluginUpdatedWithName": "Nuashonraíodh {0}", + "ProviderValue": "Soláthraí: {0}", + "ScheduledTaskFailedWithName": "Theip ar {0}", + "ScheduledTaskStartedWithName": "Thosaigh {0}", + "ServerNameNeedsToBeRestarted": "Ní mór {0} a atosú", + "Shows": "Seónna", + "Songs": "Amhráin", + "StartupEmbyServerIsLoading": "Tá freastalaí Jellyfin á luchtú. Bain triail eile as gan mhoill.", + "SubtitleDownloadFailureFromForItem": "Theip ar fhotheidil a íoslódáil ó {0} le haghaidh {1}", + "Sync": "Sioncrónaigh", + "System": "Córas", + "TvShows": "Seónna Teilifíse", + "Undefined": "Neamhshainithe", + "User": "Úsáideoir", + "UserCreatedWithName": "Cruthaíodh úsáideoir {0}", + "UserDeletedWithName": "Scriosadh úsáideoir {0}", + "UserDownloadingItemWithValues": "Tá {0} á íoslódáil {1}", + "UserLockedOutWithName": "Tá úsáideoir {0} glasáilte amach", + "UserOfflineFromDevice": "Tá {0} dícheangailte ó {1}", + "UserOnlineFromDevice": "Tá {0} ar líne ó {1}", + "UserPasswordChangedWithName": "Athraíodh pasfhocal don úsáideoir {0}", + "UserPolicyUpdatedWithName": "Nuashonraíodh polasaí úsáideora le haghaidh {0}", + "UserStartedPlayingItemWithValues": "Tá {0} ag seinnt {1} ar {2}", + "UserStoppedPlayingItemWithValues": "Chríochnaigh {0} ag imirt {1} ar {2}", + "ValueHasBeenAddedToLibrary": "Cuireadh {0} le do leabharlann meán", + "ValueSpecialEpisodeName": "Speisialta - {0}", + "VersionNumber": "Leagan {0}", + "TasksMaintenanceCategory": "Cothabháil", + "TasksLibraryCategory": "Leabharlann", + "TasksApplicationCategory": "Feidhmchlár", + "TasksChannelsCategory": "Cainéil Idirlín", + "TaskCleanActivityLog": "Loga Gníomhaíochta Glan", + "TaskCleanActivityLogDescription": "Scrios iontrálacha loga gníomhaíochta atá níos sine ná an aois chumraithe.", + "TaskCleanCache": "Eolaire Taisce Glan", + "TaskCleanCacheDescription": "Scriostar comhaid taisce nach bhfuil ag teastáil ón gcóras a thuilleadh.", + "TaskRefreshChapterImages": "Sliocht Íomhánna Caibidil", + "TaskRefreshChapterImagesDescription": "Cruthaíonn mionsamhlacha le haghaidh físeáin a bhfuil caibidlí acu.", + "TaskRefreshLibrary": "Scan Leabharlann na Meán", + "TaskRefreshPeople": "Daoine Athnuaigh", + "TaskUpdatePlugins": "Nuashonraigh Breiseáin", + "TaskCleanTranscodeDescription": "Scriostar comhaid traschódaithe níos mó ná lá amháin d'aois.", + "TaskCleanTranscode": "Eolaire Transcode Glan", + "TaskDownloadMissingSubtitles": "Íosluchtaigh fotheidil ar iarraidh" } diff --git a/Emby.Server.Implementations/Localization/Core/he.json b/Emby.Server.Implementations/Localization/Core/he.json index af57b1693e..34d5cf0509 100644 --- a/Emby.Server.Implementations/Localization/Core/he.json +++ b/Emby.Server.Implementations/Localization/Core/he.json @@ -16,7 +16,7 @@ "Folders": "תיקיות", "Genres": "ז׳אנרים", "HeaderAlbumArtists": "אמני האלבום", - "HeaderContinueWatching": "להמשיך לצפות", + "HeaderContinueWatching": "המשך צפייה", "HeaderFavoriteAlbums": "אלבומים מועדפים", "HeaderFavoriteArtists": "אמנים מועדפים", "HeaderFavoriteEpisodes": "פרקים מועדפים", @@ -32,8 +32,8 @@ "LabelIpAddressValue": "Ip כתובת: {0}", "LabelRunningTimeValue": "משך צפייה: {0}", "Latest": "אחרון", - "MessageApplicationUpdated": "שרת הJellyfin עודכן", - "MessageApplicationUpdatedTo": "שרת ה־Jellyfin עודכן לגרסה {0}", + "MessageApplicationUpdated": "שרת ג'ליפין עודכן", + "MessageApplicationUpdatedTo": "שרת ג'ליפין עודכן לגרסה {0}", "MessageNamedServerConfigurationUpdatedWithValue": "סעיף הגדרת השרת {0} עודכן", "MessageServerConfigurationUpdated": "תצורת השרת עודכנה", "MixedContent": "תוכן מעורב", @@ -43,7 +43,7 @@ "NameInstallFailed": "התקנת {0} נכשלה", "NameSeasonNumber": "עונה {0}", "NameSeasonUnknown": "עונה לא ידועה", - "NewVersionIsAvailable": "גרסה חדשה של שרת Jellyfin זמינה להורדה.", + "NewVersionIsAvailable": "גרסה חדשה של שרת ג'ליפין זמינה להורדה.", "NotificationOptionApplicationUpdateAvailable": "קיים עדכון זמין ליישום", "NotificationOptionApplicationUpdateInstalled": "עדכון ליישום הותקן", "NotificationOptionAudioPlayback": "ניגון שמע החל", @@ -72,7 +72,7 @@ "ServerNameNeedsToBeRestarted": "{0} דורש הפעלה מחדש", "Shows": "סדרות", "Songs": "שירים", - "StartupEmbyServerIsLoading": "שרת Jellyfin בהליכי טעינה. נא לנסות שנית בהקדם.", + "StartupEmbyServerIsLoading": "שרת ג'ליפין טוען. נא לנסות שוב בקרוב.", "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", "SubtitleDownloadFailureFromForItem": "הורדת כתוביות מ־{0} עבור {1} נכשלה", "Sync": "סנכרון", @@ -133,8 +133,8 @@ "TaskCleanCollectionsAndPlaylists": "מנקה אוספים ורשימות השמעה", "TaskDownloadMissingLyrics": "הורדת מילים חסרות", "TaskDownloadMissingLyricsDescription": "הורדת מילים לשירים", - "TaskMoveTrickplayImages": "מעביר את מיקום תמונות Trickplay", + "TaskMoveTrickplayImages": "העברת מיקום התמונות", "TaskExtractMediaSegments": "סריקת מדיה", "TaskExtractMediaSegmentsDescription": "מחלץ חלקי מדיה מתוספים המאפשרים זאת.", - "TaskMoveTrickplayImagesDescription": "מזיז קבצי trickplay קיימים בהתאם להגדרות הספרייה." + "TaskMoveTrickplayImagesDescription": "הזזת קבצי טריקפליי קיימים בהתאם להגדרות הספרייה." } diff --git a/Emby.Server.Implementations/Localization/Core/hi.json b/Emby.Server.Implementations/Localization/Core/hi.json index 380c08e0d2..813b18ad4b 100644 --- a/Emby.Server.Implementations/Localization/Core/hi.json +++ b/Emby.Server.Implementations/Localization/Core/hi.json @@ -99,7 +99,7 @@ "ValueHasBeenAddedToLibrary": "{0} आपके माध्यम ग्रन्थालय में उपजात हो गया हैं", "TasksLibraryCategory": "संग्रहालय", "TaskOptimizeDatabase": "जानकारी प्रवृद्धि", - "TaskDownloadMissingSubtitles": "असमेत अनुलेख को अवाहरति करें", + "TaskDownloadMissingSubtitles": "लापता अनुलेख डाउनलोड करें", "TaskRefreshLibrary": "माध्यम संग्राहत को छाने", "TaskCleanActivityLog": "क्रियाकलाप लॉग साफ करें", "TasksChannelsCategory": "इंटरनेट प्रणाली", @@ -127,5 +127,7 @@ "TaskRefreshTrickplayImages": "ट्रिकप्लै चित्रों को सृजन करे", "TaskRefreshTrickplayImagesDescription": "नियत संग्रहों में चलचित्रों का ट्रीकप्लै दर्शनों को सृजन करे.", "TaskAudioNormalization": "श्रव्य सामान्यीकरण", - "TaskAudioNormalizationDescription": "श्रव्य सामान्यीकरण के लिए फाइलें अन्वेषण करें" + "TaskAudioNormalizationDescription": "श्रव्य सामान्यीकरण के लिए फाइलें अन्वेषण करें", + "TaskDownloadMissingLyrics": "लापता गानों के बोल डाउनलोड करेँ", + "TaskDownloadMissingLyricsDescription": "गानों के बोल डाउनलोड करता है" } diff --git a/Emby.Server.Implementations/Localization/Core/ht.json b/Emby.Server.Implementations/Localization/Core/ht.json new file mode 100644 index 0000000000..4fcba99e90 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/ht.json @@ -0,0 +1,3 @@ +{ + "Books": "liv" +} diff --git a/Emby.Server.Implementations/Localization/Core/hu.json b/Emby.Server.Implementations/Localization/Core/hu.json index f205e8b64c..1a9c3ee8be 100644 --- a/Emby.Server.Implementations/Localization/Core/hu.json +++ b/Emby.Server.Implementations/Localization/Core/hu.json @@ -13,7 +13,7 @@ "DeviceOnlineWithName": "{0} belépett", "FailedLoginAttemptWithUserName": "Sikertelen bejelentkezési kísérlet innen: {0}", "Favorites": "Kedvencek", - "Folders": "Könyvtárak", + "Folders": "Mappák", "Genres": "Műfajok", "HeaderAlbumArtists": "Albumelőadók", "HeaderContinueWatching": "Megtekintés folytatása", diff --git a/Emby.Server.Implementations/Localization/Core/it.json b/Emby.Server.Implementations/Localization/Core/it.json index 6b0cfb3594..e05afbabeb 100644 --- a/Emby.Server.Implementations/Localization/Core/it.json +++ b/Emby.Server.Implementations/Localization/Core/it.json @@ -58,8 +58,8 @@ "NotificationOptionServerRestartRequired": "Riavvio del server necessario", "NotificationOptionTaskFailed": "Operazione pianificata fallita", "NotificationOptionUserLockedOut": "Utente bloccato", - "NotificationOptionVideoPlayback": "La riproduzione video è iniziata", - "NotificationOptionVideoPlaybackStopped": "La riproduzione video è stata interrotta", + "NotificationOptionVideoPlayback": "Riproduzione video iniziata", + "NotificationOptionVideoPlaybackStopped": "Riproduzione video interrotta", "Photos": "Foto", "Playlists": "Playlist", "Plugin": "Plugin", @@ -134,5 +134,7 @@ "TaskDownloadMissingLyricsDescription": "Scarica testi per le canzoni", "TaskDownloadMissingLyrics": "Scarica testi mancanti", "TaskMoveTrickplayImages": "Sposta le immagini Trickplay", - "TaskMoveTrickplayImagesDescription": "Sposta le immagini Trickplay esistenti secondo la configurazione della libreria." + "TaskMoveTrickplayImagesDescription": "Sposta le immagini Trickplay esistenti secondo la configurazione della libreria.", + "TaskExtractMediaSegmentsDescription": "Estrae o ottiene segmenti multimediali dai plugin abilitati MediaSegment.", + "TaskExtractMediaSegments": "Scansiona Segmento Media" } diff --git a/Emby.Server.Implementations/Localization/Core/ja.json b/Emby.Server.Implementations/Localization/Core/ja.json index 10f4aee251..14a5765921 100644 --- a/Emby.Server.Implementations/Localization/Core/ja.json +++ b/Emby.Server.Implementations/Localization/Core/ja.json @@ -134,5 +134,6 @@ "TaskExtractMediaSegments": "メディアセグメントを読み取る", "TaskMoveTrickplayImages": "Trickplayの画像を移動", "TaskMoveTrickplayImagesDescription": "ライブラリ設定によりTrickplayのファイルを移動。", - "TaskDownloadMissingLyrics": "記録されていない歌詞をダウンロード" + "TaskDownloadMissingLyrics": "失われた歌詞をダウンロード", + "TaskExtractMediaSegmentsDescription": "MediaSegment 対応プラグインからメディア セグメントを抽出または取得します。" } diff --git a/Emby.Server.Implementations/Localization/Core/ko.json b/Emby.Server.Implementations/Localization/Core/ko.json index a739cba358..efc9f61ddf 100644 --- a/Emby.Server.Implementations/Localization/Core/ko.json +++ b/Emby.Server.Implementations/Localization/Core/ko.json @@ -3,7 +3,7 @@ "AppDeviceValues": "앱: {0}, 장치: {1}", "Application": "애플리케이션", "Artists": "아티스트", - "AuthenticationSucceededWithUserName": "{0}이(가) 성공적으로 인증됨", + "AuthenticationSucceededWithUserName": "{0} 사용자가 성공적으로 인증됨", "Books": "도서", "CameraImageUploadedFrom": "{0}에서 새로운 카메라 이미지가 업로드됨", "Channels": "채널", @@ -70,7 +70,7 @@ "ScheduledTaskFailedWithName": "{0} 실패", "ScheduledTaskStartedWithName": "{0} 시작", "ServerNameNeedsToBeRestarted": "{0}를 재시작해야합니다", - "Shows": "쇼", + "Shows": "시리즈", "Songs": "노래", "StartupEmbyServerIsLoading": "Jellyfin 서버를 불러오고 있습니다. 잠시 후에 다시 시도하십시오.", "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", @@ -81,14 +81,14 @@ "User": "사용자", "UserCreatedWithName": "사용자 {0} 생성됨", "UserDeletedWithName": "사용자 {0} 삭제됨", - "UserDownloadingItemWithValues": "{0}이(가) {1}을 다운로드 중입니다", - "UserLockedOutWithName": "유저 {0} 은(는) 잠금처리 되었습니다", - "UserOfflineFromDevice": "{1}에서 {0}의 연결이 끊킴", - "UserOnlineFromDevice": "{0}이 {1}으로 접속", - "UserPasswordChangedWithName": "사용자 {0}의 비밀번호가 변경되었습니다", - "UserPolicyUpdatedWithName": "{0}의 사용자 정책이 업데이트되었습니다", - "UserStartedPlayingItemWithValues": "{2}에서 {0}이 {1} 재생 중", - "UserStoppedPlayingItemWithValues": "{2}에서 {0}이 {1} 재생을 마침", + "UserDownloadingItemWithValues": "{0} 사용자가 {1} 다운로드 중", + "UserLockedOutWithName": "{0} 사용자 잠김", + "UserOfflineFromDevice": "{0} 사용자의 {1}에서 연결이 끊김", + "UserOnlineFromDevice": "{0} 사용자가 {1}에서 접속함", + "UserPasswordChangedWithName": "{0} 사용자 비밀번호 변경됨", + "UserPolicyUpdatedWithName": "{0} 사용자 정책 업데이트됨", + "UserStartedPlayingItemWithValues": "{0} 사용자의 {2}에서 {1} 재생 중", + "UserStoppedPlayingItemWithValues": "{0} 사용자의 {2}에서 {1} 재생을 마침", "ValueHasBeenAddedToLibrary": "{0}가 미디어 라이브러리에 추가되었습니다", "ValueSpecialEpisodeName": "스페셜 - {0}", "VersionNumber": "버전 {0}", @@ -130,5 +130,11 @@ "TaskAudioNormalizationDescription": "오디오의 볼륨 수준을 일정하게 조정하기 위해 파일을 스캔합니다.", "TaskRefreshTrickplayImages": "비디오 탐색용 미리보기 썸네일 생성", "TaskRefreshTrickplayImagesDescription": "활성화된 라이브러리에서 비디오의 트릭플레이 미리보기를 생성합니다.", - "TaskCleanCollectionsAndPlaylistsDescription": "더 이상 존재하지 않는 컬렉션 및 재생 목록에서 항목을 제거합니다." + "TaskCleanCollectionsAndPlaylistsDescription": "더 이상 존재하지 않는 컬렉션 및 재생 목록에서 항목을 제거합니다.", + "TaskExtractMediaSegments": "미디어 세그먼트 스캔", + "TaskExtractMediaSegmentsDescription": "MediaSegment를 지원하는 플러그인에서 미디어 세그먼트를 추출하거나 가져옵니다.", + "TaskMoveTrickplayImages": "트릭플레이 이미지 위치 마이그레이션", + "TaskMoveTrickplayImagesDescription": "추출된 트릭플레이 이미지를 라이브러리 설정에 따라 이동합니다.", + "TaskDownloadMissingLyrics": "누락된 가사 다운로드", + "TaskDownloadMissingLyricsDescription": "가사 다운로드" } diff --git a/Emby.Server.Implementations/Localization/Core/lb.json b/Emby.Server.Implementations/Localization/Core/lb.json new file mode 100644 index 0000000000..176f2ba2b7 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/lb.json @@ -0,0 +1,139 @@ +{ + "Albums": "Alben", + "Application": "Applikatioun", + "Artists": "Kënschtler", + "Books": "Bicher", + "Channels": "Kanäl", + "Collections": "Kollektiounen", + "Default": "Standard", + "ChapterNameValue": "Kapitel {0}", + "DeviceOnlineWithName": "{0} ass Online", + "DeviceOfflineWithName": "{0} ass Offline", + "External": "Extern", + "Favorites": "Favoritten", + "Folders": "Dossieren", + "Forced": "Forcéiert", + "HeaderAlbumArtists": "Album Kënschtler", + "HeaderFavoriteAlbums": "Léifsten Alben", + "HeaderFavoriteArtists": "Léifsten Kënschtler", + "HeaderFavoriteEpisodes": "Léifsten Episoden", + "HeaderFavoriteShows": "Léifsten Shows", + "HeaderFavoriteSongs": "Léifsten Lidder", + "Genres": "Generen", + "HeaderContinueWatching": "Weider kucken", + "Inherit": "Iwwerhuelen", + "HeaderNextUp": "Als Nächst", + "HeaderRecordingGroups": "Opname Gruppen", + "HearingImpaired": "Daaf", + "HomeVideos": "Amateur Videoen", + "ItemRemovedWithName": "Element ewech geholl: {0}", + "LabelIpAddressValue": "IP Adress: {0}", + "LabelRunningTimeValue": "Lafzäit: {0}", + "Latest": "Dat Aktuellst", + "MessageApplicationUpdatedTo": "Jellyfin Server aktualiséiert op {0}", + "MessageNamedServerConfigurationUpdatedWithValue": "Server Konfiguratiounssektioun {0} aktualiséiert", + "MessageServerConfigurationUpdated": "Server Konfiguratioun aktualiséiert", + "Movies": "Filmer", + "Music": "Musek", + "NameInstallFailed": "{0} Installatioun net gelongen", + "NameSeasonNumber": "Staffel {0}", + "NameSeasonUnknown": "Staffel Onbekannt", + "MusicVideos": "Museksvideoen", + "NotificationOptionApplicationUpdateAvailable": "Applikatiouns Update verfügbar", + "NotificationOptionApplicationUpdateInstalled": "Applikatiouns Update nët Installéiert", + "NotificationOptionAudioPlayback": "Audio ofspillen gestart", + "NotificationOptionAudioPlaybackStopped": "Audio ofspillen gestoppt", + "NotificationOptionCameraImageUploaded": "Kamera Bild eropgelueden", + "NotificationOptionInstallationFailed": "Installatioun net gelongen", + "NotificationOptionNewLibraryContent": "Neien Bibliothéik Inhalt", + "NotificationOptionPluginError": "Plugin Feeler", + "NotificationOptionPluginInstalled": "Plugin installéiert", + "NotificationOptionPluginUninstalled": "Plugin desinstalléiert", + "NotificationOptionPluginUpdateInstalled": "Plugin Update installéiert", + "Photos": "Fotoen", + "NotificationOptionTaskFailed": "Aufgab net gelongen", + "NotificationOptionUserLockedOut": "Benotzer Gesperrt", + "NotificationOptionVideoPlaybackStopped": "Video ofspillen gestoppt", + "NotificationOptionVideoPlayback": "Video ofspillen gestartet", + "Plugin": "Plugin", + "PluginUninstalledWithName": "{0} desinstalléiert", + "PluginUpdatedWithName": "{0} aktualiséiert", + "ProviderValue": "Provider: {0}", + "ScheduledTaskFailedWithName": "Aufgab: {0} net gelongen", + "Playlists": "Playlëschten", + "Shows": "Shows", + "Songs": "Lidder", + "ServerNameNeedsToBeRestarted": "{0} muss nei gestart ginn", + "StartupEmbyServerIsLoading": "Jellyfin Server luedt. Probéier méi spéit nach eng Kéier.", + "Sync": "Synchroniséieren", + "System": "System", + "User": "Benotzer", + "TvShows": "TV Shows", + "Undefined": "Net definéiert", + "UserCreatedWithName": "Benotzer {0} erstellt", + "UserDownloadingItemWithValues": "{0} luet {1} erof", + "UserOfflineFromDevice": "{0} Benotzer Offline um Gerät {1}", + "UserLockedOutWithName": "Benotzer {0} gesperrt", + "UserOnlineFromDevice": "{0} Benotzer Online um Gerät {1}", + "UserPasswordChangedWithName": "Benotzer Passwuert geännert fir {0}", + "UserPolicyUpdatedWithName": "Benotzer Politik aktualiséiert fir: {0}", + "UserStartedPlayingItemWithValues": "{0} spillt {1} op {2} oof", + "ValueHasBeenAddedToLibrary": "{0} der Bibliothéik bäigefüügt", + "VersionNumber": "Versioun {0}", + "TasksMaintenanceCategory": "Ënnerhalt", + "TasksLibraryCategory": "Bibliothéik", + "ValueSpecialEpisodeName": "Spezial-Episodenumm", + "TasksChannelsCategory": "Internet Kanäl", + "TaskCleanActivityLog": "Aktivitéits Log botzen", + "TaskCleanActivityLogDescription": "Läscht Aktivitéitslogs méi al wéi konfiguréiert.", + "TaskCleanCache": "Aufgab Cache Botzen", + "TaskRefreshChapterImages": "Kapitel Biller erstellen", + "TaskRefreshChapterImagesDescription": "Erstellt Miniaturbiller fir Videoen, déi Kapitelen hunn.", + "TaskAudioNormalization": "Audio Normaliséierung", + "TaskRefreshLibrary": "Bibliothéik aktualiséieren", + "TaskRefreshLibraryDescription": "Scannt deng Mediebibliothéik no neien Dateien a frëscht d’Metadata op.", + "TaskCleanLogs": "Log Dateien botzen", + "TaskRefreshPeople": "Persounen aktualiséieren", + "TaskRefreshPeopleDescription": "Aktualiséiert Metadata fir Schauspiller a Regisseuren an denger Mediebibliothéik.", + "TaskRefreshTrickplayImagesDescription": "Erstellt Trickplay-Viraussiichten fir Videoen an aktivéierte Bibliothéiken.", + "TaskCleanTranscode": "Transkodéieren botzen", + "TaskCleanTranscodeDescription": "Läscht Transkodéierungsdateien, déi méi al wéi een Dag sinn.", + "TaskRefreshChannels": "Kanäl aktualiséieren", + "TaskDownloadMissingLyrics": "Fehlend Liddertexter eroflueden", + "TaskDownloadMissingLyricsDescription": "Lued Liddertexter fir Lidder erof", + "TaskDownloadMissingSubtitles": "Fehlend Ënnertitelen eroflueden", + "TaskOptimizeDatabase": "Datebank optiméieren", + "TaskKeyframeExtractor": "Schlësselbild Extrakter", + "TaskCleanCollectionsAndPlaylists": "Sammlungen a Playlisten botzen", + "TaskCleanCollectionsAndPlaylistsDescription": "Ewechhuele vun Elementer aus Sammlungen a Playlisten, déi net méi existéieren.", + "TaskExtractMediaSegments": "Mediesegment-Scan", + "NewVersionIsAvailable": "Nei Versioun fir Jellyfin Server ass verfügbar.", + "CameraImageUploadedFrom": "En neit Kamera Bild gouf vu {0} eropgelueden", + "PluginInstalledWithName": "{0} installéiert", + "TaskMoveTrickplayImagesDescription": "Verschëfft existent Trickplay-Dateien no de Bibliothéik-Astellungen.", + "AppDeviceValues": "App: {0}, Geräter: {1}", + "FailedLoginAttemptWithUserName": "Net Gelongen Umeldung {0}", + "HeaderLiveTV": "LiveTV", + "ItemAddedWithName": "Element derbäi gesat: {0}", + "NotificationOptionServerRestartRequired": "Server Restart Erfuerderlech", + "ScheduledTaskStartedWithName": "Aufgab: {0} gestart", + "AuthenticationSucceededWithUserName": "{0} Authentifikatioun gelongen", + "MixedContent": "Gemëschten Inhalt", + "MessageApplicationUpdated": "Jellyfin Server Aktualiséiert", + "SubtitleDownloadFailureFromForItem": "Ënnertitel Download Feeler vun {0} fir {1}", + "TaskCleanLogsDescription": "Läscht Log-Dateien, déi méi al wéi {0} Deeg sinn.", + "TaskUpdatePlugins": "Plugins aktualiséieren", + "UserDeletedWithName": "Benotzer {0} geläscht", + "TasksApplicationCategory": "Applikatioun", + "TaskCleanCacheDescription": "Läscht Cache-Dateien, déi net méi vum System gebraucht ginn.", + "UserStoppedPlayingItemWithValues": "{0} ass mat {1} op {2} fäerdeg", + "TaskAudioNormalizationDescription": "Scannt Dateien no Donnéeën fir d’Audio-Normaliséierung.", + "TaskRefreshTrickplayImages": "Trickplay-Biller generéieren", + "TaskDownloadMissingSubtitlesDescription": "Sicht am Internet no fehlenden Ënnertitelen op Basis vun der Metadata-Konfiguratioun.", + "TaskMoveTrickplayImages": "Trickplay-Biller-Plaz migréieren", + "TaskUpdatePluginsDescription": "Lued Aktualiséierungen erof a installéiert se fir Plugins, déi fir automatesch Updates konfiguréiert sinn.", + "TaskKeyframeExtractorDescription": "Extrahéiert Schlësselbiller aus Videodateien, fir méi präzis HLS-Playlisten ze erstellen. Dës Aufgab kann eng längere Zäit daueren.", + "TaskRefreshChannelsDescription": "Aktualiséiert Informatiounen iwwer Internetkanäl.", + "TaskExtractMediaSegmentsDescription": "Extrahéiert oder kritt Mediesegmenter aus Plugins, déi MediaSegment ënnerstëtzen.", + "TaskOptimizeDatabaseDescription": "Kompriméiert d’Datebank a schneit de fräie Speicherplatz zou. Dës Aufgab no engem Bibliothéik-Scan oder anere Ännerungen, déi Datebankmodifikatioune mat sech bréngen, auszeféieren, kann d’Performance verbesseren." +} diff --git a/Emby.Server.Implementations/Localization/Core/lt-LT.json b/Emby.Server.Implementations/Localization/Core/lt-LT.json index 95f738bd55..46fc49f5e4 100644 --- a/Emby.Server.Implementations/Localization/Core/lt-LT.json +++ b/Emby.Server.Implementations/Localization/Core/lt-LT.json @@ -94,14 +94,14 @@ "VersionNumber": "Version {0}", "TaskUpdatePluginsDescription": "Atsisiųsti ir įdiegti atnaujinimus priedams kuriem yra nustatytas automatiškas atnaujinimas.", "TaskUpdatePlugins": "Atnaujinti Priedus", - "TaskDownloadMissingSubtitlesDescription": "Ieško internete trūkstamų subtitrų remiantis metaduomenų konfigūracija.", + "TaskDownloadMissingSubtitlesDescription": "Ieško trūkstamų subtitrų internete remiantis metaduomenų konfigūracija.", "TaskCleanTranscodeDescription": "Ištrina dienos senumo perkodavimo failus.", "TaskCleanTranscode": "Išvalyti Perkodavimo Direktorija", "TaskRefreshLibraryDescription": "Ieškoti naujų failų jūsų mediatekoje ir atnaujina metaduomenis.", "TaskRefreshLibrary": "Skenuoti Mediateka", "TaskDownloadMissingSubtitles": "Atsisiųsti trūkstamus subtitrus", - "TaskRefreshChannelsDescription": "Atnaujina internetinių kanalų informacija.", - "TaskRefreshChannels": "Atnaujinti Kanalus", + "TaskRefreshChannelsDescription": "Atnaujina internetinių kanalų informaciją.", + "TaskRefreshChannels": "Atnaujinti kanalus", "TaskRefreshPeopleDescription": "Atnaujina metaduomenis apie aktorius ir režisierius jūsų mediatekoje.", "TaskRefreshPeople": "Atnaujinti Žmones", "TaskCleanLogsDescription": "Ištrina žurnalo failus kurie yra senesni nei {0} dienos.", @@ -119,22 +119,22 @@ "Forced": "Priverstas", "Default": "Numatytas", "TaskCleanActivityLogDescription": "Ištrina veiklos žuranlo įrašus, kurie yra senesni nei nustatytas amžius.", - "TaskOptimizeDatabase": "Optimizuoti duomenų bazės", + "TaskOptimizeDatabase": "Optimizuoti duomenų bazę", "TaskKeyframeExtractorDescription": "Iš vaizdo įrašo paruošia reikšminius kadrus, kad būtų sukuriamas tikslenis HLS grojaraštis. Šios užduoties vykdymas gali ilgai užtrukti.", - "TaskKeyframeExtractor": "Pagrindinių kadrų ištraukėjas", - "TaskOptimizeDatabaseDescription": "Suspaudžia duomenų bazę ir atlaisvina vietą. Paleidžiant šią užduotį, po bibliotekos skenavimo arba kitų veiksmų kurie galimai modifikuoja duomenų bazė, gali pagerinti greitaveiką.", + "TaskKeyframeExtractor": "Pagrindinių kadrų išgavėjas", + "TaskOptimizeDatabaseDescription": "Suspaudžia duomenų bazę ir atlaisvina vietą. Paleidžiant šią užduotį, po bibliotekos skenavimo arba kitų veiksmų kurie galimai modifikuoja duomenų bazę, gali pagerinti greitaveiką.", "External": "Išorinis", "HearingImpaired": "Su klausos sutrikimais", "TaskRefreshTrickplayImages": "Generuoti Trickplay atvaizdus", "TaskRefreshTrickplayImagesDescription": "Sukuria trickplay peržiūras vaizdo įrašams įgalintose bibliotekose.", - "TaskCleanCollectionsAndPlaylists": "Sutvarko duomenis jūsų kolekcijose ir grojaraščiuose", - "TaskCleanCollectionsAndPlaylistsDescription": "Pašalina nebeegzistuojančius elementus iš kolekcijų ir grojaraščių.", + "TaskCleanCollectionsAndPlaylists": "Išvalo duomenis kolekcijose ir grojaraščiuose", + "TaskCleanCollectionsAndPlaylistsDescription": "Pašalina neegzistuojančius elementus iš kolekcijų ir grojaraščių.", "TaskAudioNormalization": "Garso Normalizavimas", "TaskAudioNormalizationDescription": "Skenuoti garso normalizavimo informacijos failuose.", - "TaskExtractMediaSegments": "Medijos Segmentų Nuskaitymas", + "TaskExtractMediaSegments": "Medijos segmentų nuskaitymas", "TaskDownloadMissingLyrics": "Parsisiųsti trūkstamus dainų tekstus", "TaskExtractMediaSegmentsDescription": "Ištraukia arba gauna medijos segmentus iš MediaSegment ijungtų papildinių.", - "TaskMoveTrickplayImages": "Migruoti Trickplay Vaizdų Vietą", - "TaskMoveTrickplayImagesDescription": "Perkelia egzisuojančius trickplay failus pagal bibliotekos nustatymus.", + "TaskMoveTrickplayImages": "Pakeisti Trickplay vaizdų vietą", + "TaskMoveTrickplayImagesDescription": "Perkelia egzistuojančius trickplay failus pagal bibliotekos nustatymus.", "TaskDownloadMissingLyricsDescription": "Parsisiųsti dainų žodžius" } diff --git a/Emby.Server.Implementations/Localization/Core/lv.json b/Emby.Server.Implementations/Localization/Core/lv.json index 62277fd94a..77340a57ad 100644 --- a/Emby.Server.Implementations/Localization/Core/lv.json +++ b/Emby.Server.Implementations/Localization/Core/lv.json @@ -123,11 +123,17 @@ "External": "Ārējais", "HearingImpaired": "Ar dzirdes traucējumiem", "TaskKeyframeExtractor": "Atslēgkadru ekstraktors", - "TaskKeyframeExtractorDescription": "Ekstraktē atslēgkadrus no video failiem lai izveidotu precīzākus HLS atskaņošanas sarakstus. Šis process var būt ilgs.", + "TaskKeyframeExtractorDescription": "Izvelk atslēgkadrus no video failiem lai izveidotu precīzākus HLS atskaņošanas sarakstus. Šis process var būt ilgs.", "TaskRefreshTrickplayImages": "Ģenerēt partīšanas attēlus", "TaskRefreshTrickplayImagesDescription": "Izveido priekšskatījumus videoklipu pārtīšanai iespējotajās bibliotēkās.", "TaskAudioNormalization": "Audio normalizācija", "TaskCleanCollectionsAndPlaylistsDescription": "Noņem vairs neeksistējošus vienumus no kolekcijām un atskaņošanas sarakstiem.", "TaskAudioNormalizationDescription": "Skanē failus priekš audio normālizācijas informācijas.", - "TaskCleanCollectionsAndPlaylists": "Notīrīt kolekcijas un atskaņošanas sarakstus" + "TaskCleanCollectionsAndPlaylists": "Notīrīt kolekcijas un atskaņošanas sarakstus", + "TaskExtractMediaSegments": "Multivides segmenta skenēšana", + "TaskExtractMediaSegmentsDescription": "Izvelk vai iegūst multivides segmentus no MediaSegment iespējotiem spraudņiem.", + "TaskMoveTrickplayImages": "Trickplay attēlu pārvietošana", + "TaskMoveTrickplayImagesDescription": "Pārvieto esošos trickplay failus atbilstoši bibliotēkas iestatījumiem.", + "TaskDownloadMissingLyrics": "Lejupielādēt trūkstošos vārdus", + "TaskDownloadMissingLyricsDescription": "Lejupielādēt vārdus dziesmām" } diff --git a/Emby.Server.Implementations/Localization/Core/mk.json b/Emby.Server.Implementations/Localization/Core/mk.json index e149f8adfd..6da31227d7 100644 --- a/Emby.Server.Implementations/Localization/Core/mk.json +++ b/Emby.Server.Implementations/Localization/Core/mk.json @@ -131,5 +131,6 @@ "TaskRefreshTrickplayImages": "Генерирај слики за прегледување (Trickplay)", "TaskAudioNormalization": "Нормализација на звукот", "TaskRefreshTrickplayImagesDescription": "Креира трикплеј прегледи за видеа во овозможените библиотеки.", - "TaskCleanCollectionsAndPlaylistsDescription": "Отстранува ставки од колекциите и плејлистите што веќе не постојат." + "TaskCleanCollectionsAndPlaylistsDescription": "Отстранува ставки од колекциите и плејлистите што веќе не постојат.", + "TaskExtractMediaSegments": "Скенирање на сегменти на содржина" } diff --git a/Emby.Server.Implementations/Localization/Core/ms.json b/Emby.Server.Implementations/Localization/Core/ms.json index ebd3f7560b..a3fc7881e3 100644 --- a/Emby.Server.Implementations/Localization/Core/ms.json +++ b/Emby.Server.Implementations/Localization/Core/ms.json @@ -9,14 +9,14 @@ "Channels": "Saluran", "ChapterNameValue": "Bab {0}", "Collections": "Koleksi", - "DeviceOfflineWithName": "{0} telah diputuskan sambungan", + "DeviceOfflineWithName": "{0} telah dinyahsambung", "DeviceOnlineWithName": "{0} telah disambung", - "FailedLoginAttemptWithUserName": "Cubaan log masuk gagal dari {0}", + "FailedLoginAttemptWithUserName": "Percubaan gagal log masuk daripada {0}", "Favorites": "Kegemaran", - "Folders": "Fail-fail", + "Folders": "Folder-folder", "Genres": "Genre-genre", - "HeaderAlbumArtists": "Album Artis-artis", - "HeaderContinueWatching": "Terus Menonton", + "HeaderAlbumArtists": "Album artis-artis", + "HeaderContinueWatching": "Teruskan Menonton", "HeaderFavoriteAlbums": "Album-album Kegemaran", "HeaderFavoriteArtists": "Artis-artis Kegemaran", "HeaderFavoriteEpisodes": "Episod-episod Kegemaran", @@ -25,26 +25,26 @@ "HeaderLiveTV": "TV Siaran Langsung", "HeaderNextUp": "Seterusnya", "HeaderRecordingGroups": "Kumpulan-kumpulan Rakaman", - "HomeVideos": "Video Personal", - "Inherit": "Mewarisi", - "ItemAddedWithName": "{0} telah ditambahkan ke dalam pustaka", + "HomeVideos": "Video Peribadi", + "Inherit": "Warisi", + "ItemAddedWithName": "{0} telah ditambah ke dalam pustaka", "ItemRemovedWithName": "{0} telah dibuang daripada pustaka", "LabelIpAddressValue": "Alamat IP: {0}", "LabelRunningTimeValue": "Masa berjalan: {0}", - "Latest": "Terbaru", - "MessageApplicationUpdated": "Jellyfin Server telah dikemas kini", - "MessageApplicationUpdatedTo": "Jellyfin Server telah dikemas kini ke {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Konfigurasi pelayan di bahagian {0} telah dikemas kini", + "Latest": "Terbaharu", + "MessageApplicationUpdated": "Pelayan Jellyfin telah dikemas kini", + "MessageApplicationUpdatedTo": "Pelayan Jellyfin telah dikemas kini ke {0}", + "MessageNamedServerConfigurationUpdatedWithValue": "Konfigurasi pelayan bahagian {0} telah dikemas kini", "MessageServerConfigurationUpdated": "Konfigurasi pelayan telah dikemas kini", "MixedContent": "Kandungan campuran", "Movies": "Filem-filem", "Music": "Muzik", "MusicVideos": "Video Muzik", "NameInstallFailed": "{0} pemasangan gagal", - "NameSeasonNumber": "Musim {0}", + "NameSeasonNumber": "Musim ke-{0}", "NameSeasonUnknown": "Musim Tidak Diketahui", - "NewVersionIsAvailable": "Versi terbaru Jellyfin Server bersedia untuk dimuat turunkan.", - "NotificationOptionApplicationUpdateAvailable": "Kemas kini aplikasi telah sedia", + "NewVersionIsAvailable": "Versi terbaharu Pelayan Jellyfin telah tersedia untuk dimuat turun.", + "NotificationOptionApplicationUpdateAvailable": "Kemas kini aplikasi telah tersedia", "NotificationOptionApplicationUpdateInstalled": "Kemas kini aplikasi telah dipasang", "NotificationOptionAudioPlayback": "Ulangmain audio bermula", "NotificationOptionAudioPlaybackStopped": "Ulangmain audio dihentikan", @@ -98,8 +98,8 @@ "TasksLibraryCategory": "Perpustakaan", "TasksMaintenanceCategory": "Penyelenggaraan", "Undefined": "Tidak ditentukan", - "Forced": "Paksa", - "Default": "Asal", + "Forced": "Dipaksa", + "Default": "Lalai", "TaskCleanCache": "Bersihkan Direktori Cache", "TaskCleanActivityLogDescription": "Padamkan entri log aktiviti yang lebih tua daripada usia yang dikonfigurasi.", "TaskRefreshPeople": "Segarkan Orang", @@ -126,5 +126,15 @@ "TaskKeyframeExtractor": "Ekstrak bingkai kunci", "TaskKeyframeExtractorDescription": "Ekstrak bingkai kunci dari fail video untuk membina HLS playlist yang lebih tepat. Tugas ini mungkin perlukan masa yang panjang.", "TaskRefreshTrickplayImagesDescription": "Jana gambar prebiu Trickplay untuk video dalam perpustakaan.", - "TaskRefreshTrickplayImages": "Jana gambar Trickplay" + "TaskRefreshTrickplayImages": "Jana gambar Trickplay", + "TaskExtractMediaSegments": "Imbasan Segmen Media", + "TaskExtractMediaSegmentsDescription": "Mengekstrak atau mendapatkan segmen media daripada pemalam yang didayakan MediaSegment.", + "TaskMoveTrickplayImagesDescription": "Mengalihkan fail trickplay sedia ada mengikut tetapan pustakan digital.", + "TaskDownloadMissingLyrics": "Muat turun lirik yang hilang", + "TaskDownloadMissingLyricsDescription": "Memuat turun lirik-lirik untuk lagu-lagu", + "TaskMoveTrickplayImages": "Alih Lokasi Imej Trickplay", + "TaskCleanCollectionsAndPlaylists": "Bersihkan koleksi dan senarai audio video", + "TaskAudioNormalization": "Normalisasi Audio", + "TaskAudioNormalizationDescription": "Mengimbas fail-fail untuk data normalisasi audio.", + "TaskCleanCollectionsAndPlaylistsDescription": "Mengalih keluar item daripada koleksi dan senarai audio video yang tidak wujud lagi." } diff --git a/Emby.Server.Implementations/Localization/Core/mt.json b/Emby.Server.Implementations/Localization/Core/mt.json index c9e11165de..f7501ab404 100644 --- a/Emby.Server.Implementations/Localization/Core/mt.json +++ b/Emby.Server.Implementations/Localization/Core/mt.json @@ -1,86 +1,86 @@ { "Albums": "Albums", - "AppDeviceValues": "App: {0}, Apparat: {1}", + "AppDeviceValues": "Applikazzjoni: {0}, Device: {1}", "Application": "Applikazzjoni", "Artists": "Artisti", "AuthenticationSucceededWithUserName": "{1} awtentikat b'suċċess", "Books": "Kotba", - "CameraImageUploadedFrom": "Ttellgħet immaġni ġdida tal-kamera minn {1}", - "Channels": "Kanali", + "CameraImageUploadedFrom": "Ttella' ritratt ġdid tal-kamera minn {1}", + "Channels": "Stazzjonijiet", "ChapterNameValue": "Kapitlu {0}", "Collections": "Kollezzjonijiet", - "DeviceOfflineWithName": "{0} inqatgħa", - "DeviceOnlineWithName": "{0} qabad", + "DeviceOfflineWithName": "{0} tneħħa", + "DeviceOnlineWithName": "{0} tqabbad", "External": "Estern", - "FailedLoginAttemptWithUserName": "Tentattiv t'aċċess fallut minn {0}", + "FailedLoginAttemptWithUserName": "Attentat fallut ta' login minn {0}", "Favorites": "Favoriti", "Forced": "Sfurzat", "Genres": "Ġeneri", "HeaderAlbumArtists": "Artisti tal-album", - "HeaderContinueWatching": "Kompli Segwi", + "HeaderContinueWatching": "Kompli Ara", "HeaderFavoriteAlbums": "Albums Favoriti", "HeaderFavoriteArtists": "Artisti Favoriti", "HeaderFavoriteEpisodes": "Episodji Favoriti", "HeaderFavoriteShows": "Programmi Favoriti", "HeaderFavoriteSongs": "Kanzunetti Favoriti", "HeaderNextUp": "Li Jmiss", - "SubtitleDownloadFailureFromForItem": "Is-sottotitli naqsu milli jitniżżlu minn {0} għal {1}", - "UserPasswordChangedWithName": "Il-password inbidel għall-utent {0}", + "SubtitleDownloadFailureFromForItem": "Is-sottotitli ma setgħux jitniżżlu minn {0} għal {1}", + "UserPasswordChangedWithName": "Il-password għall-utent {0} inbidlet", "TaskUpdatePluginsDescription": "Iniżżel u jinstalla aġġornamenti għal plugins li huma kkonfigurati biex jaġġornaw awtomatikament.", - "TaskDownloadMissingSubtitlesDescription": "Ifittex fuq l-internet għal sottotitli neqsin abbażi tal-konfigurazzjoni tal-metadata.", - "TaskOptimizeDatabaseDescription": "Jikkompatti d-database u jaqta' l-ispazju ħieles. It-tħaddim ta' dan il-kompitu wara li tiskennja l-librerija jew tagħmel bidliet oħra li jimplikaw modifiki fid-database jistgħu jtejbu l-prestazzjoni.", + "TaskDownloadMissingSubtitlesDescription": "Ifittex fuq l-internet għal sottotitli neqsin skont il-konfigurazzjoni tal-metadata.", + "TaskOptimizeDatabaseDescription": "Jikkompatta d-database u jaqta' l-ispazju ħieles. It-tħaddim ta' dan it-task wara li tiskennja l-librerija jew tagħmel bidliet oħra li jimplikaw modifiki fid-database jistgħu jtejbu l-mod kif jaħdem.", "Default": "Standard", "Folders": "Folders", "HeaderLiveTV": "TV Dirett", - "HeaderRecordingGroups": "Gruppi ta' Reġistrazzjoni", + "HeaderRecordingGroups": "Gruppi ta' Rikordjar", "HearingImpaired": "Nuqqas ta' Smigħ", - "HomeVideos": "Vidjows Personali", + "HomeVideos": "Filmati Personali", "Inherit": "Jiret", - "ItemAddedWithName": "{0} ġie miżjud mal-librerija", + "ItemAddedWithName": "{0} żdied fil-librerija", "ItemRemovedWithName": "{0} tneħħa mil-librerija", - "LabelIpAddressValue": "Indirizz IP: {0}", + "LabelIpAddressValue": "Indirizz tal-IP: {0}", "Latest": "Tal-Aħħar", - "MessageApplicationUpdated": "Jellyfin Server ġie aġġornat", - "MessageApplicationUpdatedTo": "JellyFin Server ġie aġġornat għal {0}", + "MessageApplicationUpdated": "Il-Jellyfin Server ġie aġġornat", + "MessageApplicationUpdatedTo": "Il-JellyFin Server ġie aġġornat għal {0}", "MessageNamedServerConfigurationUpdatedWithValue": "Is-sezzjoni {0} tal-konfigurazzjoni tas-server ġiet aġġornata", "MessageServerConfigurationUpdated": "Il-konfigurazzjoni tas-server ġiet aġġornata", "MixedContent": "Kontenut imħallat", "Movies": "Films", "Music": "Mużika", - "MusicVideos": "Vidjows tal-Mużika", + "MusicVideos": "Music Videos", "NameInstallFailed": "L-installazzjoni ta' {0} falliet", "NameSeasonNumber": "Staġun {0}", "NameSeasonUnknown": "Staġun Mhux Magħruf", - "NewVersionIsAvailable": "Verżjoni ġdida ta' Jellyfin Server hija disponibbli biex titniżżel.", - "NotificationOptionApplicationUpdateAvailable": "Aġġornament tal-applikazzjoni disponibbli", - "NotificationOptionCameraImageUploaded": "Immaġini tal-kamera mtella'", + "NewVersionIsAvailable": "Verżjoni ġdida tal-Jellyfin Server hija disponibbli biex titniżżel.", + "NotificationOptionApplicationUpdateAvailable": "Hemm aġġornament tal-applikazzjoni", + "NotificationOptionCameraImageUploaded": "Ritratt tal-kamera mtella'", "LabelRunningTimeValue": "Tul: {0}", "NotificationOptionApplicationUpdateInstalled": "Aġġornament tal-applikazzjoni ġie installat", - "NotificationOptionAudioPlayback": "Il-playback tal-awdjo beda", + "NotificationOptionAudioPlayback": "Beda l-playback tal-awdjo", "NotificationOptionAudioPlaybackStopped": "Il-playback tal-awdjo twaqqaf", - "NotificationOptionInstallationFailed": "Installazzjoni falliet", - "NotificationOptionNewLibraryContent": "Kontenut ġdid miżjud", - "NotificationOptionPluginError": "Ħsara fil-plugin", + "NotificationOptionInstallationFailed": "L-Installazzjoni falliet", + "NotificationOptionNewLibraryContent": "Kontenut ġdid żdied", + "NotificationOptionPluginError": "Falliment fil-plugin", "NotificationOptionPluginInstalled": "Plugin installat", "NotificationOptionPluginUninstalled": "Plugin tneħħa", - "NotificationOptionServerRestartRequired": "Meħtieġ l-istartjar mill-ġdid tas-server", - "NotificationOptionTaskFailed": "Falliment tal-kompitu skedat", + "NotificationOptionServerRestartRequired": "Hemm bżonn li tagħmel restart tas-server", + "NotificationOptionTaskFailed": "Falliment tat-task skedat", "NotificationOptionUserLockedOut": "Utent imsakkar", "Photos": "Ritratti", "Playlists": "Playlists", "Plugin": "Plugin", "PluginInstalledWithName": "{0} ġie installat", - "PluginUninstalledWithName": "{0} ġie mneħħi", + "PluginUninstalledWithName": "{0} tneħħa", "PluginUpdatedWithName": "{0} ġie aġġornat", "ProviderValue": "Fornitur: {0}", "ScheduledTaskFailedWithName": "{0} falla", "ScheduledTaskStartedWithName": "{0} beda", - "ServerNameNeedsToBeRestarted": "{0} jeħtieġ li jerġa' jinbeda", + "ServerNameNeedsToBeRestarted": "{0} jeħtieġ restart", "Songs": "Kanzunetti", - "StartupEmbyServerIsLoading": "Jellyfin Server qed jixgħel. Jekk jogħġbok erġa' pprova dalwaqt.", + "StartupEmbyServerIsLoading": "Jellyfin Server qed jillowdja. Jekk jogħġbok erġa' pprova ftit tal-ħin oħra.", "Sync": "Sinkronizza", "System": "Sistema", - "Undefined": "Mhux Definit", + "Undefined": "Bla Definizzjoni", "User": "Utent", "UserCreatedWithName": "L-utent {0} inħoloq", "UserDeletedWithName": "L-utent {0} tħassar", @@ -89,45 +89,51 @@ "UserOfflineFromDevice": "{0} skonnettja minn {1}", "UserOnlineFromDevice": "{0} huwa online minn {1}", "NotificationOptionPluginUpdateInstalled": "Aġġornament ta' plugin ġie installat", - "NotificationOptionVideoPlayback": "Il-playback tal-vidjow beda", - "NotificationOptionVideoPlaybackStopped": "Il-playback tal-vidjow waqaf", - "Shows": "Programmi", - "TvShows": "Programmi tat-TV", - "UserPolicyUpdatedWithName": "Il-policy tal-utent ġiet aġġornata għal {0}", - "UserStartedPlayingItemWithValues": "{0} qed iħaddem {1} fuq {2}", - "UserStoppedPlayingItemWithValues": "{0} waqaf iħaddem {1} fuq {2}", + "NotificationOptionVideoPlayback": "Il-playback tal-filmat beda", + "NotificationOptionVideoPlaybackStopped": "Il-playback tal-filmat twaqqaf", + "Shows": "Serje", + "TvShows": "Serje Televiżivi", + "UserPolicyUpdatedWithName": "Il-politka tal-utent ġiet aġġornata għal {0}", + "UserStartedPlayingItemWithValues": "{0} qed jara {1} fuq {2}", + "UserStoppedPlayingItemWithValues": "{0} waqaf jara {1} fuq {2}", "ValueHasBeenAddedToLibrary": "{0} ġie miżjud mal-librerija tal-midja tiegħek", "ValueSpecialEpisodeName": "Speċjali - {0}", "VersionNumber": "Verżjoni {0}", "TasksMaintenanceCategory": "Manutenzjoni", "TasksLibraryCategory": "Librerija", "TasksApplicationCategory": "Applikazzjoni", - "TasksChannelsCategory": "Kanali tal-Internet", + "TasksChannelsCategory": "Stazzjonijiet tal-Internet", "TaskCleanActivityLog": "Naddaf il-Logg tal-Attività", - "TaskCleanActivityLogDescription": "Iħassar l-entrati tar-reġistru tal-attività eqdem mill-età kkonfigurata.", + "TaskCleanActivityLogDescription": "Iħassar id-daħliet tar-reġistru tal-attività eqdem mill-età li kienet kkonfigurata.", "TaskCleanCache": "Naddaf id-Direttorju tal-Cache", "TaskCleanCacheDescription": "Iħassar il-fajls tal-cache li m'għadhomx meħtieġa mis-sistema.", - "TaskRefreshChapterImages": "Oħroġ l-Immaġini tal-Kapitolu", + "TaskRefreshChapterImages": "Oħroġ ir-Ritratti tal-Kapitlu", "TaskRefreshChapterImagesDescription": "Joħloq thumbnails għal vidjows li għandhom kapitli.", - "TaskAudioNormalization": "Normalizzazzjoni Awdjo", - "TaskAudioNormalizationDescription": "Skennja fajls għal data ta' normalizzazzjoni awdjo.", + "TaskAudioNormalization": "Normalizzazzjoni tal-Awdjo", + "TaskAudioNormalizationDescription": "Skennja fajls għal data fuq in-normalizzazzjoni tal-awdjo.", "TaskRefreshLibrary": "Skennja l-Librerija tal-Midja", "TaskRefreshLibraryDescription": "Jiskennja l-librerija tal-midja tiegħek għal fajls ġodda u jġedded il-metadejta.", "TaskCleanLogs": "Naddaf id-Direttorju tal-Logg", "TaskCleanLogsDescription": "Iħassar fajls tal-logg eqdem minn {0} ijiem.", - "TaskRefreshPeople": "Aġġorna Persuni", - "TaskRefreshPeopleDescription": "Jaġġorna l-metadejta għall-atturi u d-diretturi fil-librerija tal-midja tiegħek.", + "TaskRefreshPeople": "Aġġorna l-Persuni", + "TaskRefreshPeopleDescription": "Jaġġorna l-metadata għall-atturi u d-diretturi fil-librerija tal-midja tiegħek.", "TaskRefreshTrickplayImages": "Iġġenera Stampi Trickplay", - "TaskRefreshTrickplayImagesDescription": "Joħloq previews trickplay għal vidjows fil-libreriji attivati.", - "TaskUpdatePlugins": "Aġġorna il-Plugins", - "TaskCleanTranscode": "Naddaf id-Direttorju tat-Transcode", - "TaskCleanTranscodeDescription": "Iħassar fajls transcode eqdem minn ġurnata.", - "TaskRefreshChannels": "Aġġorna l-Kanali", - "TaskRefreshChannelsDescription": "Aġġorna l-informazzjoni tal-kanali tal-internet.", + "TaskRefreshTrickplayImagesDescription": "Joħloq previews trickplay għal videos fil-libreriji li għalihom hi attivata.", + "TaskUpdatePlugins": "Aġġorna l-Plugins", + "TaskCleanTranscode": "Naddaf id-Direttorju tat-Transcoding", + "TaskCleanTranscodeDescription": "Iħassar fajls tat-transcoding li huma eqdem minn ġurnata.", + "TaskRefreshChannels": "Aġġorna l-Istazzjonijiet", + "TaskRefreshChannelsDescription": "Aġġorna l-informazzjoni tal-istazzjonijiet tal-internet.", "TaskDownloadMissingSubtitles": "Niżżel is-sottotitli nieqsa", - "TaskOptimizeDatabase": "Ottimizza d-database", + "TaskOptimizeDatabase": "Ottimiżża d-database", "TaskKeyframeExtractor": "Estrattur ta' Keyframes", - "TaskKeyframeExtractorDescription": "Jiġbed il-keyframes mill-fajls tal-vidjow biex joħloq playlists HLS aktar preċiżi. Dan il-kompitu jista' jdum għal żmien twil.", + "TaskKeyframeExtractorDescription": "Jiġbed il-keyframes mill-fajls tal-videos biex jagħmel playlists HLS aktar preċiżi. Dan it-task jista' jdum żmien twil biex ilesti.", "TaskCleanCollectionsAndPlaylists": "Naddaf il-kollezzjonijiet u l-playlists", - "TaskCleanCollectionsAndPlaylistsDescription": "Ineħħi oġġetti minn kollezzjonijiet u playlists li m'għadhomx jeżistu." + "TaskCleanCollectionsAndPlaylistsDescription": "Ineħħi oġġetti minn kollezzjonijiet u playlists li m'għadhomx jeżistu.", + "TaskDownloadMissingLyrics": "Niżżel il-lirika nieqsa", + "TaskDownloadMissingLyricsDescription": "Iniżżel il-lirika għal-kanzunetti", + "TaskExtractMediaSegments": "Scan tas-Sezzjoni tal-Midja", + "TaskExtractMediaSegmentsDescription": "Jestratta jew iġib sezzjonijiet tal-midja minn plugins attivati tal-MediaSegment.", + "TaskMoveTrickplayImages": "Mexxi l-post tat-Trickplay Image", + "TaskMoveTrickplayImagesDescription": "Tmexxi l-files tat-trickplay li jeżistu skont kif inhi kkonfigurata l-librerija." } diff --git a/Emby.Server.Implementations/Localization/Core/nl.json b/Emby.Server.Implementations/Localization/Core/nl.json index 2327a73a99..8828eadcb5 100644 --- a/Emby.Server.Implementations/Localization/Core/nl.json +++ b/Emby.Server.Implementations/Localization/Core/nl.json @@ -11,7 +11,7 @@ "Collections": "Collecties", "DeviceOfflineWithName": "Verbinding met {0} is verbroken", "DeviceOnlineWithName": "{0} is verbonden", - "FailedLoginAttemptWithUserName": "Mislukte aanmeldpoging vanaf {0}", + "FailedLoginAttemptWithUserName": "Mislukte aanmeldpoging van {0}", "Favorites": "Favorieten", "Folders": "Mappen", "Genres": "Genres", @@ -117,7 +117,7 @@ "TaskCleanActivityLogDescription": "Verwijdert activiteitenlogs ouder dan de ingestelde leeftijd.", "TaskCleanActivityLog": "Activiteitenlogboek legen", "Undefined": "Niet gedefinieerd", - "Forced": "Geforceerd", + "Forced": "Gedwongen", "Default": "Standaard", "TaskOptimizeDatabaseDescription": "Comprimeert de database en trimt vrije ruimte. Het uitvoeren van deze taak kan de prestaties verbeteren, na het scannen van de bibliotheek of andere aanpassingen die invloed hebben op de database.", "TaskOptimizeDatabase": "Database optimaliseren", diff --git a/Emby.Server.Implementations/Localization/Core/pa.json b/Emby.Server.Implementations/Localization/Core/pa.json index a25099ee0b..6062d97003 100644 --- a/Emby.Server.Implementations/Localization/Core/pa.json +++ b/Emby.Server.Implementations/Localization/Core/pa.json @@ -120,5 +120,20 @@ "Albums": "ਐਲਬਮਾਂ", "TaskOptimizeDatabase": "ਡਾਟਾਬੇਸ ਅਨੁਕੂਲ ਬਣਾਓ", "External": "ਬਾਹਰੀ", - "HearingImpaired": "ਸੁਨਣ ਵਿਚ ਕਮਜ਼ੋਰ" + "HearingImpaired": "ਸੁਨਣ ਵਿਚ ਕਮਜ਼ੋਰ", + "TaskAudioNormalizationDescription": "ਆਵਾਜ਼ ਸਧਾਰਣੀਕਰਨ ਡਾਟਾ ਲਈ ਫਾਇਲਾਂ ਖੋਜੋ।", + "TaskRefreshTrickplayImages": "ਟ੍ਰਿਕਪਲੇ ਤਸਵੀਰਾਂ ਤਿਆਰ ਕਰੋ", + "TaskExtractMediaSegments": "ਮੀਡੀਆ ਸੈਗਮੈਂਟ ਸਕੈਨ", + "TaskMoveTrickplayImagesDescription": "ਟ੍ਰਿਕਪਲੇ ਤਸਵੀਰਾਂ ਦੀ ਜਗਾ ਨੂੰ ਲਾਇਬ੍ਰੇਰੀ ਸੈਟਿੰਗਜ਼ ਅਨੁਸਾਰ ਬਦਲੋ।", + "TaskOptimizeDatabaseDescription": "ਡੇਟਾਬੇਸ ਨੂੰ ਸੰਗ੍ਰਹਿਤ ਕਰਦਾ ਹੈ ਅਤੇ ਖਾਲੀ ਜਗ੍ਹਾ ਘਟਾਉਂਦਾ ਹੈ। ਲਾਇਬ੍ਰੇਰੀ ਸਕੈਨ ਕਰਨ ਜਾਂ ਡੇਟਾਬੇਸ ਵਿੱਚ ਸੋਧਾਂ ਕਰਨ ਤੋਂ ਬਾਅਦ ਇਸ ਕੰਮ ਨੂੰ ਚਲਾਉਣਾ ਪ੍ਰਦਰਸ਼ਨ ਵਿੱਚ ਸੁਧਾਰ ਕਰ ਸਕਦਾ ਹੈ।", + "TaskExtractMediaSegmentsDescription": "ਮੀਡੀਆ ਸੈਗਮੈਂਟ ਨੂੰ ਮੀਡੀਆਸੈਗਮੈਂਟ ਯੋਗ ਪਲੱਗਇਨਾਂ ਤੋਂ ਨਿਕਾਲਦਾ ਜਾਂ ਪ੍ਰਾਪਤ ਕਰਦਾ ਹੈ।", + "TaskMoveTrickplayImages": "ਟ੍ਰਿਕਪਲੇ ਤਸਵੀਰਾਂ ਦੀ ਜਗਾ ਬਦਲੋ", + "TaskDownloadMissingLyrics": "ਅਧੂਰੇ ਬੋਲ ਡਾਊਨਲੋਡ ਕਰੋ", + "TaskDownloadMissingLyricsDescription": "ਗੀਤਾਂ ਲਈ ਡਾਊਨਲੋਡ ਕਿਤੇ ਬੋਲ", + "TaskKeyframeExtractor": "ਕੀ-ਫ੍ਰੇਮ ਐਕਸਟ੍ਰੈਕਟਰ", + "TaskCleanCollectionsAndPlaylistsDescription": "ਕਲੈਕਸ਼ਨਾਂ ਅਤੇ ਪਲੇਲਿਸਟਾਂ ਵਿੱਚੋਂ ਉਹ ਆਈਟਮ ਹਟਾਉਂਦਾ ਹੈ ਜੋ ਹੁਣ ਮੌਜੂਦ ਨਹੀਂ ਹਨ।", + "TaskCleanCollectionsAndPlaylists": "ਕਲੈਕਸ਼ਨਾਂ ਅਤੇ ਪਲੇਲਿਸਟਾਂ ਨੂੰ ਸਾਫ ਕਰੋ", + "TaskAudioNormalization": "ਆਵਾਜ਼ ਸਧਾਰਣੀਕਰਨ", + "TaskRefreshTrickplayImagesDescription": "ਚਲ ਰਹੀ ਲਾਇਬ੍ਰੇਰੀਆਂ ਵਿੱਚ ਵੀਡੀਓਜ਼ ਲਈ ਟ੍ਰਿਕਪਲੇ ਪ੍ਰੀਵਿਊ ਬਣਾਉਂਦਾ ਹੈ।", + "TaskKeyframeExtractorDescription": "ਕੀ-ਫ੍ਰੇਮਜ਼ ਨੂੰ ਵੀਡੀਓ ਫਾਈਲਾਂ ਵਿੱਚੋਂ ਨਿਕਾਲਦਾ ਹੈ ਤਾਂ ਜੋ ਹੋਰ ਜ਼ਿਆਦਾ ਸਟਿਕ ਹੋਣ ਵਾਲੀਆਂ HLS ਪਲੇਲਿਸਟਾਂ ਬਣਾਈਆਂ ਜਾ ਸਕਣ। ਇਹ ਕੰਮ ਲੰਬੇ ਸਮੇਂ ਤੱਕ ਚੱਲ ਸਕਦਾ ਹੈ।" } diff --git a/Emby.Server.Implementations/Localization/Core/pt-PT.json b/Emby.Server.Implementations/Localization/Core/pt-PT.json index 879bf64b0c..42ea5e0a46 100644 --- a/Emby.Server.Implementations/Localization/Core/pt-PT.json +++ b/Emby.Server.Implementations/Localization/Core/pt-PT.json @@ -1,6 +1,6 @@ { "Albums": "Álbuns", - "AppDeviceValues": "Aplicação {0}, Dispositivo: {1}", + "AppDeviceValues": "Aplicação: {0}, Dispositivo: {1}", "Application": "Aplicação", "Artists": "Artistas", "AuthenticationSucceededWithUserName": "{0} autenticado com sucesso", diff --git a/Emby.Server.Implementations/Localization/Core/pt.json b/Emby.Server.Implementations/Localization/Core/pt.json index 2812832cae..0bf0491bec 100644 --- a/Emby.Server.Implementations/Localization/Core/pt.json +++ b/Emby.Server.Implementations/Localization/Core/pt.json @@ -13,7 +13,7 @@ "HeaderContinueWatching": "Continuar a ver", "HeaderAlbumArtists": "Artistas do Álbum", "Genres": "Géneros", - "Folders": "Diretórios", + "Folders": "Pastas", "Favorites": "Favoritos", "Channels": "Canais", "UserDownloadingItemWithValues": "{0} está sendo baixado {1}", diff --git a/Emby.Server.Implementations/Localization/Core/ro.json b/Emby.Server.Implementations/Localization/Core/ro.json index bf59e15837..a873c157e6 100644 --- a/Emby.Server.Implementations/Localization/Core/ro.json +++ b/Emby.Server.Implementations/Localization/Core/ro.json @@ -77,7 +77,7 @@ "HeaderAlbumArtists": "Artiști album", "Genres": "Genuri", "Folders": "Dosare", - "Favorites": "Favorite", + "Favorites": "Preferate", "FailedLoginAttemptWithUserName": "Încercare de conectare eșuată pentru {0}", "DeviceOnlineWithName": "{0} este conectat", "DeviceOfflineWithName": "{0} s-a deconectat", diff --git a/Emby.Server.Implementations/Localization/Core/sl-SI.json b/Emby.Server.Implementations/Localization/Core/sl-SI.json index 19be1a23e0..b17e7ae559 100644 --- a/Emby.Server.Implementations/Localization/Core/sl-SI.json +++ b/Emby.Server.Implementations/Localization/Core/sl-SI.json @@ -126,5 +126,15 @@ "TaskKeyframeExtractorDescription": "Iz video datoteke Izvleče ključne sličice, da ustvari bolj natančne sezname predvajanja HLS. Proces lahko traja dolgo časa.", "HearingImpaired": "Oslabljen sluh", "TaskRefreshTrickplayImages": "Ustvari Trickplay slike", - "TaskRefreshTrickplayImagesDescription": "Ustvari trickplay predoglede za posnetke v omogočenih knjižnicah." + "TaskRefreshTrickplayImagesDescription": "Ustvari trickplay predoglede za posnetke v omogočenih knjižnicah.", + "TaskExtractMediaSegmentsDescription": "Ekstrahira ali pridobi medijske segmente iz vtičnikov, ki podpirajo MediaSegment.", + "TaskMoveTrickplayImagesDescription": "Premakne obstoječe datoteke trickplay v skladu z nastavitvami knjižnice.", + "TaskExtractMediaSegments": "Skeniranje segmentov v medijih", + "TaskMoveTrickplayImages": "Preseli lokacijo Trickplay slik", + "TaskDownloadMissingLyrics": "Prenesi manjkajoča besedila pesmi", + "TaskDownloadMissingLyricsDescription": "Prenesi besedila za pesmi", + "TaskCleanCollectionsAndPlaylists": "Počisti zbirke in sezname predvajanja", + "TaskAudioNormalization": "Normalizacija zvoka", + "TaskAudioNormalizationDescription": "Pregled datotek za podatke o normalizaciji zvoka.", + "TaskCleanCollectionsAndPlaylistsDescription": "Odstrani elemente iz zbirk in seznamov predvajanja, ki ne obstajajo več." } diff --git a/Emby.Server.Implementations/Localization/Core/sr.json b/Emby.Server.Implementations/Localization/Core/sr.json index 9739358df0..af40b5e5a9 100644 --- a/Emby.Server.Implementations/Localization/Core/sr.json +++ b/Emby.Server.Implementations/Localization/Core/sr.json @@ -78,7 +78,7 @@ "Genres": "Жанрови", "Folders": "Фасцикле", "Favorites": "Омиљено", - "FailedLoginAttemptWithUserName": "Неуспела пријава са {0}", + "FailedLoginAttemptWithUserName": "Неуспели покушај пријавe са {0}", "DeviceOnlineWithName": "{0} је повезан", "DeviceOfflineWithName": "{0} је прекинуо везу", "Collections": "Колекције", @@ -121,7 +121,10 @@ "TaskOptimizeDatabase": "Оптимизуј банку података", "TaskOptimizeDatabaseDescription": "Сажима базу података и скраћује слободан простор. Покретање овог задатка након скенирања библиотеке или других промена које подразумевају измене базе података које могу побољшати перформансе.", "External": "Спољно", - "TaskKeyframeExtractorDescription": "Екстрактује кљулне сличице из видео датотека да би креирао више преицзну HLS плеј-листу. Овај задатак може да потраје дуже време.", + "TaskKeyframeExtractorDescription": "Екстрактује кључне сличице из видео датотека да би креирао више прецизнију HLS плејлисту. Овај задатак може да потраје дуже време.", "TaskKeyframeExtractor": "Екстрактор кључних сличица", - "HearingImpaired": "ослабљен слух" + "HearingImpaired": "ослабљен слух", + "TaskAudioNormalization": "Нормализација звука", + "TaskCleanCollectionsAndPlaylists": "Очистите колекције и плејлисте", + "TaskAudioNormalizationDescription": "Скенира датотеке за податке о нормализацији звука." } diff --git a/Emby.Server.Implementations/Localization/Core/sv.json b/Emby.Server.Implementations/Localization/Core/sv.json index 5cf54522bf..60810b45d0 100644 --- a/Emby.Server.Implementations/Localization/Core/sv.json +++ b/Emby.Server.Implementations/Localization/Core/sv.json @@ -82,13 +82,13 @@ "UserCreatedWithName": "Användaren {0} har skapats", "UserDeletedWithName": "Användaren {0} har tagits bort", "UserDownloadingItemWithValues": "{0} laddar ner {1}", - "UserLockedOutWithName": "Användare {0} har låsts ute", - "UserOfflineFromDevice": "{0} har avbrutit anslutningen från {1}", + "UserLockedOutWithName": "Användare {0} har utelåsts", + "UserOfflineFromDevice": "{0} har kopplat ned från {1}", "UserOnlineFromDevice": "{0} är uppkopplad från {1}", "UserPasswordChangedWithName": "Lösenordet för {0} har ändrats", "UserPolicyUpdatedWithName": "Användarpolicyn har uppdaterats för {0}", - "UserStartedPlayingItemWithValues": "{0} spelar upp {1} på {2}", - "UserStoppedPlayingItemWithValues": "{0} har avslutat uppspelningen av {1} på {2}", + "UserStartedPlayingItemWithValues": "{0} spelar {1} på {2}", + "UserStoppedPlayingItemWithValues": "{0} har stoppat uppspelningen av {1} på {2}", "ValueHasBeenAddedToLibrary": "{0} har lagts till i ditt mediebibliotek", "ValueSpecialEpisodeName": "Specialavsnitt - {0}", "VersionNumber": "Version {0}", @@ -98,8 +98,8 @@ "TaskRefreshChannels": "Uppdatera kanaler", "TaskCleanTranscodeDescription": "Raderar omkodningsfiler äldre än en dag.", "TaskCleanTranscode": "Rensa omkodningskatalog", - "TaskUpdatePluginsDescription": "Laddar ned och installerar uppdateringar till tilläggsprogram som är konfigurerade att uppdateras automatiskt.", - "TaskUpdatePlugins": "Uppdatera tilläggsprogram", + "TaskUpdatePluginsDescription": "Laddar ned och installerar uppdateringar till tillägg som är konfigurerade att uppdateras automatiskt.", + "TaskUpdatePlugins": "Uppdatera tillägg", "TaskRefreshPeopleDescription": "Uppdaterar metadata för skådespelare och regissörer i ditt mediabibliotek.", "TaskCleanLogsDescription": "Raderar loggfiler som är mer än {0} dagar gamla.", "TaskCleanLogs": "Rensa loggkatalog", diff --git a/Emby.Server.Implementations/Localization/Core/uz.json b/Emby.Server.Implementations/Localization/Core/uz.json index a1b3035f37..150fb71263 100644 --- a/Emby.Server.Implementations/Localization/Core/uz.json +++ b/Emby.Server.Implementations/Localization/Core/uz.json @@ -23,5 +23,92 @@ "HeaderLiveTV": "Jonli TV", "HeaderNextUp": "Keyingisi", "ItemAddedWithName": "{0} kutbxonaga qo'shildi", - "LabelIpAddressValue": "IP manzil: {0}" + "LabelIpAddressValue": "IP manzil: {0}", + "SubtitleDownloadFailureFromForItem": "{0} dan {1} uchun taglavhalarni yuklab boʻlmadi", + "UserPasswordChangedWithName": "Foydalanuvchi {0} paroli oʻzgartirildi", + "ValueHasBeenAddedToLibrary": "{0} kutubxonaga qoʻshildi", + "TaskCleanActivityLogDescription": "Belgilangan yoshdan kattaroq faoliyat jurnali yozuvlarini oʻchiradi.", + "TaskAudioNormalization": "Ovozni normallashtirish", + "TaskRefreshLibraryDescription": "Media kutubxonasi yangi fayllar uchun skanerlanmoqda va metama'lumotlar yangilanmoqda.", + "Default": "Joriy", + "HeaderFavoriteAlbums": "Tanlangan albomlar", + "HeaderFavoriteArtists": "Tanlangan artistlar", + "HeaderFavoriteEpisodes": "Tanlangan epizodlar", + "HeaderFavoriteShows": "Tanlangan shoular", + "HeaderFavoriteSongs": "Tanlangan qo'shiqlar", + "HeaderRecordingGroups": "Yozuvlar guruhi", + "HomeVideos": "Uy videolari", + "NotificationOptionVideoPlaybackStopped": "Video ijrosi toʻxtatildi", + "TvShows": "TV seriallar", + "Undefined": "Belgilanmagan", + "User": "Foydalanuvchi", + "UserCreatedWithName": "{0} foydalanuvchi yaratildi", + "TaskCleanCacheDescription": "Tizimga kerak bo'lmagan kesh fayllari o'chiriladi.", + "TaskAudioNormalizationDescription": "Ovozni normallashtirish ma'lumotlari uchun fayllarni skanerlaydi.", + "PluginInstalledWithName": "{0} - o'rnatildi", + "PluginUninstalledWithName": "{0} - o'chirildi", + "HearingImpaired": "Yaxshi eshitmaydiganlar uchun", + "Inherit": "Meroslangan", + "NotificationOptionApplicationUpdateAvailable": "Ilova yangilanishi mavjud", + "NotificationOptionApplicationUpdateInstalled": "Ilova yangilanishi oʻrnatildi", + "LabelRunningTimeValue": "Davomiyligi", + "NotificationOptionAudioPlayback": "Audio tinglash boshlandi", + "NotificationOptionAudioPlaybackStopped": "Audio tinglash to'xtatildi", + "NotificationOptionCameraImageUploaded": "Kamera tasvirlari yuklandi", + "NotificationOptionInstallationFailed": "O'rnatishda hatolik", + "NotificationOptionNewLibraryContent": "Yangi tarkib qo'shildi", + "NotificationOptionPluginError": "Plagin ishdan chiqdi", + "NotificationOptionPluginInstalled": "Plagin o'rnatildi", + "NotificationOptionPluginUninstalled": "Plagin o'chirildi", + "NotificationOptionPluginUpdateInstalled": "Plagin uchun yangilanish o'rnatildi", + "NotificationOptionServerRestartRequired": "Server-ni qayta yuklash lozim", + "NotificationOptionTaskFailed": "Rejalashtirilgan vazifa bajarilmadi", + "NotificationOptionUserLockedOut": "Foydalanuvchi bloklangan", + "NotificationOptionVideoPlayback": "Video ijrosi boshlandi", + "Photos": "Surat", + "Latest": "So'ngi", + "MessageApplicationUpdated": "Jellyfin Server yangilandi", + "MessageApplicationUpdatedTo": "Jellyfin Server {0} gacha yangilandi", + "MessageNamedServerConfigurationUpdatedWithValue": "Server konfiguratsiyasi ({0}-boʻlim) yangilandi", + "MessageServerConfigurationUpdated": "Server konfiguratsiyasi yangilandi", + "MixedContent": "Aralashgan tarkib", + "Movies": "Kinolar", + "Music": "Qo'shiqlar", + "MusicVideos": "Musiqali videolar", + "NameInstallFailed": "Omadsiz ornatish {0}", + "NameSeasonNumber": "{0} Fasl", + "NameSeasonUnknown": "Fasl aniqlanmagan", + "Playlists": "Pleylistlar", + "NewVersionIsAvailable": "Yuklab olish uchun Jellyfin Server ning yangi versiyasi mavjud", + "Plugin": "Plagin", + "TaskCleanLogs": "Jurnallar katalogini tozalash", + "PluginUpdatedWithName": "{0} - yangilandi", + "ProviderValue": "Yetkazib beruvchi: {0}", + "ScheduledTaskFailedWithName": "{0} - omadsiz", + "ScheduledTaskStartedWithName": "{0} - ishga tushirildi", + "ServerNameNeedsToBeRestarted": "Qayta yuklash kerak {0}", + "Shows": "Teleko'rsatuv", + "Songs": "Kompozitsiyalar", + "StartupEmbyServerIsLoading": "Jellyfin Server yuklanmoqda. Tez orada qayta urinib koʻring.", + "Sync": "Sinxronizatsiya", + "System": "Tizim", + "UserDeletedWithName": "{0} foydalanuvchisi oʻchirib tashlandi", + "UserDownloadingItemWithValues": "{0} yuklanmoqda {1}", + "UserLockedOutWithName": "{0} foydalanuvchisi bloklandi", + "UserOfflineFromDevice": "{0} {1}dan uzildi", + "UserOnlineFromDevice": "{0} {1} dan ulandi", + "UserPolicyUpdatedWithName": "{0} foydalanuvchisining siyosatlari yangilandi", + "UserStartedPlayingItemWithValues": "{0} - {2} da \"{1}\" ijrosi", + "UserStoppedPlayingItemWithValues": "{0} - ijro etish to‘xtatildi {1} {2}", + "ValueSpecialEpisodeName": "Maxsus qism – {0}", + "VersionNumber": "Versiya {0}", + "TasksMaintenanceCategory": "Xizmat ko'rsatish", + "TasksLibraryCategory": "Media kutubxona", + "TasksApplicationCategory": "Ilova", + "TasksChannelsCategory": "Internet kanallari", + "TaskCleanActivityLog": "Faoliyat jurnalini tozalash", + "TaskCleanCache": "Kesh katalogini tozalash", + "TaskRefreshChapterImages": "Sahnadan tasvirini chiqarish", + "TaskRefreshChapterImagesDescription": "Sahnalarni o'z ichiga olgan videolar uchun eskizlarni yaratadi.", + "TaskRefreshLibrary": "Media kutubxonangizni skanerlash" } diff --git a/Emby.Server.Implementations/Localization/Core/zh-HK.json b/Emby.Server.Implementations/Localization/Core/zh-HK.json index 3ab9774c27..286efb7e92 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-HK.json +++ b/Emby.Server.Implementations/Localization/Core/zh-HK.json @@ -126,5 +126,15 @@ "External": "外部", "HearingImpaired": "聽力障礙", "TaskRefreshTrickplayImages": "建立 Trickplay 圖像", - "TaskRefreshTrickplayImagesDescription": "為已啟用 Trickplay 的媒體庫內的影片建立 Trickplay 預覽圖。" + "TaskRefreshTrickplayImagesDescription": "為已啟用 Trickplay 的媒體庫內的影片建立 Trickplay 預覽圖。", + "TaskExtractMediaSegments": "掃描媒體段落", + "TaskExtractMediaSegmentsDescription": "從MediaSegment中被允許的插件獲取媒體段落。", + "TaskDownloadMissingLyrics": "下載欠缺歌詞", + "TaskDownloadMissingLyricsDescription": "下載歌詞", + "TaskCleanCollectionsAndPlaylists": "整理媒體與播放清單", + "TaskAudioNormalization": "音訊同等化", + "TaskAudioNormalizationDescription": "掃描檔案裏的音訊同等化資料。", + "TaskCleanCollectionsAndPlaylistsDescription": "從資料庫及播放清單中移除已不存在的項目。", + "TaskMoveTrickplayImagesDescription": "根據媒體庫設定移動現有的 Trickplay 檔案。", + "TaskMoveTrickplayImages": "轉移 Trickplay 影像位置" } diff --git a/Emby.Server.Implementations/Localization/Core/zh-TW.json b/Emby.Server.Implementations/Localization/Core/zh-TW.json index 81d5b83d61..a4ee68fc45 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-TW.json +++ b/Emby.Server.Implementations/Localization/Core/zh-TW.json @@ -8,7 +8,7 @@ "CameraImageUploadedFrom": "已從 {0} 成功上傳一張相片", "Channels": "頻道", "ChapterNameValue": "章節 {0}", - "Collections": "系列", + "Collections": "系列作", "DeviceOfflineWithName": "{0} 已中斷連接", "DeviceOnlineWithName": "{0} 已連接", "FailedLoginAttemptWithUserName": "來自使用者 {0} 的登入失敗嘗試", @@ -126,8 +126,8 @@ "HearingImpaired": "聽力障礙", "TaskRefreshTrickplayImages": "生成快轉縮圖", "TaskRefreshTrickplayImagesDescription": "為啟用快轉縮圖的媒體庫生成快轉縮圖。", - "TaskCleanCollectionsAndPlaylists": "清理系列和播放清單", - "TaskCleanCollectionsAndPlaylistsDescription": "清理系列和播放清單中已不存在的項目。", + "TaskCleanCollectionsAndPlaylists": "清理系列作和播放清單", + "TaskCleanCollectionsAndPlaylistsDescription": "清理系列作品與播放清單中已不存在的項目。", "TaskAudioNormalization": "音量標準化", "TaskAudioNormalizationDescription": "掃描文件以找出音量標準化資料。", "TaskDownloadMissingLyrics": "下載缺少的歌詞", diff --git a/Emby.Server.Implementations/Localization/LocalizationManager.cs b/Emby.Server.Implementations/Localization/LocalizationManager.cs index ac453a5b09..17db7ad4c4 100644 --- a/Emby.Server.Implementations/Localization/LocalizationManager.cs +++ b/Emby.Server.Implementations/Localization/LocalizationManager.cs @@ -1,7 +1,8 @@ using System; using System.Collections.Concurrent; +using System.Collections.Frozen; using System.Collections.Generic; -using System.Globalization; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Reflection; @@ -26,20 +27,20 @@ namespace Emby.Server.Implementations.Localization private const string CulturesPath = "Emby.Server.Implementations.Localization.iso6392.txt"; private const string CountriesPath = "Emby.Server.Implementations.Localization.countries.json"; private static readonly Assembly _assembly = typeof(LocalizationManager).Assembly; - private static readonly string[] _unratedValues = { "n/a", "unrated", "not rated", "nr" }; + private static readonly string[] _unratedValues = ["n/a", "unrated", "not rated", "nr"]; private readonly IServerConfigurationManager _configurationManager; private readonly ILogger _logger; - private readonly Dictionary> _allParentalRatings = - new Dictionary>(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary> _allParentalRatings = new(StringComparer.OrdinalIgnoreCase); - private readonly ConcurrentDictionary> _dictionaries = - new ConcurrentDictionary>(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary> _dictionaries = new(StringComparer.OrdinalIgnoreCase); private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; - private List _cultures = new List(); + private List _cultures = []; + + private FrozenDictionary _iso6392BtoT = null!; /// /// Initializes a new instance of the class. @@ -68,35 +69,26 @@ namespace Emby.Server.Implementations.Localization continue; } - string countryCode = resource.Substring(RatingsPath.Length, 2); - var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); - - var stream = _assembly.GetManifestResourceStream(resource); - await using (stream!.ConfigureAwait(false)) // shouldn't be null here, we just got the resource path from Assembly.GetManifestResourceNames() + using var stream = _assembly.GetManifestResourceStream(resource); + if (stream is not null) { - using var reader = new StreamReader(stream!); - await foreach (var line in reader.ReadAllLinesAsync().ConfigureAwait(false)) + var ratingSystem = await JsonSerializer.DeserializeAsync(stream, _jsonOptions).ConfigureAwait(false) + ?? throw new InvalidOperationException($"Invalid resource path: '{CountriesPath}'"); + + var dict = new Dictionary(); + if (ratingSystem.Ratings is not null) { - if (string.IsNullOrWhiteSpace(line)) + foreach (var ratingEntry in ratingSystem.Ratings) { - continue; + foreach (var ratingString in ratingEntry.RatingStrings) + { + dict[ratingString] = ratingEntry.RatingScore; + } } - string[] parts = line.Split(','); - if (parts.Length == 2 - && int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)) - { - var name = parts[0]; - dict.Add(name, new ParentalRating(name, value)); - } - else - { - _logger.LogWarning("Malformed line in ratings file for country {CountryCode}", countryCode); - } + _allParentalRatings[ratingSystem.CountryCode] = dict; } } - - _allParentalRatings[countryCode] = dict; } await LoadCultures().ConfigureAwait(false); @@ -111,22 +103,30 @@ namespace Emby.Server.Implementations.Localization private async Task LoadCultures() { - List list = new List(); + List list = []; + Dictionary iso6392BtoTdict = new Dictionary(); - await using var stream = _assembly.GetManifestResourceStream(CulturesPath) - ?? throw new InvalidOperationException($"Invalid resource path: '{CulturesPath}'"); - using var reader = new StreamReader(stream); - await foreach (var line in reader.ReadAllLinesAsync().ConfigureAwait(false)) + using var stream = _assembly.GetManifestResourceStream(CulturesPath); + if (stream is null) { - if (string.IsNullOrWhiteSpace(line)) + throw new InvalidOperationException($"Invalid resource path: '{CulturesPath}'"); + } + else + { + using var reader = new StreamReader(stream); + await foreach (var line in reader.ReadAllLinesAsync().ConfigureAwait(false)) { - continue; - } + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } - var parts = line.Split('|'); + var parts = line.Split('|'); + if (parts.Length != 5) + { + throw new InvalidDataException($"Invalid culture data found at: '{line}'"); + } - if (parts.Length == 5) - { string name = parts[3]; if (string.IsNullOrWhiteSpace(name)) { @@ -139,21 +139,26 @@ namespace Emby.Server.Implementations.Localization continue; } - string[] threeletterNames; + string[] threeLetterNames; if (string.IsNullOrWhiteSpace(parts[1])) { - threeletterNames = new[] { parts[0] }; + threeLetterNames = [parts[0]]; } else { - threeletterNames = new[] { parts[0], parts[1] }; + threeLetterNames = [parts[0], parts[1]]; + + // In cases where there are two TLN the first one is ISO 639-2/T and the second one is ISO 639-2/B + // We need ISO 639-2/T for the .NET cultures so we cultivate a dictionary for the translation B->T + iso6392BtoTdict.TryAdd(parts[1], parts[0]); } - list.Add(new CultureDto(name, name, twoCharName, threeletterNames)); + list.Add(new CultureDto(name, name, twoCharName, threeLetterNames)); } - } - _cultures = list; + _cultures = list; + _iso6392BtoT = iso6392BtoTdict.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); + } } /// @@ -176,82 +181,80 @@ namespace Emby.Server.Implementations.Localization } /// - public IEnumerable GetCountries() + public IReadOnlyList GetCountries() { - using StreamReader reader = new StreamReader( - _assembly.GetManifestResourceStream(CountriesPath) ?? throw new InvalidOperationException($"Invalid resource path: '{CountriesPath}'")); - return JsonSerializer.Deserialize>(reader.ReadToEnd(), _jsonOptions) - ?? throw new InvalidOperationException($"Resource contains invalid data: '{CountriesPath}'"); + using var stream = _assembly.GetManifestResourceStream(CountriesPath) ?? throw new InvalidOperationException($"Invalid resource path: '{CountriesPath}'"); + + return JsonSerializer.Deserialize>(stream, _jsonOptions) ?? []; } /// - public IEnumerable GetParentalRatings() + public IReadOnlyList GetParentalRatings() { // Use server default language for ratings // Fall back to empty list if there are no parental ratings for that language - var ratings = GetParentalRatingsDictionary()?.Values.ToList() - ?? new List(); + var ratings = GetParentalRatingsDictionary()?.Select(x => new ParentalRating(x.Key, x.Value)).ToList() ?? []; // Add common ratings to ensure them being available for selection // Based on the US rating system due to it being the main source of rating in the metadata providers // Unrated - if (!ratings.Any(x => x.Value is null)) + if (!ratings.Any(x => x is null)) { - ratings.Add(new ParentalRating("Unrated", null)); + ratings.Add(new("Unrated", null)); } // Minimum rating possible - if (ratings.All(x => x.Value != 0)) + if (ratings.All(x => x.RatingScore?.Score != 0)) { - ratings.Add(new ParentalRating("Approved", 0)); + ratings.Add(new("Approved", new(0, null))); } // Matches PG (this has different age restrictions depending on country) - if (ratings.All(x => x.Value != 10)) + if (ratings.All(x => x.RatingScore?.Score != 10)) { - ratings.Add(new ParentalRating("10", 10)); + ratings.Add(new("10", new(10, null))); } // Matches PG-13 - if (ratings.All(x => x.Value != 13)) + if (ratings.All(x => x.RatingScore?.Score != 13)) { - ratings.Add(new ParentalRating("13", 13)); + ratings.Add(new("13", new(13, null))); } // Matches TV-14 - if (ratings.All(x => x.Value != 14)) + if (ratings.All(x => x.RatingScore?.Score != 14)) { - ratings.Add(new ParentalRating("14", 14)); + ratings.Add(new("14", new(14, null))); } // Catchall if max rating of country is less than 21 // Using 21 instead of 18 to be sure to allow access to all rated content except adult and banned - if (!ratings.Any(x => x.Value >= 21)) + if (!ratings.Any(x => x.RatingScore?.Score >= 21)) { - ratings.Add(new ParentalRating("21", 21)); + ratings.Add(new ParentalRating("21", new(21, null))); } - // A lot of countries don't excplicitly have a seperate rating for adult content - if (ratings.All(x => x.Value != 1000)) + // A lot of countries don't explicitly have a separate rating for adult content + if (ratings.All(x => x.RatingScore?.Score != 1000)) { - ratings.Add(new ParentalRating("XXX", 1000)); + ratings.Add(new ParentalRating("XXX", new(1000, null))); } - // A lot of countries don't excplicitly have a seperate rating for banned content - if (ratings.All(x => x.Value != 1001)) + // A lot of countries don't explicitly have a separate rating for banned content + if (ratings.All(x => x.RatingScore?.Score != 1001)) { - ratings.Add(new ParentalRating("Banned", 1001)); + ratings.Add(new ParentalRating("Banned", new(1001, null))); } - return ratings.OrderBy(r => r.Value); + return [.. ratings.OrderBy(r => r.RatingScore?.Score).ThenBy(r => r.RatingScore?.SubScore)]; } /// /// Gets the parental ratings dictionary. /// /// The optional two letter ISO language string. - /// . - private Dictionary? GetParentalRatingsDictionary(string? countryCode = null) + /// . + private Dictionary? GetParentalRatingsDictionary(string? countryCode = null) { // Fallback to server default if no country code is specified. if (string.IsNullOrEmpty(countryCode)) @@ -268,7 +271,7 @@ namespace Emby.Server.Implementations.Localization } /// - public int? GetRatingLevel(string rating, string? countryCode = null) + public ParentalRatingScore? GetRatingScore(string rating, string? countryCode = null) { ArgumentException.ThrowIfNullOrEmpty(rating); @@ -278,24 +281,26 @@ namespace Emby.Server.Implementations.Localization return null; } - // Convert integers directly + // 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)) { - return ratingAge; + return new(ratingAge, null); } // Fairly common for some users to have "Rated R" in their rating field - rating = rating.Replace("Rated :", string.Empty, StringComparison.OrdinalIgnoreCase); - rating = rating.Replace("Rated ", string.Empty, StringComparison.OrdinalIgnoreCase); + rating = rating.Replace("Rated :", string.Empty, StringComparison.OrdinalIgnoreCase) + .Replace("Rated:", string.Empty, StringComparison.OrdinalIgnoreCase) + .Replace("Rated ", string.Empty, StringComparison.OrdinalIgnoreCase) + .Trim(); // Use rating system matching the language if (!string.IsNullOrEmpty(countryCode)) { var ratingsDictionary = GetParentalRatingsDictionary(countryCode); - if (ratingsDictionary is not null && ratingsDictionary.TryGetValue(rating, out ParentalRating? value)) + if (ratingsDictionary is not null && ratingsDictionary.TryGetValue(rating, out ParentalRatingScore? value)) { - return value.Value; + return value; } } else @@ -303,9 +308,9 @@ namespace Emby.Server.Implementations.Localization // Fall back to server default language for ratings check // If it has no ratings, use the US ratings var ratingsDictionary = GetParentalRatingsDictionary() ?? GetParentalRatingsDictionary("us"); - if (ratingsDictionary is not null && ratingsDictionary.TryGetValue(rating, out ParentalRating? value)) + if (ratingsDictionary is not null && ratingsDictionary.TryGetValue(rating, out ParentalRatingScore? value)) { - return value.Value; + return value; } } @@ -314,7 +319,7 @@ namespace Emby.Server.Implementations.Localization { if (dictionary.TryGetValue(rating, out var value)) { - return value.Value; + return value; } } @@ -324,7 +329,7 @@ namespace Emby.Server.Implementations.Localization var ratingLevelRightPart = rating.AsSpan().RightPart(':'); if (ratingLevelRightPart.Length != 0) { - return GetRatingLevel(ratingLevelRightPart.ToString()); + return GetRatingScore(ratingLevelRightPart.ToString()); } } @@ -340,7 +345,7 @@ namespace Emby.Server.Implementations.Localization if (ratingLevelRightPart.Length != 0) { // Check rating system of culture - return GetRatingLevel(ratingLevelRightPart.ToString(), culture?.TwoLetterISOLanguageName); + return GetRatingScore(ratingLevelRightPart.ToString(), culture?.TwoLetterISOLanguageName); } } @@ -404,7 +409,7 @@ namespace Emby.Server.Implementations.Localization private async Task CopyInto(IDictionary dictionary, string resourcePath) { - await using var stream = _assembly.GetManifestResourceStream(resourcePath); + using var stream = _assembly.GetManifestResourceStream(resourcePath); // If a Culture doesn't have a translation the stream will be null and it defaults to en-us further up the chain if (stream is null) { @@ -412,12 +417,7 @@ namespace Emby.Server.Implementations.Localization return; } - var dict = await JsonSerializer.DeserializeAsync>(stream, _jsonOptions).ConfigureAwait(false); - if (dict is null) - { - throw new InvalidOperationException($"Resource contains invalid data: '{stream}'"); - } - + var dict = await JsonSerializer.DeserializeAsync>(stream, _jsonOptions).ConfigureAwait(false) ?? throw new InvalidOperationException($"Resource contains invalid data: '{stream}'"); foreach (var key in dict.Keys) { dictionary[key] = dict[key]; @@ -515,5 +515,26 @@ namespace Emby.Server.Implementations.Localization yield return new LocalizationOption("漢語 (繁體字)", "zh-TW"); yield return new LocalizationOption("廣東話 (香港)", "zh-HK"); } + + /// + public bool TryGetISO6392TFromB(string isoB, [NotNullWhen(true)] out string? isoT) + { + // Unlikely case the dictionary is not (yet) initialized properly + if (_iso6392BtoT == null) + { + isoT = null; + return false; + } + + var result = _iso6392BtoT.TryGetValue(isoB, out isoT) && !string.IsNullOrEmpty(isoT); + + // Ensure the ISO code being null if the result is false + if (!result) + { + isoT = null; + } + + return result; + } } } diff --git a/Emby.Server.Implementations/Localization/Ratings/0-prefer.csv b/Emby.Server.Implementations/Localization/Ratings/0-prefer.csv deleted file mode 100644 index 36886ba760..0000000000 --- a/Emby.Server.Implementations/Localization/Ratings/0-prefer.csv +++ /dev/null @@ -1,11 +0,0 @@ -E,0 -EC,0 -T,7 -M,18 -AO,18 -UR,18 -RP,18 -X,1000 -XX,1000 -XXX,1000 -XXXX,1000 diff --git a/Emby.Server.Implementations/Localization/Ratings/0-prefer.json b/Emby.Server.Implementations/Localization/Ratings/0-prefer.json new file mode 100644 index 0000000000..b390151611 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Ratings/0-prefer.json @@ -0,0 +1,34 @@ +{ + "countryCode": "0-prefer", + "supportsSubScores": false, + "ratings": [ + { + "ratingStrings": ["E", "EC"], + "ratingScore": { + "score": 0, + "subScore": null + } + }, + { + "ratingStrings": ["T"], + "ratingScore": { + "score": 7, + "subScore": null + } + }, + { + "ratingStrings": ["M", "AO", "UR", "RP"], + "ratingScore": { + "score": 18, + "subScore": null + } + }, + { + "ratingStrings": ["X", "XX", "XXX", "XXXX"], + "ratingScore": { + "score": 1000, + "subScore": null + } + } + ] +} diff --git a/Emby.Server.Implementations/Localization/Ratings/au.csv b/Emby.Server.Implementations/Localization/Ratings/au.csv deleted file mode 100644 index 6e12759a46..0000000000 --- a/Emby.Server.Implementations/Localization/Ratings/au.csv +++ /dev/null @@ -1,17 +0,0 @@ -Exempt,0 -G,0 -7+,7 -PG,15 -M,15 -MA,15 -MA15+,15 -MA 15+,15 -16+,16 -R,18 -R18+,18 -R 18+,18 -18+,18 -X18+,1000 -X 18+,1000 -X,1000 -RC,1001 diff --git a/Emby.Server.Implementations/Localization/Ratings/au.json b/Emby.Server.Implementations/Localization/Ratings/au.json new file mode 100644 index 0000000000..a563df899d --- /dev/null +++ b/Emby.Server.Implementations/Localization/Ratings/au.json @@ -0,0 +1,69 @@ +{ + "countryCode": "au", + "supportsSubScores": true, + "ratings": [ + { + "ratingStrings": ["Exempt", "G"], + "ratingScore": { + "score": 0, + "subScore": 0 + } + }, + { + "ratingStrings": ["7+"], + "ratingScore": { + "score": 7, + "subScore": 0 + } + }, + { + "ratingStrings": ["PG"], + "ratingScore": { + "score": 15, + "subScore": 1 + } + }, + { + "ratingStrings": ["M"], + "ratingScore": { + "score": 15, + "subScore": 2 + } + }, + { + "ratingStrings": ["MA", "MA 15+", "MA15+"], + "ratingScore": { + "score": 15, + "subScore": 3 + } + }, + { + "ratingStrings": ["16+"], + "ratingScore": { + "score": 16, + "subScore": 0 + } + }, + { + "ratingStrings": ["18+", "R", "R18+", "R 18+"], + "ratingScore": { + "score": 18, + "subScore": 1 + } + }, + { + "ratingStrings": ["X", "X18", "X 18"], + "ratingScore": { + "score": 1000, + "subScore": 0 + } + }, + { + "ratingStrings": ["RC"], + "ratingScore": { + "score": 1001, + "subScore": 0 + } + } + ] +} diff --git a/Emby.Server.Implementations/Localization/Ratings/be.csv b/Emby.Server.Implementations/Localization/Ratings/be.csv deleted file mode 100644 index d171a71328..0000000000 --- a/Emby.Server.Implementations/Localization/Ratings/be.csv +++ /dev/null @@ -1,11 +0,0 @@ -AL,0 -KT,0 -TOUS,0 -MG6,6 -6,6 -9,9 -KNT,12 -12,12 -14,14 -16,16 -18,18 diff --git a/Emby.Server.Implementations/Localization/Ratings/be.json b/Emby.Server.Implementations/Localization/Ratings/be.json new file mode 100644 index 0000000000..18ea2c2605 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Ratings/be.json @@ -0,0 +1,55 @@ +{ + "countryCode": "be", + "supportsSubScores": false, + "ratings": [ + { + "ratingStrings": ["AL", "KT", "TOUS"], + "ratingScore": { + "score": 0, + "subScore": null + } + }, + { + "ratingStrings": ["6", "MG6"], + "ratingScore": { + "score": 6, + "subScore": null + } + }, + { + "ratingStrings": ["9"], + "ratingScore": { + "score": 9, + "subScore": null + } + }, + { + "ratingStrings": ["12", "KNT"], + "ratingScore": { + "score": 12, + "subScore": null + } + }, + { + "ratingStrings": ["14"], + "ratingScore": { + "score": 14, + "subScore": null + } + }, + { + "ratingStrings": ["16"], + "ratingScore": { + "score": 16, + "subScore": null + } + }, + { + "ratingStrings": ["18"], + "ratingScore": { + "score": 18, + "subScore": null + } + } + ] +} diff --git a/Emby.Server.Implementations/Localization/Ratings/br.csv b/Emby.Server.Implementations/Localization/Ratings/br.csv deleted file mode 100644 index 5ec1eb2627..0000000000 --- a/Emby.Server.Implementations/Localization/Ratings/br.csv +++ /dev/null @@ -1,8 +0,0 @@ -Livre,0 -L,0 -ER,9 -10,10 -12,12 -14,14 -16,16 -18,18 diff --git a/Emby.Server.Implementations/Localization/Ratings/br.json b/Emby.Server.Implementations/Localization/Ratings/br.json new file mode 100644 index 0000000000..f455b6643f --- /dev/null +++ b/Emby.Server.Implementations/Localization/Ratings/br.json @@ -0,0 +1,55 @@ +{ + "countryCode": "br", + "supportsSubScores": false, + "ratings": [ + { + "ratingStrings": ["L", "AL", "Livre"], + "ratingScore": { + "score": 0, + "subScore": null + } + }, + { + "ratingStrings": ["9"], + "ratingScore": { + "score": 9, + "subScore": null + } + }, + { + "ratingStrings": ["10", "A10", "ER"], + "ratingScore": { + "score": 10, + "subScore": null + } + }, + { + "ratingStrings": ["12", "A12"], + "ratingScore": { + "score": 12, + "subScore": null + } + }, + { + "ratingStrings": ["14", "A14"], + "ratingScore": { + "score": 14, + "subScore": null + } + }, + { + "ratingStrings": ["16", "A16"], + "ratingScore": { + "score": 16, + "subScore": null + } + }, + { + "ratingStrings": ["18", "A18"], + "ratingScore": { + "score": 18, + "subScore": null + } + } + ] +} diff --git a/Emby.Server.Implementations/Localization/Ratings/ca.csv b/Emby.Server.Implementations/Localization/Ratings/ca.csv deleted file mode 100644 index 336ee28067..0000000000 --- a/Emby.Server.Implementations/Localization/Ratings/ca.csv +++ /dev/null @@ -1,20 +0,0 @@ -E,0 -G,0 -TV-Y,0 -TV-G,0 -TV-Y7,7 -TV-Y7-FV,7 -PG,9 -TV-PG,9 -PG-13,13 -13+,13 -TV-14,14 -14A,14 -16+,16 -NC-17,17 -R,18 -TV-MA,18 -18A,18 -18+,18 -A,1000 -Prohibited,1001 diff --git a/Emby.Server.Implementations/Localization/Ratings/ca.json b/Emby.Server.Implementations/Localization/Ratings/ca.json new file mode 100644 index 0000000000..fa43a8f2b7 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Ratings/ca.json @@ -0,0 +1,90 @@ +{ + "countryCode": "ca", + "supportsSubScores": true, + "ratings": [ + { + "ratingStrings": ["E", "G", "TV-Y", "TV-G"], + "ratingScore": { + "score": 0, + "subScore": 0 + } + }, + { + "ratingStrings": ["TV-Y7"], + "ratingScore": { + "score": 7, + "subScore": 0 + } + }, + { + "ratingStrings": ["TV-Y7-FV"], + "ratingScore": { + "score": 7, + "subScore": 1 + } + }, + { + "ratingStrings": ["PG", "TV-PG"], + "ratingScore": { + "score": 9, + "subScore": 0 + } + }, + { + "ratingStrings": ["14A"], + "ratingScore": { + "score": 14, + "subScore": 0 + } + }, + { + "ratingStrings": ["TV-14"], + "ratingScore": { + "score": 14, + "subScore": 1 + } + }, + { + "ratingStrings": ["16+"], + "ratingScore": { + "score": 16, + "subScore": 0 + } + }, + { + "ratingStrings": ["NC-17"], + "ratingScore": { + "score": 17, + "subScore": 0 + } + }, + { + "ratingStrings": ["18A"], + "ratingScore": { + "score": 18, + "subScore": 0 + } + }, + { + "ratingStrings": ["18+", "TV-MA", "R"], + "ratingScore": { + "score": 18, + "subScore": 1 + } + }, + { + "ratingStrings": ["A"], + "ratingScore": { + "score": 1000, + "subScore": 0 + } + }, + { + "ratingStrings": ["Prohibited"], + "ratingScore": { + "score": 1001, + "subScore": 0 + } + } + ] +} diff --git a/Emby.Server.Implementations/Localization/Ratings/cl.json b/Emby.Server.Implementations/Localization/Ratings/cl.json new file mode 100644 index 0000000000..0866194715 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Ratings/cl.json @@ -0,0 +1,41 @@ +{ + "countryCode": "cl", + "supportsSubScores": false, + "ratings": [ + { + "ratingStrings": ["TE"], + "ratingScore": { + "score": 0, + "subScore": null + } + }, + { + "ratingStrings": ["6"], + "ratingScore": { + "score": 6, + "subScore": null + } + }, + { + "ratingStrings": ["TE+7"], + "ratingScore": { + "score": 7, + "subScore": null + } + }, + { + "ratingStrings": ["14"], + "ratingScore": { + "score": 14, + "subScore": null + } + }, + { + "ratingStrings": ["18", "18V", "18S"], + "ratingScore": { + "score": 18, + "subScore": null + } + } + ] +} diff --git a/Emby.Server.Implementations/Localization/Ratings/co.csv b/Emby.Server.Implementations/Localization/Ratings/co.csv deleted file mode 100644 index e1e96c5909..0000000000 --- a/Emby.Server.Implementations/Localization/Ratings/co.csv +++ /dev/null @@ -1,7 +0,0 @@ -T,0 -7,7 -12,12 -15,15 -18,18 -X,1000 -Prohibited,1001 diff --git a/Emby.Server.Implementations/Localization/Ratings/co.json b/Emby.Server.Implementations/Localization/Ratings/co.json new file mode 100644 index 0000000000..4eff6dcc53 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Ratings/co.json @@ -0,0 +1,55 @@ +{ + "countryCode": "co", + "supportsSubScores": false, + "ratings": [ + { + "ratingStrings": ["T"], + "ratingScore": { + "score": 0, + "subScore": null + } + }, + { + "ratingStrings": ["7"], + "ratingScore": { + "score": 7, + "subScore": null + } + }, + { + "ratingStrings": ["12"], + "ratingScore": { + "score": 12, + "subScore": null + } + }, + { + "ratingStrings": ["15"], + "ratingScore": { + "score": 15, + "subScore": null + } + }, + { + "ratingStrings": ["18"], + "ratingScore": { + "score": 18, + "subScore": null + } + }, + { + "ratingStrings": ["X"], + "ratingScore": { + "score": 1000, + "subScore": null + } + }, + { + "ratingStrings": ["Prohibited"], + "ratingScore": { + "score": 1001, + "subScore": null + } + } + ] +} diff --git a/Emby.Server.Implementations/Localization/Ratings/de.csv b/Emby.Server.Implementations/Localization/Ratings/de.csv deleted file mode 100644 index f6181575e2..0000000000 --- a/Emby.Server.Implementations/Localization/Ratings/de.csv +++ /dev/null @@ -1,17 +0,0 @@ -Educational,0 -Infoprogramm,0 -FSK-0,0 -FSK 0,0 -0,0 -FSK-6,6 -FSK 6,6 -6,6 -FSK-12,12 -FSK 12,12 -12,12 -FSK-16,16 -FSK 16,16 -16,16 -FSK-18,18 -FSK 18,18 -18,18 diff --git a/Emby.Server.Implementations/Localization/Ratings/de.json b/Emby.Server.Implementations/Localization/Ratings/de.json new file mode 100644 index 0000000000..30c34b230c --- /dev/null +++ b/Emby.Server.Implementations/Localization/Ratings/de.json @@ -0,0 +1,41 @@ +{ + "countryCode": "de", + "supportsSubScores": false, + "ratings": [ + { + "ratingStrings": ["0", "FSK 0", "FSK-0", "Educational", "Infoprogramm"], + "ratingScore": { + "score": 0, + "subScore": null + } + }, + { + "ratingStrings": ["6", "FSK 6", "FSK-6"], + "ratingScore": { + "score": 6, + "subScore": null + } + }, + { + "ratingStrings": ["12", "FSK 12", "FSK-12"], + "ratingScore": { + "score": 12, + "subScore": null + } + }, + { + "ratingStrings": ["16", "FSK 16", "FSK-16"], + "ratingScore": { + "score": 16, + "subScore": null + } + }, + { + "ratingStrings": ["18", "FSK 18", "FSK-18"], + "ratingScore": { + "score": 18, + "subScore": null + } + } + ] +} diff --git a/Emby.Server.Implementations/Localization/Ratings/dk.csv b/Emby.Server.Implementations/Localization/Ratings/dk.csv deleted file mode 100644 index 4ef63b2eac..0000000000 --- a/Emby.Server.Implementations/Localization/Ratings/dk.csv +++ /dev/null @@ -1,7 +0,0 @@ -F,0 -A,0 -7,7 -11,11 -12,12 -15,15 -16,16 diff --git a/Emby.Server.Implementations/Localization/Ratings/dk.json b/Emby.Server.Implementations/Localization/Ratings/dk.json new file mode 100644 index 0000000000..9fcd6d44fd --- /dev/null +++ b/Emby.Server.Implementations/Localization/Ratings/dk.json @@ -0,0 +1,48 @@ +{ + "countryCode": "dk", + "supportsSubScores": false, + "ratings": [ + { + "ratingStrings": ["F", "A"], + "ratingScore": { + "score": 0, + "subScore": null + } + }, + { + "ratingStrings": ["7"], + "ratingScore": { + "score": 7, + "subScore": null + } + }, + { + "ratingStrings": ["11"], + "ratingScore": { + "score": 11, + "subScore": null + } + }, + { + "ratingStrings": ["12"], + "ratingScore": { + "score": 12, + "subScore": null + } + }, + { + "ratingStrings": ["15"], + "ratingScore": { + "score": 15, + "subScore": null + } + }, + { + "ratingStrings": ["16"], + "ratingScore": { + "score": 16, + "subScore": null + } + } + ] +} diff --git a/Emby.Server.Implementations/Localization/Ratings/es.csv b/Emby.Server.Implementations/Localization/Ratings/es.csv deleted file mode 100644 index 619e948d88..0000000000 --- a/Emby.Server.Implementations/Localization/Ratings/es.csv +++ /dev/null @@ -1,25 +0,0 @@ -A,0 -A/fig,0 -A/i,0 -A/fig/i,0 -APTA,0 -ERI,0 -TP,0 -0+,0 -6+,6 -7/fig,7 -7/i,7 -7/i/fig,7 -7,7 -9+,9 -10,10 -12,12 -12/fig,12 -13,13 -14,14 -16,16 -16/fig,16 -18,18 -18/fig,18 -X,1000 -Banned,1001 diff --git a/Emby.Server.Implementations/Localization/Ratings/es.json b/Emby.Server.Implementations/Localization/Ratings/es.json new file mode 100644 index 0000000000..c19629939d --- /dev/null +++ b/Emby.Server.Implementations/Localization/Ratings/es.json @@ -0,0 +1,90 @@ +{ + "countryCode": "es", + "supportsSubScores": false, + "ratings": [ + { + "ratingStrings": ["0+", "A", "A/i", "A/fig", "A/i/fig", "APTA", "ERI", "TP"], + "ratingScore": { + "score": 0, + "subScore": null + } + }, + { + "ratingStrings": ["6+"], + "ratingScore": { + "score": 6, + "subScore": null + } + }, + { + "ratingStrings": ["7", "7/i", "7/fig", "7/i/fig"], + "ratingScore": { + "score": 11, + "subScore": null + } + }, + { + "ratingStrings": ["9+"], + "ratingScore": { + "score": 9, + "subScore": null + } + }, + { + "ratingStrings": ["10"], + "ratingScore": { + "score": 10, + "subScore": null + } + }, + { + "ratingStrings": ["12", "12/fig"], + "ratingScore": { + "score": 12, + "subScore": null + } + }, + { + "ratingStrings": ["13"], + "ratingScore": { + "score": 13, + "subScore": null + } + }, + { + "ratingStrings": ["14"], + "ratingScore": { + "score": 14, + "subScore": null + } + }, + { + "ratingStrings": ["16", "16/fig"], + "ratingScore": { + "score": 16, + "subScore": null + } + }, + { + "ratingStrings": ["18", "18/fig"], + "ratingScore": { + "score": 18, + "subScore": null + } + }, + { + "ratingStrings": ["X"], + "ratingScore": { + "score": 1000, + "subScore": null + } + }, + { + "ratingStrings": ["Banned"], + "ratingScore": { + "score": 1001, + "subScore": null + } + } + ] +} diff --git a/Emby.Server.Implementations/Localization/Ratings/fi.csv b/Emby.Server.Implementations/Localization/Ratings/fi.csv deleted file mode 100644 index 7ff92f259b..0000000000 --- a/Emby.Server.Implementations/Localization/Ratings/fi.csv +++ /dev/null @@ -1,10 +0,0 @@ -S,0 -T,0 -K7,7 -7,7 -K12,12 -12,12 -K16,16 -16,16 -K18,18 -18,18 diff --git a/Emby.Server.Implementations/Localization/Ratings/fi.json b/Emby.Server.Implementations/Localization/Ratings/fi.json new file mode 100644 index 0000000000..3152317b59 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Ratings/fi.json @@ -0,0 +1,41 @@ +{ + "countryCode": "fi", + "supportsSubScores": false, + "ratings": [ + { + "ratingStrings": ["S", "T"], + "ratingScore": { + "score": 0, + "subScore": null + } + }, + { + "ratingStrings": ["7", "K7"], + "ratingScore": { + "score": 7, + "subScore": null + } + }, + { + "ratingStrings": ["12", "K12"], + "ratingScore": { + "score": 12, + "subScore": null + } + }, + { + "ratingStrings": ["16", "K16"], + "ratingScore": { + "score": 16, + "subScore": null + } + }, + { + "ratingStrings": ["18", "K18"], + "ratingScore": { + "score": 18, + "subScore": null + } + } + ] +} diff --git a/Emby.Server.Implementations/Localization/Ratings/fr.csv b/Emby.Server.Implementations/Localization/Ratings/fr.csv deleted file mode 100644 index 139ea376b7..0000000000 --- a/Emby.Server.Implementations/Localization/Ratings/fr.csv +++ /dev/null @@ -1,13 +0,0 @@ -Public Averti,0 -Tous Publics,0 -TP,0 -U,0 -0+,0 -6+,6 -9+,9 -10,10 -12,12 -14+,14 -16,16 -18,18 -X,1000 diff --git a/Emby.Server.Implementations/Localization/Ratings/fr.json b/Emby.Server.Implementations/Localization/Ratings/fr.json new file mode 100644 index 0000000000..e8bafd6b87 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Ratings/fr.json @@ -0,0 +1,69 @@ +{ + "countryCode": "fr", + "supportsSubScores": false, + "ratings": [ + { + "ratingStrings": ["0+", "Public Averti", "Tous Publics", "TP", "U"], + "ratingScore": { + "score": 0, + "subScore": null + } + }, + { + "ratingStrings": ["6+"], + "ratingScore": { + "score": 6, + "subScore": null + } + }, + { + "ratingStrings": ["9+"], + "ratingScore": { + "score": 9, + "subScore": null + } + }, + { + "ratingStrings": ["10"], + "ratingScore": { + "score": 10, + "subScore": null + } + }, + { + "ratingStrings": ["12"], + "ratingScore": { + "score": 12, + "subScore": null + } + }, + { + "ratingStrings": ["14+"], + "ratingScore": { + "score": 14, + "subScore": null + } + }, + { + "ratingStrings": ["16"], + "ratingScore": { + "score": 16, + "subScore": null + } + }, + { + "ratingStrings": ["18"], + "ratingScore": { + "score": 18, + "subScore": null + } + }, + { + "ratingStrings": ["X"], + "ratingScore": { + "score": 1000, + "subScore": null + } + } + ] +} diff --git a/Emby.Server.Implementations/Localization/Ratings/gb.csv b/Emby.Server.Implementations/Localization/Ratings/gb.csv deleted file mode 100644 index 75b1c20589..0000000000 --- a/Emby.Server.Implementations/Localization/Ratings/gb.csv +++ /dev/null @@ -1,22 +0,0 @@ -All,0 -E,0 -G,0 -U,0 -0+,0 -6+,6 -7+,7 -PG,8 -9+,9 -12,12 -12+,12 -12A,12 -Teen,13 -13+,13 -14+,14 -15,15 -16,16 -Caution,18 -18,18 -Mature,1000 -Adult,1000 -R18,1000 diff --git a/Emby.Server.Implementations/Localization/Ratings/gb.json b/Emby.Server.Implementations/Localization/Ratings/gb.json new file mode 100644 index 0000000000..7fc88272cf --- /dev/null +++ b/Emby.Server.Implementations/Localization/Ratings/gb.json @@ -0,0 +1,97 @@ +{ + "countryCode": "gb", + "supportsSubScores": true, + "ratings": [ + { + "ratingStrings": ["0+", "All", "E", "G", "U"], + "ratingScore": { + "score": 0, + "subScore": 0 + } + }, + { + "ratingStrings": ["6+"], + "ratingScore": { + "score": 6, + "subScore": 0 + } + }, + { + "ratingStrings": ["7+"], + "ratingScore": { + "score": 7, + "subScore": 0 + } + }, + { + "ratingStrings": ["PG"], + "ratingScore": { + "score": 8, + "subScore": 0 + } + }, + { + "ratingStrings": ["9"], + "ratingScore": { + "score": 9, + "subScore": 0 + } + }, + { + "ratingStrings": ["12A", "12PG"], + "ratingScore": { + "score": 12, + "subScore": 0 + } + }, + { + "ratingStrings": ["12", "12+"], + "ratingScore": { + "score": 12, + "subScore": 1 + } + }, + { + "ratingStrings": ["13+", "Teen"], + "ratingScore": { + "score": 13, + "subScore": 0 + } + }, + { + "ratingStrings": ["14+"], + "ratingScore": { + "score": 14, + "subScore": 0 + } + }, + { + "ratingStrings": ["15"], + "ratingScore": { + "score": 15, + "subScore": 3 + } + }, + { + "ratingStrings": ["16"], + "ratingScore": { + "score": 16, + "subScore": 0 + } + }, + { + "ratingStrings": ["18", "Caution"], + "ratingScore": { + "score": 18, + "subScore": 1 + } + }, + { + "ratingStrings": ["Mature", "Adult", "R18"], + "ratingScore": { + "score": 1000, + "subScore": 0 + } + } + ] +} diff --git a/Emby.Server.Implementations/Localization/Ratings/ie.csv b/Emby.Server.Implementations/Localization/Ratings/ie.csv deleted file mode 100644 index 6ef2e50128..0000000000 --- a/Emby.Server.Implementations/Localization/Ratings/ie.csv +++ /dev/null @@ -1,9 +0,0 @@ -G,4 -PG,12 -12,12 -12A,12 -12PG,12 -15,15 -15A,15 -16,16 -18,18 diff --git a/Emby.Server.Implementations/Localization/Ratings/ie.json b/Emby.Server.Implementations/Localization/Ratings/ie.json new file mode 100644 index 0000000000..f6cc56ed6d --- /dev/null +++ b/Emby.Server.Implementations/Localization/Ratings/ie.json @@ -0,0 +1,55 @@ +{ + "countryCode": "ie", + "supportsSubScores": true, + "ratings": [ + { + "ratingStrings": ["G"], + "ratingScore": { + "score": 4, + "subScore": 0 + } + }, + { + "ratingStrings": ["12A", "12PG", "PG"], + "ratingScore": { + "score": 12, + "subScore": 0 + } + }, + { + "ratingStrings": ["12"], + "ratingScore": { + "score": 12, + "subScore": 1 + } + }, + { + "ratingStrings": ["15A", "15PG"], + "ratingScore": { + "score": 15, + "subScore": 0 + } + }, + { + "ratingStrings": ["15"], + "ratingScore": { + "score": 15, + "subScore": 3 + } + }, + { + "ratingStrings": ["16"], + "ratingScore": { + "score": 16, + "subScore": 0 + } + }, + { + "ratingStrings": ["18"], + "ratingScore": { + "score": 18, + "subScore": 1 + } + } + ] +} diff --git a/Emby.Server.Implementations/Localization/Ratings/jp.csv b/Emby.Server.Implementations/Localization/Ratings/jp.csv deleted file mode 100644 index bfb5fdaae9..0000000000 --- a/Emby.Server.Implementations/Localization/Ratings/jp.csv +++ /dev/null @@ -1,11 +0,0 @@ -A,0 -G,0 -B,12 -PG12,12 -C,15 -15+,15 -R15+,15 -16+,16 -D,17 -Z,18 -18+,18 diff --git a/Emby.Server.Implementations/Localization/Ratings/jp.json b/Emby.Server.Implementations/Localization/Ratings/jp.json new file mode 100644 index 0000000000..efff9e92ce --- /dev/null +++ b/Emby.Server.Implementations/Localization/Ratings/jp.json @@ -0,0 +1,62 @@ +{ + "countryCode": "jp", + "supportsSubScores": true, + "ratings": [ + { + "ratingStrings": ["A", "G"], + "ratingScore": { + "score": 0, + "subScore": 0 + } + }, + { + "ratingStrings": ["PG12"], + "ratingScore": { + "score": 12, + "subScore": 0 + } + }, + { + "ratingStrings": ["B"], + "ratingScore": { + "score": 12, + "subScore": 1 + } + }, + { + "ratingStrings": ["15A", "15PG"], + "ratingScore": { + "score": 15, + "subScore": 0 + } + }, + { + "ratingStrings": ["C", "15+", "R15+"], + "ratingScore": { + "score": 15, + "subScore": 1 + } + }, + { + "ratingStrings": ["16+"], + "ratingScore": { + "score": 16, + "subScore": null + } + }, + { + "ratingStrings": ["D"], + "ratingScore": { + "score": 17, + "subScore": null + } + }, + { + "ratingStrings": ["18+", "Z"], + "ratingScore": { + "score": 18, + "subScore": null + } + } + ] +} diff --git a/Emby.Server.Implementations/Localization/Ratings/kz.csv b/Emby.Server.Implementations/Localization/Ratings/kz.csv deleted file mode 100644 index e26b32b67e..0000000000 --- a/Emby.Server.Implementations/Localization/Ratings/kz.csv +++ /dev/null @@ -1,6 +0,0 @@ -K,0 -БА,12 -Б14,14 -E16,16 -E18,18 -HA,18 diff --git a/Emby.Server.Implementations/Localization/Ratings/kz.json b/Emby.Server.Implementations/Localization/Ratings/kz.json new file mode 100644 index 0000000000..0f8f0c68e5 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Ratings/kz.json @@ -0,0 +1,41 @@ +{ + "countryCode": "kz", + "supportsSubScores": false, + "ratings": [ + { + "ratingStrings": ["K"], + "ratingScore": { + "score": 0, + "subScore": null + } + }, + { + "ratingStrings": ["БА"], + "ratingScore": { + "score": 12, + "subScore": null + } + }, + { + "ratingStrings": ["Б14"], + "ratingScore": { + "score": 14, + "subScore": null + } + }, + { + "ratingStrings": ["E16"], + "ratingScore": { + "score": 16, + "subScore": null + } + }, + { + "ratingStrings": ["E18", "HA"], + "ratingScore": { + "score": 18, + "subScore": null + } + } + ] +} diff --git a/Emby.Server.Implementations/Localization/Ratings/mx.csv b/Emby.Server.Implementations/Localization/Ratings/mx.csv deleted file mode 100644 index 305912f239..0000000000 --- a/Emby.Server.Implementations/Localization/Ratings/mx.csv +++ /dev/null @@ -1,6 +0,0 @@ -A,0 -AA,0 -B,12 -B-15,15 -C,18 -D,1000 diff --git a/Emby.Server.Implementations/Localization/Ratings/mx.json b/Emby.Server.Implementations/Localization/Ratings/mx.json new file mode 100644 index 0000000000..9dc3b89bd6 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Ratings/mx.json @@ -0,0 +1,41 @@ +{ + "countryCode": "mx", + "supportsSubScores": false, + "ratings": [ + { + "ratingStrings": ["A", "AA"], + "ratingScore": { + "score": 0, + "subScore": null + } + }, + { + "ratingStrings": ["B"], + "ratingScore": { + "score": 12, + "subScore": null + } + }, + { + "ratingStrings": ["B-15"], + "ratingScore": { + "score": 15, + "subScore": null + } + }, + { + "ratingStrings": ["C"], + "ratingScore": { + "score": 18, + "subScore": null + } + }, + { + "ratingStrings": ["D"], + "ratingScore": { + "score": 1000, + "subScore": null + } + } + ] +} diff --git a/Emby.Server.Implementations/Localization/Ratings/nl.csv b/Emby.Server.Implementations/Localization/Ratings/nl.csv deleted file mode 100644 index 44f372b2d6..0000000000 --- a/Emby.Server.Implementations/Localization/Ratings/nl.csv +++ /dev/null @@ -1,8 +0,0 @@ -AL,0 -MG6,6 -6,6 -9,9 -12,12 -14,14 -16,16 -18,18 diff --git a/Emby.Server.Implementations/Localization/Ratings/nl.json b/Emby.Server.Implementations/Localization/Ratings/nl.json new file mode 100644 index 0000000000..2e43eb83ab --- /dev/null +++ b/Emby.Server.Implementations/Localization/Ratings/nl.json @@ -0,0 +1,55 @@ +{ + "countryCode": "nl", + "supportsSubScores": false, + "ratings": [ + { + "ratingStrings": ["AL"], + "ratingScore": { + "score": 0, + "subScore": null + } + }, + { + "ratingStrings": ["6", "MG6"], + "ratingScore": { + "score": 6, + "subScore": null + } + }, + { + "ratingStrings": ["9"], + "ratingScore": { + "score": 9, + "subScore": null + } + }, + { + "ratingStrings": ["12"], + "ratingScore": { + "score": 12, + "subScore": null + } + }, + { + "ratingStrings": ["14"], + "ratingScore": { + "score": 14, + "subScore": null + } + }, + { + "ratingStrings": ["16"], + "ratingScore": { + "score": 16, + "subScore": null + } + }, + { + "ratingStrings": ["18"], + "ratingScore": { + "score": 18, + "subScore": null + } + } + ] +} diff --git a/Emby.Server.Implementations/Localization/Ratings/no.csv b/Emby.Server.Implementations/Localization/Ratings/no.csv deleted file mode 100644 index c8f8e93db7..0000000000 --- a/Emby.Server.Implementations/Localization/Ratings/no.csv +++ /dev/null @@ -1,9 +0,0 @@ -A,0 -6,6 -7,7 -9,9 -11,11 -12,12 -15,15 -18,18 -Not approved,1001 diff --git a/Emby.Server.Implementations/Localization/Ratings/no.json b/Emby.Server.Implementations/Localization/Ratings/no.json new file mode 100644 index 0000000000..a5e9523163 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Ratings/no.json @@ -0,0 +1,69 @@ +{ + "countryCode": "no", + "supportsSubScores": false, + "ratings": [ + { + "ratingStrings": ["A"], + "ratingScore": { + "score": 0, + "subScore": null + } + }, + { + "ratingStrings": ["6"], + "ratingScore": { + "score": 6, + "subScore": null + } + }, + { + "ratingStrings": ["7"], + "ratingScore": { + "score": 7, + "subScore": null + } + }, + { + "ratingStrings": ["9"], + "ratingScore": { + "score": 9, + "subScore": null + } + }, + { + "ratingStrings": ["11"], + "ratingScore": { + "score": 11, + "subScore": null + } + }, + { + "ratingStrings": ["12"], + "ratingScore": { + "score": 12, + "subScore": null + } + }, + { + "ratingStrings": ["15"], + "ratingScore": { + "score": 15, + "subScore": null + } + }, + { + "ratingStrings": ["18"], + "ratingScore": { + "score": 18, + "subScore": null + } + }, + { + "ratingStrings": ["Not approved"], + "ratingScore": { + "score": 1001, + "subScore": null + } + } + ] +} diff --git a/Emby.Server.Implementations/Localization/Ratings/nz.csv b/Emby.Server.Implementations/Localization/Ratings/nz.csv deleted file mode 100644 index f617f0c39d..0000000000 --- a/Emby.Server.Implementations/Localization/Ratings/nz.csv +++ /dev/null @@ -1,15 +0,0 @@ -Exempt,0 -G,0 -GY,13 -PG,13 -R13,13 -RP13,13 -R15,15 -M,16 -R16,16 -RP16,16 -GA,18 -R18,18 -MA,1000 -R,1001 -Objectionable,1001 diff --git a/Emby.Server.Implementations/Localization/Ratings/nz.json b/Emby.Server.Implementations/Localization/Ratings/nz.json new file mode 100644 index 0000000000..3c1332271e --- /dev/null +++ b/Emby.Server.Implementations/Localization/Ratings/nz.json @@ -0,0 +1,69 @@ +{ + "countryCode": "nz", + "supportsSubScores": true, + "ratings": [ + { + "ratingStrings": ["Exempt", "G"], + "ratingScore": { + "score": 0, + "subScore": 0 + } + }, + { + "ratingStrings": ["RP13", "PG"], + "ratingScore": { + "score": 13, + "subScore": 0 + } + }, + { + "ratingStrings": ["GY", "R13"], + "ratingScore": { + "score": 13, + "subScore": 1 + } + }, + { + "ratingStrings": ["RP16", "M"], + "ratingScore": { + "score": 16, + "subScore": 0 + } + }, + { + "ratingStrings": ["R16"], + "ratingScore": { + "score": 16, + "subScore": 1 + } + }, + { + "ratingStrings": ["RP18"], + "ratingScore": { + "score": 18, + "subScore": 0 + } + }, + { + "ratingStrings": ["R18", "GA"], + "ratingScore": { + "score": 18, + "subScore": 1 + } + }, + { + "ratingStrings": ["MA"], + "ratingScore": { + "score": 1000, + "subScore": 0 + } + }, + { + "ratingStrings": ["Objectionable", "R"], + "ratingScore": { + "score": 1001, + "subScore": 0 + } + } + ] +} diff --git a/Emby.Server.Implementations/Localization/Ratings/pl.json b/Emby.Server.Implementations/Localization/Ratings/pl.json new file mode 100644 index 0000000000..c3001ffb37 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Ratings/pl.json @@ -0,0 +1,41 @@ +{ + "countryCode": "pl", + "supportsSubScores": false, + "ratings": [ + { + "ratingStrings": ["b.o.", "AL"], + "ratingScore": { + "score": 0, + "subScore": null + } + }, + { + "ratingStrings": ["7", "od 7 lat"], + "ratingScore": { + "score": 7, + "subScore": null + } + }, + { + "ratingStrings": ["12", "od 12 lat"], + "ratingScore": { + "score": 12, + "subScore": null + } + }, + { + "ratingStrings": ["16", "od 16 lat"], + "ratingScore": { + "score": 16, + "subScore": null + } + }, + { + "ratingStrings": ["18", "od 18 lat", "R"], + "ratingScore": { + "score": 18, + "subScore": null + } + } + ] +} diff --git a/Emby.Server.Implementations/Localization/Ratings/ro.csv b/Emby.Server.Implementations/Localization/Ratings/ro.csv deleted file mode 100644 index 44c23e2486..0000000000 --- a/Emby.Server.Implementations/Localization/Ratings/ro.csv +++ /dev/null @@ -1,6 +0,0 @@ -AG,0 -AP-12,12 -N-15,15 -IM-18,18 -IM-18-XXX,1000 -IC,1001 diff --git a/Emby.Server.Implementations/Localization/Ratings/ro.json b/Emby.Server.Implementations/Localization/Ratings/ro.json new file mode 100644 index 0000000000..9cf735a54c --- /dev/null +++ b/Emby.Server.Implementations/Localization/Ratings/ro.json @@ -0,0 +1,48 @@ +{ + "countryCode": "ro", + "supportsSubScores": false, + "ratings": [ + { + "ratingStrings": ["AG"], + "ratingScore": { + "score": 0, + "subScore": null + } + }, + { + "ratingStrings": ["AP-12"], + "ratingScore": { + "score": 12, + "subScore": null + } + }, + { + "ratingStrings": ["N-15"], + "ratingScore": { + "score": 15, + "subScore": null + } + }, + { + "ratingStrings": ["IM-18"], + "ratingScore": { + "score": 18, + "subScore": null + } + }, + { + "ratingStrings": ["IM-18-XXX"], + "ratingScore": { + "score": 1000, + "subScore": null + } + }, + { + "ratingStrings": ["IC"], + "ratingScore": { + "score": 1001, + "subScore": null + } + } + ] +} diff --git a/Emby.Server.Implementations/Localization/Ratings/ru.csv b/Emby.Server.Implementations/Localization/Ratings/ru.csv deleted file mode 100644 index 8b264070ba..0000000000 --- a/Emby.Server.Implementations/Localization/Ratings/ru.csv +++ /dev/null @@ -1,6 +0,0 @@ -0+,0 -6+,6 -12+,12 -16+,16 -18+,18 -Refused classification,1001 diff --git a/Emby.Server.Implementations/Localization/Ratings/ru.json b/Emby.Server.Implementations/Localization/Ratings/ru.json new file mode 100644 index 0000000000..d1b8b13aa0 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Ratings/ru.json @@ -0,0 +1,48 @@ +{ + "countryCode": "ru", + "supportsSubScores": false, + "ratings": [ + { + "ratingStrings": ["0+"], + "ratingScore": { + "score": 0, + "subScore": null + } + }, + { + "ratingStrings": ["6+"], + "ratingScore": { + "score": 6, + "subScore": null + } + }, + { + "ratingStrings": ["12+"], + "ratingScore": { + "score": 12, + "subScore": null + } + }, + { + "ratingStrings": ["16+"], + "ratingScore": { + "score": 16, + "subScore": null + } + }, + { + "ratingStrings": ["18+"], + "ratingScore": { + "score": 18, + "subScore": null + } + }, + { + "ratingStrings": ["Refused classification"], + "ratingScore": { + "score": 1001, + "subScore": null + } + } + ] +} diff --git a/Emby.Server.Implementations/Localization/Ratings/se.csv b/Emby.Server.Implementations/Localization/Ratings/se.csv deleted file mode 100644 index e129c35617..0000000000 --- a/Emby.Server.Implementations/Localization/Ratings/se.csv +++ /dev/null @@ -1,10 +0,0 @@ -Alla,0 -Barntillåten,0 -Btl,0 -0+,0 -7,7 -9+,9 -10+,10 -11,11 -14,14 -15,15 diff --git a/Emby.Server.Implementations/Localization/Ratings/se.json b/Emby.Server.Implementations/Localization/Ratings/se.json new file mode 100644 index 0000000000..70084995d1 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Ratings/se.json @@ -0,0 +1,55 @@ +{ + "countryCode": "se", + "supportsSubScores": false, + "ratings": [ + { + "ratingStrings": ["0+", "Alla", "Barntillåten", "Btl"], + "ratingScore": { + "score": 0, + "subScore": null + } + }, + { + "ratingStrings": ["7"], + "ratingScore": { + "score": 7, + "subScore": null + } + }, + { + "ratingStrings": ["9+"], + "ratingScore": { + "score": 9, + "subScore": null + } + }, + { + "ratingStrings": ["10+"], + "ratingScore": { + "score": 10, + "subScore": null + } + }, + { + "ratingStrings": ["11"], + "ratingScore": { + "score": 11, + "subScore": null + } + }, + { + "ratingStrings": ["14"], + "ratingScore": { + "score": 14, + "subScore": null + } + }, + { + "ratingStrings": ["15"], + "ratingScore": { + "score": 15, + "subScore": null + } + } + ] +} diff --git a/Emby.Server.Implementations/Localization/Ratings/sk.csv b/Emby.Server.Implementations/Localization/Ratings/sk.csv deleted file mode 100644 index dbafd8efa3..0000000000 --- a/Emby.Server.Implementations/Localization/Ratings/sk.csv +++ /dev/null @@ -1,6 +0,0 @@ -NR,0 -U,0 -7,7 -12,12 -15,15 -18,18 diff --git a/Emby.Server.Implementations/Localization/Ratings/sk.json b/Emby.Server.Implementations/Localization/Ratings/sk.json new file mode 100644 index 0000000000..5ec6111ecd --- /dev/null +++ b/Emby.Server.Implementations/Localization/Ratings/sk.json @@ -0,0 +1,41 @@ +{ + "countryCode": "sk", + "supportsSubScores": false, + "ratings": [ + { + "ratingStrings": ["U", "NR"], + "ratingScore": { + "score": 0, + "subScore": null + } + }, + { + "ratingStrings": ["7"], + "ratingScore": { + "score": 7, + "subScore": null + } + }, + { + "ratingStrings": ["12"], + "ratingScore": { + "score": 12, + "subScore": null + } + }, + { + "ratingStrings": ["15"], + "ratingScore": { + "score": 15, + "subScore": null + } + }, + { + "ratingStrings": ["18"], + "ratingScore": { + "score": 18, + "subScore": null + } + } + ] +} diff --git a/Emby.Server.Implementations/Localization/Ratings/uk.csv b/Emby.Server.Implementations/Localization/Ratings/uk.csv deleted file mode 100644 index 75b1c20589..0000000000 --- a/Emby.Server.Implementations/Localization/Ratings/uk.csv +++ /dev/null @@ -1,22 +0,0 @@ -All,0 -E,0 -G,0 -U,0 -0+,0 -6+,6 -7+,7 -PG,8 -9+,9 -12,12 -12+,12 -12A,12 -Teen,13 -13+,13 -14+,14 -15,15 -16,16 -Caution,18 -18,18 -Mature,1000 -Adult,1000 -R18,1000 diff --git a/Emby.Server.Implementations/Localization/Ratings/uk.json b/Emby.Server.Implementations/Localization/Ratings/uk.json new file mode 100644 index 0000000000..7fc88272cf --- /dev/null +++ b/Emby.Server.Implementations/Localization/Ratings/uk.json @@ -0,0 +1,97 @@ +{ + "countryCode": "gb", + "supportsSubScores": true, + "ratings": [ + { + "ratingStrings": ["0+", "All", "E", "G", "U"], + "ratingScore": { + "score": 0, + "subScore": 0 + } + }, + { + "ratingStrings": ["6+"], + "ratingScore": { + "score": 6, + "subScore": 0 + } + }, + { + "ratingStrings": ["7+"], + "ratingScore": { + "score": 7, + "subScore": 0 + } + }, + { + "ratingStrings": ["PG"], + "ratingScore": { + "score": 8, + "subScore": 0 + } + }, + { + "ratingStrings": ["9"], + "ratingScore": { + "score": 9, + "subScore": 0 + } + }, + { + "ratingStrings": ["12A", "12PG"], + "ratingScore": { + "score": 12, + "subScore": 0 + } + }, + { + "ratingStrings": ["12", "12+"], + "ratingScore": { + "score": 12, + "subScore": 1 + } + }, + { + "ratingStrings": ["13+", "Teen"], + "ratingScore": { + "score": 13, + "subScore": 0 + } + }, + { + "ratingStrings": ["14+"], + "ratingScore": { + "score": 14, + "subScore": 0 + } + }, + { + "ratingStrings": ["15"], + "ratingScore": { + "score": 15, + "subScore": 3 + } + }, + { + "ratingStrings": ["16"], + "ratingScore": { + "score": 16, + "subScore": 0 + } + }, + { + "ratingStrings": ["18", "Caution"], + "ratingScore": { + "score": 18, + "subScore": 1 + } + }, + { + "ratingStrings": ["Mature", "Adult", "R18"], + "ratingScore": { + "score": 1000, + "subScore": 0 + } + } + ] +} diff --git a/Emby.Server.Implementations/Localization/Ratings/us.csv b/Emby.Server.Implementations/Localization/Ratings/us.csv deleted file mode 100644 index d103ddf42d..0000000000 --- a/Emby.Server.Implementations/Localization/Ratings/us.csv +++ /dev/null @@ -1,50 +0,0 @@ -Approved,0 -G,0 -TV-G,0 -TV-Y,0 -TV-Y7,7 -TV-Y7-FV,7 -PG,10 -PG-13,13 -TV-PG,13 -TV-PG-D,13 -TV-PG-L,13 -TV-PG-S,13 -TV-PG-V,13 -TV-PG-DL,13 -TV-PG-DS,13 -TV-PG-DV,13 -TV-PG-LS,13 -TV-PG-LV,13 -TV-PG-SV,13 -TV-PG-DLS,13 -TV-PG-DLV,13 -TV-PG-DSV,13 -TV-PG-LSV,13 -TV-PG-DLSV,13 -TV-14,14 -TV-14-D,14 -TV-14-L,14 -TV-14-S,14 -TV-14-V,14 -TV-14-DL,14 -TV-14-DS,14 -TV-14-DV,14 -TV-14-LS,14 -TV-14-LV,14 -TV-14-SV,14 -TV-14-DLS,14 -TV-14-DLV,14 -TV-14-DSV,14 -TV-14-LSV,14 -TV-14-DLSV,14 -NC-17,17 -R,17 -TV-MA,17 -TV-MA-L,17 -TV-MA-S,17 -TV-MA-V,17 -TV-MA-LS,17 -TV-MA-LV,17 -TV-MA-SV,17 -TV-MA-LSV,17 diff --git a/Emby.Server.Implementations/Localization/Ratings/us.json b/Emby.Server.Implementations/Localization/Ratings/us.json new file mode 100644 index 0000000000..08a6373129 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Ratings/us.json @@ -0,0 +1,83 @@ +{ + "countryCode": "us", + "supportsSubScores": true, + "ratings": [ + { + "ratingStrings": ["Approved", "G", "TV-G", "TV-Y"], + "ratingScore": { + "score": 0, + "subScore": 0 + } + }, + { + "ratingStrings": ["TV-Y7"], + "ratingScore": { + "score": 7, + "subScore": 0 + } + }, + { + "ratingStrings": ["TV-Y7-FV"], + "ratingScore": { + "score": 7, + "subScore": 1 + } + }, + { + "ratingStrings": ["PG", "TV-PG"], + "ratingScore": { + "score": 10, + "subScore": 0 + } + }, + { + "ratingStrings": ["TV-PG-D", "TV-PG-L", "TV-PG-S", "TV-PG-V", "TV-PG-DL", "TV-PG-DS", "TV-PG-DV", "TV-PG-LS", "TV-PG-LV", "TV-PG-SV", "TV-PG-DLS", "TV-PG-DLV", "TV-PG-DSV", "TV-PG-LSV", "TV-PG-DLSV"], + "ratingScore": { + "score": 10, + "subScore": 1 + } + }, + { + "ratingStrings": ["PG-13"], + "ratingScore": { + "score": 13, + "subScore": 0 + } + }, + { + "ratingStrings": ["TV-14"], + "ratingScore": { + "score": 14, + "subScore": 0 + } + }, + { + "ratingStrings": ["TV-14-D", "TV-14-L", "TV-14-S", "TV-14-V", "TV-14-DL", "TV-14-DS", "TV-14-DV", "TV-14-LS", "TV-14-LV", "TV-14-SV", "TV-14-DLS", "TV-14-DLV", "TV-14-DSV", "TV-14-LSV", "TV-14-DLSV"], + "ratingScore": { + "score": 14, + "subScore": 1 + } + }, + { + "ratingStrings": ["R"], + "ratingScore": { + "score": 17, + "subScore": 0 + } + }, + { + "ratingStrings": ["NC-17", "TV-MA", "TV-MA-L", "TV-MA-S", "TV-MA-V", "TV-MA-LS", "TV-MA-LV", "TV-MA-SV", "TV-MA-LSV"], + "ratingScore": { + "score": 17, + "subScore": 1 + } + }, + { + "ratingStrings": ["TV-X", "TV-AO"], + "ratingScore": { + "score": 18, + "subScore": 0 + } + } + ] +} diff --git a/Emby.Server.Implementations/Localization/countries.json b/Emby.Server.Implementations/Localization/countries.json index 0a11b3e458..d92dc880b1 100644 --- a/Emby.Server.Implementations/Localization/countries.json +++ b/Emby.Server.Implementations/Localization/countries.json @@ -336,7 +336,7 @@ "TwoLetterISORegionName": "IE" }, { - "DisplayName": "Islamic Republic of Pakistan", + "DisplayName": "Pakistan", "Name": "PK", "ThreeLetterISORegionName": "PAK", "TwoLetterISORegionName": "PK" diff --git a/Emby.Server.Implementations/Localization/iso6392.txt b/Emby.Server.Implementations/Localization/iso6392.txt index b55c0fa330..00c2aee62d 100644 --- a/Emby.Server.Implementations/Localization/iso6392.txt +++ b/Emby.Server.Implementations/Localization/iso6392.txt @@ -10,7 +10,6 @@ afr||af|Afrikaans|afrikaans ain|||Ainu|aïnou aka||ak|Akan|akan akk|||Akkadian|akkadien -alb|sqi|sq|Albanian|albanais ale|||Aleut|aléoute alg|||Algonquian languages|algonquines, langues alt|||Southern Altai|altai du Sud @@ -21,7 +20,6 @@ apa|||Apache languages|apaches, langues ara||ar|Arabic|arabe arc|||Official Aramaic (700-300 BCE); Imperial Aramaic (700-300 BCE)|araméen d'empire (700-300 BCE) arg||an|Aragonese|aragonais -arm|hye|hy|Armenian|arménien arn|||Mapudungun; Mapuche|mapudungun; mapuche; mapuce arp|||Arapaho|arapaho art|||Artificial languages|artificielles, langues @@ -41,7 +39,6 @@ bak||ba|Bashkir|bachkir bal|||Baluchi|baloutchi bam||bm|Bambara|bambara ban|||Balinese|balinais -baq|eus|eu|Basque|basque bas|||Basa|basa bat|||Baltic languages|baltes, langues bej|||Beja; Bedawiyet|bedja @@ -56,6 +53,7 @@ bin|||Bini; Edo|bini; edo bis||bi|Bislama|bichlamar bla|||Siksika|blackfoot bnt|||Bantu (Other)|bantoues, autres langues +bod|tib|bo|Tibetan|tibétain bos||bs|Bosnian|bosniaque bra|||Braj|braj bre||br|Breton|breton @@ -63,7 +61,6 @@ btk|||Batak languages|batak, langues bua|||Buriat|bouriate bug|||Buginese|bugi bul||bg|Bulgarian|bulgare -bur|mya|my|Burmese|birman byn|||Blin; Bilin|blin; bilen cad|||Caddo|caddo cai|||Central American Indian languages|amérindiennes de L'Amérique centrale, langues @@ -72,14 +69,11 @@ cat||ca|Catalan; Valencian|catalan; valencien cau|||Caucasian languages|caucasiennes, langues ceb|||Cebuano|cebuano cel|||Celtic languages|celtiques, langues; celtes, langues +ces|cze|cs|Czech|tchèque cha||ch|Chamorro|chamorro chb|||Chibcha|chibcha che||ce|Chechen|tchétchène chg|||Chagatai|djaghataï -chi|zho|zh|Chinese|chinois -chi|zho|ze|Chinese; Bilingual|chinois -chi|zho|zh-tw|Chinese; Traditional|chinois -chi|zho|zh-hk|Chinese; Hong Kong|chinois chk|||Chuukese|chuuk chm|||Mari|mari chn|||Chinook jargon|chinook, jargon @@ -101,13 +95,14 @@ crh|||Crimean Tatar; Crimean Turkish|tatar de Crimé crp|||Creoles and pidgins |créoles et pidgins csb|||Kashubian|kachoube cus|||Cushitic languages|couchitiques, langues -cze|ces|cs|Czech|tchèque +cym|wel|cy|Welsh|gallois dak|||Dakota|dakota dan||da|Danish|danois dar|||Dargwa|dargwa day|||Land Dayak languages|dayak, langues del|||Delaware|delaware den|||Slave (Athapascan)|esclave (athapascan) +deu|ger|de|German|allemand dgr|||Dogrib|dogrib din|||Dinka|dinka div||dv|Divehi; Dhivehi; Maldivian|maldivien @@ -116,28 +111,30 @@ dra|||Dravidian languages|dravidiennes, langues dsb|||Lower Sorbian|bas-sorabe dua|||Duala|douala dum|||Dutch, Middle (ca.1050-1350)|néerlandais moyen (ca. 1050-1350) -dut|nld|nl|Dutch; Flemish|néerlandais; flamand dyu|||Dyula|dioula dzo||dz|Dzongkha|dzongkha efi|||Efik|efik egy|||Egyptian (Ancient)|égyptien eka|||Ekajuk|ekajuk +ell|gre|el|Greek, Modern (1453-)|grec moderne (après 1453) elx|||Elamite|élamite eng||en|English|anglais enm|||English, Middle (1100-1500)|anglais moyen (1100-1500) epo||eo|Esperanto|espéranto est||et|Estonian|estonien +eus|baq|eu|Basque|basque ewe||ee|Ewe|éwé ewo|||Ewondo|éwondo fan|||Fang|fang fao||fo|Faroese|féroïen +fas|per|fa|Persian|persan fat|||Fanti|fanti fij||fj|Fijian|fidjien fil|||Filipino; Pilipino|filipino; pilipino fin||fi|Finnish|finnois fiu|||Finno-Ugrian languages|finno-ougriennes, langues fon|||Fon|fon -fre|fra|fr|French|français +fra|fre|fr|French|français frm|||French, Middle (ca.1400-1600)|français moyen (1400-1600) fro|||French, Old (842-ca.1400)|français ancien (842-ca.1400) frc||fr-ca|French (Canada)|french @@ -150,8 +147,6 @@ gaa|||Ga|ga gay|||Gayo|gayo gba|||Gbaya|gbaya gem|||Germanic languages|germaniques, langues -geo|kat|ka|Georgian|géorgien -ger|deu|de|German|allemand gez|||Geez|guèze gil|||Gilbertese|kiribati gla||gd|Gaelic; Scottish Gaelic|gaélique; gaélique écossais @@ -165,7 +160,6 @@ gor|||Gorontalo|gorontalo got|||Gothic|gothique grb|||Grebo|grebo grc|||Greek, Ancient (to 1453)|grec ancien (jusqu'à 1453) -gre|ell|el|Greek, Modern (1453-)|grec moderne (après 1453) grn||gn|Guarani|guarani gsw|||Swiss German; Alemannic; Alsatian|suisse alémanique; alémanique; alsacien guj||gu|Gujarati|goudjrati @@ -186,9 +180,10 @@ hrv||hr|Croatian|croate hsb|||Upper Sorbian|haut-sorabe hun||hu|Hungarian|hongrois hup|||Hupa|hupa +hye|arm|hy|Armenian|arménien iba|||Iban|iban ibo||ig|Igbo|igbo -ice|isl|is|Icelandic|islandais +isl|ice|is|Icelandic|islandais ido||io|Ido|ido iii||ii|Sichuan Yi; Nuosu|yi de Sichuan ijo|||Ijo languages|ijo, langues @@ -217,6 +212,7 @@ kam|||Kamba|kamba kan||kn|Kannada|kannada kar|||Karen languages|karen, langues kas||ks|Kashmiri|kashmiri +kat|geo|ka|Georgian|géorgien kau||kr|Kanuri|kanouri kaw|||Kawi|kawi kaz||kk|Kazakh|kazakh @@ -263,7 +259,6 @@ lui|||Luiseno|luiseno lun|||Lunda|lunda luo|||Luo (Kenya and Tanzania)|luo (Kenya et Tanzanie) lus|||Lushai|lushai -mac|mkd|mk|Macedonian|macédonien mad|||Madurese|madourais mag|||Magahi|magahi mah||mh|Marshallese|marshall @@ -271,11 +266,9 @@ mai|||Maithili|maithili mak|||Makasar|makassar mal||ml|Malayalam|malayalam man|||Mandingo|mandingue -mao|mri|mi|Maori|maori map|||Austronesian languages|austronésiennes, langues mar||mr|Marathi|marathe mas|||Masai|massaï -may|msa|ms|Malay|malais mdf|||Moksha|moksa mdr|||Mandar|mandar men|||Mende|mendé @@ -283,6 +276,7 @@ mga|||Irish, Middle (900-1200)|irlandais moyen (900-1200) mic|||Mi'kmaq; Micmac|mi'kmaq; micmac min|||Minangkabau|minangkabau mis|||Uncoded languages|langues non codées +mkd|mac|mk|Macedonian|macédonien mkh|||Mon-Khmer languages|môn-khmer, langues mlg||mg|Malagasy|malgache mlt||mt|Maltese|maltais @@ -292,11 +286,14 @@ mno|||Manobo languages|manobo, langues moh|||Mohawk|mohawk mon||mn|Mongolian|mongol mos|||Mossi|moré +mri|mao|mi|Maori|maori +msa|may|ms|Malay|malais mul|||Multiple languages|multilingue mun|||Munda languages|mounda, langues mus|||Creek|muskogee mwl|||Mirandese|mirandais mwr|||Marwari|marvari +mya|bur|my|Burmese|birman myn|||Mayan languages|maya, langues myv|||Erzya|erza nah|||Nahuatl languages|nahuatl, langues @@ -313,6 +310,7 @@ new|||Nepal Bhasa; Newari|nepal bhasa; newari nia|||Nias|nias nic|||Niger-Kordofanian languages|nigéro-kordofaniennes, langues niu|||Niuean|niué +nld|dut|nl|Dutch; Flemish|néerlandais; flamand nno||nn|Norwegian Nynorsk; Nynorsk, Norwegian|norvégien nynorsk; nynorsk, norvégien nob||nb|Bokmål, Norwegian; Norwegian Bokmål|norvégien bokmål nog|||Nogai|nogaï; nogay @@ -343,7 +341,6 @@ pan||pa|Panjabi; Punjabi|pendjabi pap|||Papiamento|papiamento pau|||Palauan|palau peo|||Persian, Old (ca.600-400 B.C.)|perse, vieux (ca. 600-400 av. J.-C.) -per|fas|fa|Persian|persan phi|||Philippine languages|philippines, langues phn|||Phoenician|phénicien pli||pi|Pali|pali @@ -363,7 +360,7 @@ rar|||Rarotongan; Cook Islands Maori|rarotonga; maori des îles Cook roa|||Romance languages|romanes, langues roh||rm|Romansh|romanche rom|||Romany|tsigane -rum|ron|ro|Romanian; Moldavian; Moldovan|roumain; moldave +ron|rum|ro|Romanian; Moldavian; Moldovan|roumain; moldave run||rn|Rundi|rundi rup|||Aromanian; Arumanian; Macedo-Romanian|aroumain; macédo-roumain rus||ru|Russian|russe @@ -376,6 +373,7 @@ sam|||Samaritan Aramaic|samaritain san||sa|Sanskrit|sanskrit sas|||Sasak|sasak sat|||Santali|santal +scc|srp|sr|Serbian|serbe scn|||Sicilian|sicilien sco|||Scots|écossais sel|||Selkup|selkoupe @@ -388,7 +386,7 @@ sin||si|Sinhala; Sinhalese|singhalais sio|||Siouan languages|sioux, langues sit|||Sino-Tibetan languages|sino-tibétaines, langues sla|||Slavic languages|slaves, langues -slo|slk|sk|Slovak|slovaque +slk|slo|sk|Slovak|slovaque slv||sl|Slovenian|slovène sma|||Southern Sami|sami du Sud sme||se|Northern Sami|sami du Nord @@ -406,9 +404,9 @@ son|||Songhai languages|songhai, langues sot||st|Sotho, Southern|sotho du Sud spa||es-mx|Spanish; Latin|espagnol; Latin spa||es|Spanish; Castilian|espagnol; castillan +sqi|alb|sq|Albanian|albanais srd||sc|Sardinian|sarde srn|||Sranan Tongo|sranan tongo -srp|scc|sr|Serbian|serbe srr|||Serer|sérère ssa|||Nilo-Saharan languages|nilo-sahariennes, langues ssw||ss|Swati|swati @@ -431,7 +429,6 @@ tet|||Tetum|tetum tgk||tg|Tajik|tadjik tgl||tl|Tagalog|tagalog tha||th|Thai|thaï -tib|bod|bo|Tibetan|tibétain tig|||Tigre|tigré tir||ti|Tigrinya|tigrigna tiv|||Tiv|tiv @@ -470,7 +467,6 @@ wak|||Wakashan languages|wakashanes, langues wal|||Walamo|walamo war|||Waray|waray was|||Washo|washo -wel|cym|cy|Welsh|gallois wen|||Sorbian languages|sorabes, langues wln||wa|Walloon|wallon wol||wo|Wolof|wolof @@ -486,6 +482,10 @@ zbl|||Blissymbols; Blissymbolics; Bliss|symboles Bliss; Bliss zen|||Zenaga|zenaga zgh|||Standard Moroccan Tamazight|amazighe standard marocain zha||za|Zhuang; Chuang|zhuang; chuang +zho|chi|zh|Chinese|chinois +zho|chi|ze|Chinese; Bilingual|chinois +zho|chi|zh-tw|Chinese; Traditional|chinois +zho|chi|zh-hk|Chinese; Hong Kong|chinois znd|||Zande languages|zandé, langues zul||zu|Zulu|zoulou zun|||Zuni|zuni diff --git a/Emby.Server.Implementations/MediaEncoder/EncodingManager.cs b/Emby.Server.Implementations/MediaEncoder/EncodingManager.cs index eb55e32c50..ea78968617 100644 --- a/Emby.Server.Implementations/MediaEncoder/EncodingManager.cs +++ b/Emby.Server.Implementations/MediaEncoder/EncodingManager.cs @@ -28,7 +28,7 @@ namespace Emby.Server.Implementations.MediaEncoder private readonly IFileSystem _fileSystem; private readonly ILogger _logger; private readonly IMediaEncoder _encoder; - private readonly IChapterManager _chapterManager; + private readonly IChapterRepository _chapterManager; private readonly ILibraryManager _libraryManager; /// @@ -40,7 +40,7 @@ namespace Emby.Server.Implementations.MediaEncoder ILogger logger, IFileSystem fileSystem, IMediaEncoder encoder, - IChapterManager chapterManager, + IChapterRepository chapterManager, ILibraryManager libraryManager) { _logger = logger; diff --git a/Emby.Server.Implementations/Playlists/PlaylistManager.cs b/Emby.Server.Implementations/Playlists/PlaylistManager.cs index 47ff22c0b3..98a43b6c98 100644 --- a/Emby.Server.Implementations/Playlists/PlaylistManager.cs +++ b/Emby.Server.Implementations/Playlists/PlaylistManager.cs @@ -9,8 +9,8 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; using Jellyfin.Extensions; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -216,14 +216,11 @@ namespace Emby.Server.Implementations.Playlists var newItems = GetPlaylistItems(newItemIds, user, options) .Where(i => i.SupportsAddingToPlaylist); - // Filter out duplicate items, if necessary - if (!_appConfig.DoPlaylistsAllowDuplicates()) - { - var existingIds = playlist.LinkedChildren.Select(c => c.ItemId).ToHashSet(); - newItems = newItems - .Where(i => !existingIds.Contains(i.Id)) - .Distinct(); - } + // Filter out duplicate items + var existingIds = playlist.LinkedChildren.Select(c => c.ItemId).ToHashSet(); + newItems = newItems + .Where(i => !existingIds.Contains(i.Id)) + .Distinct(); // Create a list of the new linked children to add to the playlist var childrenToAdd = newItems @@ -269,7 +266,7 @@ namespace Emby.Server.Implementations.Playlists var idList = entryIds.ToList(); - var removals = children.Where(i => idList.Contains(i.Item1.Id)); + var removals = children.Where(i => idList.Contains(i.Item1.ItemId?.ToString("N", CultureInfo.InvariantCulture))); playlist.LinkedChildren = children.Except(removals) .Select(i => i.Item1) @@ -286,26 +283,49 @@ namespace Emby.Server.Implementations.Playlists RefreshPriority.High); } - public async Task MoveItemAsync(string playlistId, string entryId, int newIndex) + internal static int DetermineAdjustedIndex(int newPriorIndexAllChildren, int newIndex) + { + if (newIndex == 0) + { + return newPriorIndexAllChildren > 0 ? newPriorIndexAllChildren - 1 : 0; + } + + return newPriorIndexAllChildren + 1; + } + + public async Task MoveItemAsync(string playlistId, string entryId, int newIndex, Guid callingUserId) { if (_libraryManager.GetItemById(playlistId) is not Playlist playlist) { throw new ArgumentException("No Playlist exists with the supplied Id"); } + var user = _userManager.GetUserById(callingUserId); var children = playlist.GetManageableItems().ToList(); + var accessibleChildren = children.Where(c => c.Item2.IsVisible(user)).ToArray(); - var oldIndex = children.FindIndex(i => string.Equals(entryId, i.Item1.Id, StringComparison.OrdinalIgnoreCase)); + var oldIndexAll = children.FindIndex(i => string.Equals(entryId, i.Item1.ItemId?.ToString("N", CultureInfo.InvariantCulture), StringComparison.OrdinalIgnoreCase)); + var oldIndexAccessible = accessibleChildren.FindIndex(i => string.Equals(entryId, i.Item1.ItemId?.ToString("N", CultureInfo.InvariantCulture), StringComparison.OrdinalIgnoreCase)); - if (oldIndex == newIndex) + if (oldIndexAccessible == newIndex) { return; } - var item = playlist.LinkedChildren[oldIndex]; + var newPriorItemIndex = newIndex > oldIndexAccessible ? newIndex : newIndex - 1 < 0 ? 0 : newIndex - 1; + var newPriorItemId = accessibleChildren[newPriorItemIndex].Item1.ItemId; + var newPriorItemIndexOnAllChildren = children.FindIndex(c => c.Item1.ItemId.Equals(newPriorItemId)); + var adjustedNewIndex = DetermineAdjustedIndex(newPriorItemIndexOnAllChildren, newIndex); + + var item = playlist.LinkedChildren.FirstOrDefault(i => string.Equals(entryId, i.ItemId?.ToString("N", CultureInfo.InvariantCulture), StringComparison.OrdinalIgnoreCase)); + if (item is null) + { + _logger.LogWarning("Modified item not found in playlist. ItemId: {ItemId}, PlaylistId: {PlaylistId}", entryId, playlistId); + + return; + } var newList = playlist.LinkedChildren.ToList(); - newList.Remove(item); if (newIndex >= newList.Count) @@ -314,7 +334,7 @@ namespace Emby.Server.Implementations.Playlists } else { - newList.Insert(newIndex, item); + newList.Insert(adjustedNewIndex, item); } playlist.LinkedChildren = [.. newList]; diff --git a/Emby.Server.Implementations/Playlists/PlaylistsFolder.cs b/Emby.Server.Implementations/Playlists/PlaylistsFolder.cs index f65d609c71..a5be2b616e 100644 --- a/Emby.Server.Implementations/Playlists/PlaylistsFolder.cs +++ b/Emby.Server.Implementations/Playlists/PlaylistsFolder.cs @@ -3,14 +3,16 @@ using System.Collections.Generic; using System.Linq; using System.Text.Json.Serialization; -using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using MediaBrowser.Common; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Playlists; using MediaBrowser.Model.Querying; namespace Emby.Server.Implementations.Playlists { + [RequiresSourceSerialisation] public class PlaylistsFolder : BasePluginFolder { public PlaylistsFolder() diff --git a/Emby.Server.Implementations/Plugins/PluginManager.cs b/Emby.Server.Implementations/Plugins/PluginManager.cs index db82a2900a..8eeca3667e 100644 --- a/Emby.Server.Implementations/Plugins/PluginManager.cs +++ b/Emby.Server.Implementations/Plugins/PluginManager.cs @@ -119,7 +119,7 @@ namespace Emby.Server.Implementations.Plugins // Now load the assemblies.. foreach (var plugin in _plugins) { - UpdatePluginSuperceedStatus(plugin); + UpdatePluginSupersededStatus(plugin); if (plugin.IsEnabledAndSupported == false) { @@ -214,7 +214,7 @@ namespace Emby.Server.Implementations.Plugins continue; } - UpdatePluginSuperceedStatus(plugin); + UpdatePluginSupersededStatus(plugin); if (!plugin.IsEnabledAndSupported) { continue; @@ -624,9 +624,9 @@ namespace Emby.Server.Implementations.Plugins } } - private void UpdatePluginSuperceedStatus(LocalPlugin plugin) + private void UpdatePluginSupersededStatus(LocalPlugin plugin) { - if (plugin.Manifest.Status != PluginStatus.Superceded) + if (plugin.Manifest.Status != PluginStatus.Superseded) { return; } @@ -785,30 +785,27 @@ namespace Emby.Server.Implementations.Plugins var cleaned = false; var path = entry.Path; - if (_config.RemoveOldPlugins) + // Attempt a cleanup of old folders. + try { - // Attempt a cleanup of old folders. - try - { - _logger.LogDebug("Deleting {Path}", path); - Directory.Delete(path, true); - cleaned = true; - } + _logger.LogDebug("Deleting {Path}", path); + Directory.Delete(path, true); + cleaned = true; + } #pragma warning disable CA1031 // Do not catch general exception types - catch (Exception e) + catch (Exception e) #pragma warning restore CA1031 // Do not catch general exception types - { - _logger.LogWarning(e, "Unable to delete {Path}", path); - } + { + _logger.LogWarning(e, "Unable to delete {Path}", path); + } - if (cleaned) - { - versions.RemoveAt(x); - } - else - { - ChangePluginState(entry, PluginStatus.Deleted); - } + if (cleaned) + { + versions.RemoveAt(x); + } + else + { + ChangePluginState(entry, PluginStatus.Deleted); } } @@ -835,7 +832,7 @@ namespace Emby.Server.Implementations.Plugins /// If the is null. private bool TryGetPluginDlls(LocalPlugin plugin, out IReadOnlyList whitelistedDlls) { - ArgumentNullException.ThrowIfNull(nameof(plugin)); + ArgumentNullException.ThrowIfNull(plugin); IReadOnlyList pluginDlls = Directory.GetFiles(plugin.Path, "*.dll", SearchOption.AllDirectories); @@ -879,7 +876,7 @@ namespace Emby.Server.Implementations.Plugins } /// - /// Changes the status of the other versions of the plugin to "Superceded". + /// Changes the status of the other versions of the plugin to "Superseded". /// /// The that's master. private void ProcessAlternative(LocalPlugin plugin) @@ -899,11 +896,11 @@ namespace Emby.Server.Implementations.Plugins return; } - if (plugin.Manifest.Status == PluginStatus.Active && !ChangePluginState(previousVersion, PluginStatus.Superceded)) + if (plugin.Manifest.Status == PluginStatus.Active && !ChangePluginState(previousVersion, PluginStatus.Superseded)) { _logger.LogError("Unable to enable version {Version} of {Name}", previousVersion.Version, previousVersion.Name); } - else if (plugin.Manifest.Status == PluginStatus.Superceded && !ChangePluginState(previousVersion, PluginStatus.Active)) + else if (plugin.Manifest.Status == PluginStatus.Superseded && !ChangePluginState(previousVersion, PluginStatus.Active)) { _logger.LogError("Unable to supercede version {Version} of {Name}", previousVersion.Version, previousVersion.Name); } diff --git a/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs b/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs index 9b342cfbe1..985f0a8f85 100644 --- a/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs +++ b/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs @@ -27,7 +27,7 @@ namespace Emby.Server.Implementations.ScheduledTasks private readonly IApplicationPaths _applicationPaths; private readonly ILogger _logger; private readonly ITaskManager _taskManager; - private readonly object _lastExecutionResultSyncLock = new(); + private readonly Lock _lastExecutionResultSyncLock = new(); private bool _readFromFile; private TaskResult _lastExecutionResult; private Task _currentTask; @@ -471,7 +471,7 @@ namespace Emby.Server.Implementations.ScheduledTasks new() { IntervalTicks = TimeSpan.FromDays(1).Ticks, - Type = TaskTriggerInfo.TriggerInterval + Type = TaskTriggerInfoType.IntervalTrigger } ]; } @@ -543,7 +543,7 @@ namespace Emby.Server.Implementations.ScheduledTasks { DisposeTriggers(); - var wassRunning = State == TaskState.Running; + var wasRunning = State == TaskState.Running; var startTime = CurrentExecutionStartTime; var token = CurrentCancellationTokenSource; @@ -596,7 +596,7 @@ namespace Emby.Server.Implementations.ScheduledTasks } } - if (wassRunning) + if (wasRunning) { OnTaskCompleted(startTime, DateTime.UtcNow, TaskCompletionStatus.Aborted, null); } @@ -616,7 +616,7 @@ namespace Emby.Server.Implementations.ScheduledTasks MaxRuntimeTicks = info.MaxRuntimeTicks }; - if (info.Type.Equals(nameof(DailyTrigger), StringComparison.OrdinalIgnoreCase)) + if (info.Type == TaskTriggerInfoType.DailyTrigger) { if (!info.TimeOfDayTicks.HasValue) { @@ -626,7 +626,7 @@ namespace Emby.Server.Implementations.ScheduledTasks return new DailyTrigger(TimeSpan.FromTicks(info.TimeOfDayTicks.Value), options); } - if (info.Type.Equals(nameof(WeeklyTrigger), StringComparison.OrdinalIgnoreCase)) + if (info.Type == TaskTriggerInfoType.WeeklyTrigger) { if (!info.TimeOfDayTicks.HasValue) { @@ -641,7 +641,7 @@ namespace Emby.Server.Implementations.ScheduledTasks return new WeeklyTrigger(TimeSpan.FromTicks(info.TimeOfDayTicks.Value), info.DayOfWeek.Value, options); } - if (info.Type.Equals(nameof(IntervalTrigger), StringComparison.OrdinalIgnoreCase)) + if (info.Type == TaskTriggerInfoType.IntervalTrigger) { if (!info.IntervalTicks.HasValue) { @@ -651,7 +651,7 @@ namespace Emby.Server.Implementations.ScheduledTasks return new IntervalTrigger(TimeSpan.FromTicks(info.IntervalTicks.Value), options); } - if (info.Type.Equals(nameof(StartupTrigger), StringComparison.OrdinalIgnoreCase)) + if (info.Type == TaskTriggerInfoType.StartupTrigger) { return new StartupTrigger(options); } diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs index eb6afe05d0..8d1d509ff7 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs @@ -116,6 +116,7 @@ public partial class AudioNormalizationTask : IScheduledTask { a.LUFS = await CalculateLUFSAsync( string.Format(CultureInfo.InvariantCulture, "-f concat -safe 0 -i \"{0}\"", tempFile), + OperatingSystem.IsWindows(), // Wait for process to exit on Windows before we try deleting the concat file cancellationToken).ConfigureAwait(false); } finally @@ -142,7 +143,10 @@ public partial class AudioNormalizationTask : IScheduledTask continue; } - t.LUFS = await CalculateLUFSAsync(string.Format(CultureInfo.InvariantCulture, "-i \"{0}\"", t.Path.Replace("\"", "\\\"", StringComparison.Ordinal)), cancellationToken).ConfigureAwait(false); + t.LUFS = await CalculateLUFSAsync( + string.Format(CultureInfo.InvariantCulture, "-i \"{0}\"", t.Path.Replace("\"", "\\\"", StringComparison.Ordinal)), + false, + cancellationToken).ConfigureAwait(false); } _itemRepository.SaveItems(tracks, cancellationToken); @@ -156,13 +160,13 @@ public partial class AudioNormalizationTask : IScheduledTask [ new TaskTriggerInfo { - Type = TaskTriggerInfo.TriggerInterval, + Type = TaskTriggerInfoType.IntervalTrigger, IntervalTicks = TimeSpan.FromHours(24).Ticks } ]; } - private async Task CalculateLUFSAsync(string inputArgs, CancellationToken cancellationToken) + private async Task CalculateLUFSAsync(string inputArgs, bool waitForExit, CancellationToken cancellationToken) { var args = $"-hide_banner {inputArgs} -af ebur128=framelog=verbose -f null -"; @@ -189,18 +193,28 @@ public partial class AudioNormalizationTask : IScheduledTask } using var reader = process.StandardError; + float? lufs = null; await foreach (var line in reader.ReadAllLinesAsync(cancellationToken)) { Match match = LUFSRegex().Match(line); - if (match.Success) { - return float.Parse(match.Groups[1].ValueSpan, CultureInfo.InvariantCulture.NumberFormat); + lufs = float.Parse(match.Groups[1].ValueSpan, CultureInfo.InvariantCulture.NumberFormat); + break; } } - _logger.LogError("Failed to find LUFS value in output"); - return null; + if (lufs is null) + { + _logger.LogError("Failed to find LUFS value in output"); + } + + if (waitForExit) + { + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + } + + return lufs; } } } diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs index cb3f5b8363..563e90fbea 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using Jellyfin.Data.Enums; using Jellyfin.Extensions; using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Chapters; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; @@ -32,6 +33,7 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks private readonly IEncodingManager _encodingManager; private readonly IFileSystem _fileSystem; private readonly ILocalizationManager _localization; + private readonly IChapterRepository _chapterRepository; /// /// Initializes a new instance of the class. @@ -43,6 +45,7 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. + /// Instance of the interface. public ChapterImagesTask( ILogger logger, ILibraryManager libraryManager, @@ -50,7 +53,8 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks IApplicationPaths appPaths, IEncodingManager encodingManager, IFileSystem fileSystem, - ILocalizationManager localization) + ILocalizationManager localization, + IChapterRepository chapterRepository) { _logger = logger; _libraryManager = libraryManager; @@ -59,6 +63,7 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks _encodingManager = encodingManager; _fileSystem = fileSystem; _localization = localization; + _chapterRepository = chapterRepository; } /// @@ -80,7 +85,7 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks [ new TaskTriggerInfo { - Type = TaskTriggerInfo.TriggerDaily, + Type = TaskTriggerInfoType.DailyTrigger, TimeOfDayTicks = TimeSpan.FromHours(2).Ticks, MaxRuntimeTicks = TimeSpan.FromHours(4).Ticks } @@ -141,7 +146,7 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks try { - var chapters = _itemRepo.GetChapters(video); + var chapters = _chapterRepository.GetChapters(video.Id); var success = await _encodingManager.RefreshChapterImages(video, directoryService, chapters, extract, true, cancellationToken).ConfigureAwait(false); diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanupCollectionAndPlaylistPathsTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanupCollectionAndPlaylistPathsTask.cs index 25e7ebe799..8901390aae 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanupCollectionAndPlaylistPathsTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanupCollectionAndPlaylistPathsTask.cs @@ -84,7 +84,7 @@ public class CleanupCollectionAndPlaylistPathsTask : IScheduledTask var collection = collections[index]; _logger.LogDebug("Checking boxset {CollectionName}", collection.Name); - CleanupLinkedChildren(collection, cancellationToken); + await CleanupLinkedChildrenAsync(collection, cancellationToken).ConfigureAwait(false); progress.Report(50D / collections.Length * (index + 1)); } } @@ -104,12 +104,12 @@ public class CleanupCollectionAndPlaylistPathsTask : IScheduledTask var playlist = playlists[index]; _logger.LogDebug("Checking playlist {PlaylistName}", playlist.Name); - CleanupLinkedChildren(playlist, cancellationToken); + await CleanupLinkedChildrenAsync(playlist, cancellationToken).ConfigureAwait(false); progress.Report(50D / playlists.Length * (index + 1)); } } - private void CleanupLinkedChildren(T folder, CancellationToken cancellationToken) + private async Task CleanupLinkedChildrenAsync(T folder, CancellationToken cancellationToken) where T : Folder { List? itemsToRemove = null; @@ -127,14 +127,14 @@ public class CleanupCollectionAndPlaylistPathsTask : IScheduledTask { _logger.LogDebug("Updating {FolderName}", folder.Name); folder.LinkedChildren = folder.LinkedChildren.Except(itemsToRemove).ToArray(); - _providerManager.SaveMetadataAsync(folder, ItemUpdateType.MetadataEdit); - folder.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken); + await _providerManager.SaveMetadataAsync(folder, ItemUpdateType.MetadataEdit).ConfigureAwait(false); + await folder.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false); } } /// public IEnumerable GetDefaultTriggers() { - return [new TaskTriggerInfo() { Type = TaskTriggerInfo.TriggerStartup }]; + return [new TaskTriggerInfo() { Type = TaskTriggerInfoType.StartupTrigger }]; } } diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteCacheFileTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteCacheFileTask.cs index 0325cb9af8..ff295d9b7e 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteCacheFileTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteCacheFileTask.cs @@ -73,7 +73,7 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks return [ // Every so often - new TaskTriggerInfo { Type = TaskTriggerInfo.TriggerInterval, IntervalTicks = TimeSpan.FromHours(24).Ticks } + new TaskTriggerInfo { Type = TaskTriggerInfoType.IntervalTrigger, IntervalTicks = TimeSpan.FromHours(24).Ticks } ]; } diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteLogFileTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteLogFileTask.cs index 9babe8cf9f..a091c2bd90 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteLogFileTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteLogFileTask.cs @@ -62,7 +62,7 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks { return [ - new TaskTriggerInfo { Type = TaskTriggerInfo.TriggerInterval, IntervalTicks = TimeSpan.FromHours(24).Ticks } + new TaskTriggerInfo { Type = TaskTriggerInfoType.IntervalTrigger, IntervalTicks = TimeSpan.FromHours(24).Ticks } ]; } diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs index 315c245cc5..d0896cc812 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs @@ -69,11 +69,11 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks [ new TaskTriggerInfo { - Type = TaskTriggerInfo.TriggerStartup + Type = TaskTriggerInfoType.StartupTrigger }, new TaskTriggerInfo { - Type = TaskTriggerInfo.TriggerInterval, + Type = TaskTriggerInfoType.IntervalTrigger, IntervalTicks = TimeSpan.FromHours(24).Ticks } ]; diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/MediaSegmentExtractionTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/MediaSegmentExtractionTask.cs index d6fad7526b..de1e60d307 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/MediaSegmentExtractionTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/MediaSegmentExtractionTask.cs @@ -111,7 +111,7 @@ public class MediaSegmentExtractionTask : IScheduledTask { yield return new TaskTriggerInfo { - Type = TaskTriggerInfo.TriggerInterval, + Type = TaskTriggerInfoType.IntervalTrigger, IntervalTicks = TimeSpan.FromHours(12).Ticks }; } diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs index 3e4925f74d..4d3a04377f 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs @@ -2,7 +2,7 @@ using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Server.Implementations; +using Jellyfin.Database.Implementations; using MediaBrowser.Model.Globalization; using MediaBrowser.Model.Tasks; using Microsoft.EntityFrameworkCore; @@ -18,6 +18,7 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks private readonly ILogger _logger; private readonly ILocalizationManager _localization; private readonly IDbContextFactory _provider; + private readonly IJellyfinDatabaseProvider _jellyfinDatabaseProvider; /// /// Initializes a new instance of the class. @@ -25,14 +26,17 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. + /// Instance of the JellyfinDatabaseProvider that can be used for provider specific operations. public OptimizeDatabaseTask( ILogger logger, ILocalizationManager localization, - IDbContextFactory provider) + IDbContextFactory provider, + IJellyfinDatabaseProvider jellyfinDatabaseProvider) { _logger = logger; _localization = localization; _provider = provider; + _jellyfinDatabaseProvider = jellyfinDatabaseProvider; } /// @@ -62,7 +66,7 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks return [ // Every so often - new TaskTriggerInfo { Type = TaskTriggerInfo.TriggerInterval, IntervalTicks = TimeSpan.FromHours(24).Ticks } + new TaskTriggerInfo { Type = TaskTriggerInfoType.IntervalTrigger, IntervalTicks = TimeSpan.FromHours(24).Ticks } ]; } @@ -73,20 +77,7 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks try { - var context = await _provider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); - await using (context.ConfigureAwait(false)) - { - if (context.Database.IsSqlite()) - { - await context.Database.ExecuteSqlRawAsync("PRAGMA optimize", cancellationToken).ConfigureAwait(false); - await context.Database.ExecuteSqlRawAsync("VACUUM", cancellationToken).ConfigureAwait(false); - _logger.LogInformation("jellyfin.db optimized successfully!"); - } - else - { - _logger.LogInformation("This database doesn't support optimization"); - } - } + await _jellyfinDatabaseProvider.RunScheduledOptimisation(cancellationToken).ConfigureAwait(false); } catch (Exception e) { diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs index c63bad4748..2907f18b55 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs @@ -58,7 +58,7 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks { new TaskTriggerInfo { - Type = TaskTriggerInfo.TriggerInterval, + Type = TaskTriggerInfoType.IntervalTrigger, IntervalTicks = TimeSpan.FromDays(7).Ticks } }; diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs index ad72a4c87e..b74f4d1b25 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs @@ -60,10 +60,10 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks public IEnumerable GetDefaultTriggers() { // At startup - yield return new TaskTriggerInfo { Type = TaskTriggerInfo.TriggerStartup }; + yield return new TaskTriggerInfo { Type = TaskTriggerInfoType.StartupTrigger }; // Every so often - yield return new TaskTriggerInfo { Type = TaskTriggerInfo.TriggerInterval, IntervalTicks = TimeSpan.FromHours(24).Ticks }; + yield return new TaskTriggerInfo { Type = TaskTriggerInfoType.IntervalTrigger, IntervalTicks = TimeSpan.FromHours(24).Ticks }; } /// @@ -88,7 +88,7 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks } catch (OperationCanceledException) { - // InstallPackage has it's own inner cancellation token, so only throw this if it's ours + // InstallPackage has its own inner cancellation token, so only throw this if it's ours if (cancellationToken.IsCancellationRequested) { throw; diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/RefreshMediaLibraryTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/RefreshMediaLibraryTask.cs index a59f0f3669..172448ddec 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/RefreshMediaLibraryTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/RefreshMediaLibraryTask.cs @@ -48,7 +48,7 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks { yield return new TaskTriggerInfo { - Type = TaskTriggerInfo.TriggerInterval, + Type = TaskTriggerInfoType.IntervalTrigger, IntervalTicks = TimeSpan.FromHours(12).Ticks }; } diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs index 6a8ad2bdc5..924f502860 100644 --- a/Emby.Server.Implementations/Session/SessionManager.cs +++ b/Emby.Server.Implementations/Session/SessionManager.cs @@ -7,11 +7,13 @@ using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Data.Entities; -using Jellyfin.Data.Entities.Security; +using Jellyfin.Data; using Jellyfin.Data.Enums; using Jellyfin.Data.Events; using Jellyfin.Data.Queries; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Entities.Security; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using MediaBrowser.Common.Events; using MediaBrowser.Common.Extensions; @@ -62,6 +64,9 @@ namespace Emby.Server.Implementations.Session private readonly ConcurrentDictionary _activeConnections = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary> _activeLiveStreamSessions + = new(StringComparer.OrdinalIgnoreCase); + private Timer _idleTimer; private Timer _inactiveTimer; @@ -309,13 +314,49 @@ namespace Emby.Server.Implementations.Session _activeConnections.TryRemove(key, out _); if (!string.IsNullOrEmpty(session.PlayState?.LiveStreamId)) { - await _mediaSourceManager.CloseLiveStream(session.PlayState.LiveStreamId).ConfigureAwait(false); + await CloseLiveStreamIfNeededAsync(session.PlayState.LiveStreamId, session.Id).ConfigureAwait(false); } await OnSessionEnded(session).ConfigureAwait(false); } } + /// + public async Task CloseLiveStreamIfNeededAsync(string liveStreamId, string sessionIdOrPlaySessionId) + { + bool liveStreamNeedsToBeClosed = false; + + if (_activeLiveStreamSessions.TryGetValue(liveStreamId, out var activeSessionMappings)) + { + if (activeSessionMappings.TryRemove(sessionIdOrPlaySessionId, out var correspondingId)) + { + if (!string.IsNullOrEmpty(correspondingId)) + { + activeSessionMappings.TryRemove(correspondingId, out _); + } + + liveStreamNeedsToBeClosed = true; + } + + if (activeSessionMappings.IsEmpty) + { + _activeLiveStreamSessions.TryRemove(liveStreamId, out _); + } + } + + if (liveStreamNeedsToBeClosed) + { + try + { + await _mediaSourceManager.CloseLiveStream(liveStreamId).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error closing live stream"); + } + } + } + /// public async ValueTask ReportSessionEnded(string sessionId) { @@ -343,6 +384,11 @@ namespace Emby.Server.Implementations.Session /// Task. private async Task UpdateNowPlayingItem(SessionInfo session, PlaybackProgressInfo info, BaseItem libraryItem, bool updateLastCheckInTime) { + if (session is null) + { + return; + } + if (string.IsNullOrEmpty(info.MediaSourceId)) { info.MediaSourceId = info.ItemId.ToString("N", CultureInfo.InvariantCulture); @@ -675,6 +721,11 @@ namespace Emby.Server.Implementations.Session private BaseItem GetNowPlayingItem(SessionInfo session, Guid itemId) { + if (session is null) + { + return null; + } + var item = session.FullNowPlayingItem; if (item is not null && item.Id.Equals(itemId)) { @@ -725,6 +776,11 @@ namespace Emby.Server.Implementations.Session } } + if (!string.IsNullOrEmpty(info.LiveStreamId)) + { + UpdateLiveStreamActiveSessionMappings(info.LiveStreamId, info.SessionId, info.PlaySessionId); + } + var eventArgs = new PlaybackStartEventArgs { Item = libraryItem, @@ -782,6 +838,32 @@ namespace Emby.Server.Implementations.Session return OnPlaybackProgress(info, false); } + private void UpdateLiveStreamActiveSessionMappings(string liveStreamId, string sessionId, string playSessionId) + { + var activeSessionMappings = _activeLiveStreamSessions.GetOrAdd(liveStreamId, _ => new ConcurrentDictionary()); + + if (!string.IsNullOrEmpty(playSessionId)) + { + if (!activeSessionMappings.TryGetValue(sessionId, out var currentPlaySessionId) || currentPlaySessionId != playSessionId) + { + if (!string.IsNullOrEmpty(currentPlaySessionId)) + { + activeSessionMappings.TryRemove(currentPlaySessionId, out _); + } + + activeSessionMappings[sessionId] = playSessionId; + activeSessionMappings[playSessionId] = sessionId; + } + } + else + { + if (!activeSessionMappings.TryGetValue(sessionId, out _)) + { + activeSessionMappings[sessionId] = string.Empty; + } + } + } + /// /// Used to report playback progress for an item. /// @@ -794,7 +876,11 @@ namespace Emby.Server.Implementations.Session ArgumentNullException.ThrowIfNull(info); - var session = GetSession(info.SessionId); + var session = GetSession(info.SessionId, false); + if (session is null) + { + return; + } var libraryItem = info.ItemId.IsEmpty() ? null @@ -818,6 +904,11 @@ namespace Emby.Server.Implementations.Session } } + if (!string.IsNullOrEmpty(info.LiveStreamId)) + { + UpdateLiveStreamActiveSessionMappings(info.LiveStreamId, info.SessionId, info.PlaySessionId); + } + var eventArgs = new PlaybackProgressEventArgs { Item = libraryItem, @@ -1000,14 +1091,7 @@ namespace Emby.Server.Implementations.Session if (!string.IsNullOrEmpty(info.LiveStreamId)) { - try - { - await _mediaSourceManager.CloseLiveStream(info.LiveStreamId).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error closing live stream"); - } + await CloseLiveStreamIfNeededAsync(info.LiveStreamId, session.Id).ConfigureAwait(false); } var eventArgs = new PlaybackStopEventArgs @@ -1303,7 +1387,7 @@ namespace Emby.Server.Implementations.Session if (item is null) { - _logger.LogError("A non-existent item Id {0} was passed into TranslateItemForPlayback", id); + _logger.LogError("A nonexistent item Id {0} was passed into TranslateItemForPlayback", id); return Array.Empty(); } @@ -1356,7 +1440,7 @@ namespace Emby.Server.Implementations.Session if (item is null) { - _logger.LogError("A non-existent item Id {0} was passed into TranslateItemForInstantMix", id); + _logger.LogError("A nonexistent item Id {0} was passed into TranslateItemForInstantMix", id); return new List(); } @@ -1724,7 +1808,6 @@ namespace Emby.Server.Implementations.Session fields.Remove(ItemFields.DateLastSaved); fields.Remove(ItemFields.DisplayPreferencesId); fields.Remove(ItemFields.Etag); - fields.Remove(ItemFields.InheritedParentalRatingValue); fields.Remove(ItemFields.ItemCounts); fields.Remove(ItemFields.MediaSourceCount); fields.Remove(ItemFields.MediaStreams); @@ -1938,7 +2021,11 @@ namespace Emby.Server.Implementations.Session // Don't report acceleration type for non-admin users. result = result.Select(r => { - r.TranscodingInfo.HardwareAccelerationType = HardwareAccelerationType.none; + if (r.TranscodingInfo is not null) + { + r.TranscodingInfo.HardwareAccelerationType = HardwareAccelerationType.none; + } + return r; }); } @@ -2051,6 +2138,7 @@ namespace Emby.Server.Implementations.Session } _activeConnections.Clear(); + _activeLiveStreamSessions.Clear(); } } } diff --git a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs index aba51de8f5..d4606abd2b 100644 --- a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs +++ b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs @@ -41,7 +41,7 @@ namespace Emby.Server.Implementations.Session /// /// Lock used for accessing the WebSockets watchlist. /// - private readonly object _webSocketsLock = new object(); + private readonly Lock _webSocketsLock = new(); private readonly ISessionManager _sessionManager; private readonly ILogger _logger; @@ -276,11 +276,11 @@ namespace Emby.Server.Implementations.Session /// /// The WebSocket. /// Task. - private Task SendForceKeepAlive(IWebSocketConnection webSocket) + private async Task SendForceKeepAlive(IWebSocketConnection webSocket) { - return webSocket.SendAsync( + await webSocket.SendAsync( new ForceKeepAliveMessage(WebSocketLostTimeout), - CancellationToken.None); + CancellationToken.None).ConfigureAwait(false); } } } diff --git a/Emby.Server.Implementations/Session/WebSocketController.cs b/Emby.Server.Implementations/Session/WebSocketController.cs index cf8e0fb006..c45a4a60f5 100644 --- a/Emby.Server.Implementations/Session/WebSocketController.cs +++ b/Emby.Server.Implementations/Session/WebSocketController.cs @@ -21,6 +21,7 @@ namespace Emby.Server.Implementations.Session private readonly SessionInfo _session; private readonly List _sockets; + private readonly ReaderWriterLockSlim _socketsLock; private bool _disposed = false; public WebSocketController( @@ -31,10 +32,26 @@ namespace Emby.Server.Implementations.Session _logger = logger; _session = session; _sessionManager = sessionManager; - _sockets = new List(); + _sockets = new(); + _socketsLock = new(); } - private bool HasOpenSockets => GetActiveSockets().Any(); + private bool HasOpenSockets + { + get + { + ObjectDisposedException.ThrowIf(_disposed, this); + try + { + _socketsLock.EnterReadLock(); + return _sockets.Any(i => i.State == WebSocketState.Open); + } + finally + { + _socketsLock.ExitReadLock(); + } + } + } /// public bool SupportsMediaControl => HasOpenSockets; @@ -42,23 +59,38 @@ namespace Emby.Server.Implementations.Session /// public bool IsSessionActive => HasOpenSockets; - private IEnumerable GetActiveSockets() - => _sockets.Where(i => i.State == WebSocketState.Open); - public void AddWebSocket(IWebSocketConnection connection) { _logger.LogDebug("Adding websocket to session {Session}", _session.Id); - _sockets.Add(connection); - - connection.Closed += OnConnectionClosed; + ObjectDisposedException.ThrowIf(_disposed, this); + try + { + _socketsLock.EnterWriteLock(); + _sockets.Add(connection); + connection.Closed += OnConnectionClosed; + } + finally + { + _socketsLock.ExitWriteLock(); + } } private async void OnConnectionClosed(object? sender, EventArgs e) { var connection = sender as IWebSocketConnection ?? throw new ArgumentException($"{nameof(sender)} is not of type {nameof(IWebSocketConnection)}", nameof(sender)); _logger.LogDebug("Removing websocket from session {Session}", _session.Id); - _sockets.Remove(connection); - connection.Closed -= OnConnectionClosed; + ObjectDisposedException.ThrowIf(_disposed, this); + try + { + _socketsLock.EnterWriteLock(); + _sockets.Remove(connection); + connection.Closed -= OnConnectionClosed; + } + finally + { + _socketsLock.ExitWriteLock(); + } + await _sessionManager.CloseIfNeededAsync(_session).ConfigureAwait(false); } @@ -69,7 +101,17 @@ namespace Emby.Server.Implementations.Session T data, CancellationToken cancellationToken) { - var socket = GetActiveSockets().MaxBy(i => i.LastActivityDate); + ObjectDisposedException.ThrowIf(_disposed, this); + IWebSocketConnection? socket; + try + { + _socketsLock.EnterReadLock(); + socket = _sockets.Where(i => i.State == WebSocketState.Open).MaxBy(i => i.LastActivityDate); + } + finally + { + _socketsLock.ExitReadLock(); + } if (socket is null) { @@ -94,12 +136,23 @@ namespace Emby.Server.Implementations.Session return; } - foreach (var socket in _sockets) + try { - socket.Closed -= OnConnectionClosed; - socket.Dispose(); + _socketsLock.EnterWriteLock(); + foreach (var socket in _sockets) + { + socket.Closed -= OnConnectionClosed; + socket.Dispose(); + } + + _sockets.Clear(); + } + finally + { + _socketsLock.ExitWriteLock(); } + _socketsLock.Dispose(); _disposed = true; } @@ -110,12 +163,23 @@ namespace Emby.Server.Implementations.Session return; } - foreach (var socket in _sockets) + try { - socket.Closed -= OnConnectionClosed; - await socket.DisposeAsync().ConfigureAwait(false); + _socketsLock.EnterWriteLock(); + foreach (var socket in _sockets) + { + socket.Closed -= OnConnectionClosed; + await socket.DisposeAsync().ConfigureAwait(false); + } + + _sockets.Clear(); + } + finally + { + _socketsLock.ExitWriteLock(); } + _socketsLock.Dispose(); _disposed = true; } } diff --git a/Emby.Server.Implementations/Sorting/DateLastMediaAddedComparer.cs b/Emby.Server.Implementations/Sorting/DateLastMediaAddedComparer.cs index e1c26d0121..9afc511086 100644 --- a/Emby.Server.Implementations/Sorting/DateLastMediaAddedComparer.cs +++ b/Emby.Server.Implementations/Sorting/DateLastMediaAddedComparer.cs @@ -2,8 +2,8 @@ #pragma warning disable CS1591 using System; -using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Sorting; diff --git a/Emby.Server.Implementations/Sorting/DatePlayedComparer.cs b/Emby.Server.Implementations/Sorting/DatePlayedComparer.cs index d668c17bfc..4c013a8bd7 100644 --- a/Emby.Server.Implementations/Sorting/DatePlayedComparer.cs +++ b/Emby.Server.Implementations/Sorting/DatePlayedComparer.cs @@ -1,8 +1,8 @@ #nullable disable using System; -using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Sorting; diff --git a/Emby.Server.Implementations/Sorting/IsFavoriteOrLikeComparer.cs b/Emby.Server.Implementations/Sorting/IsFavoriteOrLikeComparer.cs index 622a341b6a..cf77861673 100644 --- a/Emby.Server.Implementations/Sorting/IsFavoriteOrLikeComparer.cs +++ b/Emby.Server.Implementations/Sorting/IsFavoriteOrLikeComparer.cs @@ -1,8 +1,8 @@ #nullable disable #pragma warning disable CS1591 -using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Sorting; diff --git a/Emby.Server.Implementations/Sorting/IsPlayedComparer.cs b/Emby.Server.Implementations/Sorting/IsPlayedComparer.cs index 2a3e456c2d..e42c8a33a3 100644 --- a/Emby.Server.Implementations/Sorting/IsPlayedComparer.cs +++ b/Emby.Server.Implementations/Sorting/IsPlayedComparer.cs @@ -2,8 +2,8 @@ #pragma warning disable CS1591 -using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Sorting; diff --git a/Emby.Server.Implementations/Sorting/IsUnplayedComparer.cs b/Emby.Server.Implementations/Sorting/IsUnplayedComparer.cs index afd8ccf9f3..f54188030b 100644 --- a/Emby.Server.Implementations/Sorting/IsUnplayedComparer.cs +++ b/Emby.Server.Implementations/Sorting/IsUnplayedComparer.cs @@ -2,8 +2,8 @@ #pragma warning disable CS1591 -using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Sorting; diff --git a/Emby.Server.Implementations/Sorting/OfficialRatingComparer.cs b/Emby.Server.Implementations/Sorting/OfficialRatingComparer.cs index b4ee2c7234..789af01cc3 100644 --- a/Emby.Server.Implementations/Sorting/OfficialRatingComparer.cs +++ b/Emby.Server.Implementations/Sorting/OfficialRatingComparer.cs @@ -1,45 +1,54 @@ -#pragma warning disable CS1591 - using System; using Jellyfin.Data.Enums; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Sorting; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; -using MediaBrowser.Model.Querying; -namespace Emby.Server.Implementations.Sorting +namespace Emby.Server.Implementations.Sorting; + +/// +/// Class providing comparison for official ratings. +/// +public class OfficialRatingComparer : IBaseItemComparer { - public class OfficialRatingComparer : IBaseItemComparer + private readonly ILocalizationManager _localizationManager; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + public OfficialRatingComparer(ILocalizationManager localizationManager) { - private readonly ILocalizationManager _localization; + _localizationManager = localizationManager; + } - public OfficialRatingComparer(ILocalizationManager localization) + /// + /// Gets the name. + /// + /// The name. + public ItemSortBy Type => ItemSortBy.OfficialRating; + + /// + /// Compares the specified x. + /// + /// The x. + /// The y. + /// System.Int32. + public int Compare(BaseItem? x, BaseItem? y) + { + ArgumentNullException.ThrowIfNull(x); + ArgumentNullException.ThrowIfNull(y); + var zeroRating = new ParentalRatingScore(0, 0); + + var ratingX = string.IsNullOrEmpty(x.OfficialRating) ? zeroRating : _localizationManager.GetRatingScore(x.OfficialRating) ?? zeroRating; + var ratingY = string.IsNullOrEmpty(y.OfficialRating) ? zeroRating : _localizationManager.GetRatingScore(y.OfficialRating) ?? zeroRating; + var scoreCompare = ratingX.Score.CompareTo(ratingY.Score); + if (scoreCompare is 0) { - _localization = localization; + return (ratingX.SubScore ?? 0).CompareTo(ratingY.SubScore ?? 0); } - /// - /// Gets the name. - /// - /// The name. - public ItemSortBy Type => ItemSortBy.OfficialRating; - - /// - /// Compares the specified x. - /// - /// The x. - /// The y. - /// System.Int32. - public int Compare(BaseItem? x, BaseItem? y) - { - ArgumentNullException.ThrowIfNull(x); - - ArgumentNullException.ThrowIfNull(y); - - var levelX = string.IsNullOrEmpty(x.OfficialRating) ? 0 : _localization.GetRatingLevel(x.OfficialRating) ?? 0; - var levelY = string.IsNullOrEmpty(y.OfficialRating) ? 0 : _localization.GetRatingLevel(y.OfficialRating) ?? 0; - - return levelX.CompareTo(levelY); - } + return scoreCompare; } } diff --git a/Emby.Server.Implementations/Sorting/PlayCountComparer.cs b/Emby.Server.Implementations/Sorting/PlayCountComparer.cs index 12f88bf4da..dd2149b578 100644 --- a/Emby.Server.Implementations/Sorting/PlayCountComparer.cs +++ b/Emby.Server.Implementations/Sorting/PlayCountComparer.cs @@ -1,7 +1,7 @@ #nullable disable -using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Sorting; diff --git a/Emby.Server.Implementations/SyncPlay/Group.cs b/Emby.Server.Implementations/SyncPlay/Group.cs index a7821c0e0e..c2e834ad58 100644 --- a/Emby.Server.Implementations/SyncPlay/Group.cs +++ b/Emby.Server.Implementations/SyncPlay/Group.cs @@ -5,7 +5,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Data.Entities; +using Jellyfin.Database.Implementations.Entities; using Jellyfin.Extensions; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Session; @@ -273,7 +273,7 @@ namespace Emby.Server.Implementations.SyncPlay SetState(waitingState); } - var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupJoined, GetInfo()); + var updateSession = new SyncPlayGroupJoinedUpdate(GroupId, GetInfo()); SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, updateSession, cancellationToken); _state.SessionJoined(this, _state.Type, session, cancellationToken); @@ -291,10 +291,10 @@ namespace Emby.Server.Implementations.SyncPlay { AddSession(session); - var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupJoined, GetInfo()); + var updateSession = new SyncPlayGroupJoinedUpdate(GroupId, GetInfo()); SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, updateSession, cancellationToken); - var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.UserJoined, session.UserName); + var updateOthers = new SyncPlayUserJoinedUpdate(GroupId, session.UserName); SendGroupUpdate(session, SyncPlayBroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken); _state.SessionJoined(this, _state.Type, session, cancellationToken); @@ -314,10 +314,10 @@ namespace Emby.Server.Implementations.SyncPlay RemoveSession(session); - var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupLeft, GroupId.ToString()); + var updateSession = new SyncPlayGroupLeftUpdate(GroupId, GroupId.ToString()); SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, updateSession, cancellationToken); - var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.UserLeft, session.UserName); + var updateOthers = new SyncPlayUserLeftUpdate(GroupId, session.UserName); SendGroupUpdate(session, SyncPlayBroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken); _logger.LogInformation("Session {SessionId} left group {GroupId}.", session.Id, GroupId.ToString()); @@ -425,12 +425,6 @@ namespace Emby.Server.Implementations.SyncPlay DateTime.UtcNow); } - /// - public GroupUpdate NewSyncPlayGroupUpdate(GroupUpdateType type, T data) - { - return new GroupUpdate(GroupId, type, data); - } - /// public long SanitizePositionTicks(long? positionTicks) { diff --git a/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs b/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs index 00c655634a..b45d754554 100644 --- a/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs +++ b/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs @@ -67,7 +67,7 @@ namespace Emby.Server.Implementations.SyncPlay /// /// This lock has priority on locks made on . /// - private readonly object _groupsLock = new object(); + private readonly Lock _groupsLock = new(); private bool _disposed = false; @@ -100,7 +100,7 @@ namespace Emby.Server.Implementations.SyncPlay } /// - public void NewGroup(SessionInfo session, NewGroupRequest request, CancellationToken cancellationToken) + public GroupInfoDto NewGroup(SessionInfo session, NewGroupRequest request, CancellationToken cancellationToken) { if (session is null) { @@ -132,6 +132,7 @@ namespace Emby.Server.Implementations.SyncPlay UpdateSessionsCounter(session.UserId, 1); group.CreateGroup(session, request, cancellationToken); + return group.GetInfo(); } } @@ -159,7 +160,7 @@ namespace Emby.Server.Implementations.SyncPlay { _logger.LogWarning("Session {SessionId} tried to join group {GroupId} that does not exist.", session.Id, request.GroupId); - var error = new GroupUpdate(Guid.Empty, GroupUpdateType.GroupDoesNotExist, string.Empty); + var error = new SyncPlayGroupDoesNotExistUpdate(Guid.Empty, string.Empty); _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None); return; } @@ -171,7 +172,7 @@ namespace Emby.Server.Implementations.SyncPlay { _logger.LogWarning("Session {SessionId} tried to join group {GroupId} but does not have access to some content of the playing queue.", session.Id, group.GroupId.ToString()); - var error = new GroupUpdate(group.GroupId, GroupUpdateType.LibraryAccessDenied, string.Empty); + var error = new SyncPlayLibraryAccessDeniedUpdate(group.GroupId, string.Empty); _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None); return; } @@ -248,7 +249,7 @@ namespace Emby.Server.Implementations.SyncPlay { _logger.LogWarning("Session {SessionId} does not belong to any group.", session.Id); - var error = new GroupUpdate(Guid.Empty, GroupUpdateType.NotInGroup, string.Empty); + var error = new SyncPlayNotInGroupUpdate(Guid.Empty, string.Empty); _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None); } } @@ -288,6 +289,31 @@ namespace Emby.Server.Implementations.SyncPlay return list; } + /// + public GroupInfoDto GetGroup(SessionInfo session, Guid groupId) + { + ArgumentNullException.ThrowIfNull(session); + + var user = _userManager.GetUserById(session.UserId); + + lock (_groupsLock) + { + foreach (var (_, group) in _groups) + { + // Locking required as group is not thread-safe. + lock (group) + { + if (group.GroupId.Equals(groupId) && group.HasAccessToPlayQueue(user)) + { + return group.GetInfo(); + } + } + } + } + + return null; + } + /// public void HandleRequest(SessionInfo session, IGroupPlaybackRequest request, CancellationToken cancellationToken) { @@ -327,7 +353,7 @@ namespace Emby.Server.Implementations.SyncPlay { _logger.LogWarning("Session {SessionId} does not belong to any group.", session.Id); - var error = new GroupUpdate(Guid.Empty, GroupUpdateType.NotInGroup, string.Empty); + var error = new SyncPlayNotInGroupUpdate(Guid.Empty, string.Empty); _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None); } } diff --git a/Emby.Server.Implementations/SystemManager.cs b/Emby.Server.Implementations/SystemManager.cs index c4552474cb..92b59b23cd 100644 --- a/Emby.Server.Implementations/SystemManager.cs +++ b/Emby.Server.Implementations/SystemManager.cs @@ -1,9 +1,12 @@ +using System; using System.Linq; using System.Threading.Tasks; +using Jellyfin.Server.Implementations.StorageHelpers; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Updates; using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Library; using MediaBrowser.Model.System; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Hosting; @@ -19,6 +22,7 @@ public class SystemManager : ISystemManager private readonly IServerConfigurationManager _configurationManager; private readonly IStartupOptions _startupOptions; private readonly IInstallationManager _installationManager; + private readonly ILibraryManager _libraryManager; /// /// Initializes a new instance of the class. @@ -29,13 +33,15 @@ public class SystemManager : ISystemManager /// Instance of . /// Instance of . /// Instance of . + /// Instance of . public SystemManager( IHostApplicationLifetime applicationLifetime, IServerApplicationHost applicationHost, IServerApplicationPaths applicationPaths, IServerConfigurationManager configurationManager, IStartupOptions startupOptions, - IInstallationManager installationManager) + IInstallationManager installationManager, + ILibraryManager libraryManager) { _applicationLifetime = applicationLifetime; _applicationHost = applicationHost; @@ -43,6 +49,7 @@ public class SystemManager : ISystemManager _configurationManager = configurationManager; _startupOptions = startupOptions; _installationManager = installationManager; + _libraryManager = libraryManager; } /// @@ -53,9 +60,11 @@ public class SystemManager : ISystemManager HasPendingRestart = _applicationHost.HasPendingRestart, IsShuttingDown = _applicationLifetime.ApplicationStopping.IsCancellationRequested, Version = _applicationHost.ApplicationVersionString, + ProductName = _applicationHost.Name, WebSocketPortNumber = _applicationHost.HttpPort, CompletedInstallations = _installationManager.CompletedInstallations.ToArray(), Id = _applicationHost.SystemId, +#pragma warning disable CS0618 // Type or member is obsolete ProgramDataPath = _applicationPaths.ProgramDataPath, WebPath = _applicationPaths.WebPath, LogPath = _applicationPaths.LogDirectoryPath, @@ -63,14 +72,39 @@ public class SystemManager : ISystemManager InternalMetadataPath = _applicationPaths.InternalMetadataPath, CachePath = _applicationPaths.CachePath, TranscodingTempPath = _configurationManager.GetTranscodePath(), +#pragma warning restore CS0618 // Type or member is obsolete ServerName = _applicationHost.FriendlyName, LocalAddress = _applicationHost.GetSmartApiUrl(request), + StartupWizardCompleted = _configurationManager.CommonConfiguration.IsStartupWizardCompleted, SupportsLibraryMonitor = true, PackageName = _startupOptions.PackageName, CastReceiverApplications = _configurationManager.Configuration.CastReceiverApplications }; } + /// + public SystemStorageInfo GetSystemStorageInfo() + { + var virtualFolderInfos = _libraryManager.GetVirtualFolders().Select(e => new LibraryStorageInfo() + { + Id = Guid.Parse(e.ItemId), + Name = e.Name, + Folders = e.Locations.Select(f => StorageHelper.GetFreeSpaceOf(f)).ToArray() + }); + + return new SystemStorageInfo() + { + ProgramDataFolder = StorageHelper.GetFreeSpaceOf(_applicationPaths.ProgramDataPath), + WebFolder = StorageHelper.GetFreeSpaceOf(_applicationPaths.WebPath), + LogFolder = StorageHelper.GetFreeSpaceOf(_applicationPaths.LogDirectoryPath), + ImageCacheFolder = StorageHelper.GetFreeSpaceOf(_applicationPaths.ImageCachePath), + InternalMetadataFolder = StorageHelper.GetFreeSpaceOf(_applicationPaths.InternalMetadataPath), + CacheFolder = StorageHelper.GetFreeSpaceOf(_applicationPaths.CachePath), + TranscodingTempFolder = StorageHelper.GetFreeSpaceOf(_configurationManager.GetTranscodePath()), + Libraries = virtualFolderInfos.ToArray() + }; + } + /// public PublicSystemInfo GetPublicSystemInfo(HttpRequest request) { diff --git a/Emby.Server.Implementations/TV/TVSeriesManager.cs b/Emby.Server.Implementations/TV/TVSeriesManager.cs index d11b03a2e2..ee2e18f735 100644 --- a/Emby.Server.Implementations/TV/TVSeriesManager.cs +++ b/Emby.Server.Implementations/TV/TVSeriesManager.cs @@ -3,8 +3,10 @@ using System; using System.Collections.Generic; using System.Linq; -using Jellyfin.Data.Entities; +using Jellyfin.Data; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; @@ -91,7 +93,7 @@ namespace Emby.Server.Implementations.TV if (!string.IsNullOrEmpty(presentationUniqueKey)) { - return GetResult(GetNextUpEpisodes(request, user, new[] { presentationUniqueKey }, options), request); + return GetResult(GetNextUpEpisodes(request, user, [presentationUniqueKey], options), request); } if (limit.HasValue) @@ -99,25 +101,9 @@ namespace Emby.Server.Implementations.TV limit = limit.Value + 10; } - var items = _libraryManager - .GetItemList( - new InternalItemsQuery(user) - { - IncludeItemTypes = new[] { BaseItemKind.Episode }, - OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending) }, - SeriesPresentationUniqueKey = presentationUniqueKey, - Limit = limit, - DtoOptions = new DtoOptions { Fields = new[] { ItemFields.SeriesPresentationUniqueKey }, EnableImages = false }, - GroupBySeriesPresentationUniqueKey = true - }, - parentsFolders.ToList()) - .Cast() - .Where(episode => !string.IsNullOrEmpty(episode.SeriesPresentationUniqueKey)) - .Select(GetUniqueSeriesKey) - .ToList(); + var nextUpSeriesKeys = _libraryManager.GetNextUpSeriesKeys(new InternalItemsQuery(user) { Limit = limit }, parentsFolders, request.NextUpDateCutoff); - // Avoid implicitly captured closure - var episodes = GetNextUpEpisodes(request, user, items, options); + var episodes = GetNextUpEpisodes(request, user, nextUpSeriesKeys, options); return GetResult(episodes, request); } @@ -133,36 +119,11 @@ namespace Emby.Server.Implementations.TV .OrderByDescending(i => i.LastWatchedDate); } - // If viewing all next up for all series, remove first episodes - // But if that returns empty, keep those first episodes (avoid completely empty view) - var alwaysEnableFirstEpisode = !request.SeriesId.IsNullOrEmpty(); - var anyFound = false; - return allNextUp - .Where(i => - { - if (request.DisableFirstEpisode) - { - return i.LastWatchedDate != DateTime.MinValue; - } - - if (alwaysEnableFirstEpisode || (i.LastWatchedDate != DateTime.MinValue && i.LastWatchedDate.Date >= request.NextUpDateCutoff)) - { - anyFound = true; - return true; - } - - return !anyFound && i.LastWatchedDate == DateTime.MinValue; - }) .Select(i => i.GetEpisodeFunction()) .Where(i => i is not null)!; } - private static string GetUniqueSeriesKey(Episode episode) - { - return episode.SeriesPresentationUniqueKey; - } - private static string GetUniqueSeriesKey(Series series) { return series.GetPresentationUniqueKey(); @@ -178,13 +139,13 @@ namespace Emby.Server.Implementations.TV { AncestorWithPresentationUniqueKey = null, SeriesPresentationUniqueKey = seriesKey, - IncludeItemTypes = new[] { BaseItemKind.Episode }, + IncludeItemTypes = [BaseItemKind.Episode], IsPlayed = true, Limit = 1, ParentIndexNumberNotEquals = 0, DtoOptions = new DtoOptions { - Fields = new[] { ItemFields.SortName }, + Fields = [ItemFields.SortName], EnableImages = false } }; @@ -202,8 +163,8 @@ namespace Emby.Server.Implementations.TV { AncestorWithPresentationUniqueKey = null, SeriesPresentationUniqueKey = seriesKey, - IncludeItemTypes = new[] { BaseItemKind.Episode }, - OrderBy = new[] { (ItemSortBy.ParentIndexNumber, SortOrder.Ascending), (ItemSortBy.IndexNumber, SortOrder.Ascending) }, + IncludeItemTypes = [BaseItemKind.Episode], + OrderBy = [(ItemSortBy.ParentIndexNumber, SortOrder.Ascending), (ItemSortBy.IndexNumber, SortOrder.Ascending)], Limit = 1, IsPlayed = includePlayed, IsVirtualItem = false, @@ -228,7 +189,7 @@ namespace Emby.Server.Implementations.TV AncestorWithPresentationUniqueKey = null, SeriesPresentationUniqueKey = seriesKey, ParentIndexNumber = 0, - IncludeItemTypes = new[] { BaseItemKind.Episode }, + IncludeItemTypes = [BaseItemKind.Episode], IsPlayed = includePlayed, IsVirtualItem = false, DtoOptions = dtoOptions @@ -248,7 +209,7 @@ namespace Emby.Server.Implementations.TV consideredEpisodes.Add(nextEpisode); } - var sortedConsideredEpisodes = _libraryManager.Sort(consideredEpisodes, user, new[] { (ItemSortBy.AiredEpisodeOrder, SortOrder.Ascending) }) + var sortedConsideredEpisodes = _libraryManager.Sort(consideredEpisodes, user, [(ItemSortBy.AiredEpisodeOrder, SortOrder.Ascending)]) .Cast(); if (lastWatchedEpisode is not null) { @@ -262,7 +223,7 @@ namespace Emby.Server.Implementations.TV { var userData = _userDataManager.GetUserData(user, nextEpisode); - if (userData.PlaybackPositionTicks > 0) + if (userData?.PlaybackPositionTicks > 0) { return null; } @@ -275,6 +236,11 @@ namespace Emby.Server.Implementations.TV { var userData = _userDataManager.GetUserData(user, lastWatchedEpisode); + if (userData is null) + { + return (DateTime.MinValue, GetEpisode); + } + var lastWatchedDate = userData.LastPlayedDate ?? DateTime.MinValue.AddDays(1); return (lastWatchedDate, GetEpisode); diff --git a/Emby.Server.Implementations/Updates/InstallationManager.cs b/Emby.Server.Implementations/Updates/InstallationManager.cs index ce3d6cab88..678475b31f 100644 --- a/Emby.Server.Implementations/Updates/InstallationManager.cs +++ b/Emby.Server.Implementations/Updates/InstallationManager.cs @@ -48,7 +48,7 @@ namespace Emby.Server.Implementations.Updates /// /// The application host. private readonly IServerApplicationHost _applicationHost; - private readonly object _currentInstallationsLock = new object(); + private readonly Lock _currentInstallationsLock = new(); /// /// The current installations. @@ -187,7 +187,7 @@ namespace Emby.Server.Implementations.Updates await _pluginManager.PopulateManifest(package, version.VersionNumber, plugin.Path, plugin.Manifest.Status).ConfigureAwait(false); } - // Remove versions with a target ABI greater then the current application version. + // Remove versions with a target ABI greater than the current application version. if (Version.TryParse(version.TargetAbi, out var targetAbi) && _applicationHost.ApplicationVersion < targetAbi) { package.Versions.RemoveAt(i); diff --git a/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs b/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs index 2853e69b01..f6f2f59c52 100644 --- a/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs +++ b/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs @@ -3,7 +3,8 @@ using System.Security.Claims; using System.Text.Encodings.Web; using System.Threading.Tasks; using Jellyfin.Api.Constants; -using Jellyfin.Data.Enums; +using Jellyfin.Data; +using Jellyfin.Database.Implementations.Enums; using MediaBrowser.Controller.Authentication; using MediaBrowser.Controller.Net; using Microsoft.AspNetCore.Authentication; @@ -50,7 +51,8 @@ namespace Jellyfin.Api.Auth } var role = UserRoles.User; - if (authorizationInfo.IsApiKey || authorizationInfo.User.HasPermission(PermissionKind.IsAdministrator)) + if (authorizationInfo.IsApiKey + || (authorizationInfo.User?.HasPermission(PermissionKind.IsAdministrator) ?? false)) { role = UserRoles.Administrator; } @@ -60,10 +62,10 @@ namespace Jellyfin.Api.Auth new Claim(ClaimTypes.Name, authorizationInfo.User?.Username ?? string.Empty), new Claim(ClaimTypes.Role, role), new Claim(InternalClaimTypes.UserId, authorizationInfo.UserId.ToString("N", CultureInfo.InvariantCulture)), - new Claim(InternalClaimTypes.DeviceId, authorizationInfo.DeviceId), - new Claim(InternalClaimTypes.Device, authorizationInfo.Device), - new Claim(InternalClaimTypes.Client, authorizationInfo.Client), - new Claim(InternalClaimTypes.Version, authorizationInfo.Version), + new Claim(InternalClaimTypes.DeviceId, authorizationInfo.DeviceId ?? string.Empty), + new Claim(InternalClaimTypes.Device, authorizationInfo.Device ?? string.Empty), + new Claim(InternalClaimTypes.Client, authorizationInfo.Client ?? string.Empty), + new Claim(InternalClaimTypes.Version, authorizationInfo.Version ?? string.Empty), new Claim(InternalClaimTypes.Token, authorizationInfo.Token), new Claim(InternalClaimTypes.IsApiKey, authorizationInfo.IsApiKey.ToString(CultureInfo.InvariantCulture)) }; diff --git a/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs b/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs index 4928d5ed24..6b80d537fb 100644 --- a/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs +++ b/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs @@ -1,7 +1,8 @@ using System.Threading.Tasks; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; -using Jellyfin.Data.Enums; +using Jellyfin.Data; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; diff --git a/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs b/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs index 5fcf72fb46..7efb5b1698 100644 --- a/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs +++ b/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs @@ -1,6 +1,7 @@ using System.Threading.Tasks; using Jellyfin.Api.Extensions; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.SyncPlay; diff --git a/Jellyfin.Api/Auth/UserPermissionPolicy/UserPermissionHandler.cs b/Jellyfin.Api/Auth/UserPermissionPolicy/UserPermissionHandler.cs index f20779f6cd..d139eab16f 100644 --- a/Jellyfin.Api/Auth/UserPermissionPolicy/UserPermissionHandler.cs +++ b/Jellyfin.Api/Auth/UserPermissionPolicy/UserPermissionHandler.cs @@ -1,5 +1,6 @@ using System.Threading.Tasks; using Jellyfin.Api.Extensions; +using Jellyfin.Data; using Jellyfin.Extensions; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Library; diff --git a/Jellyfin.Api/Auth/UserPermissionPolicy/UserPermissionRequirement.cs b/Jellyfin.Api/Auth/UserPermissionPolicy/UserPermissionRequirement.cs index a7c3cce971..152c400cde 100644 --- a/Jellyfin.Api/Auth/UserPermissionPolicy/UserPermissionRequirement.cs +++ b/Jellyfin.Api/Auth/UserPermissionPolicy/UserPermissionRequirement.cs @@ -1,5 +1,5 @@ using Jellyfin.Api.Auth.DefaultAuthorizationPolicy; -using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; namespace Jellyfin.Api.Auth.UserPermissionPolicy { diff --git a/Jellyfin.Api/Controllers/ArtistsController.cs b/Jellyfin.Api/Controllers/ArtistsController.cs index 8b931f1621..7ba75dc243 100644 --- a/Jellyfin.Api/Controllers/ArtistsController.cs +++ b/Jellyfin.Api/Controllers/ArtistsController.cs @@ -4,8 +4,9 @@ using System.Linq; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; -using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -91,31 +92,31 @@ public class ArtistsController : BaseJellyfinApiController [FromQuery] int? limit, [FromQuery] string? searchTerm, [FromQuery] Guid? parentId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFilter[] filters, [FromQuery] bool? isFavorite, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] MediaType[] mediaTypes, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] MediaType[] mediaTypes, + [FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] genres, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] genreIds, + [FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] officialRatings, + [FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] tags, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] int[] years, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes, [FromQuery] string? person, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] studios, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] personIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] personTypes, + [FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] studios, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] studioIds, [FromQuery] Guid? userId, [FromQuery] string? nameStartsWithOrGreater, [FromQuery] string? nameStartsWith, [FromQuery] string? nameLessThan, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemSortBy[] sortBy, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemSortBy[] sortBy, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] SortOrder[] sortOrder, [FromQuery] bool? enableImages = true, [FromQuery] bool enableTotalRecordCount = true) { @@ -295,31 +296,31 @@ public class ArtistsController : BaseJellyfinApiController [FromQuery] int? limit, [FromQuery] string? searchTerm, [FromQuery] Guid? parentId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFilter[] filters, [FromQuery] bool? isFavorite, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] MediaType[] mediaTypes, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] MediaType[] mediaTypes, + [FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] genres, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] genreIds, + [FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] officialRatings, + [FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] tags, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] int[] years, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes, [FromQuery] string? person, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] studios, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] personIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] personTypes, + [FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] studios, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] studioIds, [FromQuery] Guid? userId, [FromQuery] string? nameStartsWithOrGreater, [FromQuery] string? nameStartsWith, [FromQuery] string? nameLessThan, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemSortBy[] sortBy, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemSortBy[] sortBy, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] SortOrder[] sortOrder, [FromQuery] bool? enableImages = true, [FromQuery] bool enableTotalRecordCount = true) { diff --git a/Jellyfin.Api/Controllers/AudioController.cs b/Jellyfin.Api/Controllers/AudioController.cs index a47c604737..e334e12640 100644 --- a/Jellyfin.Api/Controllers/AudioController.cs +++ b/Jellyfin.Api/Controllers/AudioController.cs @@ -92,18 +92,18 @@ public class AudioController : BaseJellyfinApiController [ProducesAudioFile] public async Task GetAudioStream( [FromRoute, Required] Guid itemId, - [FromQuery] [RegularExpression(EncodingHelper.ValidationRegex)] string? container, + [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? container, [FromQuery] bool? @static, [FromQuery] string? @params, [FromQuery] string? tag, [FromQuery, ParameterObsolete] string? deviceProfileId, [FromQuery] string? playSessionId, - [FromQuery] [RegularExpression(EncodingHelper.ValidationRegex)] string? segmentContainer, + [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer, [FromQuery] int? segmentLength, [FromQuery] int? minSegments, [FromQuery] string? mediaSourceId, [FromQuery] string? deviceId, - [FromQuery] [RegularExpression(EncodingHelper.ValidationRegex)] string? audioCodec, + [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec, [FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowAudioStreamCopy, @@ -114,7 +114,7 @@ public class AudioController : BaseJellyfinApiController [FromQuery] int? audioChannels, [FromQuery] int? maxAudioChannels, [FromQuery] string? profile, - [FromQuery] string? level, + [FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level, [FromQuery] float? framerate, [FromQuery] float? maxFramerate, [FromQuery] bool? copyTimestamps, @@ -133,8 +133,8 @@ public class AudioController : BaseJellyfinApiController [FromQuery] int? cpuCoreLimit, [FromQuery] string? liveStreamId, [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] [RegularExpression(EncodingHelper.ValidationRegex)] string? videoCodec, - [FromQuery] [RegularExpression(EncodingHelper.ValidationRegex)] string? subtitleCodec, + [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec, + [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec, [FromQuery] string? transcodeReasons, [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, @@ -238,7 +238,7 @@ public class AudioController : BaseJellyfinApiController /// Optional. The maximum video bit depth. /// Optional. Whether to require avc. /// Optional. Whether to deinterlace the video. - /// Optional. Whether to require a non anamporphic stream. + /// Optional. Whether to require a non anamorphic stream. /// Optional. The maximum number of audio channels to transcode. /// Optional. The limit of how many cpu cores to use. /// The live stream id. @@ -259,18 +259,18 @@ public class AudioController : BaseJellyfinApiController [ProducesAudioFile] public async Task GetAudioStreamByContainer( [FromRoute, Required] Guid itemId, - [FromRoute, Required] string container, + [FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string container, [FromQuery] bool? @static, [FromQuery] string? @params, [FromQuery] string? tag, [FromQuery, ParameterObsolete] string? deviceProfileId, [FromQuery] string? playSessionId, - [FromQuery] [RegularExpression(EncodingHelper.ValidationRegex)] string? segmentContainer, + [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer, [FromQuery] int? segmentLength, [FromQuery] int? minSegments, [FromQuery] string? mediaSourceId, [FromQuery] string? deviceId, - [FromQuery] [RegularExpression(EncodingHelper.ValidationRegex)] string? audioCodec, + [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec, [FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowAudioStreamCopy, @@ -281,7 +281,7 @@ public class AudioController : BaseJellyfinApiController [FromQuery] int? audioChannels, [FromQuery] int? maxAudioChannels, [FromQuery] string? profile, - [FromQuery] string? level, + [FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level, [FromQuery] float? framerate, [FromQuery] float? maxFramerate, [FromQuery] bool? copyTimestamps, @@ -300,8 +300,8 @@ public class AudioController : BaseJellyfinApiController [FromQuery] int? cpuCoreLimit, [FromQuery] string? liveStreamId, [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] [RegularExpression(EncodingHelper.ValidationRegex)] string? videoCodec, - [FromQuery] [RegularExpression(EncodingHelper.ValidationRegex)] string? subtitleCodec, + [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec, + [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec, [FromQuery] string? transcodeReasons, [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, diff --git a/Jellyfin.Api/Controllers/BrandingController.cs b/Jellyfin.Api/Controllers/BrandingController.cs index 3c2c4b4dbd..1d948ff206 100644 --- a/Jellyfin.Api/Controllers/BrandingController.cs +++ b/Jellyfin.Api/Controllers/BrandingController.cs @@ -29,9 +29,18 @@ public class BrandingController : BaseJellyfinApiController /// An containing the branding configuration. [HttpGet("Configuration")] [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult GetBrandingOptions() + public ActionResult GetBrandingOptions() { - return _serverConfigurationManager.GetConfiguration("branding"); + var brandingOptions = _serverConfigurationManager.GetConfiguration("branding"); + + var brandingOptionsDto = new BrandingOptionsDto + { + LoginDisclaimer = brandingOptions.LoginDisclaimer, + CustomCss = brandingOptions.CustomCss, + SplashscreenEnabled = brandingOptions.SplashscreenEnabled + }; + + return brandingOptionsDto; } /// diff --git a/Jellyfin.Api/Controllers/ChannelsController.cs b/Jellyfin.Api/Controllers/ChannelsController.cs index f83c71b578..880b3a82d4 100644 --- a/Jellyfin.Api/Controllers/ChannelsController.cs +++ b/Jellyfin.Api/Controllers/ChannelsController.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Dto; @@ -121,10 +122,10 @@ public class ChannelsController : BaseJellyfinApiController [FromQuery] Guid? userId, [FromQuery] int? startIndex, [FromQuery] int? limit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemSortBy[] sortBy, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields) + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] SortOrder[] sortOrder, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFilter[] filters, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemSortBy[] sortBy, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields) { userId = RequestHelpers.GetUserId(User, userId); var user = userId.IsNullOrEmpty() @@ -197,9 +198,9 @@ public class ChannelsController : BaseJellyfinApiController [FromQuery] Guid? userId, [FromQuery] int? startIndex, [FromQuery] int? limit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] channelIds) + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFilter[] filters, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] channelIds) { userId = RequestHelpers.GetUserId(User, userId); var user = userId.IsNullOrEmpty() diff --git a/Jellyfin.Api/Controllers/CollectionController.cs b/Jellyfin.Api/Controllers/CollectionController.cs index 2d9f1ed69a..c37f376335 100644 --- a/Jellyfin.Api/Controllers/CollectionController.cs +++ b/Jellyfin.Api/Controllers/CollectionController.cs @@ -50,7 +50,7 @@ public class CollectionController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status200OK)] public async Task> CreateCollection( [FromQuery] string? name, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] ids, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] ids, [FromQuery] Guid? parentId, [FromQuery] bool isLocked = false) { @@ -86,7 +86,7 @@ public class CollectionController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task AddToCollection( [FromRoute, Required] Guid collectionId, - [FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids) + [FromQuery, Required, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] ids) { await _collectionManager.AddToCollectionAsync(collectionId, ids).ConfigureAwait(true); return NoContent(); @@ -103,7 +103,7 @@ public class CollectionController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task RemoveFromCollection( [FromRoute, Required] Guid collectionId, - [FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids) + [FromQuery, Required, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] ids) { await _collectionManager.RemoveFromCollectionAsync(collectionId, ids).ConfigureAwait(false); return NoContent(); diff --git a/Jellyfin.Api/Controllers/ConfigurationController.cs b/Jellyfin.Api/Controllers/ConfigurationController.cs index abe8bec2db..8dcaebf6db 100644 --- a/Jellyfin.Api/Controllers/ConfigurationController.cs +++ b/Jellyfin.Api/Controllers/ConfigurationController.cs @@ -9,6 +9,7 @@ using Jellyfin.Extensions.Json; using MediaBrowser.Common.Api; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Model.Branding; using MediaBrowser.Model.Configuration; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -119,6 +120,30 @@ public class ConfigurationController : BaseJellyfinApiController return new MetadataOptions(); } + /// + /// Updates branding configuration. + /// + /// Branding configuration. + /// Branding configuration updated. + /// Update status. + [HttpPost("Configuration/Branding")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult UpdateBrandingConfiguration([FromBody, Required] BrandingOptionsDto configuration) + { + // Get the current branding configuration to preserve SplashscreenLocation + var currentBranding = (BrandingOptions)_configurationManager.GetConfiguration("branding"); + + // Update only the properties from BrandingOptionsDto + currentBranding.LoginDisclaimer = configuration.LoginDisclaimer; + currentBranding.CustomCss = configuration.CustomCss; + currentBranding.SplashscreenEnabled = configuration.SplashscreenEnabled; + + _configurationManager.SaveConfiguration("branding", currentBranding); + + return NoContent(); + } + /// /// Updates the path to the media encoder. /// diff --git a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs index 6d94d96f3a..13064882cc 100644 --- a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs +++ b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs @@ -4,8 +4,8 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Linq; using Jellyfin.Api.Helpers; -using Jellyfin.Data.Entities; -using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller; using MediaBrowser.Model.Dto; diff --git a/Jellyfin.Api/Controllers/DynamicHlsController.cs b/Jellyfin.Api/Controllers/DynamicHlsController.cs index 54e0527c90..4cac8ed678 100644 --- a/Jellyfin.Api/Controllers/DynamicHlsController.cs +++ b/Jellyfin.Api/Controllers/DynamicHlsController.cs @@ -166,18 +166,18 @@ public class DynamicHlsController : BaseJellyfinApiController [ProducesPlaylistFile] public async Task GetLiveHlsStream( [FromRoute, Required] Guid itemId, - [FromQuery] [RegularExpression(EncodingHelper.ValidationRegex)] string? container, + [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? container, [FromQuery] bool? @static, [FromQuery] string? @params, [FromQuery] string? tag, [FromQuery, ParameterObsolete] string? deviceProfileId, [FromQuery] string? playSessionId, - [FromQuery] [RegularExpression(EncodingHelper.ValidationRegex)] string? segmentContainer, + [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer, [FromQuery] int? segmentLength, [FromQuery] int? minSegments, [FromQuery] string? mediaSourceId, [FromQuery] string? deviceId, - [FromQuery] [RegularExpression(EncodingHelper.ValidationRegex)] string? audioCodec, + [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec, [FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowAudioStreamCopy, @@ -188,7 +188,7 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] int? audioChannels, [FromQuery] int? maxAudioChannels, [FromQuery] string? profile, - [FromQuery] string? level, + [FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level, [FromQuery] float? framerate, [FromQuery] float? maxFramerate, [FromQuery] bool? copyTimestamps, @@ -207,8 +207,8 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] int? cpuCoreLimit, [FromQuery] string? liveStreamId, [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] [RegularExpression(EncodingHelper.ValidationRegex)] string? videoCodec, - [FromQuery] [RegularExpression(EncodingHelper.ValidationRegex)] string? subtitleCodec, + [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec, + [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec, [FromQuery] string? transcodeReasons, [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, @@ -415,12 +415,12 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] string? tag, [FromQuery, ParameterObsolete] string? deviceProfileId, [FromQuery] string? playSessionId, - [FromQuery] [RegularExpression(EncodingHelper.ValidationRegex)] string? segmentContainer, + [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer, [FromQuery] int? segmentLength, [FromQuery] int? minSegments, [FromQuery, Required] string mediaSourceId, [FromQuery] string? deviceId, - [FromQuery] [RegularExpression(EncodingHelper.ValidationRegex)] string? audioCodec, + [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec, [FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowAudioStreamCopy, @@ -431,7 +431,7 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] int? audioChannels, [FromQuery] int? maxAudioChannels, [FromQuery] string? profile, - [FromQuery] string? level, + [FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level, [FromQuery] float? framerate, [FromQuery] float? maxFramerate, [FromQuery] bool? copyTimestamps, @@ -452,14 +452,14 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] int? cpuCoreLimit, [FromQuery] string? liveStreamId, [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] [RegularExpression(EncodingHelper.ValidationRegex)] string? videoCodec, - [FromQuery] [RegularExpression(EncodingHelper.ValidationRegex)] string? subtitleCodec, + [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec, + [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec, [FromQuery] string? transcodeReasons, [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, [FromQuery] EncodingContext? context, [FromQuery] Dictionary streamOptions, - [FromQuery] bool enableAdaptiveBitrateStreaming = true, + [FromQuery] bool enableAdaptiveBitrateStreaming = false, [FromQuery] bool enableTrickplay = true, [FromQuery] bool enableAudioVbrEncoding = true, [FromQuery] bool alwaysBurnInSubtitleWhenTranscoding = false) @@ -591,12 +591,12 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] string? tag, [FromQuery, ParameterObsolete] string? deviceProfileId, [FromQuery] string? playSessionId, - [FromQuery] [RegularExpression(EncodingHelper.ValidationRegex)] string? segmentContainer, + [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer, [FromQuery] int? segmentLength, [FromQuery] int? minSegments, [FromQuery, Required] string mediaSourceId, [FromQuery] string? deviceId, - [FromQuery] [RegularExpression(EncodingHelper.ValidationRegex)] string? audioCodec, + [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec, [FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowAudioStreamCopy, @@ -608,7 +608,7 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] int? audioChannels, [FromQuery] int? maxAudioChannels, [FromQuery] string? profile, - [FromQuery] string? level, + [FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level, [FromQuery] float? framerate, [FromQuery] float? maxFramerate, [FromQuery] bool? copyTimestamps, @@ -627,14 +627,14 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] int? cpuCoreLimit, [FromQuery] string? liveStreamId, [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] [RegularExpression(EncodingHelper.ValidationRegex)] string? videoCodec, - [FromQuery] [RegularExpression(EncodingHelper.ValidationRegex)] string? subtitleCodec, + [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec, + [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec, [FromQuery] string? transcodeReasons, [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, [FromQuery] EncodingContext? context, [FromQuery] Dictionary streamOptions, - [FromQuery] bool enableAdaptiveBitrateStreaming = true, + [FromQuery] bool enableAdaptiveBitrateStreaming = false, [FromQuery] bool enableAudioVbrEncoding = true) { var streamingRequest = new HlsAudioRequestDto @@ -761,12 +761,12 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] string? tag, [FromQuery, ParameterObsolete] string? deviceProfileId, [FromQuery] string? playSessionId, - [FromQuery] [RegularExpression(EncodingHelper.ValidationRegex)] string? segmentContainer, + [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer, [FromQuery] int? segmentLength, [FromQuery] int? minSegments, [FromQuery] string? mediaSourceId, [FromQuery] string? deviceId, - [FromQuery] [RegularExpression(EncodingHelper.ValidationRegex)] string? audioCodec, + [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec, [FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowAudioStreamCopy, @@ -777,7 +777,7 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] int? audioChannels, [FromQuery] int? maxAudioChannels, [FromQuery] string? profile, - [FromQuery] string? level, + [FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level, [FromQuery] float? framerate, [FromQuery] float? maxFramerate, [FromQuery] bool? copyTimestamps, @@ -798,8 +798,8 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] int? cpuCoreLimit, [FromQuery] string? liveStreamId, [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] [RegularExpression(EncodingHelper.ValidationRegex)] string? videoCodec, - [FromQuery] [RegularExpression(EncodingHelper.ValidationRegex)] string? subtitleCodec, + [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec, + [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec, [FromQuery] string? transcodeReasons, [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, @@ -933,12 +933,12 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] string? tag, [FromQuery, ParameterObsolete] string? deviceProfileId, [FromQuery] string? playSessionId, - [FromQuery] [RegularExpression(EncodingHelper.ValidationRegex)] string? segmentContainer, + [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer, [FromQuery] int? segmentLength, [FromQuery] int? minSegments, [FromQuery] string? mediaSourceId, [FromQuery] string? deviceId, - [FromQuery] [RegularExpression(EncodingHelper.ValidationRegex)] string? audioCodec, + [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec, [FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowAudioStreamCopy, @@ -950,7 +950,7 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] int? audioChannels, [FromQuery] int? maxAudioChannels, [FromQuery] string? profile, - [FromQuery] string? level, + [FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level, [FromQuery] float? framerate, [FromQuery] float? maxFramerate, [FromQuery] bool? copyTimestamps, @@ -969,8 +969,8 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] int? cpuCoreLimit, [FromQuery] string? liveStreamId, [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] [RegularExpression(EncodingHelper.ValidationRegex)] string? videoCodec, - [FromQuery] [RegularExpression(EncodingHelper.ValidationRegex)] string? subtitleCodec, + [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec, + [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec, [FromQuery] string? transcodeReasons, [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, @@ -1106,7 +1106,7 @@ public class DynamicHlsController : BaseJellyfinApiController [FromRoute, Required] Guid itemId, [FromRoute, Required] string playlistId, [FromRoute, Required] int segmentId, - [FromRoute, Required] string container, + [FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string container, [FromQuery, Required] long runtimeTicks, [FromQuery, Required] long actualSegmentLengthTicks, [FromQuery] bool? @static, @@ -1114,12 +1114,12 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] string? tag, [FromQuery, ParameterObsolete] string? deviceProfileId, [FromQuery] string? playSessionId, - [FromQuery] [RegularExpression(EncodingHelper.ValidationRegex)] string? segmentContainer, + [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer, [FromQuery] int? segmentLength, [FromQuery] int? minSegments, [FromQuery] string? mediaSourceId, [FromQuery] string? deviceId, - [FromQuery] [RegularExpression(EncodingHelper.ValidationRegex)] string? audioCodec, + [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec, [FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowAudioStreamCopy, @@ -1130,7 +1130,7 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] int? audioChannels, [FromQuery] int? maxAudioChannels, [FromQuery] string? profile, - [FromQuery] string? level, + [FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level, [FromQuery] float? framerate, [FromQuery] float? maxFramerate, [FromQuery] bool? copyTimestamps, @@ -1151,8 +1151,8 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] int? cpuCoreLimit, [FromQuery] string? liveStreamId, [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] [RegularExpression(EncodingHelper.ValidationRegex)] string? videoCodec, - [FromQuery] [RegularExpression(EncodingHelper.ValidationRegex)] string? subtitleCodec, + [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec, + [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec, [FromQuery] string? transcodeReasons, [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, @@ -1291,7 +1291,7 @@ public class DynamicHlsController : BaseJellyfinApiController [FromRoute, Required] Guid itemId, [FromRoute, Required] string playlistId, [FromRoute, Required] int segmentId, - [FromRoute, Required] string container, + [FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string container, [FromQuery, Required] long runtimeTicks, [FromQuery, Required] long actualSegmentLengthTicks, [FromQuery] bool? @static, @@ -1299,12 +1299,12 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] string? tag, [FromQuery, ParameterObsolete] string? deviceProfileId, [FromQuery] string? playSessionId, - [FromQuery] [RegularExpression(EncodingHelper.ValidationRegex)] string? segmentContainer, + [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer, [FromQuery] int? segmentLength, [FromQuery] int? minSegments, [FromQuery] string? mediaSourceId, [FromQuery] string? deviceId, - [FromQuery] [RegularExpression(EncodingHelper.ValidationRegex)] string? audioCodec, + [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec, [FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowAudioStreamCopy, @@ -1316,7 +1316,7 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] int? audioChannels, [FromQuery] int? maxAudioChannels, [FromQuery] string? profile, - [FromQuery] string? level, + [FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level, [FromQuery] float? framerate, [FromQuery] float? maxFramerate, [FromQuery] bool? copyTimestamps, @@ -1335,8 +1335,8 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] int? cpuCoreLimit, [FromQuery] string? liveStreamId, [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] [RegularExpression(EncodingHelper.ValidationRegex)] string? videoCodec, - [FromQuery] [RegularExpression(EncodingHelper.ValidationRegex)] string? subtitleCodec, + [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec, + [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec, [FromQuery] string? transcodeReasons, [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, @@ -1419,8 +1419,9 @@ public class DynamicHlsController : BaseJellyfinApiController TranscodingJobType, cancellationTokenSource.Token) .ConfigureAwait(false); - + var mediaSourceId = state.BaseRequest.MediaSourceId; var request = new CreateMainPlaylistRequest( + mediaSourceId is null ? null : Guid.Parse(mediaSourceId), state.MediaPath, state.SegmentLength * 1000, state.RunTimeTicks ?? 0, @@ -1675,7 +1676,7 @@ public class DynamicHlsController : BaseJellyfinApiController } var audioCodec = _encodingHelper.GetAudioEncoder(state); - var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container); + var bitStreamArgs = _encodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container); // opus, dts, truehd and flac (in FFmpeg 5 and older) are experimental in mp4 muxer var strictArgs = string.Empty; @@ -1753,7 +1754,7 @@ public class DynamicHlsController : BaseJellyfinApiController if (channels.HasValue && (channels.Value != 2 - || (state.AudioStream?.Channels != null && !useDownMixAlgorithm))) + || (state.AudioStream?.Channels is not null && !useDownMixAlgorithm))) { args += " -ac " + channels.Value; } @@ -1778,7 +1779,7 @@ public class DynamicHlsController : BaseJellyfinApiController } else if (state.AudioStream?.CodecTag is not null && state.AudioStream.CodecTag.Equals("ac-4", StringComparison.Ordinal)) { - // ac-4 audio tends to hava a super weird sample rate that will fail most encoders + // ac-4 audio tends to have a super weird sample rate that will fail most encoders // force resample it to 48KHz args += " -ar 48000"; } @@ -1819,16 +1820,14 @@ public class DynamicHlsController : BaseJellyfinApiController if (isActualOutputVideoCodecHevc || isActualOutputVideoCodecAv1) { var requestedRange = state.GetRequestedRangeTypes(state.ActualOutputVideoCodec); - var requestHasDOVI = requestedRange.Contains(VideoRangeType.DOVI.ToString(), StringComparison.OrdinalIgnoreCase); - var requestHasDOVIWithHDR10 = requestedRange.Contains(VideoRangeType.DOVIWithHDR10.ToString(), StringComparison.OrdinalIgnoreCase); - var requestHasDOVIWithHLG = requestedRange.Contains(VideoRangeType.DOVIWithHLG.ToString(), StringComparison.OrdinalIgnoreCase); - var requestHasDOVIWithSDR = requestedRange.Contains(VideoRangeType.DOVIWithSDR.ToString(), StringComparison.OrdinalIgnoreCase); + // Clients reporting Dolby Vision capabilities with fallbacks may only support the fallback layer. + // Only enable Dolby Vision remuxing if the client explicitly declares support for profiles without fallbacks. + var clientSupportsDoVi = requestedRange.Contains(VideoRangeType.DOVI.ToString(), StringComparison.OrdinalIgnoreCase); + var videoIsDoVi = EncodingHelper.IsDovi(state.VideoStream); if (EncodingHelper.IsCopyCodec(codec) - && ((state.VideoStream.VideoRangeType == VideoRangeType.DOVI && requestHasDOVI) - || (state.VideoStream.VideoRangeType == VideoRangeType.DOVIWithHDR10 && requestHasDOVIWithHDR10) - || (state.VideoStream.VideoRangeType == VideoRangeType.DOVIWithHLG && requestHasDOVIWithHLG) - || (state.VideoStream.VideoRangeType == VideoRangeType.DOVIWithSDR && requestHasDOVIWithSDR))) + && (videoIsDoVi && clientSupportsDoVi) + && !_encodingHelper.IsDoviRemoved(state)) { if (isActualOutputVideoCodecHevc) { @@ -1858,7 +1857,7 @@ public class DynamicHlsController : BaseJellyfinApiController // If h264_mp4toannexb is ever added, do not use it for live tv. if (state.VideoStream is not null && !string.Equals(state.VideoStream.NalLengthSize, "0", StringComparison.OrdinalIgnoreCase)) { - string bitStreamArgs = EncodingHelper.GetBitStreamArgs(state.VideoStream); + string bitStreamArgs = _encodingHelper.GetBitStreamArgs(state, MediaStreamType.Video); if (!string.IsNullOrEmpty(bitStreamArgs)) { args += " " + bitStreamArgs; @@ -2059,16 +2058,16 @@ public class DynamicHlsController : BaseJellyfinApiController } } - private Task DeleteLastFile(string playlistPath, string segmentExtension, int retryCount) + private async Task DeleteLastFile(string playlistPath, string segmentExtension, int retryCount) { var file = GetLastTranscodingFile(playlistPath, segmentExtension, _fileSystem); if (file is null) { - return Task.CompletedTask; + return; } - return DeleteFile(file.FullName, retryCount); + await DeleteFile(file.FullName, retryCount).ConfigureAwait(false); } private async Task DeleteFile(string path, int retryCount) diff --git a/Jellyfin.Api/Controllers/FilterController.cs b/Jellyfin.Api/Controllers/FilterController.cs index 4abca32713..3f9aa93a64 100644 --- a/Jellyfin.Api/Controllers/FilterController.cs +++ b/Jellyfin.Api/Controllers/FilterController.cs @@ -50,8 +50,8 @@ public class FilterController : BaseJellyfinApiController public ActionResult GetQueryFiltersLegacy( [FromQuery] Guid? userId, [FromQuery] Guid? parentId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] MediaType[] mediaTypes) + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] MediaType[] mediaTypes) { userId = RequestHelpers.GetUserId(User, userId); var user = userId.IsNullOrEmpty() @@ -137,7 +137,7 @@ public class FilterController : BaseJellyfinApiController public ActionResult GetQueryFilters( [FromQuery] Guid? userId, [FromQuery] Guid? parentId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] includeItemTypes, [FromQuery] bool? isAiring, [FromQuery] bool? isMovie, [FromQuery] bool? isSports, diff --git a/Jellyfin.Api/Controllers/GenresController.cs b/Jellyfin.Api/Controllers/GenresController.cs index 54d48aec21..dd60d01e0c 100644 --- a/Jellyfin.Api/Controllers/GenresController.cs +++ b/Jellyfin.Api/Controllers/GenresController.cs @@ -4,8 +4,9 @@ using System.Linq; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; -using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -76,18 +77,18 @@ public class GenresController : BaseJellyfinApiController [FromQuery] int? limit, [FromQuery] string? searchTerm, [FromQuery] Guid? parentId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] includeItemTypes, [FromQuery] bool? isFavorite, [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes, [FromQuery] Guid? userId, [FromQuery] string? nameStartsWithOrGreater, [FromQuery] string? nameStartsWith, [FromQuery] string? nameLessThan, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemSortBy[] sortBy, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemSortBy[] sortBy, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] SortOrder[] sortOrder, [FromQuery] bool? enableImages = true, [FromQuery] bool enableTotalRecordCount = true) { diff --git a/Jellyfin.Api/Controllers/ImageController.cs b/Jellyfin.Api/Controllers/ImageController.cs index b711990261..abda053d36 100644 --- a/Jellyfin.Api/Controllers/ImageController.cs +++ b/Jellyfin.Api/Controllers/ImageController.cs @@ -130,7 +130,7 @@ public class ImageController : BaseJellyfinApiController await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false); } - user.ProfileImage = new Data.Entities.ImageInfo(Path.Combine(userDataPath, "profile" + extension)); + user.ProfileImage = new Database.Implementations.Entities.ImageInfo(Path.Combine(userDataPath, "profile" + extension)); await _providerManager .SaveImage(stream, mimeType, user.ProfileImage.Path) @@ -1727,7 +1727,8 @@ public class ImageController : BaseJellyfinApiController [FromQuery, Range(0, 100)] int quality = 90) { var brandingOptions = _serverConfigurationManager.GetConfiguration("branding"); - if (!brandingOptions.SplashscreenEnabled) + var isAdmin = User.IsInRole(Constants.UserRoles.Administrator); + if (!brandingOptions.SplashscreenEnabled && !isAdmin) { return NotFound(); } diff --git a/Jellyfin.Api/Controllers/InstantMixController.cs b/Jellyfin.Api/Controllers/InstantMixController.cs index dcbacf1d78..c4b9767565 100644 --- a/Jellyfin.Api/Controllers/InstantMixController.cs +++ b/Jellyfin.Api/Controllers/InstantMixController.cs @@ -1,10 +1,12 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.ComponentModel.DataAnnotations; +using System.Linq; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; -using Jellyfin.Data.Entities; +using Jellyfin.Database.Implementations.Entities; using Jellyfin.Extensions; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -71,11 +73,11 @@ public class InstantMixController : BaseJellyfinApiController [FromRoute, Required] Guid itemId, [FromQuery] Guid? userId, [FromQuery] int? limit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields, [FromQuery] bool? enableImages, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes) { userId = RequestHelpers.GetUserId(User, userId); var user = userId.IsNullOrEmpty() @@ -115,11 +117,11 @@ public class InstantMixController : BaseJellyfinApiController [FromRoute, Required] Guid itemId, [FromQuery] Guid? userId, [FromQuery] int? limit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields, [FromQuery] bool? enableImages, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes) { userId = RequestHelpers.GetUserId(User, userId); var user = userId.IsNullOrEmpty() @@ -159,11 +161,11 @@ public class InstantMixController : BaseJellyfinApiController [FromRoute, Required] Guid itemId, [FromQuery] Guid? userId, [FromQuery] int? limit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields, [FromQuery] bool? enableImages, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes) { userId = RequestHelpers.GetUserId(User, userId); var user = userId.IsNullOrEmpty() @@ -201,11 +203,11 @@ public class InstantMixController : BaseJellyfinApiController [FromRoute, Required] string name, [FromQuery] Guid? userId, [FromQuery] int? limit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields, [FromQuery] bool? enableImages, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes) { userId = RequestHelpers.GetUserId(User, userId); var user = userId.IsNullOrEmpty() @@ -239,11 +241,11 @@ public class InstantMixController : BaseJellyfinApiController [FromRoute, Required] Guid itemId, [FromQuery] Guid? userId, [FromQuery] int? limit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields, [FromQuery] bool? enableImages, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes) { userId = RequestHelpers.GetUserId(User, userId); var user = userId.IsNullOrEmpty() @@ -283,11 +285,11 @@ public class InstantMixController : BaseJellyfinApiController [FromRoute, Required] Guid itemId, [FromQuery] Guid? userId, [FromQuery] int? limit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields, [FromQuery] bool? enableImages, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes) { userId = RequestHelpers.GetUserId(User, userId); var user = userId.IsNullOrEmpty() @@ -328,11 +330,11 @@ public class InstantMixController : BaseJellyfinApiController [FromQuery, Required] Guid id, [FromQuery] Guid? userId, [FromQuery] int? limit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields, [FromQuery] bool? enableImages, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes) { return GetInstantMixFromArtists( id, @@ -366,11 +368,11 @@ public class InstantMixController : BaseJellyfinApiController [FromQuery, Required] Guid id, [FromQuery] Guid? userId, [FromQuery] int? limit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields, [FromQuery] bool? enableImages, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes) { userId = RequestHelpers.GetUserId(User, userId); var user = userId.IsNullOrEmpty() @@ -389,23 +391,19 @@ public class InstantMixController : BaseJellyfinApiController return GetResult(items, user, limit, dtoOptions); } - private QueryResult GetResult(List items, User? user, int? limit, DtoOptions dtoOptions) + private QueryResult GetResult(IReadOnlyList items, User? user, int? limit, DtoOptions dtoOptions) { - var list = items; + var totalCount = items.Count; - var totalCount = list.Count; - - if (limit.HasValue && limit < list.Count) + if (limit.HasValue && limit < items.Count) { - list = list.GetRange(0, limit.Value); + items = items.Take(limit.Value).ToArray(); } - var returnList = _dtoService.GetBaseItemDtos(list, dtoOptions, user); - var result = new QueryResult( 0, totalCount, - returnList); + _dtoService.GetBaseItemDtos(items, dtoOptions, user)); return result; } diff --git a/Jellyfin.Api/Controllers/ItemRefreshController.cs b/Jellyfin.Api/Controllers/ItemRefreshController.cs index d7a8c37c4b..7effe61e49 100644 --- a/Jellyfin.Api/Controllers/ItemRefreshController.cs +++ b/Jellyfin.Api/Controllers/ItemRefreshController.cs @@ -50,6 +50,7 @@ public class ItemRefreshController : BaseJellyfinApiController /// (Optional) Specifies the image refresh mode. /// (Optional) Determines if metadata should be replaced. Only applicable if mode is FullRefresh. /// (Optional) Determines if images should be replaced. Only applicable if mode is FullRefresh. + /// (Optional) Determines if trickplay images should be replaced. Only applicable if mode is FullRefresh. /// Item metadata refresh queued. /// Item to refresh not found. /// An on success, or a if the item could not be found. @@ -62,7 +63,8 @@ public class ItemRefreshController : BaseJellyfinApiController [FromQuery] MetadataRefreshMode metadataRefreshMode = MetadataRefreshMode.None, [FromQuery] MetadataRefreshMode imageRefreshMode = MetadataRefreshMode.None, [FromQuery] bool replaceAllMetadata = false, - [FromQuery] bool replaceAllImages = false) + [FromQuery] bool replaceAllImages = false, + [FromQuery] bool regenerateTrickplay = false) { var item = _libraryManager.GetItemById(itemId, User.GetUserId()); if (item is null) @@ -81,7 +83,8 @@ public class ItemRefreshController : BaseJellyfinApiController || replaceAllImages || replaceAllMetadata, IsAutomated = false, - RemoveOldMetadata = replaceAllMetadata + RemoveOldMetadata = replaceAllMetadata, + RegenerateTrickplay = regenerateTrickplay }; _providerManager.QueueRefresh(item.Id, refreshOptions, RefreshPriority.High); diff --git a/Jellyfin.Api/Controllers/ItemUpdateController.cs b/Jellyfin.Api/Controllers/ItemUpdateController.cs index 4001a6addb..d49e0753ee 100644 --- a/Jellyfin.Api/Controllers/ItemUpdateController.cs +++ b/Jellyfin.Api/Controllers/ItemUpdateController.cs @@ -457,7 +457,7 @@ public class ItemUpdateController : BaseJellyfinApiController return null; } - return (SeriesStatus)Enum.Parse(typeof(SeriesStatus), item.Status, true); + return Enum.Parse(item.Status, true); } private DateTime NormalizeDateTime(DateTime val) diff --git a/Jellyfin.Api/Controllers/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs index 828bd51740..a491283363 100644 --- a/Jellyfin.Api/Controllers/ItemsController.cs +++ b/Jellyfin.Api/Controllers/ItemsController.cs @@ -4,7 +4,9 @@ using System.Linq; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; +using Jellyfin.Data; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Dto; @@ -171,8 +173,8 @@ public class ItemsController : BaseJellyfinApiController [FromQuery] bool? hasParentalRating, [FromQuery] bool? isHd, [FromQuery] bool? is4K, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] locationTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] LocationType[] locationTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] LocationType[] excludeLocationTypes, [FromQuery] bool? isMissing, [FromQuery] bool? isUnaired, [FromQuery] double? minCommunityRating, @@ -190,42 +192,42 @@ public class ItemsController : BaseJellyfinApiController [FromQuery] bool? isNews, [FromQuery] bool? isKids, [FromQuery] bool? isSports, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeItemIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] excludeItemIds, [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery] bool? recursive, [FromQuery] string? searchTerm, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] SortOrder[] sortOrder, [FromQuery] Guid? parentId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFilter[] filters, [FromQuery] bool? isFavorite, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] MediaType[] mediaTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] imageTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemSortBy[] sortBy, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] MediaType[] mediaTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] imageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemSortBy[] sortBy, [FromQuery] bool? isPlayed, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years, + [FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] genres, + [FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] officialRatings, + [FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] tags, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] int[] years, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes, [FromQuery] string? person, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] studios, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] artists, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] artistIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumArtistIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] contributingArtistIds, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] albums, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] VideoType[] videoTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] personIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] personTypes, + [FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] studios, + [FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] artists, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] excludeArtistIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] artistIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] albumArtistIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] contributingArtistIds, + [FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] albums, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] albumIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] ids, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] VideoType[] videoTypes, [FromQuery] string? minOfficialRating, [FromQuery] bool? isLocked, [FromQuery] bool? isPlaceHolder, @@ -236,12 +238,12 @@ public class ItemsController : BaseJellyfinApiController [FromQuery] int? maxWidth, [FromQuery] int? maxHeight, [FromQuery] bool? is3D, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SeriesStatus[] seriesStatus, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] SeriesStatus[] seriesStatus, [FromQuery] string? nameStartsWithOrGreater, [FromQuery] string? nameStartsWith, [FromQuery] string? nameLessThan, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] studioIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] genreIds, [FromQuery] bool enableTotalRecordCount = true, [FromQuery] bool? enableImages = true) { @@ -446,13 +448,13 @@ public class ItemsController : BaseJellyfinApiController // Min official rating if (!string.IsNullOrWhiteSpace(minOfficialRating)) { - query.MinParentalRating = _localization.GetRatingLevel(minOfficialRating); + query.MinParentalRating = _localization.GetRatingScore(minOfficialRating); } // Max official rating if (!string.IsNullOrWhiteSpace(maxOfficialRating)) { - query.MaxParentalRating = _localization.GetRatingLevel(maxOfficialRating); + query.MaxParentalRating = _localization.GetRatingScore(maxOfficialRating); } // Artists @@ -638,8 +640,8 @@ public class ItemsController : BaseJellyfinApiController [FromQuery] bool? hasParentalRating, [FromQuery] bool? isHd, [FromQuery] bool? is4K, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] locationTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] LocationType[] locationTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] LocationType[] excludeLocationTypes, [FromQuery] bool? isMissing, [FromQuery] bool? isUnaired, [FromQuery] double? minCommunityRating, @@ -657,42 +659,42 @@ public class ItemsController : BaseJellyfinApiController [FromQuery] bool? isNews, [FromQuery] bool? isKids, [FromQuery] bool? isSports, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeItemIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] excludeItemIds, [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery] bool? recursive, [FromQuery] string? searchTerm, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] SortOrder[] sortOrder, [FromQuery] Guid? parentId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFilter[] filters, [FromQuery] bool? isFavorite, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] MediaType[] mediaTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] imageTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemSortBy[] sortBy, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] MediaType[] mediaTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] imageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemSortBy[] sortBy, [FromQuery] bool? isPlayed, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years, + [FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] genres, + [FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] officialRatings, + [FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] tags, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] int[] years, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes, [FromQuery] string? person, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] studios, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] artists, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] artistIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumArtistIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] contributingArtistIds, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] albums, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] VideoType[] videoTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] personIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] personTypes, + [FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] studios, + [FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] artists, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] excludeArtistIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] artistIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] albumArtistIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] contributingArtistIds, + [FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] albums, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] albumIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] ids, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] VideoType[] videoTypes, [FromQuery] string? minOfficialRating, [FromQuery] bool? isLocked, [FromQuery] bool? isPlaceHolder, @@ -703,12 +705,12 @@ public class ItemsController : BaseJellyfinApiController [FromQuery] int? maxWidth, [FromQuery] int? maxHeight, [FromQuery] bool? is3D, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SeriesStatus[] seriesStatus, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] SeriesStatus[] seriesStatus, [FromQuery] string? nameStartsWithOrGreater, [FromQuery] string? nameStartsWith, [FromQuery] string? nameLessThan, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] studioIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] genreIds, [FromQuery] bool enableTotalRecordCount = true, [FromQuery] bool? enableImages = true) => GetItems( @@ -827,13 +829,13 @@ public class ItemsController : BaseJellyfinApiController [FromQuery] int? limit, [FromQuery] string? searchTerm, [FromQuery] Guid? parentId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] MediaType[] mediaTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] MediaType[] mediaTypes, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] includeItemTypes, [FromQuery] bool enableTotalRecordCount = true, [FromQuery] bool? enableImages = true, [FromQuery] bool excludeActiveSessions = false) @@ -929,13 +931,13 @@ public class ItemsController : BaseJellyfinApiController [FromQuery] int? limit, [FromQuery] string? searchTerm, [FromQuery] Guid? parentId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] MediaType[] mediaTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] MediaType[] mediaTypes, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] includeItemTypes, [FromQuery] bool enableTotalRecordCount = true, [FromQuery] bool? enableImages = true, [FromQuery] bool excludeActiveSessions = false) @@ -967,7 +969,7 @@ public class ItemsController : BaseJellyfinApiController [HttpGet("UserItems/{itemId}/UserData")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult GetItemUserData( + public ActionResult GetItemUserData( [FromQuery] Guid? userId, [FromRoute, Required] Guid itemId) { @@ -1005,7 +1007,7 @@ public class ItemsController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status404NotFound)] [Obsolete("Kept for backwards compatibility")] [ApiExplorerSettings(IgnoreApi = true)] - public ActionResult GetItemUserDataLegacy( + public ActionResult GetItemUserDataLegacy( [FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) => GetItemUserData(userId, itemId); @@ -1022,7 +1024,7 @@ public class ItemsController : BaseJellyfinApiController [HttpPost("UserItems/{itemId}/UserData")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult UpdateItemUserData( + public ActionResult UpdateItemUserData( [FromQuery] Guid? userId, [FromRoute, Required] Guid itemId, [FromBody, Required] UpdateUserItemDataDto userDataDto) @@ -1064,7 +1066,7 @@ public class ItemsController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status404NotFound)] [Obsolete("Kept for backwards compatibility")] [ApiExplorerSettings(IgnoreApi = true)] - public ActionResult UpdateItemUserDataLegacy( + public ActionResult UpdateItemUserDataLegacy( [FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId, [FromBody, Required] UpdateUserItemDataDto userDataDto) diff --git a/Jellyfin.Api/Controllers/LibraryController.cs b/Jellyfin.Api/Controllers/LibraryController.cs index afc93c3a8d..bde1758e99 100644 --- a/Jellyfin.Api/Controllers/LibraryController.cs +++ b/Jellyfin.Api/Controllers/LibraryController.cs @@ -11,8 +11,9 @@ using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Api.Models.LibraryDtos; -using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using MediaBrowser.Common.Api; using MediaBrowser.Common.Extensions; @@ -144,8 +145,8 @@ public class LibraryController : BaseJellyfinApiController [FromRoute, Required] Guid itemId, [FromQuery] Guid? userId, [FromQuery] bool inheritFromParent = false, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemSortBy[]? sortBy = null, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[]? sortOrder = null) + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemSortBy[]? sortBy = null, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] SortOrder[]? sortOrder = null) { userId = RequestHelpers.GetUserId(User, userId); var user = userId.IsNullOrEmpty() @@ -218,8 +219,8 @@ public class LibraryController : BaseJellyfinApiController [FromRoute, Required] Guid itemId, [FromQuery] Guid? userId, [FromQuery] bool inheritFromParent = false, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemSortBy[]? sortBy = null, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[]? sortOrder = null) + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemSortBy[]? sortBy = null, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] SortOrder[]? sortOrder = null) { userId = RequestHelpers.GetUserId(User, userId); var user = userId.IsNullOrEmpty() @@ -290,8 +291,8 @@ public class LibraryController : BaseJellyfinApiController [FromRoute, Required] Guid itemId, [FromQuery] Guid? userId, [FromQuery] bool inheritFromParent = false, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemSortBy[]? sortBy = null, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[]? sortOrder = null) + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemSortBy[]? sortBy = null, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] SortOrder[]? sortOrder = null) { var themeSongs = GetThemeSongs( itemId, @@ -400,7 +401,7 @@ public class LibraryController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult DeleteItems([FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids) + public ActionResult DeleteItems([FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] ids) { var isApiKey = User.GetIsApiKey(); var userId = User.GetUserId(); @@ -722,10 +723,10 @@ public class LibraryController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult> GetSimilarItems( [FromRoute, Required] Guid itemId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] excludeArtistIds, [FromQuery] Guid? userId, [FromQuery] int? limit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields) + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields) { userId = RequestHelpers.GetUserId(User, userId); var user = userId.IsNullOrEmpty() @@ -780,11 +781,9 @@ public class LibraryController : BaseJellyfinApiController Genres = item.Genres, Limit = limit, IncludeItemTypes = includeItemTypes.ToArray(), - SimilarTo = item, DtoOptions = dtoOptions, EnableTotalRecordCount = !isMovie ?? true, EnableGroupByMetadataKey = isMovie ?? false, - MinSimilarityScore = 2 // A remnant from album/artist scoring }; // ExcludeArtistIds @@ -793,7 +792,7 @@ public class LibraryController : BaseJellyfinApiController query.ExcludeArtistIds = excludeArtistIds; } - List itemsResult = _libraryManager.GetItemList(query); + var itemsResult = _libraryManager.GetItemList(query); var returnList = _dtoService.GetBaseItemDtos(itemsResult, dtoOptions, user); @@ -867,6 +866,16 @@ public class LibraryController : BaseJellyfinApiController .DistinctBy(i => i.Name, StringComparer.OrdinalIgnoreCase) .ToArray(); + result.MediaSegmentProviders = plugins + .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.MediaSegmentProvider)) + .Select(i => new LibraryOptionInfoDto + { + Name = i.Name, + DefaultEnabled = true + }) + .DistinctBy(i => i.Name, StringComparer.OrdinalIgnoreCase) + .ToArray(); + var typeOptions = new List(); foreach (var type in types) diff --git a/Jellyfin.Api/Controllers/LibraryStructureController.cs b/Jellyfin.Api/Controllers/LibraryStructureController.cs index 93c2393f33..2a885662b5 100644 --- a/Jellyfin.Api/Controllers/LibraryStructureController.cs +++ b/Jellyfin.Api/Controllers/LibraryStructureController.cs @@ -77,7 +77,7 @@ public class LibraryStructureController : BaseJellyfinApiController public async Task AddVirtualFolder( [FromQuery] string name, [FromQuery] CollectionTypeOptions? collectionType, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] paths, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] paths, [FromBody] AddVirtualFolderDto? libraryOptionsDto, [FromQuery] bool refreshLibrary = false) { @@ -99,6 +99,7 @@ public class LibraryStructureController : BaseJellyfinApiController /// The name of the folder. /// Whether to refresh the library. /// Folder removed. + /// Folder not found. /// A . [HttpDelete] [ProducesResponseType(StatusCodes.Status204NoContent)] @@ -106,7 +107,9 @@ public class LibraryStructureController : BaseJellyfinApiController [FromQuery] string name, [FromQuery] bool refreshLibrary = false) { + // TODO: refactor! this relies on an FileNotFound exception to return NotFound when attempting to remove a library that does not exist. await _libraryManager.RemoveVirtualFolder(name, refreshLibrary).ConfigureAwait(false); + return NoContent(); } diff --git a/Jellyfin.Api/Controllers/LiveTvController.cs b/Jellyfin.Api/Controllers/LiveTvController.cs index 0ae8baa671..10f1789ad8 100644 --- a/Jellyfin.Api/Controllers/LiveTvController.cs +++ b/Jellyfin.Api/Controllers/LiveTvController.cs @@ -15,6 +15,7 @@ using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Api.Models.LiveTvDtos; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using MediaBrowser.Common.Api; using MediaBrowser.Common.Configuration; @@ -159,10 +160,10 @@ public class LiveTvController : BaseJellyfinApiController [FromQuery] bool? isDisliked, [FromQuery] bool? enableImages, [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields, [FromQuery] bool? enableUserData, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemSortBy[] sortBy, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemSortBy[] sortBy, [FromQuery] SortOrder? sortOrder, [FromQuery] bool enableFavoriteSorting = false, [FromQuery] bool addCurrentProgram = true) @@ -283,8 +284,8 @@ public class LiveTvController : BaseJellyfinApiController [FromQuery] string? seriesTimerId, [FromQuery] bool? enableImages, [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields, [FromQuery] bool? enableUserData, [FromQuery] bool? isMovie, [FromQuery] bool? isSeries, @@ -371,8 +372,8 @@ public class LiveTvController : BaseJellyfinApiController [FromQuery] string? seriesTimerId, [FromQuery] bool? enableImages, [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields, [FromQuery] bool? enableUserData, [FromQuery] bool enableTotalRecordCount = true) { @@ -566,7 +567,7 @@ public class LiveTvController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status200OK)] [Authorize(Policy = Policies.LiveTvAccess)] public async Task>> GetLiveTvPrograms( - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] channelIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] channelIds, [FromQuery] Guid? userId, [FromQuery] DateTime? minStartDate, [FromQuery] bool? hasAired, @@ -581,17 +582,17 @@ public class LiveTvController : BaseJellyfinApiController [FromQuery] bool? isSports, [FromQuery] int? startIndex, [FromQuery] int? limit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemSortBy[] sortBy, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemSortBy[] sortBy, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] SortOrder[] sortOrder, + [FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] genres, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] genreIds, [FromQuery] bool? enableImages, [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes, [FromQuery] bool? enableUserData, [FromQuery] string? seriesTimerId, [FromQuery] Guid? librarySeriesId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields, [FromQuery] bool enableTotalRecordCount = true) { userId = RequestHelpers.GetUserId(User, userId); @@ -698,6 +699,7 @@ public class LiveTvController : BaseJellyfinApiController /// Gets recommended live tv epgs. /// /// Optional. filter by user id. + /// Optional. The record index to start at. All items with a lower index will be dropped from the results. /// Optional. The maximum number of records to return. /// Optional. Filter by programs that are currently airing, or not. /// Optional. Filter by programs that have completed airing, or not. @@ -720,6 +722,7 @@ public class LiveTvController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status200OK)] public async Task>> GetRecommendedPrograms( [FromQuery] Guid? userId, + [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery] bool? isAiring, [FromQuery] bool? hasAired, @@ -730,9 +733,9 @@ public class LiveTvController : BaseJellyfinApiController [FromQuery] bool? isSports, [FromQuery] bool? enableImages, [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] genreIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields, [FromQuery] bool? enableUserData, [FromQuery] bool enableTotalRecordCount = true) { @@ -744,6 +747,7 @@ public class LiveTvController : BaseJellyfinApiController var query = new InternalItemsQuery(user) { IsAiring = isAiring, + StartIndex = startIndex, Limit = limit, HasAired = hasAired, IsSeries = isSeries, @@ -962,9 +966,9 @@ public class LiveTvController : BaseJellyfinApiController } /// - /// Get guid info. + /// Get guide info. /// - /// Guid info returned. + /// Guide info returned. /// An containing the guide info. [HttpGet("GuideInfo")] [Authorize(Policy = Policies.LiveTvAccess)] @@ -1186,7 +1190,9 @@ public class LiveTvController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesVideoFile] - public ActionResult GetLiveStreamFile([FromRoute, Required] string streamId, [FromRoute, Required] string container) + public ActionResult GetLiveStreamFile( + [FromRoute, Required] string streamId, + [FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string container) { var liveStreamInfo = _mediaSourceManager.GetLiveStreamInfoByUniqueId(streamId); if (liveStreamInfo is null) diff --git a/Jellyfin.Api/Controllers/LocalizationController.cs b/Jellyfin.Api/Controllers/LocalizationController.cs index f65d95c411..bbce5a9e13 100644 --- a/Jellyfin.Api/Controllers/LocalizationController.cs +++ b/Jellyfin.Api/Controllers/LocalizationController.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using Jellyfin.Api.Constants; using MediaBrowser.Common.Api; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; @@ -45,7 +44,7 @@ public class LocalizationController : BaseJellyfinApiController /// An containing the list of countries. [HttpGet("Countries")] [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> GetCountries() + public ActionResult> GetCountries() { return Ok(_localization.GetCountries()); } @@ -57,7 +56,7 @@ public class LocalizationController : BaseJellyfinApiController /// An containing the list of parental ratings. [HttpGet("ParentalRatings")] [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> GetParentalRatings() + public ActionResult> GetParentalRatings() { return Ok(_localization.GetParentalRatings()); } diff --git a/Jellyfin.Api/Controllers/MediaSegmentsController.cs b/Jellyfin.Api/Controllers/MediaSegmentsController.cs index 3dc5167a2e..e30e2b54e4 100644 --- a/Jellyfin.Api/Controllers/MediaSegmentsController.cs +++ b/Jellyfin.Api/Controllers/MediaSegmentsController.cs @@ -4,7 +4,7 @@ using System.ComponentModel.DataAnnotations; using System.Linq; using System.Threading.Tasks; using Jellyfin.Api.Extensions; -using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; using MediaBrowser.Controller; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; @@ -55,7 +55,7 @@ public class MediaSegmentsController : BaseJellyfinApiController return NotFound(); } - var items = await _mediaSegmentManager.GetSegmentsAsync(item.Id, includeSegmentTypes).ConfigureAwait(false); + var items = await _mediaSegmentManager.GetSegmentsAsync(item, includeSegmentTypes).ConfigureAwait(false); return Ok(new QueryResult(items.ToArray())); } } diff --git a/Jellyfin.Api/Controllers/MoviesController.cs b/Jellyfin.Api/Controllers/MoviesController.cs index 471bcd096e..363acf815a 100644 --- a/Jellyfin.Api/Controllers/MoviesController.cs +++ b/Jellyfin.Api/Controllers/MoviesController.cs @@ -5,8 +5,9 @@ using System.Linq; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; -using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Configuration; @@ -65,7 +66,7 @@ public class MoviesController : BaseJellyfinApiController public ActionResult> GetMovieRecommendations( [FromQuery] Guid? userId, [FromQuery] Guid? parentId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields, [FromQuery] int categoryLimit = 5, [FromQuery] int itemLimit = 8) { @@ -120,7 +121,7 @@ public class MoviesController : BaseJellyfinApiController DtoOptions = dtoOptions }); - var mostRecentMovies = recentlyPlayedMovies.GetRange(0, Math.Min(recentlyPlayedMovies.Count, 6)); + var mostRecentMovies = recentlyPlayedMovies.Take(Math.Min(recentlyPlayedMovies.Count, 6)).ToList(); // Get recently played directors var recentDirectors = GetDirectors(mostRecentMovies) .ToList(); @@ -276,7 +277,6 @@ public class MoviesController : BaseJellyfinApiController Limit = itemLimit, IncludeItemTypes = itemTypes.ToArray(), IsMovie = true, - SimilarTo = item, EnableGroupByMetadataKey = true, DtoOptions = dtoOptions }); diff --git a/Jellyfin.Api/Controllers/MusicGenresController.cs b/Jellyfin.Api/Controllers/MusicGenresController.cs index 5411baa3e7..1e45e53ca1 100644 --- a/Jellyfin.Api/Controllers/MusicGenresController.cs +++ b/Jellyfin.Api/Controllers/MusicGenresController.cs @@ -4,8 +4,9 @@ using System.Linq; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; -using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -76,18 +77,18 @@ public class MusicGenresController : BaseJellyfinApiController [FromQuery] int? limit, [FromQuery] string? searchTerm, [FromQuery] Guid? parentId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] includeItemTypes, [FromQuery] bool? isFavorite, [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes, [FromQuery] Guid? userId, [FromQuery] string? nameStartsWithOrGreater, [FromQuery] string? nameStartsWith, [FromQuery] string? nameLessThan, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemSortBy[] sortBy, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemSortBy[] sortBy, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] SortOrder[] sortOrder, [FromQuery] bool? enableImages = true, [FromQuery] bool enableTotalRecordCount = true) { diff --git a/Jellyfin.Api/Controllers/PersonsController.cs b/Jellyfin.Api/Controllers/PersonsController.cs index 6ca3086015..4d12dc18fc 100644 --- a/Jellyfin.Api/Controllers/PersonsController.cs +++ b/Jellyfin.Api/Controllers/PersonsController.cs @@ -4,7 +4,7 @@ using System.Linq; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; -using Jellyfin.Data.Entities; +using Jellyfin.Database.Implementations.Entities; using Jellyfin.Extensions; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -67,14 +67,14 @@ public class PersonsController : BaseJellyfinApiController public ActionResult> GetPersons( [FromQuery] int? limit, [FromQuery] string? searchTerm, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFilter[] filters, [FromQuery] bool? isFavorite, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludePersonTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] excludePersonTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] personTypes, [FromQuery] Guid? appearsInItemId, [FromQuery] Guid? userId, [FromQuery] bool? enableImages = true) diff --git a/Jellyfin.Api/Controllers/PlaylistsController.cs b/Jellyfin.Api/Controllers/PlaylistsController.cs index e6f23b1364..ec5fdab38e 100644 --- a/Jellyfin.Api/Controllers/PlaylistsController.cs +++ b/Jellyfin.Api/Controllers/PlaylistsController.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using System.Globalization; using System.Linq; using System.Threading.Tasks; using Jellyfin.Api.Attributes; @@ -75,7 +76,7 @@ public class PlaylistsController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status200OK)] public async Task> CreatePlaylist( [FromQuery, ParameterObsolete] string? name, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder)), ParameterObsolete] IReadOnlyList ids, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder)), ParameterObsolete] IReadOnlyList ids, [FromQuery, ParameterObsolete] Guid? userId, [FromQuery, ParameterObsolete] MediaType? mediaType, [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] CreatePlaylistDto? createPlaylistRequest) @@ -369,7 +370,7 @@ public class PlaylistsController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task AddItemToPlaylist( [FromRoute, Required] Guid playlistId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] ids, [FromQuery] Guid? userId) { userId = RequestHelpers.GetUserId(User, userId); @@ -426,7 +427,7 @@ public class PlaylistsController : BaseJellyfinApiController return Forbid(); } - await _playlistManager.MoveItemAsync(playlistId, itemId, newIndex).ConfigureAwait(false); + await _playlistManager.MoveItemAsync(playlistId, itemId, newIndex, callingUserId).ConfigureAwait(false); return NoContent(); } @@ -445,7 +446,7 @@ public class PlaylistsController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task RemoveItemFromPlaylist( [FromRoute, Required] string playlistId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] entryIds) + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] entryIds) { var callingUserId = User.GetUserId(); @@ -492,11 +493,11 @@ public class PlaylistsController : BaseJellyfinApiController [FromQuery] Guid? userId, [FromQuery] int? startIndex, [FromQuery] int? limit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields, [FromQuery] bool? enableImages, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes) { var callingUserId = userId ?? User.GetUserId(); var playlist = _playlistManager.GetPlaylistForUser(playlistId, callingUserId); @@ -514,7 +515,8 @@ public class PlaylistsController : BaseJellyfinApiController return Forbid(); } - var items = playlist.GetManageableItems().ToArray(); + var user = _userManager.GetUserById(callingUserId); + var items = playlist.GetManageableItems().Where(i => i.Item2.IsVisible(user)).ToArray(); var count = items.Length; if (startIndex.HasValue) { @@ -529,11 +531,11 @@ public class PlaylistsController : BaseJellyfinApiController var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(User) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - var user = _userManager.GetUserById(callingUserId); + var dtos = _dtoService.GetBaseItemDtos(items.Select(i => i.Item2).ToList(), dtoOptions, user); for (int index = 0; index < dtos.Count; index++) { - dtos[index].PlaylistItemId = items[index].Item1.Id; + dtos[index].PlaylistItemId = items[index].Item1.ItemId?.ToString("N", CultureInfo.InvariantCulture); } var result = new QueryResult( diff --git a/Jellyfin.Api/Controllers/PlaystateController.cs b/Jellyfin.Api/Controllers/PlaystateController.cs index 88aa0178f9..1577b45947 100644 --- a/Jellyfin.Api/Controllers/PlaystateController.cs +++ b/Jellyfin.Api/Controllers/PlaystateController.cs @@ -5,7 +5,7 @@ using System.Threading.Tasks; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; -using Jellyfin.Data.Entities; +using Jellyfin.Database.Implementations.Entities; using Jellyfin.Extensions; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; @@ -72,7 +72,7 @@ public class PlaystateController : BaseJellyfinApiController [HttpPost("UserPlayedItems/{itemId}")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task> MarkPlayedItem( + public async Task> MarkPlayedItem( [FromQuery] Guid? userId, [FromRoute, Required] Guid itemId, [FromQuery, ModelBinder(typeof(LegacyDateTimeModelBinder))] DateTime? datePlayed) @@ -121,7 +121,7 @@ public class PlaystateController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status404NotFound)] [Obsolete("Kept for backwards compatibility")] [ApiExplorerSettings(IgnoreApi = true)] - public Task> MarkPlayedItemLegacy( + public Task> MarkPlayedItemLegacy( [FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId, [FromQuery, ModelBinder(typeof(LegacyDateTimeModelBinder))] DateTime? datePlayed) @@ -138,7 +138,7 @@ public class PlaystateController : BaseJellyfinApiController [HttpDelete("UserPlayedItems/{itemId}")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task> MarkUnplayedItem( + public async Task> MarkUnplayedItem( [FromQuery] Guid? userId, [FromRoute, Required] Guid itemId) { @@ -185,7 +185,7 @@ public class PlaystateController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status404NotFound)] [Obsolete("Kept for backwards compatibility")] [ApiExplorerSettings(IgnoreApi = true)] - public Task> MarkUnplayedItemLegacy( + public Task> MarkUnplayedItemLegacy( [FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) => MarkUnplayedItem(userId, itemId); @@ -502,7 +502,7 @@ public class PlaystateController : BaseJellyfinApiController /// if set to true [was played]. /// The date played. /// Task. - private UserItemDataDto UpdatePlayedStatus(User user, BaseItem item, bool wasPlayed, DateTime? datePlayed) + private UserItemDataDto? UpdatePlayedStatus(User user, BaseItem item, bool wasPlayed, DateTime? datePlayed) { if (wasPlayed) { diff --git a/Jellyfin.Api/Controllers/SearchController.cs b/Jellyfin.Api/Controllers/SearchController.cs index 8bae6fb9b6..ecf2335ba0 100644 --- a/Jellyfin.Api/Controllers/SearchController.cs +++ b/Jellyfin.Api/Controllers/SearchController.cs @@ -84,9 +84,9 @@ public class SearchController : BaseJellyfinApiController [FromQuery] int? limit, [FromQuery] Guid? userId, [FromQuery, Required] string searchTerm, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] MediaType[] mediaTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] MediaType[] mediaTypes, [FromQuery] Guid? parentId, [FromQuery] bool? isMovie, [FromQuery] bool? isSeries, diff --git a/Jellyfin.Api/Controllers/SessionController.cs b/Jellyfin.Api/Controllers/SessionController.cs index 2f9e9f091d..9886d03dee 100644 --- a/Jellyfin.Api/Controllers/SessionController.cs +++ b/Jellyfin.Api/Controllers/SessionController.cs @@ -122,7 +122,7 @@ public class SessionController : BaseJellyfinApiController public async Task Play( [FromRoute, Required] string sessionId, [FromQuery, Required] PlayCommand playCommand, - [FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] itemIds, + [FromQuery, Required, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] itemIds, [FromQuery] long? startPositionTicks, [FromQuery] string? mediaSourceId, [FromQuery] int? audioStreamIndex, @@ -347,8 +347,8 @@ public class SessionController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task PostCapabilities( [FromQuery] string? id, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] MediaType[] playableMediaTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] GeneralCommandType[] supportedCommands, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] MediaType[] playableMediaTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] GeneralCommandType[] supportedCommands, [FromQuery] bool supportsMediaControl = false, [FromQuery] bool supportsPersistentIdentifier = true) { diff --git a/Jellyfin.Api/Controllers/StartupController.cs b/Jellyfin.Api/Controllers/StartupController.cs index 41b0858d19..09f20558fe 100644 --- a/Jellyfin.Api/Controllers/StartupController.cs +++ b/Jellyfin.Api/Controllers/StartupController.cs @@ -58,6 +58,7 @@ public class StartupController : BaseJellyfinApiController { return new StartupConfigurationDto { + ServerName = _config.Configuration.ServerName, UICulture = _config.Configuration.UICulture, MetadataCountryCode = _config.Configuration.MetadataCountryCode, PreferredMetadataLanguage = _config.Configuration.PreferredMetadataLanguage @@ -74,6 +75,7 @@ public class StartupController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult UpdateInitialConfiguration([FromBody, Required] StartupConfigurationDto startupConfiguration) { + _config.Configuration.ServerName = startupConfiguration.ServerName ?? string.Empty; _config.Configuration.UICulture = startupConfiguration.UICulture ?? string.Empty; _config.Configuration.MetadataCountryCode = startupConfiguration.MetadataCountryCode ?? string.Empty; _config.Configuration.PreferredMetadataLanguage = startupConfiguration.PreferredMetadataLanguage ?? string.Empty; @@ -93,7 +95,6 @@ public class StartupController : BaseJellyfinApiController { NetworkConfiguration settings = _config.GetNetworkConfiguration(); settings.EnableRemoteAccess = startupRemoteAccessDto.EnableRemoteAccess; - settings.EnableUPnP = startupRemoteAccessDto.EnableAutomaticPortMapping; _config.SaveConfiguration(NetworkConfigurationStore.StoreKey, settings); return NoContent(); } @@ -113,8 +114,7 @@ public class StartupController : BaseJellyfinApiController var user = _userManager.Users.First(); return new StartupUserDto { - Name = user.Username, - Password = user.Password + Name = user.Username }; } diff --git a/Jellyfin.Api/Controllers/StudiosController.cs b/Jellyfin.Api/Controllers/StudiosController.cs index 708fc7436f..52cb87e72c 100644 --- a/Jellyfin.Api/Controllers/StudiosController.cs +++ b/Jellyfin.Api/Controllers/StudiosController.cs @@ -3,8 +3,8 @@ using System.ComponentModel.DataAnnotations; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; -using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; using Jellyfin.Extensions; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -73,13 +73,13 @@ public class StudiosController : BaseJellyfinApiController [FromQuery] int? limit, [FromQuery] string? searchTerm, [FromQuery] Guid? parentId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] includeItemTypes, [FromQuery] bool? isFavorite, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes, [FromQuery] Guid? userId, [FromQuery] string? nameStartsWithOrGreater, [FromQuery] string? nameStartsWith, diff --git a/Jellyfin.Api/Controllers/SubtitleController.cs b/Jellyfin.Api/Controllers/SubtitleController.cs index 9da1dce93e..e5df873f5b 100644 --- a/Jellyfin.Api/Controllers/SubtitleController.cs +++ b/Jellyfin.Api/Controllers/SubtitleController.cs @@ -395,7 +395,7 @@ public class SubtitleController : BaseJellyfinApiController var url = string.Format( CultureInfo.InvariantCulture, - "stream.vtt?CopyTimestamps=true&AddVttTimeMap=true&StartPositionTicks={0}&EndPositionTicks={1}&api_key={2}", + "stream.vtt?CopyTimestamps=true&AddVttTimeMap=true&StartPositionTicks={0}&EndPositionTicks={1}&ApiKey={2}", positionTicks.ToString(CultureInfo.InvariantCulture), endPositionTicks.ToString(CultureInfo.InvariantCulture), accessToken); diff --git a/Jellyfin.Api/Controllers/SuggestionsController.cs b/Jellyfin.Api/Controllers/SuggestionsController.cs index ad625cc6e0..52982c362d 100644 --- a/Jellyfin.Api/Controllers/SuggestionsController.cs +++ b/Jellyfin.Api/Controllers/SuggestionsController.cs @@ -3,8 +3,9 @@ using System.ComponentModel.DataAnnotations; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; -using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -59,8 +60,8 @@ public class SuggestionsController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult> GetSuggestions( [FromQuery] Guid? userId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] MediaType[] mediaType, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] type, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] MediaType[] mediaType, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] type, [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery] bool enableTotalRecordCount = false) @@ -115,8 +116,8 @@ public class SuggestionsController : BaseJellyfinApiController [ApiExplorerSettings(IgnoreApi = true)] public ActionResult> GetSuggestionsLegacy( [FromRoute, Required] Guid userId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] MediaType[] mediaType, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] type, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] MediaType[] mediaType, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] type, [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery] bool enableTotalRecordCount = false) diff --git a/Jellyfin.Api/Controllers/SyncPlayController.cs b/Jellyfin.Api/Controllers/SyncPlayController.cs index 3839781971..fbab2a7845 100644 --- a/Jellyfin.Api/Controllers/SyncPlayController.cs +++ b/Jellyfin.Api/Controllers/SyncPlayController.cs @@ -1,9 +1,9 @@ +using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Api.Constants; using Jellyfin.Api.Helpers; using Jellyfin.Api.Models.SyncPlayDtos; using MediaBrowser.Common.Api; @@ -50,17 +50,16 @@ public class SyncPlayController : BaseJellyfinApiController /// /// The settings of the new group. /// New group created. - /// A indicating success. + /// An for the created group. [HttpPost("New")] - [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status200OK)] [Authorize(Policy = Policies.SyncPlayCreateGroup)] - public async Task SyncPlayCreateGroup( + public async Task> SyncPlayCreateGroup( [FromBody, Required] NewGroupRequestDto requestData) { var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); var syncPlayRequest = new NewGroupRequest(requestData.GroupName); - _syncPlayManager.NewGroup(currentSession, syncPlayRequest, CancellationToken.None); - return NoContent(); + return Ok(_syncPlayManager.NewGroup(currentSession, syncPlayRequest, CancellationToken.None)); } /// @@ -112,6 +111,23 @@ public class SyncPlayController : BaseJellyfinApiController return Ok(_syncPlayManager.ListGroups(currentSession, syncPlayRequest).AsEnumerable()); } + /// + /// Gets a SyncPlay group by id. + /// + /// The id of the group. + /// Group returned. + /// An for the requested group. + [HttpGet("{id:guid}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = Policies.SyncPlayJoinGroup)] + public async Task> SyncPlayGetGroup([FromRoute] Guid id) + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var group = _syncPlayManager.GetGroup(currentSession, id); + return group == null ? NotFound() : Ok(group); + } + /// /// Request to set new playlist in SyncPlay group. /// diff --git a/Jellyfin.Api/Controllers/SystemController.cs b/Jellyfin.Api/Controllers/SystemController.cs index 6c5ce47158..07a1f76503 100644 --- a/Jellyfin.Api/Controllers/SystemController.cs +++ b/Jellyfin.Api/Controllers/SystemController.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Net.Mime; using Jellyfin.Api.Attributes; using Jellyfin.Api.Constants; +using Jellyfin.Api.Models.SystemInfoDtos; using MediaBrowser.Common.Api; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; @@ -71,6 +72,19 @@ public class SystemController : BaseJellyfinApiController public ActionResult GetSystemInfo() => _systemManager.GetSystemInfo(Request); + /// + /// Gets information about the server. + /// + /// Information retrieved. + /// User does not have permission to retrieve information. + /// A with info about the system. + [HttpGet("Info/Storage")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public ActionResult GetSystemStorage() + => Ok(SystemStorageDto.FromSystemStorageInfo(_systemManager.GetSystemStorageInfo())); + /// /// Gets public information about the server. /// @@ -212,20 +226,4 @@ public class SystemController : BaseJellyfinApiController FileStream stream = new FileStream(file.FullName, FileMode.Open, FileAccess.Read, fileShare, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); return File(stream, "text/plain; charset=utf-8"); } - - /// - /// Gets wake on lan information. - /// - /// Information retrieved. - /// An with the WakeOnLan infos. - [HttpGet("WakeOnLanInfo")] - [Authorize] - [Obsolete("This endpoint is obsolete.")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> GetWakeOnLanInfo() - { - var result = _networkManager.GetMacAddresses() - .Select(i => new WakeOnLanInfo(i)); - return Ok(result); - } } diff --git a/Jellyfin.Api/Controllers/TrailersController.cs b/Jellyfin.Api/Controllers/TrailersController.cs index d7d0cc4544..3e4bac89a5 100644 --- a/Jellyfin.Api/Controllers/TrailersController.cs +++ b/Jellyfin.Api/Controllers/TrailersController.cs @@ -1,6 +1,7 @@ using System; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; @@ -130,8 +131,8 @@ public class TrailersController : BaseJellyfinApiController [FromQuery] bool? hasParentalRating, [FromQuery] bool? isHd, [FromQuery] bool? is4K, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] locationTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] LocationType[] locationTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] LocationType[] excludeLocationTypes, [FromQuery] bool? isMissing, [FromQuery] bool? isUnaired, [FromQuery] double? minCommunityRating, @@ -149,41 +150,41 @@ public class TrailersController : BaseJellyfinApiController [FromQuery] bool? isNews, [FromQuery] bool? isKids, [FromQuery] bool? isSports, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeItemIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] excludeItemIds, [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery] bool? recursive, [FromQuery] string? searchTerm, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] SortOrder[] sortOrder, [FromQuery] Guid? parentId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFilter[] filters, [FromQuery] bool? isFavorite, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] MediaType[] mediaTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] imageTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemSortBy[] sortBy, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] MediaType[] mediaTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] imageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemSortBy[] sortBy, [FromQuery] bool? isPlayed, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years, + [FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] genres, + [FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] officialRatings, + [FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] tags, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] int[] years, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes, [FromQuery] string? person, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] studios, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] artists, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] artistIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumArtistIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] contributingArtistIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] albums, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] VideoType[] videoTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] personIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] personTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] studios, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] artists, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] excludeArtistIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] artistIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] albumArtistIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] contributingArtistIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] albums, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] albumIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] ids, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] VideoType[] videoTypes, [FromQuery] string? minOfficialRating, [FromQuery] bool? isLocked, [FromQuery] bool? isPlaceHolder, @@ -194,12 +195,12 @@ public class TrailersController : BaseJellyfinApiController [FromQuery] int? maxWidth, [FromQuery] int? maxHeight, [FromQuery] bool? is3D, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SeriesStatus[] seriesStatus, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] SeriesStatus[] seriesStatus, [FromQuery] string? nameStartsWithOrGreater, [FromQuery] string? nameStartsWith, [FromQuery] string? nameLessThan, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] studioIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] genreIds, [FromQuery] bool enableTotalRecordCount = true, [FromQuery] bool? enableImages = true) { diff --git a/Jellyfin.Api/Controllers/TvShowsController.cs b/Jellyfin.Api/Controllers/TvShowsController.cs index 914ccd7f93..0f08854d24 100644 --- a/Jellyfin.Api/Controllers/TvShowsController.cs +++ b/Jellyfin.Api/Controllers/TvShowsController.cs @@ -2,10 +2,12 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; +using Jellyfin.Api.Attributes; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -77,16 +79,16 @@ public class TvShowsController : BaseJellyfinApiController [FromQuery] Guid? userId, [FromQuery] int? startIndex, [FromQuery] int? limit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields, [FromQuery] Guid? seriesId, [FromQuery] Guid? parentId, [FromQuery] bool? enableImages, [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes, [FromQuery] bool? enableUserData, [FromQuery] DateTime? nextUpDateCutoff, [FromQuery] bool enableTotalRecordCount = true, - [FromQuery] bool disableFirstEpisode = false, + [FromQuery][ParameterObsolete] bool disableFirstEpisode = false, [FromQuery] bool enableResumable = true, [FromQuery] bool enableRewatching = false) { @@ -109,7 +111,6 @@ public class TvShowsController : BaseJellyfinApiController StartIndex = startIndex, User = user, EnableTotalRecordCount = enableTotalRecordCount, - DisableFirstEpisode = disableFirstEpisode, NextUpDateCutoff = nextUpDateCutoff ?? DateTime.MinValue, EnableResumable = enableResumable, EnableRewatching = enableRewatching @@ -143,11 +144,11 @@ public class TvShowsController : BaseJellyfinApiController [FromQuery] Guid? userId, [FromQuery] int? startIndex, [FromQuery] int? limit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields, [FromQuery] Guid? parentId, [FromQuery] bool? enableImages, [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes, [FromQuery] bool? enableUserData) { userId = RequestHelpers.GetUserId(User, userId); @@ -208,7 +209,7 @@ public class TvShowsController : BaseJellyfinApiController public ActionResult> GetEpisodes( [FromRoute, Required] Guid seriesId, [FromQuery] Guid? userId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields, [FromQuery] int? season, [FromQuery] Guid? seasonId, [FromQuery] bool? isMissing, @@ -218,7 +219,7 @@ public class TvShowsController : BaseJellyfinApiController [FromQuery] int? limit, [FromQuery] bool? enableImages, [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes, [FromQuery] bool? enableUserData, [FromQuery] ItemSortBy? sortBy) { @@ -332,13 +333,13 @@ public class TvShowsController : BaseJellyfinApiController public ActionResult> GetSeasons( [FromRoute, Required] Guid seriesId, [FromQuery] Guid? userId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields, [FromQuery] bool? isSpecialSeason, [FromQuery] bool? isMissing, [FromQuery] Guid? adjacentTo, [FromQuery] bool? enableImages, [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes, [FromQuery] bool? enableUserData) { userId = RequestHelpers.GetUserId(User, userId); diff --git a/Jellyfin.Api/Controllers/UniversalAudioController.cs b/Jellyfin.Api/Controllers/UniversalAudioController.cs index 41c4886d4f..fd63347030 100644 --- a/Jellyfin.Api/Controllers/UniversalAudioController.cs +++ b/Jellyfin.Api/Controllers/UniversalAudioController.cs @@ -98,17 +98,17 @@ public class UniversalAudioController : BaseJellyfinApiController [ProducesAudioFile] public async Task GetUniversalAudioStream( [FromRoute, Required] Guid itemId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] container, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] container, [FromQuery] string? mediaSourceId, [FromQuery] string? deviceId, [FromQuery] Guid? userId, - [FromQuery] [RegularExpression(EncodingHelper.ValidationRegex)] string? audioCodec, + [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec, [FromQuery] int? maxAudioChannels, [FromQuery] int? transcodingAudioChannels, [FromQuery] int? maxStreamingBitrate, [FromQuery] int? audioBitRate, [FromQuery] long? startTimeTicks, - [FromQuery] [RegularExpression(EncodingHelper.ValidationRegex)] string? transcodingContainer, + [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? transcodingContainer, [FromQuery] MediaStreamProtocol? transcodingProtocol, [FromQuery] int? maxAudioSampleRate, [FromQuery] int? maxAudioBitDepth, @@ -222,7 +222,7 @@ public class UniversalAudioController : BaseJellyfinApiController TranscodeReasons = mediaSource.TranscodeReasons == 0 ? null : mediaSource.TranscodeReasons.ToString(), Context = EncodingContext.Static, StreamOptions = new Dictionary(), - EnableAdaptiveBitrateStreaming = true, + EnableAdaptiveBitrateStreaming = false, EnableAudioVbrEncoding = enableAudioVbrEncoding }; diff --git a/Jellyfin.Api/Controllers/UserController.cs b/Jellyfin.Api/Controllers/UserController.cs index d7886d247f..d0ced277a0 100644 --- a/Jellyfin.Api/Controllers/UserController.cs +++ b/Jellyfin.Api/Controllers/UserController.cs @@ -7,7 +7,8 @@ using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.Models.UserDtos; -using Jellyfin.Data.Enums; +using Jellyfin.Data; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using MediaBrowser.Common.Api; using MediaBrowser.Common.Extensions; diff --git a/Jellyfin.Api/Controllers/UserLibraryController.cs b/Jellyfin.Api/Controllers/UserLibraryController.cs index e7bf717274..0e04beb14e 100644 --- a/Jellyfin.Api/Controllers/UserLibraryController.cs +++ b/Jellyfin.Api/Controllers/UserLibraryController.cs @@ -7,8 +7,8 @@ using System.Threading.Tasks; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; -using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; using Jellyfin.Extensions; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -305,7 +305,7 @@ public class UserLibraryController : BaseJellyfinApiController /// An containing the . [HttpDelete("UserItems/{itemId}/Rating")] [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult DeleteUserItemRating( + public ActionResult DeleteUserItemRating( [FromQuery] Guid? userId, [FromRoute, Required] Guid itemId) { @@ -338,7 +338,7 @@ public class UserLibraryController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status200OK)] [Obsolete("Kept for backwards compatibility")] [ApiExplorerSettings(IgnoreApi = true)] - public ActionResult DeleteUserItemRatingLegacy( + public ActionResult DeleteUserItemRatingLegacy( [FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) => DeleteUserItemRating(userId, itemId); @@ -353,7 +353,7 @@ public class UserLibraryController : BaseJellyfinApiController /// An containing the . [HttpPost("UserItems/{itemId}/Rating")] [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult UpdateUserItemRating( + public ActionResult UpdateUserItemRating( [FromQuery] Guid? userId, [FromRoute, Required] Guid itemId, [FromQuery] bool? likes) @@ -388,7 +388,7 @@ public class UserLibraryController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status200OK)] [Obsolete("Kept for backwards compatibility")] [ApiExplorerSettings(IgnoreApi = true)] - public ActionResult UpdateUserItemRatingLegacy( + public ActionResult UpdateUserItemRatingLegacy( [FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId, [FromQuery] bool? likes) @@ -523,12 +523,12 @@ public class UserLibraryController : BaseJellyfinApiController public ActionResult> GetLatestMedia( [FromQuery] Guid? userId, [FromQuery] Guid? parentId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] includeItemTypes, [FromQuery] bool? isPlayed, [FromQuery] bool? enableImages, [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes, [FromQuery] bool? enableUserData, [FromQuery] int limit = 20, [FromQuery] bool groupItems = true) @@ -608,12 +608,12 @@ public class UserLibraryController : BaseJellyfinApiController public ActionResult> GetLatestMediaLegacy( [FromRoute, Required] Guid userId, [FromQuery] Guid? parentId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] includeItemTypes, [FromQuery] bool? isPlayed, [FromQuery] bool? enableImages, [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes, [FromQuery] bool? enableUserData, [FromQuery] int limit = 20, [FromQuery] bool groupItems = true) @@ -634,10 +634,10 @@ public class UserLibraryController : BaseJellyfinApiController { if (item is Person) { - var hasMetdata = !string.IsNullOrWhiteSpace(item.Overview) && item.HasImage(ImageType.Primary); - var performFullRefresh = !hasMetdata && (DateTime.UtcNow - item.DateLastRefreshed).TotalDays >= 3; + var hasMetadata = !string.IsNullOrWhiteSpace(item.Overview) && item.HasImage(ImageType.Primary); + var performFullRefresh = !hasMetadata && (DateTime.UtcNow - item.DateLastRefreshed).TotalDays >= 3; - if (!hasMetdata) + if (!hasMetadata) { var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem)) { @@ -662,12 +662,15 @@ public class UserLibraryController : BaseJellyfinApiController // Get the user data for this item var data = _userDataRepository.GetUserData(user, item); - // Set favorite status - data.IsFavorite = isFavorite; + if (data is not null) + { + // Set favorite status + data.IsFavorite = isFavorite; - _userDataRepository.SaveUserData(user, item, data, UserDataSaveReason.UpdateUserRating, CancellationToken.None); + _userDataRepository.SaveUserData(user, item, data, UserDataSaveReason.UpdateUserRating, CancellationToken.None); + } - return _userDataRepository.GetUserDataDto(item, user); + return _userDataRepository.GetUserDataDto(item, user)!; } /// @@ -676,14 +679,17 @@ public class UserLibraryController : BaseJellyfinApiController /// The user. /// The item. /// if set to true [likes]. - private UserItemDataDto UpdateUserItemRatingInternal(User user, BaseItem item, bool? likes) + private UserItemDataDto? UpdateUserItemRatingInternal(User user, BaseItem item, bool? likes) { // Get the user data for this item var data = _userDataRepository.GetUserData(user, item); - data.Likes = likes; + if (data is not null) + { + data.Likes = likes; - _userDataRepository.SaveUserData(user, item, data, UserDataSaveReason.UpdateUserRating, CancellationToken.None); + _userDataRepository.SaveUserData(user, item, data, UserDataSaveReason.UpdateUserRating, CancellationToken.None); + } return _userDataRepository.GetUserDataDto(item, user); } diff --git a/Jellyfin.Api/Controllers/UserViewsController.cs b/Jellyfin.Api/Controllers/UserViewsController.cs index e24f78a888..64b2dffb32 100644 --- a/Jellyfin.Api/Controllers/UserViewsController.cs +++ b/Jellyfin.Api/Controllers/UserViewsController.cs @@ -66,7 +66,7 @@ public class UserViewsController : BaseJellyfinApiController public QueryResult GetUserViews( [FromQuery] Guid? userId, [FromQuery] bool? includeExternalContent, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] CollectionType?[] presetViews, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] CollectionType?[] presetViews, [FromQuery] bool includeHidden = false) { userId = RequestHelpers.GetUserId(User, userId); @@ -110,7 +110,7 @@ public class UserViewsController : BaseJellyfinApiController public QueryResult GetUserViewsLegacy( [FromRoute, Required] Guid userId, [FromQuery] bool? includeExternalContent, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] CollectionType?[] presetViews, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] CollectionType?[] presetViews, [FromQuery] bool includeHidden = false) => GetUserViews(userId, includeExternalContent, presetViews, includeHidden); diff --git a/Jellyfin.Api/Controllers/VideosController.cs b/Jellyfin.Api/Controllers/VideosController.cs index 8348fd937d..97f3239bbc 100644 --- a/Jellyfin.Api/Controllers/VideosController.cs +++ b/Jellyfin.Api/Controllers/VideosController.cs @@ -184,7 +184,7 @@ public class VideosController : BaseJellyfinApiController [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status400BadRequest)] - public async Task MergeVersions([FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids) + public async Task MergeVersions([FromQuery, Required, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] ids) { var userId = User.GetUserId(); var items = ids @@ -315,18 +315,18 @@ public class VideosController : BaseJellyfinApiController [ProducesVideoFile] public async Task GetVideoStream( [FromRoute, Required] Guid itemId, - [FromQuery] [RegularExpression(EncodingHelper.ValidationRegex)] string? container, + [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? container, [FromQuery] bool? @static, [FromQuery] string? @params, [FromQuery] string? tag, [FromQuery, ParameterObsolete] string? deviceProfileId, [FromQuery] string? playSessionId, - [FromQuery] [RegularExpression(EncodingHelper.ValidationRegex)] string? segmentContainer, + [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer, [FromQuery] int? segmentLength, [FromQuery] int? minSegments, [FromQuery] string? mediaSourceId, [FromQuery] string? deviceId, - [FromQuery] [RegularExpression(EncodingHelper.ValidationRegex)] string? audioCodec, + [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec, [FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowAudioStreamCopy, @@ -337,7 +337,7 @@ public class VideosController : BaseJellyfinApiController [FromQuery] int? audioChannels, [FromQuery] int? maxAudioChannels, [FromQuery] string? profile, - [FromQuery] string? level, + [FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level, [FromQuery] float? framerate, [FromQuery] float? maxFramerate, [FromQuery] bool? copyTimestamps, @@ -358,8 +358,8 @@ public class VideosController : BaseJellyfinApiController [FromQuery] int? cpuCoreLimit, [FromQuery] string? liveStreamId, [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] [RegularExpression(EncodingHelper.ValidationRegex)] string? videoCodec, - [FromQuery] [RegularExpression(EncodingHelper.ValidationRegex)] string? subtitleCodec, + [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec, + [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec, [FromQuery] string? transcodeReasons, [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, @@ -556,18 +556,18 @@ public class VideosController : BaseJellyfinApiController [ProducesVideoFile] public Task GetVideoStreamByContainer( [FromRoute, Required] Guid itemId, - [FromRoute, Required] string container, + [FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string container, [FromQuery] bool? @static, [FromQuery] string? @params, [FromQuery] string? tag, [FromQuery] string? deviceProfileId, [FromQuery] string? playSessionId, - [FromQuery] [RegularExpression(EncodingHelper.ValidationRegex)] string? segmentContainer, + [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer, [FromQuery] int? segmentLength, [FromQuery] int? minSegments, [FromQuery] string? mediaSourceId, [FromQuery] string? deviceId, - [FromQuery] [RegularExpression(EncodingHelper.ValidationRegex)] string? audioCodec, + [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec, [FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowAudioStreamCopy, @@ -578,7 +578,7 @@ public class VideosController : BaseJellyfinApiController [FromQuery] int? audioChannels, [FromQuery] int? maxAudioChannels, [FromQuery] string? profile, - [FromQuery] string? level, + [FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level, [FromQuery] float? framerate, [FromQuery] float? maxFramerate, [FromQuery] bool? copyTimestamps, @@ -599,8 +599,8 @@ public class VideosController : BaseJellyfinApiController [FromQuery] int? cpuCoreLimit, [FromQuery] string? liveStreamId, [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] [RegularExpression(EncodingHelper.ValidationRegex)] string? videoCodec, - [FromQuery] [RegularExpression(EncodingHelper.ValidationRegex)] string? subtitleCodec, + [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec, + [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec, [FromQuery] string? transcodeReasons, [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, diff --git a/Jellyfin.Api/Controllers/YearsController.cs b/Jellyfin.Api/Controllers/YearsController.cs index e4aa0ea42d..ebf98da456 100644 --- a/Jellyfin.Api/Controllers/YearsController.cs +++ b/Jellyfin.Api/Controllers/YearsController.cs @@ -1,12 +1,14 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.ComponentModel.DataAnnotations; using System.Linq; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; -using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -71,16 +73,16 @@ public class YearsController : BaseJellyfinApiController public ActionResult> GetYears( [FromQuery] int? startIndex, [FromQuery] int? limit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] SortOrder[] sortOrder, [FromQuery] Guid? parentId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] MediaType[] mediaTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemSortBy[] sortBy, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] MediaType[] mediaTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemSortBy[] sortBy, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes, [FromQuery] Guid? userId, [FromQuery] bool recursive = true, [FromQuery] bool? enableImages = true) @@ -105,18 +107,18 @@ public class YearsController : BaseJellyfinApiController bool Filter(BaseItem i) => FilterItem(i, excludeItemTypes, includeItemTypes, mediaTypes); - IList items; + IReadOnlyList items; if (parentItem.IsFolder) { var folder = (Folder)parentItem; if (userId.IsNullOrEmpty()) { - items = recursive ? folder.GetRecursiveChildren(Filter) : folder.Children.Where(Filter).ToList(); + items = recursive ? folder.GetRecursiveChildren(Filter) : folder.Children.Where(Filter).ToArray(); } else { - items = recursive ? folder.GetRecursiveChildren(user, query).ToList() : folder.GetChildren(user, true).Where(Filter).ToList(); + items = recursive ? folder.GetRecursiveChildren(user, query) : folder.GetChildren(user, true).Where(Filter).ToArray(); } } else diff --git a/Jellyfin.Api/Formatters/CssOutputFormatter.cs b/Jellyfin.Api/Formatters/CssOutputFormatter.cs index 495f771e1f..9ad1c863ea 100644 --- a/Jellyfin.Api/Formatters/CssOutputFormatter.cs +++ b/Jellyfin.Api/Formatters/CssOutputFormatter.cs @@ -1,6 +1,4 @@ -using System.Text; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; +using System.Net.Mime; using Microsoft.AspNetCore.Mvc.Formatters; namespace Jellyfin.Api.Formatters; @@ -8,28 +6,14 @@ namespace Jellyfin.Api.Formatters; /// /// Css output formatter. /// -public class CssOutputFormatter : TextOutputFormatter +public sealed class CssOutputFormatter : StringOutputFormatter { /// /// Initializes a new instance of the class. /// public CssOutputFormatter() { - SupportedMediaTypes.Add("text/css"); - - SupportedEncodings.Add(Encoding.UTF8); - SupportedEncodings.Add(Encoding.Unicode); - } - - /// - /// Write context object to stream. - /// - /// Writer context. - /// Unused. Writer encoding. - /// Write stream task. - public override Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding) - { - var stringResponse = context.Object?.ToString(); - return stringResponse is null ? Task.CompletedTask : context.HttpContext.Response.WriteAsync(stringResponse); + SupportedMediaTypes.Clear(); + SupportedMediaTypes.Add(MediaTypeNames.Text.Css); } } diff --git a/Jellyfin.Api/Formatters/XmlOutputFormatter.cs b/Jellyfin.Api/Formatters/XmlOutputFormatter.cs index 1c9feedcb7..8dbb91d0aa 100644 --- a/Jellyfin.Api/Formatters/XmlOutputFormatter.cs +++ b/Jellyfin.Api/Formatters/XmlOutputFormatter.cs @@ -1,7 +1,4 @@ using System.Net.Mime; -using System.Text; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Formatters; namespace Jellyfin.Api.Formatters; @@ -9,7 +6,7 @@ namespace Jellyfin.Api.Formatters; /// /// Xml output formatter. /// -public class XmlOutputFormatter : TextOutputFormatter +public sealed class XmlOutputFormatter : StringOutputFormatter { /// /// Initializes a new instance of the class. @@ -18,15 +15,5 @@ public class XmlOutputFormatter : TextOutputFormatter { SupportedMediaTypes.Clear(); SupportedMediaTypes.Add(MediaTypeNames.Text.Xml); - - SupportedEncodings.Add(Encoding.UTF8); - SupportedEncodings.Add(Encoding.Unicode); - } - - /// - public override Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding) - { - var stringResponse = context.Object?.ToString(); - return stringResponse is null ? Task.CompletedTask : context.HttpContext.Response.WriteAsync(stringResponse); } } diff --git a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs index 0e620e72a9..a38ad379cc 100644 --- a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs +++ b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs @@ -8,8 +8,8 @@ using System.Text; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Extensions; -using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; using Jellyfin.Extensions; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; @@ -267,7 +267,7 @@ public class DynamicHlsHelper if (EnableAdaptiveBitrateStreaming(state, isLiveStream, enableAdaptiveBitrateStreaming, _httpContextAccessor.HttpContext.GetNormalizedRemoteIP())) { - var requestedVideoBitrate = state.VideoRequest is null ? 0 : state.VideoRequest.VideoBitRate ?? 0; + var requestedVideoBitrate = state.VideoRequest?.VideoBitRate ?? 0; // By default, vary by just 200k var variation = GetBitrateVariation(totalBitrate); @@ -345,13 +345,15 @@ public class DynamicHlsHelper if (videoRange == VideoRange.HDR) { - if (videoRangeType == VideoRangeType.HLG) + switch (videoRangeType) { - builder.Append(",VIDEO-RANGE=HLG"); - } - else - { - builder.Append(",VIDEO-RANGE=PQ"); + case VideoRangeType.HLG: + case VideoRangeType.DOVIWithHLG: + builder.Append(",VIDEO-RANGE=HLG"); + break; + default: + builder.Append(",VIDEO-RANGE=PQ"); + break; } } } @@ -418,36 +420,67 @@ public class DynamicHlsHelper /// StreamState of the current stream. private void AppendPlaylistSupplementalCodecsField(StringBuilder builder, StreamState state) { - // Dolby Vision currently cannot exist when transcoding + // HDR dynamic metadata currently cannot exist when transcoding if (!EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) { return; } - var dvProfile = state.VideoStream.DvProfile; - var dvLevel = state.VideoStream.DvLevel; - var dvRangeString = state.VideoStream.VideoRangeType switch + if (EncodingHelper.IsDovi(state.VideoStream) && !_encodingHelper.IsDoviRemoved(state)) { - VideoRangeType.DOVIWithHDR10 => "db1p", - VideoRangeType.DOVIWithHLG => "db4h", - _ => string.Empty - }; - - if (dvProfile is null || dvLevel is null || string.IsNullOrEmpty(dvRangeString)) + AppendDvString(); + } + else if (EncodingHelper.IsHdr10Plus(state.VideoStream) && !_encodingHelper.IsHdr10PlusRemoved(state)) { - return; + AppendHdr10PlusString(); } - var dvFourCc = string.Equals(state.ActualOutputVideoCodec, "av1", StringComparison.OrdinalIgnoreCase) ? "dav1" : "dvh1"; - builder.Append(",SUPPLEMENTAL-CODECS=\"") - .Append(dvFourCc) - .Append('.') - .Append(dvProfile.Value.ToString("D2", CultureInfo.InvariantCulture)) - .Append('.') - .Append(dvLevel.Value.ToString("D2", CultureInfo.InvariantCulture)) - .Append('/') - .Append(dvRangeString) - .Append('"'); + return; + + void AppendDvString() + { + var dvProfile = state.VideoStream.DvProfile; + var dvLevel = state.VideoStream.DvLevel; + var dvRangeString = state.VideoStream.VideoRangeType switch + { + VideoRangeType.DOVIWithHDR10 => "db1p", + VideoRangeType.DOVIWithHLG => "db4h", + VideoRangeType.DOVIWithHDR10Plus => "db1p", // The HDR10+ metadata would be removed if Dovi metadata is not removed + _ => string.Empty // Don't label Dovi with EL and SDR due to compatability issues, ignore invalid configurations + }; + + if (dvProfile is null || dvLevel is null || string.IsNullOrEmpty(dvRangeString)) + { + return; + } + + var dvFourCc = string.Equals(state.ActualOutputVideoCodec, "av1", StringComparison.OrdinalIgnoreCase) ? "dav1" : "dvh1"; + builder.Append(",SUPPLEMENTAL-CODECS=\"") + .Append(dvFourCc) + .Append('.') + .Append(dvProfile.Value.ToString("D2", CultureInfo.InvariantCulture)) + .Append('.') + .Append(dvLevel.Value.ToString("D2", CultureInfo.InvariantCulture)) + .Append('/') + .Append(dvRangeString) + .Append('"'); + } + + void AppendHdr10PlusString() + { + var videoCodecLevel = GetOutputVideoCodecLevel(state); + if (string.IsNullOrEmpty(state.ActualOutputVideoCodec) || videoCodecLevel is null) + { + return; + } + + var videoCodecString = GetPlaylistVideoCodecs(state, state.ActualOutputVideoCodec, videoCodecLevel.Value); + builder.Append(",SUPPLEMENTAL-CODECS=\"") + .Append(videoCodecString) + .Append('/') + .Append("cdm4") + .Append('"'); + } } /// @@ -526,9 +559,7 @@ public class DynamicHlsHelper return false; } - // Having problems in android - return false; - // return state.VideoRequest.VideoBitRate.HasValue; + return state.VideoRequest?.VideoBitRate.HasValue ?? false; } private void AddSubtitles(StreamState state, IEnumerable subtitles, StringBuilder builder, ClaimsPrincipal user) @@ -550,7 +581,7 @@ public class DynamicHlsHelper var url = string.Format( CultureInfo.InvariantCulture, - "{0}/Subtitles/{1}/subtitles.m3u8?SegmentLength={2}&api_key={3}", + "{0}/Subtitles/{1}/subtitles.m3u8?SegmentLength={2}&ApiKey={3}", state.Request.MediaSourceId, stream.Index.ToString(CultureInfo.InvariantCulture), 30.ToString(CultureInfo.InvariantCulture), @@ -587,7 +618,7 @@ public class DynamicHlsHelper var url = string.Format( CultureInfo.InvariantCulture, - "Trickplay/{0}/tiles.m3u8?MediaSourceId={1}&api_key={2}", + "Trickplay/{0}/tiles.m3u8?MediaSourceId={1}&ApiKey={2}", width.ToString(CultureInfo.InvariantCulture), state.Request.MediaSourceId, user.GetToken()); @@ -616,7 +647,7 @@ public class DynamicHlsHelper && state.VideoStream is not null && state.VideoStream.Level.HasValue) { - levelString = state.VideoStream.Level.Value.ToString(CultureInfo.InvariantCulture) ?? string.Empty; + levelString = state.VideoStream.Level.Value.ToString(CultureInfo.InvariantCulture); } else { diff --git a/Jellyfin.Api/Helpers/MediaInfoHelper.cs b/Jellyfin.Api/Helpers/MediaInfoHelper.cs index 4adda0b695..454d3f08e3 100644 --- a/Jellyfin.Api/Helpers/MediaInfoHelper.cs +++ b/Jellyfin.Api/Helpers/MediaInfoHelper.cs @@ -7,8 +7,10 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Extensions; -using Jellyfin.Data.Entities; +using Jellyfin.Data; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; @@ -127,6 +129,13 @@ public class MediaInfoHelper var mediaSourcesClone = JsonSerializer.Deserialize(JsonSerializer.SerializeToUtf8Bytes(mediaSources)); if (mediaSourcesClone is not null) { + // Carry over the default audio index source. + // This field is not intended to be exposed to API clients, but it is used internally by the server + for (int i = 0; i < mediaSourcesClone.Length && i < mediaSources.Length; i++) + { + mediaSourcesClone[i].DefaultAudioIndexSource = mediaSources[i].DefaultAudioIndexSource; + } + result.MediaSources = mediaSourcesClone; } @@ -288,9 +297,7 @@ public class MediaInfoHelper mediaSource.SupportsDirectPlay = false; mediaSource.SupportsDirectStream = false; - mediaSource.TranscodingUrl = streamInfo.ToUrl("-", claimsPrincipal.GetToken()).TrimStart('-'); - mediaSource.TranscodingUrl += "&allowVideoStreamCopy=false"; - mediaSource.TranscodingUrl += "&allowAudioStreamCopy=false"; + mediaSource.TranscodingUrl = streamInfo.ToUrl(null, claimsPrincipal.GetToken(), "&allowVideoStreamCopy=false&allowAudioStreamCopy=false"); mediaSource.TranscodingContainer = streamInfo.Container; mediaSource.TranscodingSubProtocol = streamInfo.SubProtocol; if (streamInfo.AlwaysBurnInSubtitleWhenTranscoding) @@ -303,7 +310,7 @@ public class MediaInfoHelper if (!mediaSource.SupportsDirectPlay && (mediaSource.SupportsTranscoding || mediaSource.SupportsDirectStream)) { streamInfo.PlayMethod = PlayMethod.Transcode; - mediaSource.TranscodingUrl = streamInfo.ToUrl("-", claimsPrincipal.GetToken()).TrimStart('-'); + mediaSource.TranscodingUrl = streamInfo.ToUrl(null, claimsPrincipal.GetToken(), null); if (!allowVideoStreamCopy) { diff --git a/Jellyfin.Api/Helpers/RequestHelpers.cs b/Jellyfin.Api/Helpers/RequestHelpers.cs index eb83a37ba4..e10e940f21 100644 --- a/Jellyfin.Api/Helpers/RequestHelpers.cs +++ b/Jellyfin.Api/Helpers/RequestHelpers.cs @@ -5,8 +5,9 @@ using System.Security.Claims; using System.Threading.Tasks; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; -using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Dto; diff --git a/Jellyfin.Api/Helpers/StreamingHelpers.cs b/Jellyfin.Api/Helpers/StreamingHelpers.cs index 3a5db2f3fb..2601fa3be8 100644 --- a/Jellyfin.Api/Helpers/StreamingHelpers.cs +++ b/Jellyfin.Api/Helpers/StreamingHelpers.cs @@ -132,7 +132,7 @@ public static class StreamingHelpers mediaSource = string.IsNullOrEmpty(streamingRequest.MediaSourceId) ? mediaSources[0] - : mediaSources.Find(i => string.Equals(i.Id, streamingRequest.MediaSourceId, StringComparison.Ordinal)); + : mediaSources.FirstOrDefault(i => string.Equals(i.Id, streamingRequest.MediaSourceId, StringComparison.Ordinal)); if (mediaSource is null && Guid.Parse(streamingRequest.MediaSourceId).Equals(streamingRequest.Id)) { @@ -210,7 +210,7 @@ public static class StreamingHelpers && state.VideoRequest.VideoBitRate.Value >= state.VideoStream.BitRate.Value) { // Don't downscale the resolution if the width/height/MaxWidth/MaxHeight is not requested, - // and the requested video bitrate is higher than source video bitrate. + // and the requested video bitrate is greater than source video bitrate. if (state.VideoStream.Width.HasValue || state.VideoStream.Height.HasValue) { state.VideoRequest.MaxWidth = state.VideoStream?.Width; @@ -235,6 +235,11 @@ public static class StreamingHelpers state.VideoRequest.MaxHeight = resolution.MaxHeight; } } + + if (state.AudioStream is not null && !EncodingHelper.IsCopyCodec(state.OutputAudioCodec) && string.Equals(state.AudioStream.Codec, state.OutputAudioCodec, StringComparison.OrdinalIgnoreCase) && state.OutputAudioBitrate.HasValue) + { + state.OutputAudioCodec = state.SupportedAudioCodecs.Where(c => !EncodingHelper.LosslessAudioCodecs.Contains(c)).FirstOrDefault(mediaEncoder.CanEncodeToAudioCodec); + } } var ext = string.IsNullOrWhiteSpace(state.OutputContainer) diff --git a/Jellyfin.Api/Jellyfin.Api.csproj b/Jellyfin.Api/Jellyfin.Api.csproj index 5f86a6b6be..25feaa2d75 100644 --- a/Jellyfin.Api/Jellyfin.Api.csproj +++ b/Jellyfin.Api/Jellyfin.Api.csproj @@ -6,7 +6,7 @@ - net8.0 + net9.0 true diff --git a/Jellyfin.Api/ModelBinders/CommaDelimitedArrayModelBinder.cs b/Jellyfin.Api/ModelBinders/CommaDelimitedCollectionModelBinder.cs similarity index 88% rename from Jellyfin.Api/ModelBinders/CommaDelimitedArrayModelBinder.cs rename to Jellyfin.Api/ModelBinders/CommaDelimitedCollectionModelBinder.cs index 3e3604b2ad..25b84cbcc5 100644 --- a/Jellyfin.Api/ModelBinders/CommaDelimitedArrayModelBinder.cs +++ b/Jellyfin.Api/ModelBinders/CommaDelimitedCollectionModelBinder.cs @@ -8,18 +8,18 @@ using Microsoft.Extensions.Logging; namespace Jellyfin.Api.ModelBinders; /// -/// Comma delimited array model binder. +/// Comma delimited collection model binder. /// Returns an empty array of specified type if there is no query parameter. /// -public class CommaDelimitedArrayModelBinder : IModelBinder +public class CommaDelimitedCollectionModelBinder : IModelBinder { - private readonly ILogger _logger; + private readonly ILogger _logger; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// - /// Instance of the interface. - public CommaDelimitedArrayModelBinder(ILogger logger) + /// Instance of the interface. + public CommaDelimitedCollectionModelBinder(ILogger logger) { _logger = logger; } diff --git a/Jellyfin.Api/ModelBinders/PipeDelimitedArrayModelBinder.cs b/Jellyfin.Api/ModelBinders/PipeDelimitedCollectionModelBinder.cs similarity index 85% rename from Jellyfin.Api/ModelBinders/PipeDelimitedArrayModelBinder.cs rename to Jellyfin.Api/ModelBinders/PipeDelimitedCollectionModelBinder.cs index ae9f0a8cdb..7d0fb2e191 100644 --- a/Jellyfin.Api/ModelBinders/PipeDelimitedArrayModelBinder.cs +++ b/Jellyfin.Api/ModelBinders/PipeDelimitedCollectionModelBinder.cs @@ -8,18 +8,18 @@ using Microsoft.Extensions.Logging; namespace Jellyfin.Api.ModelBinders; /// -/// Comma delimited array model binder. -/// Returns an empty array of specified type if there is no query parameter. +/// Comma delimited collection model binder. +/// Returns an empty collection of specified type if there is no query parameter. /// -public class PipeDelimitedArrayModelBinder : IModelBinder +public class PipeDelimitedCollectionModelBinder : IModelBinder { - private readonly ILogger _logger; + private readonly ILogger _logger; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// - /// Instance of the interface. - public PipeDelimitedArrayModelBinder(ILogger logger) + /// Instance of the interface. + public PipeDelimitedCollectionModelBinder(ILogger logger) { _logger = logger; } diff --git a/Jellyfin.Api/Models/LibraryDtos/LibraryOptionsResultDto.cs b/Jellyfin.Api/Models/LibraryDtos/LibraryOptionsResultDto.cs index d07349bdf6..c492436689 100644 --- a/Jellyfin.Api/Models/LibraryDtos/LibraryOptionsResultDto.cs +++ b/Jellyfin.Api/Models/LibraryDtos/LibraryOptionsResultDto.cs @@ -28,6 +28,11 @@ public class LibraryOptionsResultDto /// public IReadOnlyList LyricFetchers { get; set; } = Array.Empty(); + /// + /// Gets or sets the list of MediaSegment Providers. + /// + public IReadOnlyList MediaSegmentProviders { get; set; } = Array.Empty(); + /// /// Gets or sets the type options. /// diff --git a/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs b/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs index 190d90681f..2616694d83 100644 --- a/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs +++ b/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.ComponentModel; using System.Text.Json.Serialization; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions.Json.Converters; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; @@ -17,7 +18,7 @@ public class GetProgramsDto /// /// Gets or sets the channels to return guide information for. /// - [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + [JsonConverter(typeof(JsonCommaDelimitedCollectionConverterFactory))] public IReadOnlyList? ChannelIds { get; set; } /// @@ -93,25 +94,25 @@ public class GetProgramsDto /// /// Gets or sets specify one or more sort orders, comma delimited. Options: Name, StartDate. /// - [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + [JsonConverter(typeof(JsonCommaDelimitedCollectionConverterFactory))] public IReadOnlyList? SortBy { get; set; } /// /// Gets or sets sort order. /// - [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + [JsonConverter(typeof(JsonCommaDelimitedCollectionConverterFactory))] public IReadOnlyList? SortOrder { get; set; } /// /// Gets or sets the genres to return guide information for. /// - [JsonConverter(typeof(JsonPipeDelimitedArrayConverterFactory))] + [JsonConverter(typeof(JsonPipeDelimitedCollectionConverterFactory))] public IReadOnlyList? Genres { get; set; } /// /// Gets or sets the genre ids to return guide information for. /// - [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + [JsonConverter(typeof(JsonCommaDelimitedCollectionConverterFactory))] public IReadOnlyList? GenreIds { get; set; } /// @@ -133,7 +134,7 @@ public class GetProgramsDto /// /// Gets or sets the image types to include in the output. /// - [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + [JsonConverter(typeof(JsonCommaDelimitedCollectionConverterFactory))] public IReadOnlyList? EnableImageTypes { get; set; } /// @@ -154,6 +155,6 @@ public class GetProgramsDto /// /// Gets or sets specify additional fields of information to return in the output. /// - [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + [JsonConverter(typeof(JsonCommaDelimitedCollectionConverterFactory))] public IReadOnlyList? Fields { get; set; } } diff --git a/Jellyfin.Api/Models/MediaInfoDtos/OpenLiveStreamDto.cs b/Jellyfin.Api/Models/MediaInfoDtos/OpenLiveStreamDto.cs index 978e99b35c..758c89938e 100644 --- a/Jellyfin.Api/Models/MediaInfoDtos/OpenLiveStreamDto.cs +++ b/Jellyfin.Api/Models/MediaInfoDtos/OpenLiveStreamDto.cs @@ -61,7 +61,7 @@ public class OpenLiveStreamDto public bool? EnableDirectPlay { get; set; } /// - /// Gets or sets a value indicating whether to enale direct stream. + /// Gets or sets a value indicating whether to enable direct stream. /// public bool? EnableDirectStream { get; set; } diff --git a/Jellyfin.Api/Models/MediaInfoDtos/PlaybackInfoDto.cs b/Jellyfin.Api/Models/MediaInfoDtos/PlaybackInfoDto.cs index 82f603ca1e..73ab76817c 100644 --- a/Jellyfin.Api/Models/MediaInfoDtos/PlaybackInfoDto.cs +++ b/Jellyfin.Api/Models/MediaInfoDtos/PlaybackInfoDto.cs @@ -4,7 +4,7 @@ using MediaBrowser.Model.Dlna; namespace Jellyfin.Api.Models.MediaInfoDtos; /// -/// Plabyback info dto. +/// Playback info dto. /// public class PlaybackInfoDto { diff --git a/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs b/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs index 61a3f2ed60..891d758c48 100644 --- a/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs +++ b/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs @@ -20,7 +20,7 @@ public class CreatePlaylistDto /// /// Gets or sets item ids to add to the playlist. /// - [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + [JsonConverter(typeof(JsonCommaDelimitedCollectionConverterFactory))] public IReadOnlyList Ids { get; set; } = []; /// diff --git a/Jellyfin.Api/Models/PlaylistDtos/UpdatePlaylistDto.cs b/Jellyfin.Api/Models/PlaylistDtos/UpdatePlaylistDto.cs index 80e20995c6..339a0d5d28 100644 --- a/Jellyfin.Api/Models/PlaylistDtos/UpdatePlaylistDto.cs +++ b/Jellyfin.Api/Models/PlaylistDtos/UpdatePlaylistDto.cs @@ -19,7 +19,7 @@ public class UpdatePlaylistDto /// /// Gets or sets item ids of the playlist. /// - [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + [JsonConverter(typeof(JsonCommaDelimitedCollectionConverterFactory))] public IReadOnlyList? Ids { get; set; } /// diff --git a/Jellyfin.Api/Models/StartupDtos/StartupConfigurationDto.cs b/Jellyfin.Api/Models/StartupDtos/StartupConfigurationDto.cs index 4027078190..1ba23339d0 100644 --- a/Jellyfin.Api/Models/StartupDtos/StartupConfigurationDto.cs +++ b/Jellyfin.Api/Models/StartupDtos/StartupConfigurationDto.cs @@ -5,6 +5,11 @@ namespace Jellyfin.Api.Models.StartupDtos; /// public class StartupConfigurationDto { + /// + /// Gets or sets the server name. + /// + public string? ServerName { get; set; } + /// /// Gets or sets UI language culture. /// diff --git a/Jellyfin.Api/Models/StartupDtos/StartupRemoteAccessDto.cs b/Jellyfin.Api/Models/StartupDtos/StartupRemoteAccessDto.cs index 1ae2cad4b6..9c29e372cf 100644 --- a/Jellyfin.Api/Models/StartupDtos/StartupRemoteAccessDto.cs +++ b/Jellyfin.Api/Models/StartupDtos/StartupRemoteAccessDto.cs @@ -1,3 +1,4 @@ +using System; using System.ComponentModel.DataAnnotations; namespace Jellyfin.Api.Models.StartupDtos; @@ -17,5 +18,6 @@ public class StartupRemoteAccessDto /// Gets or sets a value indicating whether enable automatic port mapping. /// [Required] + [Obsolete("No longer supported")] public bool EnableAutomaticPortMapping { get; set; } } diff --git a/Jellyfin.Api/Models/SystemInfoDtos/FolderStorageDto.cs b/Jellyfin.Api/Models/SystemInfoDtos/FolderStorageDto.cs new file mode 100644 index 0000000000..00a965898c --- /dev/null +++ b/Jellyfin.Api/Models/SystemInfoDtos/FolderStorageDto.cs @@ -0,0 +1,46 @@ +using MediaBrowser.Model.System; + +namespace Jellyfin.Api.Models.SystemInfoDtos; + +/// +/// Contains information about a specific folder. +/// +public record FolderStorageDto +{ + /// + /// Gets the path of the folder in question. + /// + public required string Path { get; init; } + + /// + /// Gets the free space of the underlying storage device of the . + /// + public long FreeSpace { get; init; } + + /// + /// Gets the used space of the underlying storage device of the . + /// + public long UsedSpace { get; init; } + + /// + /// Gets the kind of storage device of the . + /// + public string? StorageType { get; init; } + + /// + /// Gets the Device Identifier. + /// + public string? DeviceId { get; init; } + + internal static FolderStorageDto FromFolderStorageInfo(FolderStorageInfo model) + { + return new() + { + Path = model.Path, + FreeSpace = model.FreeSpace, + UsedSpace = model.UsedSpace, + StorageType = model.StorageType, + DeviceId = model.DeviceId + }; + } +} diff --git a/Jellyfin.Api/Models/SystemInfoDtos/LibraryStorageDto.cs b/Jellyfin.Api/Models/SystemInfoDtos/LibraryStorageDto.cs new file mode 100644 index 0000000000..c138324d2e --- /dev/null +++ b/Jellyfin.Api/Models/SystemInfoDtos/LibraryStorageDto.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MediaBrowser.Model.System; + +namespace Jellyfin.Api.Models.SystemInfoDtos; + +/// +/// Contains informations about a libraries storage informations. +/// +public record LibraryStorageDto +{ + /// + /// Gets or sets the Library Id. + /// + public required Guid Id { get; set; } + + /// + /// Gets or sets the name of the library. + /// + public required string Name { get; set; } + + /// + /// Gets or sets the storage informations about the folders used in a library. + /// + public required IReadOnlyCollection Folders { get; set; } + + internal static LibraryStorageDto FromLibraryStorageModel(LibraryStorageInfo model) + { + return new() + { + Id = model.Id, + Name = model.Name, + Folders = model.Folders.Select(FolderStorageDto.FromFolderStorageInfo).ToArray() + }; + } +} diff --git a/Jellyfin.Api/Models/SystemInfoDtos/SystemStorageDto.cs b/Jellyfin.Api/Models/SystemInfoDtos/SystemStorageDto.cs new file mode 100644 index 0000000000..a09042439a --- /dev/null +++ b/Jellyfin.Api/Models/SystemInfoDtos/SystemStorageDto.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MediaBrowser.Model.System; + +namespace Jellyfin.Api.Models.SystemInfoDtos; + +/// +/// Contains informations about the systems storage. +/// +public record SystemStorageDto +{ + /// + /// Gets or sets the Storage information of the program data folder. + /// + public required FolderStorageDto ProgramDataFolder { get; set; } + + /// + /// Gets or sets the Storage information of the web UI resources folder. + /// + public required FolderStorageDto WebFolder { get; set; } + + /// + /// Gets or sets the Storage information of the folder where images are cached. + /// + public required FolderStorageDto ImageCacheFolder { get; set; } + + /// + /// Gets or sets the Storage information of the cache folder. + /// + public required FolderStorageDto CacheFolder { get; set; } + + /// + /// Gets or sets the Storage information of the folder where logfiles are saved to. + /// + public required FolderStorageDto LogFolder { get; set; } + + /// + /// Gets or sets the Storage information of the folder where metadata is stored. + /// + public required FolderStorageDto InternalMetadataFolder { get; set; } + + /// + /// Gets or sets the Storage information of the transcoding cache. + /// + public required FolderStorageDto TranscodingTempFolder { get; set; } + + /// + /// Gets or sets the storage informations of all libraries. + /// + public required IReadOnlyCollection Libraries { get; set; } + + internal static SystemStorageDto FromSystemStorageInfo(SystemStorageInfo model) + { + return new SystemStorageDto() + { + ProgramDataFolder = FolderStorageDto.FromFolderStorageInfo(model.ProgramDataFolder), + WebFolder = FolderStorageDto.FromFolderStorageInfo(model.WebFolder), + ImageCacheFolder = FolderStorageDto.FromFolderStorageInfo(model.ImageCacheFolder), + CacheFolder = FolderStorageDto.FromFolderStorageInfo(model.CacheFolder), + LogFolder = FolderStorageDto.FromFolderStorageInfo(model.LogFolder), + InternalMetadataFolder = FolderStorageDto.FromFolderStorageInfo(model.InternalMetadataFolder), + TranscodingTempFolder = FolderStorageDto.FromFolderStorageInfo(model.TranscodingTempFolder), + Libraries = model.Libraries.Select(LibraryStorageDto.FromLibraryStorageModel).ToArray() + }; + } +} diff --git a/Jellyfin.Api/WebSocketListeners/ActivityLogWebSocketListener.cs b/Jellyfin.Api/WebSocketListeners/ActivityLogWebSocketListener.cs index 99516e9384..60379f4152 100644 --- a/Jellyfin.Api/WebSocketListeners/ActivityLogWebSocketListener.cs +++ b/Jellyfin.Api/WebSocketListeners/ActivityLogWebSocketListener.cs @@ -1,7 +1,8 @@ using System; using System.Threading.Tasks; -using Jellyfin.Data.Enums; +using Jellyfin.Data; using Jellyfin.Data.Events; +using Jellyfin.Database.Implementations.Enums; using MediaBrowser.Controller.Authentication; using MediaBrowser.Controller.Net; using MediaBrowser.Model.Activity; @@ -70,7 +71,9 @@ public class ActivityLogWebSocketListener : BasePeriodicWebSocketListenerThe message. protected override void Start(WebSocketMessageInfo message) { - if (!message.Connection.AuthorizationInfo.User.HasPermission(PermissionKind.IsAdministrator)) + if (!message.Connection.AuthorizationInfo.IsApiKey + && (message.Connection.AuthorizationInfo.User is null + || !message.Connection.AuthorizationInfo.User.HasPermission(PermissionKind.IsAdministrator))) { throw new AuthenticationException("Only admin users can retrieve the activity log."); } diff --git a/Jellyfin.Api/WebSocketListeners/SessionInfoWebSocketListener.cs b/Jellyfin.Api/WebSocketListeners/SessionInfoWebSocketListener.cs index a6cfe4d56c..9d149cc85a 100644 --- a/Jellyfin.Api/WebSocketListeners/SessionInfoWebSocketListener.cs +++ b/Jellyfin.Api/WebSocketListeners/SessionInfoWebSocketListener.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Threading.Tasks; -using Jellyfin.Data.Enums; +using Jellyfin.Data; +using Jellyfin.Database.Implementations.Enums; using MediaBrowser.Controller.Authentication; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Net; @@ -79,7 +80,9 @@ public class SessionInfoWebSocketListener : BasePeriodicWebSocketListenerThe message. protected override void Start(WebSocketMessageInfo message) { - if (!message.Connection.AuthorizationInfo.User.HasPermission(PermissionKind.IsAdministrator)) + if (!message.Connection.AuthorizationInfo.IsApiKey + && (message.Connection.AuthorizationInfo.User is null + || !message.Connection.AuthorizationInfo.User.HasPermission(PermissionKind.IsAdministrator))) { throw new AuthenticationException("Only admin users can subscribe to session information."); } diff --git a/Jellyfin.Data/DayOfWeekHelper.cs b/Jellyfin.Data/DayOfWeekHelper.cs index 82abfb8313..836860e0ea 100644 --- a/Jellyfin.Data/DayOfWeekHelper.cs +++ b/Jellyfin.Data/DayOfWeekHelper.cs @@ -1,7 +1,7 @@ #pragma warning disable CS1591 using System; -using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; namespace Jellyfin.Data { diff --git a/Jellyfin.Data/Enums/ArtKind.cs b/Jellyfin.Data/Enums/ArtKind.cs deleted file mode 100644 index f7a73848c8..0000000000 --- a/Jellyfin.Data/Enums/ArtKind.cs +++ /dev/null @@ -1,33 +0,0 @@ -namespace Jellyfin.Data.Enums -{ - /// - /// An enum representing types of art. - /// - public enum ArtKind - { - /// - /// Another type of art, not covered by the other members. - /// - Other = 0, - - /// - /// A poster. - /// - Poster = 1, - - /// - /// A banner. - /// - Banner = 2, - - /// - /// A thumbnail. - /// - Thumbnail = 3, - - /// - /// A logo. - /// - Logo = 4 - } -} diff --git a/Jellyfin.Data/Enums/ChromecastVersion.cs b/Jellyfin.Data/Enums/ChromecastVersion.cs deleted file mode 100644 index c9c8a4a625..0000000000 --- a/Jellyfin.Data/Enums/ChromecastVersion.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace Jellyfin.Data.Enums -{ - /// - /// An enum representing the version of Chromecast to be used by clients. - /// - public enum ChromecastVersion - { - /// - /// Stable Chromecast version. - /// - Stable = 0, - - /// - /// Unstable Chromecast version. - /// - Unstable = 1 - } -} diff --git a/Jellyfin.Data/Enums/DynamicDayOfWeek.cs b/Jellyfin.Data/Enums/DynamicDayOfWeek.cs deleted file mode 100644 index d3d8dd8227..0000000000 --- a/Jellyfin.Data/Enums/DynamicDayOfWeek.cs +++ /dev/null @@ -1,58 +0,0 @@ -namespace Jellyfin.Data.Enums -{ - /// - /// An enum that represents a day of the week, weekdays, weekends, or all days. - /// - public enum DynamicDayOfWeek - { - /// - /// Sunday. - /// - Sunday = 0, - - /// - /// Monday. - /// - Monday = 1, - - /// - /// Tuesday. - /// - Tuesday = 2, - - /// - /// Wednesday. - /// - Wednesday = 3, - - /// - /// Thursday. - /// - Thursday = 4, - - /// - /// Friday. - /// - Friday = 5, - - /// - /// Saturday. - /// - Saturday = 6, - - /// - /// All days of the week. - /// - Everyday = 7, - - /// - /// A week day, or Monday-Friday. - /// - Weekday = 8, - - /// - /// Saturday and Sunday. - /// - Weekend = 9 - } -} diff --git a/Jellyfin.Data/Enums/HomeSectionType.cs b/Jellyfin.Data/Enums/HomeSectionType.cs deleted file mode 100644 index 62da8c3fff..0000000000 --- a/Jellyfin.Data/Enums/HomeSectionType.cs +++ /dev/null @@ -1,58 +0,0 @@ -namespace Jellyfin.Data.Enums -{ - /// - /// An enum representing the different options for the home screen sections. - /// - public enum HomeSectionType - { - /// - /// None. - /// - None = 0, - - /// - /// My Media. - /// - SmallLibraryTiles = 1, - - /// - /// My Media Small. - /// - LibraryButtons = 2, - - /// - /// Active Recordings. - /// - ActiveRecordings = 3, - - /// - /// Continue Watching. - /// - Resume = 4, - - /// - /// Continue Listening. - /// - ResumeAudio = 5, - - /// - /// Latest Media. - /// - LatestMedia = 6, - - /// - /// Next Up. - /// - NextUp = 7, - - /// - /// Live TV. - /// - LiveTv = 8, - - /// - /// Continue Reading. - /// - ResumeBook = 9 - } -} diff --git a/Jellyfin.Data/Enums/IndexingKind.cs b/Jellyfin.Data/Enums/IndexingKind.cs deleted file mode 100644 index 3967712b03..0000000000 --- a/Jellyfin.Data/Enums/IndexingKind.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace Jellyfin.Data.Enums -{ - /// - /// An enum representing a type of indexing in a user's display preferences. - /// - public enum IndexingKind - { - /// - /// Index by the premiere date. - /// - PremiereDate = 0, - - /// - /// Index by the production year. - /// - ProductionYear = 1, - - /// - /// Index by the community rating. - /// - CommunityRating = 2 - } -} diff --git a/Jellyfin.Data/Enums/ItemSortBy.cs b/Jellyfin.Data/Enums/ItemSortBy.cs index 17bf1166de..ef76502947 100644 --- a/Jellyfin.Data/Enums/ItemSortBy.cs +++ b/Jellyfin.Data/Enums/ItemSortBy.cs @@ -154,14 +154,4 @@ public enum ItemSortBy /// The index number. /// IndexNumber = 29, - - /// - /// The similarity score. - /// - SimilarityScore = 30, - - /// - /// The search score. - /// - SearchScore = 31, } diff --git a/Jellyfin.Data/Enums/MediaFileKind.cs b/Jellyfin.Data/Enums/MediaFileKind.cs deleted file mode 100644 index 797c26ec27..0000000000 --- a/Jellyfin.Data/Enums/MediaFileKind.cs +++ /dev/null @@ -1,33 +0,0 @@ -namespace Jellyfin.Data.Enums -{ - /// - /// An enum representing the type of media file. - /// - public enum MediaFileKind - { - /// - /// The main file. - /// - Main = 0, - - /// - /// A sidecar file. - /// - Sidecar = 1, - - /// - /// An additional part to the main file. - /// - AdditionalPart = 2, - - /// - /// An alternative format to the main file. - /// - AlternativeFormat = 3, - - /// - /// An additional stream for the main file. - /// - AdditionalStream = 4 - } -} diff --git a/Jellyfin.Data/Enums/PermissionKind.cs b/Jellyfin.Data/Enums/PermissionKind.cs deleted file mode 100644 index c3d6705c24..0000000000 --- a/Jellyfin.Data/Enums/PermissionKind.cs +++ /dev/null @@ -1,128 +0,0 @@ -namespace Jellyfin.Data.Enums -{ - /// - /// The types of user permissions. - /// - public enum PermissionKind - { - /// - /// Whether the user is an administrator. - /// - IsAdministrator = 0, - - /// - /// Whether the user is hidden. - /// - IsHidden = 1, - - /// - /// Whether the user is disabled. - /// - IsDisabled = 2, - - /// - /// Whether the user can control shared devices. - /// - EnableSharedDeviceControl = 3, - - /// - /// Whether the user can access the server remotely. - /// - EnableRemoteAccess = 4, - - /// - /// Whether the user can manage live tv. - /// - EnableLiveTvManagement = 5, - - /// - /// Whether the user can access live tv. - /// - EnableLiveTvAccess = 6, - - /// - /// Whether the user can play media. - /// - EnableMediaPlayback = 7, - - /// - /// Whether the server should transcode audio for the user if requested. - /// - EnableAudioPlaybackTranscoding = 8, - - /// - /// Whether the server should transcode video for the user if requested. - /// - EnableVideoPlaybackTranscoding = 9, - - /// - /// Whether the user can delete content. - /// - EnableContentDeletion = 10, - - /// - /// Whether the user can download content. - /// - EnableContentDownloading = 11, - - /// - /// Whether to enable sync transcoding for the user. - /// - EnableSyncTranscoding = 12, - - /// - /// Whether the user can do media conversion. - /// - EnableMediaConversion = 13, - - /// - /// Whether the user has access to all devices. - /// - EnableAllDevices = 14, - - /// - /// Whether the user has access to all channels. - /// - EnableAllChannels = 15, - - /// - /// Whether the user has access to all folders. - /// - EnableAllFolders = 16, - - /// - /// Whether to enable public sharing for the user. - /// - EnablePublicSharing = 17, - - /// - /// Whether the user can remotely control other users. - /// - EnableRemoteControlOfOtherUsers = 18, - - /// - /// Whether the user is permitted to do playback remuxing. - /// - EnablePlaybackRemuxing = 19, - - /// - /// Whether the server should force transcoding on remote connections for the user. - /// - ForceRemoteSourceTranscoding = 20, - - /// - /// Whether the user can create, modify and delete collections. - /// - EnableCollectionManagement = 21, - - /// - /// Whether the user can edit subtitles. - /// - EnableSubtitleManagement = 22, - - /// - /// Whether the user can edit lyrics. - /// - EnableLyricManagement = 23, - } -} diff --git a/Jellyfin.Data/Enums/PersonRoleType.cs b/Jellyfin.Data/Enums/PersonRoleType.cs deleted file mode 100644 index 1e619f5eef..0000000000 --- a/Jellyfin.Data/Enums/PersonRoleType.cs +++ /dev/null @@ -1,68 +0,0 @@ -namespace Jellyfin.Data.Enums -{ - /// - /// An enum representing a person's role in a specific media item. - /// - public enum PersonRoleType - { - /// - /// Another role, not covered by the other types. - /// - Other = 0, - - /// - /// The director of the media. - /// - Director = 1, - - /// - /// An artist. - /// - Artist = 2, - - /// - /// The original artist. - /// - OriginalArtist = 3, - - /// - /// An actor. - /// - Actor = 4, - - /// - /// A voice actor. - /// - VoiceActor = 5, - - /// - /// A producer. - /// - Producer = 6, - - /// - /// A remixer. - /// - Remixer = 7, - - /// - /// A conductor. - /// - Conductor = 8, - - /// - /// A composer. - /// - Composer = 9, - - /// - /// An author. - /// - Author = 10, - - /// - /// An editor. - /// - Editor = 11 - } -} diff --git a/Jellyfin.Data/Enums/PreferenceKind.cs b/Jellyfin.Data/Enums/PreferenceKind.cs deleted file mode 100644 index d2b412e459..0000000000 --- a/Jellyfin.Data/Enums/PreferenceKind.cs +++ /dev/null @@ -1,73 +0,0 @@ -namespace Jellyfin.Data.Enums -{ - /// - /// The types of user preferences. - /// - public enum PreferenceKind - { - /// - /// A list of blocked tags. - /// - BlockedTags = 0, - - /// - /// A list of blocked channels. - /// - BlockedChannels = 1, - - /// - /// A list of blocked media folders. - /// - BlockedMediaFolders = 2, - - /// - /// A list of enabled devices. - /// - EnabledDevices = 3, - - /// - /// A list of enabled channels. - /// - EnabledChannels = 4, - - /// - /// A list of enabled folders. - /// - EnabledFolders = 5, - - /// - /// A list of folders to allow content deletion from. - /// - EnableContentDeletionFromFolders = 6, - - /// - /// A list of latest items to exclude. - /// - LatestItemExcludes = 7, - - /// - /// A list of media to exclude. - /// - MyMediaExcludes = 8, - - /// - /// A list of grouped folders. - /// - GroupedFolders = 9, - - /// - /// A list of unrated items to block. - /// - BlockUnratedItems = 10, - - /// - /// A list of ordered views. - /// - OrderedViews = 11, - - /// - /// A list of allowed tags. - /// - AllowedTags = 12 - } -} diff --git a/Jellyfin.Data/Enums/ScrollDirection.cs b/Jellyfin.Data/Enums/ScrollDirection.cs deleted file mode 100644 index 29c50e2c4e..0000000000 --- a/Jellyfin.Data/Enums/ScrollDirection.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace Jellyfin.Data.Enums -{ - /// - /// An enum representing the axis that should be scrolled. - /// - public enum ScrollDirection - { - /// - /// Horizontal scrolling direction. - /// - Horizontal = 0, - - /// - /// Vertical scrolling direction. - /// - Vertical = 1 - } -} diff --git a/Jellyfin.Data/Enums/SortOrder.cs b/Jellyfin.Data/Enums/SortOrder.cs deleted file mode 100644 index 4151448e4e..0000000000 --- a/Jellyfin.Data/Enums/SortOrder.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace Jellyfin.Data.Enums -{ - /// - /// An enum representing the sorting order. - /// - public enum SortOrder - { - /// - /// Sort in increasing order. - /// - Ascending = 0, - - /// - /// Sort in decreasing order. - /// - Descending = 1 - } -} diff --git a/Jellyfin.Data/Enums/SubtitlePlaybackMode.cs b/Jellyfin.Data/Enums/SubtitlePlaybackMode.cs deleted file mode 100644 index 79693d321a..0000000000 --- a/Jellyfin.Data/Enums/SubtitlePlaybackMode.cs +++ /dev/null @@ -1,33 +0,0 @@ -namespace Jellyfin.Data.Enums -{ - /// - /// An enum representing a subtitle playback mode. - /// - public enum SubtitlePlaybackMode - { - /// - /// The default subtitle playback mode. - /// - Default = 0, - - /// - /// Always show subtitles. - /// - Always = 1, - - /// - /// Only show forced subtitles. - /// - OnlyForced = 2, - - /// - /// Don't show subtitles. - /// - None = 3, - - /// - /// Only show subtitles when the current audio stream is in a different language. - /// - Smart = 4 - } -} diff --git a/Jellyfin.Data/Enums/SyncPlayUserAccessType.cs b/Jellyfin.Data/Enums/SyncPlayUserAccessType.cs deleted file mode 100644 index 030d16fb90..0000000000 --- a/Jellyfin.Data/Enums/SyncPlayUserAccessType.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace Jellyfin.Data.Enums -{ - /// - /// Enum SyncPlayUserAccessType. - /// - public enum SyncPlayUserAccessType - { - /// - /// User can create groups and join them. - /// - CreateAndJoinGroups = 0, - - /// - /// User can only join already existing groups. - /// - JoinGroups = 1, - - /// - /// SyncPlay is disabled for the user. - /// - None = 2 - } -} diff --git a/Jellyfin.Data/Enums/VideoRangeType.cs b/Jellyfin.Data/Enums/VideoRangeType.cs index 853c2c73db..ce232d73c3 100644 --- a/Jellyfin.Data/Enums/VideoRangeType.cs +++ b/Jellyfin.Data/Enums/VideoRangeType.cs @@ -45,6 +45,27 @@ public enum VideoRangeType /// DOVIWithSDR, + /// + /// Dolby Vision with Enhancment Layer (Profile 7). + /// + DOVIWithEL, + + /// + /// Dolby Vision and HDR10+ Metadata coexists. + /// + DOVIWithHDR10Plus, + + /// + /// Dolby Vision with Enhancment Layer (Profile 7) and HDR10+ Metadata coexists. + /// + DOVIWithELHDR10Plus, + + /// + /// Dolby Vision with invalid configuration. e.g. Profile 8 compat id 6. + /// When using this range, the server would assume the video is still HDR10 after removing the Dolby Vision metadata. + /// + DOVIInvalid, + /// /// HDR10+ video range type (10bit to 16bit). /// diff --git a/Jellyfin.Data/Enums/ViewType.cs b/Jellyfin.Data/Enums/ViewType.cs deleted file mode 100644 index c0fd7d448b..0000000000 --- a/Jellyfin.Data/Enums/ViewType.cs +++ /dev/null @@ -1,113 +0,0 @@ -namespace Jellyfin.Data.Enums -{ - /// - /// An enum representing the type of view for a library or collection. - /// - public enum ViewType - { - /// - /// Shows albums. - /// - Albums = 0, - - /// - /// Shows album artists. - /// - AlbumArtists = 1, - - /// - /// Shows artists. - /// - Artists = 2, - - /// - /// Shows channels. - /// - Channels = 3, - - /// - /// Shows collections. - /// - Collections = 4, - - /// - /// Shows episodes. - /// - Episodes = 5, - - /// - /// Shows favorites. - /// - Favorites = 6, - - /// - /// Shows genres. - /// - Genres = 7, - - /// - /// Shows guide. - /// - Guide = 8, - - /// - /// Shows movies. - /// - Movies = 9, - - /// - /// Shows networks. - /// - Networks = 10, - - /// - /// Shows playlists. - /// - Playlists = 11, - - /// - /// Shows programs. - /// - Programs = 12, - - /// - /// Shows recordings. - /// - Recordings = 13, - - /// - /// Shows schedule. - /// - Schedule = 14, - - /// - /// Shows series. - /// - Series = 15, - - /// - /// Shows shows. - /// - Shows = 16, - - /// - /// Shows songs. - /// - Songs = 17, - - /// - /// Shows songs. - /// - Suggestions = 18, - - /// - /// Shows trailers. - /// - Trailers = 19, - - /// - /// Shows upcoming. - /// - Upcoming = 20 - } -} diff --git a/Jellyfin.Data/Events/Users/UserCreatedEventArgs.cs b/Jellyfin.Data/Events/Users/UserCreatedEventArgs.cs index b3b8d28318..8de34fec2c 100644 --- a/Jellyfin.Data/Events/Users/UserCreatedEventArgs.cs +++ b/Jellyfin.Data/Events/Users/UserCreatedEventArgs.cs @@ -1,4 +1,4 @@ -using Jellyfin.Data.Entities; +using Jellyfin.Database.Implementations.Entities; namespace Jellyfin.Data.Events.Users { diff --git a/Jellyfin.Data/Events/Users/UserDeletedEventArgs.cs b/Jellyfin.Data/Events/Users/UserDeletedEventArgs.cs index d57c917c9f..c85de34ded 100644 --- a/Jellyfin.Data/Events/Users/UserDeletedEventArgs.cs +++ b/Jellyfin.Data/Events/Users/UserDeletedEventArgs.cs @@ -1,4 +1,4 @@ -using Jellyfin.Data.Entities; +using Jellyfin.Database.Implementations.Entities; namespace Jellyfin.Data.Events.Users { diff --git a/Jellyfin.Data/Events/Users/UserLockedOutEventArgs.cs b/Jellyfin.Data/Events/Users/UserLockedOutEventArgs.cs index 4475948219..46b399d26d 100644 --- a/Jellyfin.Data/Events/Users/UserLockedOutEventArgs.cs +++ b/Jellyfin.Data/Events/Users/UserLockedOutEventArgs.cs @@ -1,4 +1,4 @@ -using Jellyfin.Data.Entities; +using Jellyfin.Database.Implementations.Entities; namespace Jellyfin.Data.Events.Users { diff --git a/Jellyfin.Data/Events/Users/UserPasswordChangedEventArgs.cs b/Jellyfin.Data/Events/Users/UserPasswordChangedEventArgs.cs index a235ccada9..ee41147d5d 100644 --- a/Jellyfin.Data/Events/Users/UserPasswordChangedEventArgs.cs +++ b/Jellyfin.Data/Events/Users/UserPasswordChangedEventArgs.cs @@ -1,4 +1,4 @@ -using Jellyfin.Data.Entities; +using Jellyfin.Database.Implementations.Entities; namespace Jellyfin.Data.Events.Users { diff --git a/Jellyfin.Data/Events/Users/UserUpdatedEventArgs.cs b/Jellyfin.Data/Events/Users/UserUpdatedEventArgs.cs index 780ace6abe..0f2763f366 100644 --- a/Jellyfin.Data/Events/Users/UserUpdatedEventArgs.cs +++ b/Jellyfin.Data/Events/Users/UserUpdatedEventArgs.cs @@ -1,4 +1,4 @@ -using Jellyfin.Data.Entities; +using Jellyfin.Database.Implementations.Entities; namespace Jellyfin.Data.Events.Users { diff --git a/Jellyfin.Data/Interfaces/IHasConcurrencyToken.cs b/Jellyfin.Data/Interfaces/IHasConcurrencyToken.cs deleted file mode 100644 index 2c4091493e..0000000000 --- a/Jellyfin.Data/Interfaces/IHasConcurrencyToken.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace Jellyfin.Data.Interfaces -{ - /// - /// An interface abstracting an entity that has a concurrency token. - /// - public interface IHasConcurrencyToken - { - /// - /// Gets the version of this row. Acts as a concurrency token. - /// - uint RowVersion { get; } - - /// - /// Called when saving changes to this entity. - /// - void OnSavingChanges(); - } -} diff --git a/Jellyfin.Data/Interfaces/IHasPermissions.cs b/Jellyfin.Data/Interfaces/IHasPermissions.cs deleted file mode 100644 index bf8ec9d887..0000000000 --- a/Jellyfin.Data/Interfaces/IHasPermissions.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System.Collections.Generic; -using Jellyfin.Data.Entities; -using Jellyfin.Data.Enums; - -namespace Jellyfin.Data.Interfaces -{ - /// - /// An abstraction representing an entity that has permissions. - /// - public interface IHasPermissions - { - /// - /// Gets a collection containing this entity's permissions. - /// - ICollection Permissions { get; } - - /// - /// Checks whether this entity has the specified permission kind. - /// - /// The kind of permission. - /// true if this entity has the specified permission, false otherwise. - bool HasPermission(PermissionKind kind); - - /// - /// Sets the specified permission to the provided value. - /// - /// The kind of permission. - /// The value to set. - void SetPermission(PermissionKind kind, bool value); - } -} diff --git a/Jellyfin.Data/Interfaces/IHasReleases.cs b/Jellyfin.Data/Interfaces/IHasReleases.cs deleted file mode 100644 index 3b615893ed..0000000000 --- a/Jellyfin.Data/Interfaces/IHasReleases.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Collections.Generic; -using Jellyfin.Data.Entities.Libraries; - -namespace Jellyfin.Data.Interfaces -{ - /// - /// An abstraction representing an entity that has releases. - /// - public interface IHasReleases - { - /// - /// Gets a collection containing this entity's releases. - /// - ICollection Releases { get; } - } -} diff --git a/Jellyfin.Data/Jellyfin.Data.csproj b/Jellyfin.Data/Jellyfin.Data.csproj index e24e37740d..45374c22f7 100644 --- a/Jellyfin.Data/Jellyfin.Data.csproj +++ b/Jellyfin.Data/Jellyfin.Data.csproj @@ -1,7 +1,7 @@ - net8.0 + net9.0 false true true @@ -18,7 +18,7 @@ Jellyfin Contributors Jellyfin.Data - 10.10.0 + 10.11.0 https://github.com/jellyfin/jellyfin GPL-3.0-only @@ -38,6 +38,10 @@ + + + + diff --git a/Jellyfin.Data/UserEntityExtensions.cs b/Jellyfin.Data/UserEntityExtensions.cs new file mode 100644 index 0000000000..149fc9042d --- /dev/null +++ b/Jellyfin.Data/UserEntityExtensions.cs @@ -0,0 +1,220 @@ +using System; +using System.ComponentModel; +using System.Linq; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; +using Jellyfin.Database.Implementations.Interfaces; + +namespace Jellyfin.Data; + +/// +/// Contains extension methods for manipulation of entities. +/// +public static class UserEntityExtensions +{ + /// + /// The values being delimited here are Guids, so commas work as they do not appear in Guids. + /// + private const char Delimiter = ','; + + /// + /// Checks whether the user has the specified permission. + /// + /// The entity to update. + /// The permission kind. + /// True if the user has the specified permission. + public static bool HasPermission(this IHasPermissions entity, PermissionKind kind) + { + return entity.Permissions.FirstOrDefault(p => p.Kind == kind)?.Value ?? false; + } + + /// + /// Sets the given permission kind to the provided value. + /// + /// The entity to update. + /// The permission kind. + /// The value to set. + public static void SetPermission(this IHasPermissions entity, PermissionKind kind, bool value) + { + var currentPermission = entity.Permissions.FirstOrDefault(p => p.Kind == kind); + if (currentPermission is null) + { + entity.Permissions.Add(new Permission(kind, value)); + } + else + { + currentPermission.Value = value; + } + } + + /// + /// Gets the user's preferences for the given preference kind. + /// + /// The entity to update. + /// The preference kind. + /// A string array containing the user's preferences. + public static string[] GetPreference(this User entity, PreferenceKind preference) + { + var val = entity.Preferences.FirstOrDefault(p => p.Kind == preference)?.Value; + + return string.IsNullOrEmpty(val) ? Array.Empty() : val.Split(Delimiter); + } + + /// + /// Gets the user's preferences for the given preference kind. + /// + /// The entity to update. + /// The preference kind. + /// Type of preference. + /// A {T} array containing the user's preference. + public static T[] GetPreferenceValues(this User entity, PreferenceKind preference) + { + var val = entity.Preferences.FirstOrDefault(p => p.Kind == preference)?.Value; + if (string.IsNullOrEmpty(val)) + { + return Array.Empty(); + } + + // Convert array of {string} to array of {T} + var converter = TypeDescriptor.GetConverter(typeof(T)); + var stringValues = val.Split(Delimiter); + var convertedCount = 0; + var parsedValues = new T[stringValues.Length]; + for (var i = 0; i < stringValues.Length; i++) + { + try + { + var parsedValue = converter.ConvertFromString(stringValues[i].Trim()); + if (parsedValue is not null) + { + parsedValues[convertedCount++] = (T)parsedValue; + } + } + catch (FormatException) + { + // Unable to convert value + } + } + + return parsedValues[..convertedCount]; + } + + /// + /// Sets the specified preference to the given value. + /// + /// The entity to update. + /// The preference kind. + /// The values. + public static void SetPreference(this User entity, PreferenceKind preference, string[] values) + { + var value = string.Join(Delimiter, values); + var currentPreference = entity.Preferences.FirstOrDefault(p => p.Kind == preference); + if (currentPreference is null) + { + entity.Preferences.Add(new Preference(preference, value)); + } + else + { + currentPreference.Value = value; + } + } + + /// + /// Sets the specified preference to the given value. + /// + /// The entity to update. + /// The preference kind. + /// The values. + /// The type of value. + public static void SetPreference(this User entity, PreferenceKind preference, T[] values) + { + var value = string.Join(Delimiter, values); + var currentPreference = entity.Preferences.FirstOrDefault(p => p.Kind == preference); + if (currentPreference is null) + { + entity.Preferences.Add(new Preference(preference, value)); + } + else + { + currentPreference.Value = value; + } + } + + /// + /// Checks whether this user is currently allowed to use the server. + /// + /// The entity to update. + /// True if the current time is within an access schedule, or there are no access schedules. + public static bool IsParentalScheduleAllowed(this User entity) + { + return entity.AccessSchedules.Count == 0 + || entity.AccessSchedules.Any(i => IsParentalScheduleAllowed(i, DateTime.UtcNow)); + } + + /// + /// Checks whether the provided folder is in this user's grouped folders. + /// + /// The entity to update. + /// The Guid of the folder. + /// True if the folder is in the user's grouped folders. + public static bool IsFolderGrouped(this User entity, Guid id) + { + return Array.IndexOf(GetPreferenceValues(entity, PreferenceKind.GroupedFolders), id) != -1; + } + + /// + /// Initializes the default permissions for a user. Should only be called on user creation. + /// + /// The entity to update. + // TODO: make these user configurable? + public static void AddDefaultPermissions(this User entity) + { + entity.Permissions.Add(new Permission(PermissionKind.IsAdministrator, false)); + entity.Permissions.Add(new Permission(PermissionKind.IsDisabled, false)); + entity.Permissions.Add(new Permission(PermissionKind.IsHidden, true)); + entity.Permissions.Add(new Permission(PermissionKind.EnableAllChannels, true)); + entity.Permissions.Add(new Permission(PermissionKind.EnableAllDevices, true)); + entity.Permissions.Add(new Permission(PermissionKind.EnableAllFolders, true)); + entity.Permissions.Add(new Permission(PermissionKind.EnableContentDeletion, false)); + entity.Permissions.Add(new Permission(PermissionKind.EnableContentDownloading, true)); + entity.Permissions.Add(new Permission(PermissionKind.EnableMediaConversion, true)); + entity.Permissions.Add(new Permission(PermissionKind.EnableMediaPlayback, true)); + entity.Permissions.Add(new Permission(PermissionKind.EnablePlaybackRemuxing, true)); + entity.Permissions.Add(new Permission(PermissionKind.EnablePublicSharing, true)); + entity.Permissions.Add(new Permission(PermissionKind.EnableRemoteAccess, true)); + entity.Permissions.Add(new Permission(PermissionKind.EnableSyncTranscoding, true)); + entity.Permissions.Add(new Permission(PermissionKind.EnableAudioPlaybackTranscoding, true)); + entity.Permissions.Add(new Permission(PermissionKind.EnableLiveTvAccess, true)); + entity.Permissions.Add(new Permission(PermissionKind.EnableLiveTvManagement, true)); + entity.Permissions.Add(new Permission(PermissionKind.EnableSharedDeviceControl, true)); + entity.Permissions.Add(new Permission(PermissionKind.EnableVideoPlaybackTranscoding, true)); + entity.Permissions.Add(new Permission(PermissionKind.ForceRemoteSourceTranscoding, false)); + entity.Permissions.Add(new Permission(PermissionKind.EnableRemoteControlOfOtherUsers, false)); + entity.Permissions.Add(new Permission(PermissionKind.EnableCollectionManagement, false)); + entity.Permissions.Add(new Permission(PermissionKind.EnableSubtitleManagement, false)); + entity.Permissions.Add(new Permission(PermissionKind.EnableLyricManagement, false)); + } + + /// + /// Initializes the default preferences. Should only be called on user creation. + /// + /// The entity to update. + public static void AddDefaultPreferences(this User entity) + { + foreach (var val in Enum.GetValues()) + { + entity.Preferences.Add(new Preference(val, string.Empty)); + } + } + + private static bool IsParentalScheduleAllowed(AccessSchedule schedule, DateTime date) + { + var localTime = date.ToLocalTime(); + var hour = localTime.TimeOfDay.TotalHours; + var currentDayOfWeek = localTime.DayOfWeek; + + return schedule.DayOfWeek.Contains(currentDayOfWeek) + && hour >= schedule.StartHour + && hour <= schedule.EndHour; + } +} diff --git a/Jellyfin.Server.Implementations/Activity/ActivityManager.cs b/Jellyfin.Server.Implementations/Activity/ActivityManager.cs index 54272aeafa..8d492f7cd7 100644 --- a/Jellyfin.Server.Implementations/Activity/ActivityManager.cs +++ b/Jellyfin.Server.Implementations/Activity/ActivityManager.cs @@ -1,9 +1,10 @@ using System; using System.Linq; using System.Threading.Tasks; -using Jellyfin.Data.Entities; using Jellyfin.Data.Events; using Jellyfin.Data.Queries; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Model.Activity; using MediaBrowser.Model.Querying; using Microsoft.EntityFrameworkCore; diff --git a/Jellyfin.Server.Implementations/DbConfiguration/DatabaseConfigurationFactory.cs b/Jellyfin.Server.Implementations/DbConfiguration/DatabaseConfigurationFactory.cs new file mode 100644 index 0000000000..26d32f4173 --- /dev/null +++ b/Jellyfin.Server.Implementations/DbConfiguration/DatabaseConfigurationFactory.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; +using MediaBrowser.Common.Configuration; + +namespace Jellyfin.Server.Implementations.DatabaseConfiguration; + +/// +/// Factory for constructing a database configuration. +/// +public class DatabaseConfigurationFactory : IConfigurationFactory +{ + /// + public IEnumerable GetConfigurations() + { + yield return new DatabaseConfigurationStore(); + } +} diff --git a/Jellyfin.Server.Implementations/DbConfiguration/DatabaseConfigurationStore.cs b/Jellyfin.Server.Implementations/DbConfiguration/DatabaseConfigurationStore.cs new file mode 100644 index 0000000000..537630561c --- /dev/null +++ b/Jellyfin.Server.Implementations/DbConfiguration/DatabaseConfigurationStore.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using Jellyfin.Database.Implementations.DbConfiguration; +using MediaBrowser.Common.Configuration; + +namespace Jellyfin.Server.Implementations.DatabaseConfiguration; + +/// +/// A configuration that stores database related settings. +/// +public class DatabaseConfigurationStore : ConfigurationStore +{ + /// + /// The name of the configuration in the storage. + /// + public const string StoreKey = "database"; + + /// + /// Initializes a new instance of the class. + /// + public DatabaseConfigurationStore() + { + ConfigurationType = typeof(DatabaseConfigurationOptions); + Key = StoreKey; + } +} diff --git a/Jellyfin.Server.Implementations/Devices/DeviceManager.cs b/Jellyfin.Server.Implementations/Devices/DeviceManager.cs index d3bff2936c..51a1186452 100644 --- a/Jellyfin.Server.Implementations/Devices/DeviceManager.cs +++ b/Jellyfin.Server.Implementations/Devices/DeviceManager.cs @@ -3,12 +3,14 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Jellyfin.Data; using Jellyfin.Data.Dtos; -using Jellyfin.Data.Entities; -using Jellyfin.Data.Entities.Security; -using Jellyfin.Data.Enums; using Jellyfin.Data.Events; using Jellyfin.Data.Queries; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Entities.Security; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Devices; diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Library/LyricDownloadFailureLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Library/LyricDownloadFailureLogger.cs index 0d52bb9856..5f4864e953 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Library/LyricDownloadFailureLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Library/LyricDownloadFailureLogger.cs @@ -1,7 +1,7 @@ using System; using System.Globalization; using System.Threading.Tasks; -using Jellyfin.Data.Entities; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Events; diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Library/SubtitleDownloadFailureLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Library/SubtitleDownloadFailureLogger.cs index 0a8c064a99..8fe380e4f4 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Library/SubtitleDownloadFailureLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Library/SubtitleDownloadFailureLogger.cs @@ -1,7 +1,7 @@ using System; using System.Globalization; using System.Threading.Tasks; -using Jellyfin.Data.Entities; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Events; diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationFailedLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationFailedLogger.cs index a4424c7391..1a8931a6dc 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationFailedLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationFailedLogger.cs @@ -1,7 +1,7 @@ using System; using System.Globalization; using System.Threading.Tasks; -using Jellyfin.Data.Entities; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Events; using MediaBrowser.Controller.Events.Authentication; using MediaBrowser.Model.Activity; diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationSucceededLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationSucceededLogger.cs index e0ecef2a5d..584d559e44 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationSucceededLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationSucceededLogger.cs @@ -1,6 +1,6 @@ using System.Globalization; using System.Threading.Tasks; -using Jellyfin.Data.Entities; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Events; using MediaBrowser.Controller.Events.Authentication; using MediaBrowser.Model.Activity; diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStartLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStartLogger.cs index 0ef929a99b..73323acb37 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStartLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStartLogger.cs @@ -1,8 +1,8 @@ using System; using System.Globalization; using System.Threading.Tasks; -using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Events; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Activity; diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStopLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStopLogger.cs index 7d452ea2fd..b75567539c 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStopLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStopLogger.cs @@ -1,8 +1,8 @@ using System; using System.Globalization; using System.Threading.Tasks; -using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Events; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Activity; diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Session/SessionEndedLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Session/SessionEndedLogger.cs index 77e7859c6f..b90708a2f2 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Session/SessionEndedLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Session/SessionEndedLogger.cs @@ -1,6 +1,6 @@ using System.Globalization; using System.Threading.Tasks; -using Jellyfin.Data.Entities; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Events; using MediaBrowser.Controller.Events.Session; using MediaBrowser.Model.Activity; diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Session/SessionStartedLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Session/SessionStartedLogger.cs index 141dc20ea3..139c2e2acb 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Session/SessionStartedLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Session/SessionStartedLogger.cs @@ -1,6 +1,6 @@ using System.Globalization; using System.Threading.Tasks; -using Jellyfin.Data.Entities; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Events; using MediaBrowser.Controller.Events.Session; using MediaBrowser.Model.Activity; diff --git a/Jellyfin.Server.Implementations/Events/Consumers/System/TaskCompletedLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/System/TaskCompletedLogger.cs index b0a9393eb6..da82a3b30f 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/System/TaskCompletedLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/System/TaskCompletedLogger.cs @@ -3,7 +3,7 @@ using System.Collections.Generic; using System.Globalization; using System.Text; using System.Threading.Tasks; -using Jellyfin.Data.Entities; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Events; using MediaBrowser.Model.Activity; using MediaBrowser.Model.Globalization; diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstallationFailedLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstallationFailedLogger.cs index 0ae9b7f66f..632f30c7ad 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstallationFailedLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstallationFailedLogger.cs @@ -1,7 +1,7 @@ using System; using System.Globalization; using System.Threading.Tasks; -using Jellyfin.Data.Entities; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Common.Updates; using MediaBrowser.Controller.Events; using MediaBrowser.Model.Activity; diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstalledLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstalledLogger.cs index 287ba578ba..4b49b714cf 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstalledLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstalledLogger.cs @@ -1,7 +1,7 @@ using System; using System.Globalization; using System.Threading.Tasks; -using Jellyfin.Data.Entities; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Events; using MediaBrowser.Controller.Events.Updates; using MediaBrowser.Model.Activity; diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginUninstalledLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginUninstalledLogger.cs index 2de207b152..2d24de7fc6 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginUninstalledLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginUninstalledLogger.cs @@ -1,7 +1,7 @@ using System; using System.Globalization; using System.Threading.Tasks; -using Jellyfin.Data.Entities; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Events; using MediaBrowser.Controller.Events.Updates; using MediaBrowser.Model.Activity; diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginUpdatedLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginUpdatedLogger.cs index 08d6bf9c25..e892d3dd9a 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginUpdatedLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginUpdatedLogger.cs @@ -1,7 +1,7 @@ using System; using System.Globalization; using System.Threading.Tasks; -using Jellyfin.Data.Entities; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Events; using MediaBrowser.Controller.Events.Updates; using MediaBrowser.Model.Activity; diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Users/UserCreatedLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Users/UserCreatedLogger.cs index a09c344f61..4f063f6a1b 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Users/UserCreatedLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Users/UserCreatedLogger.cs @@ -1,7 +1,7 @@ using System.Globalization; using System.Threading.Tasks; -using Jellyfin.Data.Entities; using Jellyfin.Data.Events.Users; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Events; using MediaBrowser.Model.Activity; using MediaBrowser.Model.Globalization; diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Users/UserDeletedLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Users/UserDeletedLogger.cs index 46da8044a1..ba4a072e84 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Users/UserDeletedLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Users/UserDeletedLogger.cs @@ -1,8 +1,8 @@ using System; using System.Globalization; using System.Threading.Tasks; -using Jellyfin.Data.Entities; using Jellyfin.Data.Events.Users; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Events; using MediaBrowser.Model.Activity; using MediaBrowser.Model.Globalization; diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Users/UserLockedOutLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Users/UserLockedOutLogger.cs index 1d0d016a74..bbc00567d1 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Users/UserLockedOutLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Users/UserLockedOutLogger.cs @@ -1,7 +1,7 @@ using System.Globalization; using System.Threading.Tasks; -using Jellyfin.Data.Entities; using Jellyfin.Data.Events.Users; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Events; using MediaBrowser.Model.Activity; using MediaBrowser.Model.Globalization; diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Users/UserPasswordChangedLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Users/UserPasswordChangedLogger.cs index 2b8f966a80..7219704ec6 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Users/UserPasswordChangedLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Users/UserPasswordChangedLogger.cs @@ -1,7 +1,7 @@ using System.Globalization; using System.Threading.Tasks; -using Jellyfin.Data.Entities; using Jellyfin.Data.Events.Users; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Events; using MediaBrowser.Model.Activity; using MediaBrowser.Model.Globalization; diff --git a/Jellyfin.Server.Implementations/Extensions/ExpressionExtensions.cs b/Jellyfin.Server.Implementations/Extensions/ExpressionExtensions.cs new file mode 100644 index 0000000000..d70ac672f2 --- /dev/null +++ b/Jellyfin.Server.Implementations/Extensions/ExpressionExtensions.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; + +namespace Jellyfin.Server.Implementations.Extensions; + +/// +/// Provides extension methods. +/// +public static class ExpressionExtensions +{ + /// + /// Combines two predicates into a single predicate using a logical OR operation. + /// + /// The predicate parameter type. + /// The first predicate expression to combine. + /// The second predicate expression to combine. + /// A new expression representing the OR combination of the input predicates. + public static Expression> Or(this Expression> firstPredicate, Expression> secondPredicate) + { + ArgumentNullException.ThrowIfNull(firstPredicate); + ArgumentNullException.ThrowIfNull(secondPredicate); + + var invokedExpression = Expression.Invoke(secondPredicate, firstPredicate.Parameters); + return Expression.Lambda>(Expression.OrElse(firstPredicate.Body, invokedExpression), firstPredicate.Parameters); + } + + /// + /// Combines multiple predicates into a single predicate using a logical OR operation. + /// + /// The predicate parameter type. + /// A collection of predicate expressions to combine. + /// A new expression representing the OR combination of all input predicates. + public static Expression> Or(this IEnumerable>> predicates) + { + ArgumentNullException.ThrowIfNull(predicates); + + return predicates.Aggregate((aggregatePredicate, nextPredicate) => aggregatePredicate.Or(nextPredicate)); + } + + /// + /// Combines two predicates into a single predicate using a logical AND operation. + /// + /// The predicate parameter type. + /// The first predicate expression to combine. + /// The second predicate expression to combine. + /// A new expression representing the AND combination of the input predicates. + public static Expression> And(this Expression> firstPredicate, Expression> secondPredicate) + { + ArgumentNullException.ThrowIfNull(firstPredicate); + ArgumentNullException.ThrowIfNull(secondPredicate); + + var invokedExpression = Expression.Invoke(secondPredicate, firstPredicate.Parameters); + return Expression.Lambda>(Expression.AndAlso(firstPredicate.Body, invokedExpression), firstPredicate.Parameters); + } + + /// + /// Combines multiple predicates into a single predicate using a logical AND operation. + /// + /// The predicate parameter type. + /// A collection of predicate expressions to combine. + /// A new expression representing the AND combination of all input predicates. + public static Expression> And(this IEnumerable>> predicates) + { + ArgumentNullException.ThrowIfNull(predicates); + + return predicates.Aggregate((aggregatePredicate, nextPredicate) => aggregatePredicate.And(nextPredicate)); + } +} diff --git a/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs b/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs index ddb393d675..fbbb5bca73 100644 --- a/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs +++ b/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs @@ -1,8 +1,15 @@ using System; -using System.IO; +using System.Collections.Generic; +using System.Reflection; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.DbConfiguration; +using Jellyfin.Database.Providers.Sqlite; using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Configuration; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using JellyfinDbProviderFactory = System.Func; namespace Jellyfin.Server.Implementations.Extensions; @@ -11,17 +18,77 @@ namespace Jellyfin.Server.Implementations.Extensions; /// public static class ServiceCollectionExtensions { + private static IEnumerable DatabaseProviderTypes() + { + yield return typeof(SqliteDatabaseProvider); + } + + private static IDictionary GetSupportedDbProviders() + { + var items = new Dictionary(StringComparer.InvariantCultureIgnoreCase); + foreach (var providerType in DatabaseProviderTypes()) + { + var keyAttribute = providerType.GetCustomAttribute(); + if (keyAttribute is null || string.IsNullOrWhiteSpace(keyAttribute.DatabaseProviderKey)) + { + continue; + } + + var provider = providerType; + items[keyAttribute.DatabaseProviderKey] = (services) => (IJellyfinDatabaseProvider)ActivatorUtilities.CreateInstance(services, providerType); + } + + return items; + } + /// /// Adds the interface to the service collection with second level caching enabled. /// /// An instance of the interface. + /// The server configuration manager. + /// The startup Configuration. /// The updated service collection. - public static IServiceCollection AddJellyfinDbContext(this IServiceCollection serviceCollection) + public static IServiceCollection AddJellyfinDbContext( + this IServiceCollection serviceCollection, + IServerConfigurationManager configurationManager, + IConfiguration configuration) { + var efCoreConfiguration = configurationManager.GetConfiguration("database"); + var providers = GetSupportedDbProviders(); + JellyfinDbProviderFactory? providerFactory = null; + + if (efCoreConfiguration?.DatabaseType is null) + { + var cmdMigrationArgument = configuration.GetValue("migration-provider"); + if (!string.IsNullOrWhiteSpace(cmdMigrationArgument)) + { + efCoreConfiguration = new DatabaseConfigurationOptions() + { + DatabaseType = cmdMigrationArgument, + }; + } + else + { + // when nothing is setup via new Database configuration, fallback to SQLite with default settings. + efCoreConfiguration = new DatabaseConfigurationOptions() + { + DatabaseType = "Jellyfin-SQLite", + }; + configurationManager.SaveConfiguration("database", efCoreConfiguration); + } + } + + if (!providers.TryGetValue(efCoreConfiguration.DatabaseType.ToUpperInvariant(), out providerFactory!)) + { + throw new InvalidOperationException($"Jellyfin cannot find the database provider of type '{efCoreConfiguration.DatabaseType}'. Supported types are {string.Join(", ", providers.Keys)}"); + } + + serviceCollection.AddSingleton(providerFactory!); + serviceCollection.AddPooledDbContextFactory((serviceProvider, opt) => { - var applicationPaths = serviceProvider.GetRequiredService(); - opt.UseSqlite($"Filename={Path.Combine(applicationPaths.DataPath, "jellyfin.db")}"); + var provider = serviceProvider.GetRequiredService(); + provider.Initialise(opt); }); return serviceCollection; diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs new file mode 100644 index 0000000000..7f4364cf61 --- /dev/null +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs @@ -0,0 +1,2232 @@ +#pragma warning disable RS0030 // Do not use banned APIs +// Do not enforce that because EFCore cannot deal with cultures well. +#pragma warning disable CA1304 // Specify CultureInfo +#pragma warning disable CA1311 // Specify a culture or use an invariant version +#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using System.Text; +using System.Text.Json; +using System.Threading; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; +using Jellyfin.Extensions; +using Jellyfin.Extensions.Json; +using Jellyfin.Server.Implementations.Extensions; +using MediaBrowser.Common; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Channels; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.LiveTv; +using MediaBrowser.Model.Querying; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using BaseItemDto = MediaBrowser.Controller.Entities.BaseItem; +using BaseItemEntity = Jellyfin.Database.Implementations.Entities.BaseItemEntity; + +namespace Jellyfin.Server.Implementations.Item; + +/* + All queries in this class and all other nullable enabled EFCore repository classes will make liberal use of the null-forgiving operator "!". + This is done as the code isn't actually executed client side, but only the expressions are interpret and the compiler cannot know that. + This is your only warning/message regarding this topic. +*/ + +/// +/// Handles all storage logic for BaseItems. +/// +public sealed class BaseItemRepository + : IItemRepository +{ + /// + /// This holds all the types in the running assemblies + /// so that we can de-serialize properly when we don't have strong types. + /// + private static readonly ConcurrentDictionary _typeMap = new ConcurrentDictionary(); + private readonly IDbContextFactory _dbProvider; + private readonly IServerApplicationHost _appHost; + private readonly IItemTypeLookup _itemTypeLookup; + private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly ILogger _logger; + + private static readonly IReadOnlyList _getAllArtistsValueTypes = [ItemValueType.Artist, ItemValueType.AlbumArtist]; + private static readonly IReadOnlyList _getArtistValueTypes = [ItemValueType.Artist]; + private static readonly IReadOnlyList _getAlbumArtistValueTypes = [ItemValueType.AlbumArtist]; + private static readonly IReadOnlyList _getStudiosValueTypes = [ItemValueType.Studios]; + private static readonly IReadOnlyList _getGenreValueTypes = [ItemValueType.Genre]; + + /// + /// Initializes a new instance of the class. + /// + /// The db factory. + /// The Application host. + /// The static type lookup. + /// The server Configuration manager. + /// System logger. + public BaseItemRepository( + IDbContextFactory dbProvider, + IServerApplicationHost appHost, + IItemTypeLookup itemTypeLookup, + IServerConfigurationManager serverConfigurationManager, + ILogger logger) + { + _dbProvider = dbProvider; + _appHost = appHost; + _itemTypeLookup = itemTypeLookup; + _serverConfigurationManager = serverConfigurationManager; + _logger = logger; + } + + /// + public void DeleteItem(Guid id) + { + if (id.IsEmpty()) + { + throw new ArgumentException("Guid can't be empty", nameof(id)); + } + + using var context = _dbProvider.CreateDbContext(); + using var transaction = context.Database.BeginTransaction(); + context.AncestorIds.Where(e => e.ItemId == id || e.ParentItemId == id).ExecuteDelete(); + context.AttachmentStreamInfos.Where(e => e.ItemId == id).ExecuteDelete(); + context.BaseItemImageInfos.Where(e => e.ItemId == id).ExecuteDelete(); + context.BaseItemMetadataFields.Where(e => e.ItemId == id).ExecuteDelete(); + context.BaseItemProviders.Where(e => e.ItemId == id).ExecuteDelete(); + context.BaseItemTrailerTypes.Where(e => e.ItemId == id).ExecuteDelete(); + context.BaseItems.Where(e => e.Id == id).ExecuteDelete(); + context.Chapters.Where(e => e.ItemId == id).ExecuteDelete(); + context.CustomItemDisplayPreferences.Where(e => e.ItemId == id).ExecuteDelete(); + context.ItemDisplayPreferences.Where(e => e.ItemId == id).ExecuteDelete(); + context.ItemValues.Where(e => e.BaseItemsMap!.Count == 0).ExecuteDelete(); + context.ItemValuesMap.Where(e => e.ItemId == id).ExecuteDelete(); + context.KeyframeData.Where(e => e.ItemId == id).ExecuteDelete(); + context.MediaSegments.Where(e => e.ItemId == id).ExecuteDelete(); + context.MediaStreamInfos.Where(e => e.ItemId == id).ExecuteDelete(); + context.PeopleBaseItemMap.Where(e => e.ItemId == id).ExecuteDelete(); + context.Peoples.Where(e => e.BaseItems!.Count == 0).ExecuteDelete(); + context.TrickplayInfos.Where(e => e.ItemId == id).ExecuteDelete(); + context.SaveChanges(); + transaction.Commit(); + } + + /// + public void UpdateInheritedValues() + { + using var context = _dbProvider.CreateDbContext(); + using var transaction = context.Database.BeginTransaction(); + + context.ItemValuesMap.Where(e => e.ItemValue.Type == ItemValueType.InheritedTags).ExecuteDelete(); + // ItemValue Inheritance is now correctly mapped via AncestorId on demand + context.SaveChanges(); + + transaction.Commit(); + } + + /// + public IReadOnlyList GetItemIdsList(InternalItemsQuery filter) + { + ArgumentNullException.ThrowIfNull(filter); + PrepareFilterQuery(filter); + + using var context = _dbProvider.CreateDbContext(); + return ApplyQueryFilter(context.BaseItems.AsNoTracking(), context, filter).Select(e => e.Id).ToArray(); + } + + /// + public QueryResult<(BaseItemDto Item, ItemCounts ItemCounts)> GetAllArtists(InternalItemsQuery filter) + { + return GetItemValues(filter, _getAllArtistsValueTypes, _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]); + } + + /// + public QueryResult<(BaseItemDto Item, ItemCounts ItemCounts)> GetArtists(InternalItemsQuery filter) + { + return GetItemValues(filter, _getArtistValueTypes, _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]); + } + + /// + public QueryResult<(BaseItemDto Item, ItemCounts ItemCounts)> GetAlbumArtists(InternalItemsQuery filter) + { + return GetItemValues(filter, _getAlbumArtistValueTypes, _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]); + } + + /// + public QueryResult<(BaseItemDto Item, ItemCounts ItemCounts)> GetStudios(InternalItemsQuery filter) + { + return GetItemValues(filter, _getStudiosValueTypes, _itemTypeLookup.BaseItemKindNames[BaseItemKind.Studio]); + } + + /// + public QueryResult<(BaseItemDto Item, ItemCounts ItemCounts)> GetGenres(InternalItemsQuery filter) + { + return GetItemValues(filter, _getGenreValueTypes, _itemTypeLookup.BaseItemKindNames[BaseItemKind.Genre]); + } + + /// + public QueryResult<(BaseItemDto Item, ItemCounts ItemCounts)> GetMusicGenres(InternalItemsQuery filter) + { + return GetItemValues(filter, _getGenreValueTypes, _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicGenre]); + } + + /// + public IReadOnlyList GetStudioNames() + { + return GetItemValueNames(_getStudiosValueTypes, [], []); + } + + /// + public IReadOnlyList GetAllArtistNames() + { + return GetItemValueNames(_getAllArtistsValueTypes, [], []); + } + + /// + public IReadOnlyList GetMusicGenreNames() + { + return GetItemValueNames( + _getGenreValueTypes, + _itemTypeLookup.MusicGenreTypes, + []); + } + + /// + public IReadOnlyList GetGenreNames() + { + return GetItemValueNames( + _getGenreValueTypes, + [], + _itemTypeLookup.MusicGenreTypes); + } + + /// + public QueryResult GetItems(InternalItemsQuery filter) + { + ArgumentNullException.ThrowIfNull(filter); + if (!filter.EnableTotalRecordCount || (!filter.Limit.HasValue && (filter.StartIndex ?? 0) == 0)) + { + var returnList = GetItemList(filter); + return new QueryResult( + filter.StartIndex, + returnList.Count, + returnList); + } + + PrepareFilterQuery(filter); + var result = new QueryResult(); + + using var context = _dbProvider.CreateDbContext(); + + IQueryable dbQuery = PrepareItemQuery(context, filter); + + dbQuery = TranslateQuery(dbQuery, context, filter); + if (filter.EnableTotalRecordCount) + { + result.TotalRecordCount = dbQuery.Count(); + } + + dbQuery = ApplyGroupingFilter(dbQuery, filter); + dbQuery = ApplyQueryPaging(dbQuery, filter); + + result.Items = dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserialiseBaseItem(w, filter.SkipDeserialization)).ToArray(); + result.StartIndex = filter.StartIndex ?? 0; + return result; + } + + /// + public IReadOnlyList GetItemList(InternalItemsQuery filter) + { + ArgumentNullException.ThrowIfNull(filter); + PrepareFilterQuery(filter); + + using var context = _dbProvider.CreateDbContext(); + IQueryable dbQuery = PrepareItemQuery(context, filter); + + dbQuery = TranslateQuery(dbQuery, context, filter); + + dbQuery = ApplyGroupingFilter(dbQuery, filter); + dbQuery = ApplyQueryPaging(dbQuery, filter); + + return dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserialiseBaseItem(w, filter.SkipDeserialization)).ToArray(); + } + + /// + public IReadOnlyList GetLatestItemList(InternalItemsQuery filter, CollectionType collectionType) + { + ArgumentNullException.ThrowIfNull(filter); + PrepareFilterQuery(filter); + + // Early exit if collection type is not tvshows or music + if (collectionType != CollectionType.tvshows && collectionType != CollectionType.music) + { + return Array.Empty(); + } + + using var context = _dbProvider.CreateDbContext(); + + // Subquery to group by SeriesNames/Album and get the max Date Created for each group. + var subquery = PrepareItemQuery(context, filter); + subquery = TranslateQuery(subquery, context, filter); + var subqueryGrouped = subquery.GroupBy(g => collectionType == CollectionType.tvshows ? g.SeriesName : g.Album) + .Select(g => new + { + Key = g.Key, + MaxDateCreated = g.Max(a => a.DateCreated) + }) + .OrderByDescending(g => g.MaxDateCreated) + .Select(g => g); + + if (filter.Limit.HasValue) + { + subqueryGrouped = subqueryGrouped.Take(filter.Limit.Value); + } + + filter.Limit = null; + + var mainquery = PrepareItemQuery(context, filter); + mainquery = TranslateQuery(mainquery, context, filter); + mainquery = mainquery.Where(g => g.DateCreated >= subqueryGrouped.Min(s => s.MaxDateCreated)); + mainquery = ApplyGroupingFilter(mainquery, filter); + mainquery = ApplyQueryPaging(mainquery, filter); + + return mainquery.AsEnumerable().Where(e => e is not null).Select(w => DeserialiseBaseItem(w, filter.SkipDeserialization)).ToArray(); + } + + /// + public IReadOnlyList GetNextUpSeriesKeys(InternalItemsQuery filter, DateTime dateCutoff) + { + ArgumentNullException.ThrowIfNull(filter); + ArgumentNullException.ThrowIfNull(filter.User); + + using var context = _dbProvider.CreateDbContext(); + + var query = context.BaseItems + .AsNoTracking() + .Where(i => filter.TopParentIds.Contains(i.TopParentId!.Value)) + .Where(i => i.Type == _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode]) + .Join( + context.UserData.AsNoTracking(), + i => new { UserId = filter.User.Id, ItemId = i.Id }, + u => new { UserId = u.UserId, ItemId = u.ItemId }, + (entity, data) => new { Item = entity, UserData = data }) + .GroupBy(g => g.Item.SeriesPresentationUniqueKey) + .Select(g => new { g.Key, LastPlayedDate = g.Max(u => u.UserData.LastPlayedDate) }) + .Where(g => g.Key != null && g.LastPlayedDate != null && g.LastPlayedDate >= dateCutoff) + .OrderByDescending(g => g.LastPlayedDate) + .Select(g => g.Key!); + + if (filter.Limit.HasValue) + { + query = query.Take(filter.Limit.Value); + } + + return query.ToArray(); + } + + private IQueryable ApplyGroupingFilter(IQueryable dbQuery, InternalItemsQuery filter) + { + // This whole block is needed to filter duplicate entries on request + // for the time being it cannot be used because it would destroy the ordering + // this results in "duplicate" responses for queries that try to lookup individual series or multiple versions but + // for that case the invoker has to run a DistinctBy(e => e.PresentationUniqueKey) on their own + + // var enableGroupByPresentationUniqueKey = EnableGroupByPresentationUniqueKey(filter); + // if (enableGroupByPresentationUniqueKey && filter.GroupBySeriesPresentationUniqueKey) + // { + // dbQuery = ApplyOrder(dbQuery, filter); + // dbQuery = dbQuery.GroupBy(e => new { e.PresentationUniqueKey, e.SeriesPresentationUniqueKey }).Select(e => e.First()); + // } + // else if (enableGroupByPresentationUniqueKey) + // { + // dbQuery = ApplyOrder(dbQuery, filter); + // dbQuery = dbQuery.GroupBy(e => e.PresentationUniqueKey).Select(e => e.First()); + // } + // else if (filter.GroupBySeriesPresentationUniqueKey) + // { + // dbQuery = ApplyOrder(dbQuery, filter); + // dbQuery = dbQuery.GroupBy(e => e.SeriesPresentationUniqueKey).Select(e => e.First()); + // } + // else + // { + // dbQuery = dbQuery.Distinct(); + // dbQuery = ApplyOrder(dbQuery, filter); + // } + dbQuery = dbQuery.Distinct(); + dbQuery = ApplyOrder(dbQuery, filter); + + return dbQuery; + } + + private IQueryable ApplyQueryPaging(IQueryable dbQuery, InternalItemsQuery filter) + { + if (filter.Limit.HasValue || filter.StartIndex.HasValue) + { + var offset = filter.StartIndex ?? 0; + + if (offset > 0) + { + dbQuery = dbQuery.Skip(offset); + } + + if (filter.Limit.HasValue) + { + dbQuery = dbQuery.Take(filter.Limit.Value); + } + } + + return dbQuery; + } + + private IQueryable ApplyQueryFilter(IQueryable dbQuery, JellyfinDbContext context, InternalItemsQuery filter) + { + dbQuery = TranslateQuery(dbQuery, context, filter); + dbQuery = ApplyOrder(dbQuery, filter); + dbQuery = ApplyGroupingFilter(dbQuery, filter); + dbQuery = ApplyQueryPaging(dbQuery, filter); + return dbQuery; + } + + private IQueryable PrepareItemQuery(JellyfinDbContext context, InternalItemsQuery filter) + { + IQueryable dbQuery = context.BaseItems.AsNoTracking().AsSplitQuery() + .Include(e => e.TrailerTypes) + .Include(e => e.Provider) + .Include(e => e.LockedFields); + + if (filter.DtoOptions.EnableImages) + { + dbQuery = dbQuery.Include(e => e.Images); + } + + return dbQuery; + } + + /// + public int GetCount(InternalItemsQuery filter) + { + ArgumentNullException.ThrowIfNull(filter); + // Hack for right now since we currently don't support filtering out these duplicates within a query + PrepareFilterQuery(filter); + + using var context = _dbProvider.CreateDbContext(); + var dbQuery = TranslateQuery(context.BaseItems.AsNoTracking(), context, filter); + + return dbQuery.Count(); + } + +#pragma warning disable CA1307 // Specify StringComparison for clarity + /// + /// Gets the type. + /// + /// Name of the type. + /// Type. + /// typeName is null. + private static Type? GetType(string typeName) + { + ArgumentException.ThrowIfNullOrEmpty(typeName); + + // TODO: this isn't great. Refactor later to be both globally handled by a dedicated service not just an static variable and be loaded eagerly. + // currently this is done so that plugins may introduce their own type of baseitems as we dont know when we are first called, before or after plugins are loaded + return _typeMap.GetOrAdd(typeName, k => AppDomain.CurrentDomain.GetAssemblies() + .Select(a => a.GetType(k)) + .FirstOrDefault(t => t is not null)); + } + + /// + public void SaveImages(BaseItemDto item) + { + ArgumentNullException.ThrowIfNull(item); + + var images = item.ImageInfos.Select(e => Map(item.Id, e)); + using var context = _dbProvider.CreateDbContext(); + using var transaction = context.Database.BeginTransaction(); + context.BaseItemImageInfos.Where(e => e.ItemId == item.Id).ExecuteDelete(); + context.BaseItemImageInfos.AddRange(images); + context.SaveChanges(); + transaction.Commit(); + } + + /// + public void SaveItems(IReadOnlyList items, CancellationToken cancellationToken) + { + UpdateOrInsertItems(items, cancellationToken); + } + + /// + public void UpdateOrInsertItems(IReadOnlyList items, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(items); + cancellationToken.ThrowIfCancellationRequested(); + + var tuples = new List<(BaseItemDto Item, List? AncestorIds, BaseItemDto TopParent, IEnumerable UserDataKey, List InheritedTags)>(); + foreach (var item in items.GroupBy(e => e.Id).Select(e => e.Last())) + { + var ancestorIds = item.SupportsAncestors ? + item.GetAncestorIds().Distinct().ToList() : + null; + + var topParent = item.GetTopParent(); + + var userdataKey = item.GetUserDataKeys(); + var inheritedTags = item.GetInheritedTags(); + + tuples.Add((item, ancestorIds, topParent, userdataKey, inheritedTags)); + } + + var localItemValueCache = new Dictionary<(int MagicNumber, string Value), Guid>(); + + using var context = _dbProvider.CreateDbContext(); + using var transaction = context.Database.BeginTransaction(); + foreach (var item in tuples) + { + var entity = Map(item.Item); + // TODO: refactor this "inconsistency" + entity.TopParentId = item.TopParent?.Id; + + if (!context.BaseItems.Any(e => e.Id == entity.Id)) + { + context.BaseItems.Add(entity); + } + else + { + context.BaseItemProviders.Where(e => e.ItemId == entity.Id).ExecuteDelete(); + context.BaseItems.Attach(entity).State = EntityState.Modified; + } + + context.AncestorIds.Where(e => e.ItemId == entity.Id).ExecuteDelete(); + if (item.Item.SupportsAncestors && item.AncestorIds != null) + { + foreach (var ancestorId in item.AncestorIds) + { + if (!context.BaseItems.Any(f => f.Id == ancestorId)) + { + continue; + } + + context.AncestorIds.Add(new AncestorId() + { + ParentItemId = ancestorId, + ItemId = entity.Id, + Item = null!, + ParentItem = null! + }); + } + } + + // Never save duplicate itemValues as they are now mapped anyway. + var itemValuesToSave = GetItemValuesToSave(item.Item, item.InheritedTags).DistinctBy(e => (GetCleanValue(e.Value), e.MagicNumber)); + context.ItemValuesMap.Where(e => e.ItemId == entity.Id).ExecuteDelete(); + foreach (var itemValue in itemValuesToSave) + { + if (!localItemValueCache.TryGetValue(itemValue, out var refValue)) + { + refValue = context.ItemValues + .Where(f => f.Value == itemValue.Value && (int)f.Type == itemValue.MagicNumber) + .Select(e => e.ItemValueId) + .FirstOrDefault(); + } + + if (refValue.IsEmpty()) + { + context.ItemValues.Add(new ItemValue() + { + CleanValue = GetCleanValue(itemValue.Value), + Type = (ItemValueType)itemValue.MagicNumber, + ItemValueId = refValue = Guid.NewGuid(), + Value = itemValue.Value + }); + localItemValueCache[itemValue] = refValue; + } + + context.ItemValuesMap.Add(new ItemValueMap() + { + Item = null!, + ItemId = entity.Id, + ItemValue = null!, + ItemValueId = refValue + }); + } + } + + context.SaveChanges(); + transaction.Commit(); + } + + /// + public BaseItemDto? RetrieveItem(Guid id) + { + if (id.IsEmpty()) + { + throw new ArgumentException("Guid can't be empty", nameof(id)); + } + + using var context = _dbProvider.CreateDbContext(); + var item = PrepareItemQuery(context, new() + { + DtoOptions = new() + { + EnableImages = true + } + }).FirstOrDefault(e => e.Id == id); + if (item is null) + { + return null; + } + + return DeserialiseBaseItem(item); + } + + /// + /// Maps a Entity to the DTO. + /// + /// The entity. + /// The dto base instance. + /// The Application server Host. + /// The dto to map. + public static BaseItemDto Map(BaseItemEntity entity, BaseItemDto dto, IServerApplicationHost? appHost) + { + dto.Id = entity.Id; + dto.ParentId = entity.ParentId.GetValueOrDefault(); + dto.Path = appHost?.ExpandVirtualPath(entity.Path) ?? entity.Path; + dto.EndDate = entity.EndDate; + dto.CommunityRating = entity.CommunityRating; + dto.CustomRating = entity.CustomRating; + dto.IndexNumber = entity.IndexNumber; + dto.IsLocked = entity.IsLocked; + dto.Name = entity.Name; + dto.OfficialRating = entity.OfficialRating; + dto.Overview = entity.Overview; + dto.ParentIndexNumber = entity.ParentIndexNumber; + dto.PremiereDate = entity.PremiereDate; + dto.ProductionYear = entity.ProductionYear; + dto.SortName = entity.SortName; + dto.ForcedSortName = entity.ForcedSortName; + dto.RunTimeTicks = entity.RunTimeTicks; + dto.PreferredMetadataLanguage = entity.PreferredMetadataLanguage; + dto.PreferredMetadataCountryCode = entity.PreferredMetadataCountryCode; + dto.IsInMixedFolder = entity.IsInMixedFolder; + dto.InheritedParentalRatingValue = entity.InheritedParentalRatingValue; + dto.InheritedParentalRatingSubValue = entity.InheritedParentalRatingSubValue; + dto.CriticRating = entity.CriticRating; + dto.PresentationUniqueKey = entity.PresentationUniqueKey; + dto.OriginalTitle = entity.OriginalTitle; + dto.Album = entity.Album; + dto.LUFS = entity.LUFS; + dto.NormalizationGain = entity.NormalizationGain; + dto.IsVirtualItem = entity.IsVirtualItem; + dto.ExternalSeriesId = entity.ExternalSeriesId; + dto.Tagline = entity.Tagline; + dto.TotalBitrate = entity.TotalBitrate; + dto.ExternalId = entity.ExternalId; + dto.Size = entity.Size; + dto.Genres = entity.Genres?.Split('|') ?? []; + dto.DateCreated = entity.DateCreated.GetValueOrDefault(); + dto.DateModified = entity.DateModified.GetValueOrDefault(); + dto.ChannelId = entity.ChannelId ?? Guid.Empty; + dto.DateLastRefreshed = entity.DateLastRefreshed.GetValueOrDefault(); + dto.DateLastSaved = entity.DateLastSaved.GetValueOrDefault(); + dto.OwnerId = string.IsNullOrWhiteSpace(entity.OwnerId) ? Guid.Empty : (Guid.TryParse(entity.OwnerId, out var ownerId) ? ownerId : Guid.Empty); + dto.Width = entity.Width.GetValueOrDefault(); + dto.Height = entity.Height.GetValueOrDefault(); + if (entity.Provider is not null) + { + dto.ProviderIds = entity.Provider.ToDictionary(e => e.ProviderId, e => e.ProviderValue); + } + + if (entity.ExtraType is not null) + { + dto.ExtraType = (ExtraType)entity.ExtraType; + } + + if (entity.LockedFields is not null) + { + dto.LockedFields = entity.LockedFields?.Select(e => (MetadataField)e.Id).ToArray() ?? []; + } + + if (entity.Audio is not null) + { + dto.Audio = (ProgramAudio)entity.Audio; + } + + dto.ExtraIds = string.IsNullOrWhiteSpace(entity.ExtraIds) ? [] : entity.ExtraIds.Split('|').Select(e => Guid.Parse(e)).ToArray(); + dto.ProductionLocations = entity.ProductionLocations?.Split('|') ?? []; + dto.Studios = entity.Studios?.Split('|') ?? []; + dto.Tags = entity.Tags?.Split('|') ?? []; + + if (dto is IHasProgramAttributes hasProgramAttributes) + { + hasProgramAttributes.IsMovie = entity.IsMovie; + hasProgramAttributes.IsSeries = entity.IsSeries; + hasProgramAttributes.EpisodeTitle = entity.EpisodeTitle; + hasProgramAttributes.IsRepeat = entity.IsRepeat; + } + + if (dto is LiveTvChannel liveTvChannel) + { + liveTvChannel.ServiceName = entity.ExternalServiceId; + } + + if (dto is Trailer trailer) + { + trailer.TrailerTypes = entity.TrailerTypes?.Select(e => (TrailerType)e.Id).ToArray() ?? []; + } + + if (dto is Video video) + { + video.PrimaryVersionId = entity.PrimaryVersionId; + } + + if (dto is IHasSeries hasSeriesName) + { + hasSeriesName.SeriesName = entity.SeriesName; + hasSeriesName.SeriesId = entity.SeriesId.GetValueOrDefault(); + hasSeriesName.SeriesPresentationUniqueKey = entity.SeriesPresentationUniqueKey; + } + + if (dto is Episode episode) + { + episode.SeasonName = entity.SeasonName; + episode.SeasonId = entity.SeasonId.GetValueOrDefault(); + } + + if (dto is IHasArtist hasArtists) + { + hasArtists.Artists = entity.Artists?.Split('|', StringSplitOptions.RemoveEmptyEntries) ?? []; + } + + if (dto is IHasAlbumArtist hasAlbumArtists) + { + hasAlbumArtists.AlbumArtists = entity.AlbumArtists?.Split('|', StringSplitOptions.RemoveEmptyEntries) ?? []; + } + + if (dto is LiveTvProgram program) + { + program.ShowId = entity.ShowId; + } + + if (entity.Images is not null) + { + dto.ImageInfos = entity.Images.Select(e => Map(e, appHost)).ToArray(); + } + + // dto.Type = entity.Type; + // dto.Data = entity.Data; + // dto.MediaType = Enum.TryParse(entity.MediaType); + if (dto is IHasStartDate hasStartDate) + { + hasStartDate.StartDate = entity.StartDate.GetValueOrDefault(); + } + + // Fields that are present in the DB but are never actually used + // dto.UnratedType = entity.UnratedType; + // dto.TopParentId = entity.TopParentId; + // dto.CleanName = entity.CleanName; + // dto.UserDataKey = entity.UserDataKey; + + if (dto is Folder folder) + { + folder.DateLastMediaAdded = entity.DateLastMediaAdded; + } + + return dto; + } + + /// + /// Maps a Entity to the DTO. + /// + /// The entity. + /// The dto to map. + public BaseItemEntity Map(BaseItemDto dto) + { + var dtoType = dto.GetType(); + var entity = new BaseItemEntity() + { + Type = dtoType.ToString(), + Id = dto.Id + }; + + if (TypeRequiresDeserialization(dtoType)) + { + entity.Data = JsonSerializer.Serialize(dto, dtoType, JsonDefaults.Options); + } + + entity.ParentId = !dto.ParentId.IsEmpty() ? dto.ParentId : null; + entity.Path = GetPathToSave(dto.Path); + entity.EndDate = dto.EndDate; + entity.CommunityRating = dto.CommunityRating; + entity.CustomRating = dto.CustomRating; + entity.IndexNumber = dto.IndexNumber; + entity.IsLocked = dto.IsLocked; + entity.Name = dto.Name; + entity.CleanName = GetCleanValue(dto.Name); + entity.OfficialRating = dto.OfficialRating; + entity.Overview = dto.Overview; + entity.ParentIndexNumber = dto.ParentIndexNumber; + entity.PremiereDate = dto.PremiereDate; + entity.ProductionYear = dto.ProductionYear; + entity.SortName = dto.SortName; + entity.ForcedSortName = dto.ForcedSortName; + entity.RunTimeTicks = dto.RunTimeTicks; + entity.PreferredMetadataLanguage = dto.PreferredMetadataLanguage; + entity.PreferredMetadataCountryCode = dto.PreferredMetadataCountryCode; + entity.IsInMixedFolder = dto.IsInMixedFolder; + entity.InheritedParentalRatingValue = dto.InheritedParentalRatingValue; + entity.InheritedParentalRatingSubValue = dto.InheritedParentalRatingSubValue; + entity.CriticRating = dto.CriticRating; + entity.PresentationUniqueKey = dto.PresentationUniqueKey; + entity.OriginalTitle = dto.OriginalTitle; + entity.Album = dto.Album; + entity.LUFS = dto.LUFS; + entity.NormalizationGain = dto.NormalizationGain; + entity.IsVirtualItem = dto.IsVirtualItem; + entity.ExternalSeriesId = dto.ExternalSeriesId; + entity.Tagline = dto.Tagline; + entity.TotalBitrate = dto.TotalBitrate; + entity.ExternalId = dto.ExternalId; + entity.Size = dto.Size; + entity.Genres = string.Join('|', dto.Genres); + entity.DateCreated = dto.DateCreated; + entity.DateModified = dto.DateModified; + entity.ChannelId = dto.ChannelId; + entity.DateLastRefreshed = dto.DateLastRefreshed; + entity.DateLastSaved = dto.DateLastSaved; + entity.OwnerId = dto.OwnerId.ToString(); + entity.Width = dto.Width; + entity.Height = dto.Height; + entity.Provider = dto.ProviderIds.Select(e => new BaseItemProvider() + { + Item = entity, + ProviderId = e.Key, + ProviderValue = e.Value + }).ToList(); + + if (dto.Audio.HasValue) + { + entity.Audio = (ProgramAudioEntity)dto.Audio; + } + + if (dto.ExtraType.HasValue) + { + entity.ExtraType = (BaseItemExtraType)dto.ExtraType; + } + + entity.ExtraIds = dto.ExtraIds is not null ? string.Join('|', dto.ExtraIds) : null; + entity.ProductionLocations = dto.ProductionLocations is not null ? string.Join('|', dto.ProductionLocations) : null; + entity.Studios = dto.Studios is not null ? string.Join('|', dto.Studios) : null; + entity.Tags = dto.Tags is not null ? string.Join('|', dto.Tags) : null; + entity.LockedFields = dto.LockedFields is not null ? dto.LockedFields + .Select(e => new BaseItemMetadataField() + { + Id = (int)e, + Item = entity, + ItemId = entity.Id + }) + .ToArray() : null; + + if (dto is IHasProgramAttributes hasProgramAttributes) + { + entity.IsMovie = hasProgramAttributes.IsMovie; + entity.IsSeries = hasProgramAttributes.IsSeries; + entity.EpisodeTitle = hasProgramAttributes.EpisodeTitle; + entity.IsRepeat = hasProgramAttributes.IsRepeat; + } + + if (dto is LiveTvChannel liveTvChannel) + { + entity.ExternalServiceId = liveTvChannel.ServiceName; + } + + if (dto is Video video) + { + entity.PrimaryVersionId = video.PrimaryVersionId; + } + + if (dto is IHasSeries hasSeriesName) + { + entity.SeriesName = hasSeriesName.SeriesName; + entity.SeriesId = hasSeriesName.SeriesId; + entity.SeriesPresentationUniqueKey = hasSeriesName.SeriesPresentationUniqueKey; + } + + if (dto is Episode episode) + { + entity.SeasonName = episode.SeasonName; + entity.SeasonId = episode.SeasonId; + } + + if (dto is IHasArtist hasArtists) + { + entity.Artists = hasArtists.Artists is not null ? string.Join('|', hasArtists.Artists) : null; + } + + if (dto is IHasAlbumArtist hasAlbumArtists) + { + entity.AlbumArtists = hasAlbumArtists.AlbumArtists is not null ? string.Join('|', hasAlbumArtists.AlbumArtists) : null; + } + + if (dto is LiveTvProgram program) + { + entity.ShowId = program.ShowId; + } + + if (dto.ImageInfos is not null) + { + entity.Images = dto.ImageInfos.Select(f => Map(dto.Id, f)).ToArray(); + } + + if (dto is Trailer trailer) + { + entity.TrailerTypes = trailer.TrailerTypes?.Select(e => new BaseItemTrailerType() + { + Id = (int)e, + Item = entity, + ItemId = entity.Id + }).ToArray() ?? []; + } + + // dto.Type = entity.Type; + // dto.Data = entity.Data; + entity.MediaType = dto.MediaType.ToString(); + if (dto is IHasStartDate hasStartDate) + { + entity.StartDate = hasStartDate.StartDate; + } + + entity.UnratedType = dto.GetBlockUnratedType().ToString(); + + // Fields that are present in the DB but are never actually used + // dto.UserDataKey = entity.UserDataKey; + + if (dto is Folder folder) + { + entity.DateLastMediaAdded = folder.DateLastMediaAdded; + entity.IsFolder = folder.IsFolder; + } + + return entity; + } + + private string[] GetItemValueNames(IReadOnlyList itemValueTypes, IReadOnlyList withItemTypes, IReadOnlyList excludeItemTypes) + { + using var context = _dbProvider.CreateDbContext(); + + var query = context.ItemValuesMap + .AsNoTracking() + .Where(e => itemValueTypes.Any(w => (ItemValueType)w == e.ItemValue.Type)); + if (withItemTypes.Count > 0) + { + query = query.Where(e => withItemTypes.Contains(e.Item.Type)); + } + + if (excludeItemTypes.Count > 0) + { + query = query.Where(e => !excludeItemTypes.Contains(e.Item.Type)); + } + + // query = query.DistinctBy(e => e.CleanValue); + return query.Select(e => e.ItemValue) + .GroupBy(e => e.CleanValue) + .Select(e => e.First().Value) + .ToArray(); + } + + private static bool TypeRequiresDeserialization(Type type) + { + return type.GetCustomAttribute() == null; + } + + private BaseItemDto DeserialiseBaseItem(BaseItemEntity baseItemEntity, bool skipDeserialization = false) + { + ArgumentNullException.ThrowIfNull(baseItemEntity, nameof(baseItemEntity)); + if (_serverConfigurationManager?.Configuration is null) + { + throw new InvalidOperationException("Server Configuration manager or configuration is null"); + } + + var typeToSerialise = GetType(baseItemEntity.Type); + return BaseItemRepository.DeserialiseBaseItem( + baseItemEntity, + _logger, + _appHost, + skipDeserialization || (_serverConfigurationManager.Configuration.SkipDeserializationForBasicTypes && (typeToSerialise == typeof(Channel) || typeToSerialise == typeof(UserRootFolder)))); + } + + /// + /// Deserialises a BaseItemEntity and sets all properties. + /// + /// The DB entity. + /// Logger. + /// The application server Host. + /// If only mapping should be processed. + /// A mapped BaseItem. + /// Will be thrown if an invalid serialisation is requested. + public static BaseItemDto DeserialiseBaseItem(BaseItemEntity baseItemEntity, ILogger logger, IServerApplicationHost? appHost, bool skipDeserialization = false) + { + var type = GetType(baseItemEntity.Type) ?? throw new InvalidOperationException("Cannot deserialise unknown type."); + BaseItemDto? dto = null; + if (TypeRequiresDeserialization(type) && baseItemEntity.Data is not null && !skipDeserialization) + { + try + { + dto = JsonSerializer.Deserialize(baseItemEntity.Data, type, JsonDefaults.Options) as BaseItemDto; + } + catch (JsonException ex) + { + logger.LogError(ex, "Error deserializing item with JSON: {Data}", baseItemEntity.Data); + } + } + + if (dto is null) + { + dto = Activator.CreateInstance(type) as BaseItemDto ?? throw new InvalidOperationException("Cannot deserialise unknown type."); + } + + return Map(baseItemEntity, dto, appHost); + } + + private QueryResult<(BaseItemDto Item, ItemCounts ItemCounts)> GetItemValues(InternalItemsQuery filter, IReadOnlyList itemValueTypes, string returnType) + { + ArgumentNullException.ThrowIfNull(filter); + + if (!filter.Limit.HasValue) + { + filter.EnableTotalRecordCount = false; + } + + using var context = _dbProvider.CreateDbContext(); + + var query = TranslateQuery(context.BaseItems.AsNoTracking(), context, filter); + + query = query.Where(e => e.Type == returnType); + // this does not seem to be nesseary but it does not make any sense why this isn't working. + // && e.ItemValues!.Any(f => e.CleanName == f.ItemValue.CleanValue && itemValueTypes.Any(w => (ItemValueType)w == f.ItemValue.Type))); + + if (filter.OrderBy.Count != 0 + || !string.IsNullOrEmpty(filter.SearchTerm)) + { + query = ApplyOrder(query, filter); + } + else + { + query = query.OrderBy(e => e.SortName); + } + + if (filter.Limit.HasValue || filter.StartIndex.HasValue) + { + var offset = filter.StartIndex ?? 0; + + if (offset > 0) + { + query = query.Skip(offset); + } + + if (filter.Limit.HasValue) + { + query = query.Take(filter.Limit.Value); + } + } + + var result = new QueryResult<(BaseItemDto, ItemCounts)>(); + if (filter.EnableTotalRecordCount) + { + result.TotalRecordCount = query.GroupBy(e => e.PresentationUniqueKey).Select(e => e.First()).Count(); + } + + var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series]; + var movieTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie]; + var episodeTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode]; + var musicAlbumTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicAlbum]; + var musicArtistTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]; + var audioTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Audio]; + var trailerTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Trailer]; + + var resultQuery = query.Select(e => new + { + item = e, + // TODO: This is bad refactor! + itemCount = new ItemCounts() + { + SeriesCount = e.ItemValues!.Count(f => f.Item.Type == seriesTypeName), + EpisodeCount = e.ItemValues!.Count(f => f.Item.Type == episodeTypeName), + MovieCount = e.ItemValues!.Count(f => f.Item.Type == movieTypeName), + AlbumCount = e.ItemValues!.Count(f => f.Item.Type == musicAlbumTypeName), + ArtistCount = e.ItemValues!.Count(f => f.Item.Type == musicArtistTypeName), + SongCount = e.ItemValues!.Count(f => f.Item.Type == audioTypeName), + TrailerCount = e.ItemValues!.Count(f => f.Item.Type == trailerTypeName), + } + }); + + result.StartIndex = filter.StartIndex ?? 0; + result.Items = resultQuery.ToArray().Where(e => e is not null).Select(e => + { + return (DeserialiseBaseItem(e.item, filter.SkipDeserialization), e.itemCount); + }).ToArray(); + + return result; + } + + private static void PrepareFilterQuery(InternalItemsQuery query) + { + if (query.Limit.HasValue && query.EnableGroupByMetadataKey) + { + query.Limit = query.Limit.Value + 4; + } + + if (query.IsResumable ?? false) + { + query.IsVirtualItem = false; + } + } + + private string GetCleanValue(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return value; + } + + return value.RemoveDiacritics().ToLowerInvariant(); + } + + private List<(int MagicNumber, string Value)> GetItemValuesToSave(BaseItemDto item, List inheritedTags) + { + var list = new List<(int, string)>(); + + if (item is IHasArtist hasArtist) + { + list.AddRange(hasArtist.Artists.Select(i => (0, i))); + } + + if (item is IHasAlbumArtist hasAlbumArtist) + { + list.AddRange(hasAlbumArtist.AlbumArtists.Select(i => (1, i))); + } + + list.AddRange(item.Genres.Select(i => (2, i))); + list.AddRange(item.Studios.Select(i => (3, i))); + list.AddRange(item.Tags.Select(i => (4, i))); + + // keywords was 5 + + list.AddRange(inheritedTags.Select(i => (6, i))); + + // Remove all invalid values. + list.RemoveAll(i => string.IsNullOrWhiteSpace(i.Item2)); + + return list; + } + + private static BaseItemImageInfo Map(Guid baseItemId, ItemImageInfo e) + { + return new BaseItemImageInfo() + { + ItemId = baseItemId, + Id = Guid.NewGuid(), + Path = e.Path, + Blurhash = e.BlurHash is null ? null : Encoding.UTF8.GetBytes(e.BlurHash), + DateModified = e.DateModified, + Height = e.Height, + Width = e.Width, + ImageType = (ImageInfoImageType)e.Type, + Item = null! + }; + } + + private static ItemImageInfo Map(BaseItemImageInfo e, IServerApplicationHost? appHost) + { + return new ItemImageInfo() + { + Path = appHost?.ExpandVirtualPath(e.Path) ?? e.Path, + BlurHash = e.Blurhash is null ? null : Encoding.UTF8.GetString(e.Blurhash), + DateModified = e.DateModified, + Height = e.Height, + Width = e.Width, + Type = (ImageType)e.ImageType + }; + } + + private string? GetPathToSave(string path) + { + if (path is null) + { + return null; + } + + return _appHost.ReverseVirtualPath(path); + } + + private List GetItemByNameTypesInQuery(InternalItemsQuery query) + { + var list = new List(); + + if (IsTypeInQuery(BaseItemKind.Person, query)) + { + list.Add(_itemTypeLookup.BaseItemKindNames[BaseItemKind.Person]!); + } + + if (IsTypeInQuery(BaseItemKind.Genre, query)) + { + list.Add(_itemTypeLookup.BaseItemKindNames[BaseItemKind.Genre]!); + } + + if (IsTypeInQuery(BaseItemKind.MusicGenre, query)) + { + list.Add(_itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicGenre]!); + } + + if (IsTypeInQuery(BaseItemKind.MusicArtist, query)) + { + list.Add(_itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]!); + } + + if (IsTypeInQuery(BaseItemKind.Studio, query)) + { + list.Add(_itemTypeLookup.BaseItemKindNames[BaseItemKind.Studio]!); + } + + return list; + } + + private bool IsTypeInQuery(BaseItemKind type, InternalItemsQuery query) + { + if (query.ExcludeItemTypes.Contains(type)) + { + return false; + } + + return query.IncludeItemTypes.Length == 0 || query.IncludeItemTypes.Contains(type); + } + + private bool EnableGroupByPresentationUniqueKey(InternalItemsQuery query) + { + if (!query.GroupByPresentationUniqueKey) + { + return false; + } + + if (query.GroupBySeriesPresentationUniqueKey) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(query.PresentationUniqueKey)) + { + return false; + } + + if (query.User is null) + { + return false; + } + + if (query.IncludeItemTypes.Length == 0) + { + return true; + } + + return query.IncludeItemTypes.Contains(BaseItemKind.Episode) + || query.IncludeItemTypes.Contains(BaseItemKind.Video) + || query.IncludeItemTypes.Contains(BaseItemKind.Movie) + || query.IncludeItemTypes.Contains(BaseItemKind.MusicVideo) + || query.IncludeItemTypes.Contains(BaseItemKind.Series) + || query.IncludeItemTypes.Contains(BaseItemKind.Season); + } + + private IQueryable ApplyOrder(IQueryable query, InternalItemsQuery filter) + { + var orderBy = filter.OrderBy; + var hasSearch = !string.IsNullOrEmpty(filter.SearchTerm); + + if (hasSearch) + { + orderBy = filter.OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending), .. orderBy]; + } + else if (orderBy.Count == 0) + { + return query; + } + + IOrderedQueryable? orderedQuery = null; + + var firstOrdering = orderBy.FirstOrDefault(); + if (firstOrdering != default) + { + var expression = OrderMapper.MapOrderByField(firstOrdering.OrderBy, filter); + if (firstOrdering.SortOrder == SortOrder.Ascending) + { + orderedQuery = query.OrderBy(expression); + } + else + { + orderedQuery = query.OrderByDescending(expression); + } + + if (firstOrdering.OrderBy is ItemSortBy.Default or ItemSortBy.SortName) + { + if (firstOrdering.SortOrder is SortOrder.Ascending) + { + orderedQuery = orderedQuery.ThenBy(e => e.Name); + } + else + { + orderedQuery = orderedQuery.ThenByDescending(e => e.Name); + } + } + } + + foreach (var item in orderBy.Skip(1)) + { + var expression = OrderMapper.MapOrderByField(item.OrderBy, filter); + if (item.SortOrder == SortOrder.Ascending) + { + orderedQuery = orderedQuery!.ThenBy(expression); + } + else + { + orderedQuery = orderedQuery!.ThenByDescending(expression); + } + } + + return orderedQuery ?? query; + } + + private IQueryable TranslateQuery( + IQueryable baseQuery, + JellyfinDbContext context, + InternalItemsQuery filter) + { + const int HDWidth = 1200; + const int UHDWidth = 3800; + const int UHDHeight = 2100; + + var minWidth = filter.MinWidth; + var maxWidth = filter.MaxWidth; + var now = DateTime.UtcNow; + + if (filter.IsHD.HasValue || filter.Is4K.HasValue) + { + bool includeSD = false; + bool includeHD = false; + bool include4K = false; + + if (filter.IsHD.HasValue && !filter.IsHD.Value) + { + includeSD = true; + } + + if (filter.IsHD.HasValue && filter.IsHD.Value) + { + includeHD = true; + } + + if (filter.Is4K.HasValue && filter.Is4K.Value) + { + include4K = true; + } + + baseQuery = baseQuery.Where(e => + (includeSD && e.Width < HDWidth) || + (includeHD && e.Width >= HDWidth && !(e.Width >= UHDWidth || e.Height >= UHDHeight)) || + (include4K && (e.Width >= UHDWidth || e.Height >= UHDHeight))); + } + + if (minWidth.HasValue) + { + baseQuery = baseQuery.Where(e => e.Width >= minWidth); + } + + if (filter.MinHeight.HasValue) + { + baseQuery = baseQuery.Where(e => e.Height >= filter.MinHeight); + } + + if (maxWidth.HasValue) + { + baseQuery = baseQuery.Where(e => e.Width >= maxWidth); + } + + if (filter.MaxHeight.HasValue) + { + baseQuery = baseQuery.Where(e => e.Height <= filter.MaxHeight); + } + + if (filter.IsLocked.HasValue) + { + baseQuery = baseQuery.Where(e => e.IsLocked == filter.IsLocked); + } + + var tags = filter.Tags.ToList(); + var excludeTags = filter.ExcludeTags.ToList(); + + if (filter.IsMovie == true) + { + if (filter.IncludeItemTypes.Length == 0 + || filter.IncludeItemTypes.Contains(BaseItemKind.Movie) + || filter.IncludeItemTypes.Contains(BaseItemKind.Trailer)) + { + baseQuery = baseQuery.Where(e => e.IsMovie); + } + } + else if (filter.IsMovie.HasValue) + { + baseQuery = baseQuery.Where(e => e.IsMovie == filter.IsMovie); + } + + if (filter.IsSeries.HasValue) + { + baseQuery = baseQuery.Where(e => e.IsSeries == filter.IsSeries); + } + + if (filter.IsSports.HasValue) + { + if (filter.IsSports.Value) + { + tags.Add("Sports"); + } + else + { + excludeTags.Add("Sports"); + } + } + + if (filter.IsNews.HasValue) + { + if (filter.IsNews.Value) + { + tags.Add("News"); + } + else + { + excludeTags.Add("News"); + } + } + + if (filter.IsKids.HasValue) + { + if (filter.IsKids.Value) + { + tags.Add("Kids"); + } + else + { + excludeTags.Add("Kids"); + } + } + + if (!string.IsNullOrEmpty(filter.SearchTerm)) + { + var searchTerm = filter.SearchTerm.ToLower(); + baseQuery = baseQuery.Where(e => e.CleanName!.ToLower().Contains(searchTerm) || (e.OriginalTitle != null && e.OriginalTitle.ToLower().Contains(searchTerm))); + } + + if (filter.IsFolder.HasValue) + { + baseQuery = baseQuery.Where(e => e.IsFolder == filter.IsFolder); + } + + var includeTypes = filter.IncludeItemTypes; + // Only specify excluded types if no included types are specified + if (filter.IncludeItemTypes.Length == 0) + { + var excludeTypes = filter.ExcludeItemTypes; + if (excludeTypes.Length == 1) + { + if (_itemTypeLookup.BaseItemKindNames.TryGetValue(excludeTypes[0], out var excludeTypeName)) + { + baseQuery = baseQuery.Where(e => e.Type != excludeTypeName); + } + } + else if (excludeTypes.Length > 1) + { + var excludeTypeName = new List(); + foreach (var excludeType in excludeTypes) + { + if (_itemTypeLookup.BaseItemKindNames.TryGetValue(excludeType, out var baseItemKindName)) + { + excludeTypeName.Add(baseItemKindName!); + } + } + + baseQuery = baseQuery.Where(e => !excludeTypeName.Contains(e.Type)); + } + } + else if (includeTypes.Length == 1) + { + if (_itemTypeLookup.BaseItemKindNames.TryGetValue(includeTypes[0], out var includeTypeName)) + { + baseQuery = baseQuery.Where(e => e.Type == includeTypeName); + } + } + else if (includeTypes.Length > 1) + { + var includeTypeName = new List(); + foreach (var includeType in includeTypes) + { + if (_itemTypeLookup.BaseItemKindNames.TryGetValue(includeType, out var baseItemKindName)) + { + includeTypeName.Add(baseItemKindName!); + } + } + + baseQuery = baseQuery.Where(e => includeTypeName.Contains(e.Type)); + } + + if (filter.ChannelIds.Count > 0) + { + baseQuery = baseQuery.Where(e => e.ChannelId != null && filter.ChannelIds.Contains(e.ChannelId.Value)); + } + + if (!filter.ParentId.IsEmpty()) + { + baseQuery = baseQuery.Where(e => e.ParentId!.Value == filter.ParentId); + } + + if (!string.IsNullOrWhiteSpace(filter.Path)) + { + baseQuery = baseQuery.Where(e => e.Path == filter.Path); + } + + if (!string.IsNullOrWhiteSpace(filter.PresentationUniqueKey)) + { + baseQuery = baseQuery.Where(e => e.PresentationUniqueKey == filter.PresentationUniqueKey); + } + + if (filter.MinCommunityRating.HasValue) + { + baseQuery = baseQuery.Where(e => e.CommunityRating >= filter.MinCommunityRating); + } + + if (filter.MinIndexNumber.HasValue) + { + baseQuery = baseQuery.Where(e => e.IndexNumber >= filter.MinIndexNumber); + } + + if (filter.MinParentAndIndexNumber.HasValue) + { + baseQuery = baseQuery + .Where(e => (e.ParentIndexNumber == filter.MinParentAndIndexNumber.Value.ParentIndexNumber && e.IndexNumber >= filter.MinParentAndIndexNumber.Value.IndexNumber) || e.ParentIndexNumber > filter.MinParentAndIndexNumber.Value.ParentIndexNumber); + } + + if (filter.MinDateCreated.HasValue) + { + baseQuery = baseQuery.Where(e => e.DateCreated >= filter.MinDateCreated); + } + + if (filter.MinDateLastSaved.HasValue) + { + baseQuery = baseQuery.Where(e => e.DateLastSaved != null && e.DateLastSaved >= filter.MinDateLastSaved.Value); + } + + if (filter.MinDateLastSavedForUser.HasValue) + { + baseQuery = baseQuery.Where(e => e.DateLastSaved != null && e.DateLastSaved >= filter.MinDateLastSavedForUser.Value); + } + + if (filter.IndexNumber.HasValue) + { + baseQuery = baseQuery.Where(e => e.IndexNumber == filter.IndexNumber.Value); + } + + if (filter.ParentIndexNumber.HasValue) + { + baseQuery = baseQuery.Where(e => e.ParentIndexNumber == filter.ParentIndexNumber.Value); + } + + if (filter.ParentIndexNumberNotEquals.HasValue) + { + baseQuery = baseQuery.Where(e => e.ParentIndexNumber != filter.ParentIndexNumberNotEquals.Value || e.ParentIndexNumber == null); + } + + var minEndDate = filter.MinEndDate; + var maxEndDate = filter.MaxEndDate; + + if (filter.HasAired.HasValue) + { + if (filter.HasAired.Value) + { + maxEndDate = DateTime.UtcNow; + } + else + { + minEndDate = DateTime.UtcNow; + } + } + + if (minEndDate.HasValue) + { + baseQuery = baseQuery.Where(e => e.EndDate >= minEndDate); + } + + if (maxEndDate.HasValue) + { + baseQuery = baseQuery.Where(e => e.EndDate <= maxEndDate); + } + + if (filter.MinStartDate.HasValue) + { + baseQuery = baseQuery.Where(e => e.StartDate >= filter.MinStartDate.Value); + } + + if (filter.MaxStartDate.HasValue) + { + baseQuery = baseQuery.Where(e => e.StartDate <= filter.MaxStartDate.Value); + } + + if (filter.MinPremiereDate.HasValue) + { + baseQuery = baseQuery.Where(e => e.PremiereDate <= filter.MinPremiereDate.Value); + } + + if (filter.MaxPremiereDate.HasValue) + { + baseQuery = baseQuery.Where(e => e.PremiereDate <= filter.MaxPremiereDate.Value); + } + + if (filter.TrailerTypes.Length > 0) + { + var trailerTypes = filter.TrailerTypes.Select(e => (int)e).ToArray(); + baseQuery = baseQuery.Where(e => trailerTypes.Any(f => e.TrailerTypes!.Any(w => w.Id == f))); + } + + if (filter.IsAiring.HasValue) + { + if (filter.IsAiring.Value) + { + baseQuery = baseQuery.Where(e => e.StartDate <= now && e.EndDate >= now); + } + else + { + baseQuery = baseQuery.Where(e => e.StartDate > now && e.EndDate < now); + } + } + + if (filter.PersonIds.Length > 0) + { + baseQuery = baseQuery + .Where(e => + context.PeopleBaseItemMap.Where(w => context.BaseItems.Where(r => filter.PersonIds.Contains(r.Id)).Any(f => f.Name == w.People.Name)) + .Any(f => f.ItemId == e.Id)); + } + + if (!string.IsNullOrWhiteSpace(filter.Person)) + { + baseQuery = baseQuery.Where(e => e.Peoples!.Any(f => f.People.Name == filter.Person)); + } + + if (!string.IsNullOrWhiteSpace(filter.MinSortName)) + { + // this does not makes sense. + // baseQuery = baseQuery.Where(e => e.SortName >= query.MinSortName); + // whereClauses.Add("SortName>=@MinSortName"); + // statement?.TryBind("@MinSortName", query.MinSortName); + } + + if (!string.IsNullOrWhiteSpace(filter.ExternalSeriesId)) + { + baseQuery = baseQuery.Where(e => e.ExternalSeriesId == filter.ExternalSeriesId); + } + + if (!string.IsNullOrWhiteSpace(filter.ExternalId)) + { + baseQuery = baseQuery.Where(e => e.ExternalId == filter.ExternalId); + } + + if (!string.IsNullOrWhiteSpace(filter.Name)) + { + var cleanName = GetCleanValue(filter.Name); + baseQuery = baseQuery.Where(e => e.CleanName == cleanName); + } + + // These are the same, for now + var nameContains = filter.NameContains; + if (!string.IsNullOrWhiteSpace(nameContains)) + { + baseQuery = baseQuery.Where(e => + e.CleanName!.Contains(nameContains) + || e.OriginalTitle!.ToLower().Contains(nameContains!)); + } + + if (!string.IsNullOrWhiteSpace(filter.NameStartsWith)) + { + baseQuery = baseQuery.Where(e => e.SortName!.StartsWith(filter.NameStartsWith) || e.Name!.StartsWith(filter.NameStartsWith)); + } + + if (!string.IsNullOrWhiteSpace(filter.NameStartsWithOrGreater)) + { + // i hate this + baseQuery = baseQuery.Where(e => e.SortName!.FirstOrDefault() > filter.NameStartsWithOrGreater[0] || e.Name!.FirstOrDefault() > filter.NameStartsWithOrGreater[0]); + } + + if (!string.IsNullOrWhiteSpace(filter.NameLessThan)) + { + // i hate this + baseQuery = baseQuery.Where(e => e.SortName!.FirstOrDefault() < filter.NameLessThan[0] || e.Name!.FirstOrDefault() < filter.NameLessThan[0]); + } + + if (filter.ImageTypes.Length > 0) + { + var imgTypes = filter.ImageTypes.Select(e => (ImageInfoImageType)e).ToArray(); + baseQuery = baseQuery.Where(e => imgTypes.Any(f => e.Images!.Any(w => w.ImageType == f))); + } + + if (filter.IsLiked.HasValue) + { + baseQuery = baseQuery + .Where(e => e.UserData!.FirstOrDefault(f => f.UserId == filter.User!.Id)!.Rating >= UserItemData.MinLikeValue); + } + + if (filter.IsFavoriteOrLiked.HasValue) + { + baseQuery = baseQuery + .Where(e => e.UserData!.FirstOrDefault(f => f.UserId == filter.User!.Id)!.IsFavorite == filter.IsFavoriteOrLiked); + } + + if (filter.IsFavorite.HasValue) + { + baseQuery = baseQuery + .Where(e => e.UserData!.FirstOrDefault(f => f.UserId == filter.User!.Id)!.IsFavorite == filter.IsFavorite); + } + + if (filter.IsPlayed.HasValue) + { + // We should probably figure this out for all folders, but for right now, this is the only place where we need it + if (filter.IncludeItemTypes.Length == 1 && filter.IncludeItemTypes[0] == BaseItemKind.Series) + { + baseQuery = baseQuery.Where(e => context.BaseItems + .Where(e => e.IsFolder == false && e.IsVirtualItem == false) + .Where(f => f.UserData!.FirstOrDefault(e => e.UserId == filter.User!.Id && e.Played)!.Played) + .Any(f => f.SeriesPresentationUniqueKey == e.PresentationUniqueKey) == filter.IsPlayed); + } + else + { + baseQuery = baseQuery + .Select(e => new + { + IsPlayed = e.UserData!.Where(f => f.UserId == filter.User!.Id).Select(f => (bool?)f.Played).FirstOrDefault() ?? false, + Item = e + }) + .Where(e => e.IsPlayed == filter.IsPlayed) + .Select(f => f.Item); + } + } + + if (filter.IsResumable.HasValue) + { + if (filter.IsResumable.Value) + { + baseQuery = baseQuery + .Where(e => e.UserData!.FirstOrDefault(f => f.UserId == filter.User!.Id)!.PlaybackPositionTicks > 0); + } + else + { + baseQuery = baseQuery + .Where(e => e.UserData!.FirstOrDefault(f => f.UserId == filter.User!.Id)!.PlaybackPositionTicks == 0); + } + } + + if (filter.ArtistIds.Length > 0) + { + baseQuery = baseQuery + .Where(e => e.ItemValues!.Any(f => f.ItemValue.Type <= ItemValueType.Artist && filter.ArtistIds.Contains(f.ItemId))); + } + + if (filter.AlbumArtistIds.Length > 0) + { + baseQuery = baseQuery + .Where(e => e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Artist && filter.AlbumArtistIds.Contains(f.ItemId))); + } + + if (filter.ContributingArtistIds.Length > 0) + { + baseQuery = baseQuery + .Where(e => e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Artist && filter.ContributingArtistIds.Contains(f.ItemId))); + } + + if (filter.AlbumIds.Length > 0) + { + baseQuery = baseQuery.Where(e => context.BaseItems.Where(f => filter.AlbumIds.Contains(f.Id)).Any(f => f.Name == e.Album)); + } + + if (filter.ExcludeArtistIds.Length > 0) + { + baseQuery = baseQuery + .Where(e => !e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Artist && filter.ExcludeArtistIds.Contains(f.ItemId))); + } + + if (filter.GenreIds.Count > 0) + { + baseQuery = baseQuery + .Where(e => e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Genre && filter.GenreIds.Contains(f.ItemId))); + } + + if (filter.Genres.Count > 0) + { + var cleanGenres = filter.Genres.Select(e => GetCleanValue(e)).ToArray(); + baseQuery = baseQuery + .Where(e => e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Genre && cleanGenres.Contains(f.ItemValue.CleanValue))); + } + + if (tags.Count > 0) + { + var cleanValues = tags.Select(e => GetCleanValue(e)).ToArray(); + baseQuery = baseQuery + .Where(e => e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && cleanValues.Contains(f.ItemValue.CleanValue))); + } + + if (excludeTags.Count > 0) + { + var cleanValues = excludeTags.Select(e => GetCleanValue(e)).ToArray(); + baseQuery = baseQuery + .Where(e => !e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && cleanValues.Contains(f.ItemValue.CleanValue))); + } + + if (filter.StudioIds.Length > 0) + { + baseQuery = baseQuery + .Where(e => e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Studios && filter.StudioIds.Contains(f.ItemId))); + } + + if (filter.OfficialRatings.Length > 0) + { + baseQuery = baseQuery + .Where(e => filter.OfficialRatings.Contains(e.OfficialRating)); + } + + Expression>? minParentalRatingFilter = null; + if (filter.MinParentalRating != null) + { + var min = filter.MinParentalRating; + minParentalRatingFilter = e => e.InheritedParentalRatingValue >= min.Score || e.InheritedParentalRatingValue == null; + if (min.SubScore != null) + { + minParentalRatingFilter = minParentalRatingFilter.And(e => e.InheritedParentalRatingValue >= min.SubScore || e.InheritedParentalRatingValue == null); + } + } + + Expression>? maxParentalRatingFilter = null; + if (filter.MaxParentalRating != null) + { + var max = filter.MaxParentalRating; + maxParentalRatingFilter = e => e.InheritedParentalRatingValue <= max.Score || e.InheritedParentalRatingValue == null; + if (max.SubScore != null) + { + maxParentalRatingFilter = maxParentalRatingFilter.And(e => e.InheritedParentalRatingValue <= max.SubScore || e.InheritedParentalRatingValue == null); + } + } + + if (filter.HasParentalRating ?? false) + { + if (minParentalRatingFilter != null) + { + baseQuery = baseQuery.Where(minParentalRatingFilter); + } + + if (maxParentalRatingFilter != null) + { + baseQuery = baseQuery.Where(maxParentalRatingFilter); + } + } + else if (filter.BlockUnratedItems.Length > 0) + { + var unratedItemTypes = filter.BlockUnratedItems.Select(f => f.ToString()).ToArray(); + Expression> unratedItemFilter = e => e.InheritedParentalRatingValue != null || !unratedItemTypes.Contains(e.UnratedType); + + if (minParentalRatingFilter != null && maxParentalRatingFilter != null) + { + baseQuery = baseQuery.Where(unratedItemFilter.And(minParentalRatingFilter.And(maxParentalRatingFilter))); + } + else if (minParentalRatingFilter != null) + { + baseQuery = baseQuery.Where(unratedItemFilter.And(minParentalRatingFilter)); + } + else if (maxParentalRatingFilter != null) + { + baseQuery = baseQuery.Where(unratedItemFilter.And(maxParentalRatingFilter)); + } + else + { + baseQuery = baseQuery.Where(unratedItemFilter); + } + } + else if (minParentalRatingFilter != null || maxParentalRatingFilter != null) + { + if (minParentalRatingFilter != null) + { + baseQuery = baseQuery.Where(minParentalRatingFilter); + } + + if (maxParentalRatingFilter != null) + { + baseQuery = baseQuery.Where(maxParentalRatingFilter); + } + } + else if (!filter.HasParentalRating ?? false) + { + baseQuery = baseQuery + .Where(e => e.InheritedParentalRatingValue == null); + } + + if (filter.HasOfficialRating.HasValue) + { + if (filter.HasOfficialRating.Value) + { + baseQuery = baseQuery + .Where(e => e.OfficialRating != null && e.OfficialRating != string.Empty); + } + else + { + baseQuery = baseQuery + .Where(e => e.OfficialRating == null || e.OfficialRating == string.Empty); + } + } + + if (filter.HasOverview.HasValue) + { + if (filter.HasOverview.Value) + { + baseQuery = baseQuery + .Where(e => e.Overview != null && e.Overview != string.Empty); + } + else + { + baseQuery = baseQuery + .Where(e => e.Overview == null || e.Overview == string.Empty); + } + } + + if (filter.HasOwnerId.HasValue) + { + if (filter.HasOwnerId.Value) + { + baseQuery = baseQuery + .Where(e => e.OwnerId != null); + } + else + { + baseQuery = baseQuery + .Where(e => e.OwnerId == null); + } + } + + if (!string.IsNullOrWhiteSpace(filter.HasNoAudioTrackWithLanguage)) + { + baseQuery = baseQuery + .Where(e => !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Audio && f.Language == filter.HasNoAudioTrackWithLanguage)); + } + + if (!string.IsNullOrWhiteSpace(filter.HasNoInternalSubtitleTrackWithLanguage)) + { + baseQuery = baseQuery + .Where(e => !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle && !f.IsExternal && f.Language == filter.HasNoInternalSubtitleTrackWithLanguage)); + } + + if (!string.IsNullOrWhiteSpace(filter.HasNoExternalSubtitleTrackWithLanguage)) + { + baseQuery = baseQuery + .Where(e => !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle && f.IsExternal && f.Language == filter.HasNoExternalSubtitleTrackWithLanguage)); + } + + if (!string.IsNullOrWhiteSpace(filter.HasNoSubtitleTrackWithLanguage)) + { + baseQuery = baseQuery + .Where(e => !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle && f.Language == filter.HasNoSubtitleTrackWithLanguage)); + } + + if (filter.HasSubtitles.HasValue) + { + baseQuery = baseQuery + .Where(e => e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle) == filter.HasSubtitles.Value); + } + + if (filter.HasChapterImages.HasValue) + { + baseQuery = baseQuery + .Where(e => e.Chapters!.Any(f => f.ImagePath != null) == filter.HasChapterImages.Value); + } + + if (filter.HasDeadParentId.HasValue && filter.HasDeadParentId.Value) + { + baseQuery = baseQuery + .Where(e => e.ParentId.HasValue && !context.BaseItems.Any(f => f.Id == e.ParentId.Value)); + } + + if (filter.IsDeadArtist.HasValue && filter.IsDeadArtist.Value) + { + baseQuery = baseQuery + .Where(e => !context.ItemValues.Where(f => _getAllArtistsValueTypes.Contains(f.Type)).Any(f => f.Value == e.Name)); + } + + if (filter.IsDeadStudio.HasValue && filter.IsDeadStudio.Value) + { + baseQuery = baseQuery + .Where(e => !context.ItemValues.Where(f => _getStudiosValueTypes.Contains(f.Type)).Any(f => f.Value == e.Name)); + } + + if (filter.IsDeadGenre.HasValue && filter.IsDeadGenre.Value) + { + baseQuery = baseQuery + .Where(e => !context.ItemValues.Where(f => _getGenreValueTypes.Contains(f.Type)).Any(f => f.Value == e.Name)); + } + + if (filter.IsDeadPerson.HasValue && filter.IsDeadPerson.Value) + { + baseQuery = baseQuery + .Where(e => !context.Peoples.Any(f => f.Name == e.Name)); + } + + if (filter.Years.Length == 1) + { + baseQuery = baseQuery + .Where(e => e.ProductionYear == filter.Years[0]); + } + else if (filter.Years.Length > 1) + { + baseQuery = baseQuery + .Where(e => filter.Years.Any(f => f == e.ProductionYear)); + } + + var isVirtualItem = filter.IsVirtualItem ?? filter.IsMissing; + if (isVirtualItem.HasValue) + { + baseQuery = baseQuery + .Where(e => e.IsVirtualItem == isVirtualItem.Value); + } + + if (filter.IsSpecialSeason.HasValue) + { + if (filter.IsSpecialSeason.Value) + { + baseQuery = baseQuery + .Where(e => e.IndexNumber == 0); + } + else + { + baseQuery = baseQuery + .Where(e => e.IndexNumber != 0); + } + } + + if (filter.IsUnaired.HasValue) + { + if (filter.IsUnaired.Value) + { + baseQuery = baseQuery + .Where(e => e.PremiereDate >= now); + } + else + { + baseQuery = baseQuery + .Where(e => e.PremiereDate < now); + } + } + + if (filter.MediaTypes.Length > 0) + { + var mediaTypes = filter.MediaTypes.Select(f => f.ToString()).ToArray(); + baseQuery = baseQuery + .Where(e => mediaTypes.Contains(e.MediaType)); + } + + if (filter.ItemIds.Length > 0) + { + baseQuery = baseQuery + .Where(e => filter.ItemIds.Contains(e.Id)); + } + + if (filter.ExcludeItemIds.Length > 0) + { + baseQuery = baseQuery + .Where(e => !filter.ItemIds.Contains(e.Id)); + } + + if (filter.ExcludeProviderIds is not null && filter.ExcludeProviderIds.Count > 0) + { + baseQuery = baseQuery.Where(e => !e.Provider!.All(f => !filter.ExcludeProviderIds.All(w => f.ProviderId == w.Key && f.ProviderValue == w.Value))); + } + + if (filter.HasAnyProviderId is not null && filter.HasAnyProviderId.Count > 0) + { + baseQuery = baseQuery.Where(e => e.Provider!.Any(f => !filter.HasAnyProviderId.Any(w => f.ProviderId == w.Key && f.ProviderValue == w.Value))); + } + + if (filter.HasImdbId.HasValue) + { + baseQuery = baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId == "imdb")); + } + + if (filter.HasTmdbId.HasValue) + { + baseQuery = baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId == "tmdb")); + } + + if (filter.HasTvdbId.HasValue) + { + baseQuery = baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId == "tvdb")); + } + + var queryTopParentIds = filter.TopParentIds; + + if (queryTopParentIds.Length > 0) + { + var includedItemByNameTypes = GetItemByNameTypesInQuery(filter); + var enableItemsByName = (filter.IncludeItemsByName ?? false) && includedItemByNameTypes.Count > 0; + if (enableItemsByName && includedItemByNameTypes.Count > 0) + { + baseQuery = baseQuery.Where(e => includedItemByNameTypes.Contains(e.Type) || queryTopParentIds.Any(w => w == e.TopParentId!.Value)); + } + else + { + baseQuery = baseQuery.Where(e => queryTopParentIds.Contains(e.TopParentId!.Value)); + } + } + + if (filter.AncestorIds.Length > 0) + { + baseQuery = baseQuery.Where(e => e.Children!.Any(f => filter.AncestorIds.Contains(f.ParentItemId))); + } + + if (!string.IsNullOrWhiteSpace(filter.AncestorWithPresentationUniqueKey)) + { + baseQuery = baseQuery + .Where(e => context.BaseItems.Where(f => f.PresentationUniqueKey == filter.AncestorWithPresentationUniqueKey).Any(f => f.Children!.Any(w => w.ItemId == e.Id))); + } + + if (!string.IsNullOrWhiteSpace(filter.SeriesPresentationUniqueKey)) + { + baseQuery = baseQuery + .Where(e => e.SeriesPresentationUniqueKey == filter.SeriesPresentationUniqueKey); + } + + if (filter.ExcludeInheritedTags.Length > 0) + { + baseQuery = baseQuery + .Where(e => !e.ItemValues!.Where(w => w.ItemValue.Type == ItemValueType.InheritedTags) + .Any(f => filter.ExcludeInheritedTags.Contains(f.ItemValue.CleanValue))); + } + + if (filter.IncludeInheritedTags.Length > 0) + { + // Episodes do not store inherit tags from their parents in the database, and the tag may be still required by the client. + // In addition to the tags for the episodes themselves, we need to manually query its parent (the season)'s tags as well. + if (includeTypes.Length == 1 && includeTypes.FirstOrDefault() is BaseItemKind.Episode) + { + baseQuery = baseQuery + .Where(e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.InheritedTags) + .Any(f => filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue)) + || + (e.ParentId.HasValue && context.ItemValuesMap.Where(w => w.ItemId == e.ParentId.Value)!.Where(w => w.ItemValue.Type == ItemValueType.InheritedTags) + .Any(f => filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue)))); + } + + // A playlist should be accessible to its owner regardless of allowed tags. + else if (includeTypes.Length == 1 && includeTypes.FirstOrDefault() is BaseItemKind.Playlist) + { + baseQuery = baseQuery + .Where(e => + e.Parents! + .Any(f => + f.ParentItem.ItemValues!.Any(w => w.ItemValue.Type == ItemValueType.Tags && filter.IncludeInheritedTags.Contains(w.ItemValue.CleanValue)) + || e.Data!.Contains($"OwnerUserId\":\"{filter.User!.Id:N}\""))); + // d ^^ this is stupid it hate this. + } + else + { + baseQuery = baseQuery + .Where(e => e.Parents!.Any(f => f.ParentItem.ItemValues!.Any(w => w.ItemValue.Type == ItemValueType.Tags && filter.IncludeInheritedTags.Contains(w.ItemValue.CleanValue)))); + } + } + + if (filter.SeriesStatuses.Length > 0) + { + var seriesStatus = filter.SeriesStatuses.Select(e => e.ToString()).ToArray(); + baseQuery = baseQuery + .Where(e => seriesStatus.Any(f => e.Data!.Contains(f))); + } + + if (filter.BoxSetLibraryFolders.Length > 0) + { + var boxsetFolders = filter.BoxSetLibraryFolders.Select(e => e.ToString("N", CultureInfo.InvariantCulture)).ToArray(); + baseQuery = baseQuery + .Where(e => boxsetFolders.Any(f => e.Data!.Contains(f))); + } + + if (filter.VideoTypes.Length > 0) + { + var videoTypeBs = filter.VideoTypes.Select(e => $"\"VideoType\":\"{e}\""); + baseQuery = baseQuery + .Where(e => videoTypeBs.Any(f => e.Data!.Contains(f))); + } + + if (filter.Is3D.HasValue) + { + if (filter.Is3D.Value) + { + baseQuery = baseQuery + .Where(e => e.Data!.Contains("Video3DFormat")); + } + else + { + baseQuery = baseQuery + .Where(e => !e.Data!.Contains("Video3DFormat")); + } + } + + if (filter.IsPlaceHolder.HasValue) + { + if (filter.IsPlaceHolder.Value) + { + baseQuery = baseQuery + .Where(e => e.Data!.Contains("IsPlaceHolder\":true")); + } + else + { + baseQuery = baseQuery + .Where(e => !e.Data!.Contains("IsPlaceHolder\":true")); + } + } + + if (filter.HasSpecialFeature.HasValue) + { + if (filter.HasSpecialFeature.Value) + { + baseQuery = baseQuery + .Where(e => e.ExtraIds != null); + } + else + { + baseQuery = baseQuery + .Where(e => e.ExtraIds == null); + } + } + + if (filter.HasTrailer.HasValue || filter.HasThemeSong.HasValue || filter.HasThemeVideo.HasValue) + { + if (filter.HasTrailer.GetValueOrDefault() || filter.HasThemeSong.GetValueOrDefault() || filter.HasThemeVideo.GetValueOrDefault()) + { + baseQuery = baseQuery + .Where(e => e.ExtraIds != null); + } + else + { + baseQuery = baseQuery + .Where(e => e.ExtraIds == null); + } + } + + return baseQuery; + } +} diff --git a/Jellyfin.Server.Implementations/Item/ChapterRepository.cs b/Jellyfin.Server.Implementations/Item/ChapterRepository.cs new file mode 100644 index 0000000000..93e15735c9 --- /dev/null +++ b/Jellyfin.Server.Implementations/Item/ChapterRepository.cs @@ -0,0 +1,124 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using MediaBrowser.Controller.Chapters; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Jellyfin.Server.Implementations.Item; + +/// +/// The Chapter manager. +/// +public class ChapterRepository : IChapterRepository +{ + private readonly IDbContextFactory _dbProvider; + private readonly IImageProcessor _imageProcessor; + + /// + /// Initializes a new instance of the class. + /// + /// The EFCore provider. + /// The Image Processor. + public ChapterRepository(IDbContextFactory dbProvider, IImageProcessor imageProcessor) + { + _dbProvider = dbProvider; + _imageProcessor = imageProcessor; + } + + /// + public ChapterInfo? GetChapter(BaseItemDto baseItem, int index) + { + return GetChapter(baseItem.Id, index); + } + + /// + public IReadOnlyList GetChapters(BaseItemDto baseItem) + { + return GetChapters(baseItem.Id); + } + + /// + public ChapterInfo? GetChapter(Guid baseItemId, int index) + { + using var context = _dbProvider.CreateDbContext(); + var chapter = context.Chapters.AsNoTracking() + .Select(e => new + { + chapter = e, + baseItemPath = e.Item.Path + }) + .FirstOrDefault(e => e.chapter.ItemId.Equals(baseItemId) && e.chapter.ChapterIndex == index); + if (chapter is not null) + { + return Map(chapter.chapter, chapter.baseItemPath!); + } + + return null; + } + + /// + public IReadOnlyList GetChapters(Guid baseItemId) + { + using var context = _dbProvider.CreateDbContext(); + return context.Chapters.AsNoTracking().Where(e => e.ItemId.Equals(baseItemId)) + .Select(e => new + { + chapter = e, + baseItemPath = e.Item.Path + }) + .AsEnumerable() + .Select(e => Map(e.chapter, e.baseItemPath!)) + .ToArray(); + } + + /// + public void SaveChapters(Guid itemId, IReadOnlyList chapters) + { + using var context = _dbProvider.CreateDbContext(); + using (var transaction = context.Database.BeginTransaction()) + { + context.Chapters.Where(e => e.ItemId.Equals(itemId)).ExecuteDelete(); + for (var i = 0; i < chapters.Count; i++) + { + var chapter = chapters[i]; + context.Chapters.Add(Map(chapter, i, itemId)); + } + + context.SaveChanges(); + transaction.Commit(); + } + } + + private Chapter Map(ChapterInfo chapterInfo, int index, Guid itemId) + { + return new Chapter() + { + ChapterIndex = index, + StartPositionTicks = chapterInfo.StartPositionTicks, + ImageDateModified = chapterInfo.ImageDateModified, + ImagePath = chapterInfo.ImagePath, + ItemId = itemId, + Name = chapterInfo.Name, + Item = null! + }; + } + + private ChapterInfo Map(Chapter chapterInfo, string baseItemPath) + { + var chapterEntity = new ChapterInfo() + { + StartPositionTicks = chapterInfo.StartPositionTicks, + ImageDateModified = chapterInfo.ImageDateModified.GetValueOrDefault(), + ImagePath = chapterInfo.ImagePath, + Name = chapterInfo.Name, + }; + chapterEntity.ImageTag = _imageProcessor.GetImageCacheTag(baseItemPath, chapterEntity.ImageDateModified); + return chapterEntity; + } +} diff --git a/Jellyfin.Server.Implementations/Item/KeyframeRepository.cs b/Jellyfin.Server.Implementations/Item/KeyframeRepository.cs new file mode 100644 index 0000000000..a2267700fb --- /dev/null +++ b/Jellyfin.Server.Implementations/Item/KeyframeRepository.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using MediaBrowser.Controller.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace Jellyfin.Server.Implementations.Item; + +/// +/// Repository for obtaining Keyframe data. +/// +public class KeyframeRepository : IKeyframeRepository +{ + private readonly IDbContextFactory _dbProvider; + + /// + /// Initializes a new instance of the class. + /// + /// The EFCore db factory. + public KeyframeRepository(IDbContextFactory dbProvider) + { + _dbProvider = dbProvider; + } + + private static MediaEncoding.Keyframes.KeyframeData Map(KeyframeData entity) + { + return new MediaEncoding.Keyframes.KeyframeData( + entity.TotalDuration, + (entity.KeyframeTicks ?? []).ToList()); + } + + private KeyframeData Map(MediaEncoding.Keyframes.KeyframeData dto, Guid itemId) + { + return new() + { + ItemId = itemId, + TotalDuration = dto.TotalDuration, + KeyframeTicks = dto.KeyframeTicks.ToList() + }; + } + + /// + public IReadOnlyList GetKeyframeData(Guid itemId) + { + using var context = _dbProvider.CreateDbContext(); + + return context.KeyframeData.AsNoTracking().Where(e => e.ItemId.Equals(itemId)).Select(e => Map(e)).ToList(); + } + + /// + public async Task SaveKeyframeDataAsync(Guid itemId, MediaEncoding.Keyframes.KeyframeData data, CancellationToken cancellationToken) + { + using var context = _dbProvider.CreateDbContext(); + using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); + await context.KeyframeData.Where(e => e.ItemId.Equals(itemId)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); + await context.KeyframeData.AddAsync(Map(data, itemId), cancellationToken).ConfigureAwait(false); + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + } +} diff --git a/Jellyfin.Server.Implementations/Item/MediaAttachmentRepository.cs b/Jellyfin.Server.Implementations/Item/MediaAttachmentRepository.cs new file mode 100644 index 0000000000..3ae6dbd702 --- /dev/null +++ b/Jellyfin.Server.Implementations/Item/MediaAttachmentRepository.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Model.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Jellyfin.Server.Implementations.Item; + +/// +/// Manager for handling Media Attachments. +/// +/// Efcore Factory. +public class MediaAttachmentRepository(IDbContextFactory dbProvider) : IMediaAttachmentRepository +{ + /// + public void SaveMediaAttachments( + Guid id, + IReadOnlyList attachments, + CancellationToken cancellationToken) + { + using var context = dbProvider.CreateDbContext(); + using var transaction = context.Database.BeginTransaction(); + context.AttachmentStreamInfos.Where(e => e.ItemId.Equals(id)).ExecuteDelete(); + context.AttachmentStreamInfos.AddRange(attachments.Select(e => Map(e, id))); + context.SaveChanges(); + transaction.Commit(); + } + + /// + public IReadOnlyList GetMediaAttachments(MediaAttachmentQuery filter) + { + using var context = dbProvider.CreateDbContext(); + var query = context.AttachmentStreamInfos.AsNoTracking().Where(e => e.ItemId.Equals(filter.ItemId)); + if (filter.Index.HasValue) + { + query = query.Where(e => e.Index == filter.Index); + } + + return query.AsEnumerable().Select(Map).ToArray(); + } + + private MediaAttachment Map(AttachmentStreamInfo attachment) + { + return new MediaAttachment() + { + Codec = attachment.Codec, + CodecTag = attachment.CodecTag, + Comment = attachment.Comment, + FileName = attachment.Filename, + Index = attachment.Index, + MimeType = attachment.MimeType, + }; + } + + private AttachmentStreamInfo Map(MediaAttachment attachment, Guid id) + { + return new AttachmentStreamInfo() + { + Codec = attachment.Codec, + CodecTag = attachment.CodecTag, + Comment = attachment.Comment, + Filename = attachment.FileName, + Index = attachment.Index, + MimeType = attachment.MimeType, + ItemId = id, + Item = null! + }; + } +} diff --git a/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs b/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs new file mode 100644 index 0000000000..7eb13b7408 --- /dev/null +++ b/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs @@ -0,0 +1,227 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Globalization; +using Microsoft.EntityFrameworkCore; + +namespace Jellyfin.Server.Implementations.Item; + +/// +/// Repository for obtaining MediaStreams. +/// +public class MediaStreamRepository : IMediaStreamRepository +{ + private readonly IDbContextFactory _dbProvider; + private readonly IServerApplicationHost _serverApplicationHost; + private readonly ILocalizationManager _localization; + + /// + /// Initializes a new instance of the class. + /// + /// The EFCore db factory. + /// The Application host. + /// The Localisation Provider. + public MediaStreamRepository(IDbContextFactory dbProvider, IServerApplicationHost serverApplicationHost, ILocalizationManager localization) + { + _dbProvider = dbProvider; + _serverApplicationHost = serverApplicationHost; + _localization = localization; + } + + /// + public void SaveMediaStreams(Guid id, IReadOnlyList streams, CancellationToken cancellationToken) + { + using var context = _dbProvider.CreateDbContext(); + using var transaction = context.Database.BeginTransaction(); + + context.MediaStreamInfos.Where(e => e.ItemId.Equals(id)).ExecuteDelete(); + context.MediaStreamInfos.AddRange(streams.Select(f => Map(f, id))); + context.SaveChanges(); + + transaction.Commit(); + } + + /// + public IReadOnlyList GetMediaStreams(MediaStreamQuery filter) + { + using var context = _dbProvider.CreateDbContext(); + return TranslateQuery(context.MediaStreamInfos.AsNoTracking(), filter).AsEnumerable().Select(Map).ToArray(); + } + + private string? GetPathToSave(string? path) + { + if (path is null) + { + return null; + } + + return _serverApplicationHost.ReverseVirtualPath(path); + } + + private string? RestorePath(string? path) + { + if (path is null) + { + return null; + } + + return _serverApplicationHost.ExpandVirtualPath(path); + } + + private IQueryable TranslateQuery(IQueryable query, MediaStreamQuery filter) + { + query = query.Where(e => e.ItemId.Equals(filter.ItemId)); + if (filter.Index.HasValue) + { + query = query.Where(e => e.StreamIndex == filter.Index); + } + + if (filter.Type.HasValue) + { + var typeValue = (MediaStreamTypeEntity)filter.Type.Value; + query = query.Where(e => e.StreamType == typeValue); + } + + return query.OrderBy(e => e.StreamIndex); + } + + private MediaStream Map(MediaStreamInfo entity) + { + var dto = new MediaStream(); + dto.Index = entity.StreamIndex; + dto.Type = (MediaStreamType)entity.StreamType; + + dto.IsAVC = entity.IsAvc; + dto.Codec = entity.Codec; + + var language = entity.Language; + + // Check if the language has multiple three letter ISO codes + // if yes choose the first as that is the ISO 639-2/T code we're needing + if (language != null && _localization.TryGetISO6392TFromB(language, out string? isoT)) + { + language = isoT; + } + + dto.Language = language; + + dto.ChannelLayout = entity.ChannelLayout; + dto.Profile = entity.Profile; + dto.AspectRatio = entity.AspectRatio; + dto.Path = RestorePath(entity.Path); + dto.IsInterlaced = entity.IsInterlaced.GetValueOrDefault(); + dto.BitRate = entity.BitRate; + dto.Channels = entity.Channels; + dto.SampleRate = entity.SampleRate; + dto.IsDefault = entity.IsDefault; + dto.IsForced = entity.IsForced; + dto.IsExternal = entity.IsExternal; + dto.Height = entity.Height; + dto.Width = entity.Width; + dto.AverageFrameRate = entity.AverageFrameRate; + dto.RealFrameRate = entity.RealFrameRate; + dto.Level = entity.Level; + dto.PixelFormat = entity.PixelFormat; + dto.BitDepth = entity.BitDepth; + dto.IsAnamorphic = entity.IsAnamorphic; + dto.RefFrames = entity.RefFrames; + dto.CodecTag = entity.CodecTag; + dto.Comment = entity.Comment; + dto.NalLengthSize = entity.NalLengthSize; + dto.Title = entity.Title; + dto.TimeBase = entity.TimeBase; + dto.CodecTimeBase = entity.CodecTimeBase; + dto.ColorPrimaries = entity.ColorPrimaries; + dto.ColorSpace = entity.ColorSpace; + dto.ColorTransfer = entity.ColorTransfer; + dto.DvVersionMajor = entity.DvVersionMajor; + dto.DvVersionMinor = entity.DvVersionMinor; + dto.DvProfile = entity.DvProfile; + dto.DvLevel = entity.DvLevel; + dto.RpuPresentFlag = entity.RpuPresentFlag; + dto.ElPresentFlag = entity.ElPresentFlag; + dto.BlPresentFlag = entity.BlPresentFlag; + dto.DvBlSignalCompatibilityId = entity.DvBlSignalCompatibilityId; + dto.IsHearingImpaired = entity.IsHearingImpaired.GetValueOrDefault(); + dto.Rotation = entity.Rotation; + dto.Hdr10PlusPresentFlag = entity.Hdr10PlusPresentFlag; + + if (dto.Type is MediaStreamType.Audio or MediaStreamType.Subtitle) + { + dto.LocalizedDefault = _localization.GetLocalizedString("Default"); + dto.LocalizedExternal = _localization.GetLocalizedString("External"); + + if (dto.Type is MediaStreamType.Subtitle) + { + dto.LocalizedUndefined = _localization.GetLocalizedString("Undefined"); + dto.LocalizedForced = _localization.GetLocalizedString("Forced"); + dto.LocalizedHearingImpaired = _localization.GetLocalizedString("HearingImpaired"); + } + } + + return dto; + } + + private MediaStreamInfo Map(MediaStream dto, Guid itemId) + { + var entity = new MediaStreamInfo + { + Item = null!, + ItemId = itemId, + StreamIndex = dto.Index, + StreamType = (MediaStreamTypeEntity)dto.Type, + IsAvc = dto.IsAVC, + + Codec = dto.Codec, + Language = dto.Language, + ChannelLayout = dto.ChannelLayout, + Profile = dto.Profile, + AspectRatio = dto.AspectRatio, + Path = GetPathToSave(dto.Path) ?? dto.Path, + IsInterlaced = dto.IsInterlaced, + BitRate = dto.BitRate, + Channels = dto.Channels, + SampleRate = dto.SampleRate, + IsDefault = dto.IsDefault, + IsForced = dto.IsForced, + IsExternal = dto.IsExternal, + Height = dto.Height, + Width = dto.Width, + AverageFrameRate = dto.AverageFrameRate, + RealFrameRate = dto.RealFrameRate, + Level = dto.Level.HasValue ? (float)dto.Level : null, + PixelFormat = dto.PixelFormat, + BitDepth = dto.BitDepth, + IsAnamorphic = dto.IsAnamorphic, + RefFrames = dto.RefFrames, + CodecTag = dto.CodecTag, + Comment = dto.Comment, + NalLengthSize = dto.NalLengthSize, + Title = dto.Title, + TimeBase = dto.TimeBase, + CodecTimeBase = dto.CodecTimeBase, + ColorPrimaries = dto.ColorPrimaries, + ColorSpace = dto.ColorSpace, + ColorTransfer = dto.ColorTransfer, + DvVersionMajor = dto.DvVersionMajor, + DvVersionMinor = dto.DvVersionMinor, + DvProfile = dto.DvProfile, + DvLevel = dto.DvLevel, + RpuPresentFlag = dto.RpuPresentFlag, + ElPresentFlag = dto.ElPresentFlag, + BlPresentFlag = dto.BlPresentFlag, + DvBlSignalCompatibilityId = dto.DvBlSignalCompatibilityId, + IsHearingImpaired = dto.IsHearingImpaired, + Rotation = dto.Rotation, + Hdr10PlusPresentFlag = dto.Hdr10PlusPresentFlag, + }; + return entity; + } +} diff --git a/Jellyfin.Server.Implementations/Item/OrderMapper.cs b/Jellyfin.Server.Implementations/Item/OrderMapper.cs new file mode 100644 index 0000000000..03249b9274 --- /dev/null +++ b/Jellyfin.Server.Implementations/Item/OrderMapper.cs @@ -0,0 +1,57 @@ +using System; +using System.Linq; +using System.Linq.Expressions; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using MediaBrowser.Controller.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Jellyfin.Server.Implementations.Item; + +/// +/// Static class for methods which maps types of ordering to their respecting ordering functions. +/// +public static class OrderMapper +{ + /// + /// Creates Func to be executed later with a given BaseItemEntity input for sorting items on query. + /// + /// Item property to sort by. + /// Context Query. + /// Func to be executed later for sorting query. + public static Expression> MapOrderByField(ItemSortBy sortBy, InternalItemsQuery query) + { + return sortBy switch + { + ItemSortBy.AirTime => e => e.SortName, // TODO + ItemSortBy.Runtime => e => e.RunTimeTicks, + ItemSortBy.Random => e => EF.Functions.Random(), + ItemSortBy.DatePlayed => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.LastPlayedDate, + ItemSortBy.PlayCount => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.PlayCount, + ItemSortBy.IsFavoriteOrLiked => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.IsFavorite, + ItemSortBy.IsFolder => e => e.IsFolder, + ItemSortBy.IsPlayed => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.Played, + ItemSortBy.IsUnplayed => e => !e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.Played, + ItemSortBy.DateLastContentAdded => e => e.DateLastMediaAdded, + ItemSortBy.Artist => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.Artist).Select(f => f.ItemValue.CleanValue).FirstOrDefault(), + ItemSortBy.AlbumArtist => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.AlbumArtist).Select(f => f.ItemValue.CleanValue).FirstOrDefault(), + ItemSortBy.Studio => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.Studios).Select(f => f.ItemValue.CleanValue).FirstOrDefault(), + ItemSortBy.OfficialRating => e => e.InheritedParentalRatingValue, + // ItemSortBy.SeriesDatePlayed => "(Select MAX(LastPlayedDate) from TypedBaseItems B" + GetJoinUserDataText(query) + " where Played=1 and B.SeriesPresentationUniqueKey=A.PresentationUniqueKey)", + ItemSortBy.SeriesSortName => e => e.SeriesName, + // ItemSortBy.AiredEpisodeOrder => "AiredEpisodeOrder", + ItemSortBy.Album => e => e.Album, + ItemSortBy.DateCreated => e => e.DateCreated, + ItemSortBy.PremiereDate => e => (e.PremiereDate ?? (e.ProductionYear.HasValue ? DateTime.MinValue.AddYears(e.ProductionYear.Value - 1) : null)), + ItemSortBy.StartDate => e => e.StartDate, + ItemSortBy.Name => e => e.Name, + ItemSortBy.CommunityRating => e => e.CommunityRating, + ItemSortBy.ProductionYear => e => e.ProductionYear, + ItemSortBy.CriticRating => e => e.CriticRating, + ItemSortBy.VideoBitRate => e => e.TotalBitrate, + ItemSortBy.ParentIndexNumber => e => e.ParentIndexNumber, + ItemSortBy.IndexNumber => e => e.IndexNumber, + _ => e => e.SortName + }; + } +} diff --git a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs new file mode 100644 index 0000000000..77877835e0 --- /dev/null +++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs @@ -0,0 +1,200 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Extensions; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace Jellyfin.Server.Implementations.Item; +#pragma warning disable RS0030 // Do not use banned APIs +#pragma warning disable CA1304 // Specify CultureInfo +#pragma warning disable CA1311 // Specify a culture or use an invariant version +#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons + +/// +/// Manager for handling people. +/// +/// Efcore Factory. +/// Items lookup service. +/// +/// Initializes a new instance of the class. +/// +public class PeopleRepository(IDbContextFactory dbProvider, IItemTypeLookup itemTypeLookup) : IPeopleRepository +{ + private readonly IDbContextFactory _dbProvider = dbProvider; + + /// + public IReadOnlyList GetPeople(InternalPeopleQuery filter) + { + using var context = _dbProvider.CreateDbContext(); + var dbQuery = TranslateQuery(context.Peoples.AsNoTracking(), context, filter); + + // dbQuery = dbQuery.OrderBy(e => e.ListOrder); + if (filter.Limit > 0) + { + dbQuery = dbQuery.Take(filter.Limit); + } + + // Include PeopleBaseItemMap + if (!filter.ItemId.IsEmpty()) + { + dbQuery = dbQuery.Include(p => p.BaseItems!.Where(m => m.ItemId == filter.ItemId)); + } + + return dbQuery.AsEnumerable().Select(Map).ToArray(); + } + + /// + public IReadOnlyList GetPeopleNames(InternalPeopleQuery filter) + { + using var context = _dbProvider.CreateDbContext(); + var dbQuery = TranslateQuery(context.Peoples.AsNoTracking(), context, filter); + + // dbQuery = dbQuery.OrderBy(e => e.ListOrder); + if (filter.Limit > 0) + { + dbQuery = dbQuery.Take(filter.Limit); + } + + return dbQuery.Select(e => e.Name).ToArray(); + } + + /// + public void UpdatePeople(Guid itemId, IReadOnlyList people) + { + using var context = _dbProvider.CreateDbContext(); + using var transaction = context.Database.BeginTransaction(); + + context.PeopleBaseItemMap.Where(e => e.ItemId == itemId).ExecuteDelete(); + // TODO: yes for __SOME__ reason there can be duplicates. + foreach (var item in people.DistinctBy(e => e.Id)) + { + var personEntity = Map(item); + var existingEntity = context.Peoples.FirstOrDefault(e => e.Id == personEntity.Id); + if (existingEntity is null) + { + context.Peoples.Add(personEntity); + existingEntity = personEntity; + } + + context.PeopleBaseItemMap.Add(new PeopleBaseItemMap() + { + Item = null!, + ItemId = itemId, + People = existingEntity, + PeopleId = existingEntity.Id, + ListOrder = item.SortOrder, + SortOrder = item.SortOrder, + Role = item.Role + }); + } + + context.SaveChanges(); + transaction.Commit(); + } + + private PersonInfo Map(People people) + { + var mapping = people.BaseItems?.FirstOrDefault(); + var personInfo = new PersonInfo() + { + Id = people.Id, + Name = people.Name, + Role = mapping?.Role, + SortOrder = mapping?.SortOrder + }; + if (Enum.TryParse(people.PersonType, out var kind)) + { + personInfo.Type = kind; + } + + return personInfo; + } + + private People Map(PersonInfo people) + { + var personInfo = new People() + { + Name = people.Name, + PersonType = people.Type.ToString(), + Id = people.Id, + }; + + return personInfo; + } + + private IQueryable TranslateQuery(IQueryable query, JellyfinDbContext context, InternalPeopleQuery filter) + { + if (filter.User is not null && filter.IsFavorite.HasValue) + { + var personType = itemTypeLookup.BaseItemKindNames[BaseItemKind.Person]; + query = query.Where(e => e.PersonType == personType) + .Where(e => context.BaseItems.Where(d => d.UserData!.Any(w => w.IsFavorite == filter.IsFavorite && w.UserId.Equals(filter.User.Id))) + .Select(f => f.Name).Contains(e.Name)); + } + + if (!filter.ItemId.IsEmpty()) + { + query = query.Where(e => e.BaseItems!.Any(w => w.ItemId.Equals(filter.ItemId))); + } + + if (!filter.AppearsInItemId.IsEmpty()) + { + query = query.Where(e => e.BaseItems!.Any(w => w.ItemId.Equals(filter.AppearsInItemId))); + } + + var queryPersonTypes = filter.PersonTypes.Where(IsValidPersonType).ToList(); + if (queryPersonTypes.Count > 0) + { + query = query.Where(e => queryPersonTypes.Contains(e.PersonType)); + } + + var queryExcludePersonTypes = filter.ExcludePersonTypes.Where(IsValidPersonType).ToList(); + + if (queryExcludePersonTypes.Count > 0) + { + query = query.Where(e => !queryPersonTypes.Contains(e.PersonType)); + } + + if (filter.MaxListOrder.HasValue && !filter.ItemId.IsEmpty()) + { + query = query.Where(e => e.BaseItems!.First(w => w.ItemId == filter.ItemId).ListOrder <= filter.MaxListOrder.Value); + } + + if (!string.IsNullOrWhiteSpace(filter.NameContains)) + { + var nameContainsUpper = filter.NameContains.ToUpper(); + query = query.Where(e => e.Name.ToUpper().Contains(nameContainsUpper)); + } + + return query; + } + + private bool IsAlphaNumeric(string str) + { + if (string.IsNullOrWhiteSpace(str)) + { + return false; + } + + for (int i = 0; i < str.Length; i++) + { + if (!char.IsLetter(str[i]) && !char.IsNumber(str[i])) + { + return false; + } + } + + return true; + } + + private bool IsValidPersonType(string value) + { + return IsAlphaNumeric(value); + } +} diff --git a/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj b/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj index 20944ee4b2..6693ab8dbd 100644 --- a/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj +++ b/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj @@ -1,7 +1,7 @@ - net8.0 + net9.0 false true @@ -28,22 +28,15 @@ - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - + + diff --git a/Jellyfin.Server.Implementations/MediaSegments/MediaSegmentManager.cs b/Jellyfin.Server.Implementations/MediaSegments/MediaSegmentManager.cs index d641f521b9..d6eeafacc3 100644 --- a/Jellyfin.Server.Implementations/MediaSegments/MediaSegmentManager.cs +++ b/Jellyfin.Server.Implementations/MediaSegments/MediaSegmentManager.cs @@ -5,8 +5,9 @@ using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Data.Entities; -using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller; @@ -22,7 +23,7 @@ using Microsoft.Extensions.Logging; namespace Jellyfin.Server.Implementations.MediaSegments; /// -/// Manages media segments retrival and storage. +/// Manages media segments retrieval and storage. /// public class MediaSegmentManager : IMediaSegmentManager { @@ -61,7 +62,7 @@ public class MediaSegmentManager : IMediaSegmentManager .Where(e => !libraryOptions.DisabledMediaSegmentProviders.Contains(GetProviderId(e.Name))) .OrderBy(i => { - var index = libraryOptions.MediaSegmentProvideOrder.IndexOf(i.Name); + var index = libraryOptions.MediaSegmentProviderOrder.IndexOf(i.Name); return index == -1 ? int.MaxValue : index; }) .ToList(); @@ -139,23 +140,53 @@ public class MediaSegmentManager : IMediaSegmentManager } /// - public async Task> GetSegmentsAsync(Guid itemId, IEnumerable? typeFilter) + public async Task> GetSegmentsAsync(Guid itemId, IEnumerable? typeFilter, bool filterByProvider = true) + { + var baseItem = _libraryManager.GetItemById(itemId); + + if (baseItem is null) + { + _logger.LogError("Tried to request segments for an invalid item"); + return []; + } + + return await GetSegmentsAsync(baseItem, typeFilter, filterByProvider).ConfigureAwait(false); + } + + /// + public async Task> GetSegmentsAsync(BaseItem item, IEnumerable? typeFilter, bool filterByProvider = true) { using var db = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); var query = db.MediaSegments - .Where(e => e.ItemId.Equals(itemId)); + .Where(e => e.ItemId.Equals(item.Id)); if (typeFilter is not null) { query = query.Where(e => typeFilter.Contains(e.Type)); } + if (filterByProvider) + { + var libraryOptions = _libraryManager.GetLibraryOptions(item); + var providerIds = _segmentProviders + .Where(e => !libraryOptions.DisabledMediaSegmentProviders.Contains(GetProviderId(e.Name))) + .Select(f => GetProviderId(f.Name)) + .ToArray(); + if (providerIds.Length == 0) + { + return []; + } + + query = query.Where(e => providerIds.Contains(e.SegmentProviderId)); + } + return query .OrderBy(e => e.StartTicks) .AsNoTracking() - .ToImmutableList() - .Select(Map); + .AsEnumerable() + .Select(Map) + .ToArray(); } private static MediaSegmentDto Map(MediaSegment segment) diff --git a/Jellyfin.Server.Implementations/Migrations/DesignTimeJellyfinDbFactory.cs b/Jellyfin.Server.Implementations/Migrations/DesignTimeJellyfinDbFactory.cs deleted file mode 100644 index 940cf7c5d5..0000000000 --- a/Jellyfin.Server.Implementations/Migrations/DesignTimeJellyfinDbFactory.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Design; - -namespace Jellyfin.Server.Implementations.Migrations -{ - /// - /// The design time factory for . - /// This is only used for the creation of migrations and not during runtime. - /// - internal class DesignTimeJellyfinDbFactory : IDesignTimeDbContextFactory - { - public JellyfinDbContext CreateDbContext(string[] args) - { - var optionsBuilder = new DbContextOptionsBuilder(); - optionsBuilder.UseSqlite("Data Source=jellyfin.db"); - - return new JellyfinDbContext(optionsBuilder.Options); - } - } -} diff --git a/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs b/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs deleted file mode 100644 index 6e1f985ba7..0000000000 --- a/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs +++ /dev/null @@ -1,709 +0,0 @@ -// -using System; -using Jellyfin.Server.Implementations; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace Jellyfin.Server.Implementations.Migrations -{ - [DbContext(typeof(JellyfinDbContext))] - partial class JellyfinDbModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "8.0.8"); - - modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("DayOfWeek") - .HasColumnType("INTEGER"); - - b.Property("EndHour") - .HasColumnType("REAL"); - - b.Property("StartHour") - .HasColumnType("REAL"); - - b.Property("UserId") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("AccessSchedules"); - }); - - modelBuilder.Entity("Jellyfin.Data.Entities.ActivityLog", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("DateCreated") - .HasColumnType("TEXT"); - - b.Property("ItemId") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("LogSeverity") - .HasColumnType("INTEGER"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(512) - .HasColumnType("TEXT"); - - b.Property("Overview") - .HasMaxLength(512) - .HasColumnType("TEXT"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .HasColumnType("INTEGER"); - - b.Property("ShortOverview") - .HasMaxLength(512) - .HasColumnType("TEXT"); - - b.Property("Type") - .IsRequired() - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("UserId") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("DateCreated"); - - b.ToTable("ActivityLogs"); - }); - - modelBuilder.Entity("Jellyfin.Data.Entities.CustomItemDisplayPreferences", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("Client") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - b.Property("ItemId") - .HasColumnType("TEXT"); - - b.Property("Key") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("UserId") - .HasColumnType("TEXT"); - - b.Property("Value") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("UserId", "ItemId", "Client", "Key") - .IsUnique(); - - b.ToTable("CustomItemDisplayPreferences"); - }); - - modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("ChromecastVersion") - .HasColumnType("INTEGER"); - - b.Property("Client") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - b.Property("DashboardTheme") - .HasMaxLength(32) - .HasColumnType("TEXT"); - - b.Property("EnableNextVideoInfoOverlay") - .HasColumnType("INTEGER"); - - b.Property("IndexBy") - .HasColumnType("INTEGER"); - - b.Property("ItemId") - .HasColumnType("TEXT"); - - b.Property("ScrollDirection") - .HasColumnType("INTEGER"); - - b.Property("ShowBackdrop") - .HasColumnType("INTEGER"); - - b.Property("ShowSidebar") - .HasColumnType("INTEGER"); - - b.Property("SkipBackwardLength") - .HasColumnType("INTEGER"); - - b.Property("SkipForwardLength") - .HasColumnType("INTEGER"); - - b.Property("TvHome") - .HasMaxLength(32) - .HasColumnType("TEXT"); - - b.Property("UserId") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("UserId", "ItemId", "Client") - .IsUnique(); - - b.ToTable("DisplayPreferences"); - }); - - modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("DisplayPreferencesId") - .HasColumnType("INTEGER"); - - b.Property("Order") - .HasColumnType("INTEGER"); - - b.Property("Type") - .HasColumnType("INTEGER"); - - b.HasKey("Id"); - - b.HasIndex("DisplayPreferencesId"); - - b.ToTable("HomeSection"); - }); - - modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("LastModified") - .HasColumnType("TEXT"); - - b.Property("Path") - .IsRequired() - .HasMaxLength(512) - .HasColumnType("TEXT"); - - b.Property("UserId") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("UserId") - .IsUnique(); - - b.ToTable("ImageInfos"); - }); - - modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("Client") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - b.Property("IndexBy") - .HasColumnType("INTEGER"); - - b.Property("ItemId") - .HasColumnType("TEXT"); - - b.Property("RememberIndexing") - .HasColumnType("INTEGER"); - - b.Property("RememberSorting") - .HasColumnType("INTEGER"); - - b.Property("SortBy") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("TEXT"); - - b.Property("SortOrder") - .HasColumnType("INTEGER"); - - b.Property("UserId") - .HasColumnType("TEXT"); - - b.Property("ViewType") - .HasColumnType("INTEGER"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("ItemDisplayPreferences"); - }); - - modelBuilder.Entity("Jellyfin.Data.Entities.MediaSegment", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("EndTicks") - .HasColumnType("INTEGER"); - - b.Property("ItemId") - .HasColumnType("TEXT"); - - b.Property("SegmentProviderId") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("StartTicks") - .HasColumnType("INTEGER"); - - b.Property("Type") - .HasColumnType("INTEGER"); - - b.HasKey("Id"); - - b.ToTable("MediaSegments"); - }); - - modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("Kind") - .HasColumnType("INTEGER"); - - b.Property("Permission_Permissions_Guid") - .HasColumnType("TEXT"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .HasColumnType("INTEGER"); - - b.Property("UserId") - .HasColumnType("TEXT"); - - b.Property("Value") - .HasColumnType("INTEGER"); - - b.HasKey("Id"); - - b.HasIndex("UserId", "Kind") - .IsUnique() - .HasFilter("[UserId] IS NOT NULL"); - - b.ToTable("Permissions"); - }); - - modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("Kind") - .HasColumnType("INTEGER"); - - b.Property("Preference_Preferences_Guid") - .HasColumnType("TEXT"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .HasColumnType("INTEGER"); - - b.Property("UserId") - .HasColumnType("TEXT"); - - b.Property("Value") - .IsRequired() - .HasMaxLength(65535) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("UserId", "Kind") - .IsUnique() - .HasFilter("[UserId] IS NOT NULL"); - - b.ToTable("Preferences"); - }); - - modelBuilder.Entity("Jellyfin.Data.Entities.Security.ApiKey", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("AccessToken") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("DateCreated") - .HasColumnType("TEXT"); - - b.Property("DateLastActivity") - .HasColumnType("TEXT"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("AccessToken") - .IsUnique(); - - b.ToTable("ApiKeys"); - }); - - modelBuilder.Entity("Jellyfin.Data.Entities.Security.Device", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("AccessToken") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("AppName") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("TEXT"); - - b.Property("AppVersion") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - b.Property("DateCreated") - .HasColumnType("TEXT"); - - b.Property("DateLastActivity") - .HasColumnType("TEXT"); - - b.Property("DateModified") - .HasColumnType("TEXT"); - - b.Property("DeviceId") - .IsRequired() - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("DeviceName") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("TEXT"); - - b.Property("IsActive") - .HasColumnType("INTEGER"); - - b.Property("UserId") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("DeviceId"); - - b.HasIndex("AccessToken", "DateLastActivity"); - - b.HasIndex("DeviceId", "DateLastActivity"); - - b.HasIndex("UserId", "DeviceId"); - - b.ToTable("Devices"); - }); - - modelBuilder.Entity("Jellyfin.Data.Entities.Security.DeviceOptions", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("CustomName") - .HasColumnType("TEXT"); - - b.Property("DeviceId") - .IsRequired() - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("DeviceId") - .IsUnique(); - - b.ToTable("DeviceOptions"); - }); - - modelBuilder.Entity("Jellyfin.Data.Entities.TrickplayInfo", b => - { - b.Property("ItemId") - .HasColumnType("TEXT"); - - b.Property("Width") - .HasColumnType("INTEGER"); - - b.Property("Bandwidth") - .HasColumnType("INTEGER"); - - b.Property("Height") - .HasColumnType("INTEGER"); - - b.Property("Interval") - .HasColumnType("INTEGER"); - - b.Property("ThumbnailCount") - .HasColumnType("INTEGER"); - - b.Property("TileHeight") - .HasColumnType("INTEGER"); - - b.Property("TileWidth") - .HasColumnType("INTEGER"); - - b.HasKey("ItemId", "Width"); - - b.ToTable("TrickplayInfos"); - }); - - modelBuilder.Entity("Jellyfin.Data.Entities.User", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("AudioLanguagePreference") - .HasMaxLength(255) - .HasColumnType("TEXT"); - - b.Property("AuthenticationProviderId") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("TEXT"); - - b.Property("CastReceiverId") - .HasMaxLength(32) - .HasColumnType("TEXT"); - - b.Property("DisplayCollectionsView") - .HasColumnType("INTEGER"); - - b.Property("DisplayMissingEpisodes") - .HasColumnType("INTEGER"); - - b.Property("EnableAutoLogin") - .HasColumnType("INTEGER"); - - b.Property("EnableLocalPassword") - .HasColumnType("INTEGER"); - - b.Property("EnableNextEpisodeAutoPlay") - .HasColumnType("INTEGER"); - - b.Property("EnableUserPreferenceAccess") - .HasColumnType("INTEGER"); - - b.Property("HidePlayedInLatest") - .HasColumnType("INTEGER"); - - b.Property("InternalId") - .HasColumnType("INTEGER"); - - b.Property("InvalidLoginAttemptCount") - .HasColumnType("INTEGER"); - - b.Property("LastActivityDate") - .HasColumnType("TEXT"); - - b.Property("LastLoginDate") - .HasColumnType("TEXT"); - - b.Property("LoginAttemptsBeforeLockout") - .HasColumnType("INTEGER"); - - b.Property("MaxActiveSessions") - .HasColumnType("INTEGER"); - - b.Property("MaxParentalAgeRating") - .HasColumnType("INTEGER"); - - b.Property("MustUpdatePassword") - .HasColumnType("INTEGER"); - - b.Property("Password") - .HasMaxLength(65535) - .HasColumnType("TEXT"); - - b.Property("PasswordResetProviderId") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("TEXT"); - - b.Property("PlayDefaultAudioTrack") - .HasColumnType("INTEGER"); - - b.Property("RememberAudioSelections") - .HasColumnType("INTEGER"); - - b.Property("RememberSubtitleSelections") - .HasColumnType("INTEGER"); - - b.Property("RemoteClientBitrateLimit") - .HasColumnType("INTEGER"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .HasColumnType("INTEGER"); - - b.Property("SubtitleLanguagePreference") - .HasMaxLength(255) - .HasColumnType("TEXT"); - - b.Property("SubtitleMode") - .HasColumnType("INTEGER"); - - b.Property("SyncPlayAccess") - .HasColumnType("INTEGER"); - - b.Property("Username") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("TEXT") - .UseCollation("NOCASE"); - - b.HasKey("Id"); - - b.HasIndex("Username") - .IsUnique(); - - b.ToTable("Users"); - }); - - modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b => - { - b.HasOne("Jellyfin.Data.Entities.User", null) - .WithMany("AccessSchedules") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => - { - b.HasOne("Jellyfin.Data.Entities.User", null) - .WithMany("DisplayPreferences") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b => - { - b.HasOne("Jellyfin.Data.Entities.DisplayPreferences", null) - .WithMany("HomeSections") - .HasForeignKey("DisplayPreferencesId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b => - { - b.HasOne("Jellyfin.Data.Entities.User", null) - .WithOne("ProfileImage") - .HasForeignKey("Jellyfin.Data.Entities.ImageInfo", "UserId") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b => - { - b.HasOne("Jellyfin.Data.Entities.User", null) - .WithMany("ItemDisplayPreferences") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b => - { - b.HasOne("Jellyfin.Data.Entities.User", null) - .WithMany("Permissions") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b => - { - b.HasOne("Jellyfin.Data.Entities.User", null) - .WithMany("Preferences") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("Jellyfin.Data.Entities.Security.Device", b => - { - b.HasOne("Jellyfin.Data.Entities.User", "User") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => - { - b.Navigation("HomeSections"); - }); - - modelBuilder.Entity("Jellyfin.Data.Entities.User", b => - { - b.Navigation("AccessSchedules"); - - b.Navigation("DisplayPreferences"); - - b.Navigation("ItemDisplayPreferences"); - - b.Navigation("Permissions"); - - b.Navigation("Preferences"); - - b.Navigation("ProfileImage"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Jellyfin.Server.Implementations/ModelBuilderExtensions.cs b/Jellyfin.Server.Implementations/ModelBuilderExtensions.cs deleted file mode 100644 index 79ae1661aa..0000000000 --- a/Jellyfin.Server.Implementations/ModelBuilderExtensions.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System; -using Jellyfin.Server.Implementations.ValueConverters; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -namespace Jellyfin.Server.Implementations -{ - /// - /// Model builder extensions. - /// - public static class ModelBuilderExtensions - { - /// - /// Specify value converter for the object type. - /// - /// The model builder. - /// The . - /// The type to convert. - /// The modified . - public static ModelBuilder UseValueConverterForType(this ModelBuilder modelBuilder, ValueConverter converter) - { - var type = typeof(T); - foreach (var entityType in modelBuilder.Model.GetEntityTypes()) - { - foreach (var property in entityType.GetProperties()) - { - if (property.ClrType == type) - { - property.SetValueConverter(converter); - } - } - } - - return modelBuilder; - } - - /// - /// Specify the default . - /// - /// The model builder to extend. - /// The to specify. - public static void SetDefaultDateTimeKind(this ModelBuilder modelBuilder, DateTimeKind kind) - { - modelBuilder.UseValueConverterForType(new DateTimeKindValueConverter(kind)); - modelBuilder.UseValueConverterForType(new DateTimeKindValueConverter(kind)); - } - } -} diff --git a/Jellyfin.Server.Implementations/Security/AuthenticationManager.cs b/Jellyfin.Server.Implementations/Security/AuthenticationManager.cs index 1c9f54ab03..cf0293463f 100644 --- a/Jellyfin.Server.Implementations/Security/AuthenticationManager.cs +++ b/Jellyfin.Server.Implementations/Security/AuthenticationManager.cs @@ -1,7 +1,8 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using Jellyfin.Data.Entities.Security; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities.Security; using MediaBrowser.Controller.Security; using Microsoft.EntityFrameworkCore; diff --git a/Jellyfin.Server.Implementations/Security/AuthorizationContext.cs b/Jellyfin.Server.Implementations/Security/AuthorizationContext.cs index 2ae722982a..e3fe517c49 100644 --- a/Jellyfin.Server.Implementations/Security/AuthorizationContext.cs +++ b/Jellyfin.Server.Implementations/Security/AuthorizationContext.cs @@ -5,8 +5,10 @@ using System.Collections.Generic; using System.Net; using System.Threading.Tasks; using Jellyfin.Data.Queries; +using Jellyfin.Database.Implementations; using Jellyfin.Extensions; using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Devices; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Net; @@ -22,17 +24,20 @@ namespace Jellyfin.Server.Implementations.Security private readonly IUserManager _userManager; private readonly IDeviceManager _deviceManager; private readonly IServerApplicationHost _serverApplicationHost; + private readonly IServerConfigurationManager _configurationManager; public AuthorizationContext( IDbContextFactory jellyfinDb, IUserManager userManager, IDeviceManager deviceManager, - IServerApplicationHost serverApplicationHost) + IServerApplicationHost serverApplicationHost, + IServerConfigurationManager configurationManager) { _jellyfinDbProvider = jellyfinDb; _userManager = userManager; _deviceManager = deviceManager; _serverApplicationHost = serverApplicationHost; + _configurationManager = configurationManager; } public Task GetAuthorizationInfo(HttpContext requestContext) @@ -85,12 +90,12 @@ namespace Jellyfin.Server.Implementations.Security auth.TryGetValue("Token", out token); } - if (string.IsNullOrEmpty(token)) + if (_configurationManager.Configuration.EnableLegacyAuthorization && string.IsNullOrEmpty(token)) { token = headers["X-Emby-Token"]; } - if (string.IsNullOrEmpty(token)) + if (_configurationManager.Configuration.EnableLegacyAuthorization && string.IsNullOrEmpty(token)) { token = headers["X-MediaBrowser-Token"]; } @@ -100,8 +105,7 @@ namespace Jellyfin.Server.Implementations.Security token = queryString["ApiKey"]; } - // TODO deprecate this query parameter. - if (string.IsNullOrEmpty(token)) + if (_configurationManager.Configuration.EnableLegacyAuthorization && string.IsNullOrEmpty(token)) { token = queryString["api_key"]; } @@ -113,25 +117,20 @@ namespace Jellyfin.Server.Implementations.Security DeviceId = deviceId, Version = version, Token = token, - IsAuthenticated = false, - HasToken = false + IsAuthenticated = false }; - if (string.IsNullOrWhiteSpace(token)) + if (!authInfo.HasToken) { // Request doesn't contain a token. return authInfo; } - authInfo.HasToken = true; var dbContext = await _jellyfinDbProvider.CreateDbContextAsync().ConfigureAwait(false); await using (dbContext.ConfigureAwait(false)) { var device = _deviceManager.GetDevices( - new DeviceQuery - { - AccessToken = token - }).Items.FirstOrDefault(); + new DeviceQuery { AccessToken = token }).Items.FirstOrDefault(); if (device is not null) { @@ -227,13 +226,13 @@ namespace Jellyfin.Server.Implementations.Security /// /// The HTTP request. /// Dictionary{System.StringSystem.String}. - private static Dictionary? GetAuthorizationDictionary(HttpRequest httpReq) + private Dictionary? GetAuthorizationDictionary(HttpRequest httpReq) { - var auth = httpReq.Headers["X-Emby-Authorization"]; + var auth = httpReq.Headers[HeaderNames.Authorization]; - if (string.IsNullOrEmpty(auth)) + if (_configurationManager.Configuration.EnableLegacyAuthorization && string.IsNullOrEmpty(auth)) { - auth = httpReq.Headers[HeaderNames.Authorization]; + auth = httpReq.Headers["X-Emby-Authorization"]; } return auth.Count > 0 ? GetAuthorization(auth[0]) : null; @@ -244,7 +243,7 @@ namespace Jellyfin.Server.Implementations.Security /// /// The authorization header. /// Dictionary{System.StringSystem.String}. - private static Dictionary? GetAuthorization(ReadOnlySpan authorizationHeader) + private Dictionary? GetAuthorization(ReadOnlySpan authorizationHeader) { var firstSpace = authorizationHeader.IndexOf(' '); @@ -256,8 +255,10 @@ namespace Jellyfin.Server.Implementations.Security var name = authorizationHeader[..firstSpace]; - if (!name.Equals("MediaBrowser", StringComparison.OrdinalIgnoreCase) - && !name.Equals("Emby", StringComparison.OrdinalIgnoreCase)) + var validName = name.Equals("MediaBrowser", StringComparison.OrdinalIgnoreCase); + validName = validName || (_configurationManager.Configuration.EnableLegacyAuthorization && name.Equals("Emby", StringComparison.OrdinalIgnoreCase)); + + if (!validName) { return null; } diff --git a/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs b/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs new file mode 100644 index 0000000000..635644179f --- /dev/null +++ b/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs @@ -0,0 +1,109 @@ +using System; +using System.Globalization; +using System.IO; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Model.System; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Implementations.StorageHelpers; + +/// +/// Contains methods to help with checking for storage and returning storage data for jellyfin folders. +/// +public static class StorageHelper +{ + private const long TwoGigabyte = 2_147_483_647L; + private const long FiveHundredAndTwelveMegaByte = 536_870_911L; + private static readonly string[] _byteHumanizedSuffixes = ["B", "KB", "MB", "GB", "TB", "PB", "EB"]; + + /// + /// Tests the available storage capacity on the jellyfin paths with estimated minimum values. + /// + /// The application paths. + /// Logger. + public static void TestCommonPathsForStorageCapacity(IApplicationPaths applicationPaths, ILogger logger) + { + TestDataDirectorySize(applicationPaths.DataPath, logger, TwoGigabyte); + TestDataDirectorySize(applicationPaths.LogDirectoryPath, logger, FiveHundredAndTwelveMegaByte); + TestDataDirectorySize(applicationPaths.CachePath, logger, TwoGigabyte); + TestDataDirectorySize(applicationPaths.ProgramDataPath, logger, TwoGigabyte); + TestDataDirectorySize(applicationPaths.TempDirectory, logger, TwoGigabyte); + } + + /// + /// Gets the free space of a specific directory. + /// + /// Path to a folder. + /// The number of bytes available space. + public static FolderStorageInfo GetFreeSpaceOf(string path) + { + try + { + var driveInfo = new DriveInfo(path); + return new FolderStorageInfo() + { + Path = path, + FreeSpace = driveInfo.AvailableFreeSpace, + UsedSpace = driveInfo.TotalSize - driveInfo.AvailableFreeSpace, + StorageType = driveInfo.DriveType.ToString(), + DeviceId = driveInfo.Name, + }; + } + catch + { + return new FolderStorageInfo() + { + Path = path, + FreeSpace = -1, + UsedSpace = -1, + StorageType = null, + DeviceId = null + }; + } + } + + /// + /// Gets the underlying drive data from a given path and checks if the available storage capacity matches the threshold. + /// + /// The path to a folder to evaluate. + /// The logger. + /// The threshold to check for or -1 to just log the data. + /// Thrown when the threshold is not available on the underlying storage. + private static void TestDataDirectorySize(string path, ILogger logger, long threshold = -1) + { + logger.LogDebug("Check path {TestPath} for storage capacity", path); + var drive = new DriveInfo(path); + if (threshold != -1 && drive.AvailableFreeSpace < threshold) + { + throw new InvalidOperationException($"The path `{path}` has insufficient free space. Required: at least {HumanizeStorageSize(threshold)}."); + } + + logger.LogInformation( + "Storage path `{TestPath}` ({StorageType}) successfully checked with {FreeSpace} free which is over the minimum of {MinFree}.", + path, + drive.DriveType, + HumanizeStorageSize(drive.AvailableFreeSpace), + HumanizeStorageSize(threshold)); + } + + /// + /// Formats a size in bytes into a common human readable form. + /// + /// + /// Taken and slightly modified from https://stackoverflow.com/a/4975942/1786007 . + /// + /// The size in bytes. + /// A human readable approximate representation of the argument. + public static string HumanizeStorageSize(long byteCount) + { + if (byteCount == 0) + { + return $"0{_byteHumanizedSuffixes[0]}"; + } + + var bytes = Math.Abs(byteCount); + var place = Convert.ToInt32(Math.Floor(Math.Log(bytes, 1024))); + var num = Math.Round(bytes / Math.Pow(1024, place), 1); + return (Math.Sign(byteCount) * num).ToString(CultureInfo.InvariantCulture) + _byteHumanizedSuffixes[place]; + } +} diff --git a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs index cfe385106a..bf39f13a77 100644 --- a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs +++ b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs @@ -7,11 +7,13 @@ using System.Text; using System.Threading; using System.Threading.Tasks; using AsyncKeyedLock; -using Jellyfin.Data.Entities; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Trickplay; @@ -37,22 +39,24 @@ public class TrickplayManager : ITrickplayManager private readonly IImageEncoder _imageEncoder; private readonly IDbContextFactory _dbProvider; private readonly IApplicationPaths _appPaths; + private readonly IPathManager _pathManager; private static readonly AsyncNonKeyedLocker _resourcePool = new(1); - private static readonly string[] _trickplayImgExtensions = { ".jpg" }; + private static readonly string[] _trickplayImgExtensions = [".jpg"]; /// /// Initializes a new instance of the class. /// /// The logger. /// The media encoder. - /// The file systen. + /// The file system. /// The encoding helper. /// The library manager. /// The server configuration manager. /// The image encoder. /// The database provider. /// The application paths. + /// The path manager. public TrickplayManager( ILogger logger, IMediaEncoder mediaEncoder, @@ -62,7 +66,8 @@ public class TrickplayManager : ITrickplayManager IServerConfigurationManager config, IImageEncoder imageEncoder, IDbContextFactory dbProvider, - IApplicationPaths appPaths) + IApplicationPaths appPaths, + IPathManager pathManager) { _logger = logger; _mediaEncoder = mediaEncoder; @@ -73,6 +78,7 @@ public class TrickplayManager : ITrickplayManager _imageEncoder = imageEncoder; _dbProvider = dbProvider; _appPaths = appPaths; + _pathManager = pathManager; } /// @@ -179,7 +185,7 @@ public class TrickplayManager : ITrickplayManager { // Extract images // Note: Media sources under parent items exist as their own video/item as well. Only use this video stream for trickplay. - var mediaSource = video.GetMediaSources(false).Find(source => Guid.Parse(source.Id).Equals(video.Id)); + var mediaSource = video.GetMediaSources(false).FirstOrDefault(source => Guid.Parse(source.Id).Equals(video.Id)); if (mediaSource is null) { @@ -194,6 +200,14 @@ public class TrickplayManager : ITrickplayManager return; } + // We support video backdrops, but we should not generate trickplay images for them + var parentDirectory = Directory.GetParent(mediaPath); + if (parentDirectory is not null && string.Equals(parentDirectory.Name, "backdrops", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogDebug("Ignoring backdrop media found at {Path} for item {ItemID}", mediaPath, video.Id); + return; + } + // The width has to be even, otherwise a lot of filters will not be able to sample it var actualWidth = 2 * (width / 2); @@ -238,7 +252,7 @@ public class TrickplayManager : ITrickplayManager foreach (var tile in existingFiles) { var image = _imageEncoder.GetImageSize(tile); - localTrickplayInfo.Height = Math.Max(localTrickplayInfo.Height, image.Height); + localTrickplayInfo.Height = Math.Max(localTrickplayInfo.Height, (int)Math.Ceiling((double)image.Height / localTrickplayInfo.TileHeight)); var bitrate = (int)Math.Ceiling((decimal)new FileInfo(tile).Length * 8 / localTrickplayInfo.TileWidth / localTrickplayInfo.TileHeight / (localTrickplayInfo.Interval / 1000)); localTrickplayInfo.Bandwidth = Math.Max(localTrickplayInfo.Bandwidth, bitrate); } @@ -536,7 +550,7 @@ public class TrickplayManager : ITrickplayManager if (trickplayInfo.ThumbnailCount > 0) { - const string urlFormat = "{0}.jpg?MediaSourceId={1}&api_key={2}"; + const string urlFormat = "{0}.jpg?MediaSourceId={1}&ApiKey={2}"; const string decimalFormat = "{0:0.###}"; var resolution = $"{trickplayInfo.Width}x{trickplayInfo.Height}"; @@ -602,10 +616,7 @@ public class TrickplayManager : ITrickplayManager /// public string GetTrickplayDirectory(BaseItem item, int tileWidth, int tileHeight, int width, bool saveWithMedia = false) { - var path = saveWithMedia - ? Path.Combine(item.ContainingFolderPath, Path.ChangeExtension(item.Path, ".trickplay")) - : Path.Combine(item.GetInternalMetadataPath(), "trickplay"); - + var path = _pathManager.GetTrickplayDirectory(item, saveWithMedia); var subdirectory = string.Format( CultureInfo.InvariantCulture, "{0} - {1}x{2}", diff --git a/Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs b/Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs index acada7aa46..35c43b176d 100644 --- a/Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs +++ b/Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs @@ -2,7 +2,7 @@ using System; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Threading.Tasks; -using Jellyfin.Data.Entities; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Authentication; using MediaBrowser.Model.Cryptography; using Microsoft.Extensions.Logging; diff --git a/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs b/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs index cefbd0624d..6296881a9e 100644 --- a/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs +++ b/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs @@ -4,7 +4,7 @@ using System.IO; using System.Security.Cryptography; using System.Text.Json; using System.Threading.Tasks; -using Jellyfin.Data.Entities; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Common; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Authentication; diff --git a/Jellyfin.Server.Implementations/Users/DeviceAccessHost.cs b/Jellyfin.Server.Implementations/Users/DeviceAccessHost.cs index 45b0a0853e..92e2bb4fa7 100644 --- a/Jellyfin.Server.Implementations/Users/DeviceAccessHost.cs +++ b/Jellyfin.Server.Implementations/Users/DeviceAccessHost.cs @@ -1,9 +1,10 @@ using System.Threading; using System.Threading.Tasks; -using Jellyfin.Data.Entities; -using Jellyfin.Data.Enums; +using Jellyfin.Data; using Jellyfin.Data.Events; using Jellyfin.Data.Queries; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using MediaBrowser.Controller.Devices; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Session; diff --git a/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs b/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs index e204a16a64..0f21e11a35 100644 --- a/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs +++ b/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs @@ -5,7 +5,8 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using Jellyfin.Data.Entities; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller; using Microsoft.EntityFrameworkCore; diff --git a/Jellyfin.Server.Implementations/Users/InvalidAuthProvider.cs b/Jellyfin.Server.Implementations/Users/InvalidAuthProvider.cs index c4e4c460a6..caf9d5bd9a 100644 --- a/Jellyfin.Server.Implementations/Users/InvalidAuthProvider.cs +++ b/Jellyfin.Server.Implementations/Users/InvalidAuthProvider.cs @@ -1,5 +1,5 @@ using System.Threading.Tasks; -using Jellyfin.Data.Entities; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Authentication; namespace Jellyfin.Server.Implementations.Users diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs index 1b6635938b..3dfb14d716 100644 --- a/Jellyfin.Server.Implementations/Users/UserManager.cs +++ b/Jellyfin.Server.Implementations/Users/UserManager.cs @@ -7,10 +7,13 @@ using System.Globalization; using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; -using Jellyfin.Data.Entities; +using Jellyfin.Data; using Jellyfin.Data.Enums; using Jellyfin.Data.Events; using Jellyfin.Data.Events.Users; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using MediaBrowser.Common; using MediaBrowser.Common.Extensions; @@ -113,7 +116,7 @@ namespace Jellyfin.Server.Implementations.Users // This is some regex that matches only on unicode "word" characters, as well as -, _ and @ // In theory this will cut out most if not all 'control' characters which should help minimize any weirdness // Usernames can contain letters (a-z + whatever else unicode is cool with), numbers (0-9), at-signs (@), dashes (-), underscores (_), apostrophes ('), periods (.) and spaces ( ) - [GeneratedRegex(@"^[\w\ \-'._@]+$")] + [GeneratedRegex(@"^(?!\s)[\w\ \-'._@+]+(? @@ -146,7 +149,7 @@ namespace Jellyfin.Server.Implementations.Users ThrowIfInvalidUsername(newName); - if (user.Username.Equals(newName, StringComparison.Ordinal)) + if (user.Username.Equals(newName, StringComparison.OrdinalIgnoreCase)) { throw new ArgumentException("The new and old names must be different."); } @@ -154,8 +157,11 @@ namespace Jellyfin.Server.Implementations.Users var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); await using (dbContext.ConfigureAwait(false)) { +#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons +#pragma warning disable CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture +#pragma warning disable CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings if (await dbContext.Users - .AnyAsync(u => u.Username == newName && !u.Id.Equals(user.Id)) + .AnyAsync(u => u.Username.ToUpper() == newName.ToUpper() && !u.Id.Equals(user.Id)) .ConfigureAwait(false)) { throw new ArgumentException(string.Format( @@ -163,6 +169,9 @@ namespace Jellyfin.Server.Implementations.Users "A user with the name '{0}' already exists.", newName)); } +#pragma warning restore CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings +#pragma warning restore CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture +#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons user.Username = newName; await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false); @@ -333,7 +342,8 @@ namespace Jellyfin.Server.Implementations.Users }, Policy = new UserPolicy { - MaxParentalRating = user.MaxParentalAgeRating, + MaxParentalRating = user.MaxParentalRatingScore, + MaxParentalSubRating = user.MaxParentalRatingSubScore, EnableUserPreferenceAccess = user.EnableUserPreferenceAccess, RemoteClientBitrateLimit = user.RemoteClientBitrateLimit ?? 0, AuthenticationProviderId = user.AuthenticationProviderId, @@ -659,7 +669,8 @@ namespace Jellyfin.Server.Implementations.Users _ => policy.LoginAttemptsBeforeLockout }; - user.MaxParentalAgeRating = policy.MaxParentalRating; + user.MaxParentalRatingScore = policy.MaxParentalRating; + user.MaxParentalRatingSubScore = policy.MaxParentalSubRating; user.EnableUserPreferenceAccess = policy.EnableUserPreferenceAccess; user.RemoteClientBitrateLimit = policy.RemoteClientBitrateLimit; user.AuthenticationProviderId = policy.AuthenticationProviderId; diff --git a/Jellyfin.Server/CoreAppHost.cs b/Jellyfin.Server/CoreAppHost.cs index d5b6e93b8e..f3bf6b805a 100644 --- a/Jellyfin.Server/CoreAppHost.cs +++ b/Jellyfin.Server/CoreAppHost.cs @@ -4,13 +4,14 @@ using System.Reflection; using Emby.Server.Implementations; using Emby.Server.Implementations.Session; using Jellyfin.Api.WebSocketListeners; +using Jellyfin.Database.Implementations; using Jellyfin.Drawing; using Jellyfin.Drawing.Skia; using Jellyfin.LiveTv; -using Jellyfin.Server.Implementations; using Jellyfin.Server.Implementations.Activity; using Jellyfin.Server.Implementations.Devices; using Jellyfin.Server.Implementations.Events; +using Jellyfin.Server.Implementations.Extensions; using Jellyfin.Server.Implementations.Security; using Jellyfin.Server.Implementations.Trickplay; using Jellyfin.Server.Implementations.Users; @@ -116,9 +117,12 @@ namespace Jellyfin.Server // Jellyfin.Server yield return typeof(CoreAppHost).Assembly; - // Jellyfin.Server.Implementations + // Jellyfin.Database.Implementations yield return typeof(JellyfinDbContext).Assembly; + // Jellyfin.Server.Implementations + yield return typeof(ServiceCollectionExtensions).Assembly; + // Jellyfin.LiveTv yield return typeof(LiveTvManager).Assembly; } diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs index 597643ed19..b04e55baa6 100644 --- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs @@ -19,6 +19,7 @@ using Jellyfin.Api.Controllers; using Jellyfin.Api.Formatters; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions.Json; using Jellyfin.Server.Configuration; using Jellyfin.Server.Filters; @@ -118,15 +119,15 @@ namespace Jellyfin.Server.Extensions // https://github.com/dotnet/aspnetcore/blob/master/src/Middleware/HttpOverrides/src/ForwardedHeadersMiddleware.cs // Enable debug logging on Microsoft.AspNetCore.HttpOverrides.ForwardedHeadersMiddleware to help investigate issues. - options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost; - if (config.KnownProxies.Length == 0) { + options.ForwardedHeaders = ForwardedHeaders.None; options.KnownNetworks.Clear(); options.KnownProxies.Clear(); } else { + options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost; AddProxyAddresses(config, config.KnownProxies, options); } @@ -247,6 +248,7 @@ namespace Jellyfin.Server.Extensions c.AddSwaggerTypeMappings(); c.SchemaFilter(); + c.OperationFilter(); c.OperationFilter(); c.OperationFilter(); c.OperationFilter(); diff --git a/Jellyfin.Server/Extensions/WebHostBuilderExtensions.cs b/Jellyfin.Server/Extensions/WebHostBuilderExtensions.cs index 6b95770ed5..be9cf0f154 100644 --- a/Jellyfin.Server/Extensions/WebHostBuilderExtensions.cs +++ b/Jellyfin.Server/Extensions/WebHostBuilderExtensions.cs @@ -1,10 +1,14 @@ using System; +using System.Collections.Generic; using System.IO; using System.Net; +using System.Security.Cryptography.X509Certificates; using Jellyfin.Server.Helpers; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Extensions; +using MediaBrowser.Model.Net; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -35,56 +39,98 @@ public static class WebHostBuilderExtensions return builder .UseKestrel((builderContext, options) => { - var addresses = appHost.NetManager.GetAllBindInterfaces(false); + SetupJellyfinWebServer( + appHost.NetManager.GetAllBindInterfaces(false), + appHost.HttpPort, + appHost.ListenWithHttps ? appHost.HttpsPort : null, + appHost.Certificate, + startupConfig, + appPaths, + logger, + builderContext, + options); + }) + .UseStartup(context => new Startup(appHost, context.Configuration)); + } - bool flagged = false; - foreach (var netAdd in addresses) + /// + /// Configures a Kestrel type webServer to bind to the specific arguments. + /// + /// The IP addresses that should be listend to. + /// The http port. + /// If set the https port. If set you must also set the certificate. + /// The certificate used for https port. + /// The startup config. + /// The app paths. + /// A logger. + /// The kestrel build pipeline context. + /// The kestrel server options. + /// Will be thrown when a https port is set but no or an invalid certificate is provided. + public static void SetupJellyfinWebServer( + IReadOnlyList addresses, + int httpPort, + int? httpsPort, + X509Certificate2? certificate, + IConfiguration startupConfig, + IApplicationPaths appPaths, + ILogger logger, + WebHostBuilderContext builderContext, + KestrelServerOptions options) + { + bool flagged = false; + foreach (var netAdd in addresses) + { + var address = netAdd.Address; + logger.LogInformation("Kestrel is listening on {Address}", address.Equals(IPAddress.IPv6Any) ? "all interfaces" : address); + options.Listen(netAdd.Address, httpPort); + if (httpsPort.HasValue) + { + if (builderContext.HostingEnvironment.IsDevelopment()) { - var address = netAdd.Address; - logger.LogInformation("Kestrel is listening on {Address}", address.Equals(IPAddress.IPv6Any) ? "all interfaces" : address); - options.Listen(netAdd.Address, appHost.HttpPort); - if (appHost.ListenWithHttps) + try { options.Listen( address, - appHost.HttpsPort, - listenOptions => listenOptions.UseHttps(appHost.Certificate)); + httpsPort.Value, + listenOptions => listenOptions.UseHttps()); } - else if (builderContext.HostingEnvironment.IsDevelopment()) + catch (InvalidOperationException) { - try + if (!flagged) { - options.Listen( - address, - appHost.HttpsPort, - listenOptions => listenOptions.UseHttps()); - } - catch (InvalidOperationException) - { - if (!flagged) - { - logger.LogWarning("Failed to listen to HTTPS using the ASP.NET Core HTTPS development certificate. Please ensure it has been installed and set as trusted"); - flagged = true; - } + logger.LogWarning("Failed to listen to HTTPS using the ASP.NET Core HTTPS development certificate. Please ensure it has been installed and set as trusted"); + flagged = true; } } } - - // Bind to unix socket (only on unix systems) - if (startupConfig.UseUnixSocket() && Environment.OSVersion.Platform == PlatformID.Unix) + else { - var socketPath = StartupHelpers.GetUnixSocketPath(startupConfig, appPaths); - - // Workaround for https://github.com/aspnet/AspNetCore/issues/14134 - if (File.Exists(socketPath)) + if (certificate is null) { - File.Delete(socketPath); + throw new InvalidOperationException("Cannot run jellyfin with https without setting a valid certificate."); } - options.ListenUnixSocket(socketPath); - logger.LogInformation("Kestrel listening to unix socket {SocketPath}", socketPath); + options.Listen( + address, + httpsPort.Value, + listenOptions => listenOptions.UseHttps(certificate)); } - }) - .UseStartup(_ => new Startup(appHost)); + } + } + + // Bind to unix socket (only on unix systems) + if (startupConfig.UseUnixSocket() && Environment.OSVersion.Platform == PlatformID.Unix) + { + var socketPath = StartupHelpers.GetUnixSocketPath(startupConfig, appPaths); + + // Workaround for https://github.com/aspnet/AspNetCore/issues/14134 + if (File.Exists(socketPath)) + { + File.Delete(socketPath); + } + + options.ListenUnixSocket(socketPath); + logger.LogInformation("Kestrel listening to unix socket {SocketPath}", socketPath); + } } } diff --git a/Jellyfin.Server/Filters/AdditionalModelFilter.cs b/Jellyfin.Server/Filters/AdditionalModelFilter.cs index bf38f741cd..421eeecda0 100644 --- a/Jellyfin.Server/Filters/AdditionalModelFilter.cs +++ b/Jellyfin.Server/Filters/AdditionalModelFilter.cs @@ -25,7 +25,7 @@ namespace Jellyfin.Server.Filters public class AdditionalModelFilter : IDocumentFilter { // Array of options that should not be visible in the api spec. - private static readonly Type[] _ignoredConfigurations = { typeof(MigrationOptions) }; + private static readonly Type[] _ignoredConfigurations = { typeof(MigrationOptions), typeof(MediaBrowser.Model.Branding.BrandingOptions) }; private readonly IServerConfigurationManager _serverConfigurationManager; /// @@ -92,17 +92,51 @@ namespace Jellyfin.Server.Filters continue; } - // Additional discriminator needed for GroupUpdate models... - if (messageType == SessionMessageType.SyncPlayGroupUpdate && type != typeof(SyncPlayGroupUpdateCommandMessage)) - { - continue; - } - var schema = context.SchemaGenerator.GenerateSchema(type, context.SchemaRepository); outboundWebSocketSchemas.Add(schema); outboundWebSocketDiscriminators.Add(messageType.ToString()!, schema.Reference.ReferenceV3); } + // Add custom "SyncPlayGroupUpdateMessage" schema because Swashbuckle cannot generate it for us + var syncPlayGroupUpdateMessageSchema = new OpenApiSchema + { + Type = "object", + Description = "Untyped sync play command.", + Properties = new Dictionary + { + { + "Data", new OpenApiSchema + { + AllOf = + [ + new OpenApiSchema { Reference = new OpenApiReference { Type = ReferenceType.Schema, Id = nameof(GroupUpdate) } } + ], + Description = "Group update data", + Nullable = false, + } + }, + { "MessageId", new OpenApiSchema { Type = "string", Format = "uuid", Description = "Gets or sets the message id." } }, + { + "MessageType", new OpenApiSchema + { + Enum = Enum.GetValues().Select(type => new OpenApiString(type.ToString())).ToList(), + AllOf = + [ + new OpenApiSchema { Reference = new OpenApiReference { Type = ReferenceType.Schema, Id = nameof(SessionMessageType) } } + ], + Description = "The different kinds of messages that are used in the WebSocket api.", + Default = new OpenApiString(nameof(SessionMessageType.SyncPlayGroupUpdate)), + ReadOnly = true + } + }, + }, + AdditionalPropertiesAllowed = false, + Reference = new OpenApiReference { Type = ReferenceType.Schema, Id = "SyncPlayGroupUpdateMessage" } + }; + context.SchemaRepository.AddDefinition("SyncPlayGroupUpdateMessage", syncPlayGroupUpdateMessageSchema); + outboundWebSocketSchemas.Add(syncPlayGroupUpdateMessageSchema); + outboundWebSocketDiscriminators[nameof(SessionMessageType.SyncPlayGroupUpdate)] = syncPlayGroupUpdateMessageSchema.Reference.ReferenceV3; + var outboundWebSocketMessageSchema = new OpenApiSchema { Type = "object", @@ -140,41 +174,46 @@ namespace Jellyfin.Server.Filters }); // Manually generate sync play GroupUpdate messages. - if (!context.SchemaRepository.Schemas.TryGetValue(nameof(GroupUpdate), out var groupUpdateSchema)) + var groupUpdateTypes = typeof(GroupUpdate<>).Assembly.GetTypes() + .Where(t => t.BaseType != null + && t.BaseType.IsGenericType + && t.BaseType.GetGenericTypeDefinition() == typeof(GroupUpdate<>)) + .ToList(); + + var groupUpdateSchemas = new List(); + var groupUpdateDiscriminators = new Dictionary(); + foreach (var type in groupUpdateTypes) { - groupUpdateSchema = context.SchemaGenerator.GenerateSchema(typeof(GroupUpdate), context.SchemaRepository); + var groupUpdateType = (GroupUpdateType?)type.GetProperty(nameof(GroupUpdate.Type))?.GetCustomAttribute()?.Value; + if (groupUpdateType is null) + { + continue; + } + + var schema = context.SchemaGenerator.GenerateSchema(type, context.SchemaRepository); + groupUpdateSchemas.Add(schema); + groupUpdateDiscriminators[groupUpdateType.ToString()!] = schema.Reference.ReferenceV3; } - var groupUpdateOfGroupInfoSchema = context.SchemaGenerator.GenerateSchema(typeof(GroupUpdate), context.SchemaRepository); - var groupUpdateOfGroupStateSchema = context.SchemaGenerator.GenerateSchema(typeof(GroupUpdate), context.SchemaRepository); - var groupUpdateOfStringSchema = context.SchemaGenerator.GenerateSchema(typeof(GroupUpdate), context.SchemaRepository); - var groupUpdateOfPlayQueueSchema = context.SchemaGenerator.GenerateSchema(typeof(GroupUpdate), context.SchemaRepository); - - groupUpdateSchema.OneOf = new List + var groupUpdateSchema = new OpenApiSchema { - groupUpdateOfGroupInfoSchema, - groupUpdateOfGroupStateSchema, - groupUpdateOfStringSchema, - groupUpdateOfPlayQueueSchema - }; - - groupUpdateSchema.Discriminator = new OpenApiDiscriminator - { - PropertyName = nameof(GroupUpdate.Type), - Mapping = new Dictionary + Type = "object", + Description = "Represents the list of possible group update types", + Reference = new OpenApiReference { - { GroupUpdateType.UserJoined.ToString(), groupUpdateOfStringSchema.Reference.ReferenceV3 }, - { GroupUpdateType.UserLeft.ToString(), groupUpdateOfStringSchema.Reference.ReferenceV3 }, - { GroupUpdateType.GroupJoined.ToString(), groupUpdateOfGroupInfoSchema.Reference.ReferenceV3 }, - { GroupUpdateType.GroupLeft.ToString(), groupUpdateOfStringSchema.Reference.ReferenceV3 }, - { GroupUpdateType.StateUpdate.ToString(), groupUpdateOfGroupStateSchema.Reference.ReferenceV3 }, - { GroupUpdateType.PlayQueue.ToString(), groupUpdateOfPlayQueueSchema.Reference.ReferenceV3 }, - { GroupUpdateType.NotInGroup.ToString(), groupUpdateOfStringSchema.Reference.ReferenceV3 }, - { GroupUpdateType.GroupDoesNotExist.ToString(), groupUpdateOfStringSchema.Reference.ReferenceV3 }, - { GroupUpdateType.LibraryAccessDenied.ToString(), groupUpdateOfStringSchema.Reference.ReferenceV3 } + Id = nameof(GroupUpdate), + Type = ReferenceType.Schema + }, + OneOf = groupUpdateSchemas, + Discriminator = new OpenApiDiscriminator + { + PropertyName = nameof(GroupUpdate.Type), + Mapping = groupUpdateDiscriminators } }; + context.SchemaRepository.Schemas[nameof(GroupUpdate)] = groupUpdateSchema; + context.SchemaGenerator.GenerateSchema(typeof(ServerDiscoveryInfo), context.SchemaRepository); foreach (var configuration in _serverConfigurationManager.GetConfigurationStores()) diff --git a/Jellyfin.Server/Filters/RetryOnTemporarlyUnavailableFilter.cs b/Jellyfin.Server/Filters/RetryOnTemporarlyUnavailableFilter.cs new file mode 100644 index 0000000000..74470eda0d --- /dev/null +++ b/Jellyfin.Server/Filters/RetryOnTemporarlyUnavailableFilter.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using System.Net.Http.Headers; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Jellyfin.Server.Filters; + +internal class RetryOnTemporarlyUnavailableFilter : IOperationFilter +{ + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + operation.Responses.Add("503", new OpenApiResponse() + { + Description = "The server is currently starting or is temporarly not available.", + Headers = new Dictionary() + { + { + "Retry-After", + new() { AllowEmptyValue = true, Required = false, Description = "A hint for when to retry the operation in full seconds." } + }, + { + "Message", + new() { AllowEmptyValue = true, Required = false, Description = "A short plain-text reason why the server is not available." } + } + }, + Content = new Dictionary() + { + { + "text/html", + new OpenApiMediaType() + } + } + }); + } +} diff --git a/Jellyfin.Server/Helpers/StartupHelpers.cs b/Jellyfin.Server/Helpers/StartupHelpers.cs index 0802b23ad9..bbf6d31f1f 100644 --- a/Jellyfin.Server/Helpers/StartupHelpers.cs +++ b/Jellyfin.Server/Helpers/StartupHelpers.cs @@ -292,13 +292,5 @@ public static class StartupHelpers // Make sure we have all the code pages we can get // Ref: https://docs.microsoft.com/en-us/dotnet/api/system.text.codepagesencodingprovider.instance?view=netcore-3.0#remarks Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); - - // Increase the max http request limit - // The default connection limit is 10 for ASP.NET hosted applications and 2 for all others. - ServicePointManager.DefaultConnectionLimit = Math.Max(96, ServicePointManager.DefaultConnectionLimit); - - // Disable the "Expect: 100-Continue" header by default - // http://stackoverflow.com/questions/566437/http-post-returns-the-error-417-expectation-failed-c - ServicePointManager.Expect100Continue = false; } } diff --git a/Jellyfin.Server/Infrastructure/SymlinkFollowingPhysicalFileResultExecutor.cs b/Jellyfin.Server/Infrastructure/SymlinkFollowingPhysicalFileResultExecutor.cs index 801026c549..910b5c4672 100644 --- a/Jellyfin.Server/Infrastructure/SymlinkFollowingPhysicalFileResultExecutor.cs +++ b/Jellyfin.Server/Infrastructure/SymlinkFollowingPhysicalFileResultExecutor.cs @@ -67,41 +67,43 @@ namespace Jellyfin.Server.Infrastructure } /// - protected override Task WriteFileAsync(ActionContext context, PhysicalFileResult result, RangeItemHeaderValue? range, long rangeLength) + protected override async Task WriteFileAsync(ActionContext context, PhysicalFileResult result, RangeItemHeaderValue? range, long rangeLength) { ArgumentNullException.ThrowIfNull(context); ArgumentNullException.ThrowIfNull(result); if (range is not null && rangeLength == 0) { - return Task.CompletedTask; + return; } // It's a bit of wasted IO to perform this check again, but non-symlinks shouldn't use this code if (!IsSymLink(result.FileName)) { - return base.WriteFileAsync(context, result, range, rangeLength); + await base.WriteFileAsync(context, result, range, rangeLength).ConfigureAwait(false); + return; } var response = context.HttpContext.Response; if (range is not null) { - return SendFileAsync( + await SendFileAsync( result.FileName, response, offset: range.From ?? 0L, - count: rangeLength); + count: rangeLength).ConfigureAwait(false); + return; } - return SendFileAsync( + await SendFileAsync( result.FileName, response, offset: 0, - count: null); + count: null).ConfigureAwait(false); } - private async Task SendFileAsync(string filePath, HttpResponse response, long offset, long? count) + private async Task SendFileAsync(string filePath, HttpResponse response, long offset, long? count, CancellationToken cancellationToken = default) { var fileInfo = GetFileInfo(filePath); if (offset < 0 || offset > fileInfo.Length) @@ -118,6 +120,9 @@ namespace Jellyfin.Server.Infrastructure // Copied from SendFileFallback.SendFileAsync const int BufferSize = 1024 * 16; + var useRequestAborted = !cancellationToken.CanBeCanceled; + var localCancel = useRequestAborted ? response.HttpContext.RequestAborted : cancellationToken; + var fileStream = new FileStream( filePath, FileMode.Open, @@ -127,10 +132,17 @@ namespace Jellyfin.Server.Infrastructure options: FileOptions.Asynchronous | FileOptions.SequentialScan); await using (fileStream.ConfigureAwait(false)) { - fileStream.Seek(offset, SeekOrigin.Begin); - await StreamCopyOperation - .CopyToAsync(fileStream, response.Body, count, BufferSize, CancellationToken.None) - .ConfigureAwait(true); + try + { + localCancel.ThrowIfCancellationRequested(); + fileStream.Seek(offset, SeekOrigin.Begin); + await StreamCopyOperation + .CopyToAsync(fileStream, response.Body, count, BufferSize, localCancel) + .ConfigureAwait(true); + } + catch (OperationCanceledException) when (useRequestAborted) + { + } } } diff --git a/Jellyfin.Server/Jellyfin.Server.csproj b/Jellyfin.Server/Jellyfin.Server.csproj index e18212908e..452b03efbe 100644 --- a/Jellyfin.Server/Jellyfin.Server.csproj +++ b/Jellyfin.Server/Jellyfin.Server.csproj @@ -8,7 +8,7 @@ jellyfin Exe - net8.0 + net9.0 false false true @@ -66,6 +66,7 @@ + diff --git a/Jellyfin.Server/Migrations/IDatabaseMigrationRoutine.cs b/Jellyfin.Server/Migrations/IDatabaseMigrationRoutine.cs new file mode 100644 index 0000000000..78ff1e3fd0 --- /dev/null +++ b/Jellyfin.Server/Migrations/IDatabaseMigrationRoutine.cs @@ -0,0 +1,12 @@ +using System; +using Jellyfin.Server.Implementations; +using Microsoft.EntityFrameworkCore; + +namespace Jellyfin.Server.Migrations; + +/// +/// Defines a migration that operates on the Database. +/// +internal interface IDatabaseMigrationRoutine : IMigrationRoutine +{ +} diff --git a/Jellyfin.Server/Migrations/IMigrationRoutine.cs b/Jellyfin.Server/Migrations/IMigrationRoutine.cs index c1000eeded..29f681df52 100644 --- a/Jellyfin.Server/Migrations/IMigrationRoutine.cs +++ b/Jellyfin.Server/Migrations/IMigrationRoutine.cs @@ -1,4 +1,6 @@ using System; +using Jellyfin.Server.Implementations; +using Microsoft.EntityFrameworkCore.Internal; namespace Jellyfin.Server.Migrations { diff --git a/Jellyfin.Server/Migrations/MigrationRunner.cs b/Jellyfin.Server/Migrations/MigrationRunner.cs index 9d4441ac39..c223576dad 100644 --- a/Jellyfin.Server/Migrations/MigrationRunner.cs +++ b/Jellyfin.Server/Migrations/MigrationRunner.cs @@ -2,10 +2,15 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using Emby.Server.Implementations; using Emby.Server.Implementations.Serialization; +using Jellyfin.Database.Implementations; +using Jellyfin.Server.Implementations; using MediaBrowser.Common.Configuration; using MediaBrowser.Model.Configuration; +using Microsoft.EntityFrameworkCore.Storage; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -24,7 +29,8 @@ namespace Jellyfin.Server.Migrations typeof(PreStartupRoutines.CreateNetworkConfiguration), typeof(PreStartupRoutines.MigrateMusicBrainzTimeout), typeof(PreStartupRoutines.MigrateNetworkConfiguration), - typeof(PreStartupRoutines.MigrateEncodingOptions) + typeof(PreStartupRoutines.MigrateEncodingOptions), + typeof(PreStartupRoutines.RenameEnableGroupingIntoCollections) }; /// @@ -43,11 +49,15 @@ namespace Jellyfin.Server.Migrations typeof(Routines.RemoveDownloadImagesInAdvance), typeof(Routines.MigrateAuthenticationDb), typeof(Routines.FixPlaylistOwner), - typeof(Routines.MigrateRatingLevels), typeof(Routines.AddDefaultCastReceivers), typeof(Routines.UpdateDefaultPluginRepository), typeof(Routines.FixAudioData), - typeof(Routines.MoveTrickplayFiles) + typeof(Routines.RemoveDuplicatePlaylistChildren), + typeof(Routines.MigrateLibraryDb), + typeof(Routines.MoveExtractedFiles), + typeof(Routines.MigrateRatingLevels), + typeof(Routines.MoveTrickplayFiles), + typeof(Routines.MigrateKeyframeData), }; /// @@ -55,7 +65,8 @@ namespace Jellyfin.Server.Migrations /// /// CoreAppHost that hosts current version. /// Factory for making the logger. - public static void Run(CoreAppHost host, ILoggerFactory loggerFactory) + /// A representing the asynchronous operation. + public static async Task Run(CoreAppHost host, ILoggerFactory loggerFactory) { var logger = loggerFactory.CreateLogger(); var migrations = _migrationTypes @@ -65,7 +76,8 @@ namespace Jellyfin.Server.Migrations var migrationOptions = host.ConfigurationManager.GetConfiguration(MigrationsListStore.StoreKey); HandleStartupWizardCondition(migrations, migrationOptions, host.ConfigurationManager.Configuration.IsStartupWizardCompleted, logger); - PerformMigrations(migrations, migrationOptions, options => host.ConfigurationManager.SaveConfiguration(MigrationsListStore.StoreKey, options), logger); + await PerformMigrations(migrations, migrationOptions, options => host.ConfigurationManager.SaveConfiguration(MigrationsListStore.StoreKey, options), logger, host.ServiceProvider.GetRequiredService()) + .ConfigureAwait(false); } /// @@ -73,7 +85,8 @@ namespace Jellyfin.Server.Migrations /// /// Application paths. /// Factory for making the logger. - public static void RunPreStartup(ServerApplicationPaths appPaths, ILoggerFactory loggerFactory) + /// A representing the asynchronous operation. + public static async Task RunPreStartup(ServerApplicationPaths appPaths, ILoggerFactory loggerFactory) { var logger = loggerFactory.CreateLogger(); var migrations = _preStartupMigrationTypes @@ -93,7 +106,7 @@ namespace Jellyfin.Server.Migrations : new ServerConfiguration(); HandleStartupWizardCondition(migrations, migrationOptions, serverConfig.IsStartupWizardCompleted, logger); - PerformMigrations(migrations, migrationOptions, options => xmlSerializer.SerializeToFile(options, migrationConfigPath), logger); + await PerformMigrations(migrations, migrationOptions, options => xmlSerializer.SerializeToFile(options, migrationConfigPath), logger, null).ConfigureAwait(false); } private static void HandleStartupWizardCondition(IEnumerable migrations, MigrationOptions migrationOptions, bool isStartWizardCompleted, ILogger logger) @@ -109,35 +122,79 @@ namespace Jellyfin.Server.Migrations migrationOptions.Applied.AddRange(onlyOldInstalls.Select(m => (m.Id, m.Name))); } - private static void PerformMigrations(IMigrationRoutine[] migrations, MigrationOptions migrationOptions, Action saveConfiguration, ILogger logger) + private static async Task PerformMigrations( + IMigrationRoutine[] migrations, + MigrationOptions migrationOptions, + Action saveConfiguration, + ILogger logger, + IJellyfinDatabaseProvider? jellyfinDatabaseProvider) { // save already applied migrations, and skip them thereafter saveConfiguration(migrationOptions); var appliedMigrationIds = migrationOptions.Applied.Select(m => m.Id).ToHashSet(); + var migrationsToBeApplied = migrations.Where(e => !appliedMigrationIds.Contains(e.Id)).ToArray(); - for (var i = 0; i < migrations.Length; i++) + string? migrationKey = null; + if (jellyfinDatabaseProvider is not null && migrationsToBeApplied.Any(f => f is IDatabaseMigrationRoutine)) { - var migrationRoutine = migrations[i]; - if (appliedMigrationIds.Contains(migrationRoutine.Id)) - { - logger.LogDebug("Skipping migration '{Name}' since it is already applied", migrationRoutine.Name); - continue; - } - - logger.LogInformation("Applying migration '{Name}'", migrationRoutine.Name); - + logger.LogInformation("Performing database backup"); try { - migrationRoutine.Perform(); + migrationKey = await jellyfinDatabaseProvider.MigrationBackupFast(CancellationToken.None).ConfigureAwait(false); + logger.LogInformation("Database backup with key '{BackupKey}' has been successfully created.", migrationKey); } - catch (Exception ex) + catch (NotImplementedException) { - logger.LogError(ex, "Could not apply migration '{Name}'", migrationRoutine.Name); - throw; + logger.LogWarning("Could not perform backup of database before migration because provider does not support it"); + } + } + + List databaseMigrations = []; + try + { + foreach (var migrationRoutine in migrationsToBeApplied) + { + logger.LogInformation("Applying migration '{Name}'", migrationRoutine.Name); + var isDbMigration = migrationRoutine is IDatabaseMigrationRoutine; + + if (isDbMigration) + { + databaseMigrations.Add(migrationRoutine); + } + + try + { + migrationRoutine.Perform(); + } + catch (Exception ex) + { + logger.LogError(ex, "Could not apply migration '{Name}'", migrationRoutine.Name); + throw; + } + + // Mark the migration as completed + logger.LogInformation("Migration '{Name}' applied successfully", migrationRoutine.Name); + if (!isDbMigration) + { + migrationOptions.Applied.Add((migrationRoutine.Id, migrationRoutine.Name)); + saveConfiguration(migrationOptions); + logger.LogDebug("Migration '{Name}' marked as applied in configuration.", migrationRoutine.Name); + } + } + } + catch (Exception) when (migrationKey is not null && jellyfinDatabaseProvider is not null) + { + if (databaseMigrations.Count != 0) + { + logger.LogInformation("Rolling back database as migrations reported failure."); + await jellyfinDatabaseProvider.RestoreBackupFast(migrationKey, CancellationToken.None).ConfigureAwait(false); } - // Mark the migration as completed - logger.LogInformation("Migration '{Name}' applied successfully", migrationRoutine.Name); + throw; + } + + foreach (var migrationRoutine in databaseMigrations) + { migrationOptions.Applied.Add((migrationRoutine.Id, migrationRoutine.Name)); saveConfiguration(migrationOptions); logger.LogDebug("Migration '{Name}' marked as applied in configuration.", migrationRoutine.Name); diff --git a/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateNetworkConfiguration.cs b/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateNetworkConfiguration.cs index 49960f4305..09b2921714 100644 --- a/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateNetworkConfiguration.cs +++ b/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateNetworkConfiguration.cs @@ -1,3 +1,5 @@ +#pragma warning disable CS0618 // obsolete + using System; using System.IO; using System.Xml; diff --git a/Jellyfin.Server/Migrations/PreStartupRoutines/RenameEnableGroupingIntoCollections.cs b/Jellyfin.Server/Migrations/PreStartupRoutines/RenameEnableGroupingIntoCollections.cs new file mode 100644 index 0000000000..0a37b35a6a --- /dev/null +++ b/Jellyfin.Server/Migrations/PreStartupRoutines/RenameEnableGroupingIntoCollections.cs @@ -0,0 +1,63 @@ +using System; +using System.IO; +using System.Linq; +using System.Text; +using System.Xml.Linq; +using Emby.Server.Implementations; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.PreStartupRoutines; + +/// +public class RenameEnableGroupingIntoCollections : IMigrationRoutine +{ + private readonly ServerApplicationPaths _applicationPaths; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// An instance of . + /// An instance of the interface. + public RenameEnableGroupingIntoCollections(ServerApplicationPaths applicationPaths, ILoggerFactory loggerFactory) + { + _applicationPaths = applicationPaths; + _logger = loggerFactory.CreateLogger(); + } + + /// + public Guid Id => Guid.Parse("E73B777D-CD5C-4E71-957A-B86B3660B7CF"); + + /// + public string Name => nameof(RenameEnableGroupingIntoCollections); + + /// + public bool PerformOnNewInstall => false; + + /// + public void Perform() + { + string path = Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "system.xml"); + if (!File.Exists(path)) + { + _logger.LogWarning("Configuration file not found: {Path}", path); + return; + } + + try + { + XDocument xmlDocument = XDocument.Load(path); + var element = xmlDocument.Descendants("EnableGroupingIntoCollections").FirstOrDefault(); + if (element is not null) + { + element.Name = "EnableGroupingMoviesIntoCollections"; + _logger.LogInformation("The tag was successfully renamed to ."); + xmlDocument.Save(path); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while updating the XML file: {Message}", ex.Message); + } + } +} diff --git a/Jellyfin.Server/Migrations/Routines/CreateUserLoggingConfigFile.cs b/Jellyfin.Server/Migrations/Routines/CreateUserLoggingConfigFile.cs index ee4f8b0bab..5a8ef2e1cd 100644 --- a/Jellyfin.Server/Migrations/Routines/CreateUserLoggingConfigFile.cs +++ b/Jellyfin.Server/Migrations/Routines/CreateUserLoggingConfigFile.cs @@ -46,7 +46,7 @@ namespace Jellyfin.Server.Migrations.Routines public Guid Id => Guid.Parse("{EF103419-8451-40D8-9F34-D1A8E93A1679}"); /// - public string Name => "CreateLoggingConfigHeirarchy"; + public string Name => "CreateLoggingConfigHierarchy"; /// public bool PerformOnNewInstall => false; diff --git a/Jellyfin.Server/Migrations/Routines/FixPlaylistOwner.cs b/Jellyfin.Server/Migrations/Routines/FixPlaylistOwner.cs index 3655a610d3..192c170b26 100644 --- a/Jellyfin.Server/Migrations/Routines/FixPlaylistOwner.cs +++ b/Jellyfin.Server/Migrations/Routines/FixPlaylistOwner.cs @@ -15,12 +15,12 @@ namespace Jellyfin.Server.Migrations.Routines; /// internal class FixPlaylistOwner : IMigrationRoutine { - private readonly ILogger _logger; + private readonly ILogger _logger; private readonly ILibraryManager _libraryManager; private readonly IPlaylistManager _playlistManager; public FixPlaylistOwner( - ILogger logger, + ILogger logger, ILibraryManager libraryManager, IPlaylistManager playlistManager) { diff --git a/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs index 2f23cb1f8f..e9fe9abceb 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs @@ -2,8 +2,8 @@ using System; using System.Collections.Generic; using System.IO; using Emby.Server.Implementations.Data; -using Jellyfin.Data.Entities; -using Jellyfin.Server.Implementations; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; diff --git a/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs index c845beef2f..feaf46c843 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs @@ -2,8 +2,8 @@ using System; using System.Collections.Generic; using System.IO; using Emby.Server.Implementations.Data; -using Jellyfin.Data.Entities.Security; -using Jellyfin.Server.Implementations; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities.Security; using MediaBrowser.Controller; using MediaBrowser.Controller.Library; using Microsoft.Data.Sqlite; diff --git a/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs index 502a37cde1..a8fa2e52a1 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs @@ -5,9 +5,9 @@ using System.Linq; using System.Text.Json; using System.Text.Json.Serialization; using Emby.Server.Implementations.Data; -using Jellyfin.Data.Entities; -using Jellyfin.Data.Enums; -using Jellyfin.Server.Implementations; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using MediaBrowser.Controller; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Dto; diff --git a/Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs b/Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs new file mode 100644 index 0000000000..b8e69db8e7 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs @@ -0,0 +1,176 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text.Json; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Extensions.Json; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// +/// Migration to move extracted files to the new directories. +/// +public class MigrateKeyframeData : IDatabaseMigrationRoutine +{ + private readonly ILibraryManager _libraryManager; + private readonly ILogger _logger; + private readonly IApplicationPaths _appPaths; + private readonly IDbContextFactory _dbProvider; + private static readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// The logger. + /// Instance of the interface. + /// The EFCore db factory. + public MigrateKeyframeData( + ILibraryManager libraryManager, + ILogger logger, + IApplicationPaths appPaths, + IDbContextFactory dbProvider) + { + _libraryManager = libraryManager; + _logger = logger; + _appPaths = appPaths; + _dbProvider = dbProvider; + } + + private string KeyframeCachePath => Path.Combine(_appPaths.DataPath, "keyframes"); + + /// + public Guid Id => new("EA4bCAE1-09A4-428E-9B90-4B4FD2EA1B24"); + + /// + public string Name => "MigrateKeyframeData"; + + /// + public bool PerformOnNewInstall => false; + + /// + public void Perform() + { + const int Limit = 100; + int itemCount = 0, offset = 0, previousCount; + + var sw = Stopwatch.StartNew(); + var itemsQuery = new InternalItemsQuery + { + MediaTypes = [MediaType.Video], + SourceTypes = [SourceType.Library], + IsVirtualItem = false, + IsFolder = false + }; + + using var context = _dbProvider.CreateDbContext(); + context.KeyframeData.ExecuteDelete(); + using var transaction = context.Database.BeginTransaction(); + List keyframes = []; + + do + { + var result = _libraryManager.GetItemsResult(itemsQuery); + _logger.LogInformation("Importing keyframes for {Count} items", result.TotalRecordCount); + + var items = result.Items; + previousCount = items.Count; + offset += Limit; + foreach (var item in items) + { + if (TryGetKeyframeData(item, out var data)) + { + keyframes.Add(data); + } + + if (++itemCount % 10_000 == 0) + { + context.KeyframeData.AddRange(keyframes); + keyframes.Clear(); + _logger.LogInformation("Imported keyframes for {Count} items in {Time}", itemCount, sw.Elapsed); + } + } + } while (previousCount == Limit); + + context.KeyframeData.AddRange(keyframes); + context.SaveChanges(); + transaction.Commit(); + + _logger.LogInformation("Imported keyframes for {Count} items in {Time}", itemCount, sw.Elapsed); + + if (Directory.Exists(KeyframeCachePath)) + { + Directory.Delete(KeyframeCachePath, true); + } + } + + private bool TryGetKeyframeData(BaseItem item, [NotNullWhen(true)] out KeyframeData? data) + { + data = null; + var path = item.Path; + if (!string.IsNullOrEmpty(path)) + { + var cachePath = GetCachePath(KeyframeCachePath, path); + if (TryReadFromCache(cachePath, out var keyframeData)) + { + data = new() + { + ItemId = item.Id, + KeyframeTicks = keyframeData.KeyframeTicks.ToList(), + TotalDuration = keyframeData.TotalDuration + }; + + return true; + } + } + + return false; + } + + private string? GetCachePath(string keyframeCachePath, string filePath) + { + DateTime? lastWriteTimeUtc; + try + { + lastWriteTimeUtc = File.GetLastWriteTimeUtc(filePath); + } + catch (IOException e) + { + _logger.LogDebug("Skipping {Path}: {Exception}", filePath, e.Message); + + return null; + } + + ReadOnlySpan filename = (filePath + "_" + lastWriteTimeUtc.Value.Ticks.ToString(CultureInfo.InvariantCulture)).GetMD5() + ".json"; + var prefix = filename[..1]; + + return Path.Join(keyframeCachePath, prefix, filename); + } + + private static bool TryReadFromCache(string? cachePath, [NotNullWhen(true)] out MediaEncoding.Keyframes.KeyframeData? cachedResult) + { + if (File.Exists(cachePath)) + { + var bytes = File.ReadAllBytes(cachePath); + cachedResult = JsonSerializer.Deserialize(bytes, _jsonOptions); + + return cachedResult is not null; + } + + cachedResult = null; + + return false; + } +} diff --git a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs new file mode 100644 index 0000000000..105fd555f6 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs @@ -0,0 +1,1392 @@ +#pragma warning disable RS0030 // Do not use banned APIs + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Data; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using Emby.Server.Implementations.Data; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Extensions; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Entities; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using BaseItemEntity = Jellyfin.Database.Implementations.Entities.BaseItemEntity; +using Chapter = Jellyfin.Database.Implementations.Entities.Chapter; + +namespace Jellyfin.Server.Migrations.Routines; + +/// +/// The migration routine for migrating the userdata database to EF Core. +/// +internal class MigrateLibraryDb : IDatabaseMigrationRoutine +{ + private const string DbFilename = "library.db"; + + private readonly ILogger _logger; + private readonly IServerApplicationPaths _paths; + private readonly IJellyfinDatabaseProvider _jellyfinDatabaseProvider; + private readonly IDbContextFactory _provider; + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + /// The database provider. + /// The server application paths. + /// The database provider for special access. + public MigrateLibraryDb( + ILogger logger, + IDbContextFactory provider, + IServerApplicationPaths paths, + IJellyfinDatabaseProvider jellyfinDatabaseProvider) + { + _logger = logger; + _provider = provider; + _paths = paths; + _jellyfinDatabaseProvider = jellyfinDatabaseProvider; + } + + /// + public Guid Id => Guid.Parse("36445464-849f-429f-9ad0-bb130efa0664"); + + /// + public string Name => "MigrateLibraryDbData"; + + /// + public bool PerformOnNewInstall => false; // TODO Change back after testing + + /// + public void Perform() + { + _logger.LogInformation("Migrating the userdata from library.db may take a while, do not stop Jellyfin."); + + var dataPath = _paths.DataPath; + var libraryDbPath = Path.Combine(dataPath, DbFilename); + using var connection = new SqliteConnection($"Filename={libraryDbPath};Mode=ReadOnly"); + + var fullOperationTimer = new Stopwatch(); + fullOperationTimer.Start(); + + using (var operation = GetPreparedDbContext("Cleanup database")) + { + operation.JellyfinDbContext.AttachmentStreamInfos.ExecuteDelete(); + operation.JellyfinDbContext.BaseItems.ExecuteDelete(); + operation.JellyfinDbContext.ItemValues.ExecuteDelete(); + operation.JellyfinDbContext.UserData.ExecuteDelete(); + operation.JellyfinDbContext.MediaStreamInfos.ExecuteDelete(); + operation.JellyfinDbContext.Peoples.ExecuteDelete(); + operation.JellyfinDbContext.PeopleBaseItemMap.ExecuteDelete(); + operation.JellyfinDbContext.Chapters.ExecuteDelete(); + operation.JellyfinDbContext.AncestorIds.ExecuteDelete(); + } + + var legacyBaseItemWithUserKeys = new Dictionary(); + connection.Open(); + + var baseItemIds = new HashSet(); + using (var operation = GetPreparedDbContext("moving TypedBaseItem")) + { + const string typedBaseItemsQuery = + """ + SELECT guid, type, data, StartDate, EndDate, ChannelId, IsMovie, + IsSeries, EpisodeTitle, IsRepeat, CommunityRating, CustomRating, IndexNumber, IsLocked, PreferredMetadataLanguage, + PreferredMetadataCountryCode, Width, Height, DateLastRefreshed, Name, Path, PremiereDate, Overview, ParentIndexNumber, + ProductionYear, OfficialRating, ForcedSortName, RunTimeTicks, Size, DateCreated, DateModified, Genres, ParentId, TopParentId, + Audio, ExternalServiceId, IsInMixedFolder, DateLastSaved, LockedFields, Studios, Tags, TrailerTypes, OriginalTitle, PrimaryVersionId, + DateLastMediaAdded, Album, LUFS, NormalizationGain, CriticRating, IsVirtualItem, SeriesName, UserDataKey, SeasonName, SeasonId, SeriesId, + PresentationUniqueKey, InheritedParentalRatingValue, ExternalSeriesId, Tagline, ProviderIds, Images, ProductionLocations, ExtraIds, TotalBitrate, + ExtraType, Artists, AlbumArtists, ExternalId, SeriesPresentationUniqueKey, ShowId, OwnerId, MediaType, SortName, CleanName, UnratedType FROM TypedBaseItems + """; + using (new TrackedMigrationStep("Loading TypedBaseItems", _logger)) + { + foreach (SqliteDataReader dto in connection.Query(typedBaseItemsQuery)) + { + var baseItem = GetItem(dto); + operation.JellyfinDbContext.BaseItems.Add(baseItem.BaseItem); + baseItemIds.Add(baseItem.BaseItem.Id); + foreach (var dataKey in baseItem.LegacyUserDataKey) + { + legacyBaseItemWithUserKeys[dataKey] = baseItem.BaseItem; + } + } + } + + using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.BaseItems.Local.Count} BaseItem entries", _logger)) + { + operation.JellyfinDbContext.SaveChanges(); + } + } + + using (var operation = GetPreparedDbContext("moving ItemValues")) + { + // do not migrate inherited types as they are now properly mapped in search and lookup. + const string itemValueQuery = + """ + SELECT ItemId, Type, Value, CleanValue FROM ItemValues + WHERE Type <> 6 AND EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = ItemValues.ItemId) + """; + + // EFCores local lookup sucks. We cannot use context.ItemValues.Local here because its just super slow. + var localItems = new Dictionary<(int Type, string Value), (Database.Implementations.Entities.ItemValue ItemValue, List ItemIds)>(); + using (new TrackedMigrationStep("loading ItemValues", _logger)) + { + foreach (SqliteDataReader dto in connection.Query(itemValueQuery)) + { + var itemId = dto.GetGuid(0); + var entity = GetItemValue(dto); + var key = ((int)entity.Type, entity.Value); + if (!localItems.TryGetValue(key, out var existing)) + { + localItems[key] = existing = (entity, []); + } + + existing.ItemIds.Add(itemId); + } + + foreach (var item in localItems) + { + operation.JellyfinDbContext.ItemValues.Add(item.Value.ItemValue); + operation.JellyfinDbContext.ItemValuesMap.AddRange(item.Value.ItemIds.Distinct().Select(f => new ItemValueMap() + { + Item = null!, + ItemValue = null!, + ItemId = f, + ItemValueId = item.Value.ItemValue.ItemValueId + })); + } + } + + using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.ItemValues.Local.Count} ItemValues entries", _logger)) + { + operation.JellyfinDbContext.SaveChanges(); + } + } + + using (var operation = GetPreparedDbContext("moving UserData")) + { + var queryResult = connection.Query( + """ + SELECT key, userId, rating, played, playCount, isFavorite, playbackPositionTicks, lastPlayedDate, AudioStreamIndex, SubtitleStreamIndex FROM UserDatas + + WHERE EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.UserDataKey = UserDatas.key) + """); + + using (new TrackedMigrationStep("loading UserData", _logger)) + { + var users = operation.JellyfinDbContext.Users.AsNoTracking().ToImmutableArray(); + var userIdBlacklist = new HashSet(); + + foreach (var entity in queryResult) + { + var userData = GetUserData(users, entity, userIdBlacklist); + if (userData is null) + { + var userDataId = entity.GetString(0); + var internalUserId = entity.GetInt32(1); + + if (!userIdBlacklist.Contains(internalUserId)) + { + _logger.LogError("Was not able to migrate user data with key {0} because its id {InternalId} does not match any existing user.", userDataId, internalUserId); + userIdBlacklist.Add(internalUserId); + } + + continue; + } + + if (!legacyBaseItemWithUserKeys.TryGetValue(userData.CustomDataKey!, out var refItem)) + { + _logger.LogError("Was not able to migrate user data with key {0} because it does not reference a valid BaseItem.", entity.GetString(0)); + continue; + } + + userData.ItemId = refItem.Id; + operation.JellyfinDbContext.UserData.Add(userData); + } + + users.Clear(); + } + + legacyBaseItemWithUserKeys.Clear(); + + using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.UserData.Local.Count} UserData entries", _logger)) + { + operation.JellyfinDbContext.SaveChanges(); + } + } + + using (var operation = GetPreparedDbContext("moving MediaStreamInfos")) + { + const string mediaStreamQuery = + """ + SELECT ItemId, StreamIndex, StreamType, Codec, Language, ChannelLayout, Profile, AspectRatio, Path, + IsInterlaced, BitRate, Channels, SampleRate, IsDefault, IsForced, IsExternal, Height, Width, + AverageFrameRate, RealFrameRate, Level, PixelFormat, BitDepth, IsAnamorphic, RefFrames, CodecTag, + Comment, NalLengthSize, IsAvc, Title, TimeBase, CodecTimeBase, ColorPrimaries, ColorSpace, ColorTransfer, + DvVersionMajor, DvVersionMinor, DvProfile, DvLevel, RpuPresentFlag, ElPresentFlag, BlPresentFlag, DvBlSignalCompatibilityId, IsHearingImpaired + FROM MediaStreams + WHERE EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = MediaStreams.ItemId) + """; + + using (new TrackedMigrationStep("loading MediaStreamInfos", _logger)) + { + foreach (SqliteDataReader dto in connection.Query(mediaStreamQuery)) + { + operation.JellyfinDbContext.MediaStreamInfos.Add(GetMediaStream(dto)); + } + } + + using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.MediaStreamInfos.Local.Count} MediaStreamInfos entries", _logger)) + { + operation.JellyfinDbContext.SaveChanges(); + } + } + + using (var operation = GetPreparedDbContext("moving AttachmentStreamInfos")) + { + const string mediaAttachmentQuery = + """ + SELECT ItemId, AttachmentIndex, Codec, CodecTag, Comment, filename, MIMEType + FROM mediaattachments + WHERE EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = mediaattachments.ItemId) + """; + + using (new TrackedMigrationStep("loading AttachmentStreamInfos", _logger)) + { + foreach (SqliteDataReader dto in connection.Query(mediaAttachmentQuery)) + { + operation.JellyfinDbContext.AttachmentStreamInfos.Add(GetMediaAttachment(dto)); + } + } + + using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.AttachmentStreamInfos.Local.Count} AttachmentStreamInfos entries", _logger)) + { + operation.JellyfinDbContext.SaveChanges(); + } + } + + using (var operation = GetPreparedDbContext("moving People")) + { + const string personsQuery = + """ + SELECT ItemId, Name, Role, PersonType, SortOrder FROM People + WHERE EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = People.ItemId) + """; + + var peopleCache = new Dictionary Items)>(); + + using (new TrackedMigrationStep("loading People", _logger)) + { + foreach (SqliteDataReader reader in connection.Query(personsQuery)) + { + var itemId = reader.GetGuid(0); + if (!baseItemIds.Contains(itemId)) + { + _logger.LogError("Dont save person {0} because its not in use by any BaseItem", reader.GetString(1)); + continue; + } + + var entity = GetPerson(reader); + if (!peopleCache.TryGetValue(entity.Name, out var personCache)) + { + peopleCache[entity.Name] = personCache = (entity, []); + } + + if (reader.TryGetString(2, out var role)) + { + } + + int? sortOrder = reader.IsDBNull(4) ? null : reader.GetInt32(4); + + personCache.Items.Add(new PeopleBaseItemMap() + { + Item = null!, + ItemId = itemId, + People = null!, + PeopleId = personCache.Person.Id, + ListOrder = sortOrder, + SortOrder = sortOrder, + Role = role + }); + } + + baseItemIds.Clear(); + + foreach (var item in peopleCache) + { + operation.JellyfinDbContext.Peoples.Add(item.Value.Person); + operation.JellyfinDbContext.PeopleBaseItemMap.AddRange(item.Value.Items.DistinctBy(e => (e.ItemId, e.PeopleId))); + } + + peopleCache.Clear(); + } + + using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.Peoples.Local.Count} People entries and {operation.JellyfinDbContext.PeopleBaseItemMap.Local.Count} maps", _logger)) + { + operation.JellyfinDbContext.SaveChanges(); + } + } + + using (var operation = GetPreparedDbContext("moving Chapters")) + { + const string chapterQuery = + """ + SELECT ItemId,StartPositionTicks,Name,ImagePath,ImageDateModified,ChapterIndex from Chapters2 + WHERE EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = Chapters2.ItemId) + """; + + using (new TrackedMigrationStep("loading Chapters", _logger)) + { + foreach (SqliteDataReader dto in connection.Query(chapterQuery)) + { + var chapter = GetChapter(dto); + operation.JellyfinDbContext.Chapters.Add(chapter); + } + } + + using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.Chapters.Local.Count} Chapters entries", _logger)) + { + operation.JellyfinDbContext.SaveChanges(); + } + } + + using (var operation = GetPreparedDbContext("moving AncestorIds")) + { + const string ancestorIdsQuery = + """ + SELECT ItemId, AncestorId, AncestorIdText FROM AncestorIds + WHERE + EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = AncestorIds.ItemId) + AND + EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = AncestorIds.AncestorId) + """; + + using (new TrackedMigrationStep("loading AncestorIds", _logger)) + { + foreach (SqliteDataReader dto in connection.Query(ancestorIdsQuery)) + { + var ancestorId = GetAncestorId(dto); + operation.JellyfinDbContext.AncestorIds.Add(ancestorId); + } + } + + using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.AncestorIds.Local.Count} AncestorId entries", _logger)) + { + operation.JellyfinDbContext.SaveChanges(); + } + } + + connection.Close(); + + _logger.LogInformation("Migration of the Library.db done."); + _logger.LogInformation("Migrating Library db took {0}.", fullOperationTimer.Elapsed); + + SqliteConnection.ClearAllPools(); + + _logger.LogInformation("Move {0} to {1}.", libraryDbPath, libraryDbPath + ".old"); + File.Move(libraryDbPath, libraryDbPath + ".old", true); + + _jellyfinDatabaseProvider.RunScheduledOptimisation(CancellationToken.None).ConfigureAwait(false).GetAwaiter().GetResult(); + } + + private DatabaseMigrationStep GetPreparedDbContext(string operationName) + { + var dbContext = _provider.CreateDbContext(); + dbContext.ChangeTracker.AutoDetectChangesEnabled = false; + dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; + return new DatabaseMigrationStep(dbContext, operationName, _logger); + } + + private UserData? GetUserData(ImmutableArray users, SqliteDataReader dto, HashSet userIdBlacklist) + { + var internalUserId = dto.GetInt32(1); + var user = users.FirstOrDefault(e => e.InternalId == internalUserId); + + if (user is null) + { + if (userIdBlacklist.Contains(internalUserId)) + { + return null; + } + + _logger.LogError("Tried to find user with index '{Idx}' but there are only '{MaxIdx}' users.", internalUserId, users.Length); + return null; + } + + var oldKey = dto.GetString(0); + + return new UserData() + { + ItemId = Guid.NewGuid(), + CustomDataKey = oldKey, + UserId = user.Id, + Rating = dto.IsDBNull(2) ? null : dto.GetDouble(2), + Played = dto.GetBoolean(3), + PlayCount = dto.GetInt32(4), + IsFavorite = dto.GetBoolean(5), + PlaybackPositionTicks = dto.GetInt64(6), + LastPlayedDate = dto.IsDBNull(7) ? null : dto.GetDateTime(7), + AudioStreamIndex = dto.IsDBNull(8) ? null : dto.GetInt32(8), + SubtitleStreamIndex = dto.IsDBNull(9) ? null : dto.GetInt32(9), + Likes = null, + User = null!, + Item = null! + }; + } + + private AncestorId GetAncestorId(SqliteDataReader reader) + { + return new AncestorId() + { + ItemId = reader.GetGuid(0), + ParentItemId = reader.GetGuid(1), + Item = null!, + ParentItem = null! + }; + } + + /// + /// Gets the chapter. + /// + /// The reader. + /// ChapterInfo. + private Chapter GetChapter(SqliteDataReader reader) + { + var chapter = new Chapter + { + StartPositionTicks = reader.GetInt64(1), + ChapterIndex = reader.GetInt32(5), + Item = null!, + ItemId = reader.GetGuid(0), + }; + + if (reader.TryGetString(2, out var chapterName)) + { + chapter.Name = chapterName; + } + + if (reader.TryGetString(3, out var imagePath)) + { + chapter.ImagePath = imagePath; + } + + if (reader.TryReadDateTime(4, out var imageDateModified)) + { + chapter.ImageDateModified = imageDateModified; + } + + return chapter; + } + + private ItemValue GetItemValue(SqliteDataReader reader) + { + return new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = (ItemValueType)reader.GetInt32(1), + Value = reader.GetString(2), + CleanValue = reader.GetString(3), + }; + } + + private People GetPerson(SqliteDataReader reader) + { + var item = new People + { + Id = Guid.NewGuid(), + Name = reader.GetString(1), + }; + + if (reader.TryGetString(3, out var type)) + { + item.PersonType = type; + } + + return item; + } + + /// + /// Gets the media stream. + /// + /// The reader. + /// MediaStream. + private MediaStreamInfo GetMediaStream(SqliteDataReader reader) + { + var item = new MediaStreamInfo + { + StreamIndex = reader.GetInt32(1), + StreamType = Enum.Parse(reader.GetString(2)), + Item = null!, + ItemId = reader.GetGuid(0), + AspectRatio = null!, + ChannelLayout = null!, + Codec = null!, + IsInterlaced = false, + Language = null!, + Path = null!, + Profile = null!, + }; + + if (reader.TryGetString(3, out var codec)) + { + item.Codec = codec; + } + + if (reader.TryGetString(4, out var language)) + { + item.Language = language; + } + + if (reader.TryGetString(5, out var channelLayout)) + { + item.ChannelLayout = channelLayout; + } + + if (reader.TryGetString(6, out var profile)) + { + item.Profile = profile; + } + + if (reader.TryGetString(7, out var aspectRatio)) + { + item.AspectRatio = aspectRatio; + } + + if (reader.TryGetString(8, out var path)) + { + item.Path = path; + } + + item.IsInterlaced = reader.GetBoolean(9); + + if (reader.TryGetInt32(10, out var bitrate)) + { + item.BitRate = bitrate; + } + + if (reader.TryGetInt32(11, out var channels)) + { + item.Channels = channels; + } + + if (reader.TryGetInt32(12, out var sampleRate)) + { + item.SampleRate = sampleRate; + } + + item.IsDefault = reader.GetBoolean(13); + item.IsForced = reader.GetBoolean(14); + item.IsExternal = reader.GetBoolean(15); + + if (reader.TryGetInt32(16, out var width)) + { + item.Width = width; + } + + if (reader.TryGetInt32(17, out var height)) + { + item.Height = height; + } + + if (reader.TryGetSingle(18, out var averageFrameRate)) + { + item.AverageFrameRate = averageFrameRate; + } + + if (reader.TryGetSingle(19, out var realFrameRate)) + { + item.RealFrameRate = realFrameRate; + } + + if (reader.TryGetSingle(20, out var level)) + { + item.Level = level; + } + + if (reader.TryGetString(21, out var pixelFormat)) + { + item.PixelFormat = pixelFormat; + } + + if (reader.TryGetInt32(22, out var bitDepth)) + { + item.BitDepth = bitDepth; + } + + if (reader.TryGetBoolean(23, out var isAnamorphic)) + { + item.IsAnamorphic = isAnamorphic; + } + + if (reader.TryGetInt32(24, out var refFrames)) + { + item.RefFrames = refFrames; + } + + if (reader.TryGetString(25, out var codecTag)) + { + item.CodecTag = codecTag; + } + + if (reader.TryGetString(26, out var comment)) + { + item.Comment = comment; + } + + if (reader.TryGetString(27, out var nalLengthSize)) + { + item.NalLengthSize = nalLengthSize; + } + + if (reader.TryGetBoolean(28, out var isAVC)) + { + item.IsAvc = isAVC; + } + + if (reader.TryGetString(29, out var title)) + { + item.Title = title; + } + + if (reader.TryGetString(30, out var timeBase)) + { + item.TimeBase = timeBase; + } + + if (reader.TryGetString(31, out var codecTimeBase)) + { + item.CodecTimeBase = codecTimeBase; + } + + if (reader.TryGetString(32, out var colorPrimaries)) + { + item.ColorPrimaries = colorPrimaries; + } + + if (reader.TryGetString(33, out var colorSpace)) + { + item.ColorSpace = colorSpace; + } + + if (reader.TryGetString(34, out var colorTransfer)) + { + item.ColorTransfer = colorTransfer; + } + + if (reader.TryGetInt32(35, out var dvVersionMajor)) + { + item.DvVersionMajor = dvVersionMajor; + } + + if (reader.TryGetInt32(36, out var dvVersionMinor)) + { + item.DvVersionMinor = dvVersionMinor; + } + + if (reader.TryGetInt32(37, out var dvProfile)) + { + item.DvProfile = dvProfile; + } + + if (reader.TryGetInt32(38, out var dvLevel)) + { + item.DvLevel = dvLevel; + } + + if (reader.TryGetInt32(39, out var rpuPresentFlag)) + { + item.RpuPresentFlag = rpuPresentFlag; + } + + if (reader.TryGetInt32(40, out var elPresentFlag)) + { + item.ElPresentFlag = elPresentFlag; + } + + if (reader.TryGetInt32(41, out var blPresentFlag)) + { + item.BlPresentFlag = blPresentFlag; + } + + if (reader.TryGetInt32(42, out var dvBlSignalCompatibilityId)) + { + item.DvBlSignalCompatibilityId = dvBlSignalCompatibilityId; + } + + item.IsHearingImpaired = reader.TryGetBoolean(43, out var result) && result; + + // if (reader.TryGetInt32(44, out var rotation)) + // { + // item.Rotation = rotation; + // } + + return item; + } + + /// + /// Gets the attachment. + /// + /// The reader. + /// MediaAttachment. + private AttachmentStreamInfo GetMediaAttachment(SqliteDataReader reader) + { + var item = new AttachmentStreamInfo + { + Index = reader.GetInt32(1), + Item = null!, + ItemId = reader.GetGuid(0), + }; + + if (reader.TryGetString(2, out var codec)) + { + item.Codec = codec; + } + + if (reader.TryGetString(3, out var codecTag)) + { + item.CodecTag = codecTag; + } + + if (reader.TryGetString(4, out var comment)) + { + item.Comment = comment; + } + + if (reader.TryGetString(5, out var fileName)) + { + item.Filename = fileName; + } + + if (reader.TryGetString(6, out var mimeType)) + { + item.MimeType = mimeType; + } + + return item; + } + + private (BaseItemEntity BaseItem, string[] LegacyUserDataKey) GetItem(SqliteDataReader reader) + { + var entity = new BaseItemEntity() + { + Id = reader.GetGuid(0), + Type = reader.GetString(1), + }; + + var index = 2; + + if (reader.TryGetString(index++, out var data)) + { + entity.Data = data; + } + + if (reader.TryReadDateTime(index++, out var startDate)) + { + entity.StartDate = startDate; + } + + if (reader.TryReadDateTime(index++, out var endDate)) + { + entity.EndDate = endDate; + } + + if (reader.TryGetGuid(index++, out var guid)) + { + entity.ChannelId = guid; + } + + if (reader.TryGetBoolean(index++, out var isMovie)) + { + entity.IsMovie = isMovie; + } + + if (reader.TryGetBoolean(index++, out var isSeries)) + { + entity.IsSeries = isSeries; + } + + if (reader.TryGetString(index++, out var episodeTitle)) + { + entity.EpisodeTitle = episodeTitle; + } + + if (reader.TryGetBoolean(index++, out var isRepeat)) + { + entity.IsRepeat = isRepeat; + } + + if (reader.TryGetSingle(index++, out var communityRating)) + { + entity.CommunityRating = communityRating; + } + + if (reader.TryGetString(index++, out var customRating)) + { + entity.CustomRating = customRating; + } + + if (reader.TryGetInt32(index++, out var indexNumber)) + { + entity.IndexNumber = indexNumber; + } + + if (reader.TryGetBoolean(index++, out var isLocked)) + { + entity.IsLocked = isLocked; + } + + if (reader.TryGetString(index++, out var preferredMetadataLanguage)) + { + entity.PreferredMetadataLanguage = preferredMetadataLanguage; + } + + if (reader.TryGetString(index++, out var preferredMetadataCountryCode)) + { + entity.PreferredMetadataCountryCode = preferredMetadataCountryCode; + } + + if (reader.TryGetInt32(index++, out var width)) + { + entity.Width = width; + } + + if (reader.TryGetInt32(index++, out var height)) + { + entity.Height = height; + } + + if (reader.TryReadDateTime(index++, out var dateLastRefreshed)) + { + entity.DateLastRefreshed = dateLastRefreshed; + } + + if (reader.TryGetString(index++, out var name)) + { + entity.Name = name; + } + + if (reader.TryGetString(index++, out var restorePath)) + { + entity.Path = restorePath; + } + + if (reader.TryReadDateTime(index++, out var premiereDate)) + { + entity.PremiereDate = premiereDate; + } + + if (reader.TryGetString(index++, out var overview)) + { + entity.Overview = overview; + } + + if (reader.TryGetInt32(index++, out var parentIndexNumber)) + { + entity.ParentIndexNumber = parentIndexNumber; + } + + if (reader.TryGetInt32(index++, out var productionYear)) + { + entity.ProductionYear = productionYear; + } + + if (reader.TryGetString(index++, out var officialRating)) + { + entity.OfficialRating = officialRating; + } + + if (reader.TryGetString(index++, out var forcedSortName)) + { + entity.ForcedSortName = forcedSortName; + } + + if (reader.TryGetInt64(index++, out var runTimeTicks)) + { + entity.RunTimeTicks = runTimeTicks; + } + + if (reader.TryGetInt64(index++, out var size)) + { + entity.Size = size; + } + + if (reader.TryReadDateTime(index++, out var dateCreated)) + { + entity.DateCreated = dateCreated; + } + + if (reader.TryReadDateTime(index++, out var dateModified)) + { + entity.DateModified = dateModified; + } + + if (reader.TryGetString(index++, out var genres)) + { + entity.Genres = genres; + } + + if (reader.TryGetGuid(index++, out var parentId)) + { + entity.ParentId = parentId; + } + + if (reader.TryGetGuid(index++, out var topParentId)) + { + entity.TopParentId = topParentId; + } + + if (reader.TryGetString(index++, out var audioString) && Enum.TryParse(audioString, out var audioType)) + { + entity.Audio = audioType; + } + + if (reader.TryGetString(index++, out var serviceName)) + { + entity.ExternalServiceId = serviceName; + } + + if (reader.TryGetBoolean(index++, out var isInMixedFolder)) + { + entity.IsInMixedFolder = isInMixedFolder; + } + + if (reader.TryReadDateTime(index++, out var dateLastSaved)) + { + entity.DateLastSaved = dateLastSaved; + } + + if (reader.TryGetString(index++, out var lockedFields)) + { + entity.LockedFields = lockedFields.Split('|').Select(Enum.Parse) + .Select(e => new BaseItemMetadataField() + { + Id = (int)e, + Item = entity, + ItemId = entity.Id + }) + .ToArray(); + } + + if (reader.TryGetString(index++, out var studios)) + { + entity.Studios = studios; + } + + if (reader.TryGetString(index++, out var tags)) + { + entity.Tags = tags; + } + + if (reader.TryGetString(index++, out var trailerTypes)) + { + entity.TrailerTypes = trailerTypes.Split('|').Select(Enum.Parse) + .Select(e => new BaseItemTrailerType() + { + Id = (int)e, + Item = entity, + ItemId = entity.Id + }) + .ToArray(); + } + + if (reader.TryGetString(index++, out var originalTitle)) + { + entity.OriginalTitle = originalTitle; + } + + if (reader.TryGetString(index++, out var primaryVersionId)) + { + entity.PrimaryVersionId = primaryVersionId; + } + + if (reader.TryReadDateTime(index++, out var dateLastMediaAdded)) + { + entity.DateLastMediaAdded = dateLastMediaAdded; + } + + if (reader.TryGetString(index++, out var album)) + { + entity.Album = album; + } + + if (reader.TryGetSingle(index++, out var lUFS)) + { + entity.LUFS = lUFS; + } + + if (reader.TryGetSingle(index++, out var normalizationGain)) + { + entity.NormalizationGain = normalizationGain; + } + + if (reader.TryGetSingle(index++, out var criticRating)) + { + entity.CriticRating = criticRating; + } + + if (reader.TryGetBoolean(index++, out var isVirtualItem)) + { + entity.IsVirtualItem = isVirtualItem; + } + + if (reader.TryGetString(index++, out var seriesName)) + { + entity.SeriesName = seriesName; + } + + var userDataKeys = new List(); + if (reader.TryGetString(index++, out var directUserDataKey)) + { + userDataKeys.Add(directUserDataKey); + } + + if (reader.TryGetString(index++, out var seasonName)) + { + entity.SeasonName = seasonName; + } + + if (reader.TryGetGuid(index++, out var seasonId)) + { + entity.SeasonId = seasonId; + } + + if (reader.TryGetGuid(index++, out var seriesId)) + { + entity.SeriesId = seriesId; + } + + if (reader.TryGetString(index++, out var presentationUniqueKey)) + { + entity.PresentationUniqueKey = presentationUniqueKey; + } + + if (reader.TryGetInt32(index++, out var parentalRating)) + { + entity.InheritedParentalRatingValue = parentalRating; + } + + if (reader.TryGetString(index++, out var externalSeriesId)) + { + entity.ExternalSeriesId = externalSeriesId; + } + + if (reader.TryGetString(index++, out var tagLine)) + { + entity.Tagline = tagLine; + } + + if (reader.TryGetString(index++, out var providerIds)) + { + entity.Provider = providerIds.Split('|').Select(e => e.Split("=")) + .Select(e => new BaseItemProvider() + { + Item = null!, + ProviderId = e[0], + ProviderValue = e[1] + }).ToArray(); + } + + if (reader.TryGetString(index++, out var imageInfos)) + { + entity.Images = DeserializeImages(imageInfos).Select(f => Map(entity.Id, f)).ToArray(); + } + + if (reader.TryGetString(index++, out var productionLocations)) + { + entity.ProductionLocations = productionLocations; + } + + if (reader.TryGetString(index++, out var extraIds)) + { + entity.ExtraIds = extraIds; + } + + if (reader.TryGetInt32(index++, out var totalBitrate)) + { + entity.TotalBitrate = totalBitrate; + } + + if (reader.TryGetString(index++, out var extraTypeString) && Enum.TryParse(extraTypeString, out var extraType)) + { + entity.ExtraType = extraType; + } + + if (reader.TryGetString(index++, out var artists)) + { + entity.Artists = artists; + } + + if (reader.TryGetString(index++, out var albumArtists)) + { + entity.AlbumArtists = albumArtists; + } + + if (reader.TryGetString(index++, out var externalId)) + { + entity.ExternalId = externalId; + } + + if (reader.TryGetString(index++, out var seriesPresentationUniqueKey)) + { + entity.SeriesPresentationUniqueKey = seriesPresentationUniqueKey; + } + + if (reader.TryGetString(index++, out var showId)) + { + entity.ShowId = showId; + } + + if (reader.TryGetString(index++, out var ownerId)) + { + entity.OwnerId = ownerId; + } + + if (reader.TryGetString(index++, out var mediaType)) + { + entity.MediaType = mediaType; + } + + if (reader.TryGetString(index++, out var sortName)) + { + entity.SortName = sortName; + } + + if (reader.TryGetString(index++, out var cleanName)) + { + entity.CleanName = cleanName; + } + + if (reader.TryGetString(index++, out var unratedType)) + { + entity.UnratedType = unratedType; + } + + var baseItem = BaseItemRepository.DeserialiseBaseItem(entity, _logger, null, false); + var dataKeys = baseItem.GetUserDataKeys(); + userDataKeys.AddRange(dataKeys); + + return (entity, userDataKeys.ToArray()); + } + + private static BaseItemImageInfo Map(Guid baseItemId, ItemImageInfo e) + { + return new BaseItemImageInfo() + { + ItemId = baseItemId, + Id = Guid.NewGuid(), + Path = e.Path, + Blurhash = e.BlurHash != null ? Encoding.UTF8.GetBytes(e.BlurHash) : null, + DateModified = e.DateModified, + Height = e.Height, + Width = e.Width, + ImageType = (ImageInfoImageType)e.Type, + Item = null! + }; + } + + internal ItemImageInfo[] DeserializeImages(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return Array.Empty(); + } + + // TODO The following is an ugly performance optimization, but it's extremely unlikely that the data in the database would be malformed + var valueSpan = value.AsSpan(); + var count = valueSpan.Count('|') + 1; + + var position = 0; + var result = new ItemImageInfo[count]; + foreach (var part in valueSpan.Split('|')) + { + var image = ItemImageInfoFromValueString(part); + + if (image is not null) + { + result[position++] = image; + } + } + + if (position == count) + { + return result; + } + + if (position == 0) + { + return Array.Empty(); + } + + // Extremely unlikely, but somehow one or more of the image strings were malformed. Cut the array. + return result[..position]; + } + + internal ItemImageInfo? ItemImageInfoFromValueString(ReadOnlySpan value) + { + const char Delimiter = '*'; + + var nextSegment = value.IndexOf(Delimiter); + if (nextSegment == -1) + { + return null; + } + + ReadOnlySpan path = value[..nextSegment]; + value = value[(nextSegment + 1)..]; + nextSegment = value.IndexOf(Delimiter); + if (nextSegment == -1) + { + return null; + } + + ReadOnlySpan dateModified = value[..nextSegment]; + value = value[(nextSegment + 1)..]; + nextSegment = value.IndexOf(Delimiter); + if (nextSegment == -1) + { + nextSegment = value.Length; + } + + ReadOnlySpan imageType = value[..nextSegment]; + + var image = new ItemImageInfo + { + Path = path.ToString() + }; + + if (long.TryParse(dateModified, CultureInfo.InvariantCulture, out var ticks) + && ticks >= DateTime.MinValue.Ticks + && ticks <= DateTime.MaxValue.Ticks) + { + image.DateModified = new DateTime(ticks, DateTimeKind.Utc); + } + else + { + return null; + } + + if (Enum.TryParse(imageType, true, out ImageType type)) + { + image.Type = type; + } + else + { + return null; + } + + // Optional parameters: width*height*blurhash + if (nextSegment + 1 < value.Length - 1) + { + value = value[(nextSegment + 1)..]; + nextSegment = value.IndexOf(Delimiter); + if (nextSegment == -1 || nextSegment == value.Length) + { + return image; + } + + ReadOnlySpan widthSpan = value[..nextSegment]; + + value = value[(nextSegment + 1)..]; + nextSegment = value.IndexOf(Delimiter); + if (nextSegment == -1) + { + nextSegment = value.Length; + } + + ReadOnlySpan heightSpan = value[..nextSegment]; + + if (int.TryParse(widthSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var width) + && int.TryParse(heightSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var height)) + { + image.Width = width; + image.Height = height; + } + + if (nextSegment < value.Length - 1) + { + value = value[(nextSegment + 1)..]; + var length = value.Length; + + Span blurHashSpan = stackalloc char[length]; + for (int i = 0; i < length; i++) + { + var c = value[i]; + blurHashSpan[i] = c switch + { + '/' => Delimiter, + '\\' => '|', + _ => c + }; + } + + image.BlurHash = new string(blurHashSpan); + } + } + + return image; + } + + private class TrackedMigrationStep : IDisposable + { + private readonly string _operationName; + private readonly ILogger _logger; + private readonly Stopwatch _operationTimer; + private bool _disposed; + + public TrackedMigrationStep(string operationName, ILogger logger) + { + _operationName = operationName; + _logger = logger; + _operationTimer = Stopwatch.StartNew(); + logger.LogInformation("Start {OperationName}", operationName); + } + + public bool Disposed + { + get => _disposed; + set => _disposed = value; + } + + public virtual void Dispose() + { + if (Disposed) + { + return; + } + + Disposed = true; + _logger.LogInformation("{OperationName} took '{Time}'", _operationName, _operationTimer.Elapsed); + } + } + + private sealed class DatabaseMigrationStep : TrackedMigrationStep + { + public DatabaseMigrationStep(JellyfinDbContext jellyfinDbContext, string operationName, ILogger logger) : base(operationName, logger) + { + JellyfinDbContext = jellyfinDbContext; + } + + public JellyfinDbContext JellyfinDbContext { get; } + + public override void Dispose() + { + if (Disposed) + { + return; + } + + JellyfinDbContext.Dispose(); + base.Dispose(); + } + } +} diff --git a/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs b/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs index 247e1d8450..c38beb7232 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs @@ -1,36 +1,33 @@ using System; -using System.Globalization; -using System.IO; -using Emby.Server.Implementations.Data; -using MediaBrowser.Controller; +using System.Linq; +using Jellyfin.Database.Implementations; using MediaBrowser.Model.Globalization; -using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace Jellyfin.Server.Migrations.Routines { /// - /// Migrate rating levels to new rating level system. + /// Migrate rating levels. /// - internal class MigrateRatingLevels : IMigrationRoutine + internal class MigrateRatingLevels : IDatabaseMigrationRoutine { - private const string DbFilename = "library.db"; private readonly ILogger _logger; - private readonly IServerApplicationPaths _applicationPaths; + private readonly IDbContextFactory _provider; private readonly ILocalizationManager _localizationManager; public MigrateRatingLevels( - IServerApplicationPaths applicationPaths, + IDbContextFactory provider, ILoggerFactory loggerFactory, ILocalizationManager localizationManager) { - _applicationPaths = applicationPaths; + _provider = provider; _localizationManager = localizationManager; _logger = loggerFactory.CreateLogger(); } /// - public Guid Id => Guid.Parse("{67445D54-B895-4B24-9F4C-35CE0690EA07}"); + public Guid Id => Guid.Parse("{98724538-EB11-40E3-931A-252C55BDDE7A}"); /// public string Name => "MigrateRatingLevels"; @@ -41,54 +38,37 @@ namespace Jellyfin.Server.Migrations.Routines /// public void Perform() { - var dbPath = Path.Combine(_applicationPaths.DataPath, DbFilename); - - // Back up the database before modifying any entries - for (int i = 1; ; i++) - { - var bakPath = string.Format(CultureInfo.InvariantCulture, "{0}.bak{1}", dbPath, i); - if (!File.Exists(bakPath)) - { - try - { - File.Copy(dbPath, bakPath); - _logger.LogInformation("Library database backed up to {BackupPath}", bakPath); - break; - } - catch (Exception ex) - { - _logger.LogError(ex, "Cannot make a backup of {Library} at path {BackupPath}", DbFilename, bakPath); - throw; - } - } - } - - // Migrate parental rating strings to new levels _logger.LogInformation("Recalculating parental rating levels based on rating string."); - using var connection = new SqliteConnection($"Filename={dbPath}"); - connection.Open(); - using (var transaction = connection.BeginTransaction()) + using var context = _provider.CreateDbContext(); + using var transaction = context.Database.BeginTransaction(); + var ratings = context.BaseItems.AsNoTracking().Select(e => e.OfficialRating).Distinct(); + foreach (var rating in ratings) { - var queryResult = connection.Query("SELECT DISTINCT OfficialRating FROM TypedBaseItems"); - foreach (var entry in queryResult) + if (string.IsNullOrEmpty(rating)) { - if (!entry.TryGetString(0, out var ratingString) || string.IsNullOrEmpty(ratingString)) - { - connection.Execute("UPDATE TypedBaseItems SET InheritedParentalRatingValue = NULL WHERE OfficialRating IS NULL OR OfficialRating='';"); - } - else - { - var ratingValue = _localizationManager.GetRatingLevel(ratingString)?.ToString(CultureInfo.InvariantCulture) ?? "NULL"; - - using var statement = connection.PrepareStatement("UPDATE TypedBaseItems SET InheritedParentalRatingValue = @Value WHERE OfficialRating = @Rating;"); - statement.TryBind("@Value", ratingValue); - statement.TryBind("@Rating", ratingString); - statement.ExecuteNonQuery(); - } + int? value = null; + context.BaseItems + .Where(e => e.OfficialRating == null || e.OfficialRating == string.Empty) + .ExecuteUpdate(f => f.SetProperty(e => e.InheritedParentalRatingValue, value)); + context.BaseItems + .Where(e => e.OfficialRating == null || e.OfficialRating == string.Empty) + .ExecuteUpdate(f => f.SetProperty(e => e.InheritedParentalRatingSubValue, value)); + } + else + { + var ratingValue = _localizationManager.GetRatingScore(rating); + var score = ratingValue?.Score; + var subScore = ratingValue?.SubScore; + context.BaseItems + .Where(e => e.OfficialRating == rating) + .ExecuteUpdate(f => f.SetProperty(e => e.InheritedParentalRatingValue, score)); + context.BaseItems + .Where(e => e.OfficialRating == rating) + .ExecuteUpdate(f => f.SetProperty(e => e.InheritedParentalRatingSubValue, subScore)); } - - transaction.Commit(); } + + transaction.Commit(); } } } diff --git a/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs index 7dcae5bd9d..1b5fab7a89 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs @@ -1,10 +1,11 @@ using System; using System.IO; using Emby.Server.Implementations.Data; -using Jellyfin.Data.Entities; -using Jellyfin.Data.Enums; +using Jellyfin.Data; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions.Json; -using Jellyfin.Server.Implementations; using Jellyfin.Server.Implementations.Users; using MediaBrowser.Controller; using MediaBrowser.Controller.Entities; @@ -111,7 +112,8 @@ namespace Jellyfin.Server.Migrations.Routines { Id = entry.GetGuid(1), InternalId = entry.GetInt64(0), - MaxParentalAgeRating = policy.MaxParentalRating, + MaxParentalRatingScore = policy.MaxParentalRating, + MaxParentalRatingSubScore = null, EnableUserPreferenceAccess = policy.EnableUserPreferenceAccess, RemoteClientBitrateLimit = policy.RemoteClientBitrateLimit, InvalidLoginAttemptCount = policy.InvalidLoginAttemptCount, diff --git a/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs b/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs new file mode 100644 index 0000000000..f63c5fd409 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs @@ -0,0 +1,299 @@ +#pragma warning disable CA5351 // Do Not Use Broken Cryptographic Algorithms + +using System; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using Jellyfin.Data.Enums; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.IO; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.MediaInfo; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// +/// Migration to move extracted files to the new directories. +/// +public class MoveExtractedFiles : IDatabaseMigrationRoutine +{ + private readonly IApplicationPaths _appPaths; + private readonly ILibraryManager _libraryManager; + private readonly ILogger _logger; + private readonly IMediaSourceManager _mediaSourceManager; + private readonly IPathManager _pathManager; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// Instance of the interface. + /// The logger. + /// Instance of the interface. + /// Instance of the interface. + public MoveExtractedFiles( + IApplicationPaths appPaths, + ILibraryManager libraryManager, + ILogger logger, + IMediaSourceManager mediaSourceManager, + IPathManager pathManager) + { + _appPaths = appPaths; + _libraryManager = libraryManager; + _logger = logger; + _mediaSourceManager = mediaSourceManager; + _pathManager = pathManager; + } + + private string SubtitleCachePath => Path.Combine(_appPaths.DataPath, "subtitles"); + + private string AttachmentCachePath => Path.Combine(_appPaths.DataPath, "attachments"); + + /// + public Guid Id => new("9063b0Ef-CFF1-4EDC-9A13-74093681A89B"); + + /// + public string Name => "MoveExtractedFiles"; + + /// + public bool PerformOnNewInstall => false; + + /// + public void Perform() + { + const int Limit = 500; + int itemCount = 0, offset = 0; + + var sw = Stopwatch.StartNew(); + var itemsQuery = new InternalItemsQuery + { + MediaTypes = [MediaType.Video], + SourceTypes = [SourceType.Library], + IsVirtualItem = false, + IsFolder = false, + Limit = Limit, + StartIndex = offset, + EnableTotalRecordCount = true, + }; + + var records = _libraryManager.GetItemsResult(itemsQuery).TotalRecordCount; + _logger.LogInformation("Checking {Count} items for movable extracted files.", records); + + // Make sure directories exist + Directory.CreateDirectory(SubtitleCachePath); + Directory.CreateDirectory(AttachmentCachePath); + + itemsQuery.EnableTotalRecordCount = false; + do + { + itemsQuery.StartIndex = offset; + var result = _libraryManager.GetItemsResult(itemsQuery); + + var items = result.Items; + foreach (var item in items) + { + if (MoveSubtitleAndAttachmentFiles(item)) + { + itemCount++; + } + } + + offset += Limit; + if (offset % 5_000 == 0) + { + _logger.LogInformation("Checked extracted files for {Count} items in {Time}.", offset, sw.Elapsed); + } + } while (offset < records); + + _logger.LogInformation("Checked {Checked} items - Moved files for {Items} items in {Time}.", records, itemCount, sw.Elapsed); + + // Get all subdirectories with 1 character names (those are the legacy directories) + var subdirectories = Directory.GetDirectories(SubtitleCachePath, "*", SearchOption.AllDirectories).Where(s => s.Length == SubtitleCachePath.Length + 2).ToList(); + subdirectories.AddRange(Directory.GetDirectories(AttachmentCachePath, "*", SearchOption.AllDirectories).Where(s => s.Length == AttachmentCachePath.Length + 2)); + + // Remove all legacy subdirectories + foreach (var subdir in subdirectories) + { + Directory.Delete(subdir, true); + } + + // Remove old cache path + var attachmentCachePath = Path.Join(_appPaths.CachePath, "attachments"); + if (Directory.Exists(attachmentCachePath)) + { + Directory.Delete(attachmentCachePath, true); + } + + _logger.LogInformation("Cleaned up left over subtitles and attachments."); + } + + private bool MoveSubtitleAndAttachmentFiles(BaseItem item) + { + var mediaStreams = item.GetMediaStreams().Where(s => s.Type == MediaStreamType.Subtitle && !s.IsExternal); + var itemIdString = item.Id.ToString("N", CultureInfo.InvariantCulture); + var modified = false; + foreach (var mediaStream in mediaStreams) + { + if (mediaStream.Codec is null) + { + continue; + } + + var mediaStreamIndex = mediaStream.Index; + var extension = GetSubtitleExtension(mediaStream.Codec); + var oldSubtitleCachePath = GetOldSubtitleCachePath(item.Path, mediaStream.Index, extension); + if (string.IsNullOrEmpty(oldSubtitleCachePath) || !File.Exists(oldSubtitleCachePath)) + { + continue; + } + + var newSubtitleCachePath = _pathManager.GetSubtitlePath(itemIdString, mediaStreamIndex, extension); + if (File.Exists(newSubtitleCachePath)) + { + File.Delete(oldSubtitleCachePath); + } + else + { + var newDirectory = Path.GetDirectoryName(newSubtitleCachePath); + if (newDirectory is not null) + { + Directory.CreateDirectory(newDirectory); + File.Move(oldSubtitleCachePath, newSubtitleCachePath, false); + _logger.LogDebug("Moved subtitle {Index} for {Item} from {Source} to {Destination}", mediaStreamIndex, item.Id, oldSubtitleCachePath, newSubtitleCachePath); + + modified = true; + } + } + } + + var attachments = _mediaSourceManager.GetMediaAttachments(item.Id).Where(a => !string.Equals(a.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase)).ToList(); + var shouldExtractOneByOne = attachments.Any(a => !string.IsNullOrEmpty(a.FileName) + && (a.FileName.Contains('/', StringComparison.OrdinalIgnoreCase) || a.FileName.Contains('\\', StringComparison.OrdinalIgnoreCase))); + foreach (var attachment in attachments) + { + var attachmentIndex = attachment.Index; + var oldAttachmentPath = GetOldAttachmentDataPath(item.Path, attachmentIndex); + if (string.IsNullOrEmpty(oldAttachmentPath) || !File.Exists(oldAttachmentPath)) + { + oldAttachmentPath = GetOldAttachmentCachePath(itemIdString, attachment, shouldExtractOneByOne); + if (string.IsNullOrEmpty(oldAttachmentPath) || !File.Exists(oldAttachmentPath)) + { + continue; + } + } + + var newAttachmentPath = _pathManager.GetAttachmentPath(itemIdString, attachment.FileName ?? attachmentIndex.ToString(CultureInfo.InvariantCulture)); + if (File.Exists(newAttachmentPath)) + { + File.Delete(oldAttachmentPath); + } + else + { + var newDirectory = Path.GetDirectoryName(newAttachmentPath); + if (newDirectory is not null) + { + Directory.CreateDirectory(newDirectory); + File.Move(oldAttachmentPath, newAttachmentPath, false); + _logger.LogDebug("Moved attachment {Index} for {Item} from {Source} to {Destination}", attachmentIndex, item.Id, oldAttachmentPath, newAttachmentPath); + + modified = true; + } + } + } + + return modified; + } + + private string? GetOldAttachmentDataPath(string? mediaPath, int attachmentStreamIndex) + { + if (mediaPath is null) + { + return null; + } + + string filename; + var protocol = _mediaSourceManager.GetPathProtocol(mediaPath); + if (protocol == MediaProtocol.File) + { + DateTime? date; + try + { + date = File.GetLastWriteTimeUtc(mediaPath); + } + catch (IOException e) + { + _logger.LogDebug("Skipping attachment at index {Index} for {Path}: {Exception}", attachmentStreamIndex, mediaPath, e.Message); + + return null; + } + + filename = (mediaPath + attachmentStreamIndex.ToString(CultureInfo.InvariantCulture) + "_" + date.Value.Ticks.ToString(CultureInfo.InvariantCulture)).GetMD5().ToString("D", CultureInfo.InvariantCulture); + } + else + { + filename = (mediaPath + attachmentStreamIndex.ToString(CultureInfo.InvariantCulture)).GetMD5().ToString("D", CultureInfo.InvariantCulture); + } + + return Path.Join(_appPaths.DataPath, "attachments", filename[..1], filename); + } + + private string? GetOldAttachmentCachePath(string mediaSourceId, MediaAttachment attachment, bool shouldExtractOneByOne) + { + var attachmentFolderPath = Path.Join(_appPaths.CachePath, "attachments", mediaSourceId); + if (shouldExtractOneByOne) + { + return Path.Join(attachmentFolderPath, attachment.Index.ToString(CultureInfo.InvariantCulture)); + } + + if (string.IsNullOrEmpty(attachment.FileName)) + { + return null; + } + + return Path.Join(attachmentFolderPath, attachment.FileName); + } + + private string? GetOldSubtitleCachePath(string path, int streamIndex, string outputSubtitleExtension) + { + DateTime? date; + try + { + date = File.GetLastWriteTimeUtc(path); + } + catch (IOException e) + { + _logger.LogDebug("Skipping subtitle at index {Index} for {Path}: {Exception}", streamIndex, path, e.Message); + + return null; + } + + var ticksParam = string.Empty; + ReadOnlySpan filename = new Guid(MD5.HashData(Encoding.Unicode.GetBytes(path + "_" + streamIndex.ToString(CultureInfo.InvariantCulture) + "_" + date.Value.Ticks.ToString(CultureInfo.InvariantCulture) + ticksParam))) + outputSubtitleExtension; + + return Path.Join(SubtitleCachePath, filename[..1], filename); + } + + private static string GetSubtitleExtension(string codec) + { + if (codec.ToLower(CultureInfo.InvariantCulture).Equals("ass", StringComparison.OrdinalIgnoreCase) + || codec.ToLower(CultureInfo.InvariantCulture).Equals("ssa", StringComparison.OrdinalIgnoreCase)) + { + return "." + codec; + } + else if (codec.Contains("pgs", StringComparison.OrdinalIgnoreCase)) + { + return ".sup"; + } + else + { + return ".srt"; + } + } +} diff --git a/Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs b/Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs index c1a9e88949..eeb11e14c1 100644 --- a/Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs +++ b/Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs @@ -4,7 +4,6 @@ using System.Globalization; using System.IO; using System.Linq; using Jellyfin.Data.Enums; -using MediaBrowser.Common; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Trickplay; @@ -16,7 +15,7 @@ namespace Jellyfin.Server.Migrations.Routines; /// /// Migration to move trickplay files to the new directory. /// -public class MoveTrickplayFiles : IMigrationRoutine +public class MoveTrickplayFiles : IDatabaseMigrationRoutine { private readonly ITrickplayManager _trickplayManager; private readonly IFileSystem _fileSystem; @@ -39,7 +38,7 @@ public class MoveTrickplayFiles : IMigrationRoutine } /// - public Guid Id => new("4EF123D5-8EFF-4B0B-869D-3AED07A60E1B"); + public Guid Id => new("9540D44A-D8DC-11EF-9CBB-B77274F77C52"); /// public string Name => "MoveTrickplayFiles"; @@ -89,6 +88,12 @@ public class MoveTrickplayFiles : IMigrationRoutine { _fileSystem.MoveDirectory(oldPath, newPath); } + + oldPath = GetNewOldTrickplayDirectory(item, trickplayInfo.TileWidth, trickplayInfo.TileHeight, trickplayInfo.Width, false); + if (_fileSystem.DirectoryExists(oldPath)) + { + _fileSystem.MoveDirectory(oldPath, newPath); + } } } while (previousCount == Limit); @@ -101,4 +106,20 @@ public class MoveTrickplayFiles : IMigrationRoutine return width.HasValue ? Path.Combine(path, width.Value.ToString(CultureInfo.InvariantCulture)) : path; } + + private string GetNewOldTrickplayDirectory(BaseItem item, int tileWidth, int tileHeight, int width, bool saveWithMedia = false) + { + var path = saveWithMedia + ? Path.Combine(item.ContainingFolderPath, Path.ChangeExtension(item.Path, ".trickplay")) + : Path.Combine(item.GetInternalMetadataPath(), "trickplay"); + + var subdirectory = string.Format( + CultureInfo.InvariantCulture, + "{0} - {1}x{2}", + width.ToString(CultureInfo.InvariantCulture), + tileWidth.ToString(CultureInfo.InvariantCulture), + tileHeight.ToString(CultureInfo.InvariantCulture)); + + return Path.Combine(path, subdirectory); + } } diff --git a/Jellyfin.Server/Migrations/Routines/RemoveDuplicatePlaylistChildren.cs b/Jellyfin.Server/Migrations/Routines/RemoveDuplicatePlaylistChildren.cs new file mode 100644 index 0000000000..e183a1d63a --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/RemoveDuplicatePlaylistChildren.cs @@ -0,0 +1,64 @@ +using System; +using System.Linq; +using System.Threading; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Playlists; + +namespace Jellyfin.Server.Migrations.Routines; + +/// +/// Remove duplicate playlist entries. +/// +internal class RemoveDuplicatePlaylistChildren : IMigrationRoutine +{ + private readonly ILibraryManager _libraryManager; + private readonly IPlaylistManager _playlistManager; + + public RemoveDuplicatePlaylistChildren( + ILibraryManager libraryManager, + IPlaylistManager playlistManager) + { + _libraryManager = libraryManager; + _playlistManager = playlistManager; + } + + /// + public Guid Id => Guid.Parse("{96C156A2-7A13-4B3B-A8B8-FB80C94D20C0}"); + + /// + public string Name => "RemoveDuplicatePlaylistChildren"; + + /// + public bool PerformOnNewInstall => false; + + /// + public void Perform() + { + var playlists = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.Playlist] + }) + .Cast() + .Where(p => !p.OpenAccess || !p.OwnerUserId.Equals(Guid.Empty)) + .ToArray(); + + if (playlists.Length > 0) + { + foreach (var playlist in playlists) + { + var linkedChildren = playlist.LinkedChildren; + if (linkedChildren.Length > 0) + { + var nullItemChildren = linkedChildren.Where(c => c.ItemId is null); + var deduplicatedChildren = linkedChildren.DistinctBy(c => c.ItemId); + var newLinkedChildren = nullItemChildren.Concat(deduplicatedChildren); + playlist.LinkedChildren = linkedChildren; + playlist.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).GetAwaiter().GetResult(); + _playlistManager.SavePlaylistFile(playlist); + } + } + } + } +} diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs index 295fb8112f..8d0bf73f6a 100644 --- a/Jellyfin.Server/Program.cs +++ b/Jellyfin.Server/Program.cs @@ -1,18 +1,24 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Globalization; using System.IO; using System.Linq; using System.Reflection; +using System.Threading; using System.Threading.Tasks; using CommandLine; using Emby.Server.Implementations; +using Jellyfin.Database.Implementations; using Jellyfin.Server.Extensions; using Jellyfin.Server.Helpers; -using Jellyfin.Server.Implementations; +using Jellyfin.Server.Implementations.StorageHelpers; +using Jellyfin.Server.ServerSetupApp; using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Net; using MediaBrowser.Controller; using Microsoft.AspNetCore.Hosting; +using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -42,6 +48,9 @@ namespace Jellyfin.Server public const string LoggingConfigFileSystem = "logging.json"; private static readonly SerilogLoggerFactory _loggerFactory = new SerilogLoggerFactory(); + private static SetupServer? _setupServer; + private static CoreAppHost? _appHost; + private static IHost? _jellyfinHost = null; private static long _startTimestamp; private static ILogger _logger = NullLogger.Instance; private static bool _restartOnShutdown; @@ -80,7 +89,8 @@ namespace Jellyfin.Server // Create an instance of the application configuration to use for application startup IConfiguration startupConfig = CreateAppConfiguration(options, appPaths); - + _setupServer = new SetupServer(static () => _jellyfinHost?.Services?.GetService(), appPaths, static () => _appHost, _loggerFactory, startupConfig); + await _setupServer.RunAsync().ConfigureAwait(false); StartupHelpers.InitializeLoggingFramework(startupConfig, appPaths); _logger = _loggerFactory.CreateLogger("Main"); @@ -112,8 +122,10 @@ namespace Jellyfin.Server } } + StorageHelper.TestCommonPathsForStorageCapacity(appPaths, _loggerFactory.CreateLogger()); + StartupHelpers.PerformStaticInitialization(); - Migrations.MigrationRunner.RunPreStartup(appPaths, _loggerFactory); + await Migrations.MigrationRunner.RunPreStartup(appPaths, _loggerFactory).ConfigureAwait(false); do { @@ -122,22 +134,25 @@ namespace Jellyfin.Server if (_restartOnShutdown) { _startTimestamp = Stopwatch.GetTimestamp(); + await _setupServer.StopAsync().ConfigureAwait(false); + await _setupServer.RunAsync().ConfigureAwait(false); } } while (_restartOnShutdown); + + _setupServer.Dispose(); } private static async Task StartServer(IServerApplicationPaths appPaths, StartupOptions options, IConfiguration startupConfig) { - using var appHost = new CoreAppHost( - appPaths, - _loggerFactory, - options, - startupConfig); - - IHost? host = null; + using CoreAppHost appHost = new CoreAppHost( + appPaths, + _loggerFactory, + options, + startupConfig); + _appHost = appHost; try { - host = Host.CreateDefaultBuilder() + _jellyfinHost = Host.CreateDefaultBuilder() .UseConsoleLifetime() .ConfigureServices(services => appHost.Init(services)) .ConfigureWebHostDefaults(webHostBuilder => @@ -154,14 +169,15 @@ namespace Jellyfin.Server .Build(); // Re-use the host service provider in the app host since ASP.NET doesn't allow a custom service collection. - appHost.ServiceProvider = host.Services; + appHost.ServiceProvider = _jellyfinHost.Services; - await appHost.InitializeServices().ConfigureAwait(false); - Migrations.MigrationRunner.Run(appHost, _loggerFactory); + await appHost.InitializeServices(startupConfig).ConfigureAwait(false); + await Migrations.MigrationRunner.Run(appHost, _loggerFactory).ConfigureAwait(false); try { - await host.StartAsync().ConfigureAwait(false); + await _setupServer!.StopAsync().ConfigureAwait(false); + await _jellyfinHost.StartAsync().ConfigureAwait(false); if (!OperatingSystem.IsWindows() && startupConfig.UseUnixSocket()) { @@ -180,7 +196,7 @@ namespace Jellyfin.Server _logger.LogInformation("Startup complete {Time:g}", Stopwatch.GetElapsedTime(_startTimestamp)); - await host.WaitForShutdownAsync().ConfigureAwait(false); + await _jellyfinHost.WaitForShutdownAsync().ConfigureAwait(false); _restartOnShutdown = appHost.ShouldRestart; } catch (Exception ex) @@ -194,18 +210,15 @@ namespace Jellyfin.Server if (appHost.ServiceProvider is not null) { _logger.LogInformation("Running query planner optimizations in the database... This might take a while"); - // Run before disposing the application - var context = await appHost.ServiceProvider.GetRequiredService>().CreateDbContextAsync().ConfigureAwait(false); - await using (context.ConfigureAwait(false)) - { - if (context.Database.IsSqlite()) - { - await context.Database.ExecuteSqlRawAsync("PRAGMA optimize").ConfigureAwait(false); - } - } + + var databaseProvider = appHost.ServiceProvider.GetRequiredService(); + using var shutdownSource = new CancellationTokenSource(); + shutdownSource.CancelAfter((int)TimeSpan.FromSeconds(60).TotalMicroseconds); + await databaseProvider.RunShutdownTask(shutdownSource.Token).ConfigureAwait(false); } - host?.Dispose(); + _appHost = null; + _jellyfinHost?.Dispose(); } } diff --git a/Jellyfin.Server/ServerSetupApp/SetupServer.cs b/Jellyfin.Server/ServerSetupApp/SetupServer.cs new file mode 100644 index 0000000000..3d4810bd7f --- /dev/null +++ b/Jellyfin.Server/ServerSetupApp/SetupServer.cs @@ -0,0 +1,220 @@ +using System; +using System.IO; +using System.Linq; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using Emby.Server.Implementations.Configuration; +using Emby.Server.Implementations.Serialization; +using Jellyfin.Networking.Manager; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller; +using MediaBrowser.Model.System; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Primitives; + +namespace Jellyfin.Server.ServerSetupApp; + +/// +/// Creates a fake application pipeline that will only exist for as long as the main app is not started. +/// +public sealed class SetupServer : IDisposable +{ + private readonly Func _networkManagerFactory; + private readonly IApplicationPaths _applicationPaths; + private readonly Func _serverFactory; + private readonly ILoggerFactory _loggerFactory; + private readonly IConfiguration _startupConfiguration; + private readonly ServerConfigurationManager _configurationManager; + private IHost? _startupServer; + private bool _disposed; + + /// + /// Initializes a new instance of the class. + /// + /// The networkmanager. + /// The application paths. + /// The servers application host. + /// The logger factory. + /// The startup configuration. + public SetupServer( + Func networkManagerFactory, + IApplicationPaths applicationPaths, + Func serverApplicationHostFactory, + ILoggerFactory loggerFactory, + IConfiguration startupConfiguration) + { + _networkManagerFactory = networkManagerFactory; + _applicationPaths = applicationPaths; + _serverFactory = serverApplicationHostFactory; + _loggerFactory = loggerFactory; + _startupConfiguration = startupConfiguration; + var xmlSerializer = new MyXmlSerializer(); + _configurationManager = new ServerConfigurationManager(_applicationPaths, loggerFactory, xmlSerializer); + _configurationManager.RegisterConfiguration(); + } + + /// + /// Starts the Bind-All Setup aspcore server to provide a reflection on the current core setup. + /// + /// A Task. + public async Task RunAsync() + { + ThrowIfDisposed(); + _startupServer = Host.CreateDefaultBuilder() + .UseConsoleLifetime() + .ConfigureServices(serv => + { + serv.AddHealthChecks() + .AddCheck("StartupCheck"); + }) + .ConfigureWebHostDefaults(webHostBuilder => + { + webHostBuilder + .UseKestrel((builderContext, options) => + { + var config = _configurationManager.GetNetworkConfiguration()!; + var knownBindInterfaces = NetworkManager.GetInterfacesCore(_loggerFactory.CreateLogger(), config.EnableIPv4, config.EnableIPv6); + knownBindInterfaces = NetworkManager.FilterBindSettings(config, knownBindInterfaces.ToList(), config.EnableIPv4, config.EnableIPv6); + var bindInterfaces = NetworkManager.GetAllBindInterfaces(false, _configurationManager, knownBindInterfaces, config.EnableIPv4, config.EnableIPv6); + Extensions.WebHostBuilderExtensions.SetupJellyfinWebServer( + bindInterfaces, + config.InternalHttpPort, + null, + null, + _startupConfiguration, + _applicationPaths, + _loggerFactory.CreateLogger(), + builderContext, + options); + }) + .Configure(app => + { + app.UseHealthChecks("/health"); + + app.Map("/startup/logger", loggerRoute => + { + loggerRoute.Run(async context => + { + var networkManager = _networkManagerFactory(); + if (context.Connection.RemoteIpAddress is null || networkManager is null || !networkManager.IsInLocalNetwork(context.Connection.RemoteIpAddress)) + { + context.Response.StatusCode = (int)HttpStatusCode.Unauthorized; + return; + } + + var logFilePath = new DirectoryInfo(_applicationPaths.LogDirectoryPath) + .EnumerateFiles() + .OrderBy(f => f.CreationTimeUtc) + .FirstOrDefault() + ?.FullName; + if (logFilePath is not null) + { + await context.Response.SendFileAsync(logFilePath, CancellationToken.None).ConfigureAwait(false); + } + }); + }); + + app.Map("/System/Info/Public", systemRoute => + { + systemRoute.Run(async context => + { + var jfApplicationHost = _serverFactory(); + + var retryCounter = 0; + while (jfApplicationHost is null && retryCounter < 5) + { + await Task.Delay(500).ConfigureAwait(false); + jfApplicationHost = _serverFactory(); + retryCounter++; + } + + if (jfApplicationHost is null) + { + context.Response.StatusCode = (int)HttpStatusCode.ServiceUnavailable; + context.Response.Headers.RetryAfter = new StringValues("5"); + return; + } + + var sysInfo = new PublicSystemInfo + { + Version = jfApplicationHost.ApplicationVersionString, + ProductName = jfApplicationHost.Name, + Id = jfApplicationHost.SystemId, + ServerName = jfApplicationHost.FriendlyName, + LocalAddress = jfApplicationHost.GetSmartApiUrl(context.Request), + StartupWizardCompleted = false + }; + + await context.Response.WriteAsJsonAsync(sysInfo).ConfigureAwait(false); + }); + }); + + app.Run((context) => + { + context.Response.StatusCode = (int)HttpStatusCode.ServiceUnavailable; + context.Response.Headers.RetryAfter = new StringValues("5"); + context.Response.Headers.ContentType = new StringValues("text/html"); + context.Response.WriteAsync("

Jellyfin Server still starting. Please wait.

"); + var networkManager = _networkManagerFactory(); + if (networkManager is not null && context.Connection.RemoteIpAddress is not null && networkManager.IsInLocalNetwork(context.Connection.RemoteIpAddress)) + { + context.Response.WriteAsync("

You can download the current logfiles here.

"); + } + + return Task.CompletedTask; + }); + }); + }) + .Build(); + await _startupServer.StartAsync().ConfigureAwait(false); + } + + /// + /// Stops the Setup server. + /// + /// A task. Duh. + public async Task StopAsync() + { + ThrowIfDisposed(); + if (_startupServer is null) + { + throw new InvalidOperationException("Tried to stop a non existing startup server"); + } + + await _startupServer.StopAsync().ConfigureAwait(false); + } + + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _startupServer?.Dispose(); + } + + private void ThrowIfDisposed() + { + ObjectDisposedException.ThrowIf(_disposed, this); + } + + private class SetupHealthcheck : IHealthCheck + { + public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + return Task.FromResult(HealthCheckResult.Degraded("Server is still starting up.")); + } + } +} diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs index e9fb3e4c27..688b169359 100644 --- a/Jellyfin.Server/Startup.cs +++ b/Jellyfin.Server/Startup.cs @@ -6,6 +6,7 @@ using System.Net.Mime; using System.Text; using Emby.Server.Implementations.EntryPoints; using Jellyfin.Api.Middleware; +using Jellyfin.Database.Implementations; using Jellyfin.LiveTv.Extensions; using Jellyfin.LiveTv.Recordings; using Jellyfin.MediaEncoding.Hls.Extensions; @@ -13,7 +14,6 @@ using Jellyfin.Networking; using Jellyfin.Networking.HappyEyeballs; using Jellyfin.Server.Extensions; using Jellyfin.Server.HealthChecks; -using Jellyfin.Server.Implementations; using Jellyfin.Server.Implementations.Extensions; using Jellyfin.Server.Infrastructure; using MediaBrowser.Common.Net; @@ -39,15 +39,18 @@ namespace Jellyfin.Server public class Startup { private readonly CoreAppHost _serverApplicationHost; + private readonly IConfiguration _configuration; private readonly IServerConfigurationManager _serverConfigurationManager; /// /// Initializes a new instance of the class. /// /// The server application host. - public Startup(CoreAppHost appHost) + /// The used Configuration. + public Startup(CoreAppHost appHost, IConfiguration configuration) { _serverApplicationHost = appHost; + _configuration = configuration; _serverConfigurationManager = appHost.ConfigurationManager; } @@ -67,7 +70,7 @@ namespace Jellyfin.Server // TODO remove once this is fixed upstream https://github.com/dotnet/aspnetcore/issues/34371 services.AddSingleton, SymlinkFollowingPhysicalFileResultExecutor>(); services.AddJellyfinApi(_serverApplicationHost.GetApiPluginAssemblies(), _serverConfigurationManager.GetNetworkConfiguration()); - services.AddJellyfinDbContext(); + services.AddJellyfinDbContext(_serverApplicationHost.ConfigurationManager, _configuration); services.AddJellyfinApiSwagger(); // configure custom legacy authentication @@ -129,7 +132,6 @@ namespace Jellyfin.Server services.AddHostedService(); services.AddHostedService(); - services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); diff --git a/Jellyfin.sln b/Jellyfin.sln index 30eab6cc21..cdc8c8f651 100644 --- a/Jellyfin.sln +++ b/Jellyfin.sln @@ -30,7 +30,6 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{41093F42-C7CC-4D07-956B-6182CBEDE2EC}" ProjectSection(SolutionItems) = preProject .editorconfig = .editorconfig - jellyfin.ruleset = jellyfin.ruleset SharedVersion.cs = SharedVersion.cs EndProjectSection EndProject @@ -39,9 +38,6 @@ EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Api", "Jellyfin.Api\Jellyfin.Api.csproj", "{DFBEFB4C-DA19-4143-98B7-27320C7F7163}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}" - ProjectSection(SolutionItems) = preProject - tests\jellyfin-tests.ruleset = tests\jellyfin-tests.ruleset - EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Common.Tests", "tests\Jellyfin.Common.Tests\Jellyfin.Common.Tests.csproj", "{DF194677-DFD3-42AF-9F75-D44D5A416478}" EndProject @@ -91,6 +87,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.LiveTv.Tests", "te EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.LiveTv", "src\Jellyfin.LiveTv\Jellyfin.LiveTv.csproj", "{8C6B2B13-58A4-4506-9DAB-1F882A093FE0}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Jellyfin.Database", "Jellyfin.Database", "{4C54CE05-69C8-48FA-8785-39F7F6DB1CAD}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Database.Providers.Sqlite", "src\Jellyfin.Database\Jellyfin.Database.Providers.Sqlite\Jellyfin.Database.Providers.Sqlite.csproj", "{A5590358-33CC-4B39-BDE7-DC62FEB03C76}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Database.Implementations", "src\Jellyfin.Database\Jellyfin.Database.Implementations\Jellyfin.Database.Implementations.csproj", "{8C9F9221-8415-496C-B1F5-E7756F03FA59}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -245,17 +247,28 @@ Global {8C6B2B13-58A4-4506-9DAB-1F882A093FE0}.Debug|Any CPU.Build.0 = Debug|Any CPU {8C6B2B13-58A4-4506-9DAB-1F882A093FE0}.Release|Any CPU.ActiveCfg = Release|Any CPU {8C6B2B13-58A4-4506-9DAB-1F882A093FE0}.Release|Any CPU.Build.0 = Release|Any CPU + {A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Release|Any CPU.Build.0 = Release|Any CPU + {8C9F9221-8415-496C-B1F5-E7756F03FA59}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8C9F9221-8415-496C-B1F5-E7756F03FA59}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8C9F9221-8415-496C-B1F5-E7756F03FA59}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8C9F9221-8415-496C-B1F5-E7756F03FA59}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution + {08FFF49B-F175-4807-A2B5-73B0EBD9F716} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C} + {154872D9-6C12-4007-96E3-8F70A58386CE} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C} {DF194677-DFD3-42AF-9F75-D44D5A416478} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6} {28464062-0939-4AA7-9F7B-24DDDA61A7C0} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6} {3998657B-1CCC-49DD-A19F-275DC8495F57} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6} {A2FD0A10-8F62-4F9D-B171-FFDF9F0AFA9D} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6} {2E3A1B4B-4225-4AAA-8B29-0181A84E7AEE} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6} {462584F7-5023-4019-9EAC-B98CA458C0A0} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6} + {0A3FCC4D-C714-4072-B90F-E374A15F9FF9} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C} {30922383-D513-4F4D-B890-A940B57FA353} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6} {FC1BC0CE-E8D2-4AE9-A6AB-8A02143B335D} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6} {42816EA8-4511-4CBF-A9C7-7791D5DDDAE6} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6} @@ -268,11 +281,11 @@ Global {DA9FD356-4894-4830-B208-D6BCE3E65B11} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C} {FE47334C-EFDE-4519-BD50-F24430FF360B} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6} {24960660-DE6C-47BF-AEEF-CEE8F19FE6C2} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6} - {08FFF49B-F175-4807-A2B5-73B0EBD9F716} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C} - {154872D9-6C12-4007-96E3-8F70A58386CE} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C} - {0A3FCC4D-C714-4072-B90F-E374A15F9FF9} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C} {C4F71272-C6BE-4C30-BE0D-4E6ED651D6D3} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6} {8C6B2B13-58A4-4506-9DAB-1F882A093FE0} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C} + {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C} + {A5590358-33CC-4B39-BDE7-DC62FEB03C76} = {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD} + {8C9F9221-8415-496C-B1F5-E7756F03FA59} = {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3448830C-EBDC-426C-85CD-7BBB9651A7FE} diff --git a/MediaBrowser.Common/Configuration/IApplicationPaths.cs b/MediaBrowser.Common/Configuration/IApplicationPaths.cs index 57c6546675..7a8ab32361 100644 --- a/MediaBrowser.Common/Configuration/IApplicationPaths.cs +++ b/MediaBrowser.Common/Configuration/IApplicationPaths.cs @@ -84,5 +84,11 @@ namespace MediaBrowser.Common.Configuration /// /// The magic string used for virtual path manipulation. string VirtualDataPath { get; } + + /// + /// Gets the path used for storing trickplay files. + /// + /// The trickplay path. + string TrickplayPath { get; } } } diff --git a/MediaBrowser.Common/Configuration/IConfigurationManager.cs b/MediaBrowser.Common/Configuration/IConfigurationManager.cs index e6696a571d..18a8d3e7b7 100644 --- a/MediaBrowser.Common/Configuration/IConfigurationManager.cs +++ b/MediaBrowser.Common/Configuration/IConfigurationManager.cs @@ -61,7 +61,7 @@ namespace MediaBrowser.Common.Configuration object GetConfiguration(string key); /// - /// Gets the array of coniguration stores. + /// Gets the array of configuration stores. /// /// Array of ConfigurationStore. ConfigurationStore[] GetConfigurationStores(); diff --git a/MediaBrowser.Common/MediaBrowser.Common.csproj b/MediaBrowser.Common/MediaBrowser.Common.csproj index c1945bf931..de6be4707e 100644 --- a/MediaBrowser.Common/MediaBrowser.Common.csproj +++ b/MediaBrowser.Common/MediaBrowser.Common.csproj @@ -8,7 +8,7 @@ Jellyfin Contributors Jellyfin.Common - 10.10.0 + 10.11.0 https://github.com/jellyfin/jellyfin GPL-3.0-only @@ -28,7 +28,7 @@
- net8.0 + net9.0 false true true diff --git a/MediaBrowser.Common/Net/INetworkManager.cs b/MediaBrowser.Common/Net/INetworkManager.cs index 78a391d36d..d838144ff6 100644 --- a/MediaBrowser.Common/Net/INetworkManager.cs +++ b/MediaBrowser.Common/Net/INetworkManager.cs @@ -94,12 +94,6 @@ namespace MediaBrowser.Common.Net /// IP address to use, or loopback address if all else fails. string GetBindAddress(string source, out int? port); - /// - /// Get a list of all the MAC addresses associated with active interfaces. - /// - /// List of MAC addresses. - IReadOnlyList GetMacAddresses(); - /// /// Returns true if the address is part of the user defined LAN. /// diff --git a/MediaBrowser.Common/Net/NetworkConfiguration.cs b/MediaBrowser.Common/Net/NetworkConfiguration.cs index 61a51c99e2..053357296d 100644 --- a/MediaBrowser.Common/Net/NetworkConfiguration.cs +++ b/MediaBrowser.Common/Net/NetworkConfiguration.cs @@ -110,6 +110,7 @@ public class NetworkConfiguration /// /// Gets or sets a value indicating whether to enable automatic port forwarding. /// + [Obsolete("No longer supported")] public bool EnableUPnP { get; set; } /// diff --git a/MediaBrowser.Common/Net/NetworkUtils.cs b/MediaBrowser.Common/Net/NetworkUtils.cs index e482089f0a..24ed47a81b 100644 --- a/MediaBrowser.Common/Net/NetworkUtils.cs +++ b/MediaBrowser.Common/Net/NetworkUtils.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.Net; using System.Net.Sockets; using System.Text.RegularExpressions; @@ -102,7 +103,7 @@ public static partial class NetworkUtils Span bytes = stackalloc byte[mask.AddressFamily == AddressFamily.InterNetwork ? NetworkConstants.IPv4MaskBytes : NetworkConstants.IPv6MaskBytes]; if (!mask.TryWriteBytes(bytes, out var bytesWritten)) { - Console.WriteLine("Unable to write address bytes, only ${bytesWritten} bytes written."); + Console.WriteLine("Unable to write address bytes, only {0} bytes written.", bytesWritten.ToString(CultureInfo.InvariantCulture)); } var zeroed = false; @@ -197,14 +198,25 @@ public static partial class NetworkUtils /// True if parsing was successful. public static bool TryParseToSubnet(ReadOnlySpan value, [NotNullWhen(true)] out IPNetwork? result, bool negated = false) { + // If multiple IP addresses are in a comma-separated string, the individual addresses may contain leading and/or trailing whitespace value = value.Trim(); + + bool isAddressNegated = false; + if (value.StartsWith('!')) + { + isAddressNegated = true; + value = value[1..]; // Remove leading '!' character + } + + if (isAddressNegated != negated) + { + result = null; + return false; + } + if (value.Contains('/')) { - if (negated && value.StartsWith("!") && IPNetwork.TryParse(value[1..], out result)) - { - return true; - } - else if (!negated && IPNetwork.TryParse(value, out result)) + if (IPNetwork.TryParse(value, out result)) { return true; } @@ -325,4 +337,23 @@ public static partial class NetworkUtils return new IPAddress(BitConverter.GetBytes(broadCastIPAddress)); } + + /// + /// Check if a subnet contains an address. This method also handles IPv4 mapped to IPv6 addresses. + /// + /// The . + /// The . + /// Whether the supplied IP is in the supplied network. + public static bool SubnetContainsAddress(IPNetwork network, IPAddress address) + { + ArgumentNullException.ThrowIfNull(address); + ArgumentNullException.ThrowIfNull(network); + + if (address.IsIPv4MappedToIPv6) + { + address = address.MapToIPv4(); + } + + return network.Contains(address); + } } diff --git a/MediaBrowser.Common/Plugins/BasePluginOfT.cs b/MediaBrowser.Common/Plugins/BasePluginOfT.cs index 116e9cef80..58992ecd73 100644 --- a/MediaBrowser.Common/Plugins/BasePluginOfT.cs +++ b/MediaBrowser.Common/Plugins/BasePluginOfT.cs @@ -4,6 +4,7 @@ using System; using System.IO; using System.Runtime.InteropServices; +using System.Threading; using MediaBrowser.Common.Configuration; using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Serialization; @@ -20,12 +21,12 @@ namespace MediaBrowser.Common.Plugins /// /// The configuration sync lock. /// - private readonly object _configurationSyncLock = new object(); + private readonly Lock _configurationSyncLock = new(); /// /// The configuration save lock. /// - private readonly object _configurationSaveLock = new object(); + private readonly Lock _configurationSaveLock = new(); /// /// The configuration. @@ -193,7 +194,7 @@ namespace MediaBrowser.Common.Plugins } catch { - var config = (TConfigurationType)Activator.CreateInstance(typeof(TConfigurationType)); + var config = Activator.CreateInstance(); SaveConfiguration(config); return config; } diff --git a/MediaBrowser.Common/RequiresSourceSerialisationAttribute.cs b/MediaBrowser.Common/RequiresSourceSerialisationAttribute.cs new file mode 100644 index 0000000000..b22e7cba17 --- /dev/null +++ b/MediaBrowser.Common/RequiresSourceSerialisationAttribute.cs @@ -0,0 +1,11 @@ +using System; + +namespace MediaBrowser.Common; + +/// +/// Marks a BaseItem as needing custom serialisation from the Data field of the db. +/// +[System.AttributeUsage(System.AttributeTargets.Class, Inherited = true, AllowMultiple = false)] +public sealed class RequiresSourceSerialisationAttribute : System.Attribute +{ +} diff --git a/MediaBrowser.Controller/Authentication/IAuthenticationProvider.cs b/MediaBrowser.Controller/Authentication/IAuthenticationProvider.cs index 81b532fda8..976a667acd 100644 --- a/MediaBrowser.Controller/Authentication/IAuthenticationProvider.cs +++ b/MediaBrowser.Controller/Authentication/IAuthenticationProvider.cs @@ -1,7 +1,7 @@ #pragma warning disable CS1591 using System.Threading.Tasks; -using Jellyfin.Data.Entities; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Model.Users; namespace MediaBrowser.Controller.Authentication diff --git a/MediaBrowser.Controller/Authentication/IPasswordResetProvider.cs b/MediaBrowser.Controller/Authentication/IPasswordResetProvider.cs index 8c9d1baf88..592ce99556 100644 --- a/MediaBrowser.Controller/Authentication/IPasswordResetProvider.cs +++ b/MediaBrowser.Controller/Authentication/IPasswordResetProvider.cs @@ -4,7 +4,7 @@ using System; using System.Threading.Tasks; -using Jellyfin.Data.Entities; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Model.Users; namespace MediaBrowser.Controller.Authentication diff --git a/MediaBrowser.Controller/Channels/Channel.cs b/MediaBrowser.Controller/Channels/Channel.cs index f186523b9a..199e22b3fa 100644 --- a/MediaBrowser.Controller/Channels/Channel.cs +++ b/MediaBrowser.Controller/Channels/Channel.cs @@ -7,8 +7,9 @@ using System.Globalization; using System.Linq; using System.Text.Json.Serialization; using System.Threading; -using Jellyfin.Data.Entities; -using Jellyfin.Data.Enums; +using Jellyfin.Data; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Querying; @@ -22,7 +23,7 @@ namespace MediaBrowser.Controller.Channels [JsonIgnore] public override SourceType SourceType => SourceType.Channel; - public override bool IsVisible(User user) + public override bool IsVisible(User user, bool skipAllowedTagsCheck = false) { var blockedChannelsPreference = user.GetPreferenceValues(PreferenceKind.BlockedChannels); if (blockedChannelsPreference.Length != 0) @@ -41,7 +42,7 @@ namespace MediaBrowser.Controller.Channels } } - return base.IsVisible(user); + return base.IsVisible(user, skipAllowedTagsCheck); } protected override QueryResult GetItemsInternal(InternalItemsQuery query) diff --git a/MediaBrowser.Controller/Chapters/IChapterManager.cs b/MediaBrowser.Controller/Chapters/IChapterManager.cs deleted file mode 100644 index c049bb97e7..0000000000 --- a/MediaBrowser.Controller/Chapters/IChapterManager.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System; -using System.Collections.Generic; -using MediaBrowser.Model.Entities; - -namespace MediaBrowser.Controller.Chapters -{ - /// - /// Interface IChapterManager. - /// - public interface IChapterManager - { - /// - /// Saves the chapters. - /// - /// The item. - /// The set of chapters. - void SaveChapters(Guid itemId, IReadOnlyList chapters); - } -} diff --git a/MediaBrowser.Controller/Chapters/IChapterRepository.cs b/MediaBrowser.Controller/Chapters/IChapterRepository.cs new file mode 100644 index 0000000000..e22cb0f584 --- /dev/null +++ b/MediaBrowser.Controller/Chapters/IChapterRepository.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; + +namespace MediaBrowser.Controller.Chapters; + +/// +/// Interface IChapterManager. +/// +public interface IChapterRepository +{ + /// + /// Saves the chapters. + /// + /// The item. + /// The set of chapters. + void SaveChapters(Guid itemId, IReadOnlyList chapters); + + /// + /// Gets all chapters associated with the baseItem. + /// + /// The baseitem. + /// A readonly list of chapter instances. + IReadOnlyList GetChapters(BaseItemDto baseItem); + + /// + /// Gets a single chapter of a BaseItem on a specific index. + /// + /// The baseitem. + /// The index of that chapter. + /// A chapter instance. + ChapterInfo? GetChapter(BaseItemDto baseItem, int index); + + /// + /// Gets all chapters associated with the baseItem. + /// + /// The BaseItems id. + /// A readonly list of chapter instances. + IReadOnlyList GetChapters(Guid baseItemId); + + /// + /// Gets a single chapter of a BaseItem on a specific index. + /// + /// The BaseItems id. + /// The index of that chapter. + /// A chapter instance. + ChapterInfo? GetChapter(Guid baseItemId, int index); +} diff --git a/MediaBrowser.Controller/Collections/ICollectionManager.cs b/MediaBrowser.Controller/Collections/ICollectionManager.cs index 38a78a67b5..206b5ac426 100644 --- a/MediaBrowser.Controller/Collections/ICollectionManager.cs +++ b/MediaBrowser.Controller/Collections/ICollectionManager.cs @@ -3,7 +3,7 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; -using Jellyfin.Data.Entities; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; diff --git a/MediaBrowser.Controller/Devices/IDeviceManager.cs b/MediaBrowser.Controller/Devices/IDeviceManager.cs index cade53d994..ea38950d32 100644 --- a/MediaBrowser.Controller/Devices/IDeviceManager.cs +++ b/MediaBrowser.Controller/Devices/IDeviceManager.cs @@ -1,10 +1,10 @@ using System; using System.Threading.Tasks; using Jellyfin.Data.Dtos; -using Jellyfin.Data.Entities; -using Jellyfin.Data.Entities.Security; using Jellyfin.Data.Events; using Jellyfin.Data.Queries; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Entities.Security; using MediaBrowser.Model.Devices; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Querying; @@ -58,7 +58,7 @@ public interface IDeviceManager QueryResult GetDevices(DeviceQuery query); /// - /// Gets device infromation based on the provided query. + /// Gets device information based on the provided query. /// /// The device query. /// A representing the retrieval of the device information. @@ -109,7 +109,7 @@ public interface IDeviceManager DeviceOptionsDto? GetDeviceOptions(string deviceId); /// - /// Gets the dto for client capabilites. + /// Gets the dto for client capabilities. /// /// The client capabilities. /// of the device. diff --git a/MediaBrowser.Controller/Drawing/IImageProcessor.cs b/MediaBrowser.Controller/Drawing/IImageProcessor.cs index 0d1e2a5a07..4eeec99b0b 100644 --- a/MediaBrowser.Controller/Drawing/IImageProcessor.cs +++ b/MediaBrowser.Controller/Drawing/IImageProcessor.cs @@ -3,9 +3,10 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; -using Jellyfin.Data.Entities; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Drawing; +using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; namespace MediaBrowser.Controller.Drawing @@ -57,6 +58,22 @@ namespace MediaBrowser.Controller.Drawing /// BlurHash. string GetImageBlurHash(string path, ImageDimensions imageDimensions); + /// + /// Gets the image cache tag. + /// + /// The items basePath. + /// The image last modification date. + /// Guid. + string? GetImageCacheTag(string baseItemPath, DateTime imageDateModified); + + /// + /// Gets the image cache tag. + /// + /// The item. + /// The image. + /// Guid. + string? GetImageCacheTag(BaseItemDto item, ChapterInfo image); + /// /// Gets the image cache tag. /// @@ -65,6 +82,14 @@ namespace MediaBrowser.Controller.Drawing /// Guid. string GetImageCacheTag(BaseItem item, ItemImageInfo image); + /// + /// Gets the image cache tag. + /// + /// The item. + /// The image. + /// Guid. + string GetImageCacheTag(BaseItemDto item, ItemImageInfo image); + string? GetImageCacheTag(BaseItem item, ChapterInfo chapter); string? GetImageCacheTag(User user); diff --git a/MediaBrowser.Controller/Dto/DtoOptions.cs b/MediaBrowser.Controller/Dto/DtoOptions.cs index cb638cf90b..a71cdbd62c 100644 --- a/MediaBrowser.Controller/Dto/DtoOptions.cs +++ b/MediaBrowser.Controller/Dto/DtoOptions.cs @@ -34,7 +34,7 @@ namespace MediaBrowser.Controller.Dto EnableUserData = true; AddCurrentProgram = true; - Fields = allFields ? AllItemFields : Array.Empty(); + Fields = allFields ? AllItemFields : []; ImageTypes = AllImageTypes; } diff --git a/MediaBrowser.Controller/Dto/IDtoService.cs b/MediaBrowser.Controller/Dto/IDtoService.cs index 22453f0f76..f1d507fcbd 100644 --- a/MediaBrowser.Controller/Dto/IDtoService.cs +++ b/MediaBrowser.Controller/Dto/IDtoService.cs @@ -1,7 +1,7 @@ #pragma warning disable CA1002 using System.Collections.Generic; -using Jellyfin.Data.Entities; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Dto; diff --git a/MediaBrowser.Controller/Entities/AggregateFolder.cs b/MediaBrowser.Controller/Entities/AggregateFolder.cs index 40cdd6c91e..a02802f41e 100644 --- a/MediaBrowser.Controller/Entities/AggregateFolder.cs +++ b/MediaBrowser.Controller/Entities/AggregateFolder.cs @@ -23,7 +23,7 @@ namespace MediaBrowser.Controller.Entities /// public class AggregateFolder : Folder { - private readonly object _childIdsLock = new object(); + private readonly Lock _childIdsLock = new(); /// /// The _virtual children. @@ -64,7 +64,7 @@ namespace MediaBrowser.Controller.Entities return CreateResolveArgs(directoryService, true).FileSystemChildren; } - protected override List LoadChildren() + protected override IReadOnlyList LoadChildren() { lock (_childIdsLock) { diff --git a/MediaBrowser.Controller/Entities/Audio/IHasAlbumArtist.cs b/MediaBrowser.Controller/Entities/Audio/IHasAlbumArtist.cs index 1625c748a8..b085398c5e 100644 --- a/MediaBrowser.Controller/Entities/Audio/IHasAlbumArtist.cs +++ b/MediaBrowser.Controller/Entities/Audio/IHasAlbumArtist.cs @@ -22,7 +22,7 @@ namespace MediaBrowser.Controller.Entities.Audio IReadOnlyList Artists { get; set; } } - public static class Extentions + public static class Extensions { public static IEnumerable GetAllArtists(this T item) where T : IHasArtist, IHasAlbumArtist diff --git a/MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs b/MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs index a0aae8769c..d016d8f62b 100644 --- a/MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs +++ b/MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs @@ -8,8 +8,10 @@ using System.Linq; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Data.Entities; +using Jellyfin.Data; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; @@ -21,6 +23,7 @@ namespace MediaBrowser.Controller.Entities.Audio /// /// Class MusicAlbum. /// + [Common.RequiresSourceSerialisation] public class MusicAlbum : Folder, IHasAlbumArtist, IHasArtist, IHasMusicGenres, IHasLookupInfo, IMetadataContainer { public MusicAlbum() diff --git a/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs b/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs index 1ab6c97066..58841e5b78 100644 --- a/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs +++ b/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs @@ -8,8 +8,10 @@ using System.Linq; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Data.Entities; +using Jellyfin.Data; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; @@ -21,6 +23,7 @@ namespace MediaBrowser.Controller.Entities.Audio /// /// Class MusicArtist. /// + [Common.RequiresSourceSerialisation] public class MusicArtist : Folder, IItemByName, IHasMusicGenres, IHasDualAccess, IHasLookupInfo { [JsonIgnore] @@ -84,7 +87,7 @@ namespace MediaBrowser.Controller.Entities.Audio return !IsAccessedByName; } - public IList GetTaggedItems(InternalItemsQuery query) + public IReadOnlyList GetTaggedItems(InternalItemsQuery query) { if (query.IncludeItemTypes.Length == 0) { @@ -110,15 +113,15 @@ namespace MediaBrowser.Controller.Entities.Audio return base.IsSaveLocalMetadataEnabled(); } - protected override Task ValidateChildrenInternal(IProgress progress, bool recursive, bool refreshChildMetadata, bool allowRemoveRoot, MetadataRefreshOptions refreshOptions, IDirectoryService directoryService, CancellationToken cancellationToken) + protected override async Task ValidateChildrenInternal(IProgress progress, bool recursive, bool refreshChildMetadata, bool allowRemoveRoot, MetadataRefreshOptions refreshOptions, IDirectoryService directoryService, CancellationToken cancellationToken) { if (IsAccessedByName) { // Should never get in here anyway - return Task.CompletedTask; + return; } - return base.ValidateChildrenInternal(progress, recursive, refreshChildMetadata, false, refreshOptions, directoryService, cancellationToken); + await base.ValidateChildrenInternal(progress, recursive, refreshChildMetadata, false, refreshOptions, directoryService, cancellationToken).ConfigureAwait(false); } public override List GetUserDataKeys() @@ -137,11 +140,9 @@ namespace MediaBrowser.Controller.Entities.Audio private static List GetUserDataKeys(MusicArtist item) { var list = new List(); - var id = item.GetProviderId(MetadataProvider.MusicBrainzArtist); - - if (!string.IsNullOrEmpty(id)) + if (item.TryGetProviderId(MetadataProvider.MusicBrainzArtist, out var externalId)) { - list.Add("Artist-Musicbrainz-" + id); + list.Add("Artist-Musicbrainz-" + externalId); } list.Add("Artist-" + (item.Name ?? string.Empty).RemoveDiacritics()); diff --git a/MediaBrowser.Controller/Entities/Audio/MusicGenre.cs b/MediaBrowser.Controller/Entities/Audio/MusicGenre.cs index 7448d02ea5..65669e6804 100644 --- a/MediaBrowser.Controller/Entities/Audio/MusicGenre.cs +++ b/MediaBrowser.Controller/Entities/Audio/MusicGenre.cs @@ -14,6 +14,7 @@ namespace MediaBrowser.Controller.Entities.Audio /// /// Class MusicGenre. /// + [Common.RequiresSourceSerialisation] public class MusicGenre : BaseItem, IItemByName { [JsonIgnore] @@ -64,7 +65,7 @@ namespace MediaBrowser.Controller.Entities.Audio return true; } - public IList GetTaggedItems(InternalItemsQuery query) + public IReadOnlyList GetTaggedItems(InternalItemsQuery query) { query.GenreIds = new[] { Id }; query.IncludeItemTypes = new[] { BaseItemKind.MusicVideo, BaseItemKind.Audio, BaseItemKind.MusicAlbum, BaseItemKind.MusicArtist }; diff --git a/MediaBrowser.Controller/Entities/AudioBook.cs b/MediaBrowser.Controller/Entities/AudioBook.cs index 782481fbcd..666bf2a750 100644 --- a/MediaBrowser.Controller/Entities/AudioBook.cs +++ b/MediaBrowser.Controller/Entities/AudioBook.cs @@ -9,6 +9,7 @@ using MediaBrowser.Controller.Providers; namespace MediaBrowser.Controller.Entities { + [Common.RequiresSourceSerialisation] public class AudioBook : Audio.Audio, IHasSeries, IHasLookupInfo { [JsonIgnore] diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index eb605f6c87..d1a6b35841 100644 --- a/MediaBrowser.Controller/Entities/BaseItem.cs +++ b/MediaBrowser.Controller/Entities/BaseItem.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Globalization; using System.IO; using System.Linq; @@ -11,14 +12,18 @@ using System.Text; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Data.Entities; +using Jellyfin.Data; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Channels; +using MediaBrowser.Controller.Chapters; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; @@ -47,7 +52,7 @@ namespace MediaBrowser.Controller.Entities /// The supported image extensions. /// public static readonly string[] SupportedImageExtensions - = new[] { ".png", ".jpg", ".jpeg", ".webp", ".tbn", ".gif", ".svg" }; + = [".png", ".jpg", ".jpeg", ".webp", ".tbn", ".gif", ".svg"]; private static readonly List _supportedExtensions = new List(SupportedImageExtensions) { @@ -443,7 +448,7 @@ namespace MediaBrowser.Controller.Entities return Array.Empty(); } - return new[] { Path }; + return [Path]; } } @@ -479,6 +484,8 @@ namespace MediaBrowser.Controller.Entities public static IItemRepository ItemRepository { get; set; } + public static IChapterRepository ChapterRepository { get; set; } + public static IFileSystem FileSystem { get; set; } public static IUserDataManager UserDataManager { get; set; } @@ -574,6 +581,9 @@ namespace MediaBrowser.Controller.Entities [JsonIgnore] public int? InheritedParentalRatingValue { get; set; } + [JsonIgnore] + public int? InheritedParentalRatingSubValue { get; set; } + /// /// Gets or sets the critic rating. /// @@ -915,7 +925,7 @@ namespace MediaBrowser.Controller.Entities // Remove from middle if surrounded by spaces sortable = sortable.Replace(" " + search + " ", " ", StringComparison.Ordinal); - // Remove from end if followed by a space + // Remove from end if preceeded by a space if (sortable.EndsWith(" " + search, StringComparison.Ordinal)) { sortable = sortable.Remove(sortable.Length - (search.Length + 1)); @@ -1041,7 +1051,7 @@ namespace MediaBrowser.Controller.Entities return PlayAccess.Full; } - public virtual List GetMediaStreams() + public virtual IReadOnlyList GetMediaStreams() { return MediaSourceManager.GetMediaStreams(new MediaStreamQuery { @@ -1054,7 +1064,7 @@ namespace MediaBrowser.Controller.Entities return false; } - public virtual List GetMediaSources(bool enablePathSubstitution) + public virtual IReadOnlyList GetMediaSources(bool enablePathSubstitution) { if (SourceType == SourceType.Channel) { @@ -1088,7 +1098,7 @@ namespace MediaBrowser.Controller.Entities return 1; }).ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0) .ThenByDescending(i => i, new MediaSourceWidthComparator()) - .ToList(); + .ToArray(); } protected virtual IEnumerable<(BaseItem Item, MediaSourceType MediaSourceType)> GetAllItemsForMediaSources() @@ -1299,7 +1309,7 @@ namespace MediaBrowser.Controller.Entities return false; } - if (GetParents().Any(i => !i.IsVisible(user))) + if (GetParents().Any(i => !i.IsVisible(user, true))) { return false; } @@ -1521,18 +1531,20 @@ namespace MediaBrowser.Controller.Entities /// Determines if a given user has access to this item. /// /// The user. + /// Don't check for allowed tags. /// true if [is parental allowed] [the specified user]; otherwise, false. /// If user is null. - public bool IsParentalAllowed(User user) + public bool IsParentalAllowed(User user, bool skipAllowedTagsCheck) { ArgumentNullException.ThrowIfNull(user); - if (!IsVisibleViaTags(user)) + if (!IsVisibleViaTags(user, skipAllowedTagsCheck)) { return false; } - var maxAllowedRating = user.MaxParentalAgeRating; + var maxAllowedRating = user.MaxParentalRatingScore; + var maxAllowedSubRating = user.MaxParentalRatingSubScore; var rating = CustomRatingForComparison; if (string.IsNullOrEmpty(rating)) @@ -1546,10 +1558,10 @@ namespace MediaBrowser.Controller.Entities return !GetBlockUnratedValue(user); } - var value = LocalizationManager.GetRatingLevel(rating); + var ratingScore = LocalizationManager.GetRatingScore(rating); // Could not determine rating level - if (!value.HasValue) + if (ratingScore is null) { var isAllowed = !GetBlockUnratedValue(user); @@ -1561,10 +1573,15 @@ namespace MediaBrowser.Controller.Entities return isAllowed; } - return !maxAllowedRating.HasValue || value.Value <= maxAllowedRating.Value; + if (maxAllowedSubRating is not null) + { + return (ratingScore.SubScore ?? 0) <= maxAllowedSubRating && ratingScore.Score <= maxAllowedRating.Value; + } + + return !maxAllowedRating.HasValue || ratingScore.Score <= maxAllowedRating.Value; } - public int? GetInheritedParentalRatingValue() + public ParentalRatingScore GetParentalRatingScore() { var rating = CustomRatingForComparison; @@ -1578,7 +1595,7 @@ namespace MediaBrowser.Controller.Entities return null; } - return LocalizationManager.GetRatingLevel(rating); + return LocalizationManager.GetRatingScore(rating); } public List GetInheritedTags() @@ -1599,7 +1616,7 @@ namespace MediaBrowser.Controller.Entities return list.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); } - private bool IsVisibleViaTags(User user) + private bool IsVisibleViaTags(User user, bool skipAllowedTagsCheck) { var allTags = GetInheritedTags(); if (user.GetPreference(PreferenceKind.BlockedTags).Any(i => allTags.Contains(i, StringComparison.OrdinalIgnoreCase))) @@ -1614,7 +1631,7 @@ namespace MediaBrowser.Controller.Entities } var allowedTagsPreference = user.GetPreference(PreferenceKind.AllowedTags); - if (allowedTagsPreference.Length != 0 && !allowedTagsPreference.Any(i => allTags.Contains(i, StringComparison.OrdinalIgnoreCase))) + if (!skipAllowedTagsCheck && allowedTagsPreference.Length != 0 && !allowedTagsPreference.Any(i => allTags.Contains(i, StringComparison.OrdinalIgnoreCase))) { return false; } @@ -1654,13 +1671,14 @@ namespace MediaBrowser.Controller.Entities /// Default is just parental allowed. Can be overridden for more functionality. /// /// The user. + /// Don't check for allowed tags. /// true if the specified user is visible; otherwise, false. /// is null. - public virtual bool IsVisible(User user) + public virtual bool IsVisible(User user, bool skipAllowedTagsCheck = false) { ArgumentNullException.ThrowIfNull(user); - return IsParentalAllowed(user); + return IsParentalAllowed(user, skipAllowedTagsCheck); } public virtual bool IsVisibleStandalone(User user) @@ -1675,7 +1693,7 @@ namespace MediaBrowser.Controller.Entities public virtual string GetClientTypeName() { - if (IsFolder && SourceType == SourceType.Channel && this is not Channel) + if (IsFolder && SourceType == SourceType.Channel && this is not Channel && this is not Season && this is not Series) { return "ChannelFolderItem"; } @@ -1769,7 +1787,6 @@ namespace MediaBrowser.Controller.Entities public void AddStudio(string name) { ArgumentException.ThrowIfNullOrEmpty(name); - var current = Studios; if (!current.Contains(name, StringComparison.OrdinalIgnoreCase)) @@ -1781,21 +1798,21 @@ namespace MediaBrowser.Controller.Entities } else { - Studios = [..current, name]; + Studios = [.. current, name]; } } } public void SetStudios(IEnumerable names) { - Studios = names.Distinct().ToArray(); + Studios = names.Trimmed().Distinct().ToArray(); } /// /// Adds a genre to the item. /// /// The name. - /// Throwns if name is null. + /// Throws if name is null. public void AddGenre(string name) { ArgumentException.ThrowIfNullOrEmpty(name); @@ -1803,7 +1820,7 @@ namespace MediaBrowser.Controller.Entities var genres = Genres; if (!genres.Contains(name, StringComparison.OrdinalIgnoreCase)) { - Genres = [..genres, name]; + Genres = [.. genres, name]; } } @@ -1821,7 +1838,10 @@ namespace MediaBrowser.Controller.Entities { ArgumentNullException.ThrowIfNull(user); - var data = UserDataManager.GetUserData(user, this); + var data = UserDataManager.GetUserData(user, this) ?? new UserItemData() + { + Key = GetUserDataKeys().First(), + }; if (datePlayed.HasValue) { @@ -1964,7 +1984,7 @@ namespace MediaBrowser.Controller.Entities public void RemoveImage(ItemImageInfo image) { - RemoveImages(new[] { image }); + RemoveImages([image]); } public void RemoveImages(IEnumerable deletedImages) @@ -1974,11 +1994,11 @@ namespace MediaBrowser.Controller.Entities public void AddImage(ItemImageInfo image) { - ImageInfos = [..ImageInfos, image]; + ImageInfos = [.. ImageInfos, image]; } - public virtual Task UpdateToRepositoryAsync(ItemUpdateType updateReason, CancellationToken cancellationToken) - => LibraryManager.UpdateItemAsync(this, GetParent(), updateReason, cancellationToken); + public virtual async Task UpdateToRepositoryAsync(ItemUpdateType updateReason, CancellationToken cancellationToken) + => await LibraryManager.UpdateItemAsync(this, GetParent(), updateReason, cancellationToken).ConfigureAwait(false); /// /// Validates that images within the item are still on the filesystem. @@ -1999,7 +2019,7 @@ namespace MediaBrowser.Controller.Entities continue; } - (deletedImages ??= new List()).Add(imageInfo); + (deletedImages ??= []).Add(imageInfo); } var anyImagesRemoved = deletedImages?.Count > 0; @@ -2031,7 +2051,7 @@ namespace MediaBrowser.Controller.Entities { if (imageType == ImageType.Chapter) { - var chapter = ItemRepository.GetChapter(this, imageIndex); + var chapter = ChapterRepository.GetChapter(this.Id, imageIndex); if (chapter is null) { @@ -2081,7 +2101,7 @@ namespace MediaBrowser.Controller.Entities if (image.Type == ImageType.Chapter) { - var chapters = ItemRepository.GetChapters(this); + var chapters = ChapterRepository.GetChapters(this.Id); for (var i = 0; i < chapters.Count; i++) { if (chapters[i].ImagePath == image.Path) @@ -2202,11 +2222,7 @@ namespace MediaBrowser.Controller.Entities { return new[] { - new FileSystemMetadata - { - FullName = Path, - IsDirectory = IsFolder - } + FileSystem.GetFileSystemInfo(Path) }.Concat(GetLocalMetadataFilesToDelete()); } @@ -2214,7 +2230,7 @@ namespace MediaBrowser.Controller.Entities { if (IsFolder || !IsInMixedFolder) { - return new List(); + return []; } var filename = System.IO.Path.GetFileNameWithoutExtension(Path); @@ -2367,7 +2383,7 @@ namespace MediaBrowser.Controller.Entities } } - protected Task RefreshMetadataForOwnedItem(BaseItem ownedItem, bool copyTitleMetadata, MetadataRefreshOptions options, CancellationToken cancellationToken) + protected async Task RefreshMetadataForOwnedItem(BaseItem ownedItem, bool copyTitleMetadata, MetadataRefreshOptions options, CancellationToken cancellationToken) { var newOptions = new MetadataRefreshOptions(options) { @@ -2428,10 +2444,10 @@ namespace MediaBrowser.Controller.Entities } } - return ownedItem.RefreshMetadata(newOptions, cancellationToken); + await ownedItem.RefreshMetadata(newOptions, cancellationToken).ConfigureAwait(false); } - protected Task RefreshMetadataForOwnedVideo(MetadataRefreshOptions options, bool copyTitleMetadata, string path, CancellationToken cancellationToken) + protected async Task RefreshMetadataForOwnedVideo(MetadataRefreshOptions options, bool copyTitleMetadata, string path, CancellationToken cancellationToken) { var newOptions = new MetadataRefreshOptions(options) { @@ -2441,9 +2457,7 @@ namespace MediaBrowser.Controller.Entities var id = LibraryManager.GetNewItemId(path, typeof(Video)); // Try to retrieve it from the db. If we don't find it, use the resolved version - var video = LibraryManager.GetItemById(id) as Video; - - if (video is null) + if (LibraryManager.GetItemById(id) is not Video video) { video = LibraryManager.ResolvePath(FileSystem.GetFileSystemInfo(path)) as Video; @@ -2452,15 +2466,15 @@ namespace MediaBrowser.Controller.Entities if (video is null) { - return Task.FromResult(true); + return; } if (video.OwnerId.IsEmpty()) { - video.OwnerId = this.Id; + video.OwnerId = Id; } - return RefreshMetadataForOwnedItem(video, copyTitleMetadata, newOptions, cancellationToken); + await RefreshMetadataForOwnedItem(video, copyTitleMetadata, newOptions, cancellationToken).ConfigureAwait(false); } public string GetEtag(User user) @@ -2472,10 +2486,10 @@ namespace MediaBrowser.Controller.Entities protected virtual List GetEtagValues(User user) { - return new List - { + return + [ DateLastSaved.Ticks.ToString(CultureInfo.InvariantCulture) - }; + ]; } public virtual IEnumerable GetAncestorIds() @@ -2495,7 +2509,7 @@ namespace MediaBrowser.Controller.Entities public virtual IEnumerable GetIdsForAncestorQuery() { - return new[] { Id }; + return [Id]; } public virtual double? GetRefreshProgress() @@ -2509,11 +2523,29 @@ namespace MediaBrowser.Controller.Entities var item = this; - var inheritedParentalRatingValue = item.GetInheritedParentalRatingValue() ?? null; - if (inheritedParentalRatingValue != item.InheritedParentalRatingValue) + var rating = item.GetParentalRatingScore(); + if (rating is not null) { - item.InheritedParentalRatingValue = inheritedParentalRatingValue; - updateType |= ItemUpdateType.MetadataImport; + if (rating.Score != item.InheritedParentalRatingValue) + { + item.InheritedParentalRatingValue = rating.Score; + updateType |= ItemUpdateType.MetadataImport; + } + + if (rating.SubScore != item.InheritedParentalRatingSubValue) + { + item.InheritedParentalRatingSubValue = rating.SubScore; + updateType |= ItemUpdateType.MetadataImport; + } + } + else + { + if (item.InheritedParentalRatingValue is not null) + { + item.InheritedParentalRatingValue = null; + item.InheritedParentalRatingSubValue = null; + updateType |= ItemUpdateType.MetadataImport; + } } return updateType; @@ -2524,7 +2556,7 @@ namespace MediaBrowser.Controller.Entities /// /// Media children. /// true if the rating was updated; otherwise false. - public bool UpdateRatingToItems(IList children) + public bool UpdateRatingToItems(IReadOnlyList children) { var currentOfficialRating = OfficialRating; @@ -2533,8 +2565,9 @@ namespace MediaBrowser.Controller.Entities .Select(i => i.OfficialRating) .Where(i => !string.IsNullOrEmpty(i)) .Distinct(StringComparer.OrdinalIgnoreCase) - .Select(rating => (rating, LocalizationManager.GetRatingLevel(rating))) - .OrderBy(i => i.Item2 ?? 1000) + .Select(rating => (rating, LocalizationManager.GetRatingScore(rating))) + .OrderBy(i => i.Item2 is null ? 1001 : i.Item2.Score) + .ThenBy(i => i.Item2 is null ? 1001 : i.Item2.SubScore) .Select(i => i.rating); OfficialRating = ratings.FirstOrDefault() ?? currentOfficialRating; diff --git a/MediaBrowser.Controller/Entities/Book.cs b/MediaBrowser.Controller/Entities/Book.cs index 66dea1084c..5187669373 100644 --- a/MediaBrowser.Controller/Entities/Book.cs +++ b/MediaBrowser.Controller/Entities/Book.cs @@ -10,6 +10,7 @@ using MediaBrowser.Controller.Providers; namespace MediaBrowser.Controller.Entities { + [Common.RequiresSourceSerialisation] public class Book : BaseItem, IHasLookupInfo, IHasSeries { public Book() diff --git a/MediaBrowser.Controller/Entities/CollectionFolder.cs b/MediaBrowser.Controller/Entities/CollectionFolder.cs index 4ead477f83..ca79e62454 100644 --- a/MediaBrowser.Controller/Entities/CollectionFolder.cs +++ b/MediaBrowser.Controller/Entities/CollectionFolder.cs @@ -11,8 +11,8 @@ using System.Text.Json; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; using Jellyfin.Extensions.Json; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; @@ -96,11 +96,11 @@ namespace MediaBrowser.Controller.Entities return GetLibraryOptions(Path); } - public override bool IsVisible(User user) + public override bool IsVisible(User user, bool skipAllowedTagsCheck = false) { if (GetLibraryOptions().Enabled) { - return base.IsVisible(user); + return base.IsVisible(user, skipAllowedTagsCheck); } return false; diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs index 83c19a54e1..4da22854b2 100644 --- a/MediaBrowser.Controller/Entities/Folder.cs +++ b/MediaBrowser.Controller/Entities/Folder.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.IO; using System.Linq; using System.Security; @@ -11,8 +12,11 @@ using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using System.Threading.Tasks.Dataflow; -using Jellyfin.Data.Entities; +using J2N.Collections.Generic.Extensions; +using Jellyfin.Data; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Collections; @@ -217,7 +221,7 @@ namespace MediaBrowser.Controller.Entities LibraryManager.CreateItem(item, this); } - public override bool IsVisible(User user) + public override bool IsVisible(User user, bool skipAllowedTagsCheck = false) { if (this is ICollectionFolder && this is not BasePluginFolder) { @@ -239,7 +243,7 @@ namespace MediaBrowser.Controller.Entities } } - return base.IsVisible(user); + return base.IsVisible(user, skipAllowedTagsCheck); } /// @@ -247,7 +251,7 @@ namespace MediaBrowser.Controller.Entities /// We want this synchronous. /// /// Returns children. - protected virtual List LoadChildren() + protected virtual IReadOnlyList LoadChildren() { // logger.LogDebug("Loading children from {0} {1} {2}", GetType().Name, Id, Path); // just load our children from the repo - the library will be validated and maintained in other processes @@ -528,13 +532,13 @@ namespace MediaBrowser.Controller.Entities } } - private Task RefreshMetadataRecursive(IList children, MetadataRefreshOptions refreshOptions, bool recursive, IProgress progress, CancellationToken cancellationToken) + private async Task RefreshMetadataRecursive(IList children, MetadataRefreshOptions refreshOptions, bool recursive, IProgress progress, CancellationToken cancellationToken) { - return RunTasks( + await RunTasks( (baseItem, innerProgress) => RefreshChildMetadata(baseItem, refreshOptions, recursive && baseItem.IsFolder, innerProgress, cancellationToken), children, progress, - cancellationToken); + cancellationToken).ConfigureAwait(false); } private async Task RefreshAllMetadataForContainer(IMetadataContainer container, MetadataRefreshOptions refreshOptions, IProgress progress, CancellationToken cancellationToken) @@ -575,13 +579,13 @@ namespace MediaBrowser.Controller.Entities /// The progress. /// The cancellation token. /// Task. - private Task ValidateSubFolders(IList children, IDirectoryService directoryService, IProgress progress, CancellationToken cancellationToken) + private async Task ValidateSubFolders(IList children, IDirectoryService directoryService, IProgress progress, CancellationToken cancellationToken) { - return RunTasks( + await RunTasks( (folder, innerProgress) => folder.ValidateChildrenInternal(innerProgress, true, false, false, null, directoryService, cancellationToken), children, progress, - cancellationToken); + cancellationToken).ConfigureAwait(false); } /// @@ -659,7 +663,7 @@ namespace MediaBrowser.Controller.Entities /// Get our children from the repo - stubbed for now. /// /// IEnumerable{BaseItem}. - protected List GetCachedChildren() + protected IReadOnlyList GetCachedChildren() { return ItemRepository.GetItemList(new InternalItemsQuery { @@ -1060,11 +1064,6 @@ namespace MediaBrowser.Controller.Entities return false; } - if (queryParent is Series) - { - return false; - } - if (queryParent is Season) { return false; @@ -1084,12 +1083,15 @@ namespace MediaBrowser.Controller.Entities if (!param.HasValue) { - if (user is not null && !configurationManager.Configuration.EnableGroupingIntoCollections) + if (user is not null && query.IncludeItemTypes.Any(type => + (type == BaseItemKind.Movie && !configurationManager.Configuration.EnableGroupingMoviesIntoCollections) || + (type == BaseItemKind.Series && !configurationManager.Configuration.EnableGroupingShowsIntoCollections))) { return false; } - if (query.IncludeItemTypes.Length == 0 || query.IncludeItemTypes.Contains(BaseItemKind.Movie)) + if (query.IncludeItemTypes.Length == 0 + || query.IncludeItemTypes.Any(type => type == BaseItemKind.Movie || type == BaseItemKind.Series)) { param = true; } @@ -1200,6 +1202,11 @@ namespace MediaBrowser.Controller.Entities return false; } + if (request.Is4K.HasValue) + { + return false; + } + if (request.IsHD.HasValue) { return false; @@ -1240,11 +1247,6 @@ namespace MediaBrowser.Controller.Entities return false; } - if (request.GenreIds.Count > 0) - { - return false; - } - if (request.VideoTypes.Length > 0) { return false; @@ -1283,14 +1285,14 @@ namespace MediaBrowser.Controller.Entities return true; } - public List GetChildren(User user, bool includeLinkedChildren) + public IReadOnlyList GetChildren(User user, bool includeLinkedChildren) { ArgumentNullException.ThrowIfNull(user); return GetChildren(user, includeLinkedChildren, new InternalItemsQuery(user)); } - public virtual List GetChildren(User user, bool includeLinkedChildren, InternalItemsQuery query) + public virtual IReadOnlyList GetChildren(User user, bool includeLinkedChildren, InternalItemsQuery query) { ArgumentNullException.ThrowIfNull(user); @@ -1304,7 +1306,7 @@ namespace MediaBrowser.Controller.Entities AddChildren(user, includeLinkedChildren, result, false, query); - return result.Values.ToList(); + return result.Values.ToArray(); } protected virtual IEnumerable GetEligibleChildrenForRecursiveChildren(User user) @@ -1369,7 +1371,7 @@ namespace MediaBrowser.Controller.Entities } } - public virtual IEnumerable GetRecursiveChildren(User user, InternalItemsQuery query) + public virtual IReadOnlyList GetRecursiveChildren(User user, InternalItemsQuery query) { ArgumentNullException.ThrowIfNull(user); @@ -1377,35 +1379,35 @@ namespace MediaBrowser.Controller.Entities AddChildren(user, true, result, true, query); - return result.Values; + return result.Values.ToArray(); } /// /// Gets the recursive children. /// /// IList{BaseItem}. - public IList GetRecursiveChildren() + public IReadOnlyList GetRecursiveChildren() { return GetRecursiveChildren(true); } - public IList GetRecursiveChildren(bool includeLinkedChildren) + public IReadOnlyList GetRecursiveChildren(bool includeLinkedChildren) { return GetRecursiveChildren(i => true, includeLinkedChildren); } - public IList GetRecursiveChildren(Func filter) + public IReadOnlyList GetRecursiveChildren(Func filter) { return GetRecursiveChildren(filter, true); } - public IList GetRecursiveChildren(Func filter, bool includeLinkedChildren) + public IReadOnlyList GetRecursiveChildren(Func filter, bool includeLinkedChildren) { var result = new Dictionary(); AddChildrenToList(result, includeLinkedChildren, true, filter); - return result.Values.ToList(); + return result.Values.ToArray(); } /// @@ -1556,11 +1558,12 @@ namespace MediaBrowser.Controller.Entities /// Gets the linked children. /// /// IEnumerable{BaseItem}. - public IEnumerable> GetLinkedChildrenInfos() + public IReadOnlyList> GetLinkedChildrenInfos() { return LinkedChildren .Select(i => new Tuple(i, GetLinkedChild(i))) - .Where(i => i.Item2 is not null); + .Where(i => i.Item2 is not null) + .ToArray(); } protected override async Task RefreshedOwnedItems(MetadataRefreshOptions options, IReadOnlyList fileSystemChildren, CancellationToken cancellationToken) diff --git a/MediaBrowser.Controller/Entities/Genre.cs b/MediaBrowser.Controller/Entities/Genre.cs index ddf62dd4cb..6ec78a270e 100644 --- a/MediaBrowser.Controller/Entities/Genre.cs +++ b/MediaBrowser.Controller/Entities/Genre.cs @@ -14,6 +14,7 @@ namespace MediaBrowser.Controller.Entities /// /// Class Genre. /// + [Common.RequiresSourceSerialisation] public class Genre : BaseItem, IItemByName { /// @@ -61,7 +62,7 @@ namespace MediaBrowser.Controller.Entities return false; } - public IList GetTaggedItems(InternalItemsQuery query) + public IReadOnlyList GetTaggedItems(InternalItemsQuery query) { query.GenreIds = new[] { Id }; query.ExcludeItemTypes = new[] diff --git a/MediaBrowser.Controller/Entities/IHasMediaSources.cs b/MediaBrowser.Controller/Entities/IHasMediaSources.cs index 90d9bdd2d3..ad35494c28 100644 --- a/MediaBrowser.Controller/Entities/IHasMediaSources.cs +++ b/MediaBrowser.Controller/Entities/IHasMediaSources.cs @@ -22,8 +22,8 @@ namespace MediaBrowser.Controller.Entities /// /// true to enable path substitution, false to not. /// A list of media sources. - List GetMediaSources(bool enablePathSubstitution); + IReadOnlyList GetMediaSources(bool enablePathSubstitution); - List GetMediaStreams(); + IReadOnlyList GetMediaStreams(); } } diff --git a/MediaBrowser.Controller/Entities/IItemByName.cs b/MediaBrowser.Controller/Entities/IItemByName.cs index cac8aa61a5..4928bda7a2 100644 --- a/MediaBrowser.Controller/Entities/IItemByName.cs +++ b/MediaBrowser.Controller/Entities/IItemByName.cs @@ -9,7 +9,7 @@ namespace MediaBrowser.Controller.Entities /// public interface IItemByName { - IList GetTaggedItems(InternalItemsQuery query); + IReadOnlyList GetTaggedItems(InternalItemsQuery query); } public interface IHasDualAccess : IItemByName diff --git a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs index 1461a3680a..d50f3d075c 100644 --- a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs +++ b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs @@ -3,8 +3,10 @@ using System; using System.Collections.Generic; using System.Linq; -using Jellyfin.Data.Entities; +using Jellyfin.Data; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using MediaBrowser.Controller.Dto; using MediaBrowser.Model.Entities; @@ -37,7 +39,6 @@ namespace MediaBrowser.Controller.Entities IncludeItemTypes = Array.Empty(); ItemIds = Array.Empty(); MediaTypes = Array.Empty(); - MinSimilarityScore = 20; OfficialRatings = Array.Empty(); OrderBy = Array.Empty<(ItemSortBy, SortOrder)>(); PersonIds = Array.Empty(); @@ -71,8 +72,6 @@ namespace MediaBrowser.Controller.Entities public User? User { get; set; } - public BaseItem? SimilarTo { get; set; } - public bool? IsFolder { get; set; } public bool? IsFavorite { get; set; } @@ -233,9 +232,9 @@ namespace MediaBrowser.Controller.Entities public int? IndexNumber { get; set; } - public int? MinParentalRating { get; set; } + public ParentalRatingScore? MinParentalRating { get; set; } - public int? MaxParentalRating { get; set; } + public ParentalRatingScore? MaxParentalRating { get; set; } public bool? HasDeadParentId { get; set; } @@ -295,8 +294,6 @@ namespace MediaBrowser.Controller.Entities public DtoOptions DtoOptions { get; set; } - public int MinSimilarityScore { get; set; } - public string? HasNoAudioTrackWithLanguage { get; set; } public string? HasNoInternalSubtitleTrackWithLanguage { get; set; } @@ -309,6 +306,8 @@ namespace MediaBrowser.Controller.Entities public bool? IsDeadStudio { get; set; } + public bool? IsDeadGenre { get; set; } + public bool? IsDeadPerson { get; set; } /// @@ -363,16 +362,17 @@ namespace MediaBrowser.Controller.Entities public void SetUser(User user) { - MaxParentalRating = user.MaxParentalAgeRating; - - if (MaxParentalRating.HasValue) + var maxRating = user.MaxParentalRatingScore; + if (maxRating.HasValue) { - string other = UnratedItem.Other.ToString(); - BlockUnratedItems = user.GetPreference(PreferenceKind.BlockUnratedItems) - .Where(i => i != other) - .Select(e => Enum.Parse(e, true)).ToArray(); + MaxParentalRating = new(maxRating.Value, user.MaxParentalRatingSubScore); } + var other = UnratedItem.Other.ToString(); + BlockUnratedItems = user.GetPreference(PreferenceKind.BlockUnratedItems) + .Where(i => i != other) + .Select(e => Enum.Parse(e, true)).ToArray(); + ExcludeInheritedTags = user.GetPreference(PreferenceKind.BlockedTags); IncludeInheritedTags = user.GetPreference(PreferenceKind.AllowedTags); diff --git a/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs b/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs index 3e1d892748..203a16a668 100644 --- a/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs +++ b/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs @@ -4,7 +4,7 @@ using System; using System.Collections.Generic; -using Jellyfin.Data.Entities; +using Jellyfin.Database.Implementations.Entities; namespace MediaBrowser.Controller.Entities { diff --git a/MediaBrowser.Controller/Entities/LinkedChild.cs b/MediaBrowser.Controller/Entities/LinkedChild.cs index fd5fef3dc5..98e4f525f5 100644 --- a/MediaBrowser.Controller/Entities/LinkedChild.cs +++ b/MediaBrowser.Controller/Entities/LinkedChild.cs @@ -4,7 +4,6 @@ using System; using System.Globalization; -using System.Text.Json.Serialization; namespace MediaBrowser.Controller.Entities { @@ -12,7 +11,6 @@ namespace MediaBrowser.Controller.Entities { public LinkedChild() { - Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); } public string Path { get; set; } @@ -21,9 +19,6 @@ namespace MediaBrowser.Controller.Entities public string LibraryItemId { get; set; } - [JsonIgnore] - public string Id { get; set; } - /// /// Gets or sets the linked item id. /// @@ -31,6 +26,8 @@ namespace MediaBrowser.Controller.Entities public static LinkedChild Create(BaseItem item) { + ArgumentNullException.ThrowIfNull(item); + var child = new LinkedChild { Path = item.Path, diff --git a/MediaBrowser.Controller/Entities/Movies/BoxSet.cs b/MediaBrowser.Controller/Entities/Movies/BoxSet.cs index a07187d2fd..d656fccb4f 100644 --- a/MediaBrowser.Controller/Entities/Movies/BoxSet.cs +++ b/MediaBrowser.Controller/Entities/Movies/BoxSet.cs @@ -4,10 +4,13 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Linq; using System.Text.Json.Serialization; -using Jellyfin.Data.Entities; +using Jellyfin.Data; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Querying; @@ -91,7 +94,7 @@ namespace MediaBrowser.Controller.Entities.Movies return Enumerable.Empty(); } - protected override List LoadChildren() + protected override IReadOnlyList LoadChildren() { if (IsLegacyBoxSet) { @@ -99,7 +102,7 @@ namespace MediaBrowser.Controller.Entities.Movies } // Save a trip to the database - return new List(); + return []; } public override bool IsAuthorizedToDelete(User user, List allCollectionFolders) @@ -127,16 +130,16 @@ namespace MediaBrowser.Controller.Entities.Movies return LibraryManager.Sort(items, user, new[] { sortBy }, SortOrder.Ascending); } - public override List GetChildren(User user, bool includeLinkedChildren, InternalItemsQuery query) + public override IReadOnlyList GetChildren(User user, bool includeLinkedChildren, InternalItemsQuery query) { var children = base.GetChildren(user, includeLinkedChildren, query); - return Sort(children, user).ToList(); + return Sort(children, user).ToArray(); } - public override IEnumerable GetRecursiveChildren(User user, InternalItemsQuery query) + public override IReadOnlyList GetRecursiveChildren(User user, InternalItemsQuery query) { var children = base.GetRecursiveChildren(user, query); - return Sort(children, user).ToList(); + return Sort(children, user).ToArray(); } public BoxSetInfo GetLookupInfo() @@ -144,14 +147,14 @@ namespace MediaBrowser.Controller.Entities.Movies return GetItemLookupInfo(); } - public override bool IsVisible(User user) + public override bool IsVisible(User user, bool skipAllowedTagsCheck = false) { if (IsLegacyBoxSet) { - return base.IsVisible(user); + return base.IsVisible(user, skipAllowedTagsCheck); } - if (base.IsVisible(user)) + if (base.IsVisible(user, skipAllowedTagsCheck)) { if (LinkedChildren.Length == 0) { diff --git a/MediaBrowser.Controller/Entities/PeopleHelper.cs b/MediaBrowser.Controller/Entities/PeopleHelper.cs index 5292bd7727..24b1843ce6 100644 --- a/MediaBrowser.Controller/Entities/PeopleHelper.cs +++ b/MediaBrowser.Controller/Entities/PeopleHelper.cs @@ -10,11 +10,13 @@ namespace MediaBrowser.Controller.Entities { public static class PeopleHelper { - public static void AddPerson(List people, PersonInfo person) + public static void AddPerson(ICollection people, PersonInfo person) { ArgumentNullException.ThrowIfNull(person); ArgumentException.ThrowIfNullOrEmpty(person.Name); + person.Name = person.Name.Trim(); + // Normalize if (string.Equals(person.Role, PersonType.GuestStar, StringComparison.OrdinalIgnoreCase)) { diff --git a/MediaBrowser.Controller/Entities/Person.cs b/MediaBrowser.Controller/Entities/Person.cs index 7f265084fb..5cc4d322f7 100644 --- a/MediaBrowser.Controller/Entities/Person.cs +++ b/MediaBrowser.Controller/Entities/Person.cs @@ -14,6 +14,7 @@ namespace MediaBrowser.Controller.Entities /// /// This is the full Person object that can be retrieved with all of it's data. /// + [Common.RequiresSourceSerialisation] public class Person : BaseItem, IItemByName, IHasLookupInfo { /// @@ -62,7 +63,7 @@ namespace MediaBrowser.Controller.Entities return value; } - public IList GetTaggedItems(InternalItemsQuery query) + public IReadOnlyList GetTaggedItems(InternalItemsQuery query) { query.PersonIds = new[] { Id }; diff --git a/MediaBrowser.Controller/Entities/PersonInfo.cs b/MediaBrowser.Controller/Entities/PersonInfo.cs index 3df0b0b785..0ed870bacf 100644 --- a/MediaBrowser.Controller/Entities/PersonInfo.cs +++ b/MediaBrowser.Controller/Entities/PersonInfo.cs @@ -17,8 +17,14 @@ namespace MediaBrowser.Controller.Entities public PersonInfo() { ProviderIds = new Dictionary(StringComparer.OrdinalIgnoreCase); + Id = Guid.NewGuid(); } + /// + /// Gets or Sets the PersonId. + /// + public Guid Id { get; set; } + public Guid ItemId { get; set; } /// diff --git a/MediaBrowser.Controller/Entities/PhotoAlbum.cs b/MediaBrowser.Controller/Entities/PhotoAlbum.cs index a7ecb9061c..5b31b4f116 100644 --- a/MediaBrowser.Controller/Entities/PhotoAlbum.cs +++ b/MediaBrowser.Controller/Entities/PhotoAlbum.cs @@ -4,6 +4,7 @@ using System.Text.Json.Serialization; namespace MediaBrowser.Controller.Entities { + [Common.RequiresSourceSerialisation] public class PhotoAlbum : Folder { [JsonIgnore] diff --git a/MediaBrowser.Controller/Entities/Studio.cs b/MediaBrowser.Controller/Entities/Studio.cs index a3736a4bfc..9103b09a95 100644 --- a/MediaBrowser.Controller/Entities/Studio.cs +++ b/MediaBrowser.Controller/Entities/Studio.cs @@ -13,6 +13,7 @@ namespace MediaBrowser.Controller.Entities /// /// Class Studio. /// + [Common.RequiresSourceSerialisation] public class Studio : BaseItem, IItemByName { /// @@ -63,7 +64,7 @@ namespace MediaBrowser.Controller.Entities return true; } - public IList GetTaggedItems(InternalItemsQuery query) + public IReadOnlyList GetTaggedItems(InternalItemsQuery query) { query.StudioIds = new[] { Id }; diff --git a/MediaBrowser.Controller/Entities/TV/Season.cs b/MediaBrowser.Controller/Entities/TV/Season.cs index 181b9be2bf..408161b03d 100644 --- a/MediaBrowser.Controller/Entities/TV/Season.cs +++ b/MediaBrowser.Controller/Entities/TV/Season.cs @@ -7,9 +7,11 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Text.Json.Serialization; -using Jellyfin.Data.Entities; +using System.Threading; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; using Jellyfin.Extensions; +using MediaBrowser.Common; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Querying; @@ -19,6 +21,7 @@ namespace MediaBrowser.Controller.Entities.TV /// /// Class Season. /// + [RequiresSourceSerialisation] public class Season : Folder, IHasSeries, IHasLookupInfo { [JsonIgnore] @@ -132,7 +135,7 @@ namespace MediaBrowser.Controller.Entities.TV var series = Series; if (series is not null) { - return series.PresentationUniqueKey + "-" + (IndexNumber ?? 0).ToString("000", CultureInfo.InvariantCulture); + return series.PresentationUniqueKey + "-" + IndexNumber.Value.ToString("000", CultureInfo.InvariantCulture); } } @@ -150,6 +153,21 @@ namespace MediaBrowser.Controller.Entities.TV protected override QueryResult GetItemsInternal(InternalItemsQuery query) { + if (SourceType == SourceType.Channel) + { + try + { + query.Parent = this; + query.ChannelIds = new[] { ChannelId }; + return ChannelManager.GetChannelItemsInternal(query, new Progress(), CancellationToken.None).GetAwaiter().GetResult(); + } + catch + { + // Already logged at lower levels + return new QueryResult(); + } + } + if (query.User is null) { return base.GetItemsInternal(query); @@ -255,7 +273,7 @@ namespace MediaBrowser.Controller.Entities.TV if (!IndexNumber.HasValue && !string.IsNullOrEmpty(Path)) { - IndexNumber ??= LibraryManager.GetSeasonNumberFromPath(Path); + IndexNumber ??= LibraryManager.GetSeasonNumberFromPath(Path, ParentId); // If a change was made record it if (IndexNumber.HasValue) diff --git a/MediaBrowser.Controller/Entities/TV/Series.cs b/MediaBrowser.Controller/Entities/TV/Series.cs index a324f79eff..b4ad05921e 100644 --- a/MediaBrowser.Controller/Entities/TV/Series.cs +++ b/MediaBrowser.Controller/Entities/TV/Series.cs @@ -9,12 +9,13 @@ using System.Linq; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Data.Entities; +using Jellyfin.Data; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Providers; using MediaBrowser.Model.Querying; using MetadataProvider = MediaBrowser.Model.Entities.MetadataProvider; @@ -23,7 +24,7 @@ namespace MediaBrowser.Controller.Entities.TV /// /// Class Series. /// - public class Series : Folder, IHasTrailers, IHasDisplayOrder, IHasLookupInfo, IMetadataContainer + public class Series : Folder, IHasTrailers, IHasDisplayOrder, IHasLookupInfo, IMetadataContainer, ISupportsBoxSetGrouping { public Series() { @@ -189,12 +190,12 @@ namespace MediaBrowser.Controller.Entities.TV return list; } - public override List GetChildren(User user, bool includeLinkedChildren, InternalItemsQuery query) + public override IReadOnlyList GetChildren(User user, bool includeLinkedChildren, InternalItemsQuery query) { return GetSeasons(user, new DtoOptions(true)); } - public List GetSeasons(User user, DtoOptions options) + public IReadOnlyList GetSeasons(User user, DtoOptions options) { var query = new InternalItemsQuery(user) { @@ -225,6 +226,21 @@ namespace MediaBrowser.Controller.Entities.TV { var user = query.User; + if (SourceType == SourceType.Channel) + { + try + { + query.Parent = this; + query.ChannelIds = [ChannelId]; + return ChannelManager.GetChannelItemsInternal(query, new Progress(), CancellationToken.None).GetAwaiter().GetResult(); + } + catch + { + // Already logged at lower levels + return new QueryResult(); + } + } + if (query.Recursive) { var seriesKey = GetUniqueSeriesKey(this); @@ -371,7 +387,25 @@ namespace MediaBrowser.Controller.Entities.TV query.IsMissing = false; } - var allItems = LibraryManager.GetItemList(query); + IReadOnlyList allItems; + if (SourceType == SourceType.Channel) + { + try + { + query.Parent = parentSeason; + query.ChannelIds = [ChannelId]; + allItems = [.. ChannelManager.GetChannelItemsInternal(query, new Progress(), CancellationToken.None).GetAwaiter().GetResult().Items]; + } + catch + { + // Already logged at lower levels + return []; + } + } + else + { + allItems = LibraryManager.GetItemList(query); + } return GetSeasonEpisodes(parentSeason, user, allItems, options, shouldIncludeMissingEpisodes); } diff --git a/MediaBrowser.Controller/Entities/UserRootFolder.cs b/MediaBrowser.Controller/Entities/UserRootFolder.cs index a687adeddc..bc7e22d9a0 100644 --- a/MediaBrowser.Controller/Entities/UserRootFolder.cs +++ b/MediaBrowser.Controller/Entities/UserRootFolder.cs @@ -8,7 +8,7 @@ using System.Linq; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Data.Entities; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Library; using MediaBrowser.Model.Querying; @@ -21,7 +21,7 @@ namespace MediaBrowser.Controller.Entities /// public class UserRootFolder : Folder { - private readonly object _childIdsLock = new object(); + private readonly Lock _childIdsLock = new(); private List _childrenIds = null; /// @@ -52,7 +52,7 @@ namespace MediaBrowser.Controller.Entities } } - protected override List LoadChildren() + protected override IReadOnlyList LoadChildren() { lock (_childIdsLock) { diff --git a/MediaBrowser.Controller/Entities/UserView.cs b/MediaBrowser.Controller/Entities/UserView.cs index e4fb340f78..dfa31315cb 100644 --- a/MediaBrowser.Controller/Entities/UserView.cs +++ b/MediaBrowser.Controller/Entities/UserView.cs @@ -8,8 +8,8 @@ using System.Linq; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; using Jellyfin.Extensions; using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.TV; @@ -134,7 +134,7 @@ namespace MediaBrowser.Controller.Entities } /// - public override IEnumerable GetRecursiveChildren(User user, InternalItemsQuery query) + public override IReadOnlyList GetRecursiveChildren(User user, InternalItemsQuery query) { query.SetUser(user); query.Recursive = true; @@ -145,7 +145,7 @@ namespace MediaBrowser.Controller.Entities } /// - protected override IEnumerable GetEligibleChildrenForRecursiveChildren(User user) + protected override IReadOnlyList GetEligibleChildrenForRecursiveChildren(User user) { return GetChildren(user, false); } diff --git a/MediaBrowser.Controller/Entities/UserViewBuilder.cs b/MediaBrowser.Controller/Entities/UserViewBuilder.cs index 420349f35c..c2b4da32ac 100644 --- a/MediaBrowser.Controller/Entities/UserViewBuilder.cs +++ b/MediaBrowser.Controller/Entities/UserViewBuilder.cs @@ -6,8 +6,10 @@ using System; using System.Collections.Generic; using System.Globalization; using System.Linq; -using Jellyfin.Data.Entities; +using Jellyfin.Data; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.TV; @@ -236,7 +238,7 @@ namespace MediaBrowser.Controller.Entities return ConvertToResult(_libraryManager.GetItemList(query)); } - private QueryResult ConvertToResult(List items) + private QueryResult ConvertToResult(IReadOnlyList items) { return new QueryResult(items); } diff --git a/MediaBrowser.Controller/Entities/Year.cs b/MediaBrowser.Controller/Entities/Year.cs index afdaf448b7..37820296cc 100644 --- a/MediaBrowser.Controller/Entities/Year.cs +++ b/MediaBrowser.Controller/Entities/Year.cs @@ -13,6 +13,7 @@ namespace MediaBrowser.Controller.Entities /// /// Class Year. /// + [Common.RequiresSourceSerialisation] public class Year : BaseItem, IItemByName { [JsonIgnore] @@ -55,7 +56,7 @@ namespace MediaBrowser.Controller.Entities return true; } - public IList GetTaggedItems(InternalItemsQuery query) + public IReadOnlyList GetTaggedItems(InternalItemsQuery query) { if (!int.TryParse(Name, NumberStyles.Integer, CultureInfo.InvariantCulture, out var year)) { diff --git a/MediaBrowser.Controller/Extensions/ConfigurationExtensions.cs b/MediaBrowser.Controller/Extensions/ConfigurationExtensions.cs index f8049cd488..e4806109a1 100644 --- a/MediaBrowser.Controller/Extensions/ConfigurationExtensions.cs +++ b/MediaBrowser.Controller/Extensions/ConfigurationExtensions.cs @@ -49,11 +49,6 @@ namespace MediaBrowser.Controller.Extensions /// public const string FfmpegPathKey = "ffmpeg"; - /// - /// The key for a setting that indicates whether playlists should allow duplicate entries. - /// - public const string PlaylistsAllowDuplicatesKey = "playlists:allowDuplicates"; - /// /// The key for a setting that indicates whether kestrel should bind to a unix socket. /// @@ -120,14 +115,6 @@ namespace MediaBrowser.Controller.Extensions public static bool GetFFmpegImgExtractPerfTradeoff(this IConfiguration configuration) => configuration.GetValue(FfmpegImgExtractPerfTradeoffKey); - /// - /// Gets a value indicating whether playlists should allow duplicate entries from the . - /// - /// The configuration to read the setting from. - /// True if playlists should allow duplicates, otherwise false. - public static bool DoPlaylistsAllowDuplicates(this IConfiguration configuration) - => configuration.GetValue(PlaylistsAllowDuplicatesKey); - /// /// Gets a value indicating whether kestrel should bind to a unix socket from the . /// diff --git a/MediaBrowser.Controller/IDisplayPreferencesManager.cs b/MediaBrowser.Controller/IDisplayPreferencesManager.cs index 10c0f56e09..a97096eaee 100644 --- a/MediaBrowser.Controller/IDisplayPreferencesManager.cs +++ b/MediaBrowser.Controller/IDisplayPreferencesManager.cs @@ -1,6 +1,6 @@ using System; using System.Collections.Generic; -using Jellyfin.Data.Entities; +using Jellyfin.Database.Implementations.Entities; namespace MediaBrowser.Controller { diff --git a/MediaBrowser.Controller/IO/IPathManager.cs b/MediaBrowser.Controller/IO/IPathManager.cs new file mode 100644 index 0000000000..7c20164a6f --- /dev/null +++ b/MediaBrowser.Controller/IO/IPathManager.cs @@ -0,0 +1,49 @@ +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Dto; + +namespace MediaBrowser.Controller.IO; + +/// +/// Interface ITrickplayManager. +/// +public interface IPathManager +{ + /// + /// Gets the path to the trickplay image base folder. + /// + /// The item. + /// Whether or not the tile should be saved next to the media file. + /// The absolute path. + public string GetTrickplayDirectory(BaseItem item, bool saveWithMedia = false); + + /// + /// Gets the path to the subtitle file. + /// + /// The media source id. + /// The stream index. + /// The subtitle file extension. + /// The absolute path. + public string GetSubtitlePath(string mediaSourceId, int streamIndex, string extension); + + /// + /// Gets the path to the subtitle file. + /// + /// The media source id. + /// The absolute path. + public string GetSubtitleFolderPath(string mediaSourceId); + + /// + /// Gets the path to the attachment file. + /// + /// The media source id. + /// The attachmentFileName index. + /// The absolute path. + public string GetAttachmentPath(string mediaSourceId, string fileName); + + /// + /// Gets the path to the attachment folder. + /// + /// The media source id. + /// The absolute path. + public string GetAttachmentFolderPath(string mediaSourceId); +} diff --git a/MediaBrowser.Controller/ISystemManager.cs b/MediaBrowser.Controller/ISystemManager.cs index ef3034d2f5..08344a1e51 100644 --- a/MediaBrowser.Controller/ISystemManager.cs +++ b/MediaBrowser.Controller/ISystemManager.cs @@ -1,5 +1,6 @@ using MediaBrowser.Model.System; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; namespace MediaBrowser.Controller; @@ -31,4 +32,10 @@ public interface ISystemManager /// Starts the application shutdown process. /// void Shutdown(); + + /// + /// Gets the systems storage resources. + /// + /// The . + SystemStorageInfo GetSystemStorageInfo(); } diff --git a/MediaBrowser.Controller/Library/IIntroProvider.cs b/MediaBrowser.Controller/Library/IIntroProvider.cs index 4a9721acbe..860e948afe 100644 --- a/MediaBrowser.Controller/Library/IIntroProvider.cs +++ b/MediaBrowser.Controller/Library/IIntroProvider.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Threading.Tasks; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; namespace MediaBrowser.Controller.Library @@ -23,6 +24,6 @@ namespace MediaBrowser.Controller.Library /// The item. /// The user. /// IEnumerable{System.String}. - Task> GetIntros(BaseItem item, Jellyfin.Data.Entities.User user); + Task> GetIntros(BaseItem item, User user); } } diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs index b802b7e6ea..df90f546cd 100644 --- a/MediaBrowser.Controller/Library/ILibraryManager.cs +++ b/MediaBrowser.Controller/Library/ILibraryManager.cs @@ -4,8 +4,9 @@ using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; @@ -426,8 +427,9 @@ namespace MediaBrowser.Controller.Library /// Gets the season number from path. /// /// The path. + /// The parent id. /// System.Nullable<System.Int32>. - int? GetSeasonNumberFromPath(string path); + int? GetSeasonNumberFromPath(string path, Guid? parentId); /// /// Fills the missing episode numbers from path. @@ -483,21 +485,21 @@ namespace MediaBrowser.Controller.Library /// /// The item. /// List<PersonInfo>. - List GetPeople(BaseItem item); + IReadOnlyList GetPeople(BaseItem item); /// /// Gets the people. /// /// The query. /// List<PersonInfo>. - List GetPeople(InternalPeopleQuery query); + IReadOnlyList GetPeople(InternalPeopleQuery query); /// /// Gets the people items. /// /// The query. /// List<Person>. - List GetPeopleItems(InternalPeopleQuery query); + IReadOnlyList GetPeopleItems(InternalPeopleQuery query); /// /// Updates the people. @@ -513,21 +515,21 @@ namespace MediaBrowser.Controller.Library /// The people. /// The cancellation token. /// The async task. - Task UpdatePeopleAsync(BaseItem item, List people, CancellationToken cancellationToken); + Task UpdatePeopleAsync(BaseItem item, IReadOnlyList people, CancellationToken cancellationToken); /// /// Gets the item ids. /// /// The query. /// List<Guid>. - List GetItemIds(InternalItemsQuery query); + IReadOnlyList GetItemIds(InternalItemsQuery query); /// /// Gets the people names. /// /// The query. /// List<System.String>. - List GetPeopleNames(InternalPeopleQuery query); + IReadOnlyList GetPeopleNames(InternalPeopleQuery query); /// /// Queries the items. @@ -553,9 +555,9 @@ namespace MediaBrowser.Controller.Library /// /// The query. /// QueryResult<BaseItem>. - List GetItemList(InternalItemsQuery query); + IReadOnlyList GetItemList(InternalItemsQuery query); - List GetItemList(InternalItemsQuery query, bool allowExternalContent); + IReadOnlyList GetItemList(InternalItemsQuery query, bool allowExternalContent); /// /// Gets the items. @@ -563,7 +565,25 @@ namespace MediaBrowser.Controller.Library /// The query to use. /// Items to use for query. /// List of items. - List GetItemList(InternalItemsQuery query, List parents); + IReadOnlyList GetItemList(InternalItemsQuery query, List parents); + + /// + /// Gets the TVShow/Album items for Latest api. + /// + /// The query to use. + /// Items to use for query. + /// Collection Type. + /// List of items. + IReadOnlyList GetLatestItemList(InternalItemsQuery query, IReadOnlyList parents, CollectionType collectionType); + + /// + /// Gets the list of series presentation keys for next up. + /// + /// The query to use. + /// Items to use for query. + /// The minimum date for a series to have been most recently watched. + /// List of series presentation keys. + IReadOnlyList GetNextUpSeriesKeys(InternalItemsQuery query, IReadOnlyCollection parents, DateTime dateCutoff); /// /// Gets the items result. diff --git a/MediaBrowser.Controller/Library/IMediaSourceManager.cs b/MediaBrowser.Controller/Library/IMediaSourceManager.cs index 44a1a85e30..2b6781a197 100644 --- a/MediaBrowser.Controller/Library/IMediaSourceManager.cs +++ b/MediaBrowser.Controller/Library/IMediaSourceManager.cs @@ -6,7 +6,7 @@ using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Data.Entities; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.Persistence; @@ -29,31 +29,31 @@ namespace MediaBrowser.Controller.Library /// /// The item identifier. /// IEnumerable<MediaStream>. - List GetMediaStreams(Guid itemId); + IReadOnlyList GetMediaStreams(Guid itemId); /// /// Gets the media streams. /// /// The query. /// IEnumerable<MediaStream>. - List GetMediaStreams(MediaStreamQuery query); + IReadOnlyList GetMediaStreams(MediaStreamQuery query); /// /// Gets the media attachments. /// /// The item identifier. /// IEnumerable<MediaAttachment>. - List GetMediaAttachments(Guid itemId); + IReadOnlyList GetMediaAttachments(Guid itemId); /// /// Gets the media attachments. /// /// The query. /// IEnumerable<MediaAttachment>. - List GetMediaAttachments(MediaAttachmentQuery query); + IReadOnlyList GetMediaAttachments(MediaAttachmentQuery query); /// - /// Gets the playack media sources. + /// Gets the playback media sources. /// /// Item to use. /// User to use for operation. @@ -61,7 +61,7 @@ namespace MediaBrowser.Controller.Library /// Option to enable path substitution. /// CancellationToken to use for operation. /// List of media sources wrapped in an awaitable task. - Task> GetPlaybackMediaSources(BaseItem item, User user, bool allowMediaProbe, bool enablePathSubstitution, CancellationToken cancellationToken); + Task> GetPlaybackMediaSources(BaseItem item, User user, bool allowMediaProbe, bool enablePathSubstitution, CancellationToken cancellationToken); /// /// Gets the static media sources. @@ -70,7 +70,7 @@ namespace MediaBrowser.Controller.Library /// Option to enable path substitution. /// User to use for operation. /// List of media sources. - List GetStaticMediaSources(BaseItem item, bool enablePathSubstitution, User user = null); + IReadOnlyList GetStaticMediaSources(BaseItem item, bool enablePathSubstitution, User user = null); /// /// Gets the static media source. @@ -123,7 +123,7 @@ namespace MediaBrowser.Controller.Library /// The . /// The . /// A task containing the 's for the recording. - Task> GetRecordingStreamMediaSources(ActiveRecordingInfo info, CancellationToken cancellationToken); + Task> GetRecordingStreamMediaSources(ActiveRecordingInfo info, CancellationToken cancellationToken); /// /// Closes the media source. diff --git a/MediaBrowser.Controller/Library/IMusicManager.cs b/MediaBrowser.Controller/Library/IMusicManager.cs index 93073cc79b..20764ec601 100644 --- a/MediaBrowser.Controller/Library/IMusicManager.cs +++ b/MediaBrowser.Controller/Library/IMusicManager.cs @@ -1,7 +1,7 @@ #pragma warning disable CA1002, CS1591 using System.Collections.Generic; -using Jellyfin.Data.Entities; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; @@ -17,7 +17,7 @@ namespace MediaBrowser.Controller.Library /// The user to use. /// The options to use. /// List of items. - List GetInstantMixFromItem(BaseItem item, User? user, DtoOptions dtoOptions); + IReadOnlyList GetInstantMixFromItem(BaseItem item, User? user, DtoOptions dtoOptions); /// /// Gets the instant mix from artist. @@ -26,7 +26,7 @@ namespace MediaBrowser.Controller.Library /// The user to use. /// The options to use. /// List of items. - List GetInstantMixFromArtist(MusicArtist artist, User? user, DtoOptions dtoOptions); + IReadOnlyList GetInstantMixFromArtist(MusicArtist artist, User? user, DtoOptions dtoOptions); /// /// Gets the instant mix from genre. @@ -35,6 +35,6 @@ namespace MediaBrowser.Controller.Library /// The user to use. /// The options to use. /// List of items. - List GetInstantMixFromGenres(IEnumerable genres, User? user, DtoOptions dtoOptions); + IReadOnlyList GetInstantMixFromGenres(IEnumerable genres, User? user, DtoOptions dtoOptions); } } diff --git a/MediaBrowser.Controller/Library/IUserDataManager.cs b/MediaBrowser.Controller/Library/IUserDataManager.cs index f36fd393f7..eb46611dd9 100644 --- a/MediaBrowser.Controller/Library/IUserDataManager.cs +++ b/MediaBrowser.Controller/Library/IUserDataManager.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; using System.Threading; -using Jellyfin.Data.Entities; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Dto; @@ -44,7 +44,7 @@ namespace MediaBrowser.Controller.Library /// User to use. /// Item to use. /// User data. - UserItemData GetUserData(User user, BaseItem item); + UserItemData? GetUserData(User user, BaseItem item); /// /// Gets the user data dto. @@ -52,7 +52,7 @@ namespace MediaBrowser.Controller.Library /// Item to use. /// User to use. /// User data dto. - UserItemDataDto GetUserDataDto(BaseItem item, User user); + UserItemDataDto? GetUserDataDto(BaseItem item, User user); /// /// Gets the user data dto. @@ -62,7 +62,7 @@ namespace MediaBrowser.Controller.Library /// User to use. /// Dto options to use. /// User data dto. - UserItemDataDto GetUserDataDto(BaseItem item, BaseItemDto? itemDto, User user, DtoOptions options); + UserItemDataDto? GetUserDataDto(BaseItem item, BaseItemDto? itemDto, User user, DtoOptions options); /// /// Updates playstate for an item and returns true or false indicating if it was played to completion. diff --git a/MediaBrowser.Controller/Library/IUserManager.cs b/MediaBrowser.Controller/Library/IUserManager.cs index 1c115be857..0109cf4b7d 100644 --- a/MediaBrowser.Controller/Library/IUserManager.cs +++ b/MediaBrowser.Controller/Library/IUserManager.cs @@ -3,8 +3,8 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; -using Jellyfin.Data.Entities; using Jellyfin.Data.Events; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Users; diff --git a/MediaBrowser.Controller/Library/PlaybackProgressEventArgs.cs b/MediaBrowser.Controller/Library/PlaybackProgressEventArgs.cs index 76e9eb1f54..b0a6782c77 100644 --- a/MediaBrowser.Controller/Library/PlaybackProgressEventArgs.cs +++ b/MediaBrowser.Controller/Library/PlaybackProgressEventArgs.cs @@ -4,7 +4,7 @@ using System; using System.Collections.Generic; -using Jellyfin.Data.Entities; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Session; using MediaBrowser.Model.Dto; diff --git a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs index c0e46ba245..8d59eef9f1 100644 --- a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs +++ b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs @@ -6,8 +6,8 @@ using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Data.Entities; using Jellyfin.Data.Events; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Dto; diff --git a/MediaBrowser.Controller/LiveTv/LiveTvChannel.cs b/MediaBrowser.Controller/LiveTv/LiveTvChannel.cs index 3c2cf8e3d2..b10e77e10a 100644 --- a/MediaBrowser.Controller/LiveTv/LiveTvChannel.cs +++ b/MediaBrowser.Controller/LiveTv/LiveTvChannel.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Globalization; using System.Linq; using System.Text.Json.Serialization; @@ -119,13 +120,10 @@ namespace MediaBrowser.Controller.LiveTv return "TvChannel"; } - public IEnumerable GetTaggedItems() - => Enumerable.Empty(); + public IEnumerable GetTaggedItems() => []; - public override List GetMediaSources(bool enablePathSubstitution) + public override IReadOnlyList GetMediaSources(bool enablePathSubstitution) { - var list = new List(); - var info = new MediaSourceInfo { Id = Id.ToString("N", CultureInfo.InvariantCulture), @@ -138,14 +136,12 @@ namespace MediaBrowser.Controller.LiveTv IsInfiniteStream = RunTimeTicks is null }; - list.Add(info); - - return list; + return [info]; } - public override List GetMediaStreams() + public override IReadOnlyList GetMediaStreams() { - return new List(); + return []; } protected override string GetInternalMetadataPath(string basePath) diff --git a/MediaBrowser.Controller/LiveTv/LiveTvProgram.cs b/MediaBrowser.Controller/LiveTv/LiveTvProgram.cs index 2ac6f99633..83944f741c 100644 --- a/MediaBrowser.Controller/LiveTv/LiveTvProgram.cs +++ b/MediaBrowser.Controller/LiveTv/LiveTvProgram.cs @@ -18,6 +18,7 @@ using MediaBrowser.Model.Providers; namespace MediaBrowser.Controller.LiveTv { + [Common.RequiresSourceSerialisation] public class LiveTvProgram : BaseItem, IHasLookupInfo, IHasStartDate, IHasProgramAttributes { private const string EmbyServiceName = "Emby"; diff --git a/MediaBrowser.Controller/MediaBrowser.Controller.csproj b/MediaBrowser.Controller/MediaBrowser.Controller.csproj index 1ef2eb343d..3353ad63f1 100644 --- a/MediaBrowser.Controller/MediaBrowser.Controller.csproj +++ b/MediaBrowser.Controller/MediaBrowser.Controller.csproj @@ -8,7 +8,7 @@ Jellyfin Contributors Jellyfin.Controller - 10.10.0 + 10.11.0 https://github.com/jellyfin/jellyfin GPL-3.0-only @@ -18,6 +18,7 @@ + @@ -27,6 +28,7 @@ + @@ -34,7 +36,7 @@ - net8.0 + net9.0 false true true diff --git a/MediaBrowser.Controller/MediaEncoding/BitStreamFilterOptionType.cs b/MediaBrowser.Controller/MediaEncoding/BitStreamFilterOptionType.cs new file mode 100644 index 0000000000..41d21e4404 --- /dev/null +++ b/MediaBrowser.Controller/MediaEncoding/BitStreamFilterOptionType.cs @@ -0,0 +1,32 @@ +namespace MediaBrowser.Controller.MediaEncoding; + +/// +/// Enum BitStreamFilterOptionType. +/// +public enum BitStreamFilterOptionType +{ + /// + /// hevc_metadata bsf with remove_dovi option. + /// + HevcMetadataRemoveDovi = 0, + + /// + /// hevc_metadata bsf with remove_hdr10plus option. + /// + HevcMetadataRemoveHdr10Plus = 1, + + /// + /// av1_metadata bsf with remove_dovi option. + /// + Av1MetadataRemoveDovi = 2, + + /// + /// av1_metadata bsf with remove_hdr10plus option. + /// + Av1MetadataRemoveHdr10Plus = 3, + + /// + /// dovi_rpu bsf with strip option. + /// + DoviRpuStrip = 4, +} diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs index 28f0d1fff7..7c3138002f 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs @@ -13,10 +13,13 @@ using System.Runtime.InteropServices; using System.Text; using System.Text.RegularExpressions; using System.Threading; +using Jellyfin.Data; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Extensions; +using MediaBrowser.Controller.IO; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Dto; @@ -35,7 +38,13 @@ namespace MediaBrowser.Controller.MediaEncoding /// periods, underscores, commas, and vertical bars, with a length between 0 and 40 characters. /// This should matches all common valid codecs. /// - public const string ValidationRegex = @"^[a-zA-Z0-9\-\._,|]{0,40}$"; + public const string ContainerValidationRegex = @"^[a-zA-Z0-9\-\._,|]{0,40}$"; + + /// + /// The level validation regex. + /// This regular expression matches strings representing a double. + /// + public const string LevelValidationRegex = @"-?[0-9]+(?:\.[0-9]+)?"; private const string _defaultMjpegEncoder = "mjpeg"; @@ -53,6 +62,7 @@ namespace MediaBrowser.Controller.MediaEncoding private readonly ISubtitleEncoder _subtitleEncoder; private readonly IConfiguration _config; private readonly IConfigurationManager _configurationManager; + private readonly IPathManager _pathManager; // i915 hang was fixed by linux 6.2 (3f882f2) private readonly Version _minKerneli915Hang = new Version(5, 18); @@ -60,7 +70,7 @@ namespace MediaBrowser.Controller.MediaEncoding private readonly Version _minFixedKernel60i915Hang = new Version(6, 0, 18); private readonly Version _minKernelVersionAmdVkFmtModifier = new Version(5, 15); - private readonly Version _minFFmpegImplictHwaccel = new Version(6, 0); + private readonly Version _minFFmpegImplicitHwaccel = new Version(6, 0); private readonly Version _minFFmpegHwaUnsafeOutput = new Version(6, 0); private readonly Version _minFFmpegOclCuTonemapMode = new Version(5, 1, 3); private readonly Version _minFFmpegSvtAv1Params = new Version(5, 1); @@ -74,8 +84,9 @@ namespace MediaBrowser.Controller.MediaEncoding private readonly Version _minFFmpegQsvVppOutRangeOption = new Version(7, 0, 1); private readonly Version _minFFmpegVaapiDeviceVendorId = new Version(7, 0, 1); private readonly Version _minFFmpegQsvVppScaleModeOption = new Version(6, 0); + private readonly Version _minFFmpegRkmppHevcDecDoviRpu = new Version(7, 1, 1); - private static readonly Regex _validationRegex = new(ValidationRegex, RegexOptions.Compiled); + private static readonly Regex _containerValidationRegex = new(ContainerValidationRegex, RegexOptions.Compiled); private static readonly string[] _videoProfilesH264 = [ @@ -151,13 +162,22 @@ namespace MediaBrowser.Controller.MediaEncoding IMediaEncoder mediaEncoder, ISubtitleEncoder subtitleEncoder, IConfiguration config, - IConfigurationManager configurationManager) + IConfigurationManager configurationManager, + IPathManager pathManager) { _appPaths = appPaths; _mediaEncoder = mediaEncoder; _subtitleEncoder = subtitleEncoder; _config = config; _configurationManager = configurationManager; + _pathManager = pathManager; + } + + private enum DynamicHdrMetadataRemovalPlan + { + None, + RemoveDovi, + RemoveHdr10Plus, } [GeneratedRegex(@"\s+")] @@ -309,7 +329,6 @@ namespace MediaBrowser.Controller.MediaEncoding private bool IsSwTonemapAvailable(EncodingJobInfo state, EncodingOptions options) { if (state.VideoStream is null - || !options.EnableTonemapping || GetVideoColorBitDepth(state) < 10 || !_mediaEncoder.SupportsFilter("tonemapx")) { @@ -331,8 +350,17 @@ namespace MediaBrowser.Controller.MediaEncoding if (state.VideoStream.VideoRange == VideoRange.HDR && state.VideoStream.VideoRangeType == VideoRangeType.DOVI) { - // Only native SW decoder and HW accelerator can parse dovi rpu. + // Only native SW decoder, HW accelerator and hevc_rkmpp decoder can parse dovi rpu. var vidDecoder = GetHardwareVideoDecoder(state, options) ?? string.Empty; + + var isRkmppDecoder = vidDecoder.Contains("rkmpp", StringComparison.OrdinalIgnoreCase); + if (isRkmppDecoder + && _mediaEncoder.EncoderVersion >= _minFFmpegRkmppHevcDecDoviRpu + && string.Equals(state.VideoStream?.Codec, "hevc", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + var isSwDecoder = string.IsNullOrEmpty(vidDecoder); var isNvdecDecoder = vidDecoder.Contains("cuda", StringComparison.OrdinalIgnoreCase); var isVaapiDecoder = vidDecoder.Contains("vaapi", StringComparison.OrdinalIgnoreCase); @@ -341,11 +369,8 @@ namespace MediaBrowser.Controller.MediaEncoding return isSwDecoder || isNvdecDecoder || isVaapiDecoder || isD3d11vaDecoder || isVideoToolBoxDecoder; } - return state.VideoStream.VideoRange == VideoRange.HDR - && (state.VideoStream.VideoRangeType == VideoRangeType.HDR10 - || state.VideoStream.VideoRangeType == VideoRangeType.HLG - || state.VideoStream.VideoRangeType == VideoRangeType.DOVIWithHDR10 - || state.VideoStream.VideoRangeType == VideoRangeType.DOVIWithHLG); + // GPU tonemapping supports all HDR RangeTypes + return state.VideoStream.VideoRange == VideoRange.HDR; } private bool IsVulkanHwTonemapAvailable(EncodingJobInfo state, EncodingOptions options) @@ -380,8 +405,7 @@ namespace MediaBrowser.Controller.MediaEncoding } return state.VideoStream.VideoRange == VideoRange.HDR - && (state.VideoStream.VideoRangeType == VideoRangeType.HDR10 - || state.VideoStream.VideoRangeType == VideoRangeType.DOVIWithHDR10); + && IsDoviWithHdr10Bl(state.VideoStream); } private bool IsVideoToolboxTonemapAvailable(EncodingJobInfo state, EncodingOptions options) @@ -396,7 +420,8 @@ namespace MediaBrowser.Controller.MediaEncoding // Certain DV profile 5 video works in Safari with direct playing, but the VideoToolBox does not produce correct mapping results with transcoding. // All other HDR formats working. return state.VideoStream.VideoRange == VideoRange.HDR - && state.VideoStream.VideoRangeType is VideoRangeType.HDR10 or VideoRangeType.HLG or VideoRangeType.HDR10Plus or VideoRangeType.DOVIWithHDR10 or VideoRangeType.DOVIWithHLG; + && (IsDoviWithHdr10Bl(state.VideoStream) + || state.VideoStream.VideoRangeType is VideoRangeType.HLG); } private bool IsVideoStreamHevcRext(EncodingJobInfo state) @@ -451,7 +476,7 @@ namespace MediaBrowser.Controller.MediaEncoding return GetMjpegEncoder(state, encodingOptions); } - if (_validationRegex.IsMatch(codec)) + if (_containerValidationRegex.IsMatch(codec)) { return codec.ToLowerInvariant(); } @@ -492,7 +517,7 @@ namespace MediaBrowser.Controller.MediaEncoding public static string GetInputFormat(string container) { - if (string.IsNullOrEmpty(container) || !_validationRegex.IsMatch(container)) + if (string.IsNullOrEmpty(container) || !_containerValidationRegex.IsMatch(container)) { return null; } @@ -631,7 +656,7 @@ namespace MediaBrowser.Controller.MediaEncoding { if (string.IsNullOrWhiteSpace(container)) { - // this may not work, but if the client is that broken we can not do anything better + // this may not work, but if the client is that broken we cannot do anything better return "aac"; } @@ -710,7 +735,7 @@ namespace MediaBrowser.Controller.MediaEncoding { var codec = state.OutputAudioCodec; - if (!_validationRegex.IsMatch(codec)) + if (!_containerValidationRegex.IsMatch(codec)) { codec = "aac"; } @@ -861,9 +886,9 @@ namespace MediaBrowser.Controller.MediaEncoding && _mediaEncoder.EncoderVersion >= _minFFmpegVaapiDeviceVendorId; // Priority: 'renderNodePath' > 'vendorId' > 'kernelDriver' - var driverOpts = string.IsNullOrEmpty(renderNodePath) - ? (haveVendorId ? $",vendor_id={vendorId}" : (string.IsNullOrEmpty(kernelDriver) ? string.Empty : $",kernel_driver={kernelDriver}")) - : renderNodePath; + var driverOpts = File.Exists(renderNodePath) + ? renderNodePath + : (haveVendorId ? $",vendor_id={vendorId}" : (string.IsNullOrEmpty(kernelDriver) ? string.Empty : $",kernel_driver={kernelDriver}")); // 'driver' behaves similarly to env LIBVA_DRIVER_NAME driverOpts += string.IsNullOrEmpty(driver) ? string.Empty : ",driver=" + driver; @@ -1300,6 +1325,13 @@ namespace MediaBrowser.Controller.MediaEncoding || codec.Contains("hevc", StringComparison.OrdinalIgnoreCase); } + public static bool IsAv1(MediaStream stream) + { + var codec = stream.Codec ?? string.Empty; + + return codec.Contains("av1", StringComparison.OrdinalIgnoreCase); + } + public static bool IsAAC(MediaStream stream) { var codec = stream.Codec ?? string.Empty; @@ -1307,8 +1339,125 @@ namespace MediaBrowser.Controller.MediaEncoding return codec.Contains("aac", StringComparison.OrdinalIgnoreCase); } - public static string GetBitStreamArgs(MediaStream stream) + public static bool IsDoviWithHdr10Bl(MediaStream stream) { + var rangeType = stream?.VideoRangeType; + + return rangeType is VideoRangeType.DOVIWithHDR10 + or VideoRangeType.DOVIWithEL + or VideoRangeType.DOVIWithHDR10Plus + or VideoRangeType.DOVIWithELHDR10Plus + or VideoRangeType.DOVIInvalid; + } + + public static bool IsDovi(MediaStream stream) + { + var rangeType = stream?.VideoRangeType; + + return IsDoviWithHdr10Bl(stream) + || (rangeType is VideoRangeType.DOVI + or VideoRangeType.DOVIWithHLG + or VideoRangeType.DOVIWithSDR); + } + + public static bool IsHdr10Plus(MediaStream stream) + { + var rangeType = stream?.VideoRangeType; + + return rangeType is VideoRangeType.HDR10Plus + or VideoRangeType.DOVIWithHDR10Plus + or VideoRangeType.DOVIWithELHDR10Plus; + } + + /// + /// Check if dynamic HDR metadata should be removed during stream copy. + /// Please note this check assumes the range check has already been done + /// and trivial fallbacks like HDR10+ to HDR10, DOVIWithHDR10 to HDR10 is already checked. + /// + private static DynamicHdrMetadataRemovalPlan ShouldRemoveDynamicHdrMetadata(EncodingJobInfo state) + { + var videoStream = state.VideoStream; + if (videoStream.VideoRange is not VideoRange.HDR) + { + return DynamicHdrMetadataRemovalPlan.None; + } + + var requestedRangeTypes = state.GetRequestedRangeTypes(state.VideoStream.Codec); + if (requestedRangeTypes.Length == 0) + { + return DynamicHdrMetadataRemovalPlan.None; + } + + var requestHasHDR10 = requestedRangeTypes.Contains(VideoRangeType.HDR10.ToString(), StringComparison.OrdinalIgnoreCase); + var requestHasDOVI = requestedRangeTypes.Contains(VideoRangeType.DOVI.ToString(), StringComparison.OrdinalIgnoreCase); + var requestHasDOVIwithEL = requestedRangeTypes.Contains(VideoRangeType.DOVIWithEL.ToString(), StringComparison.OrdinalIgnoreCase); + var requestHasDOVIwithELHDR10plus = requestedRangeTypes.Contains(VideoRangeType.DOVIWithELHDR10Plus.ToString(), StringComparison.OrdinalIgnoreCase); + + var shouldRemoveHdr10Plus = false; + // Case 1: Client supports HDR10, does not support DOVI with EL but EL presets + var shouldRemoveDovi = (!requestHasDOVIwithEL && requestHasHDR10) && videoStream.VideoRangeType == VideoRangeType.DOVIWithEL; + + // Case 2: Client supports DOVI, does not support broken DOVI config + // Client does not report DOVI support should be allowed to copy bad data for remuxing as HDR10 players would not crash + shouldRemoveDovi = shouldRemoveDovi || (requestHasDOVI && videoStream.VideoRangeType == VideoRangeType.DOVIInvalid); + + // Special case: we have a video with both EL and HDR10+ + // If the client supports EL but not in the case of coexistence with HDR10+, remove HDR10+ for compatibility reasons. + // Otherwise, remove DOVI if the client is not a DOVI player + if (videoStream.VideoRangeType == VideoRangeType.DOVIWithELHDR10Plus) + { + shouldRemoveHdr10Plus = requestHasDOVIwithEL && !requestHasDOVIwithELHDR10plus; + shouldRemoveDovi = shouldRemoveDovi || !shouldRemoveHdr10Plus; + } + + if (shouldRemoveDovi) + { + return DynamicHdrMetadataRemovalPlan.RemoveDovi; + } + + // If the client is a Dolby Vision Player, remove the HDR10+ metadata to avoid playback issues + shouldRemoveHdr10Plus = shouldRemoveHdr10Plus || (requestHasDOVI && videoStream.VideoRangeType == VideoRangeType.DOVIWithHDR10Plus); + return shouldRemoveHdr10Plus ? DynamicHdrMetadataRemovalPlan.RemoveHdr10Plus : DynamicHdrMetadataRemovalPlan.None; + } + + private bool CanEncoderRemoveDynamicHdrMetadata(DynamicHdrMetadataRemovalPlan plan, MediaStream videoStream) + { + return plan switch + { + DynamicHdrMetadataRemovalPlan.RemoveDovi => _mediaEncoder.SupportsBitStreamFilterWithOption(BitStreamFilterOptionType.DoviRpuStrip) + || (IsH265(videoStream) && _mediaEncoder.SupportsBitStreamFilterWithOption(BitStreamFilterOptionType.HevcMetadataRemoveDovi)) + || (IsAv1(videoStream) && _mediaEncoder.SupportsBitStreamFilterWithOption(BitStreamFilterOptionType.Av1MetadataRemoveDovi)), + DynamicHdrMetadataRemovalPlan.RemoveHdr10Plus => (IsH265(videoStream) && _mediaEncoder.SupportsBitStreamFilterWithOption(BitStreamFilterOptionType.HevcMetadataRemoveHdr10Plus)) + || (IsAv1(videoStream) && _mediaEncoder.SupportsBitStreamFilterWithOption(BitStreamFilterOptionType.Av1MetadataRemoveHdr10Plus)), + _ => true, + }; + } + + public bool IsDoviRemoved(EncodingJobInfo state) + { + return state?.VideoStream is not null && ShouldRemoveDynamicHdrMetadata(state) == DynamicHdrMetadataRemovalPlan.RemoveDovi + && CanEncoderRemoveDynamicHdrMetadata(DynamicHdrMetadataRemovalPlan.RemoveDovi, state.VideoStream); + } + + public bool IsHdr10PlusRemoved(EncodingJobInfo state) + { + return state?.VideoStream is not null && ShouldRemoveDynamicHdrMetadata(state) == DynamicHdrMetadataRemovalPlan.RemoveHdr10Plus + && CanEncoderRemoveDynamicHdrMetadata(DynamicHdrMetadataRemovalPlan.RemoveHdr10Plus, state.VideoStream); + } + + public string GetBitStreamArgs(EncodingJobInfo state, MediaStreamType streamType) + { + if (state is null) + { + return null; + } + + var stream = streamType switch + { + MediaStreamType.Audio => state.AudioStream, + MediaStreamType.Video => state.VideoStream, + _ => state.VideoStream + }; // TODO This is auto inserted into the mpegts mux so it might not be needed. // https://www.ffmpeg.org/ffmpeg-bitstream-filters.html#h264_005fmp4toannexb if (IsH264(stream)) @@ -1316,21 +1465,57 @@ namespace MediaBrowser.Controller.MediaEncoding return "-bsf:v h264_mp4toannexb"; } - if (IsH265(stream)) - { - return "-bsf:v hevc_mp4toannexb"; - } - if (IsAAC(stream)) { // Convert adts header(mpegts) to asc header(mp4). return "-bsf:a aac_adtstoasc"; } + if (IsH265(stream)) + { + var filter = "-bsf:v hevc_mp4toannexb"; + + // The following checks are not complete because the copy would be rejected + // if the encoder cannot remove required metadata. + // And if bsf is used, we must already be using copy codec. + switch (ShouldRemoveDynamicHdrMetadata(state)) + { + default: + case DynamicHdrMetadataRemovalPlan.None: + break; + case DynamicHdrMetadataRemovalPlan.RemoveDovi: + filter += _mediaEncoder.SupportsBitStreamFilterWithOption(BitStreamFilterOptionType.HevcMetadataRemoveDovi) + ? ",hevc_metadata=remove_dovi=1" + : ",dovi_rpu=strip=1"; + break; + case DynamicHdrMetadataRemovalPlan.RemoveHdr10Plus: + filter += ",hevc_metadata=remove_hdr10plus=1"; + break; + } + + return filter; + } + + if (IsAv1(stream)) + { + switch (ShouldRemoveDynamicHdrMetadata(state)) + { + default: + case DynamicHdrMetadataRemovalPlan.None: + return null; + case DynamicHdrMetadataRemovalPlan.RemoveDovi: + return _mediaEncoder.SupportsBitStreamFilterWithOption(BitStreamFilterOptionType.Av1MetadataRemoveDovi) + ? "-bsf:v av1_metadata=remove_dovi=1" + : "-bsf:v dovi_rpu=strip=1"; + case DynamicHdrMetadataRemovalPlan.RemoveHdr10Plus: + return "-bsf:v av1_metadata=remove_hdr10plus=1"; + } + } + return null; } - public static string GetAudioBitStreamArguments(EncodingJobInfo state, string segmentContainer, string mediaSourceContainer) + public string GetAudioBitStreamArguments(EncodingJobInfo state, string segmentContainer, string mediaSourceContainer) { var bitStreamArgs = string.Empty; var segmentFormat = GetSegmentFileExtension(segmentContainer).TrimStart('.'); @@ -1341,7 +1526,7 @@ namespace MediaBrowser.Controller.MediaEncoding || string.Equals(mediaSourceContainer, "aac", StringComparison.OrdinalIgnoreCase) || string.Equals(mediaSourceContainer, "hls", StringComparison.OrdinalIgnoreCase))) { - bitStreamArgs = GetBitStreamArgs(state.AudioStream); + bitStreamArgs = GetBitStreamArgs(state, MediaStreamType.Audio); bitStreamArgs = string.IsNullOrEmpty(bitStreamArgs) ? string.Empty : " " + bitStreamArgs; } @@ -1620,7 +1805,7 @@ namespace MediaBrowser.Controller.MediaEncoding var alphaParam = enableAlpha ? ":alpha=1" : string.Empty; var sub2videoParam = enableSub2video ? ":sub2video=1" : string.Empty; - var fontPath = Path.Combine(_appPaths.CachePath, "attachments", state.MediaSource.Id); + var fontPath = _pathManager.GetAttachmentFolderPath(state.MediaSource.Id); var fontParam = string.Format( CultureInfo.InvariantCulture, ":fontsdir='{0}'", @@ -2061,7 +2246,13 @@ namespace MediaBrowser.Controller.MediaEncoding // libx265 only accept level option in -x265-params. // level option may cause libx265 to fail. // libx265 cannot adjust the given level, just throw an error. - param += " -x265-params:0 subme=3:merange=25:rc-lookahead=10:me=star:ctu=32:max-tu-size=32:min-cu-size=16:rskip=2:rskip-edge-threshold=2:no-sao=1:no-strong-intra-smoothing=1:no-scenecut=1:no-open-gop=1:no-info=1"; + param += " -x265-params:0 no-scenecut=1:no-open-gop=1:no-info=1"; + + if (encodingOptions.EncoderPreset < EncoderPreset.ultrafast) + { + // The following params are slower than the ultrafast preset, don't use when ultrafast is selected. + param += ":subme=3:merange=25:rc-lookahead=10:me=star:ctu=32:max-tu-size=32:min-cu-size=16:rskip=2:rskip-edge-threshold=2:no-sao=1:no-strong-intra-smoothing=1"; + } } if (string.Equals(videoEncoder, "libsvtav1", StringComparison.OrdinalIgnoreCase) @@ -2162,7 +2353,6 @@ namespace MediaBrowser.Controller.MediaEncoding } // DOVIWithHDR10 should be compatible with HDR10 supporting players. Same goes with HLG and of course SDR. So allow copy of those formats - var requestHasHDR10 = requestedRangeTypes.Contains(VideoRangeType.HDR10.ToString(), StringComparison.OrdinalIgnoreCase); var requestHasHLG = requestedRangeTypes.Contains(VideoRangeType.HLG.ToString(), StringComparison.OrdinalIgnoreCase); var requestHasSDR = requestedRangeTypes.Contains(VideoRangeType.SDR.ToString(), StringComparison.OrdinalIgnoreCase); @@ -2170,9 +2360,17 @@ namespace MediaBrowser.Controller.MediaEncoding if (!requestedRangeTypes.Contains(videoStream.VideoRangeType.ToString(), StringComparison.OrdinalIgnoreCase) && !((requestHasHDR10 && videoStream.VideoRangeType == VideoRangeType.DOVIWithHDR10) || (requestHasHLG && videoStream.VideoRangeType == VideoRangeType.DOVIWithHLG) - || (requestHasSDR && videoStream.VideoRangeType == VideoRangeType.DOVIWithSDR))) + || (requestHasSDR && videoStream.VideoRangeType == VideoRangeType.DOVIWithSDR) + || (requestHasHDR10 && videoStream.VideoRangeType == VideoRangeType.HDR10Plus))) { - return false; + // Check complicated cases where we need to remove dynamic metadata + // Conservatively refuse to copy if the encoder can't remove dynamic metadata, + // but a removal is required for compatability reasons. + var dynamicHdrMetadataRemovalPlan = ShouldRemoveDynamicHdrMetadata(state); + if (!CanEncoderRemoveDynamicHdrMetadata(dynamicHdrMetadataRemovalPlan, videoStream)) + { + return false; + } } } @@ -2196,7 +2394,10 @@ namespace MediaBrowser.Controller.MediaEncoding { var videoFrameRate = videoStream.ReferenceFrameRate; - if (!videoFrameRate.HasValue || videoFrameRate.Value > requestedFramerate.Value) + // Add a little tolerance to the framerate check because some videos might record a framerate + // that is slightly greater than the intended framerate, but the device can still play it correctly. + // 0.05 fps tolerance should be safe enough. + if (!videoFrameRate.HasValue || videoFrameRate.Value > requestedFramerate.Value + 0.05f) { return false; } @@ -2681,10 +2882,10 @@ namespace MediaBrowser.Controller.MediaEncoding var seekTick = isHlsRemuxing ? time + 5000000L : time; // Seeking beyond EOF makes no sense in transcoding. Clamp the seekTick value to - // [0, RuntimeTicks - 0.5s], so that the muxer gets packets and avoid error codes. + // [0, RuntimeTicks - 5.0s], so that the muxer gets packets and avoid error codes. if (maxTime > 0) { - seekTick = Math.Clamp(seekTick, 0, Math.Max(maxTime - 5000000L, 0)); + seekTick = Math.Clamp(seekTick, 0, Math.Max(maxTime - 50000000L, 0)); } seekParam += string.Format(CultureInfo.InvariantCulture, "-ss {0}", _mediaEncoder.GetTimeParameter(seekTick)); @@ -3318,24 +3519,25 @@ namespace MediaBrowser.Controller.MediaEncoding && options.VppTonemappingBrightness >= -100 && options.VppTonemappingBrightness <= 100) { - procampParams += $"=b={options.VppTonemappingBrightness}"; + procampParams += "procamp_vaapi=b={0}"; doVaVppProcamp = true; } if (options.VppTonemappingContrast > 1 && options.VppTonemappingContrast <= 10) { - procampParams += doVaVppProcamp ? ":" : "="; - procampParams += $"c={options.VppTonemappingContrast}"; + procampParams += doVaVppProcamp ? ":c={1}" : "procamp_vaapi=c={1}"; doVaVppProcamp = true; } - args = "{0}tonemap_vaapi=format={1}:p=bt709:t=bt709:m=bt709:extra_hw_frames=32"; + args = procampParams + "{2}tonemap_vaapi=format={3}:p=bt709:t=bt709:m=bt709:extra_hw_frames=32"; return string.Format( CultureInfo.InvariantCulture, args, - doVaVppProcamp ? $"procamp_vaapi{procampParams}," : string.Empty, + options.VppTonemappingBrightness, + options.VppTonemappingContrast, + doVaVppProcamp ? "," : string.Empty, videoFormat ?? "nv12"); } else @@ -3523,20 +3725,29 @@ namespace MediaBrowser.Controller.MediaEncoding { // tonemapx requires yuv420p10 input for dovi reshaping, let ffmpeg convert the frame when necessary var tonemapFormat = requireDoviReshaping ? "yuv420p" : outFormat; - - var tonemapArgs = $"tonemapx=tonemap={options.TonemappingAlgorithm}:desat={options.TonemappingDesat}:peak={options.TonemappingPeak}:t=bt709:m=bt709:p=bt709:format={tonemapFormat}"; + var tonemapArgString = "tonemapx=tonemap={0}:desat={1}:peak={2}:t=bt709:m=bt709:p=bt709:format={3}"; if (options.TonemappingParam != 0) { - tonemapArgs += $":param={options.TonemappingParam}"; + tonemapArgString += ":param={4}"; } var range = options.TonemappingRange; if (range == TonemappingRange.tv || range == TonemappingRange.pc) { - tonemapArgs += $":range={options.TonemappingRange}"; + tonemapArgString += ":range={5}"; } + var tonemapArgs = string.Format( + CultureInfo.InvariantCulture, + tonemapArgString, + options.TonemappingAlgorithm, + options.TonemappingDesat, + options.TonemappingPeak, + tonemapFormat, + options.TonemappingParam, + options.TonemappingRange); + mainFilters.Add(tonemapArgs); } else @@ -3595,7 +3806,7 @@ namespace MediaBrowser.Controller.MediaEncoding return GetSwVidFilterChain(state, options, vidEncoder); } - // prefered nvdec/cuvid + cuda filters + nvenc pipeline + // preferred nvdec/cuvid + cuda filters + nvenc pipeline return GetNvidiaVidFiltersPrefered(state, options, vidDecoder, vidEncoder); } @@ -3636,8 +3847,8 @@ namespace MediaBrowser.Controller.MediaEncoding var subH = state.SubtitleStream?.Height; var rotation = state.VideoStream?.Rotation ?? 0; - var tranposeDir = rotation == 0 ? string.Empty : GetVideoTransposeDirection(state); - var doCuTranspose = !string.IsNullOrEmpty(tranposeDir) && _mediaEncoder.SupportsFilter("transpose_cuda"); + var transposeDir = rotation == 0 ? string.Empty : GetVideoTransposeDirection(state); + var doCuTranspose = !string.IsNullOrEmpty(transposeDir) && _mediaEncoder.SupportsFilter("transpose_cuda"); var swapWAndH = Math.Abs(rotation) == 90 && (isSwDecoder || (isNvDecoder && doCuTranspose)); var swpInW = swapWAndH ? inH : inW; var swpInH = swapWAndH ? inW : inH; @@ -3683,7 +3894,7 @@ namespace MediaBrowser.Controller.MediaEncoding // hw transpose if (doCuTranspose) { - mainFilters.Add($"transpose_cuda=dir={tranposeDir}"); + mainFilters.Add($"transpose_cuda=dir={transposeDir}"); } var isRext = IsVideoStreamHevcRext(state); @@ -3803,7 +4014,7 @@ namespace MediaBrowser.Controller.MediaEncoding return GetSwVidFilterChain(state, options, vidEncoder); } - // prefered d3d11va + opencl filters + amf pipeline + // preferred d3d11va + opencl filters + amf pipeline return GetAmdDx11VidFiltersPrefered(state, options, vidDecoder, vidEncoder); } @@ -3843,8 +4054,8 @@ namespace MediaBrowser.Controller.MediaEncoding var subH = state.SubtitleStream?.Height; var rotation = state.VideoStream?.Rotation ?? 0; - var tranposeDir = rotation == 0 ? string.Empty : GetVideoTransposeDirection(state); - var doOclTranspose = !string.IsNullOrEmpty(tranposeDir) + var transposeDir = rotation == 0 ? string.Empty : GetVideoTransposeDirection(state); + var doOclTranspose = !string.IsNullOrEmpty(transposeDir) && _mediaEncoder.SupportsFilterWithOption(FilterOptionType.TransposeOpenclReversal); var swapWAndH = Math.Abs(rotation) == 90 && (isSwDecoder || (isD3d11vaDecoder && doOclTranspose)); var swpInW = swapWAndH ? inH : inW; @@ -3888,12 +4099,12 @@ namespace MediaBrowser.Controller.MediaEncoding // map from d3d11va to opencl via d3d11-opencl interop. mainFilters.Add("hwmap=derive_device=opencl:mode=read"); - // hw deint <= TODO: finsh the 'yadif_opencl' filter + // hw deint <= TODO: finish the 'yadif_opencl' filter // hw transpose if (doOclTranspose) { - mainFilters.Add($"transpose_opencl=dir={tranposeDir}"); + mainFilters.Add($"transpose_opencl=dir={transposeDir}"); } var outFormat = doOclTonemap ? string.Empty : "nv12"; @@ -4029,13 +4240,13 @@ namespace MediaBrowser.Controller.MediaEncoding return GetSwVidFilterChain(state, options, vidEncoder); } - // prefered qsv(vaapi) + opencl filters pipeline + // preferred qsv(vaapi) + opencl filters pipeline if (isIntelVaapiOclSupported) { return GetIntelQsvVaapiVidFiltersPrefered(state, options, vidDecoder, vidEncoder); } - // prefered qsv(d3d11) + opencl filters pipeline + // preferred qsv(d3d11) + opencl filters pipeline if (isIntelDx11OclSupported) { return GetIntelQsvDx11VidFiltersPrefered(state, options, vidDecoder, vidEncoder); @@ -4084,8 +4295,8 @@ namespace MediaBrowser.Controller.MediaEncoding var subH = state.SubtitleStream?.Height; var rotation = state.VideoStream?.Rotation ?? 0; - var tranposeDir = rotation == 0 ? string.Empty : GetVideoTransposeDirection(state); - var doVppTranspose = !string.IsNullOrEmpty(tranposeDir); + var transposeDir = rotation == 0 ? string.Empty : GetVideoTransposeDirection(state); + var doVppTranspose = !string.IsNullOrEmpty(transposeDir); var swapWAndH = Math.Abs(rotation) == 90 && (isSwDecoder || ((isD3d11vaDecoder || isQsvDecoder) && doVppTranspose)); var swpInW = swapWAndH ? inH : inW; var swpInH = swapWAndH ? inW : inH; @@ -4128,31 +4339,46 @@ namespace MediaBrowser.Controller.MediaEncoding else if (isD3d11vaDecoder || isQsvDecoder) { var isRext = IsVideoStreamHevcRext(state); - var twoPassVppTonemap = isRext; + var twoPassVppTonemap = false; var doVppFullRangeOut = isMjpegEncoder && _mediaEncoder.EncoderVersion >= _minFFmpegQsvVppOutRangeOption; var doVppScaleModeHq = isMjpegEncoder && _mediaEncoder.EncoderVersion >= _minFFmpegQsvVppScaleModeOption; var doVppProcamp = false; var procampParams = string.Empty; + var procampParamsString = string.Empty; if (doVppTonemap) { + if (isRext) + { + // VPP tonemap requires p010 input + twoPassVppTonemap = true; + } + if (options.VppTonemappingBrightness != 0 && options.VppTonemappingBrightness >= -100 && options.VppTonemappingBrightness <= 100) { - procampParams += $":brightness={options.VppTonemappingBrightness}"; + procampParamsString += ":brightness={0}"; twoPassVppTonemap = doVppProcamp = true; } if (options.VppTonemappingContrast > 1 && options.VppTonemappingContrast <= 10) { - procampParams += $":contrast={options.VppTonemappingContrast}"; + procampParamsString += ":contrast={1}"; twoPassVppTonemap = doVppProcamp = true; } - procampParams += doVppProcamp ? ":procamp=1:async_depth=2" : string.Empty; + if (doVppProcamp) + { + procampParamsString += ":procamp=1:async_depth=2"; + procampParams = string.Format( + CultureInfo.InvariantCulture, + procampParamsString, + options.VppTonemappingBrightness, + options.VppTonemappingContrast); + } } var outFormat = doOclTonemap ? ((doVppTranspose || isRext) ? "p010" : string.Empty) : "nv12"; @@ -4163,7 +4389,7 @@ namespace MediaBrowser.Controller.MediaEncoding if (!string.IsNullOrEmpty(hwScaleFilter) && doVppTranspose) { - hwScaleFilter += $":transpose={tranposeDir}"; + hwScaleFilter += $":transpose={transposeDir}"; } if (!string.IsNullOrEmpty(hwScaleFilter) && isMjpegEncoder) @@ -4356,8 +4582,8 @@ namespace MediaBrowser.Controller.MediaEncoding var subH = state.SubtitleStream?.Height; var rotation = state.VideoStream?.Rotation ?? 0; - var tranposeDir = rotation == 0 ? string.Empty : GetVideoTransposeDirection(state); - var doVppTranspose = !string.IsNullOrEmpty(tranposeDir); + var transposeDir = rotation == 0 ? string.Empty : GetVideoTransposeDirection(state); + var doVppTranspose = !string.IsNullOrEmpty(transposeDir); var swapWAndH = Math.Abs(rotation) == 90 && (isSwDecoder || ((isVaapiDecoder || isQsvDecoder) && doVppTranspose)); var swpInW = swapWAndH ? inH : inW; var swpInH = swapWAndH ? inW : inH; @@ -4417,7 +4643,7 @@ namespace MediaBrowser.Controller.MediaEncoding // hw transpose(vaapi vpp) if (isVaapiDecoder && doVppTranspose) { - mainFilters.Add($"transpose_vaapi=dir={tranposeDir}"); + mainFilters.Add($"transpose_vaapi=dir={transposeDir}"); } var outFormat = doTonemap ? (((isQsvDecoder && doVppTranspose) || isRext) ? "p010" : string.Empty) : "nv12"; @@ -4427,7 +4653,7 @@ namespace MediaBrowser.Controller.MediaEncoding if (!string.IsNullOrEmpty(hwScaleFilter) && isQsvDecoder && doVppTranspose) { - hwScaleFilter += $":transpose={tranposeDir}"; + hwScaleFilter += $":transpose={transposeDir}"; } if (!string.IsNullOrEmpty(hwScaleFilter) && isMjpegEncoder) @@ -4628,14 +4854,14 @@ namespace MediaBrowser.Controller.MediaEncoding return swFilterChain; } - // prefered vaapi + opencl filters pipeline + // preferred vaapi + opencl filters pipeline if (_mediaEncoder.IsVaapiDeviceInteliHD) { // Intel iHD path, with extra vpp tonemap and overlay support. return GetIntelVaapiFullVidFiltersPrefered(state, options, vidDecoder, vidEncoder); } - // prefered vaapi + vulkan filters pipeline + // preferred vaapi + vulkan filters pipeline if (_mediaEncoder.IsVaapiDeviceAmd && isVaapiVkSupported && _mediaEncoder.IsVaapiDeviceSupportVulkanDrmInterop @@ -4687,8 +4913,8 @@ namespace MediaBrowser.Controller.MediaEncoding var subH = state.SubtitleStream?.Height; var rotation = state.VideoStream?.Rotation ?? 0; - var tranposeDir = rotation == 0 ? string.Empty : GetVideoTransposeDirection(state); - var doVaVppTranspose = !string.IsNullOrEmpty(tranposeDir); + var transposeDir = rotation == 0 ? string.Empty : GetVideoTransposeDirection(state); + var doVaVppTranspose = !string.IsNullOrEmpty(transposeDir); var swapWAndH = Math.Abs(rotation) == 90 && (isSwDecoder || (isVaapiDecoder && doVaVppTranspose)); var swpInW = swapWAndH ? inH : inW; var swpInH = swapWAndH ? inW : inH; @@ -4743,7 +4969,7 @@ namespace MediaBrowser.Controller.MediaEncoding // hw transpose if (doVaVppTranspose) { - mainFilters.Add($"transpose_vaapi=dir={tranposeDir}"); + mainFilters.Add($"transpose_vaapi=dir={transposeDir}"); } var outFormat = doTonemap ? (isRext ? "p010" : string.Empty) : "nv12"; @@ -4920,8 +5146,8 @@ namespace MediaBrowser.Controller.MediaEncoding || string.Equals(state.SubtitleStream.Codec, "ssa", StringComparison.OrdinalIgnoreCase)); var rotation = state.VideoStream?.Rotation ?? 0; - var tranposeDir = rotation == 0 ? string.Empty : GetVideoTransposeDirection(state); - var doVkTranspose = isVaapiDecoder && !string.IsNullOrEmpty(tranposeDir); + var transposeDir = rotation == 0 ? string.Empty : GetVideoTransposeDirection(state); + var doVkTranspose = isVaapiDecoder && !string.IsNullOrEmpty(transposeDir); var swapWAndH = Math.Abs(rotation) == 90 && (isSwDecoder || (isVaapiDecoder && doVkTranspose)); var swpInW = swapWAndH ? inH : inW; var swpInH = swapWAndH ? inW : inH; @@ -5014,13 +5240,13 @@ namespace MediaBrowser.Controller.MediaEncoding // vk transpose if (doVkTranspose) { - if (string.Equals(tranposeDir, "reversal", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(transposeDir, "reversal", StringComparison.OrdinalIgnoreCase)) { mainFilters.Add("flip_vulkan"); } else { - mainFilters.Add($"transpose_vulkan=dir={tranposeDir}"); + mainFilters.Add($"transpose_vulkan=dir={transposeDir}"); } } @@ -5388,8 +5614,8 @@ namespace MediaBrowser.Controller.MediaEncoding var usingHwSurface = isVtDecoder && (_mediaEncoder.EncoderVersion >= _minFFmpegWorkingVtHwSurface); var rotation = state.VideoStream?.Rotation ?? 0; - var tranposeDir = rotation == 0 ? string.Empty : GetVideoTransposeDirection(state); - var doVtTranspose = !string.IsNullOrEmpty(tranposeDir) && _mediaEncoder.SupportsFilter("transpose_vt"); + var transposeDir = rotation == 0 ? string.Empty : GetVideoTransposeDirection(state); + var doVtTranspose = !string.IsNullOrEmpty(transposeDir) && _mediaEncoder.SupportsFilter("transpose_vt"); var swapWAndH = Math.Abs(rotation) == 90 && doVtTranspose; var swpInW = swapWAndH ? inH : inW; var swpInH = swapWAndH ? inW : inH; @@ -5433,7 +5659,7 @@ namespace MediaBrowser.Controller.MediaEncoding // hw transpose if (doVtTranspose) { - mainFilters.Add($"transpose_vt=dir={tranposeDir}"); + mainFilters.Add($"transpose_vt=dir={transposeDir}"); } if (doVtTonemap) @@ -5548,7 +5774,7 @@ namespace MediaBrowser.Controller.MediaEncoding return GetSwVidFilterChain(state, options, vidEncoder); } - // prefered rkmpp + rkrga + opencl filters pipeline + // preferred rkmpp + rkrga + opencl filters pipeline if (isRkmppOclSupported) { return GetRkmppVidFiltersPrefered(state, options, vidDecoder, vidEncoder); @@ -5586,7 +5812,7 @@ namespace MediaBrowser.Controller.MediaEncoding var doDeintH2645 = doDeintH264 || doDeintHevc; var doOclTonemap = IsHwTonemapAvailable(state, options); - var hasSubs = state.SubtitleStream != null && ShouldEncodeSubtitle(state); + var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state); var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream; var hasGraphicalSubs = hasSubs && !state.SubtitleStream.IsTextSubtitleStream; var hasAssSubs = hasSubs @@ -5596,8 +5822,8 @@ namespace MediaBrowser.Controller.MediaEncoding var subH = state.SubtitleStream?.Height; var rotation = state.VideoStream?.Rotation ?? 0; - var tranposeDir = rotation == 0 ? string.Empty : GetVideoTransposeDirection(state); - var doRkVppTranspose = !string.IsNullOrEmpty(tranposeDir); + var transposeDir = rotation == 0 ? string.Empty : GetVideoTransposeDirection(state); + var doRkVppTranspose = !string.IsNullOrEmpty(transposeDir); var swapWAndH = Math.Abs(rotation) == 90 && (isSwDecoder || (isRkmppDecoder && doRkVppTranspose)); var swpInW = swapWAndH ? inH : inW; var swpInH = swapWAndH ? inW : inH; @@ -5662,13 +5888,17 @@ namespace MediaBrowser.Controller.MediaEncoding if (!string.IsNullOrEmpty(doScaling) && !IsScaleRatioSupported(inW, inH, reqW, reqH, reqMaxW, reqMaxH, 8.0f)) { - var hwScaleFilterFirstPass = $"scale_rkrga=w=iw/7.9:h=ih/7.9:format={outFormat}:afbc=1"; + // Vendor provided BSP kernel has an RGA driver bug that causes the output to be corrupted for P010 format. + // Use NV15 instead of P010 to avoid the issue. + // SDR inputs are using BGRA formats already which is not affected. + var intermediateFormat = string.Equals(outFormat, "p010", StringComparison.OrdinalIgnoreCase) ? "nv15" : outFormat; + var hwScaleFilterFirstPass = $"scale_rkrga=w=iw/7.9:h=ih/7.9:format={intermediateFormat}:force_divisible_by=4:afbc=1"; mainFilters.Add(hwScaleFilterFirstPass); } if (!string.IsNullOrEmpty(hwScaleFilter) && doRkVppTranspose) { - hwScaleFilter += $":transpose={tranposeDir}"; + hwScaleFilter += $":transpose={transposeDir}"; } // try enabling AFBC to save DDR bandwidth @@ -6142,7 +6372,7 @@ namespace MediaBrowser.Controller.MediaEncoding var ffmpegVersion = _mediaEncoder.EncoderVersion; // Set the av1 codec explicitly to trigger hw accelerator, otherwise libdav1d will be used. - var isAv1 = ffmpegVersion < _minFFmpegImplictHwaccel + var isAv1 = ffmpegVersion < _minFFmpegImplicitHwaccel && string.Equals(videoCodec, "av1", StringComparison.OrdinalIgnoreCase); // Allow profile mismatch if decoding H.264 baseline with d3d11va and vaapi hwaccels. @@ -6582,6 +6812,7 @@ namespace MediaBrowser.Controller.MediaEncoding || string.Equals("yuv420p12le", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase) || string.Equals("yuv422p12le", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase) || string.Equals("yuv444p12le", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase); + var isAv1SupportedSwFormatsVt = is8_10bitSwFormatsVt || string.Equals("yuv420p12le", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase); // The related patches make videotoolbox hardware surface working is only available in jellyfin-ffmpeg 7.0.1 at the moment. bool useHwSurface = (_mediaEncoder.EncoderVersion >= _minFFmpegWorkingVtHwSurface) && IsVideoToolboxFullSupported(); @@ -6615,6 +6846,13 @@ namespace MediaBrowser.Controller.MediaEncoding { return GetHwaccelType(state, options, "hevc", bitDepth, useHwSurface); } + + if (string.Equals("av1", videoStream.Codec, StringComparison.OrdinalIgnoreCase) + && isAv1SupportedSwFormatsVt + && _mediaEncoder.IsVideoToolboxAv1DecodeAvailable) + { + return GetHwaccelType(state, options, "av1", bitDepth, useHwSurface); + } } return null; @@ -6943,7 +7181,7 @@ namespace MediaBrowser.Controller.MediaEncoding state.RemoteHttpHeaders = mediaSource.RequiredHttpHeaders; state.ReadInputAtNativeFramerate = mediaSource.ReadAtNativeFramerate; - if (state.ReadInputAtNativeFramerate + if ((state.ReadInputAtNativeFramerate && !state.IsSegmentedLiveStream) || (mediaSource.Protocol == MediaProtocol.File && string.Equals(mediaSource.Container, "wtv", StringComparison.OrdinalIgnoreCase))) { @@ -7036,7 +7274,7 @@ namespace MediaBrowser.Controller.MediaEncoding { // DTS and TrueHD are not supported by HLS // Keep them in the supported codecs list, but shift them to the end of the list so that if transcoding happens, another codec is used - shiftAudioCodecs.Add("dca"); + shiftAudioCodecs.Add("dts"); shiftAudioCodecs.Add("truehd"); } else @@ -7197,7 +7435,7 @@ namespace MediaBrowser.Controller.MediaEncoding && string.Equals(state.OutputContainer, "ts", StringComparison.OrdinalIgnoreCase) && !string.Equals(state.VideoStream.NalLengthSize, "0", StringComparison.OrdinalIgnoreCase)) { - string bitStreamArgs = GetBitStreamArgs(state.VideoStream); + string bitStreamArgs = GetBitStreamArgs(state, MediaStreamType.Video); if (!string.IsNullOrEmpty(bitStreamArgs)) { args += " " + bitStreamArgs; diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs index caa312987d..8d6211051b 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs @@ -4,10 +4,11 @@ using System; using System.Collections.Generic; +using System.ComponentModel; using System.Globalization; using System.Linq; -using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Drawing; using MediaBrowser.Model.Dto; diff --git a/MediaBrowser.Controller/MediaEncoding/IAttachmentExtractor.cs b/MediaBrowser.Controller/MediaEncoding/IAttachmentExtractor.cs index 09840d2eea..d8d1364727 100644 --- a/MediaBrowser.Controller/MediaEncoding/IAttachmentExtractor.cs +++ b/MediaBrowser.Controller/MediaEncoding/IAttachmentExtractor.cs @@ -9,26 +9,33 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; -namespace MediaBrowser.Controller.MediaEncoding +namespace MediaBrowser.Controller.MediaEncoding; + +public interface IAttachmentExtractor { - public interface IAttachmentExtractor - { - Task<(MediaAttachment Attachment, Stream Stream)> GetAttachment( - BaseItem item, - string mediaSourceId, - int attachmentStreamIndex, - CancellationToken cancellationToken); + /// + /// Gets the path to the attachment file. + /// + /// The . + /// The media source id. + /// The attachment index. + /// The cancellation token. + /// The async task. + Task<(MediaAttachment Attachment, Stream Stream)> GetAttachment( + BaseItem item, + string mediaSourceId, + int attachmentStreamIndex, + CancellationToken cancellationToken); - Task ExtractAllAttachments( - string inputFile, - MediaSourceInfo mediaSource, - string outputPath, - CancellationToken cancellationToken); - - Task ExtractAllAttachmentsExternal( - string inputArgument, - string id, - string outputPath, - CancellationToken cancellationToken); - } + /// + /// Gets the path to the attachment file. + /// + /// The input file path. + /// The source id. + /// The cancellation token. + /// The async task. + Task ExtractAllAttachments( + string inputFile, + MediaSourceInfo mediaSource, + CancellationToken cancellationToken); } diff --git a/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs b/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs index c767b4a519..de6353c4c1 100644 --- a/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs +++ b/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs @@ -75,6 +75,12 @@ namespace MediaBrowser.Controller.MediaEncoding /// true if the Vaapi device supports vulkan drm interop, false otherwise. bool IsVaapiDeviceSupportVulkanDrmInterop { get; } + /// + /// Gets a value indicating whether av1 decoding is available via VideoToolbox. + /// + /// true if the av1 is available via VideoToolbox, false otherwise. + bool IsVideoToolboxAv1DecodeAvailable { get; } + /// /// Whether given encoder codec is supported. /// @@ -110,6 +116,13 @@ namespace MediaBrowser.Controller.MediaEncoding /// true if the filter is supported, false otherwise. bool SupportsFilterWithOption(FilterOptionType option); + /// + /// Whether the bitstream filter is supported with the given option. + /// + /// The option. + /// true if the bitstream filter is supported, false otherwise. + bool SupportsBitStreamFilterWithOption(BitStreamFilterOptionType option); + /// /// Extracts the audio image. /// diff --git a/MediaBrowser.Controller/MediaEncoding/TranscodingJob.cs b/MediaBrowser.Controller/MediaEncoding/TranscodingJob.cs index fefa66cdb8..56990d0b82 100644 --- a/MediaBrowser.Controller/MediaEncoding/TranscodingJob.cs +++ b/MediaBrowser.Controller/MediaEncoding/TranscodingJob.cs @@ -12,8 +12,8 @@ namespace MediaBrowser.Controller.MediaEncoding; public sealed class TranscodingJob : IDisposable { private readonly ILogger _logger; - private readonly object _processLock = new(); - private readonly object _timerLock = new(); + private readonly Lock _processLock = new(); + private readonly Lock _timerLock = new(); private Timer? _killTimer; diff --git a/MediaBrowser.Controller/MediaSegements/IMediaSegmentManager.cs b/MediaBrowser.Controller/MediaSegments/IMediaSegmentManager.cs similarity index 71% rename from MediaBrowser.Controller/MediaSegements/IMediaSegmentManager.cs rename to MediaBrowser.Controller/MediaSegments/IMediaSegmentManager.cs index 010d7edb4f..456977b88e 100644 --- a/MediaBrowser.Controller/MediaSegements/IMediaSegmentManager.cs +++ b/MediaBrowser.Controller/MediaSegments/IMediaSegmentManager.cs @@ -2,8 +2,8 @@ using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Data.Entities; -using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using MediaBrowser.Controller.Entities; using MediaBrowser.Model.MediaSegments; @@ -46,12 +46,22 @@ public interface IMediaSegmentManager Task DeleteSegmentAsync(Guid segmentId); /// - /// Obtains all segments accociated with the itemId. + /// Obtains all segments associated with the itemId. /// /// The id of the . - /// filteres all media segments of the given type to be included. If null all types are included. + /// filters all media segments of the given type to be included. If null all types are included. + /// When set filters the segments to only return those that which providers are currently enabled on their library. /// An enumerator of 's. - Task> GetSegmentsAsync(Guid itemId, IEnumerable? typeFilter); + Task> GetSegmentsAsync(Guid itemId, IEnumerable? typeFilter, bool filterByProvider = true); + + /// + /// Obtains all segments associated with the itemId. + /// + /// The . + /// filters all media segments of the given type to be included. If null all types are included. + /// When set filters the segments to only return those that which providers are currently enabled on their library. + /// An enumerator of 's. + Task> GetSegmentsAsync(BaseItem item, IEnumerable? typeFilter, bool filterByProvider = true); /// /// Gets information about any media segments stored for the given itemId. diff --git a/MediaBrowser.Controller/MediaSegements/IMediaSegmentProvider.cs b/MediaBrowser.Controller/MediaSegments/IMediaSegmentProvider.cs similarity index 100% rename from MediaBrowser.Controller/MediaSegements/IMediaSegmentProvider.cs rename to MediaBrowser.Controller/MediaSegments/IMediaSegmentProvider.cs diff --git a/MediaBrowser.Controller/Net/AuthorizationInfo.cs b/MediaBrowser.Controller/Net/AuthorizationInfo.cs index 2452b25ab1..dd5eb9a01d 100644 --- a/MediaBrowser.Controller/Net/AuthorizationInfo.cs +++ b/MediaBrowser.Controller/Net/AuthorizationInfo.cs @@ -1,7 +1,6 @@ -#nullable disable - using System; -using Jellyfin.Data.Entities; +using System.Diagnostics.CodeAnalysis; +using Jellyfin.Database.Implementations.Entities; namespace MediaBrowser.Controller.Net { @@ -20,31 +19,31 @@ namespace MediaBrowser.Controller.Net /// Gets or sets the device identifier. /// /// The device identifier. - public string DeviceId { get; set; } + public string? DeviceId { get; set; } /// /// Gets or sets the device. /// /// The device. - public string Device { get; set; } + public string? Device { get; set; } /// /// Gets or sets the client. /// /// The client. - public string Client { get; set; } + public string? Client { get; set; } /// /// Gets or sets the version. /// /// The version. - public string Version { get; set; } + public string? Version { get; set; } /// /// Gets or sets the token. /// /// The token. - public string Token { get; set; } + public string? Token { get; set; } /// /// Gets or sets a value indicating whether the authorization is from an api key. @@ -54,7 +53,7 @@ namespace MediaBrowser.Controller.Net /// /// Gets or sets the user making the request. /// - public User User { get; set; } + public User? User { get; set; } /// /// Gets or sets a value indicating whether the token is authenticated. @@ -62,8 +61,9 @@ namespace MediaBrowser.Controller.Net public bool IsAuthenticated { get; set; } /// - /// Gets or sets a value indicating whether the request has a token. + /// Gets a value indicating whether the request has a token. /// - public bool HasToken { get; set; } + [MemberNotNullWhen(true, nameof(Token))] + public bool HasToken => !string.IsNullOrWhiteSpace(Token); } } diff --git a/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs b/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs index a47d2fa45d..4757bfa303 100644 --- a/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs +++ b/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs @@ -33,7 +33,7 @@ namespace MediaBrowser.Controller.Net SingleWriter = false }); - private readonly object _activeConnectionsLock = new(); + private readonly Lock _activeConnectionsLock = new(); /// /// The _active connections. diff --git a/MediaBrowser.Controller/Net/IWebSocketConnection.cs b/MediaBrowser.Controller/Net/IWebSocketConnection.cs index bba5a6b851..bdc0f9a10f 100644 --- a/MediaBrowser.Controller/Net/IWebSocketConnection.cs +++ b/MediaBrowser.Controller/Net/IWebSocketConnection.cs @@ -24,9 +24,9 @@ namespace MediaBrowser.Controller.Net DateTime LastActivityDate { get; } /// - /// Gets or sets the date of last Keeplive received. + /// Gets or sets the date of last Keepalive received. /// - /// The date of last Keeplive received. + /// The date of last Keepalive received. DateTime LastKeepAliveDate { get; set; } /// diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandMessage.cs deleted file mode 100644 index 6a501aa7ea..0000000000 --- a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandMessage.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.ComponentModel; -using MediaBrowser.Model.Session; -using MediaBrowser.Model.SyncPlay; - -namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; - -/// -/// Untyped sync play command. -/// -public class SyncPlayGroupUpdateCommandMessage : OutboundWebSocketMessage -{ - /// - /// Initializes a new instance of the class. - /// - /// The send command. - public SyncPlayGroupUpdateCommandMessage(GroupUpdate data) - : base(data) - { - } - - /// - [DefaultValue(SessionMessageType.SyncPlayGroupUpdate)] - public override SessionMessageType MessageType => SessionMessageType.SyncPlayGroupUpdate; -} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfGroupInfoMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfGroupInfoMessage.cs deleted file mode 100644 index 47f706e2a4..0000000000 --- a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfGroupInfoMessage.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.ComponentModel; -using MediaBrowser.Model.Session; -using MediaBrowser.Model.SyncPlay; - -namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; - -/// -/// Sync play group update command with group info. -/// GroupUpdateTypes: GroupJoined. -/// -public class SyncPlayGroupUpdateCommandOfGroupInfoMessage : OutboundWebSocketMessage> -{ - /// - /// Initializes a new instance of the class. - /// - /// The group info. - public SyncPlayGroupUpdateCommandOfGroupInfoMessage(GroupUpdate data) - : base(data) - { - } - - /// - [DefaultValue(SessionMessageType.SyncPlayGroupUpdate)] - public override SessionMessageType MessageType => SessionMessageType.SyncPlayGroupUpdate; -} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfGroupStateUpdateMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfGroupStateUpdateMessage.cs deleted file mode 100644 index 11ddb1e250..0000000000 --- a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfGroupStateUpdateMessage.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.ComponentModel; -using MediaBrowser.Model.Session; -using MediaBrowser.Model.SyncPlay; - -namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; - -/// -/// Sync play group update command with group state update. -/// GroupUpdateTypes: StateUpdate. -/// -public class SyncPlayGroupUpdateCommandOfGroupStateUpdateMessage : OutboundWebSocketMessage> -{ - /// - /// Initializes a new instance of the class. - /// - /// The group info. - public SyncPlayGroupUpdateCommandOfGroupStateUpdateMessage(GroupUpdate data) - : base(data) - { - } - - /// - [DefaultValue(SessionMessageType.SyncPlayGroupUpdate)] - public override SessionMessageType MessageType => SessionMessageType.SyncPlayGroupUpdate; -} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfPlayQueueUpdateMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfPlayQueueUpdateMessage.cs deleted file mode 100644 index 7e73399b1b..0000000000 --- a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfPlayQueueUpdateMessage.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.ComponentModel; -using MediaBrowser.Model.Session; -using MediaBrowser.Model.SyncPlay; - -namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; - -/// -/// Sync play group update command with play queue update. -/// GroupUpdateTypes: PlayQueue. -/// -public class SyncPlayGroupUpdateCommandOfPlayQueueUpdateMessage : OutboundWebSocketMessage> -{ - /// - /// Initializes a new instance of the class. - /// - /// The play queue update. - public SyncPlayGroupUpdateCommandOfPlayQueueUpdateMessage(GroupUpdate data) - : base(data) - { - } - - /// - [DefaultValue(SessionMessageType.SyncPlayGroupUpdate)] - public override SessionMessageType MessageType => SessionMessageType.SyncPlayGroupUpdate; -} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfStringMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfStringMessage.cs deleted file mode 100644 index 5b5ccd3eda..0000000000 --- a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfStringMessage.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.ComponentModel; -using MediaBrowser.Model.Session; -using MediaBrowser.Model.SyncPlay; - -namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; - -/// -/// Sync play group update command with string. -/// GroupUpdateTypes: GroupDoesNotExist (error), LibraryAccessDenied (error), NotInGroup (error), GroupLeft (groupId), UserJoined (username), UserLeft (username). -/// -public class SyncPlayGroupUpdateCommandOfStringMessage : OutboundWebSocketMessage> -{ - /// - /// Initializes a new instance of the class. - /// - /// The send command. - public SyncPlayGroupUpdateCommandOfStringMessage(GroupUpdate data) - : base(data) - { - } - - /// - [DefaultValue(SessionMessageType.SyncPlayGroupUpdate)] - public override SessionMessageType MessageType => SessionMessageType.SyncPlayGroupUpdate; -} diff --git a/MediaBrowser.Controller/Persistence/IItemRepository.cs b/MediaBrowser.Controller/Persistence/IItemRepository.cs index 2c52b2b45e..e185898bfb 100644 --- a/MediaBrowser.Controller/Persistence/IItemRepository.cs +++ b/MediaBrowser.Controller/Persistence/IItemRepository.cs @@ -5,159 +5,101 @@ using System; using System.Collections.Generic; using System.Threading; +using Jellyfin.Data.Enums; using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; -namespace MediaBrowser.Controller.Persistence +namespace MediaBrowser.Controller.Persistence; + +/// +/// Provides an interface to implement an Item repository. +/// +public interface IItemRepository { /// - /// Provides an interface to implement an Item repository. + /// Deletes the item. /// - public interface IItemRepository : IDisposable - { - /// - /// Deletes the item. - /// - /// The identifier. - void DeleteItem(Guid id); + /// The identifier. + void DeleteItem(Guid id); - /// - /// Saves the items. - /// - /// The items. - /// The cancellation token. - void SaveItems(IReadOnlyList items, CancellationToken cancellationToken); + /// + /// Saves the items. + /// + /// The items. + /// The cancellation token. + void SaveItems(IReadOnlyList items, CancellationToken cancellationToken); - void SaveImages(BaseItem item); + void SaveImages(BaseItem item); - /// - /// Retrieves the item. - /// - /// The id. - /// BaseItem. - BaseItem RetrieveItem(Guid id); + /// + /// Retrieves the item. + /// + /// The id. + /// BaseItem. + BaseItem RetrieveItem(Guid id); - /// - /// Gets chapters for an item. - /// - /// The item. - /// The list of chapter info. - List GetChapters(BaseItem item); + /// + /// Gets the items. + /// + /// The query. + /// QueryResult<BaseItem>. + QueryResult GetItems(InternalItemsQuery filter); - /// - /// Gets a single chapter for an item. - /// - /// The item. - /// The chapter index. - /// The chapter info at the specified index. - ChapterInfo GetChapter(BaseItem item, int index); + /// + /// Gets the item ids list. + /// + /// The query. + /// List<Guid>. + IReadOnlyList GetItemIdsList(InternalItemsQuery filter); - /// - /// Saves the chapters. - /// - /// The item id. - /// The list of chapters to save. - void SaveChapters(Guid id, IReadOnlyList chapters); + /// + /// Gets the item list. + /// + /// The query. + /// List<BaseItem>. + IReadOnlyList GetItemList(InternalItemsQuery filter); - /// - /// Gets the media streams. - /// - /// The query. - /// IEnumerable{MediaStream}. - List GetMediaStreams(MediaStreamQuery query); + /// + /// Gets the item list. Used mainly by the Latest api endpoint. + /// + /// The query. + /// Collection Type. + /// List<BaseItem>. + IReadOnlyList GetLatestItemList(InternalItemsQuery filter, CollectionType collectionType); - /// - /// Saves the media streams. - /// - /// The identifier. - /// The streams. - /// The cancellation token. - void SaveMediaStreams(Guid id, IReadOnlyList streams, CancellationToken cancellationToken); + /// + /// Gets the list of series presentation keys for next up. + /// + /// The query. + /// The minimum date for a series to have been most recently watched. + /// The list of keys. + IReadOnlyList GetNextUpSeriesKeys(InternalItemsQuery filter, DateTime dateCutoff); - /// - /// Gets the media attachments. - /// - /// The query. - /// IEnumerable{MediaAttachment}. - List GetMediaAttachments(MediaAttachmentQuery query); + /// + /// Updates the inherited values. + /// + void UpdateInheritedValues(); - /// - /// Saves the media attachments. - /// - /// The identifier. - /// The attachments. - /// The cancellation token. - void SaveMediaAttachments(Guid id, IReadOnlyList attachments, CancellationToken cancellationToken); + int GetCount(InternalItemsQuery filter); - /// - /// Gets the items. - /// - /// The query. - /// QueryResult<BaseItem>. - QueryResult GetItems(InternalItemsQuery query); + QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetGenres(InternalItemsQuery filter); - /// - /// Gets the item ids list. - /// - /// The query. - /// List<Guid>. - List GetItemIdsList(InternalItemsQuery query); + QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetMusicGenres(InternalItemsQuery filter); - /// - /// Gets the people. - /// - /// The query. - /// List<PersonInfo>. - List GetPeople(InternalPeopleQuery query); + QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetStudios(InternalItemsQuery filter); - /// - /// Updates the people. - /// - /// The item identifier. - /// The people. - void UpdatePeople(Guid itemId, List people); + QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetArtists(InternalItemsQuery filter); - /// - /// Gets the people names. - /// - /// The query. - /// List<System.String>. - List GetPeopleNames(InternalPeopleQuery query); + QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetAlbumArtists(InternalItemsQuery filter); - /// - /// Gets the item list. - /// - /// The query. - /// List<BaseItem>. - List GetItemList(InternalItemsQuery query); + QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetAllArtists(InternalItemsQuery filter); - /// - /// Updates the inherited values. - /// - void UpdateInheritedValues(); + IReadOnlyList GetMusicGenreNames(); - int GetCount(InternalItemsQuery query); + IReadOnlyList GetStudioNames(); - QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetGenres(InternalItemsQuery query); + IReadOnlyList GetGenreNames(); - QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetMusicGenres(InternalItemsQuery query); - - QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetStudios(InternalItemsQuery query); - - QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetArtists(InternalItemsQuery query); - - QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetAlbumArtists(InternalItemsQuery query); - - QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetAllArtists(InternalItemsQuery query); - - List GetMusicGenreNames(); - - List GetStudioNames(); - - List GetGenreNames(); - - List GetAllArtistNames(); - } + IReadOnlyList GetAllArtistNames(); } diff --git a/MediaBrowser.Controller/Persistence/IItemTypeLookup.cs b/MediaBrowser.Controller/Persistence/IItemTypeLookup.cs new file mode 100644 index 0000000000..6699d3a4df --- /dev/null +++ b/MediaBrowser.Controller/Persistence/IItemTypeLookup.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using Jellyfin.Data.Enums; +using MediaBrowser.Model.Querying; + +namespace MediaBrowser.Controller.Persistence; + +/// +/// Provides static lookup data for and for the domain. +/// +public interface IItemTypeLookup +{ + /// + /// Gets all serialisation target types for music related kinds. + /// + IReadOnlyList MusicGenreTypes { get; } + + /// + /// Gets mapping for all BaseItemKinds and their expected serialization target. + /// + IReadOnlyDictionary BaseItemKindNames { get; } +} diff --git a/MediaBrowser.Controller/Persistence/IKeyframeRepository.cs b/MediaBrowser.Controller/Persistence/IKeyframeRepository.cs new file mode 100644 index 0000000000..4930434a7f --- /dev/null +++ b/MediaBrowser.Controller/Persistence/IKeyframeRepository.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.MediaEncoding.Keyframes; + +namespace MediaBrowser.Controller.Persistence; + +/// +/// Provides methods for accessing keyframe data. +/// +public interface IKeyframeRepository +{ + /// + /// Gets the keyframe data. + /// + /// The item id. + /// The keyframe data. + IReadOnlyList GetKeyframeData(Guid itemId); + + /// + /// Saves the keyframe data. + /// + /// The item id. + /// The keyframe data. + /// The cancellation token. + /// The task object representing the asynchronous operation. + Task SaveKeyframeDataAsync(Guid itemId, KeyframeData data, CancellationToken cancellationToken); +} diff --git a/MediaBrowser.Controller/Persistence/IMediaAttachmentRepository.cs b/MediaBrowser.Controller/Persistence/IMediaAttachmentRepository.cs new file mode 100644 index 0000000000..4773f40581 --- /dev/null +++ b/MediaBrowser.Controller/Persistence/IMediaAttachmentRepository.cs @@ -0,0 +1,28 @@ +#nullable disable + +#pragma warning disable CS1591 + +using System; +using System.Collections.Generic; +using System.Threading; +using MediaBrowser.Model.Entities; + +namespace MediaBrowser.Controller.Persistence; + +public interface IMediaAttachmentRepository +{ + /// + /// Gets the media attachments. + /// + /// The query. + /// IEnumerable{MediaAttachment}. + IReadOnlyList GetMediaAttachments(MediaAttachmentQuery filter); + + /// + /// Saves the media attachments. + /// + /// The identifier. + /// The attachments. + /// The cancellation token. + void SaveMediaAttachments(Guid id, IReadOnlyList attachments, CancellationToken cancellationToken); +} diff --git a/MediaBrowser.Controller/Persistence/IMediaStreamRepository.cs b/MediaBrowser.Controller/Persistence/IMediaStreamRepository.cs new file mode 100644 index 0000000000..665129eafd --- /dev/null +++ b/MediaBrowser.Controller/Persistence/IMediaStreamRepository.cs @@ -0,0 +1,31 @@ +#nullable disable + +#pragma warning disable CS1591 + +using System; +using System.Collections.Generic; +using System.Threading; +using MediaBrowser.Model.Entities; + +namespace MediaBrowser.Controller.Persistence; + +/// +/// Provides methods for accessing MediaStreams. +/// +public interface IMediaStreamRepository +{ + /// + /// Gets the media streams. + /// + /// The query. + /// IEnumerable{MediaStream}. + IReadOnlyList GetMediaStreams(MediaStreamQuery filter); + + /// + /// Saves the media streams. + /// + /// The identifier. + /// The streams. + /// The cancellation token. + void SaveMediaStreams(Guid id, IReadOnlyList streams, CancellationToken cancellationToken); +} diff --git a/MediaBrowser.Controller/Persistence/IPeopleRepository.cs b/MediaBrowser.Controller/Persistence/IPeopleRepository.cs new file mode 100644 index 0000000000..418289cb4c --- /dev/null +++ b/MediaBrowser.Controller/Persistence/IPeopleRepository.cs @@ -0,0 +1,33 @@ +#nullable disable + +#pragma warning disable CS1591 + +using System; +using System.Collections.Generic; +using MediaBrowser.Controller.Entities; + +namespace MediaBrowser.Controller.Persistence; + +public interface IPeopleRepository +{ + /// + /// Gets the people. + /// + /// The query. + /// The list of people matching the filter. + IReadOnlyList GetPeople(InternalPeopleQuery filter); + + /// + /// Updates the people. + /// + /// The item identifier. + /// The people. + void UpdatePeople(Guid itemId, IReadOnlyList people); + + /// + /// Gets the people names. + /// + /// The query. + /// The list of people names matching the filter. + IReadOnlyList GetPeopleNames(InternalPeopleQuery filter); +} diff --git a/MediaBrowser.Controller/Persistence/IUserDataRepository.cs b/MediaBrowser.Controller/Persistence/IUserDataRepository.cs deleted file mode 100644 index f2fb2826a0..0000000000 --- a/MediaBrowser.Controller/Persistence/IUserDataRepository.cs +++ /dev/null @@ -1,55 +0,0 @@ -#nullable disable - -using System; -using System.Collections.Generic; -using System.Threading; -using MediaBrowser.Controller.Entities; - -namespace MediaBrowser.Controller.Persistence -{ - /// - /// Provides an interface to implement a UserData repository. - /// - public interface IUserDataRepository : IDisposable - { - /// - /// Saves the user data. - /// - /// The user id. - /// The key. - /// The user data. - /// The cancellation token. - void SaveUserData(long userId, string key, UserItemData userData, CancellationToken cancellationToken); - - /// - /// Gets the user data. - /// - /// The user id. - /// The key. - /// The user data. - UserItemData GetUserData(long userId, string key); - - /// - /// Gets the user data. - /// - /// The user id. - /// The keys. - /// The user data. - UserItemData GetUserData(long userId, List keys); - - /// - /// Return all user data associated with the given user. - /// - /// The user id. - /// The list of user item data. - List GetAllUserData(long userId); - - /// - /// Save all user data associated with the given user. - /// - /// The user id. - /// The user item data. - /// The cancellation token. - void SaveAllUserData(long userId, UserItemData[] userData, CancellationToken cancellationToken); - } -} diff --git a/MediaBrowser.Controller/Playlists/IPlaylistManager.cs b/MediaBrowser.Controller/Playlists/IPlaylistManager.cs index 038cbd2d67..497c4a511e 100644 --- a/MediaBrowser.Controller/Playlists/IPlaylistManager.cs +++ b/MediaBrowser.Controller/Playlists/IPlaylistManager.cs @@ -92,8 +92,9 @@ namespace MediaBrowser.Controller.Playlists /// The playlist identifier. /// The entry identifier. /// The new index. + /// The calling user. /// Task. - Task MoveItemAsync(string playlistId, string entryId, int newIndex); + Task MoveItemAsync(string playlistId, string entryId, int newIndex, Guid callingUserId); /// /// Removed all playlists of a user. diff --git a/MediaBrowser.Controller/Playlists/Playlist.cs b/MediaBrowser.Controller/Playlists/Playlist.cs index 45aefacf6d..1062399e3f 100644 --- a/MediaBrowser.Controller/Playlists/Playlist.cs +++ b/MediaBrowser.Controller/Playlists/Playlist.cs @@ -9,8 +9,10 @@ using System.Linq; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Data.Entities; +using Jellyfin.Data; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; @@ -137,27 +139,27 @@ namespace MediaBrowser.Controller.Playlists return Task.CompletedTask; } - public override List GetChildren(User user, bool includeLinkedChildren, InternalItemsQuery query) + public override IReadOnlyList GetChildren(User user, bool includeLinkedChildren, InternalItemsQuery query) { return GetPlayableItems(user, query); } - protected override IEnumerable GetNonCachedChildren(IDirectoryService directoryService) + protected override IReadOnlyList GetNonCachedChildren(IDirectoryService directoryService) { return []; } - public override IEnumerable GetRecursiveChildren(User user, InternalItemsQuery query) + public override IReadOnlyList GetRecursiveChildren(User user, InternalItemsQuery query) { return GetPlayableItems(user, query); } - public IEnumerable> GetManageableItems() + public IReadOnlyList> GetManageableItems() { return GetLinkedChildrenInfos(); } - private List GetPlayableItems(User user, InternalItemsQuery query) + private IReadOnlyList GetPlayableItems(User user, InternalItemsQuery query) { query ??= new InternalItemsQuery(user); @@ -227,11 +229,11 @@ namespace MediaBrowser.Controller.Playlists return [item]; } - public override bool IsVisible(User user) + public override bool IsVisible(User user, bool skipAllowedTagsCheck = false) { if (!IsSharedItem) { - return base.IsVisible(user); + return base.IsVisible(user, skipAllowedTagsCheck); } if (OpenAccess) diff --git a/MediaBrowser.Controller/Providers/DirectoryService.cs b/MediaBrowser.Controller/Providers/DirectoryService.cs index 474f09dc5e..a1edfa3c96 100644 --- a/MediaBrowser.Controller/Providers/DirectoryService.cs +++ b/MediaBrowser.Controller/Providers/DirectoryService.cs @@ -10,14 +10,15 @@ namespace MediaBrowser.Controller.Providers { public class DirectoryService : IDirectoryService { - private readonly IFileSystem _fileSystem; - + // TODO make static and switch to FastConcurrentLru. private readonly ConcurrentDictionary _cache = new(StringComparer.Ordinal); private readonly ConcurrentDictionary _fileCache = new(StringComparer.Ordinal); private readonly ConcurrentDictionary> _filePathCache = new(StringComparer.Ordinal); + private readonly IFileSystem _fileSystem; + public DirectoryService(IFileSystem fileSystem) { _fileSystem = fileSystem; diff --git a/MediaBrowser.Controller/Providers/IExternalId.cs b/MediaBrowser.Controller/Providers/IExternalId.cs index f451eac6dd..584c3297a9 100644 --- a/MediaBrowser.Controller/Providers/IExternalId.cs +++ b/MediaBrowser.Controller/Providers/IExternalId.cs @@ -31,12 +31,6 @@ namespace MediaBrowser.Controller.Providers /// ExternalIdMediaType? Type { get; } - /// - /// Gets the URL format string for this id. - /// - [Obsolete("Obsolete in 10.10, to be removed in 10.11")] - string? UrlFormatString { get; } - /// /// Determines whether this id supports a given item type. /// diff --git a/MediaBrowser.Controller/Providers/IProviderManager.cs b/MediaBrowser.Controller/Providers/IProviderManager.cs index 38fc5f2cca..0d3a334dfb 100644 --- a/MediaBrowser.Controller/Providers/IProviderManager.cs +++ b/MediaBrowser.Controller/Providers/IProviderManager.cs @@ -77,7 +77,8 @@ namespace MediaBrowser.Controller.Providers Task SaveImage(BaseItem item, Stream source, string mimeType, ImageType type, int? imageIndex, CancellationToken cancellationToken); /// - /// Saves the image. + /// Saves the image by giving the image path on filesystem. + /// This method will remove the image on the source path after saving it to the destination. /// /// Image to save. /// Source of image. diff --git a/MediaBrowser.Controller/Providers/MetadataResult.cs b/MediaBrowser.Controller/Providers/MetadataResult.cs index cfff3eb144..ef69885fcf 100644 --- a/MediaBrowser.Controller/Providers/MetadataResult.cs +++ b/MediaBrowser.Controller/Providers/MetadataResult.cs @@ -3,6 +3,7 @@ #pragma warning disable CA1002, CA2227, CS1591 using System.Collections.Generic; +using System.Linq; using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Entities; @@ -13,6 +14,7 @@ namespace MediaBrowser.Controller.Providers // Images aren't always used so the allocation is a waste a lot of the time private List _images; private List<(string Url, ImageType Type)> _remoteImages; + private List _people; public MetadataResult() { @@ -21,17 +23,21 @@ namespace MediaBrowser.Controller.Providers public List Images { - get => _images ??= new List(); + get => _images ??= []; set => _images = value; } public List<(string Url, ImageType Type)> RemoteImages { - get => _remoteImages ??= new List<(string Url, ImageType Type)>(); + get => _remoteImages ??= []; set => _remoteImages = value; } - public List People { get; set; } + public IReadOnlyList People + { + get => _people; + set => _people = value?.ToList(); + } public bool HasMetadata { get; set; } @@ -47,7 +53,7 @@ namespace MediaBrowser.Controller.Providers { People ??= new List(); - PeopleHelper.AddPerson(People, p); + PeopleHelper.AddPerson(_people, p); } /// @@ -61,7 +67,7 @@ namespace MediaBrowser.Controller.Providers } else { - People.Clear(); + _people.Clear(); } } } diff --git a/MediaBrowser.Controller/Session/ISessionManager.cs b/MediaBrowser.Controller/Session/ISessionManager.cs index 462a624553..2b3afa1174 100644 --- a/MediaBrowser.Controller/Session/ISessionManager.cs +++ b/MediaBrowser.Controller/Session/ISessionManager.cs @@ -6,7 +6,8 @@ using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Data.Entities.Security; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Entities.Security; using MediaBrowser.Controller.Authentication; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Dto; @@ -73,7 +74,7 @@ namespace MediaBrowser.Controller.Session /// The remote end point. /// The user. /// A task containing the session information. - Task LogSessionActivity(string appName, string appVersion, string deviceId, string deviceName, string remoteEndPoint, Jellyfin.Data.Entities.User user); + Task LogSessionActivity(string appName, string appVersion, string deviceId, string deviceName, string remoteEndPoint, User user); /// /// Used to report that a session controller has connected. @@ -160,7 +161,7 @@ namespace MediaBrowser.Controller.Session /// The identifier of the session. /// The group update. /// The cancellation token. - /// Type of group. + /// The group update type. /// Task. Task SendSyncPlayGroupUpdate(string sessionId, GroupUpdate command, CancellationToken cancellationToken); @@ -324,7 +325,7 @@ namespace MediaBrowser.Controller.Session Task GetSessionByAuthenticationToken(Device info, string deviceId, string remoteEndpoint, string appVersion); /// - /// Logouts the specified access token. + /// Logs out the specified access token. /// /// The access token. /// A representing the log out process. @@ -341,5 +342,13 @@ namespace MediaBrowser.Controller.Session Task RevokeUserTokens(Guid userId, string currentAccessToken); Task CloseIfNeededAsync(SessionInfo session); + + /// + /// Used to close the livestream if needed. + /// + /// The livestream id. + /// The session id or playsession id. + /// Task. + Task CloseLiveStreamIfNeededAsync(string liveStreamId, string sessionIdOrPlaySessionId); } } diff --git a/MediaBrowser.Controller/Session/SessionInfo.cs b/MediaBrowser.Controller/Session/SessionInfo.cs index 3ba1bfce42..96783f6073 100644 --- a/MediaBrowser.Controller/Session/SessionInfo.cs +++ b/MediaBrowser.Controller/Session/SessionInfo.cs @@ -25,7 +25,7 @@ namespace MediaBrowser.Controller.Session private readonly ISessionManager _sessionManager; private readonly ILogger _logger; - private readonly object _progressLock = new(); + private readonly Lock _progressLock = new(); private Timer _progressTimer; private PlaybackProgressInfo _lastProgressInfo; @@ -286,7 +286,7 @@ namespace MediaBrowser.Controller.Session /// /// Gets or sets the playlist item id. /// - /// The splaylist item id. + /// The playlist item id. public string PlaylistItemId { get; set; } /// diff --git a/MediaBrowser.Controller/Sorting/IUserBaseItemComparer.cs b/MediaBrowser.Controller/Sorting/IUserBaseItemComparer.cs index bd47db39a6..97f653edf5 100644 --- a/MediaBrowser.Controller/Sorting/IUserBaseItemComparer.cs +++ b/MediaBrowser.Controller/Sorting/IUserBaseItemComparer.cs @@ -1,11 +1,12 @@ #nullable disable +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Library; namespace MediaBrowser.Controller.Sorting { /// - /// Represents a BaseItem comparer that requires a User to perform it's comparison. + /// Represents a BaseItem comparer that requires a User to perform its comparison. /// public interface IUserBaseItemComparer : IBaseItemComparer { @@ -13,7 +14,7 @@ namespace MediaBrowser.Controller.Sorting /// Gets or sets the user. /// /// The user. - Jellyfin.Data.Entities.User User { get; set; } + User User { get; set; } /// /// Gets or sets the user manager. diff --git a/MediaBrowser.Controller/Streaming/StreamState.cs b/MediaBrowser.Controller/Streaming/StreamState.cs index b5dbe29ec7..195dda5fe8 100644 --- a/MediaBrowser.Controller/Streaming/StreamState.cs +++ b/MediaBrowser.Controller/Streaming/StreamState.cs @@ -51,7 +51,7 @@ public class StreamState : EncodingJobInfo, IDisposable public VideoRequestDto? VideoRequest => Request as VideoRequestDto; /// - /// Gets or sets the direct stream provicer. + /// Gets or sets the direct stream provider. /// /// /// Deprecated. diff --git a/MediaBrowser.Controller/SyncPlay/GroupStates/AbstractGroupState.cs b/MediaBrowser.Controller/SyncPlay/GroupStates/AbstractGroupState.cs index 51c95a1bb2..31890c40ae 100644 --- a/MediaBrowser.Controller/SyncPlay/GroupStates/AbstractGroupState.cs +++ b/MediaBrowser.Controller/SyncPlay/GroupStates/AbstractGroupState.cs @@ -80,7 +80,7 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates } var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.RemoveItems); - var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + var update = new SyncPlayPlayQueueUpdate(context.GroupId, playQueueUpdate); context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); if (playingItemRemoved && !context.PlayQueue.IsItemPlaying()) @@ -106,7 +106,7 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates } var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.MoveItem); - var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + var update = new SyncPlayPlayQueueUpdate(context.GroupId, playQueueUpdate); context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); } @@ -127,7 +127,7 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates _ => PlayQueueUpdateReason.Queue }; var playQueueUpdate = context.GetPlayQueueUpdate(reason); - var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + var update = new SyncPlayPlayQueueUpdate(context.GroupId, playQueueUpdate); context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); } @@ -184,7 +184,7 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates { context.SetRepeatMode(request.Mode); var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.RepeatMode); - var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + var update = new SyncPlayPlayQueueUpdate(context.GroupId, playQueueUpdate); context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); } @@ -193,7 +193,7 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates { context.SetShuffleMode(request.Mode); var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.ShuffleMode); - var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + var update = new SyncPlayPlayQueueUpdate(context.GroupId, playQueueUpdate); context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); } @@ -221,7 +221,7 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates { // Notify relevant state change event. var stateUpdate = new GroupStateUpdate(Type, reason.Action); - var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.StateUpdate, stateUpdate); + var update = new SyncPlayStateUpdate(context.GroupId, stateUpdate); context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); } diff --git a/MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs b/MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs index dcc06db1ed..132765b719 100644 --- a/MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs +++ b/MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs @@ -78,7 +78,7 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates // Prepare new session. var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.NewPlaylist); - var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + var update = new SyncPlayPlayQueueUpdate(context.GroupId, playQueueUpdate); context.SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, update, cancellationToken); context.SetBuffering(session, true); @@ -152,7 +152,7 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates } var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.NewPlaylist); - var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + var update = new SyncPlayPlayQueueUpdate(context.GroupId, playQueueUpdate); context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); // Reset status of sessions and await for all Ready events. @@ -177,7 +177,7 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates if (result) { var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.SetCurrentItem); - var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + var update = new SyncPlayPlayQueueUpdate(context.GroupId, playQueueUpdate); context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); // Reset status of sessions and await for all Ready events. @@ -215,7 +215,7 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates context.RestartCurrentItem(); var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.NewPlaylist); - var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + var update = new SyncPlayPlayQueueUpdate(context.GroupId, playQueueUpdate); context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); // Reset status of sessions and await for all Ready events. @@ -336,7 +336,7 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates _logger.LogDebug("Session {SessionId} reported wrong playlist item in group {GroupId}.", session.Id, context.GroupId.ToString()); var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.SetCurrentItem); - var updateSession = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + var updateSession = new SyncPlayPlayQueueUpdate(context.GroupId, playQueueUpdate); context.SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, updateSession, cancellationToken); context.SetBuffering(session, true); @@ -410,7 +410,7 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates _logger.LogDebug("Session {SessionId} reported wrong playlist item in group {GroupId}.", session.Id, context.GroupId.ToString()); var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.SetCurrentItem); - var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + var update = new SyncPlayPlayQueueUpdate(context.GroupId, playQueueUpdate); context.SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, update, cancellationToken); context.SetBuffering(session, true); @@ -583,7 +583,7 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates { // Send playing-queue update. var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.NextItem); - var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + var update = new SyncPlayPlayQueueUpdate(context.GroupId, playQueueUpdate); context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); // Reset status of sessions and await for all Ready events. @@ -629,7 +629,7 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates { // Send playing-queue update. var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.PreviousItem); - var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + var update = new SyncPlayPlayQueueUpdate(context.GroupId, playQueueUpdate); context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); // Reset status of sessions and await for all Ready events. diff --git a/MediaBrowser.Controller/SyncPlay/IGroupStateContext.cs b/MediaBrowser.Controller/SyncPlay/IGroupStateContext.cs index d2de224503..ddf86be71f 100644 --- a/MediaBrowser.Controller/SyncPlay/IGroupStateContext.cs +++ b/MediaBrowser.Controller/SyncPlay/IGroupStateContext.cs @@ -66,11 +66,11 @@ namespace MediaBrowser.Controller.SyncPlay /// /// Sends a GroupUpdate message to the interested sessions. /// - /// The type of the data of the message. /// The current session. /// The filtering type. /// The message to send. /// The cancellation token. + /// The group update type. /// The task. Task SendGroupUpdate(SessionInfo from, SyncPlayBroadcastType type, GroupUpdate message, CancellationToken cancellationToken); @@ -91,15 +91,6 @@ namespace MediaBrowser.Controller.SyncPlay /// The command. SendCommand NewSyncPlayCommand(SendCommandType type); - /// - /// Builds a new group update message. - /// - /// The type of the data of the message. - /// The update type. - /// The data to send. - /// The group update. - GroupUpdate NewSyncPlayGroupUpdate(GroupUpdateType type, T data); - /// /// Sanitizes the PositionTicks, considers the current playing item when available. /// diff --git a/MediaBrowser.Controller/SyncPlay/ISyncPlayManager.cs b/MediaBrowser.Controller/SyncPlay/ISyncPlayManager.cs index a6999a12c9..6365a389eb 100644 --- a/MediaBrowser.Controller/SyncPlay/ISyncPlayManager.cs +++ b/MediaBrowser.Controller/SyncPlay/ISyncPlayManager.cs @@ -20,7 +20,8 @@ namespace MediaBrowser.Controller.SyncPlay /// The session that's creating the group. /// The request. /// The cancellation token. - void NewGroup(SessionInfo session, NewGroupRequest request, CancellationToken cancellationToken); + /// The newly created group. + GroupInfoDto NewGroup(SessionInfo session, NewGroupRequest request, CancellationToken cancellationToken); /// /// Adds the session to a group. @@ -46,6 +47,14 @@ namespace MediaBrowser.Controller.SyncPlay /// The list of available groups. List ListGroups(SessionInfo session, ListGroupsRequest request); + /// + /// Gets available groups for a session by id. + /// + /// The session. + /// The group id. + /// The groups or null. + GroupInfoDto GetGroup(SessionInfo session, Guid groupId); + /// /// Handle a request by a session in a group. /// diff --git a/MediaBrowser.Controller/Trickplay/ITrickplayManager.cs b/MediaBrowser.Controller/Trickplay/ITrickplayManager.cs index 800317800d..9ac8ead11c 100644 --- a/MediaBrowser.Controller/Trickplay/ITrickplayManager.cs +++ b/MediaBrowser.Controller/Trickplay/ITrickplayManager.cs @@ -2,7 +2,7 @@ using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Data.Entities; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Configuration; diff --git a/MediaBrowser.LocalMetadata/Images/LocalImageProvider.cs b/MediaBrowser.LocalMetadata/Images/LocalImageProvider.cs index 9aa9c3548d..0bb341da18 100644 --- a/MediaBrowser.LocalMetadata/Images/LocalImageProvider.cs +++ b/MediaBrowser.LocalMetadata/Images/LocalImageProvider.cs @@ -320,7 +320,7 @@ namespace MediaBrowser.LocalMetadata.Images { AddImage(files, images, name + "-fanart", ImageType.Backdrop, imagePrefix); - // Support without the prefix if it's in it's own folder + // Support without the prefix if it's in its own folder if (!isInMixedFolder) { AddImage(files, images, name + "-fanart", ImageType.Backdrop); diff --git a/MediaBrowser.LocalMetadata/MediaBrowser.LocalMetadata.csproj b/MediaBrowser.LocalMetadata/MediaBrowser.LocalMetadata.csproj index 05177ac398..8e3c8cf7f4 100644 --- a/MediaBrowser.LocalMetadata/MediaBrowser.LocalMetadata.csproj +++ b/MediaBrowser.LocalMetadata/MediaBrowser.LocalMetadata.csproj @@ -11,7 +11,7 @@ - net8.0 + net9.0 false true diff --git a/MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs b/MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs index e4ac59b676..119effe791 100644 --- a/MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs +++ b/MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs @@ -234,8 +234,8 @@ namespace MediaBrowser.LocalMetadata.Parsers item.CustomRating = reader.ReadNormalizedString(); break; case "RunningTime": - var runtimeText = reader.ReadElementContentAsString(); - if (!string.IsNullOrWhiteSpace(runtimeText)) + var runtimeText = reader.ReadNormalizedString(); + if (!string.IsNullOrEmpty(runtimeText)) { if (int.TryParse(runtimeText.AsSpan().LeftPart(' '), NumberStyles.Integer, CultureInfo.InvariantCulture, out var runtime)) { @@ -253,7 +253,7 @@ namespace MediaBrowser.LocalMetadata.Parsers break; case "LockData": - item.IsLocked = string.Equals(reader.ReadElementContentAsString(), "true", StringComparison.OrdinalIgnoreCase); + item.IsLocked = string.Equals(reader.ReadNormalizedString(), "true", StringComparison.OrdinalIgnoreCase); break; case "Network": foreach (var name in reader.GetStringArray()) @@ -331,9 +331,9 @@ namespace MediaBrowser.LocalMetadata.Parsers case "Rating": case "IMDBrating": { - var rating = reader.ReadElementContentAsString(); + var rating = reader.ReadNormalizedString(); - if (!string.IsNullOrWhiteSpace(rating)) + if (!string.IsNullOrEmpty(rating)) { // All external meta is saving this as '.' for decimal I believe...but just to be sure if (float.TryParse(rating.Replace(',', '.'), NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var val)) @@ -449,7 +449,7 @@ namespace MediaBrowser.LocalMetadata.Parsers case "OwnerUserId": { - var val = reader.ReadElementContentAsString(); + var val = reader.ReadNormalizedString(); if (Guid.TryParse(val, out var guid) && !guid.Equals(Guid.Empty)) { @@ -464,7 +464,7 @@ namespace MediaBrowser.LocalMetadata.Parsers case "Format3D": { - var val = reader.ReadElementContentAsString(); + var val = reader.ReadNormalizedString(); if (item is Video video) { @@ -498,7 +498,7 @@ namespace MediaBrowser.LocalMetadata.Parsers string readerName = reader.Name; if (_validProviderIds!.TryGetValue(readerName, out string? providerIdValue)) { - var id = reader.ReadElementContentAsString(); + var id = reader.ReadNormalizedString(); item.TrySetProviderId(providerIdValue, id); } else @@ -580,7 +580,12 @@ namespace MediaBrowser.LocalMetadata.Parsers switch (reader.Name) { case "Tagline": - item.Tagline = reader.ReadNormalizedString(); + var val = reader.ReadNormalizedString(); + if (!string.IsNullOrEmpty(val)) + { + item.Tagline = val; + } + break; default: reader.Skip(); @@ -842,7 +847,7 @@ namespace MediaBrowser.LocalMetadata.Parsers userId = reader.ReadNormalizedString(); break; case "CanEdit": - canEdit = string.Equals(reader.ReadElementContentAsString(), "true", StringComparison.OrdinalIgnoreCase); + canEdit = string.Equals(reader.ReadNormalizedString(), "true", StringComparison.OrdinalIgnoreCase); break; default: reader.Skip(); @@ -856,7 +861,7 @@ namespace MediaBrowser.LocalMetadata.Parsers } // This is valid - if (!string.IsNullOrWhiteSpace(userId) && Guid.TryParse(userId, out var guid)) + if (!string.IsNullOrEmpty(userId) && Guid.TryParse(userId, out var guid)) { return new PlaylistUserPermissions(guid, canEdit); } diff --git a/MediaBrowser.LocalMetadata/Parsers/BoxSetXmlParser.cs b/MediaBrowser.LocalMetadata/Parsers/BoxSetXmlParser.cs index 952ed3aacb..00634de5b5 100644 --- a/MediaBrowser.LocalMetadata/Parsers/BoxSetXmlParser.cs +++ b/MediaBrowser.LocalMetadata/Parsers/BoxSetXmlParser.cs @@ -15,7 +15,7 @@ namespace MediaBrowser.LocalMetadata.Parsers /// /// Initializes a new instance of the class. /// - /// Instance of the interface. + /// Instance of the interface. /// Instance of the interface. public BoxSetXmlParser(ILogger logger, IProviderManager providerManager) : base(logger, providerManager) diff --git a/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs b/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs index ee0d10bea9..c25adb7746 100644 --- a/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs +++ b/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs @@ -96,7 +96,7 @@ namespace MediaBrowser.LocalMetadata.Savers var directory = Path.GetDirectoryName(path) ?? throw new InvalidDataException($"Provided path ({path}) is not valid."); Directory.CreateDirectory(directory); - // On Windows, savint the file will fail if the file is hidden or readonly + // On Windows, saving the file will fail if the file is hidden or readonly FileSystem.SetAttributes(path, false, false); var fileStreamOptions = new FileStreamOptions() diff --git a/MediaBrowser.LocalMetadata/Savers/PlaylistXmlSaver.cs b/MediaBrowser.LocalMetadata/Savers/PlaylistXmlSaver.cs index 3f018cae9b..ae767a72ac 100644 --- a/MediaBrowser.LocalMetadata/Savers/PlaylistXmlSaver.cs +++ b/MediaBrowser.LocalMetadata/Savers/PlaylistXmlSaver.cs @@ -45,16 +45,16 @@ namespace MediaBrowser.LocalMetadata.Savers } /// - protected override Task WriteCustomElementsAsync(BaseItem item, XmlWriter writer) + protected override async Task WriteCustomElementsAsync(BaseItem item, XmlWriter writer) { var game = (Playlist)item; if (game.PlaylistMediaType == MediaType.Unknown) { - return Task.CompletedTask; + return; } - return writer.WriteElementStringAsync(null, "PlaylistMediaType", null, game.PlaylistMediaType.ToString()); + await writer.WriteElementStringAsync(null, "PlaylistMediaType", null, game.PlaylistMediaType.ToString()).ConfigureAwait(false); } /// diff --git a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs index 431fc0b178..89291c73bf 100644 --- a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs +++ b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs @@ -1,7 +1,4 @@ -#pragma warning disable CS1591 - using System; -using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.IO; @@ -9,28 +6,27 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using AsyncKeyedLock; -using MediaBrowser.Common; -using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.MediaEncoding.Encoder; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; -using MediaBrowser.Model.MediaInfo; using Microsoft.Extensions.Logging; namespace MediaBrowser.MediaEncoding.Attachments { + /// public sealed class AttachmentExtractor : IAttachmentExtractor, IDisposable { private readonly ILogger _logger; - private readonly IApplicationPaths _appPaths; private readonly IFileSystem _fileSystem; private readonly IMediaEncoder _mediaEncoder; private readonly IMediaSourceManager _mediaSourceManager; + private readonly IPathManager _pathManager; private readonly AsyncKeyedLocker _semaphoreLocks = new(o => { @@ -38,18 +34,26 @@ namespace MediaBrowser.MediaEncoding.Attachments o.PoolInitialFill = 1; }); + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The . + /// The . + /// The . + /// The . public AttachmentExtractor( ILogger logger, - IApplicationPaths appPaths, IFileSystem fileSystem, IMediaEncoder mediaEncoder, - IMediaSourceManager mediaSourceManager) + IMediaSourceManager mediaSourceManager, + IPathManager pathManager) { _logger = logger; - _appPaths = appPaths; _fileSystem = fileSystem; _mediaEncoder = mediaEncoder; _mediaSourceManager = mediaSourceManager; + _pathManager = pathManager; } /// @@ -77,157 +81,151 @@ namespace MediaBrowser.MediaEncoding.Attachments throw new ResourceNotFoundException($"MediaSource {mediaSourceId} has no attachment with stream index {attachmentStreamIndex}"); } + if (string.Equals(mediaAttachment.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase)) + { + throw new ResourceNotFoundException($"Attachment with stream index {attachmentStreamIndex} can't be extracted for MediaSource {mediaSourceId}"); + } + var attachmentStream = await GetAttachmentStream(mediaSource, mediaAttachment, cancellationToken) .ConfigureAwait(false); return (mediaAttachment, attachmentStream); } + /// public async Task ExtractAllAttachments( string inputFile, MediaSourceInfo mediaSource, - string outputPath, CancellationToken cancellationToken) { var shouldExtractOneByOne = mediaSource.MediaAttachments.Any(a => !string.IsNullOrEmpty(a.FileName) && (a.FileName.Contains('/', StringComparison.OrdinalIgnoreCase) || a.FileName.Contains('\\', StringComparison.OrdinalIgnoreCase))); - if (shouldExtractOneByOne) + if (shouldExtractOneByOne && !inputFile.EndsWith(".mks", StringComparison.OrdinalIgnoreCase)) { - var attachmentIndexes = mediaSource.MediaAttachments.Select(a => a.Index); - foreach (var i in attachmentIndexes) + foreach (var attachment in mediaSource.MediaAttachments) { - var newName = Path.Join(outputPath, i.ToString(CultureInfo.InvariantCulture)); - await ExtractAttachment(inputFile, mediaSource, i, newName, cancellationToken).ConfigureAwait(false); + if (!string.Equals(attachment.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase)) + { + await ExtractAttachment(inputFile, mediaSource, attachment, cancellationToken).ConfigureAwait(false); + } } } else { - using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false)) - { - if (!Directory.Exists(outputPath)) - { - await ExtractAllAttachmentsInternal( - _mediaEncoder.GetInputArgument(inputFile, mediaSource), - outputPath, - false, - cancellationToken).ConfigureAwait(false); - } - } - } - } - - public async Task ExtractAllAttachmentsExternal( - string inputArgument, - string id, - string outputPath, - CancellationToken cancellationToken) - { - using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false)) - { - if (!File.Exists(Path.Join(outputPath, id))) - { - await ExtractAllAttachmentsInternal( - inputArgument, - outputPath, - true, - cancellationToken).ConfigureAwait(false); - - if (Directory.Exists(outputPath)) - { - File.Create(Path.Join(outputPath, id)); - } - } + await ExtractAllAttachmentsInternal( + inputFile, + mediaSource, + false, + cancellationToken).ConfigureAwait(false); } } private async Task ExtractAllAttachmentsInternal( - string inputPath, - string outputPath, + string inputFile, + MediaSourceInfo mediaSource, bool isExternal, CancellationToken cancellationToken) { + var inputPath = _mediaEncoder.GetInputArgument(inputFile, mediaSource); + ArgumentException.ThrowIfNullOrEmpty(inputPath); - ArgumentException.ThrowIfNullOrEmpty(outputPath); - Directory.CreateDirectory(outputPath); - - var processArgs = string.Format( - CultureInfo.InvariantCulture, - "-dump_attachment:t \"\" -y {0} -i {1} -t 0 -f null null", - inputPath.EndsWith(".concat\"", StringComparison.OrdinalIgnoreCase) ? "-f concat -safe 0" : string.Empty, - inputPath); - - int exitCode; - - using (var process = new Process - { - StartInfo = new ProcessStartInfo - { - Arguments = processArgs, - FileName = _mediaEncoder.EncoderPath, - UseShellExecute = false, - CreateNoWindow = true, - WindowStyle = ProcessWindowStyle.Hidden, - WorkingDirectory = outputPath, - ErrorDialog = false - }, - EnableRaisingEvents = true - }) + var outputFolder = _pathManager.GetAttachmentFolderPath(mediaSource.Id); + using (await _semaphoreLocks.LockAsync(outputFolder, cancellationToken).ConfigureAwait(false)) { - _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments); - - process.Start(); - - try + if (!Directory.Exists(outputFolder)) { - await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); - exitCode = process.ExitCode; - } - catch (OperationCanceledException) - { - process.Kill(true); - exitCode = -1; - } - } - - var failed = false; - - if (exitCode != 0) - { - if (isExternal && exitCode == 1) - { - // ffmpeg returns exitCode 1 because there is no video or audio stream - // this can be ignored + Directory.CreateDirectory(outputFolder); } else { - failed = true; - - _logger.LogWarning("Deleting extracted attachments {Path} due to failure: {ExitCode}", outputPath, exitCode); - try + var fileNames = Directory.GetFiles(outputFolder, "*", SearchOption.TopDirectoryOnly).Select(f => Path.GetFileName(f)); + var missingFiles = mediaSource.MediaAttachments.Where(a => !fileNames.Contains(a.FileName) && !string.Equals(a.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase)); + if (!missingFiles.Any()) { - Directory.Delete(outputPath); - } - catch (IOException ex) - { - _logger.LogError(ex, "Error deleting extracted attachments {Path}", outputPath); + // Skip extraction if all files already exist + return; } } - } - else if (!Directory.Exists(outputPath)) - { - failed = true; - } - if (failed) - { - _logger.LogError("ffmpeg attachment extraction failed for {InputPath} to {OutputPath}", inputPath, outputPath); + var processArgs = string.Format( + CultureInfo.InvariantCulture, + "-dump_attachment:t \"\" -y {0} -i {1} -t 0 -f null null", + inputPath.EndsWith(".concat\"", StringComparison.OrdinalIgnoreCase) ? "-f concat -safe 0" : string.Empty, + inputPath); - throw new InvalidOperationException( - string.Format(CultureInfo.InvariantCulture, "ffmpeg attachment extraction failed for {0} to {1}", inputPath, outputPath)); + int exitCode; + + using (var process = new Process + { + StartInfo = new ProcessStartInfo + { + Arguments = processArgs, + FileName = _mediaEncoder.EncoderPath, + UseShellExecute = false, + CreateNoWindow = true, + WindowStyle = ProcessWindowStyle.Hidden, + WorkingDirectory = outputFolder, + ErrorDialog = false + }, + EnableRaisingEvents = true + }) + { + _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments); + + process.Start(); + + try + { + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + exitCode = process.ExitCode; + } + catch (OperationCanceledException) + { + process.Kill(true); + exitCode = -1; + } + } + + var failed = false; + + if (exitCode != 0) + { + if (isExternal && exitCode == 1) + { + // ffmpeg returns exitCode 1 because there is no video or audio stream + // this can be ignored + } + else + { + failed = true; + + _logger.LogWarning("Deleting extracted attachments {Path} due to failure: {ExitCode}", outputFolder, exitCode); + try + { + Directory.Delete(outputFolder); + } + catch (IOException ex) + { + _logger.LogError(ex, "Error deleting extracted attachments {Path}", outputFolder); + } + } + } + else if (!Directory.Exists(outputFolder)) + { + failed = true; + } + + if (failed) + { + _logger.LogError("ffmpeg attachment extraction failed for {InputPath} to {OutputPath}", inputPath, outputFolder); + + throw new InvalidOperationException( + string.Format(CultureInfo.InvariantCulture, "ffmpeg attachment extraction failed for {0} to {1}", inputPath, outputFolder)); + } + + _logger.LogInformation("ffmpeg attachment extraction completed for {InputPath} to {OutputPath}", inputPath, outputFolder); } - - _logger.LogInformation("ffmpeg attachment extraction completed for {InputPath} to {OutputPath}", inputPath, outputPath); } private async Task GetAttachmentStream( @@ -235,192 +233,31 @@ namespace MediaBrowser.MediaEncoding.Attachments MediaAttachment mediaAttachment, CancellationToken cancellationToken) { - var attachmentPath = await GetReadableFile(mediaSource.Path, mediaSource.Path, mediaSource, mediaAttachment, cancellationToken).ConfigureAwait(false); + var attachmentPath = await ExtractAttachment(mediaSource.Path, mediaSource, mediaAttachment, cancellationToken) + .ConfigureAwait(false); return AsyncFile.OpenRead(attachmentPath); } - private async Task GetReadableFile( - string mediaPath, + private async Task ExtractAttachment( string inputFile, MediaSourceInfo mediaSource, MediaAttachment mediaAttachment, CancellationToken cancellationToken) { - await CacheAllAttachments(mediaPath, inputFile, mediaSource, cancellationToken).ConfigureAwait(false); - - var outputPath = GetAttachmentCachePath(mediaPath, mediaSource, mediaAttachment.Index); - await ExtractAttachment(inputFile, mediaSource, mediaAttachment.Index, outputPath, cancellationToken) - .ConfigureAwait(false); - - return outputPath; - } - - private async Task CacheAllAttachments( - string mediaPath, - string inputFile, - MediaSourceInfo mediaSource, - CancellationToken cancellationToken) - { - var outputFileLocks = new List(); - var extractableAttachmentIds = new List(); - - try + var attachmentFolderPath = _pathManager.GetAttachmentFolderPath(mediaSource.Id); + using (await _semaphoreLocks.LockAsync(attachmentFolderPath, cancellationToken).ConfigureAwait(false)) { - foreach (var attachment in mediaSource.MediaAttachments) - { - var outputPath = GetAttachmentCachePath(mediaPath, mediaSource, attachment.Index); - - var releaser = await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false); - - if (File.Exists(outputPath)) - { - releaser.Dispose(); - continue; - } - - outputFileLocks.Add(releaser); - extractableAttachmentIds.Add(attachment.Index); - } - - if (extractableAttachmentIds.Count > 0) - { - await CacheAllAttachmentsInternal(mediaPath, _mediaEncoder.GetInputArgument(inputFile, mediaSource), mediaSource, extractableAttachmentIds, cancellationToken).ConfigureAwait(false); - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Unable to cache media attachments for File:{File}", mediaPath); - } - finally - { - outputFileLocks.ForEach(x => x.Dispose()); - } - } - - private async Task CacheAllAttachmentsInternal( - string mediaPath, - string inputFile, - MediaSourceInfo mediaSource, - List extractableAttachmentIds, - CancellationToken cancellationToken) - { - var outputPaths = new List(); - var processArgs = string.Empty; - - foreach (var attachmentId in extractableAttachmentIds) - { - var outputPath = GetAttachmentCachePath(mediaPath, mediaSource, attachmentId); - - Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new FileNotFoundException($"Calculated path ({outputPath}) is not valid.")); - - outputPaths.Add(outputPath); - processArgs += string.Format( - CultureInfo.InvariantCulture, - " -dump_attachment:{0} \"{1}\"", - attachmentId, - EncodingUtils.NormalizePath(outputPath)); - } - - processArgs += string.Format( - CultureInfo.InvariantCulture, - " -i {0} -t 0 -f null null", - inputFile); - - int exitCode; - - using (var process = new Process - { - StartInfo = new ProcessStartInfo - { - Arguments = processArgs, - FileName = _mediaEncoder.EncoderPath, - UseShellExecute = false, - CreateNoWindow = true, - WindowStyle = ProcessWindowStyle.Hidden, - ErrorDialog = false - }, - EnableRaisingEvents = true - }) - { - _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments); - - process.Start(); - - try - { - await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); - exitCode = process.ExitCode; - } - catch (OperationCanceledException) - { - process.Kill(true); - exitCode = -1; - } - } - - var failed = false; - - if (exitCode == -1) - { - failed = true; - - foreach (var outputPath in outputPaths) - { - try - { - _logger.LogWarning("Deleting extracted media attachment due to failure: {Path}", outputPath); - _fileSystem.DeleteFile(outputPath); - } - catch (FileNotFoundException) - { - // ffmpeg failed, so it is normal that one or more expected output files do not exist. - // There is no need to log anything for the user here. - } - catch (IOException ex) - { - _logger.LogError(ex, "Error deleting extracted media attachment {Path}", outputPath); - } - } - } - else - { - foreach (var outputPath in outputPaths) - { - if (!File.Exists(outputPath)) - { - _logger.LogError("ffmpeg media attachment extraction failed for {InputPath} to {OutputPath}", inputFile, outputPath); - failed = true; - continue; - } - - _logger.LogInformation("ffmpeg media attachment extraction completed for {InputPath} to {OutputPath}", inputFile, outputPath); - } - } - - if (failed) - { - throw new FfmpegException( - string.Format(CultureInfo.InvariantCulture, "ffmpeg media attachment extraction failed for {0}", inputFile)); - } - } - - private async Task ExtractAttachment( - string inputFile, - MediaSourceInfo mediaSource, - int attachmentStreamIndex, - string outputPath, - CancellationToken cancellationToken) - { - using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false)) - { - if (!File.Exists(outputPath)) + var attachmentPath = _pathManager.GetAttachmentPath(mediaSource.Id, mediaAttachment.FileName ?? mediaAttachment.Index.ToString(CultureInfo.InvariantCulture)); + if (!File.Exists(attachmentPath)) { await ExtractAttachmentInternal( _mediaEncoder.GetInputArgument(inputFile, mediaSource), - attachmentStreamIndex, - outputPath, + mediaAttachment.Index, + attachmentPath, cancellationToken).ConfigureAwait(false); } + + return attachmentPath; } } @@ -510,23 +347,6 @@ namespace MediaBrowser.MediaEncoding.Attachments _logger.LogInformation("ffmpeg attachment extraction completed for {InputPath} to {OutputPath}", inputPath, outputPath); } - private string GetAttachmentCachePath(string mediaPath, MediaSourceInfo mediaSource, int attachmentStreamIndex) - { - string filename; - if (mediaSource.Protocol == MediaProtocol.File) - { - var date = _fileSystem.GetLastWriteTimeUtc(mediaPath); - filename = (mediaPath + attachmentStreamIndex.ToString(CultureInfo.InvariantCulture) + "_" + date.Ticks.ToString(CultureInfo.InvariantCulture)).GetMD5().ToString("D", CultureInfo.InvariantCulture); - } - else - { - filename = (mediaPath + attachmentStreamIndex.ToString(CultureInfo.InvariantCulture)).GetMD5().ToString("D", CultureInfo.InvariantCulture); - } - - var prefix = filename.AsSpan(0, 1); - return Path.Join(_appPaths.DataPath, "attachments", prefix, filename); - } - /// public void Dispose() { diff --git a/MediaBrowser.MediaEncoding/BdInfo/BdInfoDirectoryInfo.cs b/MediaBrowser.MediaEncoding/BdInfo/BdInfoDirectoryInfo.cs index fca17d4c05..7c0be5a9f6 100644 --- a/MediaBrowser.MediaEncoding/BdInfo/BdInfoDirectoryInfo.cs +++ b/MediaBrowser.MediaEncoding/BdInfo/BdInfoDirectoryInfo.cs @@ -84,7 +84,7 @@ public class BdInfoDirectoryInfo : IDirectoryInfo /// Gets the files matching a pattern. /// /// The search pattern. - /// All files of the directory matchign the search pattern. + /// All files of the directory matching the search pattern. public IFileInfo[] GetFiles(string searchPattern) { return _fileSystem.GetFiles(_impl.FullName, new[] { searchPattern }, false, false) @@ -96,8 +96,8 @@ public class BdInfoDirectoryInfo : IDirectoryInfo /// Gets the files matching a pattern and search options. /// /// The search pattern. - /// The search optin. - /// All files of the directory matchign the search pattern and options. + /// The search option. + /// All files of the directory matching the search pattern and options. public IFileInfo[] GetFiles(string searchPattern, SearchOption searchOption) { return _fileSystem.GetFiles( diff --git a/MediaBrowser.MediaEncoding/Encoder/ApplePlatformHelper.cs b/MediaBrowser.MediaEncoding/Encoder/ApplePlatformHelper.cs new file mode 100644 index 0000000000..a8ff58b091 --- /dev/null +++ b/MediaBrowser.MediaEncoding/Encoder/ApplePlatformHelper.cs @@ -0,0 +1,87 @@ +#pragma warning disable CA1031 + +using System; +using System.Linq; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.MediaEncoding.Encoder; + +/// +/// Helper class for Apple platform specific operations. +/// +[SupportedOSPlatform("macos")] +public static class ApplePlatformHelper +{ + private static readonly string[] _av1DecodeBlacklistedCpuClass = ["M1", "M2"]; + + private static string GetSysctlValue(ReadOnlySpan name) + { + IntPtr length = IntPtr.Zero; + // Get length of the value + int osStatus = SysctlByName(name, IntPtr.Zero, 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}"); + } + + IntPtr buffer = Marshal.AllocHGlobal(length.ToInt32()); + try + { + osStatus = SysctlByName(name, buffer, 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}"); + } + + return Marshal.PtrToStringAnsi(buffer) ?? string.Empty; + } + finally + { + Marshal.FreeHGlobal(buffer); + } + } + + private static int SysctlByName(ReadOnlySpan name, IntPtr oldp, ref IntPtr oldlenp, IntPtr newp, uint newlen) + { + return NativeMethods.SysctlByName(name.ToArray(), oldp, ref oldlenp, newp, newlen); + } + + /// + /// Check if the current system has hardware acceleration for AV1 decoding. + /// + /// The logger used for error logging. + /// Boolean indicates the hwaccel support. + public static bool HasAv1HardwareAccel(ILogger logger) + { + if (!RuntimeInformation.OSArchitecture.Equals(Architecture.Arm64)) + { + return false; + } + + try + { + string cpuBrandString = GetSysctlValue("machdep.cpu.brand_string"u8); + return !_av1DecodeBlacklistedCpuClass.Any(blacklistedCpuClass => cpuBrandString.Contains(blacklistedCpuClass, StringComparison.OrdinalIgnoreCase)); + } + catch (NotSupportedException e) + { + logger.LogError("Error getting CPU brand string: {Message}", e.Message); + } + catch (Exception e) + { + logger.LogError("Unknown error occured: {Exception}", e); + } + + 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); + } +} diff --git a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs index 23d9ca7ef5..d28cd70ef5 100644 --- a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs +++ b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs @@ -5,7 +5,9 @@ using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.Linq; +using System.Runtime.Versioning; using System.Text.RegularExpressions; +using MediaBrowser.Controller.MediaEncoding; using Microsoft.Extensions.Logging; namespace MediaBrowser.MediaEncoding.Encoder @@ -159,6 +161,15 @@ namespace MediaBrowser.MediaEncoding.Encoder { 6, new string[] { "transpose_opencl", "rotate by half-turn" } } }; + private static readonly Dictionary _bsfOptionsDict = new Dictionary + { + { BitStreamFilterOptionType.HevcMetadataRemoveDovi, ("hevc_metadata", "remove_dovi") }, + { BitStreamFilterOptionType.HevcMetadataRemoveHdr10Plus, ("hevc_metadata", "remove_hdr10plus") }, + { BitStreamFilterOptionType.Av1MetadataRemoveDovi, ("av1_metadata", "remove_dovi") }, + { BitStreamFilterOptionType.Av1MetadataRemoveHdr10Plus, ("av1_metadata", "remove_hdr10plus") }, + { BitStreamFilterOptionType.DoviRpuStrip, ("dovi_rpu", "strip") } + }; + // These are the library versions that corresponds to our minimum ffmpeg version 4.4 according to the version table below // Refers to the versions in https://ffmpeg.org/download.html private static readonly Dictionary _ffmpegMinimumLibraryVersions = new Dictionary @@ -285,6 +296,9 @@ namespace MediaBrowser.MediaEncoding.Encoder public IDictionary GetFiltersWithOption() => GetFFmpegFiltersWithOption(); + public IDictionary GetBitStreamFiltersWithOption() => _bsfOptionsDict + .ToDictionary(item => item.Key, item => CheckBitStreamFilterWithOption(item.Value.Item1, item.Value.Item2)); + public Version? GetFFmpegVersion() { string output; @@ -437,6 +451,12 @@ namespace MediaBrowser.MediaEncoding.Encoder } } + [SupportedOSPlatform("macos")] + public bool CheckIsVideoToolboxAv1DecodeAvailable() + { + return ApplePlatformHelper.HasAv1HardwareAccel(_logger); + } + private IEnumerable GetHwaccelTypes() { string? output = null; @@ -488,6 +508,34 @@ namespace MediaBrowser.MediaEncoding.Encoder return false; } + public bool CheckBitStreamFilterWithOption(string filter, string option) + { + if (string.IsNullOrEmpty(filter) || string.IsNullOrEmpty(option)) + { + return false; + } + + string output; + try + { + output = GetProcessOutput(_encoderPath, "-h bsf=" + filter, false, null); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error detecting the given bit stream filter"); + return false; + } + + if (output.Contains("Bit stream filter " + filter, StringComparison.Ordinal)) + { + return output.Contains(option, StringComparison.Ordinal); + } + + _logger.LogWarning("Bit stream filter: {Name} with option {Option} is not available", filter, option); + + return false; + } + public bool CheckSupportedRuntimeKey(string keyDesc, Version? ffmpegVersion) { if (string.IsNullOrEmpty(keyDesc)) @@ -516,6 +564,11 @@ namespace MediaBrowser.MediaEncoding.Encoder return !string.IsNullOrEmpty(flag) && GetProcessExitCode(_encoderPath, $"-loglevel quiet -hwaccel_flags +{flag} -hide_banner -f lavfi -i nullsrc=s=1x1:d=100 -f null -"); } + public bool CheckSupportedProberOption(string option, string proberPath) + { + return !string.IsNullOrEmpty(option) && GetProcessExitCode(proberPath, $"-loglevel quiet -f lavfi -i nullsrc=s=1x1:d=1 -{option}"); + } + private IEnumerable GetCodecs(Codec codec) { string codecstr = codec == Codec.Encoder ? "encoders" : "decoders"; diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs index 826ffd0b7e..39c0bfed40 100644 --- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs +++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs @@ -62,7 +62,7 @@ namespace MediaBrowser.MediaEncoding.Encoder private readonly AsyncNonKeyedLocker _thumbnailResourcePool; - private readonly object _runningProcessesLock = new object(); + private readonly Lock _runningProcessesLock = new(); private readonly List _runningProcesses = new List(); // MediaEncoder is registered as a Singleton @@ -73,9 +73,11 @@ namespace MediaBrowser.MediaEncoding.Encoder private List _hwaccels = new List(); private List _filters = new List(); private IDictionary _filtersWithOption = new Dictionary(); + private IDictionary _bitStreamFiltersWithOption = new Dictionary(); private bool _isPkeyPauseSupported = false; private bool _isLowPriorityHwDecodeSupported = false; + private bool _proberSupportsFirstVideoFrame = false; private bool _isVaapiDeviceAmd = false; private bool _isVaapiDeviceInteliHD = false; @@ -83,6 +85,8 @@ namespace MediaBrowser.MediaEncoding.Encoder private bool _isVaapiDeviceSupportVulkanDrmModifier = false; private bool _isVaapiDeviceSupportVulkanDrmInterop = false; + private bool _isVideoToolboxAv1DecodeAvailable = false; + private static string[] _vulkanImageDrmFmtModifierExts = { "VK_EXT_image_drm_format_modifier", @@ -122,7 +126,13 @@ namespace MediaBrowser.MediaEncoding.Encoder _jsonSerializerOptions = new JsonSerializerOptions(JsonDefaults.Options); _jsonSerializerOptions.Converters.Add(new JsonBoolStringConverter()); - var semaphoreCount = 2 * Environment.ProcessorCount; + // Although the type is not nullable, this might still be null during unit tests + var semaphoreCount = serverConfig.Configuration?.ParallelImageEncodingLimit ?? 0; + if (semaphoreCount < 1) + { + semaphoreCount = Environment.ProcessorCount; + } + _thumbnailResourcePool = new(semaphoreCount); } @@ -153,6 +163,8 @@ namespace MediaBrowser.MediaEncoding.Encoder /// public bool IsVaapiDeviceSupportVulkanDrmInterop => _isVaapiDeviceSupportVulkanDrmInterop; + public bool IsVideoToolboxAv1DecodeAvailable => _isVideoToolboxAv1DecodeAvailable; + [GeneratedRegex(@"[^\/\\]+?(\.[^\/\\\n.]+)?$")] private static partial Regex FfprobePathRegex(); @@ -212,6 +224,7 @@ namespace MediaBrowser.MediaEncoding.Encoder SetAvailableEncoders(validator.GetEncoders()); SetAvailableFilters(validator.GetFilters()); SetAvailableFiltersWithOption(validator.GetFiltersWithOption()); + SetAvailableBitStreamFiltersWithOption(validator.GetBitStreamFiltersWithOption()); SetAvailableHwaccels(validator.GetHwaccels()); SetMediaEncoderVersion(validator); @@ -219,6 +232,7 @@ namespace MediaBrowser.MediaEncoding.Encoder _isPkeyPauseSupported = validator.CheckSupportedRuntimeKey("p pause transcoding", _ffmpegVersion); _isLowPriorityHwDecodeSupported = validator.CheckSupportedHwaccelFlag("low_priority"); + _proberSupportsFirstVideoFrame = validator.CheckSupportedProberOption("only_first_vframe", _ffprobePath); // Check the Vaapi device vendor if (OperatingSystem.IsLinux() @@ -255,6 +269,12 @@ namespace MediaBrowser.MediaEncoding.Encoder _logger.LogInformation("VAAPI device {RenderNodePath} supports Vulkan DRM interop", options.VaapiDevice); } } + + // Check if VideoToolbox supports AV1 decode + if (OperatingSystem.IsMacOS() && SupportsHwaccel("videotoolbox")) + { + _isVideoToolboxAv1DecodeAvailable = validator.CheckIsVideoToolboxAv1DecodeAvailable(); + } } _logger.LogInformation("FFmpeg: {FfmpegPath}", _ffmpegPath ?? string.Empty); @@ -326,6 +346,11 @@ namespace MediaBrowser.MediaEncoding.Encoder _filtersWithOption = dict; } + public void SetAvailableBitStreamFiltersWithOption(IDictionary dict) + { + _bitStreamFiltersWithOption = dict; + } + public void SetMediaEncoderVersion(EncoderValidator validator) { _ffmpegVersion = validator.GetFFmpegVersion(); @@ -366,6 +391,11 @@ namespace MediaBrowser.MediaEncoding.Encoder return false; } + public bool SupportsBitStreamFilterWithOption(BitStreamFilterOptionType option) + { + return _bitStreamFiltersWithOption.TryGetValue(option, out var val) && val; + } + public bool CanEncodeToAudioCodec(string codec) { if (string.Equals(codec, "opus", StringComparison.OrdinalIgnoreCase)) @@ -485,6 +515,12 @@ namespace MediaBrowser.MediaEncoding.Encoder var args = extractChapters ? "{0} -i {1} -threads {2} -v warning -print_format json -show_streams -show_chapters -show_format" : "{0} -i {1} -threads {2} -v warning -print_format json -show_streams -show_format"; + + if (_proberSupportsFirstVideoFrame) + { + args += " -show_frames -only_first_vframe"; + } + args = string.Format(CultureInfo.InvariantCulture, args, probeSizeArgument, inputPath, _threads).Trim(); var process = new Process @@ -684,13 +720,11 @@ namespace MediaBrowser.MediaEncoding.Encoder filters.Add(scaler); - // Use ffmpeg to sample 100 (we can drop this if required using thumbnail=50 for 50 frames) frames and pick the best thumbnail. Have a fall back just in case. - // mpegts need larger batch size otherwise the corrupted thumbnail will be created. Larger batch size will lower the processing speed. + // Use ffmpeg to sample N frames and pick the best thumbnail. Have a fall back just in case. var enableThumbnail = !useTradeoff && useIFrame && !string.Equals("wtv", container, StringComparison.OrdinalIgnoreCase); if (enableThumbnail) { - var useLargerBatchSize = string.Equals("mpegts", container, StringComparison.OrdinalIgnoreCase); - filters.Add("thumbnail=n=" + (useLargerBatchSize ? "50" : "24")); + filters.Add("thumbnail=n=24"); } // Use SW tonemap on HDR video stream only when the zscale or tonemapx filter is available. @@ -714,14 +748,26 @@ namespace MediaBrowser.MediaEncoding.Encoder var vf = string.Join(',', filters); var mapArg = imageStreamIndex.HasValue ? (" -map 0:" + imageStreamIndex.Value.ToString(CultureInfo.InvariantCulture)) : string.Empty; - var args = string.Format(CultureInfo.InvariantCulture, "-i {0}{3} -threads {4} -v quiet -vframes 1 -vf {2}{5} -f image2 \"{1}\"", inputPath, tempExtractPath, vf, mapArg, _threads, isAudio ? string.Empty : GetImageResolutionParameter()); + var args = string.Format( + CultureInfo.InvariantCulture, + "-i {0}{1} -threads {2} -v quiet -vframes 1 -vf {3}{4}{5} -f image2 \"{6}\"", + inputPath, + mapArg, + _threads, + vf, + isAudio ? string.Empty : GetImageResolutionParameter(), + EncodingHelper.GetVideoSyncOption("0", EncoderVersion).Trim(), // passthrough timestamp + tempExtractPath); if (offset.HasValue) { args = string.Format(CultureInfo.InvariantCulture, "-ss {0} ", GetTimeParameter(offset.Value)) + args; } - if (useIFrame && useTradeoff) + // The mpegts demuxer cannot seek to keyframes, so we have to let the + // decoder discard non-keyframes, which may contain corrupted images. + var seekMpegTs = offset.HasValue && string.Equals("mpegts", container, StringComparison.OrdinalIgnoreCase); + if ((useIFrame && useTradeoff) || seekMpegTs) { args = "-skip_frame nokey " + args; } @@ -1035,6 +1081,16 @@ namespace MediaBrowser.MediaEncoding.Encoder if (exitCode == -1) { _logger.LogError("ffmpeg image extraction failed for {ProcessDescription}", processDescription); + // Cleanup temp folder here, because the targetDirectory is not returned and the cleanup for failed ffmpeg process is not possible for caller. + // Ideally the ffmpeg should not write any files if it fails, but it seems like it is not guaranteed. + try + { + Directory.Delete(targetDirectory, true); + } + catch (Exception e) + { + _logger.LogError(e, "Failed to delete ffmpeg temp directory {TargetDirectory}", targetDirectory); + } throw new FfmpegException(string.Format(CultureInfo.InvariantCulture, "ffmpeg image extraction failed for {0}", processDescription)); } @@ -1091,14 +1147,14 @@ namespace MediaBrowser.MediaEncoding.Encoder private void StopProcesses() { - List proceses; + List processes; lock (_runningProcessesLock) { - proceses = _runningProcesses.ToList(); + processes = _runningProcesses.ToList(); _runningProcesses.Clear(); } - foreach (var process in proceses) + foreach (var process in processes) { if (!process.HasExited) { diff --git a/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj b/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj index be63513a72..be7eeda929 100644 --- a/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj +++ b/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj @@ -6,7 +6,7 @@ - net8.0 + net9.0 false true diff --git a/MediaBrowser.MediaEncoding/Probing/FFProbeHelpers.cs b/MediaBrowser.MediaEncoding/Probing/FFProbeHelpers.cs index 1b5b5262a2..6f51e1a6ab 100644 --- a/MediaBrowser.MediaEncoding/Probing/FFProbeHelpers.cs +++ b/MediaBrowser.MediaEncoding/Probing/FFProbeHelpers.cs @@ -24,7 +24,7 @@ namespace MediaBrowser.MediaEncoding.Probing if (result.Streams is not null) { - // Convert all dictionaries to case insensitive + // Convert all dictionaries to case-insensitive foreach (var stream in result.Streams) { if (stream.Tags is not null) @@ -70,7 +70,7 @@ namespace MediaBrowser.MediaEncoding.Probing } /// - /// Converts a dictionary to case insensitive. + /// Converts a dictionary to case-insensitive. /// /// The dict. /// Dictionary{System.StringSystem.String}. diff --git a/MediaBrowser.MediaEncoding/Probing/InternalMediaInfoResult.cs b/MediaBrowser.MediaEncoding/Probing/InternalMediaInfoResult.cs index d4d153b083..53eea64db1 100644 --- a/MediaBrowser.MediaEncoding/Probing/InternalMediaInfoResult.cs +++ b/MediaBrowser.MediaEncoding/Probing/InternalMediaInfoResult.cs @@ -30,5 +30,12 @@ namespace MediaBrowser.MediaEncoding.Probing /// The chapters. [JsonPropertyName("chapters")] public IReadOnlyList Chapters { get; set; } + + /// + /// Gets or sets the frames. + /// + /// The streams. + [JsonPropertyName("frames")] + public IReadOnlyList Frames { get; set; } } } diff --git a/MediaBrowser.MediaEncoding/Probing/MediaFrameInfo.cs b/MediaBrowser.MediaEncoding/Probing/MediaFrameInfo.cs new file mode 100644 index 0000000000..bed4368ed2 --- /dev/null +++ b/MediaBrowser.MediaEncoding/Probing/MediaFrameInfo.cs @@ -0,0 +1,184 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace MediaBrowser.MediaEncoding.Probing; + +/// +/// Class MediaFrameInfo. +/// +public class MediaFrameInfo +{ + /// + /// Gets or sets the media type. + /// + [JsonPropertyName("media_type")] + public string? MediaType { get; set; } + + /// + /// Gets or sets the StreamIndex. + /// + [JsonPropertyName("stream_index")] + public int? StreamIndex { get; set; } + + /// + /// Gets or sets the KeyFrame. + /// + [JsonPropertyName("key_frame")] + public int? KeyFrame { get; set; } + + /// + /// Gets or sets the Pts. + /// + [JsonPropertyName("pts")] + public long? Pts { get; set; } + + /// + /// Gets or sets the PtsTime. + /// + [JsonPropertyName("pts_time")] + public string? PtsTime { get; set; } + + /// + /// Gets or sets the BestEffortTimestamp. + /// + [JsonPropertyName("best_effort_timestamp")] + public long BestEffortTimestamp { get; set; } + + /// + /// Gets or sets the BestEffortTimestampTime. + /// + [JsonPropertyName("best_effort_timestamp_time")] + public string? BestEffortTimestampTime { get; set; } + + /// + /// Gets or sets the Duration. + /// + [JsonPropertyName("duration")] + public int Duration { get; set; } + + /// + /// Gets or sets the DurationTime. + /// + [JsonPropertyName("duration_time")] + public string? DurationTime { get; set; } + + /// + /// Gets or sets the PktPos. + /// + [JsonPropertyName("pkt_pos")] + public string? PktPos { get; set; } + + /// + /// Gets or sets the PktSize. + /// + [JsonPropertyName("pkt_size")] + public string? PktSize { get; set; } + + /// + /// Gets or sets the Width. + /// + [JsonPropertyName("width")] + public int? Width { get; set; } + + /// + /// Gets or sets the Height. + /// + [JsonPropertyName("height")] + public int? Height { get; set; } + + /// + /// Gets or sets the CropTop. + /// + [JsonPropertyName("crop_top")] + public int? CropTop { get; set; } + + /// + /// Gets or sets the CropBottom. + /// + [JsonPropertyName("crop_bottom")] + public int? CropBottom { get; set; } + + /// + /// Gets or sets the CropLeft. + /// + [JsonPropertyName("crop_left")] + public int? CropLeft { get; set; } + + /// + /// Gets or sets the CropRight. + /// + [JsonPropertyName("crop_right")] + public int? CropRight { get; set; } + + /// + /// Gets or sets the PixFmt. + /// + [JsonPropertyName("pix_fmt")] + public string? PixFmt { get; set; } + + /// + /// Gets or sets the SampleAspectRatio. + /// + [JsonPropertyName("sample_aspect_ratio")] + public string? SampleAspectRatio { get; set; } + + /// + /// Gets or sets the PictType. + /// + [JsonPropertyName("pict_type")] + public string? PictType { get; set; } + + /// + /// Gets or sets the InterlacedFrame. + /// + [JsonPropertyName("interlaced_frame")] + public int? InterlacedFrame { get; set; } + + /// + /// Gets or sets the TopFieldFirst. + /// + [JsonPropertyName("top_field_first")] + public int? TopFieldFirst { get; set; } + + /// + /// Gets or sets the RepeatPict. + /// + [JsonPropertyName("repeat_pict")] + public int? RepeatPict { get; set; } + + /// + /// Gets or sets the ColorRange. + /// + [JsonPropertyName("color_range")] + public string? ColorRange { get; set; } + + /// + /// Gets or sets the ColorSpace. + /// + [JsonPropertyName("color_space")] + public string? ColorSpace { get; set; } + + /// + /// Gets or sets the ColorPrimaries. + /// + [JsonPropertyName("color_primaries")] + public string? ColorPrimaries { get; set; } + + /// + /// Gets or sets the ColorTransfer. + /// + [JsonPropertyName("color_transfer")] + public string? ColorTransfer { get; set; } + + /// + /// Gets or sets the ChromaLocation. + /// + [JsonPropertyName("chroma_location")] + public string? ChromaLocation { get; set; } + + /// + /// Gets or sets the SideDataList. + /// + [JsonPropertyName("side_data_list")] + public IReadOnlyList? SideDataList { get; set; } +} diff --git a/MediaBrowser.MediaEncoding/Probing/MediaFrameSideDataInfo.cs b/MediaBrowser.MediaEncoding/Probing/MediaFrameSideDataInfo.cs new file mode 100644 index 0000000000..3f7dd9a69d --- /dev/null +++ b/MediaBrowser.MediaEncoding/Probing/MediaFrameSideDataInfo.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace MediaBrowser.MediaEncoding.Probing; + +/// +/// Class MediaFrameSideDataInfo. +/// Currently only records the SideDataType for HDR10+ detection. +/// +public class MediaFrameSideDataInfo +{ + /// + /// Gets or sets the SideDataType. + /// + [JsonPropertyName("side_data_type")] + public string? SideDataType { get; set; } +} diff --git a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs index 334796f585..5784deacd2 100644 --- a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs +++ b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs @@ -10,6 +10,7 @@ using System.Text.RegularExpressions; using System.Xml; using Jellyfin.Data.Enums; using Jellyfin.Extensions; +using MediaBrowser.Controller.Extensions; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; @@ -104,8 +105,9 @@ namespace MediaBrowser.MediaEncoding.Probing SetSize(data, info); var internalStreams = data.Streams ?? Array.Empty(); + var internalFrames = data.Frames ?? Array.Empty(); - info.MediaStreams = internalStreams.Select(s => GetMediaStream(isAudio, s, data.Format)) + info.MediaStreams = internalStreams.Select(s => GetMediaStream(isAudio, s, data.Format, internalFrames)) .Where(i => i is not null) // Drop subtitle streams if we don't know the codec because it will just cause failures if we don't know how to handle them .Where(i => i.Type != MediaStreamType.Subtitle || !string.IsNullOrWhiteSpace(i.Codec)) @@ -531,42 +533,44 @@ namespace MediaBrowser.MediaEncoding.Probing private void ProcessPairs(string key, List pairs, MediaInfo info) { List peoples = new List(); + var distinctPairs = pairs.Select(p => p.Value) + .Where(i => !string.IsNullOrWhiteSpace(i)) + .Trimmed() + .Distinct(StringComparer.OrdinalIgnoreCase); + if (string.Equals(key, "studio", StringComparison.OrdinalIgnoreCase)) { - info.Studios = pairs.Select(p => p.Value) - .Where(i => !string.IsNullOrWhiteSpace(i)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); + info.Studios = distinctPairs.ToArray(); } else if (string.Equals(key, "screenwriters", StringComparison.OrdinalIgnoreCase)) { - foreach (var pair in pairs) + foreach (var pair in distinctPairs) { peoples.Add(new BaseItemPerson { - Name = pair.Value, + Name = pair, Type = PersonKind.Writer }); } } else if (string.Equals(key, "producers", StringComparison.OrdinalIgnoreCase)) { - foreach (var pair in pairs) + foreach (var pair in distinctPairs) { peoples.Add(new BaseItemPerson { - Name = pair.Value, + Name = pair, Type = PersonKind.Producer }); } } else if (string.Equals(key, "directors", StringComparison.OrdinalIgnoreCase)) { - foreach (var pair in pairs) + foreach (var pair in distinctPairs) { peoples.Add(new BaseItemPerson { - Name = pair.Value, + Name = pair, Type = PersonKind.Director }); } @@ -591,10 +595,10 @@ namespace MediaBrowser.MediaEncoding.Probing switch (reader.Name) { case "key": - name = reader.ReadElementContentAsString(); + name = reader.ReadNormalizedString(); break; case "string": - value = reader.ReadElementContentAsString(); + value = reader.ReadNormalizedString(); break; default: reader.Skip(); @@ -607,8 +611,8 @@ namespace MediaBrowser.MediaEncoding.Probing } } - if (string.IsNullOrWhiteSpace(name) - || string.IsNullOrWhiteSpace(value)) + if (string.IsNullOrEmpty(name) + || string.IsNullOrEmpty(value)) { return null; } @@ -682,8 +686,9 @@ namespace MediaBrowser.MediaEncoding.Probing /// if set to true [is info]. /// The stream info. /// The format info. + /// The frame info. /// MediaStream. - private MediaStream GetMediaStream(bool isAudio, MediaStreamInfo streamInfo, MediaFormatInfo formatInfo) + private MediaStream GetMediaStream(bool isAudio, MediaStreamInfo streamInfo, MediaFormatInfo formatInfo, IReadOnlyList frameInfoList) { // These are mp4 chapters if (string.Equals(streamInfo.CodecName, "mov_text", StringComparison.OrdinalIgnoreCase)) @@ -901,6 +906,15 @@ namespace MediaBrowser.MediaEncoding.Probing } } } + + var frameInfo = frameInfoList?.FirstOrDefault(i => i.StreamIndex == stream.Index); + if (frameInfo?.SideDataList != null) + { + if (frameInfo.SideDataList.Any(data => string.Equals(data.SideDataType, "HDR Dynamic Metadata SMPTE2094-40 (HDR10+)", StringComparison.OrdinalIgnoreCase))) + { + stream.Hdr10PlusPresentFlag = true; + } + } } else if (streamInfo.CodecType == CodecType.Data) { @@ -951,7 +965,7 @@ namespace MediaBrowser.MediaEncoding.Probing // Get average bitrate info from tag "NUMBER_OF_BYTES" and "DURATION" if possible. var durationInSeconds = GetRuntimeSecondsFromTags(streamInfo); var bytes = GetNumberOfBytesFromTags(streamInfo); - if (durationInSeconds is not null && bytes is not null) + if (durationInSeconds is not null && durationInSeconds.Value >= 1 && bytes is not null) { bps = Convert.ToInt32(bytes * 8 / durationInSeconds, CultureInfo.InvariantCulture); if (bps > 0) @@ -1453,7 +1467,7 @@ namespace MediaBrowser.MediaEncoding.Probing var genres = new List(info.Genres); foreach (var genre in Split(genreVal, true)) { - if (string.IsNullOrWhiteSpace(genre)) + if (string.IsNullOrEmpty(genre)) { continue; } @@ -1648,7 +1662,7 @@ namespace MediaBrowser.MediaEncoding.Probing using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 1)) { - fs.Read(packetBuffer); + fs.ReadExactly(packetBuffer); } if (packetBuffer[0] == 71) diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEditParser.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEditParser.cs index a79d801fb5..d060b247da 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEditParser.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEditParser.cs @@ -17,7 +17,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles public class SubtitleEditParser : ISubtitleParser { private readonly ILogger _logger; - private readonly Dictionary _subtitleFormats; + private readonly Dictionary> _subtitleFormatTypes; /// /// Initializes a new instance of the class. @@ -26,10 +26,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles public SubtitleEditParser(ILogger logger) { _logger = logger; - _subtitleFormats = GetSubtitleFormats() - .Where(subtitleFormat => !string.IsNullOrEmpty(subtitleFormat.Extension)) - .GroupBy(subtitleFormat => subtitleFormat.Extension.TrimStart('.'), StringComparer.OrdinalIgnoreCase) - .ToDictionary(g => g.Key, g => g.ToArray(), StringComparer.OrdinalIgnoreCase); + _subtitleFormatTypes = GetSubtitleFormatTypes(); } /// @@ -38,13 +35,14 @@ namespace MediaBrowser.MediaEncoding.Subtitles var subtitle = new Subtitle(); var lines = stream.ReadAllLines().ToList(); - if (!_subtitleFormats.TryGetValue(fileExtension, out var subtitleFormats)) + if (!_subtitleFormatTypes.TryGetValue(fileExtension, out var subtitleFormatTypesForExtension)) { throw new ArgumentException($"Unsupported file extension: {fileExtension}", nameof(fileExtension)); } - foreach (var subtitleFormat in subtitleFormats) + foreach (var subtitleFormatType in subtitleFormatTypesForExtension) { + var subtitleFormat = (SubtitleFormat)Activator.CreateInstance(subtitleFormatType, true)!; _logger.LogDebug( "Trying to parse '{FileExtension}' subtitle using the {SubtitleFormatParser} format parser", fileExtension, @@ -97,11 +95,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles /// public bool SupportsFileExtension(string fileExtension) - => _subtitleFormats.ContainsKey(fileExtension); + => _subtitleFormatTypes.ContainsKey(fileExtension); - private List GetSubtitleFormats() + private Dictionary> GetSubtitleFormatTypes() { - var subtitleFormats = new List(); + var subtitleFormatTypes = new Dictionary>(StringComparer.OrdinalIgnoreCase); var assembly = typeof(SubtitleFormat).Assembly; foreach (var type in assembly.GetTypes()) @@ -113,9 +111,20 @@ namespace MediaBrowser.MediaEncoding.Subtitles try { - // It shouldn't be null, but the exception is caught if it is - var subtitleFormat = (SubtitleFormat)Activator.CreateInstance(type, true)!; - subtitleFormats.Add(subtitleFormat); + var tempInstance = (SubtitleFormat)Activator.CreateInstance(type, true)!; + var extension = tempInstance.Extension.TrimStart('.'); + if (!string.IsNullOrEmpty(extension)) + { + // Store only the type, we will instantiate from it later + if (!subtitleFormatTypes.TryGetValue(extension, out var subtitleFormatTypesForExtension)) + { + subtitleFormatTypes[extension] = [type]; + } + else + { + subtitleFormatTypesForExtension.Add(type); + } + } } catch (Exception ex) { @@ -123,7 +132,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles } } - return subtitleFormats; + return subtitleFormatTypes; } } } diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs index a731d4785b..777e335874 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs @@ -13,10 +13,10 @@ using System.Threading; using System.Threading.Tasks; using AsyncKeyedLock; using MediaBrowser.Common; -using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Model.Dto; @@ -31,12 +31,12 @@ namespace MediaBrowser.MediaEncoding.Subtitles public sealed class SubtitleEncoder : ISubtitleEncoder, IDisposable { private readonly ILogger _logger; - private readonly IApplicationPaths _appPaths; private readonly IFileSystem _fileSystem; private readonly IMediaEncoder _mediaEncoder; private readonly IHttpClientFactory _httpClientFactory; private readonly IMediaSourceManager _mediaSourceManager; private readonly ISubtitleParser _subtitleParser; + private readonly IPathManager _pathManager; /// /// The _semaphoreLocks. @@ -49,24 +49,22 @@ namespace MediaBrowser.MediaEncoding.Subtitles public SubtitleEncoder( ILogger logger, - IApplicationPaths appPaths, IFileSystem fileSystem, IMediaEncoder mediaEncoder, IHttpClientFactory httpClientFactory, IMediaSourceManager mediaSourceManager, - ISubtitleParser subtitleParser) + ISubtitleParser subtitleParser, + IPathManager pathManager) { _logger = logger; - _appPaths = appPaths; _fileSystem = fileSystem; _mediaEncoder = mediaEncoder; _httpClientFactory = httpClientFactory; _mediaSourceManager = mediaSourceManager; _subtitleParser = subtitleParser; + _pathManager = pathManager; } - private string SubtitleCachePath => Path.Combine(_appPaths.DataPath, "subtitles"); - private MemoryStream ConvertSubtitles( Stream stream, string inputFormat, @@ -830,26 +828,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles private string GetSubtitleCachePath(MediaSourceInfo mediaSource, int subtitleStreamIndex, string outputSubtitleExtension) { - if (mediaSource.Protocol == MediaProtocol.File) - { - var ticksParam = string.Empty; - - var date = _fileSystem.GetLastWriteTimeUtc(mediaSource.Path); - - ReadOnlySpan filename = (mediaSource.Path + "_" + subtitleStreamIndex.ToString(CultureInfo.InvariantCulture) + "_" + date.Ticks.ToString(CultureInfo.InvariantCulture) + ticksParam).GetMD5() + outputSubtitleExtension; - - var prefix = filename.Slice(0, 1); - - return Path.Join(SubtitleCachePath, prefix, filename); - } - else - { - ReadOnlySpan filename = (mediaSource.Path + "_" + subtitleStreamIndex.ToString(CultureInfo.InvariantCulture)).GetMD5() + outputSubtitleExtension; - - var prefix = filename.Slice(0, 1); - - return Path.Join(SubtitleCachePath, prefix, filename); - } + return _pathManager.GetSubtitlePath(mediaSource.Id, subtitleStreamIndex, outputSubtitleExtension); } /// diff --git a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs index 57557d55ca..0cda803d64 100644 --- a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs +++ b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs @@ -10,7 +10,8 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; using AsyncKeyedLock; -using Jellyfin.Data.Enums; +using Jellyfin.Data; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using MediaBrowser.Common; using MediaBrowser.Common.Configuration; @@ -241,14 +242,7 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable if (closeLiveStream && !string.IsNullOrWhiteSpace(job.LiveStreamId)) { - try - { - await _mediaSourceManager.CloseLiveStream(job.LiveStreamId).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error closing live stream for {Path}", job.Path); - } + await _sessionManager.CloseLiveStreamIfNeededAsync(job.LiveStreamId, job.PlaySessionId).ConfigureAwait(false); } } @@ -404,24 +398,19 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable // If subtitles get burned in fonts may need to be extracted from the media file if (state.SubtitleStream is not null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode) { - var attachmentPath = Path.Combine(_appPaths.CachePath, "attachments", state.MediaSource.Id); if (state.MediaSource.VideoType == VideoType.Dvd || state.MediaSource.VideoType == VideoType.BluRay) { var concatPath = Path.Join(_appPaths.CachePath, "concat", state.MediaSource.Id + ".concat"); - await _attachmentExtractor.ExtractAllAttachments(concatPath, state.MediaSource, attachmentPath, cancellationTokenSource.Token).ConfigureAwait(false); + await _attachmentExtractor.ExtractAllAttachments(concatPath, state.MediaSource, cancellationTokenSource.Token).ConfigureAwait(false); } else { - await _attachmentExtractor.ExtractAllAttachments(state.MediaPath, state.MediaSource, attachmentPath, cancellationTokenSource.Token).ConfigureAwait(false); + await _attachmentExtractor.ExtractAllAttachments(state.MediaPath, state.MediaSource, cancellationTokenSource.Token).ConfigureAwait(false); } if (state.SubtitleStream.IsExternal && Path.GetExtension(state.SubtitleStream.Path.AsSpan()).Equals(".mks", StringComparison.OrdinalIgnoreCase)) { - string subtitlePath = state.SubtitleStream.Path; - string subtitlePathArgument = string.Format(CultureInfo.InvariantCulture, "file:\"{0}\"", subtitlePath.Replace("\"", "\\\"", StringComparison.Ordinal)); - string subtitleId = subtitlePath.GetMD5().ToString("N", CultureInfo.InvariantCulture); - - await _attachmentExtractor.ExtractAllAttachmentsExternal(subtitlePathArgument, subtitleId, attachmentPath, cancellationTokenSource.Token).ConfigureAwait(false); + await _attachmentExtractor.ExtractAllAttachments(state.SubtitleStream.Path, state.MediaSource, cancellationTokenSource.Token).ConfigureAwait(false); } } diff --git a/MediaBrowser.Model/Activity/IActivityManager.cs b/MediaBrowser.Model/Activity/IActivityManager.cs index 28073fb8d7..95aa567ada 100644 --- a/MediaBrowser.Model/Activity/IActivityManager.cs +++ b/MediaBrowser.Model/Activity/IActivityManager.cs @@ -2,9 +2,9 @@ using System; using System.Threading.Tasks; -using Jellyfin.Data.Entities; using Jellyfin.Data.Events; using Jellyfin.Data.Queries; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Model.Querying; namespace MediaBrowser.Model.Activity diff --git a/MediaBrowser.Model/Branding/BrandingOptions.cs b/MediaBrowser.Model/Branding/BrandingOptions.cs index c6580598b4..5ec6b0dd4b 100644 --- a/MediaBrowser.Model/Branding/BrandingOptions.cs +++ b/MediaBrowser.Model/Branding/BrandingOptions.cs @@ -1,5 +1,3 @@ -using System.Text.Json.Serialization; - namespace MediaBrowser.Model.Branding; /// @@ -27,10 +25,5 @@ public class BrandingOptions /// /// Gets or sets the splashscreen location on disk. /// - /// - /// Not served via the API. - /// Only used to save the custom uploaded user splashscreen in the configuration file. - /// - [JsonIgnore] public string? SplashscreenLocation { get; set; } } diff --git a/MediaBrowser.Model/Branding/BrandingOptionsDto.cs b/MediaBrowser.Model/Branding/BrandingOptionsDto.cs new file mode 100644 index 0000000000..c0d8cb31c2 --- /dev/null +++ b/MediaBrowser.Model/Branding/BrandingOptionsDto.cs @@ -0,0 +1,25 @@ +namespace MediaBrowser.Model.Branding; + +/// +/// The branding options DTO for API use. +/// This DTO excludes SplashscreenLocation to prevent it from being updated via API. +/// +public class BrandingOptionsDto +{ + /// + /// Gets or sets the login disclaimer. + /// + /// The login disclaimer. + public string? LoginDisclaimer { get; set; } + + /// + /// Gets or sets the custom CSS. + /// + /// The custom CSS. + public string? CustomCss { get; set; } + + /// + /// Gets or sets a value indicating whether to enable the splashscreen. + /// + public bool SplashscreenEnabled { get; set; } = false; +} diff --git a/MediaBrowser.Model/Configuration/LibraryOptions.cs b/MediaBrowser.Model/Configuration/LibraryOptions.cs index 6054ba34e5..590b74304d 100644 --- a/MediaBrowser.Model/Configuration/LibraryOptions.cs +++ b/MediaBrowser.Model/Configuration/LibraryOptions.cs @@ -15,7 +15,7 @@ namespace MediaBrowser.Model.Configuration TypeOptions = Array.Empty(); DisabledSubtitleFetchers = Array.Empty(); DisabledMediaSegmentProviders = Array.Empty(); - MediaSegmentProvideOrder = Array.Empty(); + MediaSegmentProviderOrder = Array.Empty(); SubtitleFetcherOrder = Array.Empty(); DisabledLocalMetadataReaders = Array.Empty(); DisabledLyricFetchers = Array.Empty(); @@ -99,7 +99,7 @@ namespace MediaBrowser.Model.Configuration public string[] DisabledMediaSegmentProviders { get; set; } - public string[] MediaSegmentProvideOrder { get; set; } + public string[] MediaSegmentProviderOrder { get; set; } public bool SkipSubtitlesIfEmbeddedSubtitlesPresent { get; set; } diff --git a/MediaBrowser.Model/Configuration/ServerConfiguration.cs b/MediaBrowser.Model/Configuration/ServerConfiguration.cs index 5ad588200b..a58c01c960 100644 --- a/MediaBrowser.Model/Configuration/ServerConfiguration.cs +++ b/MediaBrowser.Model/Configuration/ServerConfiguration.cs @@ -83,9 +83,9 @@ public class ServerConfiguration : BaseApplicationConfiguration public bool QuickConnectAvailable { get; set; } = true; /// - /// Gets or sets a value indicating whether [enable case sensitive item ids]. + /// Gets or sets a value indicating whether [enable case-sensitive item ids]. /// - /// true if [enable case sensitive item ids]; otherwise, false. + /// true if [enable case-sensitive item ids]; otherwise, false. public bool EnableCaseSensitiveItemIds { get; set; } = true; public bool DisableLiveTvChannelUserDataName { get; set; } = true; @@ -177,6 +177,11 @@ public class ServerConfiguration : BaseApplicationConfiguration /// The library update duration. public int LibraryUpdateDuration { get; set; } = 30; + /// + /// Gets or sets the maximum amount of items to cache. + /// + public int CacheSize { get; set; } = Environment.ProcessorCount * 100; + /// /// Gets or sets the image saving convention. /// @@ -199,7 +204,9 @@ public class ServerConfiguration : BaseApplicationConfiguration public bool EnableFolderView { get; set; } = false; - public bool EnableGroupingIntoCollections { get; set; } = false; + public bool EnableGroupingMoviesIntoCollections { get; set; } = false; + + public bool EnableGroupingShowsIntoCollections { get; set; } = false; public bool DisplaySpecialsWithinSeasons { get; set; } = true; @@ -243,18 +250,13 @@ public class ServerConfiguration : BaseApplicationConfiguration /// public int LibraryMetadataRefreshConcurrency { get; set; } - /// - /// Gets or sets a value indicating whether older plugins should automatically be deleted from the plugin folder. - /// - public bool RemoveOldPlugins { get; set; } - /// /// Gets or sets a value indicating whether clients should be allowed to upload logs. /// public bool AllowClientLogUpload { get; set; } = true; /// - /// Gets or sets the dummy chapter duration in seconds, use 0 (zero) or less to disable generation alltogether. + /// Gets or sets the dummy chapter duration in seconds, use 0 (zero) or less to disable generation altogether. /// /// The dummy chapters duration. public int DummyChapterDuration { get; set; } @@ -281,4 +283,9 @@ public class ServerConfiguration : BaseApplicationConfiguration /// /// The trickplay options. public TrickplayOptions TrickplayOptions { get; set; } = new TrickplayOptions(); + + /// + /// Gets or sets a value indicating whether old authorization methods are allowed. + /// + public bool EnableLegacyAuthorization { get; set; } = true; } diff --git a/MediaBrowser.Model/Configuration/UserConfiguration.cs b/MediaBrowser.Model/Configuration/UserConfiguration.cs index b477f2593a..fe4b2de65f 100644 --- a/MediaBrowser.Model/Configuration/UserConfiguration.cs +++ b/MediaBrowser.Model/Configuration/UserConfiguration.cs @@ -1,7 +1,7 @@ #pragma warning disable CS1591 using System; -using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; namespace MediaBrowser.Model.Configuration { diff --git a/MediaBrowser.Model/Dlna/ConditionProcessor.cs b/MediaBrowser.Model/Dlna/ConditionProcessor.cs index af0787990d..1b61bfe155 100644 --- a/MediaBrowser.Model/Dlna/ConditionProcessor.cs +++ b/MediaBrowser.Model/Dlna/ConditionProcessor.cs @@ -25,9 +25,10 @@ namespace MediaBrowser.Model.Dlna /// The framerate. /// The packet length. /// The . - /// A value indicating whether tthe video is anamorphic. - /// A value indicating whether tthe video is interlaced. + /// A value indicating whether the video is anamorphic. + /// A value indicating whether the video is interlaced. /// The reference frames. + /// The number of streams. /// The number of video streams. /// The number of audio streams. /// The video codec tag. @@ -48,6 +49,7 @@ namespace MediaBrowser.Model.Dlna bool? isAnamorphic, bool? isInterlaced, int? refFrames, + int numStreams, int? numVideoStreams, int? numAudioStreams, string? videoCodecTag, @@ -83,6 +85,8 @@ namespace MediaBrowser.Model.Dlna return IsConditionSatisfied(condition, width); case ProfileConditionValue.RefFrames: return IsConditionSatisfied(condition, refFrames); + case ProfileConditionValue.NumStreams: + return IsConditionSatisfied(condition, numStreams); case ProfileConditionValue.NumAudioStreams: return IsConditionSatisfied(condition, numAudioStreams); case ProfileConditionValue.NumVideoStreams: @@ -341,6 +345,15 @@ namespace MediaBrowser.Model.Dlna return !condition.IsRequired; } + // Special case: HDR10 also satisfies if the video is HDR10Plus + if (currentValue.Value == VideoRangeType.HDR10Plus) + { + if (IsConditionSatisfied(condition, VideoRangeType.HDR10)) + { + return true; + } + } + var conditionType = condition.Condition; if (conditionType == ProfileConditionType.EqualsAny) { diff --git a/MediaBrowser.Model/Dlna/DirectPlayProfile.cs b/MediaBrowser.Model/Dlna/DirectPlayProfile.cs index 438df34415..553ccfc64b 100644 --- a/MediaBrowser.Model/Dlna/DirectPlayProfile.cs +++ b/MediaBrowser.Model/Dlna/DirectPlayProfile.cs @@ -59,7 +59,7 @@ public class DirectPlayProfile /// True if supported. public bool SupportsAudioCodec(string? codec) { - // Video profiles can have audio codec restrictions too, therefore incude Video as valid type. + // Video profiles can have audio codec restrictions too, therefore include Video as valid type. return (Type == DlnaProfileType.Audio || Type == DlnaProfileType.Video) && ContainerHelper.ContainsContainer(AudioCodec, codec); } } diff --git a/MediaBrowser.Model/Dlna/ProfileConditionValue.cs b/MediaBrowser.Model/Dlna/ProfileConditionValue.cs index a32433e185..b66a15840b 100644 --- a/MediaBrowser.Model/Dlna/ProfileConditionValue.cs +++ b/MediaBrowser.Model/Dlna/ProfileConditionValue.cs @@ -27,6 +27,7 @@ namespace MediaBrowser.Model.Dlna IsInterlaced = 21, AudioSampleRate = 22, AudioBitDepth = 23, - VideoRangeType = 24 + VideoRangeType = 24, + NumStreams = 25 } } diff --git a/MediaBrowser.Model/Dlna/StreamBuilder.cs b/MediaBrowser.Model/Dlna/StreamBuilder.cs index a25ddc367d..61e04a8134 100644 --- a/MediaBrowser.Model/Dlna/StreamBuilder.cs +++ b/MediaBrowser.Model/Dlna/StreamBuilder.cs @@ -30,7 +30,7 @@ namespace MediaBrowser.Model.Dlna private readonly ITranscoderSupport _transcoderSupport; private static readonly string[] _supportedHlsVideoCodecs = ["h264", "hevc", "vp9", "av1"]; private static readonly string[] _supportedHlsAudioCodecsTs = ["aac", "ac3", "eac3", "mp3"]; - private static readonly string[] _supportedHlsAudioCodecsMp4 = ["aac", "ac3", "eac3", "mp3", "alac", "flac", "opus", "dca", "truehd"]; + private static readonly string[] _supportedHlsAudioCodecsMp4 = ["aac", "ac3", "eac3", "mp3", "alac", "flac", "opus", "dts", "truehd"]; /// /// Initializes a new instance of the class. @@ -101,21 +101,16 @@ namespace MediaBrowser.Model.Dlna MediaStream audioStream = item.GetDefaultAudioStream(null); + ArgumentNullException.ThrowIfNull(audioStream); + var directPlayInfo = GetAudioDirectPlayProfile(item, audioStream, options); var directPlayMethod = directPlayInfo.PlayMethod; var transcodeReasons = directPlayInfo.TranscodeReasons; - var inputAudioChannels = audioStream?.Channels; - var inputAudioBitrate = audioStream?.BitRate; - var inputAudioSampleRate = audioStream?.SampleRate; - var inputAudioBitDepth = audioStream?.BitDepth; - if (directPlayMethod is PlayMethod.DirectPlay) { - var profile = options.Profile; - var audioFailureConditions = GetProfileConditionsForAudio(profile.CodecProfiles, item.Container, audioStream?.Codec, inputAudioChannels, inputAudioBitrate, inputAudioSampleRate, inputAudioBitDepth, true); - var audioFailureReasons = AggregateFailureConditions(item, profile, "AudioCodecProfile", audioFailureConditions); + var audioFailureReasons = GetCompatibilityAudioCodec(options, item, item.Container, audioStream, null, false, false); transcodeReasons |= audioFailureReasons; if (audioFailureReasons == 0) @@ -188,6 +183,11 @@ namespace MediaBrowser.Model.Dlna SetStreamInfoOptionsFromTranscodingProfile(item, playlistItem, transcodingProfile); + var inputAudioChannels = audioStream.Channels; + var inputAudioBitrate = audioStream.BitRate; + var inputAudioSampleRate = audioStream.SampleRate; + var inputAudioBitDepth = audioStream.BitDepth; + var audioTranscodingConditions = GetProfileConditionsForAudio(options.Profile.CodecProfiles, transcodingProfile.Container, transcodingProfile.AudioCodec, inputAudioChannels, inputAudioBitrate, inputAudioSampleRate, inputAudioBitDepth, false).ToArray(); ApplyTranscodingConditions(playlistItem, audioTranscodingConditions, null, true, true); @@ -208,6 +208,14 @@ namespace MediaBrowser.Model.Dlna var longBitrate = Math.Min(transcodingBitrate, playlistItem.AudioBitrate ?? transcodingBitrate); playlistItem.AudioBitrate = longBitrate > int.MaxValue ? int.MaxValue : Convert.ToInt32(longBitrate); + + // Pure audio transcoding does not support comma separated list of transcoding codec at the moment. + // So just use the AudioCodec as is would be safe enough as the _transcoderSupport.CanEncodeToAudioCodec + // would fail so this profile will not even be picked up. + if (playlistItem.AudioCodecs.Count == 0 && !string.IsNullOrWhiteSpace(transcodingProfile.AudioCodec)) + { + playlistItem.AudioCodecs = [transcodingProfile.AudioCodec]; + } } playlistItem.TranscodeReasons = transcodeReasons; @@ -330,6 +338,9 @@ namespace MediaBrowser.Model.Dlna case ProfileConditionValue.IsSecondaryAudio: return TranscodeReason.SecondaryAudioNotSupported; + case ProfileConditionValue.NumStreams: + return TranscodeReason.StreamCountExceedsLimit; + case ProfileConditionValue.NumAudioStreams: // TODO return 0; @@ -654,15 +665,39 @@ namespace MediaBrowser.Model.Dlna // Collect candidate audio streams ICollection candidateAudioStreams = audioStream is null ? [] : [audioStream]; - if (!options.AudioStreamIndex.HasValue || options.AudioStreamIndex < 0) + // When the index is explicitly required by client or the default is specified by user, don't do any stream reselection. + if (!item.DefaultAudioIndexSource.HasFlag(AudioIndexSource.User) && (options.AudioStreamIndex is null or < 0)) { - if (audioStream?.IsDefault == true) + // When user has no preferences allow stream selection on all streams. + if (item.DefaultAudioIndexSource == AudioIndexSource.None && audioStream is not null) { - candidateAudioStreams = item.MediaStreams.Where(stream => stream.Type == MediaStreamType.Audio && stream.IsDefault).ToArray(); + candidateAudioStreams = item.MediaStreams.Where(stream => stream.Type == MediaStreamType.Audio).ToArray(); + if (audioStream.IsDefault) + { + // If default is picked, only allow selection within default streams. + candidateAudioStreams = candidateAudioStreams.Where(stream => stream.IsDefault).ToArray(); + } } - else + + if (item.DefaultAudioIndexSource.HasFlag(AudioIndexSource.Language)) { + // If user has language preference, only allow stream selection within the same language. candidateAudioStreams = item.MediaStreams.Where(stream => stream.Type == MediaStreamType.Audio && stream.Language == audioStream?.Language).ToArray(); + if (item.DefaultAudioIndexSource.HasFlag(AudioIndexSource.Default)) + { + var defaultStreamsInPreferredLanguage = candidateAudioStreams.Where(stream => stream.IsDefault).ToArray(); + + // If the user also prefers default streams, try limit selection within default tracks in the same language. + // If there is no default stream in the preferred language, allow selection on all default streams to match the "Play default audio track regardless of language" setting. + candidateAudioStreams = defaultStreamsInPreferredLanguage.Length > 0 + ? defaultStreamsInPreferredLanguage + : item.MediaStreams.Where(stream => stream.Type == MediaStreamType.Audio && stream.IsDefault).ToArray(); + } + } + else if (item.DefaultAudioIndexSource.HasFlag(AudioIndexSource.Default)) + { + // If user prefers default streams, only allow stream selection on default streams. + candidateAudioStreams = item.MediaStreams.Where(stream => stream.Type == MediaStreamType.Audio && stream.IsDefault).ToArray(); } } @@ -789,7 +824,7 @@ namespace MediaBrowser.Model.Dlna options.SubtitleStreamIndex, playlistItem.PlayMethod, playlistItem.TranscodeReasons, - playlistItem.ToUrl("media:", "")); + playlistItem.ToUrl("media:", "", null)); item.Container = NormalizeMediaSourceFormatIntoSingleContainer(item.Container, options.Profile, DlnaProfileType.Video, directPlayProfile); return playlistItem; @@ -802,6 +837,10 @@ namespace MediaBrowser.Model.Dlna MediaStream? audioStream, StreamInfo playlistItem) { + var mediaSource = playlistItem.MediaSource; + + ArgumentNullException.ThrowIfNull(mediaSource); + if (!(item.SupportsTranscoding || item.SupportsDirectStream)) { return (null, null); @@ -816,17 +855,7 @@ namespace MediaBrowser.Model.Dlna } var videoCodec = videoStream?.Codec; - float videoFramerate = videoStream?.ReferenceFrameRate ?? 0; - TransportStreamTimestamp? timestamp = videoStream is null ? TransportStreamTimestamp.None : item.Timestamp; - int? numAudioStreams = item.GetStreamCount(MediaStreamType.Audio); - int? numVideoStreams = item.GetStreamCount(MediaStreamType.Video); - var audioCodec = audioStream?.Codec; - var audioProfile = audioStream?.Profile; - var audioChannels = audioStream?.Channels; - var audioBitrate = audioStream?.BitRate; - var audioSampleRate = audioStream?.SampleRate; - var audioBitDepth = audioStream?.BitDepth; var analyzedProfiles = transcodingProfiles .Select(transcodingProfile => @@ -835,37 +864,40 @@ namespace MediaBrowser.Model.Dlna var container = transcodingProfile.Container; - if (options.AllowVideoStreamCopy) + if (videoStream is not null + && options.AllowVideoStreamCopy + && ContainerHelper.ContainsContainer(transcodingProfile.VideoCodec, videoCodec)) { - if (ContainerHelper.ContainsContainer(transcodingProfile.VideoCodec, videoCodec)) - { - var appliedVideoConditions = options.Profile.CodecProfiles - .Where(i => i.Type == CodecType.Video && - i.ContainsAnyCodec(videoCodec, container) && - i.ApplyConditions.All(applyCondition => ConditionProcessor.IsVideoConditionSatisfied(applyCondition, videoStream?.Width, videoStream?.Height, videoStream?.BitDepth, videoStream?.BitRate, videoStream?.Profile, videoStream?.VideoRangeType, videoStream?.Level, videoFramerate, videoStream?.PacketLength, timestamp, videoStream?.IsAnamorphic, videoStream?.IsInterlaced, videoStream?.RefFrames, numVideoStreams, numAudioStreams, videoStream?.CodecTag, videoStream?.IsAVC))) - .Select(i => - i.Conditions.All(condition => ConditionProcessor.IsVideoConditionSatisfied(condition, videoStream?.Width, videoStream?.Height, videoStream?.BitDepth, videoStream?.BitRate, videoStream?.Profile, videoStream?.VideoRangeType, videoStream?.Level, videoFramerate, videoStream?.PacketLength, timestamp, videoStream?.IsAnamorphic, videoStream?.IsInterlaced, videoStream?.RefFrames, numVideoStreams, numAudioStreams, videoStream?.CodecTag, videoStream?.IsAVC))); - - // An empty appliedVideoConditions means that the codec has no conditions for the current video stream - var conditionsSatisfied = appliedVideoConditions.All(satisfied => satisfied); - rank.Video = conditionsSatisfied ? 1 : 2; - } + var failures = GetCompatibilityVideoCodec(options, mediaSource, container, videoStream); + rank.Video = failures == 0 ? 1 : 2; } - if (options.AllowAudioStreamCopy) + if (audioStream is not null + && options.AllowAudioStreamCopy) { - if (ContainerHelper.ContainsContainer(transcodingProfile.AudioCodec, audioCodec)) - { - var appliedVideoConditions = options.Profile.CodecProfiles - .Where(i => i.Type == CodecType.VideoAudio && - i.ContainsAnyCodec(audioCodec, container) && - i.ApplyConditions.All(applyCondition => ConditionProcessor.IsVideoAudioConditionSatisfied(applyCondition, audioChannels, audioBitrate, audioSampleRate, audioBitDepth, audioProfile, false))) - .Select(i => - i.Conditions.All(condition => ConditionProcessor.IsVideoAudioConditionSatisfied(condition, audioChannels, audioBitrate, audioSampleRate, audioBitDepth, audioProfile, false))); + // For Audio stream, we prefer the audio codec that can be directly copied, then the codec that can otherwise satisfies + // the transcoding conditions, then the one does not satisfy the transcoding conditions. + // For example: A client can support both aac and flac, but flac only supports 2 channels while aac supports 6. + // When the source audio is 6 channel flac, we should transcode to 6 channel aac, instead of down-mix to 2 channel flac. + var transcodingAudioCodecs = ContainerHelper.Split(transcodingProfile.AudioCodec); - // An empty appliedVideoConditions means that the codec has no conditions for the current audio stream - var conditionsSatisfied = appliedVideoConditions.All(satisfied => satisfied); - rank.Audio = conditionsSatisfied ? 1 : 2; + foreach (var transcodingAudioCodec in transcodingAudioCodecs) + { + var failures = GetCompatibilityAudioCodec(options, mediaSource, container, audioStream, transcodingAudioCodec, true, false); + + var rankAudio = 3; + + if (failures == 0) + { + rankAudio = string.Equals(transcodingAudioCodec, audioCodec, StringComparison.OrdinalIgnoreCase) ? 1 : 2; + } + + rank.Audio = Math.Min(rank.Audio, rankAudio); + + if (rank.Audio == 1) + { + break; + } } } @@ -955,9 +987,18 @@ namespace MediaBrowser.Model.Dlna var audioStreamWithSupportedCodec = candidateAudioStreams.Where(stream => ContainerHelper.ContainsContainer(audioCodecs, false, stream.Codec)).FirstOrDefault(); - var directAudioStream = audioStreamWithSupportedCodec?.Channels is not null && audioStreamWithSupportedCodec.Channels.Value <= (playlistItem.TranscodingMaxAudioChannels ?? int.MaxValue) ? audioStreamWithSupportedCodec : null; + var channelsExceedsLimit = audioStreamWithSupportedCodec is not null && audioStreamWithSupportedCodec.Channels > (playlistItem.TranscodingMaxAudioChannels ?? int.MaxValue); - var channelsExceedsLimit = audioStreamWithSupportedCodec is not null && directAudioStream is null; + var directAudioFailures = audioStreamWithSupportedCodec is null ? default : GetCompatibilityAudioCodec(options, item, container ?? string.Empty, audioStreamWithSupportedCodec, null, true, false); + + playlistItem.TranscodeReasons |= directAudioFailures; + + var directAudioStreamSatisfied = audioStreamWithSupportedCodec is not null && !channelsExceedsLimit + && directAudioFailures == 0; + + directAudioStreamSatisfied = directAudioStreamSatisfied && !playlistItem.TranscodeReasons.HasFlag(TranscodeReason.ContainerBitrateExceedsLimit); + + var directAudioStream = directAudioStreamSatisfied ? audioStreamWithSupportedCodec : null; if (channelsExceedsLimit && playlistItem.TargetAudioStream is not null) { @@ -1005,6 +1046,7 @@ namespace MediaBrowser.Model.Dlna int? packetLength = videoStream?.PacketLength; int? refFrames = videoStream?.RefFrames; + int numStreams = item.MediaStreams.Count; int? numAudioStreams = item.GetStreamCount(MediaStreamType.Audio); int? numVideoStreams = item.GetStreamCount(MediaStreamType.Video); @@ -1013,7 +1055,7 @@ namespace MediaBrowser.Model.Dlna var appliedVideoConditions = options.Profile.CodecProfiles .Where(i => i.Type == CodecType.Video && i.ContainsAnyCodec(playlistItem.VideoCodecs, container, useSubContainer) && - i.ApplyConditions.All(applyCondition => ConditionProcessor.IsVideoConditionSatisfied(applyCondition, width, height, bitDepth, videoBitrate, videoProfile, videoRangeType, videoLevel, videoFramerate, packetLength, timestamp, isAnamorphic, isInterlaced, refFrames, numVideoStreams, numAudioStreams, videoCodecTag, isAvc))) + i.ApplyConditions.All(applyCondition => ConditionProcessor.IsVideoConditionSatisfied(applyCondition, width, height, bitDepth, videoBitrate, videoProfile, videoRangeType, videoLevel, videoFramerate, packetLength, timestamp, isAnamorphic, isInterlaced, refFrames, numStreams, numVideoStreams, numAudioStreams, videoCodecTag, isAvc))) // Reverse codec profiles for backward compatibility - first codec profile has higher priority .Reverse(); foreach (var condition in appliedVideoConditions) @@ -1079,12 +1121,12 @@ namespace MediaBrowser.Model.Dlna _logger.LogDebug( "Transcode Result for Profile: {Profile}, Path: {Path}, PlayMethod: {PlayMethod}, AudioStreamIndex: {AudioStreamIndex}, SubtitleStreamIndex: {SubtitleStreamIndex}, Reasons: {TranscodeReason}", - options.Profile?.Name ?? "Anonymous Profile", + options.Profile.Name ?? "Anonymous Profile", item.Path ?? "Unknown path", - playlistItem?.PlayMethod, + playlistItem.PlayMethod, audioStream?.Index, - playlistItem?.SubtitleStreamIndex, - playlistItem?.TranscodeReasons); + playlistItem.SubtitleStreamIndex, + playlistItem.TranscodeReasons); } private static int GetDefaultAudioBitrate(string? audioCodec, int? audioChannels) @@ -1243,52 +1285,14 @@ namespace MediaBrowser.Model.Dlna DeviceProfile profile = options.Profile; string container = mediaSource.Container; - // Video - int? width = videoStream?.Width; - int? height = videoStream?.Height; - int? bitDepth = videoStream?.BitDepth; - int? videoBitrate = videoStream?.BitRate; - double? videoLevel = videoStream?.Level; - string? videoProfile = videoStream?.Profile; - VideoRangeType? videoRangeType = videoStream?.VideoRangeType; - float videoFramerate = videoStream is null ? 0 : videoStream.ReferenceFrameRate ?? 0; - bool? isAnamorphic = videoStream?.IsAnamorphic; - bool? isInterlaced = videoStream?.IsInterlaced; - string? videoCodecTag = videoStream?.CodecTag; - bool? isAvc = videoStream?.IsAVC; - - TransportStreamTimestamp? timestamp = videoStream is null ? TransportStreamTimestamp.None : mediaSource.Timestamp; - int? packetLength = videoStream?.PacketLength; - int? refFrames = videoStream?.RefFrames; - - int? numAudioStreams = mediaSource.GetStreamCount(MediaStreamType.Audio); - int? numVideoStreams = mediaSource.GetStreamCount(MediaStreamType.Video); - - var checkVideoConditions = (ProfileCondition[] conditions) => - conditions.Where(applyCondition => !ConditionProcessor.IsVideoConditionSatisfied(applyCondition, width, height, bitDepth, videoBitrate, videoProfile, videoRangeType, videoLevel, videoFramerate, packetLength, timestamp, isAnamorphic, isInterlaced, refFrames, numVideoStreams, numAudioStreams, videoCodecTag, isAvc)); - // Check container conditions - var containerProfileReasons = AggregateFailureConditions( - mediaSource, - profile, - "VideoCodecProfile", - profile.ContainerProfiles - .Where(containerProfile => containerProfile.Type == DlnaProfileType.Video && containerProfile.ContainsContainer(container)) - .SelectMany(containerProfile => checkVideoConditions(containerProfile.Conditions))); + var containerProfileReasons = GetCompatibilityContainer(options, mediaSource, container, videoStream); // Check video conditions - var videoCodecProfileReasons = AggregateFailureConditions( - mediaSource, - profile, - "VideoCodecProfile", - profile.CodecProfiles - .Where(codecProfile => codecProfile.Type == CodecType.Video && - codecProfile.ContainsAnyCodec(videoStream?.Codec, container) && - !checkVideoConditions(codecProfile.ApplyConditions).Any()) - .SelectMany(codecProfile => checkVideoConditions(codecProfile.Conditions))); + var videoCodecProfileReasons = videoStream is null ? default : GetCompatibilityVideoCodec(options, mediaSource, container, videoStream); // Check audio candidates profile conditions - var audioStreamMatches = candidateAudioStreams.ToDictionary(s => s, audioStream => CheckVideoAudioStreamDirectPlay(options, mediaSource, container, audioStream)); + var audioStreamMatches = candidateAudioStreams.ToDictionary(s => s, audioStream => GetCompatibilityAudioCodecDirect(options, mediaSource, container, audioStream, true, mediaSource.IsSecondaryAudio(audioStream) ?? false)); TranscodeReason subtitleProfileReasons = 0; if (subtitleStream is not null) @@ -1401,20 +1405,6 @@ namespace MediaBrowser.Model.Dlna return (Profile: null, PlayMethod: null, AudioStreamIndex: null, TranscodeReasons: failureReasons); } - private TranscodeReason CheckVideoAudioStreamDirectPlay(MediaOptions options, MediaSourceInfo mediaSource, string container, MediaStream audioStream) - { - var profile = options.Profile; - var audioFailureConditions = GetProfileConditionsForVideoAudio(profile.CodecProfiles, container, audioStream.Codec, audioStream.Channels, audioStream.BitRate, audioStream.SampleRate, audioStream.BitDepth, audioStream.Profile, mediaSource.IsSecondaryAudio(audioStream)); - - var audioStreamFailureReasons = AggregateFailureConditions(mediaSource, profile, "VideoAudioCodecProfile", audioFailureConditions); - if (audioStream.IsExternal == true) - { - audioStreamFailureReasons |= TranscodeReason.AudioIsExternal; - } - - return audioStreamFailureReasons; - } - private TranscodeReason AggregateFailureConditions(MediaSourceInfo mediaSource, DeviceProfile profile, string type, IEnumerable conditions) { return conditions.Aggregate(0, (reasons, i) => @@ -1888,6 +1878,7 @@ namespace MediaBrowser.Model.Dlna case ProfileConditionValue.AudioProfile: case ProfileConditionValue.Has64BitOffsets: case ProfileConditionValue.PacketLength: + case ProfileConditionValue.NumStreams: case ProfileConditionValue.NumAudioStreams: case ProfileConditionValue.NumVideoStreams: case ProfileConditionValue.IsSecondaryAudio: @@ -2205,7 +2196,7 @@ namespace MediaBrowser.Model.Dlna } } - private static bool IsAudioDirectPlaySupported(DirectPlayProfile profile, MediaSourceInfo item, MediaStream audioStream) + private static bool IsAudioContainerSupported(DirectPlayProfile profile, MediaSourceInfo item) { // Check container type if (!profile.SupportsContainer(item.Container)) @@ -2213,6 +2204,20 @@ namespace MediaBrowser.Model.Dlna return false; } + // Never direct play audio in matroska when the device only declare support for webm. + // The first check is not enough because mkv is assumed can be webm. + // See https://github.com/jellyfin/jellyfin/issues/13344 + return !ContainerHelper.ContainsContainer("mkv", item.Container) + || profile.SupportsContainer("mkv"); + } + + private static bool IsAudioDirectPlaySupported(DirectPlayProfile profile, MediaSourceInfo item, MediaStream audioStream) + { + if (!IsAudioContainerSupported(profile, item)) + { + return false; + } + // Check audio codec string? audioCodec = audioStream?.Codec; if (!profile.SupportsAudioCodec(audioCodec)) @@ -2227,19 +2232,16 @@ namespace MediaBrowser.Model.Dlna { // Check container type, this should NOT be supported // If the container is supported, the file should be directly played - if (!profile.SupportsContainer(item.Container)) + if (IsAudioContainerSupported(profile, item)) { - // Check audio codec, we cannot use the SupportsAudioCodec here - // Because that one assumes empty container supports all codec, which is just useless - string? audioCodec = audioStream?.Codec; - if (string.Equals(profile.AudioCodec, audioCodec, StringComparison.OrdinalIgnoreCase) || - string.Equals(profile.Container, audioCodec, StringComparison.OrdinalIgnoreCase)) - { - return true; - } + return false; } - return false; + // Check audio codec, we cannot use the SupportsAudioCodec here + // Because that one assumes empty container supports all codec, which is just useless + string? audioCodec = audioStream?.Codec; + return string.Equals(profile.AudioCodec, audioCodec, StringComparison.OrdinalIgnoreCase) + || string.Equals(profile.Container, audioCodec, StringComparison.OrdinalIgnoreCase); } private int GetRank(ref TranscodeReason a, TranscodeReason[] rankings) @@ -2258,5 +2260,141 @@ namespace MediaBrowser.Model.Dlna return index; } + + /// + /// Check the profile conditions. + /// + /// Profile conditions. + /// Media source. + /// Video stream. + /// Failed profile conditions. + private IEnumerable CheckVideoConditions(ProfileCondition[] conditions, MediaSourceInfo mediaSource, MediaStream? videoStream) + { + int? width = videoStream?.Width; + int? height = videoStream?.Height; + int? bitDepth = videoStream?.BitDepth; + int? videoBitrate = videoStream?.BitRate; + double? videoLevel = videoStream?.Level; + string? videoProfile = videoStream?.Profile; + VideoRangeType? videoRangeType = videoStream?.VideoRangeType; + float videoFramerate = videoStream is null ? 0 : videoStream.ReferenceFrameRate ?? 0; + bool? isAnamorphic = videoStream?.IsAnamorphic; + bool? isInterlaced = videoStream?.IsInterlaced; + string? videoCodecTag = videoStream?.CodecTag; + bool? isAvc = videoStream?.IsAVC; + + TransportStreamTimestamp? timestamp = videoStream is null ? TransportStreamTimestamp.None : mediaSource.Timestamp; + int? packetLength = videoStream?.PacketLength; + int? refFrames = videoStream?.RefFrames; + + int numStreams = mediaSource.MediaStreams.Count; + int? numAudioStreams = mediaSource.GetStreamCount(MediaStreamType.Audio); + int? numVideoStreams = mediaSource.GetStreamCount(MediaStreamType.Video); + + return conditions.Where(applyCondition => !ConditionProcessor.IsVideoConditionSatisfied(applyCondition, width, height, bitDepth, videoBitrate, videoProfile, videoRangeType, videoLevel, videoFramerate, packetLength, timestamp, isAnamorphic, isInterlaced, refFrames, numStreams, numVideoStreams, numAudioStreams, videoCodecTag, isAvc)); + } + + /// + /// Check the compatibility of the container. + /// + /// Media options. + /// Media source. + /// Container. + /// Video stream. + /// Transcode reasons if the container is not fully compatible. + private TranscodeReason GetCompatibilityContainer(MediaOptions options, MediaSourceInfo mediaSource, string container, MediaStream? videoStream) + { + var profile = options.Profile; + + var failures = AggregateFailureConditions( + mediaSource, + profile, + "VideoCodecProfile", + profile.ContainerProfiles + .Where(containerProfile => containerProfile.Type == DlnaProfileType.Video && containerProfile.ContainsContainer(container)) + .SelectMany(containerProfile => CheckVideoConditions(containerProfile.Conditions, mediaSource, videoStream))); + + return failures; + } + + /// + /// Check the compatibility of the video codec. + /// + /// Media options. + /// Media source. + /// Container. + /// Video stream. + /// Transcode reasons if the video stream is not fully compatible. + private TranscodeReason GetCompatibilityVideoCodec(MediaOptions options, MediaSourceInfo mediaSource, string container, MediaStream videoStream) + { + var profile = options.Profile; + + string videoCodec = videoStream.Codec; + + var failures = AggregateFailureConditions( + mediaSource, + profile, + "VideoCodecProfile", + profile.CodecProfiles + .Where(codecProfile => codecProfile.Type == CodecType.Video && + codecProfile.ContainsAnyCodec(videoCodec, container) && + !CheckVideoConditions(codecProfile.ApplyConditions, mediaSource, videoStream).Any()) + .SelectMany(codecProfile => CheckVideoConditions(codecProfile.Conditions, mediaSource, videoStream))); + + return failures; + } + + /// + /// Check the compatibility of the audio codec. + /// + /// Media options. + /// Media source. + /// Container. + /// Audio stream. + /// Override audio codec. + /// The media source is video. + /// The audio stream is secondary. + /// Transcode reasons if the audio stream is not fully compatible. + private TranscodeReason GetCompatibilityAudioCodec(MediaOptions options, MediaSourceInfo mediaSource, string container, MediaStream audioStream, string? transcodingAudioCodec, bool isVideo, bool isSecondaryAudio) + { + var profile = options.Profile; + + var audioCodec = transcodingAudioCodec ?? audioStream.Codec; + var audioProfile = audioStream.Profile; + var audioChannels = audioStream.Channels; + var audioBitrate = audioStream.BitRate; + var audioSampleRate = audioStream.SampleRate; + var audioBitDepth = audioStream.BitDepth; + + var audioFailureConditions = isVideo + ? GetProfileConditionsForVideoAudio(profile.CodecProfiles, container, audioCodec, audioChannels, audioBitrate, audioSampleRate, audioBitDepth, audioProfile, isSecondaryAudio) + : GetProfileConditionsForAudio(profile.CodecProfiles, container, audioCodec, audioChannels, audioBitrate, audioSampleRate, audioBitDepth, true); + + var failures = AggregateFailureConditions(mediaSource, profile, "AudioCodecProfile", audioFailureConditions); + + return failures; + } + + /// + /// Check the compatibility of the audio codec for direct playback. + /// + /// Media options. + /// Media source. + /// Container. + /// Audio stream. + /// The media source is video. + /// The audio stream is secondary. + /// Transcode reasons if the audio stream is not fully compatible for direct playback. + private TranscodeReason GetCompatibilityAudioCodecDirect(MediaOptions options, MediaSourceInfo mediaSource, string container, MediaStream audioStream, bool isVideo, bool isSecondaryAudio) + { + var failures = GetCompatibilityAudioCodec(options, mediaSource, container, audioStream, null, isVideo, isSecondaryAudio); + + if (audioStream.IsExternal) + { + failures |= TranscodeReason.AudioIsExternal; + } + + return failures; + } } } diff --git a/MediaBrowser.Model/Dlna/StreamInfo.cs b/MediaBrowser.Model/Dlna/StreamInfo.cs index 1ae4e1962d..13acd15a3f 100644 --- a/MediaBrowser.Model/Dlna/StreamInfo.cs +++ b/MediaBrowser.Model/Dlna/StreamInfo.cs @@ -1,7 +1,13 @@ +#pragma warning disable CA1819 // Properties should not return arrays + using System; using System.Collections.Generic; +using System.ComponentModel; using System.Globalization; +using System.Linq; +using System.Text; using Jellyfin.Data.Enums; +using Jellyfin.Extensions; using MediaBrowser.Model.Drawing; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; @@ -871,202 +877,271 @@ public class StreamInfo /// /// The base Url. /// The access Token. + /// Optional extra query. /// A querystring representation of this object. - public string ToUrl(string baseUrl, string? accessToken) + public string ToUrl(string? baseUrl, string? accessToken, string? query) { - ArgumentException.ThrowIfNullOrEmpty(baseUrl); - - List list = []; - foreach (NameValuePair pair in BuildParams(this, accessToken)) + var sb = new StringBuilder(); + if (!string.IsNullOrEmpty(baseUrl)) { - if (string.IsNullOrEmpty(pair.Value)) - { - continue; - } - - // Try to keep the url clean by omitting defaults - if (string.Equals(pair.Name, "StartTimeTicks", StringComparison.OrdinalIgnoreCase) - && string.Equals(pair.Value, "0", StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - if (string.Equals(pair.Name, "SubtitleStreamIndex", StringComparison.OrdinalIgnoreCase) - && string.Equals(pair.Value, "-1", StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - if (string.Equals(pair.Name, "Static", StringComparison.OrdinalIgnoreCase) - && string.Equals(pair.Value, "false", StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - var encodedValue = pair.Value.Replace(" ", "%20", StringComparison.Ordinal); - - list.Add(string.Format(CultureInfo.InvariantCulture, "{0}={1}", pair.Name, encodedValue)); + sb.Append(baseUrl.TrimEnd('/')); } - string queryString = string.Join('&', list); - - return GetUrl(baseUrl, queryString); - } - - private string GetUrl(string baseUrl, string queryString) - { - ArgumentException.ThrowIfNullOrEmpty(baseUrl); - - string extension = string.IsNullOrEmpty(Container) ? string.Empty : "." + Container; - - baseUrl = baseUrl.TrimEnd('/'); - if (MediaType == DlnaProfileType.Audio) { - if (SubProtocol == MediaStreamProtocol.hls) + sb.Append("/audio/"); + } + else + { + sb.Append("/videos/"); + } + + sb.Append(ItemId); + + if (SubProtocol == MediaStreamProtocol.hls) + { + sb.Append("/master.m3u8?"); + } + else + { + sb.Append("/stream"); + + if (!string.IsNullOrEmpty(Container)) { - return string.Format(CultureInfo.InvariantCulture, "{0}/audio/{1}/master.m3u8?{2}", baseUrl, ItemId, queryString); + sb.Append('.'); + sb.Append(Container); } - return string.Format(CultureInfo.InvariantCulture, "{0}/audio/{1}/stream{2}?{3}", baseUrl, ItemId, extension, queryString); + sb.Append('?'); + } + + if (!string.IsNullOrEmpty(DeviceProfileId)) + { + sb.Append("&DeviceProfileId="); + sb.Append(DeviceProfileId); + } + + if (!string.IsNullOrEmpty(DeviceId)) + { + sb.Append("&DeviceId="); + sb.Append(DeviceId); + } + + if (!string.IsNullOrEmpty(MediaSourceId)) + { + sb.Append("&MediaSourceId="); + sb.Append(MediaSourceId); + } + + // default true so don't store. + if (IsDirectStream) + { + sb.Append("&Static=true"); + } + + if (VideoCodecs.Count != 0) + { + sb.Append("&VideoCodec="); + sb.AppendJoin(',', VideoCodecs); + } + + if (AudioCodecs.Count != 0) + { + sb.Append("&AudioCodec="); + sb.AppendJoin(',', AudioCodecs); + } + + if (AudioStreamIndex.HasValue) + { + sb.Append("&AudioStreamIndex="); + sb.Append(AudioStreamIndex.Value.ToString(CultureInfo.InvariantCulture)); + } + + if (SubtitleStreamIndex.HasValue && (AlwaysBurnInSubtitleWhenTranscoding || SubtitleDeliveryMethod != SubtitleDeliveryMethod.External) && SubtitleStreamIndex != -1) + { + sb.Append("&SubtitleStreamIndex="); + sb.Append(SubtitleStreamIndex.Value.ToString(CultureInfo.InvariantCulture)); + } + + if (VideoBitrate.HasValue) + { + sb.Append("&VideoBitrate="); + sb.Append(VideoBitrate.Value.ToString(CultureInfo.InvariantCulture)); + } + + if (AudioBitrate.HasValue) + { + sb.Append("&AudioBitrate="); + sb.Append(AudioBitrate.Value.ToString(CultureInfo.InvariantCulture)); + } + + if (AudioSampleRate.HasValue) + { + sb.Append("&AudioSampleRate="); + sb.Append(AudioSampleRate.Value.ToString(CultureInfo.InvariantCulture)); + } + + if (MaxFramerate.HasValue) + { + sb.Append("&MaxFramerate="); + sb.Append(MaxFramerate.Value.ToString(CultureInfo.InvariantCulture)); + } + + if (MaxWidth.HasValue) + { + sb.Append("&MaxWidth="); + sb.Append(MaxWidth.Value.ToString(CultureInfo.InvariantCulture)); + } + + if (MaxHeight.HasValue) + { + sb.Append("&MaxHeight="); + sb.Append(MaxHeight.Value.ToString(CultureInfo.InvariantCulture)); } if (SubProtocol == MediaStreamProtocol.hls) { - return string.Format(CultureInfo.InvariantCulture, "{0}/videos/{1}/master.m3u8?{2}", baseUrl, ItemId, queryString); - } + if (!string.IsNullOrEmpty(Container)) + { + sb.Append("&SegmentContainer="); + sb.Append(Container); + } - return string.Format(CultureInfo.InvariantCulture, "{0}/videos/{1}/stream{2}?{3}", baseUrl, ItemId, extension, queryString); - } + if (SegmentLength.HasValue) + { + sb.Append("&SegmentLength="); + sb.Append(SegmentLength.Value.ToString(CultureInfo.InvariantCulture)); + } - private static List BuildParams(StreamInfo item, string? accessToken) - { - List list = []; + if (MinSegments.HasValue) + { + sb.Append("&MinSegments="); + sb.Append(MinSegments.Value.ToString(CultureInfo.InvariantCulture)); + } - string audioCodecs = item.AudioCodecs.Count == 0 ? - string.Empty : - string.Join(',', item.AudioCodecs); - - string videoCodecs = item.VideoCodecs.Count == 0 ? - string.Empty : - string.Join(',', item.VideoCodecs); - - list.Add(new NameValuePair("DeviceProfileId", item.DeviceProfileId ?? string.Empty)); - list.Add(new NameValuePair("DeviceId", item.DeviceId ?? string.Empty)); - list.Add(new NameValuePair("MediaSourceId", item.MediaSourceId ?? string.Empty)); - list.Add(new NameValuePair("Static", item.IsDirectStream.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); - list.Add(new NameValuePair("VideoCodec", videoCodecs)); - list.Add(new NameValuePair("AudioCodec", audioCodecs)); - list.Add(new NameValuePair("AudioStreamIndex", item.AudioStreamIndex.HasValue ? item.AudioStreamIndex.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); - list.Add(new NameValuePair("SubtitleStreamIndex", item.SubtitleStreamIndex.HasValue && (item.AlwaysBurnInSubtitleWhenTranscoding || item.SubtitleDeliveryMethod != SubtitleDeliveryMethod.External) ? item.SubtitleStreamIndex.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); - list.Add(new NameValuePair("VideoBitrate", item.VideoBitrate.HasValue ? item.VideoBitrate.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); - list.Add(new NameValuePair("AudioBitrate", item.AudioBitrate.HasValue ? item.AudioBitrate.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); - list.Add(new NameValuePair("AudioSampleRate", item.AudioSampleRate.HasValue ? item.AudioSampleRate.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); - - list.Add(new NameValuePair("MaxFramerate", item.MaxFramerate.HasValue ? item.MaxFramerate.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); - list.Add(new NameValuePair("MaxWidth", item.MaxWidth.HasValue ? item.MaxWidth.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); - list.Add(new NameValuePair("MaxHeight", item.MaxHeight.HasValue ? item.MaxHeight.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); - - long startPositionTicks = item.StartPositionTicks; - - if (item.SubProtocol == MediaStreamProtocol.hls) - { - list.Add(new NameValuePair("StartTimeTicks", string.Empty)); + sb.Append("&BreakOnNonKeyFrames="); + sb.Append(BreakOnNonKeyFrames.ToString(CultureInfo.InvariantCulture)); } else { - list.Add(new NameValuePair("StartTimeTicks", startPositionTicks.ToString(CultureInfo.InvariantCulture))); + if (StartPositionTicks != 0) + { + sb.Append("&StartTimeTicks="); + sb.Append(StartPositionTicks.ToString(CultureInfo.InvariantCulture)); + } } - list.Add(new NameValuePair("PlaySessionId", item.PlaySessionId ?? string.Empty)); - list.Add(new NameValuePair("api_key", accessToken ?? string.Empty)); - - string? liveStreamId = item.MediaSource?.LiveStreamId; - list.Add(new NameValuePair("LiveStreamId", liveStreamId ?? string.Empty)); - - list.Add(new NameValuePair("SubtitleMethod", item.SubtitleStreamIndex.HasValue && item.SubtitleDeliveryMethod != SubtitleDeliveryMethod.External ? item.SubtitleDeliveryMethod.ToString() : string.Empty)); - - if (!item.IsDirectStream) + if (!string.IsNullOrEmpty(PlaySessionId)) { - if (item.RequireNonAnamorphic) - { - list.Add(new NameValuePair("RequireNonAnamorphic", item.RequireNonAnamorphic.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); - } - - list.Add(new NameValuePair("TranscodingMaxAudioChannels", item.TranscodingMaxAudioChannels.HasValue ? item.TranscodingMaxAudioChannels.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); - - if (item.EnableSubtitlesInManifest) - { - list.Add(new NameValuePair("EnableSubtitlesInManifest", item.EnableSubtitlesInManifest.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); - } - - if (item.EnableMpegtsM2TsMode) - { - list.Add(new NameValuePair("EnableMpegtsM2TsMode", item.EnableMpegtsM2TsMode.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); - } - - if (item.EstimateContentLength) - { - list.Add(new NameValuePair("EstimateContentLength", item.EstimateContentLength.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); - } - - if (item.TranscodeSeekInfo != TranscodeSeekInfo.Auto) - { - list.Add(new NameValuePair("TranscodeSeekInfo", item.TranscodeSeekInfo.ToString().ToLowerInvariant())); - } - - if (item.CopyTimestamps) - { - list.Add(new NameValuePair("CopyTimestamps", item.CopyTimestamps.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); - } - - list.Add(new NameValuePair("RequireAvc", item.RequireAvc.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); - - list.Add(new NameValuePair("EnableAudioVbrEncoding", item.EnableAudioVbrEncoding.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); + sb.Append("&PlaySessionId="); + sb.Append(PlaySessionId); } - list.Add(new NameValuePair("Tag", item.MediaSource?.ETag ?? string.Empty)); - - string subtitleCodecs = item.SubtitleCodecs.Count == 0 ? - string.Empty : - string.Join(",", item.SubtitleCodecs); - - list.Add(new NameValuePair("SubtitleCodec", item.SubtitleStreamIndex.HasValue && item.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Embed ? subtitleCodecs : string.Empty)); - - if (item.SubProtocol == MediaStreamProtocol.hls) + if (!string.IsNullOrEmpty(accessToken)) { - list.Add(new NameValuePair("SegmentContainer", item.Container ?? string.Empty)); - - if (item.SegmentLength.HasValue) - { - list.Add(new NameValuePair("SegmentLength", item.SegmentLength.Value.ToString(CultureInfo.InvariantCulture))); - } - - if (item.MinSegments.HasValue) - { - list.Add(new NameValuePair("MinSegments", item.MinSegments.Value.ToString(CultureInfo.InvariantCulture))); - } - - list.Add(new NameValuePair("BreakOnNonKeyFrames", item.BreakOnNonKeyFrames.ToString(CultureInfo.InvariantCulture))); + sb.Append("&ApiKey="); + sb.Append(accessToken); } - foreach (var pair in item.StreamOptions) + var liveStreamId = MediaSource?.LiveStreamId; + if (!string.IsNullOrEmpty(liveStreamId)) { - if (string.IsNullOrEmpty(pair.Value)) + sb.Append("&LiveStreamId="); + sb.Append(liveStreamId); + } + + if (!IsDirectStream) + { + if (RequireNonAnamorphic) { - continue; + sb.Append("&RequireNonAnamorphic="); + sb.Append(RequireNonAnamorphic.ToString(CultureInfo.InvariantCulture)); } - // strip spaces to avoid having to encode h264 profile names - list.Add(new NameValuePair(pair.Key, pair.Value.Replace(" ", string.Empty, StringComparison.Ordinal))); + if (TranscodingMaxAudioChannels.HasValue) + { + sb.Append("&TranscodingMaxAudioChannels="); + sb.Append(TranscodingMaxAudioChannels.Value.ToString(CultureInfo.InvariantCulture)); + } + + if (EnableSubtitlesInManifest) + { + sb.Append("&EnableSubtitlesInManifest="); + sb.Append(EnableSubtitlesInManifest.ToString(CultureInfo.InvariantCulture)); + } + + if (EnableMpegtsM2TsMode) + { + sb.Append("&EnableMpegtsM2TsMode="); + sb.Append(EnableMpegtsM2TsMode.ToString(CultureInfo.InvariantCulture)); + } + + if (EstimateContentLength) + { + sb.Append("&EstimateContentLength="); + sb.Append(EstimateContentLength.ToString(CultureInfo.InvariantCulture)); + } + + if (TranscodeSeekInfo != TranscodeSeekInfo.Auto) + { + sb.Append("&TranscodeSeekInfo="); + sb.Append(TranscodeSeekInfo.ToString()); + } + + if (CopyTimestamps) + { + sb.Append("&CopyTimestamps="); + sb.Append(CopyTimestamps.ToString(CultureInfo.InvariantCulture)); + } + + sb.Append("&RequireAvc="); + sb.Append(RequireAvc.ToString(CultureInfo.InvariantCulture).ToLowerInvariant()); + + sb.Append("&EnableAudioVbrEncoding="); + sb.Append(EnableAudioVbrEncoding.ToString(CultureInfo.InvariantCulture).ToLowerInvariant()); } - if (!item.IsDirectStream) + var etag = MediaSource?.ETag; + if (!string.IsNullOrEmpty(etag)) { - list.Add(new NameValuePair("TranscodeReasons", item.TranscodeReasons.ToString())); + sb.Append("&Tag="); + sb.Append(etag); } - return list; + if (SubtitleStreamIndex.HasValue && SubtitleDeliveryMethod != SubtitleDeliveryMethod.External) + { + sb.Append("&SubtitleMethod="); + sb.Append(SubtitleDeliveryMethod); + } + + if (SubtitleStreamIndex.HasValue && SubtitleDeliveryMethod == SubtitleDeliveryMethod.Embed && SubtitleCodecs.Count != 0) + { + sb.Append("&SubtitleCodec="); + sb.AppendJoin(',', SubtitleCodecs); + } + + foreach (var pair in StreamOptions) + { + // Strip spaces to avoid having to encode h264 profile names + sb.Append('&'); + sb.Append(pair.Key); + sb.Append('='); + sb.Append(pair.Value.Replace(" ", string.Empty, StringComparison.Ordinal)); + } + + var transcodeReasonsValues = TranscodeReasons.GetUniqueFlags().ToArray(); + if (!IsDirectStream && transcodeReasonsValues.Length > 0) + { + sb.Append("&TranscodeReasons="); + sb.AppendJoin(',', transcodeReasonsValues); + } + + if (!string.IsNullOrEmpty(query)) + { + sb.Append(query); + } + + return sb.ToString(); } /// @@ -1189,7 +1264,7 @@ public class StreamInfo if (!string.IsNullOrEmpty(accessToken)) { - info.Url += "?api_key=" + accessToken; + info.Url += "?ApiKey=" + accessToken; } info.IsExternalUrl = false; diff --git a/MediaBrowser.Model/Dlna/TranscodingProfile.cs b/MediaBrowser.Model/Dlna/TranscodingProfile.cs index 5a9fa22ae4..5797d42506 100644 --- a/MediaBrowser.Model/Dlna/TranscodingProfile.cs +++ b/MediaBrowser.Model/Dlna/TranscodingProfile.cs @@ -1,3 +1,4 @@ +using System; using System.ComponentModel; using System.Xml.Serialization; using Jellyfin.Data.Enums; @@ -6,6 +7,7 @@ namespace MediaBrowser.Model.Dlna; /// /// A class for transcoding profile information. +/// Note for client developers: Conditions defined in has higher priority and can override values defined here. /// public class TranscodingProfile { @@ -17,6 +19,33 @@ public class TranscodingProfile Conditions = []; } + /// + /// Initializes a new instance of the class copying the values from another instance. + /// + /// Another instance of to be copied. + public TranscodingProfile(TranscodingProfile other) + { + ArgumentNullException.ThrowIfNull(other); + + Container = other.Container; + Type = other.Type; + VideoCodec = other.VideoCodec; + AudioCodec = other.AudioCodec; + Protocol = other.Protocol; + EstimateContentLength = other.EstimateContentLength; + EnableMpegtsM2TsMode = other.EnableMpegtsM2TsMode; + TranscodeSeekInfo = other.TranscodeSeekInfo; + CopyTimestamps = other.CopyTimestamps; + Context = other.Context; + EnableSubtitlesInManifest = other.EnableSubtitlesInManifest; + MaxAudioChannels = other.MaxAudioChannels; + MinSegments = other.MinSegments; + SegmentLength = other.SegmentLength; + BreakOnNonKeyFrames = other.BreakOnNonKeyFrames; + Conditions = other.Conditions; + EnableAudioVbrEncoding = other.EnableAudioVbrEncoding; + } + /// /// Gets or sets the container. /// diff --git a/MediaBrowser.Model/Drawing/ImageFormatExtensions.cs b/MediaBrowser.Model/Drawing/ImageFormatExtensions.cs index 1c60ba4601..53b9b1fad5 100644 --- a/MediaBrowser.Model/Drawing/ImageFormatExtensions.cs +++ b/MediaBrowser.Model/Drawing/ImageFormatExtensions.cs @@ -17,12 +17,12 @@ public static class ImageFormatExtensions public static string GetMimeType(this ImageFormat format) => format switch { - ImageFormat.Bmp => "image/bmp", + ImageFormat.Bmp => MediaTypeNames.Image.Bmp, ImageFormat.Gif => MediaTypeNames.Image.Gif, ImageFormat.Jpg => MediaTypeNames.Image.Jpeg, - ImageFormat.Png => "image/png", - ImageFormat.Webp => "image/webp", - ImageFormat.Svg => "image/svg+xml", + ImageFormat.Png => MediaTypeNames.Image.Png, + ImageFormat.Webp => MediaTypeNames.Image.Webp, + ImageFormat.Svg => MediaTypeNames.Image.Svg, _ => throw new InvalidEnumArgumentException(nameof(format), (int)format, typeof(ImageFormat)) }; diff --git a/MediaBrowser.Model/Dto/BaseItemDto.cs b/MediaBrowser.Model/Dto/BaseItemDto.cs index 7e8949e1fb..937409111a 100644 --- a/MediaBrowser.Model/Dto/BaseItemDto.cs +++ b/MediaBrowser.Model/Dto/BaseItemDto.cs @@ -3,8 +3,9 @@ using System; using System.Collections.Generic; -using Jellyfin.Data.Entities; +using System.ComponentModel; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Model.Drawing; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Library; @@ -550,7 +551,7 @@ namespace MediaBrowser.Model.Dto /// Gets or sets the parent primary image item identifier. /// /// The parent primary image item identifier. - public string ParentPrimaryImageItemId { get; set; } + public Guid? ParentPrimaryImageItemId { get; set; } /// /// Gets or sets the parent primary image tag. @@ -586,6 +587,7 @@ namespace MediaBrowser.Model.Dto /// Gets or sets the type of the media. /// /// The type of the media. + [DefaultValue(MediaType.Unknown)] public MediaType MediaType { get; set; } /// diff --git a/MediaBrowser.Model/Dto/BaseItemPerson.cs b/MediaBrowser.Model/Dto/BaseItemPerson.cs index d3bcf492d8..80e2cfb08d 100644 --- a/MediaBrowser.Model/Dto/BaseItemPerson.cs +++ b/MediaBrowser.Model/Dto/BaseItemPerson.cs @@ -1,6 +1,7 @@ #nullable disable using System; using System.Collections.Generic; +using System.ComponentModel; using System.Text.Json.Serialization; using Jellyfin.Data.Enums; using MediaBrowser.Model.Entities; @@ -34,6 +35,7 @@ namespace MediaBrowser.Model.Dto /// Gets or sets the type. /// /// The type. + [DefaultValue(PersonKind.Unknown)] public PersonKind Type { get; set; } /// diff --git a/MediaBrowser.Model/Dto/ClientCapabilitiesDto.cs b/MediaBrowser.Model/Dto/ClientCapabilitiesDto.cs index 5963ed270d..d481593cd5 100644 --- a/MediaBrowser.Model/Dto/ClientCapabilitiesDto.cs +++ b/MediaBrowser.Model/Dto/ClientCapabilitiesDto.cs @@ -15,13 +15,13 @@ public class ClientCapabilitiesDto /// /// Gets or sets the list of playable media types. /// - [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + [JsonConverter(typeof(JsonCommaDelimitedCollectionConverterFactory))] public IReadOnlyList PlayableMediaTypes { get; set; } = []; /// /// Gets or sets the list of supported commands. /// - [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + [JsonConverter(typeof(JsonCommaDelimitedCollectionConverterFactory))] public IReadOnlyList SupportedCommands { get; set; } = []; /// diff --git a/MediaBrowser.Model/Dto/DisplayPreferencesDto.cs b/MediaBrowser.Model/Dto/DisplayPreferencesDto.cs index 90163ae91f..54cbe65f68 100644 --- a/MediaBrowser.Model/Dto/DisplayPreferencesDto.cs +++ b/MediaBrowser.Model/Dto/DisplayPreferencesDto.cs @@ -1,5 +1,5 @@ using System.Collections.Generic; -using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; namespace MediaBrowser.Model.Dto { diff --git a/MediaBrowser.Model/Dto/MediaSourceInfo.cs b/MediaBrowser.Model/Dto/MediaSourceInfo.cs index eff2e09da1..75ccdcf276 100644 --- a/MediaBrowser.Model/Dto/MediaSourceInfo.cs +++ b/MediaBrowser.Model/Dto/MediaSourceInfo.cs @@ -1,12 +1,10 @@ #nullable disable #pragma warning disable CS1591 -using System; using System.Collections.Generic; using System.ComponentModel; using System.Text.Json.Serialization; using Jellyfin.Data.Enums; -using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Entities; using MediaBrowser.Model.MediaInfo; using MediaBrowser.Model.Session; @@ -17,15 +15,16 @@ namespace MediaBrowser.Model.Dto { public MediaSourceInfo() { - Formats = Array.Empty(); - MediaStreams = Array.Empty(); - MediaAttachments = Array.Empty(); - RequiredHttpHeaders = new Dictionary(); + Formats = []; + MediaStreams = []; + MediaAttachments = []; + RequiredHttpHeaders = []; SupportsTranscoding = true; SupportsDirectStream = true; SupportsDirectPlay = true; SupportsProbing = true; UseMostCompatibleTranscodingProfile = false; + DefaultAudioIndexSource = AudioIndexSource.None; } public MediaProtocol Protocol { get; set; } @@ -120,6 +119,9 @@ namespace MediaBrowser.Model.Dto [JsonIgnore] public TranscodeReason TranscodeReasons { get; set; } + [JsonIgnore] + public AudioIndexSource DefaultAudioIndexSource { get; set; } + public int? DefaultAudioStreamIndex { get; set; } public int? DefaultSubtitleStreamIndex { get; set; } diff --git a/MediaBrowser.Model/Dto/MetadataEditorInfo.cs b/MediaBrowser.Model/Dto/MetadataEditorInfo.cs index a3035bf612..2f3a5d1179 100644 --- a/MediaBrowser.Model/Dto/MetadataEditorInfo.cs +++ b/MediaBrowser.Model/Dto/MetadataEditorInfo.cs @@ -1,35 +1,55 @@ -#pragma warning disable CS1591 - -using System; using System.Collections.Generic; using Jellyfin.Data.Enums; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; using MediaBrowser.Model.Providers; -namespace MediaBrowser.Model.Dto +namespace MediaBrowser.Model.Dto; + +/// +/// A class representing metadata editor information. +/// +public class MetadataEditorInfo { - public class MetadataEditorInfo + /// + /// Initializes a new instance of the class. + /// + public MetadataEditorInfo() { - public MetadataEditorInfo() - { - ParentalRatingOptions = Array.Empty(); - Countries = Array.Empty(); - Cultures = Array.Empty(); - ExternalIdInfos = Array.Empty(); - ContentTypeOptions = Array.Empty(); - } - - public IReadOnlyList ParentalRatingOptions { get; set; } - - public IReadOnlyList Countries { get; set; } - - public IReadOnlyList Cultures { get; set; } - - public IReadOnlyList ExternalIdInfos { get; set; } - - public CollectionType? ContentType { get; set; } - - public IReadOnlyList ContentTypeOptions { get; set; } + ParentalRatingOptions = []; + Countries = []; + Cultures = []; + ExternalIdInfos = []; + ContentTypeOptions = []; } + + /// + /// Gets or sets the parental rating options. + /// + public IReadOnlyList ParentalRatingOptions { get; set; } + + /// + /// Gets or sets the countries. + /// + public IReadOnlyList Countries { get; set; } + + /// + /// Gets or sets the cultures. + /// + public IReadOnlyList Cultures { get; set; } + + /// + /// Gets or sets the external id infos. + /// + public IReadOnlyList ExternalIdInfos { get; set; } + + /// + /// Gets or sets the content type. + /// + public CollectionType? ContentType { get; set; } + + /// + /// Gets or sets the content type options. + /// + public IReadOnlyList ContentTypeOptions { get; set; } } diff --git a/MediaBrowser.Model/Dto/SessionInfoDto.cs b/MediaBrowser.Model/Dto/SessionInfoDto.cs index 2496c933a2..d727cd8741 100644 --- a/MediaBrowser.Model/Dto/SessionInfoDto.cs +++ b/MediaBrowser.Model/Dto/SessionInfoDto.cs @@ -163,7 +163,7 @@ public class SessionInfoDto /// /// Gets or sets the playlist item id. /// - /// The splaylist item id. + /// The playlist item id. public string? PlaylistItemId { get; set; } /// diff --git a/MediaBrowser.Model/Entities/HardwareAccelerationType.cs b/MediaBrowser.Model/Entities/HardwareAccelerationType.cs index 198a2e00f6..ece18ec3e7 100644 --- a/MediaBrowser.Model/Entities/HardwareAccelerationType.cs +++ b/MediaBrowser.Model/Entities/HardwareAccelerationType.cs @@ -8,7 +8,7 @@ namespace MediaBrowser.Model.Entities; public enum HardwareAccelerationType { /// - /// Software accelleration. + /// Software acceleration. /// none = 0, diff --git a/MediaBrowser.Model/Entities/MediaAttachment.cs b/MediaBrowser.Model/Entities/MediaAttachment.cs index 34e3eabc97..f8f7ad0f9f 100644 --- a/MediaBrowser.Model/Entities/MediaAttachment.cs +++ b/MediaBrowser.Model/Entities/MediaAttachment.cs @@ -1,51 +1,49 @@ -#nullable disable -namespace MediaBrowser.Model.Entities +namespace MediaBrowser.Model.Entities; + +/// +/// Class MediaAttachment. +/// +public class MediaAttachment { /// - /// Class MediaAttachment. + /// Gets or sets the codec. /// - public class MediaAttachment - { - /// - /// Gets or sets the codec. - /// - /// The codec. - public string Codec { get; set; } + /// The codec. + public string? Codec { get; set; } - /// - /// Gets or sets the codec tag. - /// - /// The codec tag. - public string CodecTag { get; set; } + /// + /// Gets or sets the codec tag. + /// + /// The codec tag. + public string? CodecTag { get; set; } - /// - /// Gets or sets the comment. - /// - /// The comment. - public string Comment { get; set; } + /// + /// Gets or sets the comment. + /// + /// The comment. + public string? Comment { get; set; } - /// - /// Gets or sets the index. - /// - /// The index. - public int Index { get; set; } + /// + /// Gets or sets the index. + /// + /// The index. + public int Index { get; set; } - /// - /// Gets or sets the filename. - /// - /// The filename. - public string FileName { get; set; } + /// + /// Gets or sets the filename. + /// + /// The filename. + public string? FileName { get; set; } - /// - /// Gets or sets the MIME type. - /// - /// The MIME type. - public string MimeType { get; set; } + /// + /// Gets or sets the MIME type. + /// + /// The MIME type. + public string? MimeType { get; set; } - /// - /// Gets or sets the delivery URL. - /// - /// The delivery URL. - public string DeliveryUrl { get; set; } - } + /// + /// Gets or sets the delivery URL. + /// + /// The delivery URL. + public string? DeliveryUrl { get; set; } } diff --git a/MediaBrowser.Model/Entities/MediaStream.cs b/MediaBrowser.Model/Entities/MediaStream.cs index 85c1f797b4..5c8f37fcdb 100644 --- a/MediaBrowser.Model/Entities/MediaStream.cs +++ b/MediaBrowser.Model/Entities/MediaStream.cs @@ -2,6 +2,7 @@ #pragma warning disable CS1591 using System; +using System.Collections.Frozen; using System.Collections.Generic; using System.ComponentModel; using System.Globalization; @@ -153,10 +154,13 @@ namespace MediaBrowser.Model.Entities /// The title. public string Title { get; set; } + public bool? Hdr10PlusPresentFlag { get; set; } + /// /// Gets the video range. /// /// The video range. + [DefaultValue(VideoRange.Unknown)] public VideoRange VideoRange { get @@ -171,6 +175,7 @@ namespace MediaBrowser.Model.Entities /// Gets the video range type. /// /// The video range type. + [DefaultValue(VideoRangeType.Unknown)] public VideoRangeType VideoRangeType { get @@ -268,7 +273,7 @@ namespace MediaBrowser.Model.Entities // Do not display the language code in display titles if unset or set to a special code. Show it in all other cases (possibly expanded). if (!string.IsNullOrEmpty(Language) && !_specialCodes.Contains(Language, StringComparison.OrdinalIgnoreCase)) { - // Get full language string i.e. eng -> English. Will not work for some languages which use ISO 639-2/B instead of /T codes. + // Get full language string i.e. eng -> English. string fullLanguage = CultureInfo .GetCultures(CultureTypes.NeutralCultures) .FirstOrDefault(r => r.ThreeLetterISOLanguageName.Equals(Language, StringComparison.OrdinalIgnoreCase)) @@ -371,7 +376,7 @@ namespace MediaBrowser.Model.Entities if (!string.IsNullOrEmpty(Language)) { - // Get full language string i.e. eng -> English. Will not work for some languages which use ISO 639-2/B instead of /T codes. + // Get full language string i.e. eng -> English. string fullLanguage = CultureInfo .GetCultures(CultureTypes.NeutralCultures) .FirstOrDefault(r => r.ThreeLetterISOLanguageName.Equals(Language, StringComparison.OrdinalIgnoreCase)) @@ -383,7 +388,7 @@ namespace MediaBrowser.Model.Entities attributes.Add(string.IsNullOrEmpty(LocalizedUndefined) ? "Und" : LocalizedUndefined); } - if (IsHearingImpaired) + if (IsHearingImpaired == true) { attributes.Add(string.IsNullOrEmpty(LocalizedHearingImpaired) ? "Hearing Impaired" : LocalizedHearingImpaired); } @@ -537,7 +542,7 @@ namespace MediaBrowser.Model.Entities get { // In some cases AverageFrameRate for videos will be read as 1000fps even if it is not. - // This is probably due to a library compatability issue. + // This is probably due to a library compatibility issue. // See https://github.com/jellyfin/jellyfin/pull/12603#discussion_r1748044018 for more info. return AverageFrameRate < 1000 ? AverageFrameRate : RealFrameRate; } @@ -778,8 +783,8 @@ namespace MediaBrowser.Model.Entities var blPresentFlag = BlPresentFlag == 1; var dvBlCompatId = DvBlSignalCompatibilityId; - var isDoViProfile = dvProfile == 5 || dvProfile == 7 || dvProfile == 8 || dvProfile == 10; - var isDoViFlag = rpuPresentFlag && blPresentFlag && (dvBlCompatId == 0 || dvBlCompatId == 1 || dvBlCompatId == 4 || dvBlCompatId == 2 || dvBlCompatId == 6); + var isDoViProfile = dvProfile is 5 or 7 or 8 or 10; + var isDoViFlag = rpuPresentFlag && blPresentFlag && dvBlCompatId is 0 or 1 or 4 or 2 or 6; if ((isDoViProfile && isDoViFlag) || string.Equals(codecTag, "dovi", StringComparison.OrdinalIgnoreCase) @@ -787,7 +792,7 @@ namespace MediaBrowser.Model.Entities || string.Equals(codecTag, "dvhe", StringComparison.OrdinalIgnoreCase) || string.Equals(codecTag, "dav1", StringComparison.OrdinalIgnoreCase)) { - return dvProfile switch + var dvRangeSet = dvProfile switch { 5 => (VideoRange.HDR, VideoRangeType.DOVI), 8 => dvBlCompatId switch @@ -795,32 +800,40 @@ namespace MediaBrowser.Model.Entities 1 => (VideoRange.HDR, VideoRangeType.DOVIWithHDR10), 4 => (VideoRange.HDR, VideoRangeType.DOVIWithHLG), 2 => (VideoRange.SDR, VideoRangeType.DOVIWithSDR), - // While not in Dolby Spec, Profile 8 CCid 6 media are possible to create, and since CCid 6 stems from Bluray (Profile 7 originally) an HDR10 base layer is guaranteed to exist. - 6 => (VideoRange.HDR, VideoRangeType.DOVIWithHDR10), - // There is no other case to handle here as per Dolby Spec. Default case included for completeness and linting purposes - _ => (VideoRange.SDR, VideoRangeType.SDR) + // Out of Dolby Spec files should be marked as invalid + _ => (VideoRange.HDR, VideoRangeType.DOVIInvalid) }, - 7 => (VideoRange.HDR, VideoRangeType.HDR10), + 7 => (VideoRange.HDR, VideoRangeType.DOVIWithEL), 10 => dvBlCompatId switch { 0 => (VideoRange.HDR, VideoRangeType.DOVI), 1 => (VideoRange.HDR, VideoRangeType.DOVIWithHDR10), 2 => (VideoRange.SDR, VideoRangeType.DOVIWithSDR), 4 => (VideoRange.HDR, VideoRangeType.DOVIWithHLG), - // While not in Dolby Spec, Profile 8 CCid 6 media are possible to create, and since CCid 6 stems from Bluray (Profile 7 originally) an HDR10 base layer is guaranteed to exist. - 6 => (VideoRange.HDR, VideoRangeType.DOVIWithHDR10), - // There is no other case to handle here as per Dolby Spec. Default case included for completeness and linting purposes - _ => (VideoRange.SDR, VideoRangeType.SDR) + // Out of Dolby Spec files should be marked as invalid + _ => (VideoRange.HDR, VideoRangeType.DOVIInvalid) }, _ => (VideoRange.SDR, VideoRangeType.SDR) }; + + if (Hdr10PlusPresentFlag == true) + { + return dvRangeSet.Item2 switch + { + VideoRangeType.DOVIWithHDR10 => (VideoRange.HDR, VideoRangeType.DOVIWithHDR10Plus), + VideoRangeType.DOVIWithEL => (VideoRange.HDR, VideoRangeType.DOVIWithELHDR10Plus), + _ => dvRangeSet + }; + } + + return dvRangeSet; } var colorTransfer = ColorTransfer; if (string.Equals(colorTransfer, "smpte2084", StringComparison.OrdinalIgnoreCase)) { - return (VideoRange.HDR, VideoRangeType.HDR10); + return Hdr10PlusPresentFlag == true ? (VideoRange.HDR, VideoRangeType.HDR10Plus) : (VideoRange.HDR, VideoRangeType.HDR10); } else if (string.Equals(colorTransfer, "arib-std-b67", StringComparison.OrdinalIgnoreCase)) { diff --git a/MediaBrowser.Model/Entities/MetadataProvider.cs b/MediaBrowser.Model/Entities/MetadataProvider.cs index bd8db99416..65337b60fd 100644 --- a/MediaBrowser.Model/Entities/MetadataProvider.cs +++ b/MediaBrowser.Model/Entities/MetadataProvider.cs @@ -27,7 +27,7 @@ namespace MediaBrowser.Model.Entities Tvdb = 4, /// - /// The tvcom providerd. + /// The tvcom provider. /// Tvcom = 5, @@ -84,6 +84,11 @@ namespace MediaBrowser.Model.Entities /// /// The TvMaze provider. /// - TvMaze = 19 + TvMaze = 19, + + /// + /// The MusicBrainz recording provider. + /// + MusicBrainzRecording = 20, } } diff --git a/MediaBrowser.Model/Entities/ParentalRating.cs b/MediaBrowser.Model/Entities/ParentalRating.cs index c92640818c..4f11989021 100644 --- a/MediaBrowser.Model/Entities/ParentalRating.cs +++ b/MediaBrowser.Model/Entities/ParentalRating.cs @@ -1,33 +1,40 @@ -#nullable disable -#pragma warning disable CS1591 +namespace MediaBrowser.Model.Entities; -namespace MediaBrowser.Model.Entities +/// +/// Class ParentalRating. +/// +public class ParentalRating { /// - /// Class ParentalRating. + /// Initializes a new instance of the class. /// - public class ParentalRating + /// The name. + /// The score. + public ParentalRating(string name, ParentalRatingScore? score) { - public ParentalRating() - { - } - - public ParentalRating(string name, int? value) - { - Name = name; - Value = value; - } - - /// - /// Gets or sets the name. - /// - /// The name. - public string Name { get; set; } - - /// - /// Gets or sets the value. - /// - /// The value. - public int? Value { get; set; } + Name = name; + Value = score?.Score; + RatingScore = score; } + + /// + /// Gets or sets the name. + /// + /// The name. + public string Name { get; set; } + + /// + /// Gets or sets the value. + /// + /// The value. + /// + /// Deprecated. + /// + public int? Value { get; set; } + + /// + /// Gets or sets the rating score. + /// + /// The rating score. + public ParentalRatingScore? RatingScore { get; set; } } diff --git a/MediaBrowser.Model/Entities/ParentalRatingEntry.cs b/MediaBrowser.Model/Entities/ParentalRatingEntry.cs new file mode 100644 index 0000000000..69be74ac0a --- /dev/null +++ b/MediaBrowser.Model/Entities/ParentalRatingEntry.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace MediaBrowser.Model.Entities; + +/// +/// A class representing an parental rating entry. +/// +public class ParentalRatingEntry +{ + /// + /// Gets or sets the rating strings. + /// + [JsonPropertyName("ratingStrings")] + public required IReadOnlyList RatingStrings { get; set; } + + /// + /// Gets or sets the score. + /// + [JsonPropertyName("ratingScore")] + public required ParentalRatingScore RatingScore { get; set; } +} diff --git a/MediaBrowser.Model/Entities/ParentalRatingScore.cs b/MediaBrowser.Model/Entities/ParentalRatingScore.cs new file mode 100644 index 0000000000..b9bb99685f --- /dev/null +++ b/MediaBrowser.Model/Entities/ParentalRatingScore.cs @@ -0,0 +1,32 @@ +using System.Text.Json.Serialization; + +namespace MediaBrowser.Model.Entities; + +/// +/// A class representing an parental rating score. +/// +public class ParentalRatingScore +{ + /// + /// Initializes a new instance of the class. + /// + /// The score. + /// The sub score. + public ParentalRatingScore(int score, int? subScore) + { + Score = score; + SubScore = subScore; + } + + /// + /// Gets or sets the score. + /// + [JsonPropertyName("score")] + public int Score { get; set; } + + /// + /// Gets or sets the sub score. + /// + [JsonPropertyName("subScore")] + public int? SubScore { get; set; } +} diff --git a/MediaBrowser.Model/Entities/ParentalRatingSystem.cs b/MediaBrowser.Model/Entities/ParentalRatingSystem.cs new file mode 100644 index 0000000000..b452f29018 --- /dev/null +++ b/MediaBrowser.Model/Entities/ParentalRatingSystem.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace MediaBrowser.Model.Entities; + +/// +/// A class representing a parental rating system. +/// +public class ParentalRatingSystem +{ + /// + /// Gets or sets the country code. + /// + [JsonPropertyName("countryCode")] + public required string CountryCode { get; set; } + + /// + /// Gets or sets a value indicating whether sub scores are supported. + /// + [JsonPropertyName("supportsSubScores")] + public bool SupportsSubScores { get; set; } + + /// + /// Gets or sets the ratings. + /// + [JsonPropertyName("ratings")] + public IReadOnlyList? Ratings { get; set; } +} diff --git a/MediaBrowser.Model/Entities/ProviderIdsExtensions.cs b/MediaBrowser.Model/Entities/ProviderIdsExtensions.cs index 479ec7712d..385a86d31c 100644 --- a/MediaBrowser.Model/Entities/ProviderIdsExtensions.cs +++ b/MediaBrowser.Model/Entities/ProviderIdsExtensions.cs @@ -11,7 +11,7 @@ namespace MediaBrowser.Model.Entities; public static class ProviderIdsExtensions { /// - /// Case insensitive dictionary of string representation. + /// Case-insensitive dictionary of string representation. /// private static readonly Dictionary _metadataProviderEnumDictionary = Enum.GetValues() @@ -107,7 +107,7 @@ public static class ProviderIdsExtensions /// The instance. /// The name, this should not contain a '=' character. /// The value. - /// Due to how deserialization from the database works the name can not contain '='. + /// Due to how deserialization from the database works the name cannot contain '='. /// true if the provider id got set successfully; otherwise, false. public static bool TrySetProviderId(this IHasProviderIds instance, string? name, string? value) { @@ -153,7 +153,7 @@ public static class ProviderIdsExtensions /// The instance. /// The name, this should not contain a '=' character. /// The value. - /// Due to how deserialization from the database works the name can not contain '='. + /// Due to how deserialization from the database works the name cannot contain '='. public static void SetProviderId(this IHasProviderIds instance, string name, string value) { ArgumentNullException.ThrowIfNull(instance); diff --git a/MediaBrowser.Model/Extensions/ContainerHelper.cs b/MediaBrowser.Model/Extensions/ContainerHelper.cs index c86328ba68..848cc2f621 100644 --- a/MediaBrowser.Model/Extensions/ContainerHelper.cs +++ b/MediaBrowser.Model/Extensions/ContainerHelper.cs @@ -14,13 +14,14 @@ public static class ContainerHelper /// in . /// /// The comma-delimited string being searched. - /// If the parameter begins with the - character, the operation is reversed. + /// If the parameter begins with the - character, the operation is reversed. + /// If the parameter is empty or null, all containers in will be accepted. /// The comma-delimited string being matched. /// The result of the operation. public static bool ContainsContainer(string? profileContainers, string? inputContainer) { var isNegativeList = false; - if (profileContainers != null && profileContainers.StartsWith('-')) + if (profileContainers is not null && profileContainers.StartsWith('-')) { isNegativeList = true; profileContainers = profileContainers[1..]; @@ -34,13 +35,14 @@ public static class ContainerHelper /// in . /// /// The comma-delimited string being searched. - /// If the parameter begins with the - character, the operation is reversed. + /// If the parameter begins with the - character, the operation is reversed. + /// If the parameter is empty or null, all containers in will be accepted. /// The comma-delimited string being matched. /// The result of the operation. public static bool ContainsContainer(string? profileContainers, ReadOnlySpan inputContainer) { var isNegativeList = false; - if (profileContainers != null && profileContainers.StartsWith('-')) + if (profileContainers is not null && profileContainers.StartsWith('-')) { isNegativeList = true; profileContainers = profileContainers[1..]; @@ -53,7 +55,8 @@ public static class ContainerHelper /// Compares two containers, returning if an item in /// does not exist in . /// - /// The comma-delimited string being searched. + /// The comma-delimited string being searched. + /// If the parameter is empty or null, all containers in will be accepted. /// The boolean result to return if a match is not found. /// The comma-delimited string being matched. /// The result of the operation. @@ -71,7 +74,8 @@ public static class ContainerHelper /// Compares two containers, returning if an item in /// does not exist in . /// - /// The comma-delimited string being searched. + /// The comma-delimited string being searched. + /// If the parameter is empty or null, all containers in will be accepted. /// The boolean result to return if a match is not found. /// The comma-delimited string being matched. /// The result of the operation. @@ -106,7 +110,8 @@ public static class ContainerHelper /// Compares two containers, returning if an item in /// does not exist in . /// - /// The profile containers being matched searched. + /// The profile containers being matched searched. + /// If the parameter is empty or null, all containers in will be accepted. /// The boolean result to return if a match is not found. /// The comma-delimited string being matched. /// The result of the operation. diff --git a/MediaBrowser.Model/Extensions/LibraryOptionsExtension.cs b/MediaBrowser.Model/Extensions/LibraryOptionsExtension.cs index 4a814f22a3..b088cfb53b 100644 --- a/MediaBrowser.Model/Extensions/LibraryOptionsExtension.cs +++ b/MediaBrowser.Model/Extensions/LibraryOptionsExtension.cs @@ -18,7 +18,7 @@ public static class LibraryOptionsExtension { ArgumentNullException.ThrowIfNull(options); - return options.CustomTagDelimiters.Select(x => + var delimiterList = options.CustomTagDelimiters.Select(x => { var isChar = char.TryParse(x, out var c); if (isChar) @@ -27,6 +27,8 @@ public static class LibraryOptionsExtension } return null; - }).Where(x => x is not null).Select(x => x!.Value).ToArray(); + }).Where(x => x is not null).Select(x => x!.Value).ToList(); + delimiterList.Add('\0'); + return delimiterList.ToArray(); } } diff --git a/MediaBrowser.Model/Globalization/ILocalizationManager.cs b/MediaBrowser.Model/Globalization/ILocalizationManager.cs index 02a29e7faf..f6e65028e4 100644 --- a/MediaBrowser.Model/Globalization/ILocalizationManager.cs +++ b/MediaBrowser.Model/Globalization/ILocalizationManager.cs @@ -1,65 +1,73 @@ using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using MediaBrowser.Model.Entities; -namespace MediaBrowser.Model.Globalization +namespace MediaBrowser.Model.Globalization; + +/// +/// Interface ILocalizationManager. +/// +public interface ILocalizationManager { /// - /// Interface ILocalizationManager. + /// Gets the cultures. /// - public interface ILocalizationManager - { - /// - /// Gets the cultures. - /// - /// . - IEnumerable GetCultures(); + /// . + IEnumerable GetCultures(); - /// - /// Gets the countries. - /// - /// . - IEnumerable GetCountries(); + /// + /// Gets the countries. + /// + /// . + IReadOnlyList GetCountries(); - /// - /// Gets the parental ratings. - /// - /// . - IEnumerable GetParentalRatings(); + /// + /// Gets the parental ratings. + /// + /// . + IReadOnlyList GetParentalRatings(); - /// - /// Gets the rating level. - /// - /// The rating. - /// The optional two letter ISO language string. - /// or null. - int? GetRatingLevel(string rating, string? countryCode = null); + /// + /// Gets the rating level. + /// + /// The rating. + /// The optional two letter ISO language string. + /// or null. + ParentalRatingScore? GetRatingScore(string rating, string? countryCode = null); - /// - /// Gets the localized string. - /// - /// The phrase. - /// The culture. - /// . - string GetLocalizedString(string phrase, string culture); + /// + /// Gets the localized string. + /// + /// The phrase. + /// The culture. + /// . + string GetLocalizedString(string phrase, string culture); - /// - /// Gets the localized string. - /// - /// The phrase. - /// System.String. - string GetLocalizedString(string phrase); + /// + /// Gets the localized string. + /// + /// The phrase. + /// System.String. + string GetLocalizedString(string phrase); - /// - /// Gets the localization options. - /// - /// . - IEnumerable GetLocalizationOptions(); + /// + /// Gets the localization options. + /// + /// . + IEnumerable GetLocalizationOptions(); - /// - /// Returns the correct for the given language. - /// - /// The language. - /// The correct for the given language. - CultureDto? FindLanguageInfo(string language); - } + /// + /// Returns the correct for the given language. + /// + /// The language. + /// The correct for the given language. + CultureDto? FindLanguageInfo(string language); + + /// + /// Returns the language in ISO 639-2/T when the input is ISO 639-2/B. + /// + /// The language in ISO 639-2/B. + /// The language in ISO 639-2/T. + /// Whether the language could be converted. + public bool TryGetISO6392TFromB(string isoB, [NotNullWhen(true)] out string? isoT); } diff --git a/MediaBrowser.Model/IO/IFileSystem.cs b/MediaBrowser.Model/IO/IFileSystem.cs index 2085328ddc..0ed2e30d52 100644 --- a/MediaBrowser.Model/IO/IFileSystem.cs +++ b/MediaBrowser.Model/IO/IFileSystem.cs @@ -145,7 +145,7 @@ namespace MediaBrowser.Model.IO /// Gets the directories. /// /// The path. - /// If set to true also searches in subdirectiories. + /// If set to true also searches in subdirectories. /// All found directories. IEnumerable GetDirectories(string path, bool recursive = false); @@ -153,12 +153,40 @@ namespace MediaBrowser.Model.IO /// Gets the files. /// /// The path in which to search. - /// If set to true also searches in subdirectiories. + /// If set to true also searches in subdirectories. /// All found files. IEnumerable GetFiles(string path, bool recursive = false); + /// + /// Gets the files. + /// + /// The path in which to search. + /// The search string to match against the names of files. This parameter can contain a combination of valid literal path and wildcard (* and ?) characters, but it doesn't support regular expressions. + /// If set to true also searches in subdirectories. + /// All found files. + IEnumerable GetFiles(string path, string searchPattern, bool recursive = false); + + /// + /// Gets the files. + /// + /// The path in which to search. + /// The file extensions to search for. + /// Enable case-sensitive check for extensions. + /// If set to true also searches in subdirectories. + /// All found files. IEnumerable GetFiles(string path, IReadOnlyList? extensions, bool enableCaseSensitiveExtensions, bool recursive); + /// + /// Gets the files. + /// + /// The path in which to search. + /// The search string to match against the names of files. This parameter can contain a combination of valid literal path and wildcard (* and ?) characters, but it doesn't support regular expressions. + /// The file extensions to search for. + /// Enable case-sensitive check for extensions. + /// If set to true also searches in subdirectories. + /// All found files. + IEnumerable GetFiles(string path, string searchPattern, IReadOnlyList? extensions, bool enableCaseSensitiveExtensions, bool recursive); + /// /// Gets the file system entries. /// diff --git a/MediaBrowser.Model/Library/UserViewQuery.cs b/MediaBrowser.Model/Library/UserViewQuery.cs index 643a1f9b13..01d5e3b6ca 100644 --- a/MediaBrowser.Model/Library/UserViewQuery.cs +++ b/MediaBrowser.Model/Library/UserViewQuery.cs @@ -1,8 +1,8 @@ #pragma warning disable CS1591 using System; -using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; namespace MediaBrowser.Model.Library { diff --git a/MediaBrowser.Model/LiveTv/LiveTvChannelQuery.cs b/MediaBrowser.Model/LiveTv/LiveTvChannelQuery.cs index d872572b77..38e2731762 100644 --- a/MediaBrowser.Model/LiveTv/LiveTvChannelQuery.cs +++ b/MediaBrowser.Model/LiveTv/LiveTvChannelQuery.cs @@ -3,6 +3,7 @@ using System; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; namespace MediaBrowser.Model.LiveTv { diff --git a/MediaBrowser.Model/LiveTv/SeriesTimerInfoDto.cs b/MediaBrowser.Model/LiveTv/SeriesTimerInfoDto.cs index b26f5f45fe..1e8add9430 100644 --- a/MediaBrowser.Model/LiveTv/SeriesTimerInfoDto.cs +++ b/MediaBrowser.Model/LiveTv/SeriesTimerInfoDto.cs @@ -83,7 +83,7 @@ namespace MediaBrowser.Model.LiveTv /// Gets or sets the parent primary image item identifier. /// /// The parent primary image item identifier. - public string ParentPrimaryImageItemId { get; set; } + public Guid? ParentPrimaryImageItemId { get; set; } /// /// Gets or sets the parent primary image tag. diff --git a/MediaBrowser.Model/LiveTv/SeriesTimerQuery.cs b/MediaBrowser.Model/LiveTv/SeriesTimerQuery.cs index dae885775c..e93ad81d3a 100644 --- a/MediaBrowser.Model/LiveTv/SeriesTimerQuery.cs +++ b/MediaBrowser.Model/LiveTv/SeriesTimerQuery.cs @@ -1,6 +1,6 @@ #pragma warning disable CS1591 -using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; namespace MediaBrowser.Model.LiveTv { diff --git a/MediaBrowser.Model/LiveTv/TunerHostInfo.cs b/MediaBrowser.Model/LiveTv/TunerHostInfo.cs index a355387b1a..b70333bce4 100644 --- a/MediaBrowser.Model/LiveTv/TunerHostInfo.cs +++ b/MediaBrowser.Model/LiveTv/TunerHostInfo.cs @@ -9,6 +9,7 @@ namespace MediaBrowser.Model.LiveTv { AllowHWTranscoding = true; IgnoreDts = true; + ReadAtNativeFramerate = false; AllowStreamSharing = true; AllowFmp4TranscodingContainer = false; FallbackMaxStreamingBitrate = 30000000; @@ -43,5 +44,7 @@ namespace MediaBrowser.Model.LiveTv public string UserAgent { get; set; } public bool IgnoreDts { get; set; } + + public bool ReadAtNativeFramerate { get; set; } } } diff --git a/MediaBrowser.Model/Lyrics/LyricLine.cs b/MediaBrowser.Model/Lyrics/LyricLine.cs index 64d1f64c2c..788bace69a 100644 --- a/MediaBrowser.Model/Lyrics/LyricLine.cs +++ b/MediaBrowser.Model/Lyrics/LyricLine.cs @@ -1,3 +1,5 @@ +using System.Collections.Generic; + namespace MediaBrowser.Model.Lyrics; /// @@ -10,10 +12,12 @@ public class LyricLine /// /// The lyric text. /// The lyric start time in ticks. - public LyricLine(string text, long? start = null) + /// The time-aligned cues for the song's lyrics. + public LyricLine(string text, long? start = null, IReadOnlyList? cues = null) { Text = text; Start = start; + Cues = cues; } /// @@ -25,4 +29,9 @@ public class LyricLine /// Gets the start time in ticks. /// public long? Start { get; } + + /// + /// Gets the time-aligned cues for the song's lyrics. + /// + public IReadOnlyList? Cues { get; } } diff --git a/MediaBrowser.Model/Lyrics/LyricLineCue.cs b/MediaBrowser.Model/Lyrics/LyricLineCue.cs new file mode 100644 index 0000000000..1172a0231d --- /dev/null +++ b/MediaBrowser.Model/Lyrics/LyricLineCue.cs @@ -0,0 +1,35 @@ +namespace MediaBrowser.Model.Lyrics; + +/// +/// LyricLineCue model, holds information about the timing of words within a LyricLine. +/// +public class LyricLineCue +{ + /// + /// Initializes a new instance of the class. + /// + /// The start of the character index of the lyric. + /// The start of the timestamp the lyric is synced to in ticks. + /// The end of the timestamp the lyric is synced to in ticks. + public LyricLineCue(int position, long start, long? end) + { + Position = position; + Start = start; + End = end; + } + + /// + /// Gets the character index of the lyric. + /// + public int Position { get; } + + /// + /// Gets the timestamp the lyric is synced to in ticks. + /// + public long Start { get; } + + /// + /// Gets the end timestamp the lyric is synced to in ticks. + /// + public long? End { get; } +} diff --git a/MediaBrowser.Model/Lyrics/LyricSearchRequest.cs b/MediaBrowser.Model/Lyrics/LyricSearchRequest.cs index 48c442a55e..67f3d7b42c 100644 --- a/MediaBrowser.Model/Lyrics/LyricSearchRequest.cs +++ b/MediaBrowser.Model/Lyrics/LyricSearchRequest.cs @@ -15,7 +15,12 @@ public class LyricSearchRequest : IHasProviderIds public string? MediaPath { get; set; } /// - /// Gets or sets the artist name. + /// Gets or sets the album artist names. + /// + public IReadOnlyList? AlbumArtistsNames { get; set; } + + /// + /// Gets or sets the artist names. /// public IReadOnlyList? ArtistNames { get; set; } diff --git a/MediaBrowser.Model/MediaBrowser.Model.csproj b/MediaBrowser.Model/MediaBrowser.Model.csproj index 9489fe1905..e9dab6bc8a 100644 --- a/MediaBrowser.Model/MediaBrowser.Model.csproj +++ b/MediaBrowser.Model/MediaBrowser.Model.csproj @@ -8,13 +8,13 @@ Jellyfin Contributors Jellyfin.Model - 10.10.0 + 10.11.0 https://github.com/jellyfin/jellyfin GPL-3.0-only - net8.0 + net9.0 false true true @@ -35,7 +35,7 @@ - + diff --git a/MediaBrowser.Model/MediaInfo/AudioIndexSource.cs b/MediaBrowser.Model/MediaInfo/AudioIndexSource.cs new file mode 100644 index 0000000000..810087b926 --- /dev/null +++ b/MediaBrowser.Model/MediaInfo/AudioIndexSource.cs @@ -0,0 +1,30 @@ +using System; + +namespace MediaBrowser.Model.MediaInfo; + +/// +/// How is the audio index determined. +/// +[Flags] +public enum AudioIndexSource +{ + /// + /// The default index when no preference is specified. + /// + None = 0, + + /// + /// The index is calculated whether the track is marked as default or not. + /// + Default = 1 << 0, + + /// + /// The index is calculated whether the track is in preferred language or not. + /// + Language = 1 << 1, + + /// + /// The index is specified by the user. + /// + User = 1 << 2 +} diff --git a/MediaBrowser.Model/MediaSegments/MediaSegmentDto.cs b/MediaBrowser.Model/MediaSegments/MediaSegmentDto.cs index a0433fee18..d9129c3957 100644 --- a/MediaBrowser.Model/MediaSegments/MediaSegmentDto.cs +++ b/MediaBrowser.Model/MediaSegments/MediaSegmentDto.cs @@ -1,5 +1,6 @@ using System; -using Jellyfin.Data.Enums; +using System.ComponentModel; +using Jellyfin.Database.Implementations.Enums; namespace MediaBrowser.Model.MediaSegments; @@ -21,6 +22,7 @@ public class MediaSegmentDto /// /// Gets or sets the type of content this segment defines. /// + [DefaultValue(MediaSegmentType.Unknown)] public MediaSegmentType Type { get; set; } /// diff --git a/MediaBrowser.Model/Net/MimeTypes.cs b/MediaBrowser.Model/Net/MimeTypes.cs index 5d65b0f9ba..de087d0e77 100644 --- a/MediaBrowser.Model/Net/MimeTypes.cs +++ b/MediaBrowser.Model/Net/MimeTypes.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; +using System.Net.Mime; using Jellyfin.Extensions; namespace MediaBrowser.Model.Net @@ -125,6 +126,7 @@ namespace MediaBrowser.Model.Net new("audio/vorbis", ".vorbis"), new("audio/x-ape", ".ape"), new("audio/xsp", ".xsp"), + new("audio/x-aac", ".aac"), new("audio/x-wavpack", ".wv"), // Type image @@ -143,7 +145,7 @@ namespace MediaBrowser.Model.Net new("video/x-matroska", ".mkv"), }.ToFrozenDictionary(pair => pair.Key, pair => pair.Value, StringComparer.OrdinalIgnoreCase); - public static string GetMimeType(string path) => GetMimeType(path, "application/octet-stream"); + public static string GetMimeType(string path) => GetMimeType(path, MediaTypeNames.Application.Octet); /// /// Gets the type of the MIME. diff --git a/MediaBrowser.Model/Playlists/PlaylistCreationRequest.cs b/MediaBrowser.Model/Playlists/PlaylistCreationRequest.cs index ec54b1afd3..98f7c6ce12 100644 --- a/MediaBrowser.Model/Playlists/PlaylistCreationRequest.cs +++ b/MediaBrowser.Model/Playlists/PlaylistCreationRequest.cs @@ -38,5 +38,5 @@ public class PlaylistCreationRequest /// /// Gets or sets a value indicating whether the playlist is public. /// - public bool? Public { get; set; } = true; + public bool? Public { get; set; } = false; } diff --git a/MediaBrowser.Model/Plugins/PluginStatus.cs b/MediaBrowser.Model/Plugins/PluginStatus.cs index bd420d7b4e..9c7a8f0c2c 100644 --- a/MediaBrowser.Model/Plugins/PluginStatus.cs +++ b/MediaBrowser.Model/Plugins/PluginStatus.cs @@ -34,7 +34,12 @@ namespace MediaBrowser.Model.Plugins Malfunctioned = -3, /// - /// This plugin has been superceded by another version. + /// This plugin has been superseded by another version. + /// + Superseded = -4, + + /// + /// [DEPRECATED] See Superseded. /// Superceded = -4, diff --git a/MediaBrowser.Model/Providers/ExternalIdInfo.cs b/MediaBrowser.Model/Providers/ExternalIdInfo.cs index 1f5163aa8e..e7a3099243 100644 --- a/MediaBrowser.Model/Providers/ExternalIdInfo.cs +++ b/MediaBrowser.Model/Providers/ExternalIdInfo.cs @@ -1,5 +1,3 @@ -using System; - namespace MediaBrowser.Model.Providers { /// @@ -13,15 +11,11 @@ namespace MediaBrowser.Model.Providers /// Name of the external id provider (IE: IMDB, MusicBrainz, etc). /// Key for this id. This key should be unique across all providers. /// Specific media type for this id. - /// URL format string. - public ExternalIdInfo(string name, string key, ExternalIdMediaType? type, string? urlFormatString) + public ExternalIdInfo(string name, string key, ExternalIdMediaType? type) { Name = name; Key = key; Type = type; -#pragma warning disable CS0618 // Type or member is obsolete - Remove 10.11 - UrlFormatString = urlFormatString; -#pragma warning restore CS0618 // Type or member is obsolete } /// @@ -46,11 +40,5 @@ namespace MediaBrowser.Model.Providers /// This can be used along with the to localize the external id on the client. /// public ExternalIdMediaType? Type { get; set; } - - /// - /// Gets or sets the URL format string. - /// - [Obsolete("Obsolete in 10.10, to be removed in 10.11")] - public string? UrlFormatString { get; set; } } } diff --git a/MediaBrowser.Model/Providers/ExternalIdMediaType.cs b/MediaBrowser.Model/Providers/ExternalIdMediaType.cs index ef518369cc..71a131bb80 100644 --- a/MediaBrowser.Model/Providers/ExternalIdMediaType.cs +++ b/MediaBrowser.Model/Providers/ExternalIdMediaType.cs @@ -71,6 +71,11 @@ namespace MediaBrowser.Model.Providers /// /// A book. /// - Book = 13 + Book = 13, + + /// + /// A music recording. + /// + Recording = 14 } } diff --git a/MediaBrowser.Model/Querying/ItemFields.cs b/MediaBrowser.Model/Querying/ItemFields.cs index 49d7c0bcb0..ffecd392f2 100644 --- a/MediaBrowser.Model/Querying/ItemFields.cs +++ b/MediaBrowser.Model/Querying/ItemFields.cs @@ -1,7 +1,3 @@ -#pragma warning disable CS1591 - -using System; - namespace MediaBrowser.Model.Querying { /// @@ -39,6 +35,9 @@ namespace MediaBrowser.Model.Querying /// Trickplay, + /// + /// The child count. + /// ChildCount, /// @@ -81,11 +80,6 @@ namespace MediaBrowser.Model.Querying /// Genres, - /// - /// The home page URL. - /// - HomePageUrl, - /// /// The item counts. /// @@ -101,6 +95,9 @@ namespace MediaBrowser.Model.Querying /// MediaSources, + /// + /// The original title. + /// OriginalTitle, /// @@ -123,6 +120,9 @@ namespace MediaBrowser.Model.Querying /// People, + /// + /// Value indicating whether playback access is granted. + /// PlayAccess, /// @@ -140,6 +140,9 @@ namespace MediaBrowser.Model.Querying /// PrimaryImageAspectRatio, + /// + /// The recursive item count. + /// RecursiveItemCount, /// @@ -147,14 +150,6 @@ namespace MediaBrowser.Model.Querying /// Settings, - /// - /// The screenshot image tags. - /// - [Obsolete("Screenshot image type is no longer used.")] - ScreenshotImageTags, - - SeriesPrimaryImage, - /// /// The series studio. /// @@ -201,26 +196,58 @@ namespace MediaBrowser.Model.Querying SeasonUserData, /// - /// The service name. + /// The last time metadata was refreshed. /// - ServiceName, - ThemeSongIds, - ThemeVideoIds, - ExternalEtag, - PresentationUniqueKey, - InheritedParentalRatingValue, - ExternalSeriesId, - SeriesPresentationUniqueKey, DateLastRefreshed, + + /// + /// The last time metadata was saved. + /// DateLastSaved, + + /// + /// The refresh state. + /// RefreshState, + + /// + /// The channel image. + /// ChannelImage, + + /// + /// Value indicating whether media source display is enabled. + /// EnableMediaSourceDisplay, + + /// + /// The width. + /// Width, + + /// + /// The height. + /// Height, + + /// + /// The external Ids. + /// ExtraIds, + + /// + /// The local trailer count. + /// LocalTrailerCount, + + /// + /// Value indicating whether the item is HD. + /// IsHD, + + /// + /// The special feature count. + /// SpecialFeatureCount } } diff --git a/MediaBrowser.Model/Querying/LatestItemsQuery.cs b/MediaBrowser.Model/Querying/LatestItemsQuery.cs index 251ff5d681..40dc813978 100644 --- a/MediaBrowser.Model/Querying/LatestItemsQuery.cs +++ b/MediaBrowser.Model/Querying/LatestItemsQuery.cs @@ -2,8 +2,8 @@ #pragma warning disable CS1591 using System; -using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Model.Entities; namespace MediaBrowser.Model.Querying diff --git a/MediaBrowser.Model/Querying/NextUpQuery.cs b/MediaBrowser.Model/Querying/NextUpQuery.cs index 8dece28a09..a2a3a9d1bb 100644 --- a/MediaBrowser.Model/Querying/NextUpQuery.cs +++ b/MediaBrowser.Model/Querying/NextUpQuery.cs @@ -1,79 +1,72 @@ #pragma warning disable CS1591 using System; -using Jellyfin.Data.Entities; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Model.Entities; -namespace MediaBrowser.Model.Querying +namespace MediaBrowser.Model.Querying; + +public class NextUpQuery { - public class NextUpQuery + public NextUpQuery() { - public NextUpQuery() - { - EnableImageTypes = Array.Empty(); - EnableTotalRecordCount = true; - DisableFirstEpisode = false; - NextUpDateCutoff = DateTime.MinValue; - EnableResumable = false; - EnableRewatching = false; - } - - /// - /// Gets or sets the user. - /// - /// The user. - public required User User { get; set; } - - /// - /// Gets or sets the parent identifier. - /// - /// The parent identifier. - public Guid? ParentId { get; set; } - - /// - /// Gets or sets the series id. - /// - /// The series id. - public Guid? SeriesId { get; set; } - - /// - /// Gets or sets the start index. Use for paging. - /// - /// The start index. - public int? StartIndex { get; set; } - - /// - /// Gets or sets the maximum number of items to return. - /// - /// The limit. - public int? Limit { get; set; } - - /// - /// Gets or sets the enable image types. - /// - /// The enable image types. - public ImageType[] EnableImageTypes { get; set; } - - public bool EnableTotalRecordCount { get; set; } - - /// - /// Gets or sets a value indicating whether do disable sending first episode as next up. - /// - public bool DisableFirstEpisode { get; set; } - - /// - /// Gets or sets a value indicating the oldest date for a show to appear in Next Up. - /// - public DateTime NextUpDateCutoff { get; set; } - - /// - /// Gets or sets a value indicating whether to include resumable episodes as next up. - /// - public bool EnableResumable { get; set; } - - /// - /// Gets or sets a value indicating whether getting rewatching next up list. - /// - public bool EnableRewatching { get; set; } + EnableImageTypes = Array.Empty(); + EnableTotalRecordCount = true; + NextUpDateCutoff = DateTime.MinValue; + EnableResumable = false; + EnableRewatching = false; } + + /// + /// Gets or sets the user. + /// + /// The user. + public required User User { get; set; } + + /// + /// Gets or sets the parent identifier. + /// + /// The parent identifier. + public Guid? ParentId { get; set; } + + /// + /// Gets or sets the series id. + /// + /// The series id. + public Guid? SeriesId { get; set; } + + /// + /// Gets or sets the start index. Use for paging. + /// + /// The start index. + public int? StartIndex { get; set; } + + /// + /// Gets or sets the maximum number of items to return. + /// + /// The limit. + public int? Limit { get; set; } + + /// + /// Gets or sets the enable image types. + /// + /// The enable image types. + public ImageType[] EnableImageTypes { get; set; } + + public bool EnableTotalRecordCount { get; set; } + + /// + /// Gets or sets a value indicating the oldest date for a show to appear in Next Up. + /// + public DateTime NextUpDateCutoff { get; set; } + + /// + /// Gets or sets a value indicating whether to include resumable episodes as next up. + /// + public bool EnableResumable { get; set; } + + /// + /// Gets or sets a value indicating whether getting rewatching next up list. + /// + public bool EnableRewatching { get; set; } } diff --git a/MediaBrowser.Model/Search/SearchHint.cs b/MediaBrowser.Model/Search/SearchHint.cs index 2e2979fcf0..a18a813cc5 100644 --- a/MediaBrowser.Model/Search/SearchHint.cs +++ b/MediaBrowser.Model/Search/SearchHint.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.ComponentModel; using Jellyfin.Data.Enums; namespace MediaBrowser.Model.Search @@ -115,6 +116,7 @@ namespace MediaBrowser.Model.Search /// Gets or sets the type of the media. /// /// The type of the media. + [DefaultValue(MediaType.Unknown)] public MediaType MediaType { get; set; } /// diff --git a/MediaBrowser.Model/Session/TranscodeReason.cs b/MediaBrowser.Model/Session/TranscodeReason.cs index 39c5ac8fa4..902bab9a6e 100644 --- a/MediaBrowser.Model/Session/TranscodeReason.cs +++ b/MediaBrowser.Model/Session/TranscodeReason.cs @@ -14,6 +14,7 @@ namespace MediaBrowser.Model.Session SubtitleCodecNotSupported = 1 << 3, AudioIsExternal = 1 << 4, SecondaryAudioNotSupported = 1 << 5, + StreamCountExceedsLimit = 1 << 26, // Video Constraints VideoProfileNotSupported = 1 << 6, diff --git a/MediaBrowser.Model/Session/TranscodingInfo.cs b/MediaBrowser.Model/Session/TranscodingInfo.cs index ae25267aca..11e83844b0 100644 --- a/MediaBrowser.Model/Session/TranscodingInfo.cs +++ b/MediaBrowser.Model/Session/TranscodingInfo.cs @@ -5,7 +5,7 @@ using MediaBrowser.Model.Entities; namespace MediaBrowser.Model.Session; /// -/// Class holding information on a runnning transcode. +/// Class holding information on a running transcode. /// public class TranscodingInfo { diff --git a/MediaBrowser.Model/SyncPlay/GroupUpdate.cs b/MediaBrowser.Model/SyncPlay/GroupUpdate.cs index ec67d7ea87..7944434999 100644 --- a/MediaBrowser.Model/SyncPlay/GroupUpdate.cs +++ b/MediaBrowser.Model/SyncPlay/GroupUpdate.cs @@ -5,15 +5,18 @@ namespace MediaBrowser.Model.SyncPlay; /// /// Group update without data. /// -public abstract class GroupUpdate +/// The type of the update data. +public abstract class GroupUpdate { /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The group identifier. - protected GroupUpdate(Guid groupId) + /// The update data. + protected GroupUpdate(Guid groupId, T data) { GroupId = groupId; + Data = data; } /// @@ -22,9 +25,15 @@ public abstract class GroupUpdate /// The group identifier. public Guid GroupId { get; } + /// + /// Gets the update data. + /// + /// The update data. + public T Data { get; } + /// /// Gets the update type. /// /// The update type. - public GroupUpdateType Type { get; init; } + public abstract GroupUpdateType Type { get; } } diff --git a/MediaBrowser.Model/SyncPlay/GroupUpdateOfT.cs b/MediaBrowser.Model/SyncPlay/GroupUpdateOfT.cs deleted file mode 100644 index 25cd444611..0000000000 --- a/MediaBrowser.Model/SyncPlay/GroupUpdateOfT.cs +++ /dev/null @@ -1,31 +0,0 @@ -#pragma warning disable SA1649 - -using System; - -namespace MediaBrowser.Model.SyncPlay; - -/// -/// Class GroupUpdate. -/// -/// The type of the data of the message. -public class GroupUpdate : GroupUpdate -{ - /// - /// Initializes a new instance of the class. - /// - /// The group identifier. - /// The update type. - /// The update data. - public GroupUpdate(Guid groupId, GroupUpdateType type, T data) - : base(groupId) - { - Data = data; - Type = type; - } - - /// - /// Gets the update data. - /// - /// The update data. - public T Data { get; } -} diff --git a/MediaBrowser.Model/SyncPlay/GroupUpdateType.cs b/MediaBrowser.Model/SyncPlay/GroupUpdateType.cs index 907d1defe0..e792229a47 100644 --- a/MediaBrowser.Model/SyncPlay/GroupUpdateType.cs +++ b/MediaBrowser.Model/SyncPlay/GroupUpdateType.cs @@ -45,16 +45,6 @@ namespace MediaBrowser.Model.SyncPlay /// GroupDoesNotExist, - /// - /// The create-group-denied error. Sent when a user tries to create a group without required permissions. - /// - CreateGroupDenied, - - /// - /// The join-group-denied error. Sent when a user tries to join a group without required permissions. - /// - JoinGroupDenied, - /// /// The library-access-denied error. Sent when a user tries to join a group without required access to the library. /// diff --git a/MediaBrowser.Model/SyncPlay/SyncPlayGroupDoesNotExistUpdate.cs b/MediaBrowser.Model/SyncPlay/SyncPlayGroupDoesNotExistUpdate.cs new file mode 100644 index 0000000000..7e2d10c8b8 --- /dev/null +++ b/MediaBrowser.Model/SyncPlay/SyncPlayGroupDoesNotExistUpdate.cs @@ -0,0 +1,21 @@ +using System; +using System.ComponentModel; + +namespace MediaBrowser.Model.SyncPlay; + +/// +public class SyncPlayGroupDoesNotExistUpdate : GroupUpdate +{ + /// + /// Initializes a new instance of the class. + /// + /// The groupId. + /// The data. + public SyncPlayGroupDoesNotExistUpdate(Guid groupId, string data) : base(groupId, data) + { + } + + /// + [DefaultValue(GroupUpdateType.GroupDoesNotExist)] + public override GroupUpdateType Type => GroupUpdateType.GroupDoesNotExist; +} diff --git a/MediaBrowser.Model/SyncPlay/SyncPlayGroupJoinedUpdate.cs b/MediaBrowser.Model/SyncPlay/SyncPlayGroupJoinedUpdate.cs new file mode 100644 index 0000000000..bfb49152a3 --- /dev/null +++ b/MediaBrowser.Model/SyncPlay/SyncPlayGroupJoinedUpdate.cs @@ -0,0 +1,21 @@ +using System; +using System.ComponentModel; + +namespace MediaBrowser.Model.SyncPlay; + +/// +public class SyncPlayGroupJoinedUpdate : GroupUpdate +{ + /// + /// Initializes a new instance of the class. + /// + /// The groupId. + /// The data. + public SyncPlayGroupJoinedUpdate(Guid groupId, GroupInfoDto data) : base(groupId, data) + { + } + + /// + [DefaultValue(GroupUpdateType.GroupJoined)] + public override GroupUpdateType Type => GroupUpdateType.GroupJoined; +} diff --git a/MediaBrowser.Model/SyncPlay/SyncPlayGroupLeftUpdate.cs b/MediaBrowser.Model/SyncPlay/SyncPlayGroupLeftUpdate.cs new file mode 100644 index 0000000000..5ff60c5c27 --- /dev/null +++ b/MediaBrowser.Model/SyncPlay/SyncPlayGroupLeftUpdate.cs @@ -0,0 +1,21 @@ +using System; +using System.ComponentModel; + +namespace MediaBrowser.Model.SyncPlay; + +/// +public class SyncPlayGroupLeftUpdate : GroupUpdate +{ + /// + /// Initializes a new instance of the class. + /// + /// The groupId. + /// The data. + public SyncPlayGroupLeftUpdate(Guid groupId, string data) : base(groupId, data) + { + } + + /// + [DefaultValue(GroupUpdateType.GroupLeft)] + public override GroupUpdateType Type => GroupUpdateType.GroupLeft; +} diff --git a/MediaBrowser.Model/SyncPlay/SyncPlayLibraryAccessDeniedUpdate.cs b/MediaBrowser.Model/SyncPlay/SyncPlayLibraryAccessDeniedUpdate.cs new file mode 100644 index 0000000000..0d9a722f78 --- /dev/null +++ b/MediaBrowser.Model/SyncPlay/SyncPlayLibraryAccessDeniedUpdate.cs @@ -0,0 +1,21 @@ +using System; +using System.ComponentModel; + +namespace MediaBrowser.Model.SyncPlay; + +/// +public class SyncPlayLibraryAccessDeniedUpdate : GroupUpdate +{ + /// + /// Initializes a new instance of the class. + /// + /// The groupId. + /// The data. + public SyncPlayLibraryAccessDeniedUpdate(Guid groupId, string data) : base(groupId, data) + { + } + + /// + [DefaultValue(GroupUpdateType.LibraryAccessDenied)] + public override GroupUpdateType Type => GroupUpdateType.LibraryAccessDenied; +} diff --git a/MediaBrowser.Model/SyncPlay/SyncPlayNotInGroupUpdate.cs b/MediaBrowser.Model/SyncPlay/SyncPlayNotInGroupUpdate.cs new file mode 100644 index 0000000000..a3b610f619 --- /dev/null +++ b/MediaBrowser.Model/SyncPlay/SyncPlayNotInGroupUpdate.cs @@ -0,0 +1,21 @@ +using System; +using System.ComponentModel; + +namespace MediaBrowser.Model.SyncPlay; + +/// +public class SyncPlayNotInGroupUpdate : GroupUpdate +{ + /// + /// Initializes a new instance of the class. + /// + /// The groupId. + /// The data. + public SyncPlayNotInGroupUpdate(Guid groupId, string data) : base(groupId, data) + { + } + + /// + [DefaultValue(GroupUpdateType.NotInGroup)] + public override GroupUpdateType Type => GroupUpdateType.NotInGroup; +} diff --git a/MediaBrowser.Model/SyncPlay/SyncPlayPlayQueueUpdate.cs b/MediaBrowser.Model/SyncPlay/SyncPlayPlayQueueUpdate.cs new file mode 100644 index 0000000000..83d9bd40bc --- /dev/null +++ b/MediaBrowser.Model/SyncPlay/SyncPlayPlayQueueUpdate.cs @@ -0,0 +1,21 @@ +using System; +using System.ComponentModel; + +namespace MediaBrowser.Model.SyncPlay; + +/// +public class SyncPlayPlayQueueUpdate : GroupUpdate +{ + /// + /// Initializes a new instance of the class. + /// + /// The groupId. + /// The data. + public SyncPlayPlayQueueUpdate(Guid groupId, PlayQueueUpdate data) : base(groupId, data) + { + } + + /// + [DefaultValue(GroupUpdateType.PlayQueue)] + public override GroupUpdateType Type => GroupUpdateType.PlayQueue; +} diff --git a/MediaBrowser.Model/SyncPlay/SyncPlayStateUpdate.cs b/MediaBrowser.Model/SyncPlay/SyncPlayStateUpdate.cs new file mode 100644 index 0000000000..744ca46a0b --- /dev/null +++ b/MediaBrowser.Model/SyncPlay/SyncPlayStateUpdate.cs @@ -0,0 +1,21 @@ +using System; +using System.ComponentModel; + +namespace MediaBrowser.Model.SyncPlay; + +/// +public class SyncPlayStateUpdate : GroupUpdate +{ + /// + /// Initializes a new instance of the class. + /// + /// The groupId. + /// The data. + public SyncPlayStateUpdate(Guid groupId, GroupStateUpdate data) : base(groupId, data) + { + } + + /// + [DefaultValue(GroupUpdateType.StateUpdate)] + public override GroupUpdateType Type => GroupUpdateType.StateUpdate; +} diff --git a/MediaBrowser.Model/SyncPlay/SyncPlayUserJoinedUpdate.cs b/MediaBrowser.Model/SyncPlay/SyncPlayUserJoinedUpdate.cs new file mode 100644 index 0000000000..e8c6b4df41 --- /dev/null +++ b/MediaBrowser.Model/SyncPlay/SyncPlayUserJoinedUpdate.cs @@ -0,0 +1,21 @@ +using System; +using System.ComponentModel; + +namespace MediaBrowser.Model.SyncPlay; + +/// +public class SyncPlayUserJoinedUpdate : GroupUpdate +{ + /// + /// Initializes a new instance of the class. + /// + /// The groupId. + /// The data. + public SyncPlayUserJoinedUpdate(Guid groupId, string data) : base(groupId, data) + { + } + + /// + [DefaultValue(GroupUpdateType.UserJoined)] + public override GroupUpdateType Type => GroupUpdateType.UserJoined; +} diff --git a/MediaBrowser.Model/SyncPlay/SyncPlayUserLeftUpdate.cs b/MediaBrowser.Model/SyncPlay/SyncPlayUserLeftUpdate.cs new file mode 100644 index 0000000000..97be8e63a8 --- /dev/null +++ b/MediaBrowser.Model/SyncPlay/SyncPlayUserLeftUpdate.cs @@ -0,0 +1,21 @@ +using System; +using System.ComponentModel; + +namespace MediaBrowser.Model.SyncPlay; + +/// +public class SyncPlayUserLeftUpdate : GroupUpdate +{ + /// + /// Initializes a new instance of the class. + /// + /// The groupId. + /// The data. + public SyncPlayUserLeftUpdate(Guid groupId, string data) : base(groupId, data) + { + } + + /// + [DefaultValue(GroupUpdateType.UserLeft)] + public override GroupUpdateType Type => GroupUpdateType.UserLeft; +} diff --git a/MediaBrowser.Model/System/FolderStorageInfo.cs b/MediaBrowser.Model/System/FolderStorageInfo.cs new file mode 100644 index 0000000000..7b10e4ea58 --- /dev/null +++ b/MediaBrowser.Model/System/FolderStorageInfo.cs @@ -0,0 +1,32 @@ +namespace MediaBrowser.Model.System; + +/// +/// Contains information about a specific folder. +/// +public record FolderStorageInfo +{ + /// + /// Gets the path of the folder in question. + /// + public required string Path { get; init; } + + /// + /// Gets the free space of the underlying storage device of the . + /// + public long FreeSpace { get; init; } + + /// + /// Gets the used space of the underlying storage device of the . + /// + public long UsedSpace { get; init; } + + /// + /// Gets the kind of storage device of the . + /// + public string? StorageType { get; init; } + + /// + /// Gets the Device Identifier. + /// + public string? DeviceId { get; init; } +} diff --git a/MediaBrowser.Model/System/LibraryStorageInfo.cs b/MediaBrowser.Model/System/LibraryStorageInfo.cs new file mode 100644 index 0000000000..d4111b29c7 --- /dev/null +++ b/MediaBrowser.Model/System/LibraryStorageInfo.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; + +namespace MediaBrowser.Model.System; + +/// +/// Contains informations about a libraries storage informations. +/// +public class LibraryStorageInfo +{ + /// + /// Gets or sets the Library Id. + /// + public required Guid Id { get; set; } + + /// + /// Gets or sets the name of the library. + /// + public required string Name { get; set; } + + /// + /// Gets or sets the storage informations about the folders used in a library. + /// + public required IReadOnlyCollection Folders { get; set; } +} diff --git a/MediaBrowser.Model/System/PublicSystemInfo.cs b/MediaBrowser.Model/System/PublicSystemInfo.cs index 31a8956427..c26cfb667c 100644 --- a/MediaBrowser.Model/System/PublicSystemInfo.cs +++ b/MediaBrowser.Model/System/PublicSystemInfo.cs @@ -47,7 +47,7 @@ namespace MediaBrowser.Model.System /// Gets or sets a value indicating whether the startup wizard is completed. /// /// - /// Nullable for OpenAPI specification only to retain backwards compatibility in apiclients. + /// Nullable for OpenAPI specification only to retain backwards compatibility in api clients. /// /// The startup completion status.] public bool? StartupWizardCompleted { get; set; } diff --git a/MediaBrowser.Model/System/SystemInfo.cs b/MediaBrowser.Model/System/SystemInfo.cs index f37ac6a147..232a2a6bc8 100644 --- a/MediaBrowser.Model/System/SystemInfo.cs +++ b/MediaBrowser.Model/System/SystemInfo.cs @@ -6,133 +6,139 @@ using System.Collections.Generic; using System.ComponentModel; using MediaBrowser.Model.Updates; -namespace MediaBrowser.Model.System +namespace MediaBrowser.Model.System; + +/// +/// Class SystemInfo. +/// +public class SystemInfo : PublicSystemInfo { /// - /// Class SystemInfo. + /// Initializes a new instance of the class. /// - public class SystemInfo : PublicSystemInfo + public SystemInfo() { - /// - /// Initializes a new instance of the class. - /// - public SystemInfo() - { - CompletedInstallations = Array.Empty(); - } - - /// - /// Gets or sets the display name of the operating system. - /// - /// The display name of the operating system. - [Obsolete("This is no longer set")] - public string OperatingSystemDisplayName { get; set; } = string.Empty; - - /// - /// Gets or sets the package name. - /// - /// The value of the '-package' command line argument. - public string PackageName { get; set; } - - /// - /// Gets or sets a value indicating whether this instance has pending restart. - /// - /// true if this instance has pending restart; otherwise, false. - public bool HasPendingRestart { get; set; } - - public bool IsShuttingDown { get; set; } - - /// - /// Gets or sets a value indicating whether [supports library monitor]. - /// - /// true if [supports library monitor]; otherwise, false. - public bool SupportsLibraryMonitor { get; set; } - - /// - /// Gets or sets the web socket port number. - /// - /// The web socket port number. - public int WebSocketPortNumber { get; set; } - - /// - /// Gets or sets the completed installations. - /// - /// The completed installations. - public InstallationInfo[] CompletedInstallations { get; set; } - - /// - /// Gets or sets a value indicating whether this instance can self restart. - /// - /// true. - [Obsolete("This is always true")] - [DefaultValue(true)] - public bool CanSelfRestart { get; set; } = true; - - [Obsolete("This is always false")] - [DefaultValue(false)] - public bool CanLaunchWebBrowser { get; set; } = false; - - /// - /// Gets or sets the program data path. - /// - /// The program data path. - public string ProgramDataPath { get; set; } - - /// - /// Gets or sets the web UI resources path. - /// - /// The web UI resources path. - public string WebPath { get; set; } - - /// - /// Gets or sets the items by name path. - /// - /// The items by name path. - public string ItemsByNamePath { get; set; } - - /// - /// Gets or sets the cache path. - /// - /// The cache path. - public string CachePath { get; set; } - - /// - /// Gets or sets the log path. - /// - /// The log path. - public string LogPath { get; set; } - - /// - /// Gets or sets the internal metadata path. - /// - /// The internal metadata path. - public string InternalMetadataPath { get; set; } - - /// - /// Gets or sets the transcode path. - /// - /// The transcode path. - public string TranscodingTempPath { get; set; } - - /// - /// Gets or sets the list of cast receiver applications. - /// - public IReadOnlyList CastReceiverApplications { get; set; } - - /// - /// Gets or sets a value indicating whether this instance has update available. - /// - /// true if this instance has update available; otherwise, false. - [Obsolete("This should be handled by the package manager")] - [DefaultValue(false)] - public bool HasUpdateAvailable { get; set; } - - [Obsolete("This isn't set correctly anymore")] - [DefaultValue("System")] - public string EncoderLocation { get; set; } = "System"; - - [Obsolete("This is no longer set")] - [DefaultValue("X64")] - public string SystemArchitecture { get; set; } = "X64"; + CompletedInstallations = Array.Empty(); } + + /// + /// Gets or sets the display name of the operating system. + /// + /// The display name of the operating system. + [Obsolete("This is no longer set")] + public string OperatingSystemDisplayName { get; set; } = string.Empty; + + /// + /// Gets or sets the package name. + /// + /// The value of the '-package' command line argument. + public string PackageName { get; set; } + + /// + /// Gets or sets a value indicating whether this instance has pending restart. + /// + /// true if this instance has pending restart; otherwise, false. + public bool HasPendingRestart { get; set; } + + public bool IsShuttingDown { get; set; } + + /// + /// Gets or sets a value indicating whether [supports library monitor]. + /// + /// true if [supports library monitor]; otherwise, false. + public bool SupportsLibraryMonitor { get; set; } + + /// + /// Gets or sets the web socket port number. + /// + /// The web socket port number. + public int WebSocketPortNumber { get; set; } + + /// + /// Gets or sets the completed installations. + /// + /// The completed installations. + public InstallationInfo[] CompletedInstallations { get; set; } + + /// + /// Gets or sets a value indicating whether this instance can self restart. + /// + /// true. + [Obsolete("This is always true")] + [DefaultValue(true)] + public bool CanSelfRestart { get; set; } = true; + + [Obsolete("This is always false")] + [DefaultValue(false)] + public bool CanLaunchWebBrowser { get; set; } = false; + + /// + /// Gets or sets the program data path. + /// + /// The program data path. + [Obsolete("Use the newer SystemStorageDto instead")] + public string ProgramDataPath { get; set; } + + /// + /// Gets or sets the web UI resources path. + /// + /// The web UI resources path. + [Obsolete("Use the newer SystemStorageDto instead")] + public string WebPath { get; set; } + + /// + /// Gets or sets the items by name path. + /// + /// The items by name path. + [Obsolete("Use the newer SystemStorageDto instead")] + public string ItemsByNamePath { get; set; } + + /// + /// Gets or sets the cache path. + /// + /// The cache path. + [Obsolete("Use the newer SystemStorageDto instead")] + public string CachePath { get; set; } + + /// + /// Gets or sets the log path. + /// + /// The log path. + [Obsolete("Use the newer SystemStorageDto instead")] + public string LogPath { get; set; } + + /// + /// Gets or sets the internal metadata path. + /// + /// The internal metadata path. + [Obsolete("Use the newer SystemStorageDto instead")] + public string InternalMetadataPath { get; set; } + + /// + /// Gets or sets the transcode path. + /// + /// The transcode path. + [Obsolete("Use the newer SystemStorageDto instead")] + public string TranscodingTempPath { get; set; } + + /// + /// Gets or sets the list of cast receiver applications. + /// + public IReadOnlyList CastReceiverApplications { get; set; } + + /// + /// Gets or sets a value indicating whether this instance has update available. + /// + /// true if this instance has update available; otherwise, false. + [Obsolete("This should be handled by the package manager")] + [DefaultValue(false)] + public bool HasUpdateAvailable { get; set; } + + [Obsolete("This isn't set correctly anymore")] + [DefaultValue("System")] + public string EncoderLocation { get; set; } = "System"; + + [Obsolete("This is no longer set")] + [DefaultValue("X64")] + public string SystemArchitecture { get; set; } = "X64"; } diff --git a/MediaBrowser.Model/System/SystemStorageInfo.cs b/MediaBrowser.Model/System/SystemStorageInfo.cs new file mode 100644 index 0000000000..42e7a37e02 --- /dev/null +++ b/MediaBrowser.Model/System/SystemStorageInfo.cs @@ -0,0 +1,56 @@ +using System.Collections.Generic; + +namespace MediaBrowser.Model.System; + +/// +/// Contains informations about the systems storage. +/// +public class SystemStorageInfo +{ + /// + /// Gets or sets the program data path. + /// + /// The program data path. + public required FolderStorageInfo ProgramDataFolder { get; set; } + + /// + /// Gets or sets the web UI resources path. + /// + /// The web UI resources path. + public required FolderStorageInfo WebFolder { get; set; } + + /// + /// Gets or sets the items by name path. + /// + /// The items by name path. + public required FolderStorageInfo ImageCacheFolder { get; set; } + + /// + /// Gets or sets the cache path. + /// + /// The cache path. + public required FolderStorageInfo CacheFolder { get; set; } + + /// + /// Gets or sets the log path. + /// + /// The log path. + public required FolderStorageInfo LogFolder { get; set; } + + /// + /// Gets or sets the internal metadata path. + /// + /// The internal metadata path. + public required FolderStorageInfo InternalMetadataFolder { get; set; } + + /// + /// Gets or sets the transcode path. + /// + /// The transcode path. + public required FolderStorageInfo TranscodingTempFolder { get; set; } + + /// + /// Gets or sets the storage informations of all libraries. + /// + public required IReadOnlyCollection Libraries { get; set; } +} diff --git a/MediaBrowser.Model/System/WakeOnLanInfo.cs b/MediaBrowser.Model/System/WakeOnLanInfo.cs deleted file mode 100644 index aba19a6baf..0000000000 --- a/MediaBrowser.Model/System/WakeOnLanInfo.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System.Net.NetworkInformation; - -namespace MediaBrowser.Model.System -{ - /// - /// Provides the MAC address and port for wake-on-LAN functionality. - /// - public class WakeOnLanInfo - { - /// - /// Initializes a new instance of the class. - /// - /// The MAC address. - public WakeOnLanInfo(PhysicalAddress macAddress) : this(macAddress.ToString()) - { - } - - /// - /// Initializes a new instance of the class. - /// - /// The MAC address. - public WakeOnLanInfo(string macAddress) : this() - { - MacAddress = macAddress; - } - - /// - /// Initializes a new instance of the class. - /// - public WakeOnLanInfo() - { - Port = 9; - } - - /// - /// Gets the MAC address of the device. - /// - /// The MAC address. - public string? MacAddress { get; } - - /// - /// Gets or sets the wake-on-LAN port. - /// - /// The wake-on-LAN port. - public int Port { get; set; } - } -} diff --git a/MediaBrowser.Model/Tasks/TaskTriggerInfo.cs b/MediaBrowser.Model/Tasks/TaskTriggerInfo.cs index 63709557db..186c0aed35 100644 --- a/MediaBrowser.Model/Tasks/TaskTriggerInfo.cs +++ b/MediaBrowser.Model/Tasks/TaskTriggerInfo.cs @@ -8,31 +8,11 @@ namespace MediaBrowser.Model.Tasks /// public class TaskTriggerInfo { - /// - /// The daily trigger. - /// - public const string TriggerDaily = "DailyTrigger"; - - /// - /// The weekly trigger. - /// - public const string TriggerWeekly = "WeeklyTrigger"; - - /// - /// The interval trigger. - /// - public const string TriggerInterval = "IntervalTrigger"; - - /// - /// The startup trigger. - /// - public const string TriggerStartup = "StartupTrigger"; - /// /// Gets or sets the type. /// /// The type. - public string Type { get; set; } + public TaskTriggerInfoType Type { get; set; } /// /// Gets or sets the time of day. diff --git a/MediaBrowser.Model/Tasks/TaskTriggerInfoType.cs b/MediaBrowser.Model/Tasks/TaskTriggerInfoType.cs new file mode 100644 index 0000000000..b596cf5803 --- /dev/null +++ b/MediaBrowser.Model/Tasks/TaskTriggerInfoType.cs @@ -0,0 +1,28 @@ +namespace MediaBrowser.Model.Tasks +{ + /// + /// Enum TaskTriggerInfoType. + /// + public enum TaskTriggerInfoType + { + /// + /// The daily trigger. + /// + DailyTrigger, + + /// + /// The weekly trigger. + /// + WeeklyTrigger, + + /// + /// The interval trigger. + /// + IntervalTrigger, + + /// + /// The startup trigger. + /// + StartupTrigger + } +} diff --git a/MediaBrowser.Model/Users/UserPolicy.cs b/MediaBrowser.Model/Users/UserPolicy.cs index 951e057632..2c393ca862 100644 --- a/MediaBrowser.Model/Users/UserPolicy.cs +++ b/MediaBrowser.Model/Users/UserPolicy.cs @@ -6,7 +6,8 @@ using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.Xml.Serialization; using Jellyfin.Data.Enums; -using AccessSchedule = Jellyfin.Data.Entities.AccessSchedule; +using Jellyfin.Database.Implementations.Enums; +using AccessSchedule = Jellyfin.Database.Implementations.Entities.AccessSchedule; namespace MediaBrowser.Model.Users { @@ -110,6 +111,8 @@ namespace MediaBrowser.Model.Users /// The max parental rating. public int? MaxParentalRating { get; set; } + public int? MaxParentalSubRating { get; set; } + public string[] BlockedTags { get; set; } public string[] AllowedTags { get; set; } diff --git a/MediaBrowser.Providers/BoxSets/BoxSetMetadataService.cs b/MediaBrowser.Providers/BoxSets/BoxSetMetadataService.cs index 32ab7716f7..b51ab4c08e 100644 --- a/MediaBrowser.Providers/BoxSets/BoxSetMetadataService.cs +++ b/MediaBrowser.Providers/BoxSets/BoxSetMetadataService.cs @@ -39,7 +39,7 @@ namespace MediaBrowser.Providers.BoxSets protected override bool EnableUpdatingPremiereDateFromChildren => true; /// - protected override IList GetChildrenForMetadataUpdates(BoxSet item) + protected override IReadOnlyList GetChildrenForMetadataUpdates(BoxSet item) { return item.GetLinkedChildren(); } diff --git a/MediaBrowser.Providers/Chapters/ChapterManager.cs b/MediaBrowser.Providers/Chapters/ChapterManager.cs deleted file mode 100644 index 3cbfe7d4d7..0000000000 --- a/MediaBrowser.Providers/Chapters/ChapterManager.cs +++ /dev/null @@ -1,26 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using MediaBrowser.Controller.Chapters; -using MediaBrowser.Controller.Persistence; -using MediaBrowser.Model.Entities; - -namespace MediaBrowser.Providers.Chapters -{ - public class ChapterManager : IChapterManager - { - private readonly IItemRepository _itemRepo; - - public ChapterManager(IItemRepository itemRepo) - { - _itemRepo = itemRepo; - } - - /// - public void SaveChapters(Guid itemId, IReadOnlyList chapters) - { - _itemRepo.SaveChapters(itemId, chapters); - } - } -} diff --git a/MediaBrowser.Providers/Lyric/LrcLyricParser.cs b/MediaBrowser.Providers/Lyric/LrcLyricParser.cs index fffdf4887b..27d17b535c 100644 --- a/MediaBrowser.Providers/Lyric/LrcLyricParser.cs +++ b/MediaBrowser.Providers/Lyric/LrcLyricParser.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text.RegularExpressions; using Jellyfin.Extensions; using LrcParser.Model; using LrcParser.Parser; @@ -14,7 +15,7 @@ namespace MediaBrowser.Providers.Lyric; /// /// LRC Lyric Parser. /// -public class LrcLyricParser : ILyricParser +public partial class LrcLyricParser : ILyricParser { private readonly LyricParser _lrcLyricParser; @@ -65,13 +66,47 @@ public class LrcLyricParser : ILyricParser } List lyricList = []; - - for (int i = 0; i < sortedLyricData.Count; i++) + for (var l = 0; l < sortedLyricData.Count; l++) { - long ticks = TimeSpan.FromMilliseconds(sortedLyricData[i].StartTime).Ticks; - lyricList.Add(new LyricLine(sortedLyricData[i].Text.Trim(), ticks)); + var cues = new List(); + var lyric = sortedLyricData[l]; + + if (lyric.TimeTags.Count != 0) + { + var keys = lyric.TimeTags.Keys.ToList(); + int current = 0, next = 1; + while (next < keys.Count) + { + var currentKey = keys[current]; + var currentMs = lyric.TimeTags[currentKey] ?? 0; + var nextMs = lyric.TimeTags[keys[next]] ?? 0; + + cues.Add(new LyricLineCue( + position: Math.Max(currentKey.Index, 0), + start: TimeSpan.FromMilliseconds(currentMs).Ticks, + end: TimeSpan.FromMilliseconds(nextMs).Ticks)); + + current++; + next++; + } + + var lastKey = keys[current]; + var lastMs = lyric.TimeTags[lastKey] ?? 0; + + cues.Add(new LyricLineCue( + position: Math.Max(lastKey.Index, 0), + start: TimeSpan.FromMilliseconds(lastMs).Ticks, + end: l + 1 < sortedLyricData.Count ? TimeSpan.FromMilliseconds(sortedLyricData[l + 1].StartTime).Ticks : null)); + } + + long lyricStartTicks = TimeSpan.FromMilliseconds(lyric.StartTime).Ticks; + lyricList.Add(new LyricLine(WhitespaceRegex().Replace(lyric.Text.Trim(), " "), lyricStartTicks, cues)); } return new LyricDto { Lyrics = lyricList }; } + + // Replacement is required until https://github.com/karaoke-dev/LrcParser/issues/83 is resolved. + [GeneratedRegex(@"\s+")] + private static partial Regex WhitespaceRegex(); } diff --git a/MediaBrowser.Providers/Lyric/LyricManager.cs b/MediaBrowser.Providers/Lyric/LyricManager.cs index f4b18a8c14..913a104a0d 100644 --- a/MediaBrowser.Providers/Lyric/LyricManager.cs +++ b/MediaBrowser.Providers/Lyric/LyricManager.cs @@ -78,7 +78,8 @@ public class LyricManager : ILyricManager MediaPath = audio.Path, SongName = audio.Name, AlbumName = audio.Album, - ArtistNames = audio.GetAllArtists().ToList(), + AlbumArtistsNames = audio.AlbumArtists, + ArtistNames = audio.Artists, Duration = audio.RunTimeTicks, IsAutomated = isAutomated }; diff --git a/MediaBrowser.Providers/Lyric/LyricScheduledTask.cs b/MediaBrowser.Providers/Lyric/LyricScheduledTask.cs index 89d71e1722..b8861ee5ec 100644 --- a/MediaBrowser.Providers/Lyric/LyricScheduledTask.cs +++ b/MediaBrowser.Providers/Lyric/LyricScheduledTask.cs @@ -117,7 +117,8 @@ public class LyricScheduledTask : IScheduledTask MediaPath = audioItem.Path, SongName = audioItem.Name, AlbumName = audioItem.Album, - ArtistNames = audioItem.GetAllArtists().ToList(), + AlbumArtistsNames = audioItem.AlbumArtists, + ArtistNames = audioItem.Artists, Duration = audioItem.RunTimeTicks, IsAutomated = true, DisabledLyricFetchers = libraryOptions.DisabledLyricFetchers, @@ -162,7 +163,7 @@ public class LyricScheduledTask : IScheduledTask [ new TaskTriggerInfo { - Type = TaskTriggerInfo.TriggerInterval, + Type = TaskTriggerInfoType.IntervalTrigger, IntervalTicks = TimeSpan.FromHours(24).Ticks } ]; diff --git a/MediaBrowser.Providers/Manager/ImageSaver.cs b/MediaBrowser.Providers/Manager/ImageSaver.cs index 9a676cb2e7..8f6aa2db37 100644 --- a/MediaBrowser.Providers/Manager/ImageSaver.cs +++ b/MediaBrowser.Providers/Manager/ImageSaver.cs @@ -291,6 +291,7 @@ namespace MediaBrowser.Providers.Manager var fileStreamOptions = AsyncFile.WriteOptions; fileStreamOptions.Mode = FileMode.Create; + fileStreamOptions.Options = FileOptions.WriteThrough; if (source.CanSeek) { fileStreamOptions.PreallocationSize = source.Length; diff --git a/MediaBrowser.Providers/Manager/ItemImageProvider.cs b/MediaBrowser.Providers/Manager/ItemImageProvider.cs index 36a7c2fabe..ee22b4bc67 100644 --- a/MediaBrowser.Providers/Manager/ItemImageProvider.cs +++ b/MediaBrowser.Providers/Manager/ItemImageProvider.cs @@ -6,6 +6,7 @@ using System.IO; using System.Linq; using System.Net; using System.Net.Http; +using System.Net.Mime; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Entities; @@ -229,9 +230,7 @@ namespace MediaBrowser.Providers.Manager { var mimeType = MimeTypes.GetMimeType(response.Path); - var stream = AsyncFile.OpenRead(response.Path); - - await _providerManager.SaveImage(item, stream, mimeType, imageType, null, cancellationToken).ConfigureAwait(false); + await _providerManager.SaveImage(item, response.Path, mimeType, imageType, null, null, cancellationToken).ConfigureAwait(false); } } @@ -387,8 +386,8 @@ namespace MediaBrowser.Providers.Manager item.RemoveImages(images); - // Cleanup old metadata directory for episodes if empty - if (item is Episode) + // Cleanup old metadata directory for episodes if empty, as long as it's not a virtual item + if (item is Episode && !item.IsVirtualItem) { var oldLocalMetadataDirectory = Path.Combine(item.ContainingFolderPath, "metadata"); if (_fileSystem.DirectoryExists(oldLocalMetadataDirectory) && !_fileSystem.GetFiles(oldLocalMetadataDirectory).Any()) @@ -553,10 +552,16 @@ namespace MediaBrowser.Providers.Manager var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); await using (stream.ConfigureAwait(false)) { + var mimetype = response.Content.Headers.ContentType?.MediaType; + if (mimetype is null || mimetype.Equals(MediaTypeNames.Application.Octet, StringComparison.OrdinalIgnoreCase)) + { + mimetype = MimeTypes.GetMimeType(response.RequestMessage.RequestUri.GetLeftPart(UriPartial.Path)); + } + await _providerManager.SaveImage( item, stream, - response.Content.Headers.ContentType?.MediaType, + mimetype, type, null, cancellationToken).ConfigureAwait(false); @@ -679,10 +684,16 @@ namespace MediaBrowser.Providers.Manager var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); await using (stream.ConfigureAwait(false)) { + var mimetype = response.Content.Headers.ContentType?.MediaType; + if (mimetype is null || mimetype.Equals(MediaTypeNames.Application.Octet, StringComparison.OrdinalIgnoreCase)) + { + mimetype = MimeTypes.GetMimeType(response.RequestMessage.RequestUri.GetLeftPart(UriPartial.Path)); + } + await _providerManager.SaveImage( item, stream, - response.Content.Headers.ContentType?.MediaType, + mimetype, imageType, null, cancellationToken).ConfigureAwait(false); diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs index 7203bf1158..45f66f85fc 100644 --- a/MediaBrowser.Providers/Manager/MetadataService.cs +++ b/MediaBrowser.Providers/Manager/MetadataService.cs @@ -74,10 +74,11 @@ namespace MediaBrowser.Providers.Manager public virtual async Task RefreshMetadata(BaseItem item, MetadataRefreshOptions refreshOptions, CancellationToken cancellationToken) { var itemOfType = (TItemType)item; - var updateType = ItemUpdateType.None; - var libraryOptions = LibraryManager.GetLibraryOptions(item); + var isFirstRefresh = item.DateLastRefreshed == default; + var hasRefreshedMetadata = true; + var hasRefreshedImages = true; var requiresRefresh = libraryOptions.AutomaticRefreshIntervalDays > 0 && (DateTime.UtcNow - item.DateLastRefreshed).TotalDays >= libraryOptions.AutomaticRefreshIntervalDays; @@ -131,9 +132,10 @@ namespace MediaBrowser.Providers.Manager People = LibraryManager.GetPeople(item) }; - bool hasRefreshedMetadata = true; - bool hasRefreshedImages = true; - var isFirstRefresh = item.DateLastRefreshed == default; + var beforeSaveResult = BeforeSave(itemOfType, isFirstRefresh || refreshOptions.ReplaceAllMetadata || refreshOptions.MetadataRefreshMode == MetadataRefreshMode.FullRefresh || requiresRefresh || refreshOptions.ForceSave, updateType); + updateType |= beforeSaveResult; + + updateType = await SaveInternal(item, refreshOptions, updateType, isFirstRefresh, requiresRefresh, metadataResult, cancellationToken).ConfigureAwait(false); // Next run metadata providers if (refreshOptions.MetadataRefreshMode != MetadataRefreshMode.None) @@ -188,43 +190,44 @@ namespace MediaBrowser.Providers.Manager } } - var beforeSaveResult = BeforeSave(itemOfType, isFirstRefresh || refreshOptions.ReplaceAllMetadata || refreshOptions.MetadataRefreshMode == MetadataRefreshMode.FullRefresh || requiresRefresh || refreshOptions.ForceSave, updateType); - updateType |= beforeSaveResult; - - // Save if changes were made, or it's never been saved before - if (refreshOptions.ForceSave || updateType > ItemUpdateType.None || isFirstRefresh || refreshOptions.ReplaceAllMetadata || requiresRefresh) + if (hasRefreshedMetadata && hasRefreshedImages) { - if (item.IsFileProtocol) - { - var file = TryGetFile(item.Path, refreshOptions.DirectoryService); - if (file is not null) - { - item.DateModified = file.LastWriteTimeUtc; - } - } - - // If any of these properties are set then make sure the updateType is not None, just to force everything to save - if (refreshOptions.ForceSave || refreshOptions.ReplaceAllMetadata) - { - updateType |= ItemUpdateType.MetadataDownload; - } - - if (hasRefreshedMetadata && hasRefreshedImages) - { - item.DateLastRefreshed = DateTime.UtcNow; - } - else - { - item.DateLastRefreshed = default; - } - - // Save to database - await SaveItemAsync(metadataResult, updateType, cancellationToken).ConfigureAwait(false); + item.DateLastRefreshed = DateTime.UtcNow; + updateType |= item.OnMetadataChanged(); } + updateType = await SaveInternal(item, refreshOptions, updateType, isFirstRefresh, requiresRefresh, metadataResult, cancellationToken).ConfigureAwait(false); + await AfterMetadataRefresh(itemOfType, refreshOptions, cancellationToken).ConfigureAwait(false); return updateType; + + async Task SaveInternal(BaseItem item, MetadataRefreshOptions refreshOptions, ItemUpdateType updateType, bool isFirstRefresh, bool requiresRefresh, MetadataResult metadataResult, CancellationToken cancellationToken) + { + // Save if changes were made, or it's never been saved before + if (refreshOptions.ForceSave || updateType > ItemUpdateType.None || isFirstRefresh || refreshOptions.ReplaceAllMetadata || requiresRefresh) + { + if (item.IsFileProtocol) + { + var file = TryGetFile(item.Path, refreshOptions.DirectoryService); + if (file is not null) + { + item.DateModified = file.LastWriteTimeUtc; + } + } + + // If any of these properties are set then make sure the updateType is not None, just to force everything to save + if (refreshOptions.ForceSave || refreshOptions.ReplaceAllMetadata) + { + updateType |= ItemUpdateType.MetadataDownload; + } + + // Save to database + await SaveItemAsync(metadataResult, updateType, cancellationToken).ConfigureAwait(false); + } + + return updateType; + } } private void ApplySearchResult(ItemLookupInfo lookupInfo, RemoteSearchResult result) @@ -322,17 +325,17 @@ namespace MediaBrowser.Providers.Manager return false; } - protected virtual IList GetChildrenForMetadataUpdates(TItemType item) + protected virtual IReadOnlyList GetChildrenForMetadataUpdates(TItemType item) { if (item is Folder folder) { return folder.GetRecursiveChildren(); } - return Array.Empty(); + return []; } - protected virtual ItemUpdateType UpdateMetadataFromChildren(TItemType item, IList children, bool isFullRefresh, ItemUpdateType currentUpdateType) + protected virtual ItemUpdateType UpdateMetadataFromChildren(TItemType item, IReadOnlyList children, bool isFullRefresh, ItemUpdateType currentUpdateType) { var updateType = ItemUpdateType.None; @@ -371,7 +374,7 @@ namespace MediaBrowser.Providers.Manager return updateType; } - private ItemUpdateType UpdateCumulativeRunTimeTicks(TItemType item, IList children) + private ItemUpdateType UpdateCumulativeRunTimeTicks(TItemType item, IReadOnlyList children) { if (item is Folder folder && folder.SupportsCumulativeRunTimeTicks) { @@ -395,7 +398,7 @@ namespace MediaBrowser.Providers.Manager return ItemUpdateType.None; } - private ItemUpdateType UpdateDateLastMediaAdded(TItemType item, IList children) + private ItemUpdateType UpdateDateLastMediaAdded(TItemType item, IReadOnlyList children) { var updateType = ItemUpdateType.None; @@ -429,7 +432,7 @@ namespace MediaBrowser.Providers.Manager return updateType; } - private ItemUpdateType UpdatePremiereDate(TItemType item, IList children) + private ItemUpdateType UpdatePremiereDate(TItemType item, IReadOnlyList children) { var updateType = ItemUpdateType.None; @@ -467,7 +470,7 @@ namespace MediaBrowser.Providers.Manager return updateType; } - private ItemUpdateType UpdateGenres(TItemType item, IList children) + private ItemUpdateType UpdateGenres(TItemType item, IReadOnlyList children) { var updateType = ItemUpdateType.None; @@ -488,7 +491,7 @@ namespace MediaBrowser.Providers.Manager return updateType; } - private ItemUpdateType UpdateStudios(TItemType item, IList children) + private ItemUpdateType UpdateStudios(TItemType item, IReadOnlyList children) { var updateType = ItemUpdateType.None; @@ -509,7 +512,7 @@ namespace MediaBrowser.Providers.Manager return updateType; } - private ItemUpdateType UpdateOfficialRating(TItemType item, IList children) + private ItemUpdateType UpdateOfficialRating(TItemType item, IReadOnlyList children) { var updateType = ItemUpdateType.None; @@ -1142,20 +1145,26 @@ namespace MediaBrowser.Providers.Manager } } - private static void MergePeople(List source, List target) + private static void MergePeople(IReadOnlyList source, IReadOnlyList target) { - if (target is null) - { - target = new List(); - } + var sourceByName = source.ToLookup(p => p.Name.RemoveDiacritics(), StringComparer.OrdinalIgnoreCase); + var targetByName = target.ToLookup(p => p.Name.RemoveDiacritics(), StringComparer.OrdinalIgnoreCase); - foreach (var person in target) + foreach (var name in targetByName.Select(g => g.Key)) { - var normalizedName = person.Name.RemoveDiacritics(); - var personInSource = source.FirstOrDefault(i => string.Equals(i.Name.RemoveDiacritics(), normalizedName, StringComparison.OrdinalIgnoreCase)); + var targetPeople = targetByName[name].ToArray(); + var sourcePeople = sourceByName[name].ToArray(); - if (personInSource is not null) + if (sourcePeople.Length == 0) { + continue; + } + + for (int i = 0; i < targetPeople.Length; i++) + { + var person = targetPeople[i]; + var personInSource = i < sourcePeople.Length ? sourcePeople[i] : sourcePeople[0]; + foreach (var providerId in personInSource.ProviderIds) { person.ProviderIds.TryAdd(providerId.Key, providerId.Value); @@ -1165,6 +1174,16 @@ namespace MediaBrowser.Providers.Manager { person.ImageUrl = personInSource.ImageUrl; } + + if (!string.IsNullOrWhiteSpace(personInSource.Role) && string.IsNullOrWhiteSpace(person.Role)) + { + person.Role = personInSource.Role; + } + + if (personInSource.SortOrder.HasValue && !person.SortOrder.HasValue) + { + person.SortOrder = personInSource.SortOrder; + } } } } diff --git a/MediaBrowser.Providers/Manager/ProviderManager.cs b/MediaBrowser.Providers/Manager/ProviderManager.cs index 81a9af68be..856f33b497 100644 --- a/MediaBrowser.Providers/Manager/ProviderManager.cs +++ b/MediaBrowser.Providers/Manager/ProviderManager.cs @@ -7,6 +7,7 @@ using System.Linq; using System.Net; using System.Net.Http; using System.Net.Mime; +using System.Runtime.ExceptionServices; using System.Threading; using System.Threading.Tasks; using AsyncKeyedLock; @@ -47,7 +48,7 @@ namespace MediaBrowser.Providers.Manager /// public class ProviderManager : IProviderManager, IDisposable { - private readonly object _refreshQueueLock = new(); + private readonly Lock _refreshQueueLock = new(); private readonly ILogger _logger; private readonly IHttpClientFactory _httpClientFactory; private readonly ILibraryMonitor _libraryMonitor; @@ -199,24 +200,15 @@ namespace MediaBrowser.Providers.Manager // TODO: Isolate this hack into the tvh plugin if (string.IsNullOrEmpty(contentType)) { + // Special case for imagecache if (url.Contains("/imagecache/", StringComparison.OrdinalIgnoreCase)) { contentType = MediaTypeNames.Image.Png; } - else - { - throw new HttpRequestException("Invalid image received: contentType not set.", null, response.StatusCode); - } } - // TVDb will sometimes serve a rubbish 404 html page with a 200 OK code, because reasons... - if (contentType.Equals(MediaTypeNames.Text.Html, StringComparison.OrdinalIgnoreCase)) - { - throw new HttpRequestException("Invalid image received.", null, HttpStatusCode.NotFound); - } - - // some iptv/epg providers don't correctly report media type, extract from url if no extension found - if (string.IsNullOrWhiteSpace(MimeTypes.ToExtension(contentType))) + // some providers don't correctly report media type, extract from url if no extension found + if (contentType is null || contentType.Equals(MediaTypeNames.Application.Octet, StringComparison.OrdinalIgnoreCase)) { // Strip query parameters from url to get actual path. contentType = MimeTypes.GetMimeType(new Uri(url).GetLeftPart(UriPartial.Path)); @@ -224,7 +216,7 @@ namespace MediaBrowser.Providers.Manager if (!contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) { - throw new HttpRequestException($"Request returned {contentType} instead of an image type", null, HttpStatusCode.NotFound); + throw new HttpRequestException($"Request returned '{contentType}' instead of an image type", null, HttpStatusCode.NotFound); } var responseBytes = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false); @@ -251,15 +243,31 @@ namespace MediaBrowser.Providers.Manager } /// - public Task SaveImage(BaseItem item, string source, string mimeType, ImageType type, int? imageIndex, bool? saveLocallyWithMedia, CancellationToken cancellationToken) + public async Task SaveImage(BaseItem item, string source, string mimeType, ImageType type, int? imageIndex, bool? saveLocallyWithMedia, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(source)) { throw new ArgumentNullException(nameof(source)); } - var fileStream = AsyncFile.OpenRead(source); - return new ImageSaver(_configurationManager, _libraryMonitor, _fileSystem, _logger).SaveImage(item, fileStream, mimeType, type, imageIndex, saveLocallyWithMedia, cancellationToken); + try + { + var fileStream = AsyncFile.OpenRead(source); + await new ImageSaver(_configurationManager, _libraryMonitor, _fileSystem, _logger) + .SaveImage(item, fileStream, mimeType, type, imageIndex, saveLocallyWithMedia, cancellationToken) + .ConfigureAwait(false); + } + finally + { + try + { + File.Delete(source); + } + catch (Exception ex) + { + _logger.LogError(ex, "Source file {Source} not found or in use, skip removing", source); + } + } } /// @@ -891,35 +899,10 @@ namespace MediaBrowser.Providers.Manager /// public IEnumerable GetExternalUrls(BaseItem item) { -#pragma warning disable CS0618 // Type or member is obsolete - Remove 10.11 - var legacyExternalIdUrls = GetExternalIds(item) - .Select(i => - { - var urlFormatString = i.UrlFormatString; - if (string.IsNullOrEmpty(urlFormatString) - || !item.TryGetProviderId(i.Key, out var providerId)) - { - return null; - } - - return new ExternalUrl - { - Name = i.ProviderName, - Url = string.Format( - CultureInfo.InvariantCulture, - urlFormatString, - providerId) - }; - }) - .OfType(); -#pragma warning restore CS0618 // Type or member is obsolete - - var externalUrls = _externalUrlProviders + return _externalUrlProviders .SelectMany(p => p .GetExternalUrls(item) .Select(externalUrl => new ExternalUrl { Name = p.Name, Url = externalUrl })); - - return legacyExternalIdUrls.Concat(externalUrls).OrderBy(u => u.Name); } /// @@ -929,10 +912,7 @@ namespace MediaBrowser.Providers.Manager .Select(i => new ExternalIdInfo( name: i.ProviderName, key: i.Key, - type: i.Type, -#pragma warning disable CS0618 // Type or member is obsolete - Remove 10.11 - urlFormatString: i.UrlFormatString)); -#pragma warning restore CS0618 // Type or member is obsolete + type: i.Type)); } /// @@ -1016,7 +996,6 @@ namespace MediaBrowser.Providers.Manager /// public void QueueRefresh(Guid itemId, MetadataRefreshOptions options, RefreshPriority priority) { - ArgumentNullException.ThrowIfNull(itemId); if (itemId.IsEmpty()) { throw new ArgumentException("Guid can't be empty", nameof(itemId)); diff --git a/MediaBrowser.Providers/MediaBrowser.Providers.csproj b/MediaBrowser.Providers/MediaBrowser.Providers.csproj index 9a65852f02..34b3104b0b 100644 --- a/MediaBrowser.Providers/MediaBrowser.Providers.csproj +++ b/MediaBrowser.Providers/MediaBrowser.Providers.csproj @@ -23,15 +23,14 @@ - + - net8.0 + net9.0 false true - ../jellyfin.ruleset diff --git a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs index 27f6d120f9..0bb21b2878 100644 --- a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs +++ b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using ATL; using Jellyfin.Data.Enums; +using Jellyfin.Extensions; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Library; @@ -19,6 +20,7 @@ using MediaBrowser.Model.Entities; using MediaBrowser.Model.Extensions; using MediaBrowser.Model.MediaInfo; using Microsoft.Extensions.Logging; +using static Jellyfin.Extensions.StringExtensions; namespace MediaBrowser.Providers.MediaInfo { @@ -36,6 +38,7 @@ namespace MediaBrowser.Providers.MediaInfo private readonly IMediaSourceManager _mediaSourceManager; private readonly LyricResolver _lyricResolver; private readonly ILyricManager _lyricManager; + private readonly IMediaStreamRepository _mediaStreamRepository; /// /// Initializes a new instance of the class. @@ -47,6 +50,7 @@ namespace MediaBrowser.Providers.MediaInfo /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. + /// Instance of the . public AudioFileProber( ILogger logger, IMediaSourceManager mediaSourceManager, @@ -54,7 +58,8 @@ namespace MediaBrowser.Providers.MediaInfo IItemRepository itemRepo, ILibraryManager libraryManager, LyricResolver lyricResolver, - ILyricManager lyricManager) + ILyricManager lyricManager, + IMediaStreamRepository mediaStreamRepository) { _mediaEncoder = mediaEncoder; _itemRepo = itemRepo; @@ -63,6 +68,7 @@ namespace MediaBrowser.Providers.MediaInfo _mediaSourceManager = mediaSourceManager; _lyricResolver = lyricResolver; _lyricManager = lyricManager; + _mediaStreamRepository = mediaStreamRepository; ATL.Settings.DisplayValueSeparator = InternalValueSeparator; ATL.Settings.UseFileNameWhenNoTitle = false; ATL.Settings.ID3v2_separatev2v3Values = false; @@ -149,7 +155,7 @@ namespace MediaBrowser.Providers.MediaInfo audio.HasLyrics = mediaStreams.Any(s => s.Type == MediaStreamType.Lyric); - _itemRepo.SaveMediaStreams(audio.Id, mediaStreams, cancellationToken); + _mediaStreamRepository.SaveMediaStreams(audio.Id, mediaStreams, cancellationToken); } /// @@ -170,16 +176,20 @@ namespace MediaBrowser.Providers.MediaInfo _logger.LogWarning("File {File} only has ID3v1 tags, some fields may be truncated", audio.Path); } - track.Title = string.IsNullOrEmpty(track.Title) ? mediaInfo.Name : track.Title; - track.Album = string.IsNullOrEmpty(track.Album) ? mediaInfo.Album : track.Album; - track.Year ??= mediaInfo.ProductionYear; - track.TrackNumber ??= mediaInfo.IndexNumber; - track.DiscNumber ??= mediaInfo.ParentIndexNumber; + // We should never use the property setter of the ATL.Track class. + // That setter is meant for its own tag parser and external editor usage and will have unwanted side effects + // For example, setting the Year property will also set the Date property, which is not what we want here. + // To properly handle fallback values, we make a clone of those fields when valid. + var trackTitle = (string.IsNullOrEmpty(track.Title) ? mediaInfo.Name : track.Title)?.Trim(); + var trackAlbum = (string.IsNullOrEmpty(track.Album) ? mediaInfo.Album : track.Album)?.Trim(); + var trackYear = track.Year is null or 0 ? mediaInfo.ProductionYear : track.Year; + var trackTrackNumber = track.TrackNumber is null or 0 ? mediaInfo.IndexNumber : track.TrackNumber; + var trackDiscNumber = track.DiscNumber is null or 0 ? mediaInfo.ParentIndexNumber : track.DiscNumber; if (audio.SupportsPeople && !audio.LockedFields.Contains(MetadataField.Cast)) { var people = new List(); - var albumArtists = string.IsNullOrEmpty(track.AlbumArtist) ? mediaInfo.AlbumArtists : track.AlbumArtist.Split(InternalValueSeparator); + var albumArtists = string.IsNullOrEmpty(track.AlbumArtist) ? [] : track.AlbumArtist.Split(InternalValueSeparator); if (libraryOptions.UseCustomTagDelimiters) { @@ -188,11 +198,11 @@ namespace MediaBrowser.Providers.MediaInfo foreach (var albumArtist in albumArtists) { - if (!string.IsNullOrEmpty(albumArtist)) + if (!string.IsNullOrWhiteSpace(albumArtist)) { PeopleHelper.AddPerson(people, new PersonInfo { - Name = albumArtist, + Name = albumArtist.Trim(), Type = PersonKind.AlbumArtist }); } @@ -210,7 +220,7 @@ namespace MediaBrowser.Providers.MediaInfo if (performers is null || performers.Length == 0) { - performers = string.IsNullOrEmpty(track.Artist) ? mediaInfo.Artists : track.Artist.Split(InternalValueSeparator); + performers = string.IsNullOrEmpty(track.Artist) ? [] : track.Artist.Split(InternalValueSeparator); } if (libraryOptions.UseCustomTagDelimiters) @@ -220,11 +230,11 @@ namespace MediaBrowser.Providers.MediaInfo foreach (var performer in performers) { - if (!string.IsNullOrEmpty(performer)) + if (!string.IsNullOrWhiteSpace(performer)) { PeopleHelper.AddPerson(people, new PersonInfo { - Name = performer, + Name = performer.Trim(), Type = PersonKind.Artist }); } @@ -232,11 +242,11 @@ namespace MediaBrowser.Providers.MediaInfo foreach (var composer in track.Composer.Split(InternalValueSeparator)) { - if (!string.IsNullOrEmpty(composer)) + if (!string.IsNullOrWhiteSpace(composer)) { PeopleHelper.AddPerson(people, new PersonInfo { - Name = composer, + Name = composer.Trim(), Type = PersonKind.Composer }); } @@ -271,22 +281,22 @@ namespace MediaBrowser.Providers.MediaInfo } } - if (!audio.LockedFields.Contains(MetadataField.Name) && !string.IsNullOrEmpty(track.Title)) + if (!audio.LockedFields.Contains(MetadataField.Name) && !string.IsNullOrEmpty(trackTitle)) { - audio.Name = track.Title; + audio.Name = trackTitle; } if (options.ReplaceAllMetadata) { - audio.Album = track.Album; - audio.IndexNumber = track.TrackNumber; - audio.ParentIndexNumber = track.DiscNumber; + audio.Album = trackAlbum; + audio.IndexNumber = trackTrackNumber; + audio.ParentIndexNumber = trackDiscNumber; } else { - audio.Album ??= track.Album; - audio.IndexNumber ??= track.TrackNumber; - audio.ParentIndexNumber ??= track.DiscNumber; + audio.Album ??= trackAlbum; + audio.IndexNumber ??= trackTrackNumber; + audio.ParentIndexNumber ??= trackDiscNumber; } if (track.Date.HasValue) @@ -294,11 +304,12 @@ namespace MediaBrowser.Providers.MediaInfo audio.PremiereDate = track.Date; } - if (track.Year.HasValue) + if (trackYear.HasValue) { - var year = track.Year.Value; + var year = trackYear.Value; audio.ProductionYear = year; + // ATL library handles such fallback this with its own internal logic, but we also need to handle it here for the ffprobe fallbacks. if (!audio.PremiereDate.HasValue) { try @@ -307,20 +318,22 @@ namespace MediaBrowser.Providers.MediaInfo } catch (ArgumentOutOfRangeException ex) { - _logger.LogError(ex, "Error parsing YEAR tag in {File}. '{TagValue}' is an invalid year", audio.Path, track.Year); + _logger.LogError(ex, "Error parsing YEAR tag in {File}. '{TagValue}' is an invalid year", audio.Path, trackYear); } } } if (!audio.LockedFields.Contains(MetadataField.Genres)) { - var genres = string.IsNullOrEmpty(track.Genre) ? mediaInfo.Genres : track.Genre.Split(InternalValueSeparator).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + var genres = string.IsNullOrEmpty(track.Genre) ? [] : track.Genre.Split(InternalValueSeparator).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); if (libraryOptions.UseCustomTagDelimiters) { genres = genres.SelectMany(g => SplitWithCustomDelimiter(g, libraryOptions.GetCustomTagDelimiters(), libraryOptions.DelimiterWhitelist)).ToArray(); } + genres = genres.Trimmed().Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + audio.Genres = options.ReplaceAllMetadata || audio.Genres is null || audio.Genres.Length == 0 ? genres : audio.Genres; @@ -347,7 +360,8 @@ namespace MediaBrowser.Providers.MediaInfo || track.AdditionalFields.TryGetValue("MusicBrainz Artist Id", out musicBrainzArtistTag)) && !string.IsNullOrEmpty(musicBrainzArtistTag)) { - audio.TrySetProviderId(MetadataProvider.MusicBrainzArtist, musicBrainzArtistTag); + var id = GetFirstMusicBrainzId(musicBrainzArtistTag, libraryOptions.UseCustomTagDelimiters, libraryOptions.GetCustomTagDelimiters(), libraryOptions.DelimiterWhitelist); + audio.TrySetProviderId(MetadataProvider.MusicBrainzArtist, id); } } @@ -357,7 +371,8 @@ namespace MediaBrowser.Providers.MediaInfo || track.AdditionalFields.TryGetValue("MusicBrainz Album Artist Id", out musicBrainzReleaseArtistIdTag)) && !string.IsNullOrEmpty(musicBrainzReleaseArtistIdTag)) { - audio.TrySetProviderId(MetadataProvider.MusicBrainzAlbumArtist, musicBrainzReleaseArtistIdTag); + var id = GetFirstMusicBrainzId(musicBrainzReleaseArtistIdTag, libraryOptions.UseCustomTagDelimiters, libraryOptions.GetCustomTagDelimiters(), libraryOptions.DelimiterWhitelist); + audio.TrySetProviderId(MetadataProvider.MusicBrainzAlbumArtist, id); } } @@ -367,7 +382,8 @@ namespace MediaBrowser.Providers.MediaInfo || track.AdditionalFields.TryGetValue("MusicBrainz Album Id", out musicBrainzReleaseIdTag)) && !string.IsNullOrEmpty(musicBrainzReleaseIdTag)) { - audio.TrySetProviderId(MetadataProvider.MusicBrainzAlbum, musicBrainzReleaseIdTag); + var id = GetFirstMusicBrainzId(musicBrainzReleaseIdTag, libraryOptions.UseCustomTagDelimiters, libraryOptions.GetCustomTagDelimiters(), libraryOptions.DelimiterWhitelist); + audio.TrySetProviderId(MetadataProvider.MusicBrainzAlbum, id); } } @@ -377,7 +393,8 @@ namespace MediaBrowser.Providers.MediaInfo || track.AdditionalFields.TryGetValue("MusicBrainz Release Group Id", out musicBrainzReleaseGroupIdTag)) && !string.IsNullOrEmpty(musicBrainzReleaseGroupIdTag)) { - audio.TrySetProviderId(MetadataProvider.MusicBrainzReleaseGroup, musicBrainzReleaseGroupIdTag); + var id = GetFirstMusicBrainzId(musicBrainzReleaseGroupIdTag, libraryOptions.UseCustomTagDelimiters, libraryOptions.GetCustomTagDelimiters(), libraryOptions.DelimiterWhitelist); + audio.TrySetProviderId(MetadataProvider.MusicBrainzReleaseGroup, id); } } @@ -387,7 +404,26 @@ namespace MediaBrowser.Providers.MediaInfo || track.AdditionalFields.TryGetValue("MusicBrainz Release Track Id", out trackMbId)) && !string.IsNullOrEmpty(trackMbId)) { - audio.TrySetProviderId(MetadataProvider.MusicBrainzTrack, trackMbId); + var id = GetFirstMusicBrainzId(trackMbId, libraryOptions.UseCustomTagDelimiters, libraryOptions.GetCustomTagDelimiters(), libraryOptions.DelimiterWhitelist); + audio.TrySetProviderId(MetadataProvider.MusicBrainzTrack, id); + } + } + + if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzRecording, out _)) + { + if ((track.AdditionalFields.TryGetValue("MUSICBRAINZ_TRACKID", out var recordingMbId) + || track.AdditionalFields.TryGetValue("MusicBrainz Track Id", out recordingMbId)) + && !string.IsNullOrEmpty(recordingMbId)) + { + audio.TrySetProviderId(MetadataProvider.MusicBrainzRecording, recordingMbId); + } + else if (track.AdditionalFields.TryGetValue("UFID", out var ufIdValue) && !string.IsNullOrEmpty(ufIdValue)) + { + // If tagged with MB Picard, the format is 'http://musicbrainz.org\0' + if (ufIdValue.Contains("musicbrainz.org", StringComparison.OrdinalIgnoreCase)) + { + audio.TrySetProviderId(MetadataProvider.MusicBrainzRecording, ufIdValue.AsSpan().RightPart('\0').ToString()); + } } } @@ -441,5 +477,18 @@ namespace MediaBrowser.Providers.MediaInfo return items; } + + // MusicBrainz IDs are multi-value tags, so we need to split them + // However, our current provider can only have one single ID, which means we need to pick the first one + private string? GetFirstMusicBrainzId(string tag, bool useCustomTagDelimiters, char[] tagDelimiters, string[] whitelist) + { + var val = tag.Split(InternalValueSeparator).FirstOrDefault(); + if (val is not null && useCustomTagDelimiters) + { + val = SplitWithCustomDelimiter(val, tagDelimiters, whitelist).FirstOrDefault(); + } + + return val; + } } } diff --git a/MediaBrowser.Providers/MediaInfo/AudioImageProvider.cs b/MediaBrowser.Providers/MediaInfo/AudioImageProvider.cs index d1c0ddb375..cc2b3face3 100644 --- a/MediaBrowser.Providers/MediaInfo/AudioImageProvider.cs +++ b/MediaBrowser.Providers/MediaInfo/AudioImageProvider.cs @@ -1,4 +1,4 @@ -#nullable disable +#pragma warning disable CA1826 // CA1826 Do not use Enumerable methods on Indexable collections. using System; using System.Collections.Generic; @@ -74,18 +74,17 @@ namespace MediaBrowser.Providers.MediaInfo return GetImage((Audio)item, imageStreams, cancellationToken); } - private async Task GetImage(Audio item, List imageStreams, CancellationToken cancellationToken) + private async Task GetImage(Audio item, IReadOnlyList imageStreams, CancellationToken cancellationToken) { var path = GetAudioImagePath(item); if (!File.Exists(path)) { - Directory.CreateDirectory(Path.GetDirectoryName(path)); - + var directoryName = Path.GetDirectoryName(path) ?? throw new InvalidOperationException($"Invalid path '{path}'"); + Directory.CreateDirectory(directoryName); var imageStream = imageStreams.FirstOrDefault(i => (i.Comment ?? string.Empty).Contains("front", StringComparison.OrdinalIgnoreCase)) ?? imageStreams.FirstOrDefault(i => (i.Comment ?? string.Empty).Contains("cover", StringComparison.OrdinalIgnoreCase)) ?? imageStreams.FirstOrDefault(); - var imageStreamIndex = imageStream?.Index; var tempFile = await _mediaEncoder.ExtractAudioImage(item.Path, imageStreamIndex, cancellationToken).ConfigureAwait(false); diff --git a/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs b/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs index 246ba2733f..266e1861f9 100644 --- a/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs +++ b/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs @@ -6,6 +6,7 @@ using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Extensions; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Chapters; using MediaBrowser.Controller.Configuration; @@ -31,6 +32,7 @@ namespace MediaBrowser.Providers.MediaInfo public class FFProbeVideoInfo { private readonly ILogger _logger; + private readonly IMediaSourceManager _mediaSourceManager; private readonly IMediaEncoder _mediaEncoder; private readonly IItemRepository _itemRepo; private readonly IBlurayExaminer _blurayExaminer; @@ -38,11 +40,12 @@ namespace MediaBrowser.Providers.MediaInfo private readonly IEncodingManager _encodingManager; private readonly IServerConfigurationManager _config; private readonly ISubtitleManager _subtitleManager; - private readonly IChapterManager _chapterManager; + private readonly IChapterRepository _chapterManager; private readonly ILibraryManager _libraryManager; private readonly AudioResolver _audioResolver; private readonly SubtitleResolver _subtitleResolver; - private readonly IMediaSourceManager _mediaSourceManager; + private readonly IMediaAttachmentRepository _mediaAttachmentRepository; + private readonly IMediaStreamRepository _mediaStreamRepository; public FFProbeVideoInfo( ILogger logger, @@ -54,10 +57,12 @@ namespace MediaBrowser.Providers.MediaInfo IEncodingManager encodingManager, IServerConfigurationManager config, ISubtitleManager subtitleManager, - IChapterManager chapterManager, + IChapterRepository chapterManager, ILibraryManager libraryManager, AudioResolver audioResolver, - SubtitleResolver subtitleResolver) + SubtitleResolver subtitleResolver, + IMediaAttachmentRepository mediaAttachmentRepository, + IMediaStreamRepository mediaStreamRepository) { _logger = logger; _mediaSourceManager = mediaSourceManager; @@ -72,6 +77,9 @@ namespace MediaBrowser.Providers.MediaInfo _libraryManager = libraryManager; _audioResolver = audioResolver; _subtitleResolver = subtitleResolver; + _mediaAttachmentRepository = mediaAttachmentRepository; + _mediaStreamRepository = mediaStreamRepository; + _mediaStreamRepository = mediaStreamRepository; } public async Task ProbeVideo( @@ -267,11 +275,11 @@ namespace MediaBrowser.Providers.MediaInfo video.HasSubtitles = mediaStreams.Any(i => i.Type == MediaStreamType.Subtitle); - _itemRepo.SaveMediaStreams(video.Id, mediaStreams, cancellationToken); + _mediaStreamRepository.SaveMediaStreams(video.Id, mediaStreams, cancellationToken); if (mediaAttachments.Any()) { - _itemRepo.SaveMediaAttachments(video.Id, mediaAttachments, cancellationToken); + _mediaAttachmentRepository.SaveMediaAttachments(video.Id, mediaAttachments, cancellationToken); } if (options.MetadataRefreshMode == MetadataRefreshMode.FullRefresh @@ -354,7 +362,7 @@ namespace MediaBrowser.Providers.MediaInfo blurayVideoStream.Codec = ffmpegVideoStream.Codec; blurayVideoStream.BitRate = blurayVideoStream.BitRate.GetValueOrDefault() == 0 ? ffmpegVideoStream.BitRate : blurayVideoStream.BitRate; blurayVideoStream.Width = blurayVideoStream.Width.GetValueOrDefault() == 0 ? ffmpegVideoStream.Width : blurayVideoStream.Width; - blurayVideoStream.Height = blurayVideoStream.Height.GetValueOrDefault() == 0 ? ffmpegVideoStream.Width : blurayVideoStream.Height; + blurayVideoStream.Height = blurayVideoStream.Height.GetValueOrDefault() == 0 ? ffmpegVideoStream.Height : blurayVideoStream.Height; blurayVideoStream.ColorRange = ffmpegVideoStream.ColorRange; blurayVideoStream.ColorSpace = ffmpegVideoStream.ColorSpace; blurayVideoStream.ColorTransfer = ffmpegVideoStream.ColorTransfer; @@ -400,7 +408,7 @@ namespace MediaBrowser.Providers.MediaInfo { video.Genres = Array.Empty(); - foreach (var genre in data.Genres) + foreach (var genre in data.Genres.Trimmed()) { video.AddGenre(genre); } @@ -509,9 +517,9 @@ namespace MediaBrowser.Providers.MediaInfo { PeopleHelper.AddPerson(people, new PersonInfo { - Name = person.Name, + Name = person.Name.Trim(), Type = person.Type, - Role = person.Role + Role = person.Role.Trim() }); } @@ -627,7 +635,7 @@ namespace MediaBrowser.Providers.MediaInfo { var runtime = video.RunTimeTicks.GetValueOrDefault(); - // Only process files with a runtime higher than 0 and lower than 12h. The latter are likely corrupted. + // Only process files with a runtime greater than 0 and less than 12h. The latter are likely corrupted. if (runtime < 0 || runtime > TimeSpan.FromHours(12).Ticks) { throw new ArgumentException( diff --git a/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs b/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs index fbec4e9634..0716cdfa01 100644 --- a/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs +++ b/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs @@ -119,7 +119,7 @@ namespace MediaBrowser.Providers.MediaInfo || (mediaStream.Type == MediaStreamType.Subtitle && _type == DlnaProfileType.Subtitle)) { mediaStream.Index = startIndex++; - mediaStream.IsDefault = pathInfo.IsDefault || mediaStream.IsDefault; + mediaStream.IsDefault = pathInfo.IsDefault; mediaStream.IsForced = pathInfo.IsForced || mediaStream.IsForced; mediaStream.IsHearingImpaired = pathInfo.IsHearingImpaired || mediaStream.IsHearingImpaired; diff --git a/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs b/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs index 04da8fb882..1c2f8b9134 100644 --- a/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs +++ b/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs @@ -61,12 +61,14 @@ namespace MediaBrowser.Providers.MediaInfo /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. - /// Instance of the interface. + /// Instance of the interface. /// Instance of the interface. /// Instance of the . /// Instance of the interface. /// The . /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. public ProbeProvider( IMediaSourceManager mediaSourceManager, IMediaEncoder mediaEncoder, @@ -76,12 +78,14 @@ namespace MediaBrowser.Providers.MediaInfo IEncodingManager encodingManager, IServerConfigurationManager config, ISubtitleManager subtitleManager, - IChapterManager chapterManager, + IChapterRepository chapterManager, ILibraryManager libraryManager, IFileSystem fileSystem, ILoggerFactory loggerFactory, NamingOptions namingOptions, - ILyricManager lyricManager) + ILyricManager lyricManager, + IMediaAttachmentRepository mediaAttachmentRepository, + IMediaStreamRepository mediaStreamRepository) { _logger = loggerFactory.CreateLogger(); _audioResolver = new AudioResolver(loggerFactory.CreateLogger(), localization, mediaEncoder, fileSystem, namingOptions); @@ -101,7 +105,9 @@ namespace MediaBrowser.Providers.MediaInfo chapterManager, libraryManager, _audioResolver, - _subtitleResolver); + _subtitleResolver, + mediaAttachmentRepository, + mediaStreamRepository); _audioProber = new AudioFileProber( loggerFactory.CreateLogger(), @@ -110,7 +116,8 @@ namespace MediaBrowser.Providers.MediaInfo itemRepo, libraryManager, _lyricResolver, - lyricManager); + lyricManager, + mediaStreamRepository); } /// diff --git a/MediaBrowser.Providers/MediaInfo/SubtitleDownloader.cs b/MediaBrowser.Providers/MediaInfo/SubtitleDownloader.cs index 20fb4dab9c..227f310255 100644 --- a/MediaBrowser.Providers/MediaInfo/SubtitleDownloader.cs +++ b/MediaBrowser.Providers/MediaInfo/SubtitleDownloader.cs @@ -31,7 +31,7 @@ namespace MediaBrowser.Providers.MediaInfo public async Task> DownloadSubtitles( Video video, - List mediaStreams, + IReadOnlyList mediaStreams, bool skipIfEmbeddedSubtitlesPresent, bool skipIfAudioTrackMatches, bool requirePerfectMatch, @@ -68,7 +68,7 @@ namespace MediaBrowser.Providers.MediaInfo public Task DownloadSubtitles( Video video, - List mediaStreams, + IReadOnlyList mediaStreams, bool skipIfEmbeddedSubtitlesPresent, bool skipIfAudioTrackMatches, bool requirePerfectMatch, @@ -120,7 +120,7 @@ namespace MediaBrowser.Providers.MediaInfo private async Task DownloadSubtitles( Video video, - List mediaStreams, + IReadOnlyList mediaStreams, bool skipIfEmbeddedSubtitlesPresent, bool skipIfAudioTrackMatches, bool requirePerfectMatch, diff --git a/MediaBrowser.Providers/MediaInfo/SubtitleScheduledTask.cs b/MediaBrowser.Providers/MediaInfo/SubtitleScheduledTask.cs index 6eb75891aa..938f3cb327 100644 --- a/MediaBrowser.Providers/MediaInfo/SubtitleScheduledTask.cs +++ b/MediaBrowser.Providers/MediaInfo/SubtitleScheduledTask.cs @@ -217,7 +217,7 @@ namespace MediaBrowser.Providers.MediaInfo return new[] { // Every so often - new TaskTriggerInfo { Type = TaskTriggerInfo.TriggerInterval, IntervalTicks = TimeSpan.FromHours(24).Ticks } + new TaskTriggerInfo { Type = TaskTriggerInfoType.IntervalTrigger, IntervalTicks = TimeSpan.FromHours(24).Ticks } }; } } diff --git a/MediaBrowser.Providers/MediaInfo/VideoImageProvider.cs b/MediaBrowser.Providers/MediaInfo/VideoImageProvider.cs index ba7ad40727..3d446053b3 100644 --- a/MediaBrowser.Providers/MediaInfo/VideoImageProvider.cs +++ b/MediaBrowser.Providers/MediaInfo/VideoImageProvider.cs @@ -1,3 +1,5 @@ +#pragma warning disable CA1826 // CA1826 Do not use Enumerable methods on Indexable collections. + using System; using System.Collections.Generic; using System.Linq; diff --git a/MediaBrowser.Providers/Movies/ImdbExternalId.cs b/MediaBrowser.Providers/Movies/ImdbExternalId.cs index a8d74aa0b5..def0b13c07 100644 --- a/MediaBrowser.Providers/Movies/ImdbExternalId.cs +++ b/MediaBrowser.Providers/Movies/ImdbExternalId.cs @@ -21,9 +21,6 @@ namespace MediaBrowser.Providers.Movies /// public ExternalIdMediaType? Type => null; - /// - public string UrlFormatString => "https://www.imdb.com/title/{0}"; - /// public bool Supports(IHasProviderIds item) { diff --git a/MediaBrowser.Providers/Movies/ImdbExternalUrlProvider.cs b/MediaBrowser.Providers/Movies/ImdbExternalUrlProvider.cs new file mode 100644 index 0000000000..980bac102e --- /dev/null +++ b/MediaBrowser.Providers/Movies/ImdbExternalUrlProvider.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; + +namespace MediaBrowser.Providers.Movies; + +/// +/// External URLs for IMDb. +/// +public class ImdbExternalUrlProvider : IExternalUrlProvider +{ + /// + public string Name => "IMDb"; + + /// + public IEnumerable GetExternalUrls(BaseItem item) + { + var baseUrl = "https://www.imdb.com/"; + if (item.TryGetProviderId(MetadataProvider.Imdb, out var externalId)) + { + if (item is Person) + { + yield return baseUrl + $"name/{externalId}"; + } + else + { + yield return baseUrl + $"title/{externalId}"; + } + } + } +} diff --git a/MediaBrowser.Providers/Movies/ImdbPersonExternalId.cs b/MediaBrowser.Providers/Movies/ImdbPersonExternalId.cs index 8151ab4715..aa2b2fae95 100644 --- a/MediaBrowser.Providers/Movies/ImdbPersonExternalId.cs +++ b/MediaBrowser.Providers/Movies/ImdbPersonExternalId.cs @@ -18,9 +18,6 @@ namespace MediaBrowser.Providers.Movies /// public ExternalIdMediaType? Type => ExternalIdMediaType.Person; - /// - public string UrlFormatString => "https://www.imdb.com/name/{0}"; - /// public bool Supports(IHasProviderIds item) => item is Person; } diff --git a/MediaBrowser.Providers/Music/AlbumMetadataService.cs b/MediaBrowser.Providers/Music/AlbumMetadataService.cs index a39bd16cea..0bcc301cb9 100644 --- a/MediaBrowser.Providers/Music/AlbumMetadataService.cs +++ b/MediaBrowser.Providers/Music/AlbumMetadataService.cs @@ -47,11 +47,11 @@ namespace MediaBrowser.Providers.Music protected override bool EnableUpdatingStudiosFromChildren => true; /// - protected override IList GetChildrenForMetadataUpdates(MusicAlbum item) + protected override IReadOnlyList GetChildrenForMetadataUpdates(MusicAlbum item) => item.GetRecursiveChildren(i => i is Audio); /// - protected override ItemUpdateType UpdateMetadataFromChildren(MusicAlbum item, IList children, bool isFullRefresh, ItemUpdateType currentUpdateType) + protected override ItemUpdateType UpdateMetadataFromChildren(MusicAlbum item, IReadOnlyList children, bool isFullRefresh, ItemUpdateType currentUpdateType) { var updateType = base.UpdateMetadataFromChildren(item, children, isFullRefresh, currentUpdateType); @@ -187,7 +187,7 @@ namespace MediaBrowser.Providers.Music { PeopleHelper.AddPerson(people, new PersonInfo { - Name = albumArtist, + Name = albumArtist.Trim(), Type = PersonKind.AlbumArtist }); } @@ -196,7 +196,7 @@ namespace MediaBrowser.Providers.Music { PeopleHelper.AddPerson(people, new PersonInfo { - Name = artist, + Name = artist.Trim(), Type = PersonKind.Artist }); } diff --git a/MediaBrowser.Providers/Music/ArtistMetadataService.cs b/MediaBrowser.Providers/Music/ArtistMetadataService.cs index 1f342c0db1..c47f9a5006 100644 --- a/MediaBrowser.Providers/Music/ArtistMetadataService.cs +++ b/MediaBrowser.Providers/Music/ArtistMetadataService.cs @@ -1,6 +1,7 @@ #pragma warning disable CS1591 using System.Collections.Generic; +using System.Collections.Immutable; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; @@ -28,7 +29,7 @@ namespace MediaBrowser.Providers.Music protected override bool EnableUpdatingGenresFromChildren => true; /// - protected override IList GetChildrenForMetadataUpdates(MusicArtist item) + protected override IReadOnlyList GetChildrenForMetadataUpdates(MusicArtist item) { return item.IsAccessedByName ? item.GetTaggedItems(new InternalItemsQuery diff --git a/MediaBrowser.Providers/Music/ImvdbId.cs b/MediaBrowser.Providers/Music/ImvdbId.cs index ed69f369c0..b2c0b7019e 100644 --- a/MediaBrowser.Providers/Music/ImvdbId.cs +++ b/MediaBrowser.Providers/Music/ImvdbId.cs @@ -18,9 +18,6 @@ namespace MediaBrowser.Providers.Music /// public ExternalIdMediaType? Type => null; - /// - public string? UrlFormatString => null; - /// public bool Supports(IHasProviderIds item) => item is MusicVideo; diff --git a/MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs b/MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs index 43889bfbf5..7be54453f8 100644 --- a/MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs +++ b/MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs @@ -36,7 +36,7 @@ namespace MediaBrowser.Providers.Playlists protected override bool EnableUpdatingStudiosFromChildren => true; /// - protected override IList GetChildrenForMetadataUpdates(Playlist item) + protected override IReadOnlyList GetChildrenForMetadataUpdates(Playlist item) => item.GetLinkedChildren(); /// diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumExternalId.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumExternalId.cs index 138cfef19a..622bb1dba4 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumExternalId.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumExternalId.cs @@ -18,9 +18,6 @@ namespace MediaBrowser.Providers.Plugins.AudioDb /// public ExternalIdMediaType? Type => null; - /// - public string UrlFormatString => "https://www.theaudiodb.com/album/{0}"; - /// public bool Supports(IHasProviderIds item) => item is MusicAlbum; } diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumExternalUrlProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumExternalUrlProvider.cs new file mode 100644 index 0000000000..01d2841059 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumExternalUrlProvider.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; + +namespace MediaBrowser.Providers.Plugins.AudioDb; + +/// +/// External artist URLs for AudioDb. +/// +public class AudioDbAlbumExternalUrlProvider : IExternalUrlProvider +{ + /// + public string Name => "TheAudioDb Album"; + + /// + public IEnumerable GetExternalUrls(BaseItem item) + { + if (item.TryGetProviderId(MetadataProvider.AudioDbAlbum, out var externalId)) + { + var baseUrl = "https://www.theaudiodb.com/"; + switch (item) + { + case MusicAlbum: + yield return baseUrl + $"album/{externalId}"; + break; + } + } + } +} diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumImageProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumImageProvider.cs index 8a516e1ce7..d2eeb7f079 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumImageProvider.cs @@ -4,7 +4,6 @@ using System.Collections.Generic; using System.IO; -using System.Linq; using System.Net.Http; using System.Text.Json; using System.Threading; @@ -50,9 +49,7 @@ namespace MediaBrowser.Providers.Plugins.AudioDb /// public async Task> GetImages(BaseItem item, CancellationToken cancellationToken) { - var id = item.GetProviderId(MetadataProvider.MusicBrainzReleaseGroup); - - if (!string.IsNullOrWhiteSpace(id)) + if (item.TryGetProviderId(MetadataProvider.MusicBrainzReleaseGroup, out var id)) { await AudioDbAlbumProvider.Current.EnsureInfo(id, cancellationToken).ConfigureAwait(false); @@ -70,7 +67,7 @@ namespace MediaBrowser.Providers.Plugins.AudioDb } } - return Enumerable.Empty(); + return []; } private List GetImages(AudioDbAlbumProvider.Album item) diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs index daad9706cd..ff30af8797 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs @@ -148,21 +148,19 @@ namespace MediaBrowser.Providers.Plugins.AudioDb item.Overview = (overview ?? string.Empty).StripHtml(); } - internal Task EnsureInfo(string musicBrainzReleaseGroupId, CancellationToken cancellationToken) + internal async Task EnsureInfo(string musicBrainzReleaseGroupId, CancellationToken cancellationToken) { var xmlPath = GetAlbumInfoPath(_config.ApplicationPaths, musicBrainzReleaseGroupId); var fileInfo = _fileSystem.GetFileSystemInfo(xmlPath); - if (fileInfo.Exists) + if (fileInfo.Exists + && (DateTime.UtcNow - _fileSystem.GetLastWriteTimeUtc(fileInfo)).TotalDays <= 2) { - if ((DateTime.UtcNow - _fileSystem.GetLastWriteTimeUtc(fileInfo)).TotalDays <= 2) - { - return Task.CompletedTask; - } + return; } - return DownloadInfo(musicBrainzReleaseGroupId, cancellationToken); + await DownloadInfo(musicBrainzReleaseGroupId, cancellationToken).ConfigureAwait(false); } internal async Task DownloadInfo(string musicBrainzReleaseGroupId, CancellationToken cancellationToken) diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistExternalId.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistExternalId.cs index 8aceb48c0c..3b5955b5be 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistExternalId.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistExternalId.cs @@ -18,9 +18,6 @@ namespace MediaBrowser.Providers.Plugins.AudioDb /// public ExternalIdMediaType? Type => ExternalIdMediaType.Artist; - /// - public string UrlFormatString => "https://www.theaudiodb.com/artist/{0}"; - /// public bool Supports(IHasProviderIds item) => item is MusicArtist; } diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistExternalUrlProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistExternalUrlProvider.cs new file mode 100644 index 0000000000..56b0d9bcb2 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistExternalUrlProvider.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; + +namespace MediaBrowser.Providers.Plugins.AudioDb; + +/// +/// External artist URLs for AudioDb. +/// +public class AudioDbArtistExternalUrlProvider : IExternalUrlProvider +{ + /// + public string Name => "TheAudioDb Artist"; + + /// + public IEnumerable GetExternalUrls(BaseItem item) + { + if (item.TryGetProviderId(MetadataProvider.AudioDbArtist, out var externalId)) + { + var baseUrl = "https://www.theaudiodb.com/"; + switch (item) + { + case MusicAlbum: + case Person: + yield return baseUrl + $"artist/{externalId}"; + break; + } + } + } +} diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistImageProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistImageProvider.cs index 4e7757cd26..88730f34d2 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistImageProvider.cs @@ -4,7 +4,6 @@ using System.Collections.Generic; using System.IO; -using System.Linq; using System.Net.Http; using System.Text.Json; using System.Threading; @@ -43,21 +42,19 @@ namespace MediaBrowser.Providers.Plugins.AudioDb /// public IEnumerable GetSupportedImages(BaseItem item) { - return new ImageType[] - { + return + [ ImageType.Primary, ImageType.Logo, ImageType.Banner, ImageType.Backdrop - }; + ]; } /// public async Task> GetImages(BaseItem item, CancellationToken cancellationToken) { - var id = item.GetProviderId(MetadataProvider.MusicBrainzArtist); - - if (!string.IsNullOrWhiteSpace(id)) + if (item.TryGetProviderId(MetadataProvider.MusicBrainzArtist, out var id)) { await AudioDbArtistProvider.Current.EnsureArtistInfo(id, cancellationToken).ConfigureAwait(false); @@ -75,7 +72,7 @@ namespace MediaBrowser.Providers.Plugins.AudioDb } } - return Enumerable.Empty(); + return []; } private List GetImages(AudioDbArtistProvider.Artist item) diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs index 92742b1aa5..00bd96282c 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs @@ -131,7 +131,7 @@ namespace MediaBrowser.Providers.Plugins.AudioDb item.Overview = (overview ?? string.Empty).StripHtml(); } - internal Task EnsureArtistInfo(string musicBrainzId, CancellationToken cancellationToken) + internal async Task EnsureArtistInfo(string musicBrainzId, CancellationToken cancellationToken) { var xmlPath = GetArtistInfoPath(_config.ApplicationPaths, musicBrainzId); @@ -140,10 +140,10 @@ namespace MediaBrowser.Providers.Plugins.AudioDb if (fileInfo.Exists && (DateTime.UtcNow - _fileSystem.GetLastWriteTimeUtc(fileInfo)).TotalDays <= 2) { - return Task.CompletedTask; + return; } - return DownloadArtistInfo(musicBrainzId, cancellationToken); + await DownloadArtistInfo(musicBrainzId, cancellationToken).ConfigureAwait(false); } internal async Task DownloadArtistInfo(string musicBrainzId, CancellationToken cancellationToken) diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbOtherAlbumExternalId.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbOtherAlbumExternalId.cs index 014481da24..fdfd330cd3 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbOtherAlbumExternalId.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbOtherAlbumExternalId.cs @@ -18,9 +18,6 @@ namespace MediaBrowser.Providers.Plugins.AudioDb /// public ExternalIdMediaType? Type => ExternalIdMediaType.Album; - /// - public string UrlFormatString => "https://www.theaudiodb.com/album/{0}"; - /// public bool Supports(IHasProviderIds item) => item is Audio; } diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbOtherArtistExternalId.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbOtherArtistExternalId.cs index 7875391043..5a39ec1cd9 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbOtherArtistExternalId.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbOtherArtistExternalId.cs @@ -18,9 +18,6 @@ namespace MediaBrowser.Providers.Plugins.AudioDb /// public ExternalIdMediaType? Type => ExternalIdMediaType.OtherArtist; - /// - public string UrlFormatString => "https://www.theaudiodb.com/artist/{0}"; - /// public bool Supports(IHasProviderIds item) => item is Audio || item is MusicAlbum; } diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumArtistExternalId.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumArtistExternalId.cs index 825fe32fa2..f1fc4a137b 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumArtistExternalId.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumArtistExternalId.cs @@ -19,9 +19,6 @@ public class MusicBrainzAlbumArtistExternalId : IExternalId /// public ExternalIdMediaType? Type => ExternalIdMediaType.AlbumArtist; - /// - public string UrlFormatString => Plugin.Instance!.Configuration.Server + "/artist/{0}"; - /// public bool Supports(IHasProviderIds item) => item is Audio; } diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumArtistExternalUrlProvider.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumArtistExternalUrlProvider.cs new file mode 100644 index 0000000000..f4b3f4f8c2 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumArtistExternalUrlProvider.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; + +namespace MediaBrowser.Providers.Plugins.MusicBrainz; + +/// +/// External album artist URLs for MusicBrainz. +/// +public class MusicBrainzAlbumArtistExternalUrlProvider : IExternalUrlProvider +{ + /// + public string Name => "MusicBrainz Album Artist"; + + /// + public IEnumerable GetExternalUrls(BaseItem item) + { + if (item is MusicAlbum) + { + if (item.TryGetProviderId(MetadataProvider.MusicBrainzAlbumArtist, out var externalId)) + { + yield return Plugin.Instance!.Configuration.Server + $"/artist/{externalId}"; + } + } + } +} diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumExternalId.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumExternalId.cs index b7d53984c5..48784e0ecd 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumExternalId.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumExternalId.cs @@ -19,9 +19,6 @@ public class MusicBrainzAlbumExternalId : IExternalId /// public ExternalIdMediaType? Type => ExternalIdMediaType.Album; - /// - public string UrlFormatString => Plugin.Instance!.Configuration.Server + "/release/{0}"; - /// public bool Supports(IHasProviderIds item) => item is Audio || item is MusicAlbum; } diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumExternalUrlProvider.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumExternalUrlProvider.cs new file mode 100644 index 0000000000..b9d3b48353 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumExternalUrlProvider.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; + +namespace MediaBrowser.Providers.Plugins.MusicBrainz; + +/// +/// External album URLs for MusicBrainz. +/// +public class MusicBrainzAlbumExternalUrlProvider : IExternalUrlProvider +{ + /// + public string Name => "MusicBrainz Album"; + + /// + public IEnumerable GetExternalUrls(BaseItem item) + { + if (item is MusicAlbum) + { + if (item.TryGetProviderId(MetadataProvider.MusicBrainzAlbum, out var externalId)) + { + yield return Plugin.Instance!.Configuration.Server + $"/release/{externalId}"; + } + } + } +} diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistExternalId.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistExternalId.cs index b3f001618d..bd5d67ed1b 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistExternalId.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistExternalId.cs @@ -19,9 +19,6 @@ public class MusicBrainzArtistExternalId : IExternalId /// public ExternalIdMediaType? Type => ExternalIdMediaType.Artist; - /// - public string UrlFormatString => Plugin.Instance!.Configuration.Server + "/artist/{0}"; - /// public bool Supports(IHasProviderIds item) => item is MusicArtist; } diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistExternalUrlProvider.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistExternalUrlProvider.cs new file mode 100644 index 0000000000..ee5a597c62 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistExternalUrlProvider.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; + +namespace MediaBrowser.Providers.Plugins.MusicBrainz; + +/// +/// External artist URLs for MusicBrainz. +/// +public class MusicBrainzArtistExternalUrlProvider : IExternalUrlProvider +{ + /// + public string Name => "MusicBrainz Artist"; + + /// + public IEnumerable GetExternalUrls(BaseItem item) + { + if (item.TryGetProviderId(MetadataProvider.MusicBrainzArtist, out var externalId)) + { + switch (item) + { + case MusicAlbum: + case Person: + yield return Plugin.Instance!.Configuration.Server + $"/artist/{externalId}"; + + break; + } + } + } +} diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzOtherArtistExternalId.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzOtherArtistExternalId.cs index a0a922293d..470cdad662 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzOtherArtistExternalId.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzOtherArtistExternalId.cs @@ -19,9 +19,6 @@ public class MusicBrainzOtherArtistExternalId : IExternalId /// public ExternalIdMediaType? Type => ExternalIdMediaType.OtherArtist; - /// - public string UrlFormatString => Plugin.Instance!.Configuration.Server + "/artist/{0}"; - /// public bool Supports(IHasProviderIds item) => item is Audio or MusicAlbum; } diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzRecordingId.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzRecordingId.cs new file mode 100644 index 0000000000..89d8b9b998 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzRecordingId.cs @@ -0,0 +1,24 @@ +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; + +namespace MediaBrowser.Providers.Plugins.MusicBrainz; + +/// +/// MusicBrainz recording id. +/// +public class MusicBrainzRecordingId : IExternalId +{ + /// + public string ProviderName => "MusicBrainz"; + + /// + public string Key => MetadataProvider.MusicBrainzRecording.ToString(); + + /// + public ExternalIdMediaType? Type => ExternalIdMediaType.Recording; + + /// + public bool Supports(IHasProviderIds item) => item is Audio; +} diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzReleaseGroupExternalId.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzReleaseGroupExternalId.cs index 47b6d69633..c19b62abfe 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzReleaseGroupExternalId.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzReleaseGroupExternalId.cs @@ -19,9 +19,6 @@ public class MusicBrainzReleaseGroupExternalId : IExternalId /// public ExternalIdMediaType? Type => ExternalIdMediaType.ReleaseGroup; - /// - public string UrlFormatString => Plugin.Instance!.Configuration.Server + "/release-group/{0}"; - /// public bool Supports(IHasProviderIds item) => item is Audio or MusicAlbum; } diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzReleaseGroupExternalUrlProvider.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzReleaseGroupExternalUrlProvider.cs new file mode 100644 index 0000000000..dd0a939f72 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzReleaseGroupExternalUrlProvider.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; + +namespace MediaBrowser.Providers.Plugins.MusicBrainz; + +/// +/// External release group URLs for MusicBrainz. +/// +public class MusicBrainzReleaseGroupExternalUrlProvider : IExternalUrlProvider +{ + /// + public string Name => "MusicBrainz Release Group"; + + /// + public IEnumerable GetExternalUrls(BaseItem item) + { + if (item is MusicAlbum) + { + if (item.TryGetProviderId(MetadataProvider.MusicBrainzReleaseGroup, out var externalId)) + { + yield return Plugin.Instance!.Configuration.Server + $"/release-group/{externalId}"; + } + } + } +} diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzTrackExternalUrlProvider.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzTrackExternalUrlProvider.cs new file mode 100644 index 0000000000..59e6f42b19 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzTrackExternalUrlProvider.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; + +namespace MediaBrowser.Providers.Plugins.MusicBrainz; + +/// +/// External track URLs for MusicBrainz. +/// +public class MusicBrainzTrackExternalUrlProvider : IExternalUrlProvider +{ + /// + public string Name => "MusicBrainz Track"; + + /// + public IEnumerable GetExternalUrls(BaseItem item) + { + if (item is Audio) + { + if (item.TryGetProviderId(MetadataProvider.MusicBrainzTrack, out var externalId)) + { + yield return Plugin.Instance!.Configuration.Server + $"/track/{externalId}"; + } + } + } +} diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzTrackId.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzTrackId.cs index cb4345660d..6a7b6f5412 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzTrackId.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzTrackId.cs @@ -19,9 +19,6 @@ public class MusicBrainzTrackId : IExternalId /// public ExternalIdMediaType? Type => ExternalIdMediaType.Track; - /// - public string UrlFormatString => Plugin.Instance!.Configuration.Server + "/track/{0}"; - /// public bool Supports(IHasProviderIds item) => item is Audio; } diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbEpisodeProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbEpisodeProvider.cs index d8b33a799f..ccff31ebaa 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/OmdbEpisodeProvider.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbEpisodeProvider.cs @@ -55,13 +55,12 @@ namespace MediaBrowser.Providers.Plugins.Omdb if (info.SeriesProviderIds.TryGetValue(MetadataProvider.Imdb.ToString(), out string? seriesImdbId) && !string.IsNullOrEmpty(seriesImdbId) - && info.IndexNumber.HasValue - && info.ParentIndexNumber.HasValue) + && info.IndexNumber.HasValue) { result.HasMetadata = await _omdbProvider.FetchEpisodeData( result, info.IndexNumber.Value, - info.ParentIndexNumber.Value, + info.ParentIndexNumber ?? 1, info.GetProviderId(MetadataProvider.Imdb), seriesImdbId, info.MetadataLanguage, diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs index de0da7f7bd..ad9edb031c 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs @@ -421,7 +421,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb { var person = new PersonInfo { - Name = result.Director, + Name = result.Director.Trim(), Type = PersonKind.Director }; @@ -432,7 +432,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb { var person = new PersonInfo { - Name = result.Writer, + Name = result.Writer.Trim(), Type = PersonKind.Writer }; diff --git a/MediaBrowser.Providers/Plugins/StudioImages/StudiosImageProvider.cs b/MediaBrowser.Providers/Plugins/StudioImages/StudiosImageProvider.cs index 5ca9f6f9a4..a50d69df53 100644 --- a/MediaBrowser.Providers/Plugins/StudioImages/StudiosImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/StudioImages/StudiosImageProvider.cs @@ -101,11 +101,11 @@ namespace MediaBrowser.Providers.Plugins.StudioImages return string.Format(CultureInfo.InvariantCulture, "{0}/images/{1}/{2}.jpg", GetRepositoryUrl(), image, filename); } - private Task EnsureThumbsList(string file, CancellationToken cancellationToken) + private async Task EnsureThumbsList(string file, CancellationToken cancellationToken) { string url = string.Format(CultureInfo.InvariantCulture, "{0}/thumbs.txt", GetRepositoryUrl()); - return EnsureList(url, file, _fileSystem, cancellationToken); + await EnsureList(url, file, _fileSystem, cancellationToken).ConfigureAwait(false); } /// diff --git a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetExternalId.cs b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetExternalId.cs index d453a4ff44..2076589d34 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetExternalId.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetExternalId.cs @@ -20,9 +20,6 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets /// public ExternalIdMediaType? Type => ExternalIdMediaType.BoxSet; - /// - public string UrlFormatString => TmdbUtils.BaseTmdbUrl + "collection/{0}"; - /// public bool Supports(IHasProviderIds item) { diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieExternalId.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieExternalId.cs index 6d6032e8f6..9a1d872ec2 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieExternalId.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieExternalId.cs @@ -20,9 +20,6 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies /// public ExternalIdMediaType? Type => ExternalIdMediaType.Movie; - /// - public string UrlFormatString => TmdbUtils.BaseTmdbUrl + "movie/{0}"; - /// public bool Supports(IHasProviderIds item) { diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs index 8d68e2dcfe..9bb6507fe6 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs @@ -198,7 +198,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies }; movie.SetProviderId(MetadataProvider.Tmdb, tmdbId); - movie.SetProviderId(MetadataProvider.Imdb, movieResult.ImdbId); + movie.TrySetProviderId(MetadataProvider.Imdb, movieResult.ImdbId); if (movieResult.BelongsToCollection is not null) { movie.SetProviderId(MetadataProvider.TmdbCollection, movieResult.BelongsToCollection.Id.ToString(CultureInfo.InvariantCulture)); @@ -234,7 +234,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies var genres = movieResult.Genres; - foreach (var genre in genres.Select(g => g.Name)) + foreach (var genre in genres.Select(g => g.Name).Trimmed()) { movie.AddGenre(genre); } @@ -254,7 +254,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies var personInfo = new PersonInfo { Name = actor.Name.Trim(), - Role = actor.Character, + Role = actor.Character.Trim(), Type = PersonKind.Actor, SortOrder = actor.Order }; @@ -289,7 +289,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies var personInfo = new PersonInfo { Name = person.Name.Trim(), - Role = person.Job, + Role = person.Job?.Trim(), Type = type }; diff --git a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonExternalId.cs b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonExternalId.cs index d26a70028c..2c0787b15d 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonExternalId.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonExternalId.cs @@ -19,9 +19,6 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People /// public ExternalIdMediaType? Type => ExternalIdMediaType.Person; - /// - public string UrlFormatString => TmdbUtils.BaseTmdbUrl + "person/{0}"; - /// public bool Supports(IHasProviderIds item) { diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs index d1fec7cb13..7de0e430f2 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs @@ -63,10 +63,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV return Enumerable.Empty(); } - var seasonNumber = episode.ParentIndexNumber; + var seasonNumber = episode.ParentIndexNumber ?? 1; var episodeNumber = episode.IndexNumber; - if (!seasonNumber.HasValue || !episodeNumber.HasValue) + if (!episodeNumber.HasValue) { return Enumerable.Empty(); } @@ -75,7 +75,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV // TODO use image languages if All Languages isn't toggled, but there's currently no way to get that value in here var episodeResult = await _tmdbClientManager - .GetEpisodeAsync(seriesTmdbId, seasonNumber.Value, episodeNumber.Value, series.DisplayOrder, null, null, cancellationToken) + .GetEpisodeAsync(seriesTmdbId, seasonNumber, episodeNumber.Value, series.DisplayOrder, null, null, cancellationToken) .ConfigureAwait(false); var stills = episodeResult?.Images?.Stills; diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs index e628abde55..73c3b4f16f 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs @@ -47,7 +47,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV public async Task> GetSearchResults(EpisodeInfo searchInfo, CancellationToken cancellationToken) { // The search query must either provide an episode number or date - if (!searchInfo.IndexNumber.HasValue || !searchInfo.ParentIndexNumber.HasValue) + if (!searchInfo.IndexNumber.HasValue) { return Enumerable.Empty(); } @@ -96,10 +96,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV return metadataResult; } - var seasonNumber = info.ParentIndexNumber; + var seasonNumber = info.ParentIndexNumber ?? 1; var episodeNumber = info.IndexNumber; - if (!seasonNumber.HasValue || !episodeNumber.HasValue) + if (!episodeNumber.HasValue) { return metadataResult; } @@ -112,7 +112,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV List? result = null; for (int? episode = startindex; episode <= endindex; episode++) { - var episodeInfo = await _tmdbClientManager.GetEpisodeAsync(seriesTmdbId, seasonNumber.Value, episode.Value, info.SeriesDisplayOrder, info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage), cancellationToken).ConfigureAwait(false); + var episodeInfo = await _tmdbClientManager.GetEpisodeAsync(seriesTmdbId, seasonNumber, episode.Value, info.SeriesDisplayOrder, info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage), cancellationToken).ConfigureAwait(false); if (episodeInfo is not null) { (result ??= new List()).Add(episodeInfo); @@ -156,7 +156,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV else { episodeResult = await _tmdbClientManager - .GetEpisodeAsync(seriesTmdbId, seasonNumber.Value, episodeNumber.Value, info.SeriesDisplayOrder, info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage), cancellationToken) + .GetEpisodeAsync(seriesTmdbId, seasonNumber, episodeNumber.Value, info.SeriesDisplayOrder, info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage), cancellationToken) .ConfigureAwait(false); } @@ -211,7 +211,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV metadataResult.AddPerson(new PersonInfo { Name = actor.Name.Trim(), - Role = actor.Character, + Role = actor.Character.Trim(), Type = PersonKind.Actor, SortOrder = actor.Order }); @@ -225,7 +225,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV metadataResult.AddPerson(new PersonInfo { Name = guest.Name.Trim(), - Role = guest.Character, + Role = guest.Character.Trim(), Type = PersonKind.GuestStar, SortOrder = guest.Order }); @@ -249,7 +249,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV metadataResult.AddPerson(new PersonInfo { Name = person.Name.Trim(), - Role = person.Job, + Role = person.Job?.Trim(), Type = type }); } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs index 3f208b5993..b0a1e00df9 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs @@ -82,12 +82,13 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV var cast = credits.Cast.OrderBy(c => c.Order).Take(Plugin.Instance.Configuration.MaxCastMembers).ToList(); for (var i = 0; i < cast.Count; i++) { + var member = cast[i]; result.AddPerson(new PersonInfo { - Name = cast[i].Name.Trim(), - Role = cast[i].Character, + Name = member.Name.Trim(), + Role = member.Character.Trim(), Type = PersonKind.Actor, - SortOrder = cast[i].Order + SortOrder = member.Order }); } } @@ -108,7 +109,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV result.AddPerson(new PersonInfo { Name = person.Name.Trim(), - Role = person.Job, + Role = person.Job?.Trim(), Type = type }); } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesExternalId.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesExternalId.cs index 5f2d7909a9..840cec9841 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesExternalId.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesExternalId.cs @@ -19,9 +19,6 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV /// public ExternalIdMediaType? Type => ExternalIdMediaType.Series; - /// - public string UrlFormatString => TmdbUtils.BaseTmdbUrl + "tv/{0}"; - /// public bool Supports(IHasProviderIds item) { diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs index e4062740fe..9ace9c6743 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs @@ -330,7 +330,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV var personInfo = new PersonInfo { Name = actor.Name.Trim(), - Role = actor.Character, + Role = actor.Character.Trim(), Type = PersonKind.Actor, SortOrder = actor.Order, ImageUrl = _tmdbClientManager.GetPosterUrl(actor.ProfilePath) @@ -368,7 +368,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV yield return new PersonInfo { Name = person.Name.Trim(), - Role = person.Job, + Role = person.Job?.Trim(), Type = type }; } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbExternalUrlProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbExternalUrlProvider.cs new file mode 100644 index 0000000000..27e3f93a3d --- /dev/null +++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbExternalUrlProvider.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using TMDbLib.Objects.TvShows; + +namespace MediaBrowser.Providers.Plugins.Tmdb; + +/// +/// External URLs for TMDb. +/// +public class TmdbExternalUrlProvider : IExternalUrlProvider +{ + /// + public string Name => "TMDB"; + + /// + public IEnumerable GetExternalUrls(BaseItem item) + { + switch (item) + { + case Series: + if (item.TryGetProviderId(MetadataProvider.Tmdb, out var externalId)) + { + yield return TmdbUtils.BaseTmdbUrl + $"tv/{externalId}"; + } + + break; + case Season season: + if (season.Series.TryGetProviderId(MetadataProvider.Tmdb, out var seriesExternalId)) + { + var orderString = season.Series.DisplayOrder; + var seasonNumber = season.IndexNumber; + if (string.IsNullOrEmpty(orderString) && seasonNumber is not null) + { + // Default order is airdate + yield return TmdbUtils.BaseTmdbUrl + $"tv/{seriesExternalId}/season/{seasonNumber}"; + } + + if (Enum.TryParse(season.Series.DisplayOrder, out var order)) + { + if (order.Equals(TvGroupType.OriginalAirDate) && seasonNumber is not null) + { + yield return TmdbUtils.BaseTmdbUrl + $"tv/{seriesExternalId}/season/{seasonNumber}"; + } + } + } + + break; + case Episode episode: + if (episode.Series.TryGetProviderId(MetadataProvider.Imdb, out seriesExternalId)) + { + var orderString = episode.Series.DisplayOrder; + var seasonNumber = episode.Season?.IndexNumber; + var episodeNumber = episode.IndexNumber; + if (string.IsNullOrEmpty(orderString) && seasonNumber is not null && episodeNumber is not null) + { + // Default order is airdate + yield return TmdbUtils.BaseTmdbUrl + $"tv/{seriesExternalId}/season/{seasonNumber}/episode/{episodeNumber}"; + } + + if (Enum.TryParse(orderString, out var order)) + { + if (order.Equals(TvGroupType.OriginalAirDate) && seasonNumber is not null && episodeNumber is not null) + { + yield return TmdbUtils.BaseTmdbUrl + $"tv/{seriesExternalId}/season/{seasonNumber}/episode/{episodeNumber}"; + } + } + } + + break; + case Movie: + if (item.TryGetProviderId(MetadataProvider.Tmdb, out externalId)) + { + yield return TmdbUtils.BaseTmdbUrl + $"movie/{externalId}"; + } + + break; + case Person: + if (item.TryGetProviderId(MetadataProvider.Tmdb, out externalId)) + { + yield return TmdbUtils.BaseTmdbUrl + $"person/{externalId}"; + } + + break; + case BoxSet: + if (item.TryGetProviderId(MetadataProvider.Tmdb, out externalId)) + { + yield return TmdbUtils.BaseTmdbUrl + $"collection/{externalId}"; + } + + break; + } + } +} diff --git a/MediaBrowser.Providers/TV/SeasonMetadataService.cs b/MediaBrowser.Providers/TV/SeasonMetadataService.cs index 8b690193ee..b27ccaa6a3 100644 --- a/MediaBrowser.Providers/TV/SeasonMetadataService.cs +++ b/MediaBrowser.Providers/TV/SeasonMetadataService.cs @@ -80,11 +80,11 @@ namespace MediaBrowser.Providers.TV } /// - protected override IList GetChildrenForMetadataUpdates(Season item) + protected override IReadOnlyList GetChildrenForMetadataUpdates(Season item) => item.GetEpisodes(); /// - protected override ItemUpdateType UpdateMetadataFromChildren(Season item, IList children, bool isFullRefresh, ItemUpdateType currentUpdateType) + protected override ItemUpdateType UpdateMetadataFromChildren(Season item, IReadOnlyList children, bool isFullRefresh, ItemUpdateType currentUpdateType) { var updateType = base.UpdateMetadataFromChildren(item, children, isFullRefresh, currentUpdateType); @@ -96,7 +96,7 @@ namespace MediaBrowser.Providers.TV return updateType; } - private ItemUpdateType SaveIsVirtualItem(Season item, IList episodes) + private ItemUpdateType SaveIsVirtualItem(Season item, IReadOnlyList episodes) { var isVirtualItem = item.LocationType == LocationType.Virtual && (episodes.Count == 0 || episodes.All(i => i.LocationType == LocationType.Virtual)); diff --git a/MediaBrowser.Providers/TV/SeriesMetadataService.cs b/MediaBrowser.Providers/TV/SeriesMetadataService.cs index f4aede463e..42d59d3483 100644 --- a/MediaBrowser.Providers/TV/SeriesMetadataService.cs +++ b/MediaBrowser.Providers/TV/SeriesMetadataService.cs @@ -55,7 +55,7 @@ namespace MediaBrowser.Providers.TV foreach (var season in seasons) { - var hasUpdate = refreshOptions != null && season.BeforeMetadataRefresh(refreshOptions.ReplaceAllMetadata); + var hasUpdate = refreshOptions is not null && season.BeforeMetadataRefresh(refreshOptions.ReplaceAllMetadata); if (hasUpdate) { await season.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false); @@ -140,38 +140,39 @@ namespace MediaBrowser.Providers.TV private void RemoveObsoleteEpisodes(Series series) { - var episodes = series.GetEpisodes(null, new DtoOptions(), true).OfType().ToList(); - var numberOfEpisodes = episodes.Count; - // TODO: O(n^2), but can it be done faster without overcomplicating it? - for (var i = 0; i < numberOfEpisodes; i++) + var episodesBySeason = series.GetEpisodes(null, new DtoOptions(), true) + .OfType() + .GroupBy(e => e.ParentIndexNumber) + .ToList(); + + foreach (var seasonEpisodes in episodesBySeason) { - var currentEpisode = episodes[i]; - // The outer loop only examines virtual episodes - if (!currentEpisode.IsVirtualItem) + List nonPhysicalEpisodes = []; + List physicalEpisodes = []; + foreach (var episode in seasonEpisodes) { - continue; + if (episode.IsVirtualItem || episode.IsMissingEpisode) + { + nonPhysicalEpisodes.Add(episode); + continue; + } + + physicalEpisodes.Add(episode); } - // Virtual episodes without an episode number are practically orphaned and should be deleted - if (!currentEpisode.IndexNumber.HasValue) + // Only consider non-physical episodes + foreach (var episode in nonPhysicalEpisodes) { - DeleteEpisode(currentEpisode); - continue; - } + // Episodes without an episode number are practically orphaned and should be deleted + // Episodes with a physical equivalent should be deleted (they are no longer missing) + var shouldKeep = episode.IndexNumber.HasValue && !physicalEpisodes.Any(e => e.ContainsEpisodeNumber(episode.IndexNumber.Value)); - for (var j = i + 1; j < numberOfEpisodes; j++) - { - var comparisonEpisode = episodes[j]; - // The inner loop is only for "physical" episodes - if (comparisonEpisode.IsVirtualItem - || currentEpisode.ParentIndexNumber != comparisonEpisode.ParentIndexNumber - || !comparisonEpisode.ContainsEpisodeNumber(currentEpisode.IndexNumber.Value)) + if (shouldKeep) { continue; } - DeleteEpisode(currentEpisode); - break; + DeleteEpisode(episode); } } } diff --git a/MediaBrowser.Providers/TV/Zap2ItExternalId.cs b/MediaBrowser.Providers/TV/Zap2ItExternalId.cs index 3cb18e4248..8907d7744a 100644 --- a/MediaBrowser.Providers/TV/Zap2ItExternalId.cs +++ b/MediaBrowser.Providers/TV/Zap2ItExternalId.cs @@ -18,9 +18,6 @@ namespace MediaBrowser.Providers.TV /// public ExternalIdMediaType? Type => null; - /// - public string UrlFormatString => "http://tvlistings.zap2it.com/overview.html?programSeriesId={0}"; - /// public bool Supports(IHasProviderIds item) => item is Series; } diff --git a/MediaBrowser.Providers/TV/Zap2ItExternalUrlProvider.cs b/MediaBrowser.Providers/TV/Zap2ItExternalUrlProvider.cs new file mode 100644 index 0000000000..52b0583e58 --- /dev/null +++ b/MediaBrowser.Providers/TV/Zap2ItExternalUrlProvider.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; + +namespace MediaBrowser.Providers.TV; + +/// +/// External URLs for TMDb. +/// +public class Zap2ItExternalUrlProvider : IExternalUrlProvider +{ + /// + public string Name => "Zap2It"; + + /// + public IEnumerable GetExternalUrls(BaseItem item) + { + if (item.TryGetProviderId(MetadataProvider.Zap2It, out var externalId)) + { + yield return $"http://tvlistings.zap2it.com/overview.html?programSeriesId={externalId}"; + } + } +} diff --git a/MediaBrowser.Providers/Trickplay/TrickplayImagesTask.cs b/MediaBrowser.Providers/Trickplay/TrickplayImagesTask.cs index 31c0eeb31e..4310f93d4b 100644 --- a/MediaBrowser.Providers/Trickplay/TrickplayImagesTask.cs +++ b/MediaBrowser.Providers/Trickplay/TrickplayImagesTask.cs @@ -63,7 +63,7 @@ public class TrickplayImagesTask : IScheduledTask { new TaskTriggerInfo { - Type = TaskTriggerInfo.TriggerDaily, + Type = TaskTriggerInfoType.DailyTrigger, TimeOfDayTicks = TimeSpan.FromHours(3).Ticks } }; diff --git a/MediaBrowser.XbmcMetadata/MediaBrowser.XbmcMetadata.csproj b/MediaBrowser.XbmcMetadata/MediaBrowser.XbmcMetadata.csproj index c20073eea1..b195af96cf 100644 --- a/MediaBrowser.XbmcMetadata/MediaBrowser.XbmcMetadata.csproj +++ b/MediaBrowser.XbmcMetadata/MediaBrowser.XbmcMetadata.csproj @@ -15,7 +15,7 @@ - net8.0 + net9.0 false true diff --git a/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs b/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs index a8800431e1..75ad0d58ca 100644 --- a/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs +++ b/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs @@ -73,7 +73,7 @@ namespace MediaBrowser.XbmcMetadata.Parsers protected IProviderManager ProviderManager { get; } /// - /// Gets a value indicating whether URLs after a closing XML tag are supporrted. + /// Gets a value indicating whether URLs after a closing XML tag are supported. /// protected virtual bool SupportsUrlAfterClosingXmlTag => false; @@ -312,8 +312,11 @@ namespace MediaBrowser.XbmcMetadata.Parsers if (user is not null) { userData = _userDataManager.GetUserData(user, item); - userData.Played = played; - _userDataManager.SaveUserData(user, item, userData, UserDataSaveReason.Import, CancellationToken.None); + if (userData is not null) + { + userData.Played = played; + _userDataManager.SaveUserData(user, item, userData, UserDataSaveReason.Import, CancellationToken.None); + } } } @@ -326,8 +329,11 @@ namespace MediaBrowser.XbmcMetadata.Parsers if (user is not null) { userData = _userDataManager.GetUserData(user, item); - userData.PlayCount = count; - _userDataManager.SaveUserData(user, item, userData, UserDataSaveReason.Import, CancellationToken.None); + if (userData is not null) + { + userData.PlayCount = count; + _userDataManager.SaveUserData(user, item, userData, UserDataSaveReason.Import, CancellationToken.None); + } } } @@ -340,8 +346,11 @@ namespace MediaBrowser.XbmcMetadata.Parsers if (user is not null) { userData = _userDataManager.GetUserData(user, item); - userData.LastPlayedDate = lastPlayed; - _userDataManager.SaveUserData(user, item, userData, UserDataSaveReason.Import, CancellationToken.None); + if (userData is not null) + { + userData.LastPlayedDate = lastPlayed; + _userDataManager.SaveUserData(user, item, userData, UserDataSaveReason.Import, CancellationToken.None); + } } } @@ -663,7 +672,7 @@ namespace MediaBrowser.XbmcMetadata.Parsers } var fileSystemMetadata = _directoryService.GetFile(val); - // non existing file returns null + // nonexistent file returns null if (fileSystemMetadata is null || !fileSystemMetadata.Exists) { Logger.LogWarning("Artwork file {Path} specified in nfo file for {ItemName} does not exist.", uri, itemResult.Item.Name); diff --git a/MediaBrowser.XbmcMetadata/Parsers/EpisodeNfoParser.cs b/MediaBrowser.XbmcMetadata/Parsers/EpisodeNfoParser.cs index 2a1a14834b..19b1bbe7b6 100644 --- a/MediaBrowser.XbmcMetadata/Parsers/EpisodeNfoParser.cs +++ b/MediaBrowser.XbmcMetadata/Parsers/EpisodeNfoParser.cs @@ -95,7 +95,7 @@ namespace MediaBrowser.XbmcMetadata.Parsers originalTitle.Append(" / ").Append(additionalEpisode.Item.OriginalTitle); } - if (additionalEpisode.Item.IndexNumber != null) + if (additionalEpisode.Item.IndexNumber is not null) { item.Item.IndexNumberEnd = Math.Max((int)additionalEpisode.Item.IndexNumber, item.Item.IndexNumberEnd ?? (int)additionalEpisode.Item.IndexNumber); } diff --git a/MediaBrowser.XbmcMetadata/Parsers/MovieNfoParser.cs b/MediaBrowser.XbmcMetadata/Parsers/MovieNfoParser.cs index 2d65188b63..137abff478 100644 --- a/MediaBrowser.XbmcMetadata/Parsers/MovieNfoParser.cs +++ b/MediaBrowser.XbmcMetadata/Parsers/MovieNfoParser.cs @@ -50,23 +50,20 @@ namespace MediaBrowser.XbmcMetadata.Parsers { case "id": { - // get ids from attributes + // Get ids from attributes + item.TrySetProviderId(MetadataProvider.Tmdb, reader.GetAttribute("TMDB")); + item.TrySetProviderId(MetadataProvider.Tvdb, reader.GetAttribute("TVDB")); string? imdbId = reader.GetAttribute("IMDB"); - string? tmdbId = reader.GetAttribute("TMDB"); - // read id from content + // Read id from content + // Content can be arbitrary according to Kodi wiki, so only parse if we are sure it matches a provider-specific schema var contentId = reader.ReadElementContentAsString(); - if (contentId.Contains("tt", StringComparison.Ordinal) && string.IsNullOrEmpty(imdbId)) + if (string.IsNullOrEmpty(imdbId) && contentId.StartsWith("tt", StringComparison.Ordinal)) { imdbId = contentId; } - else if (string.IsNullOrEmpty(tmdbId)) - { - tmdbId = contentId; - } item.TrySetProviderId(MetadataProvider.Imdb, imdbId); - item.TrySetProviderId(MetadataProvider.Tmdb, tmdbId); break; } @@ -82,21 +79,13 @@ namespace MediaBrowser.XbmcMetadata.Parsers if (!string.IsNullOrWhiteSpace(val) && movie is not null) { - // TODO Handle this better later - if (!val.Contains('<', StringComparison.Ordinal)) + try { - movie.CollectionName = val; + ParseSetXml(val, movie); } - else + catch (Exception ex) { - try - { - ParseSetXml(val, movie); - } - catch (Exception ex) - { - Logger.LogError(ex, "Error parsing set node"); - } + Logger.LogError(ex, "Error parsing set node"); } } @@ -139,7 +128,12 @@ namespace MediaBrowser.XbmcMetadata.Parsers // Loop through each element while (!reader.EOF && reader.ReadState == ReadState.Interactive) { - if (reader.NodeType == XmlNodeType.Element) + if (reader.NodeType == XmlNodeType.Text && reader.Depth == 1) + { + movie.CollectionName = reader.Value; + break; + } + else if (reader.NodeType == XmlNodeType.Element) { switch (reader.Name) { diff --git a/MediaBrowser.XbmcMetadata/Parsers/SeriesNfoParser.cs b/MediaBrowser.XbmcMetadata/Parsers/SeriesNfoParser.cs index 59abef919e..b0944515bf 100644 --- a/MediaBrowser.XbmcMetadata/Parsers/SeriesNfoParser.cs +++ b/MediaBrowser.XbmcMetadata/Parsers/SeriesNfoParser.cs @@ -1,3 +1,4 @@ +using System; using System.Globalization; using System.Xml; using Emby.Naming.TV; @@ -48,16 +49,20 @@ namespace MediaBrowser.XbmcMetadata.Parsers { case "id": { - item.TrySetProviderId(MetadataProvider.Imdb, reader.GetAttribute("IMDB")); + // Get ids from attributes item.TrySetProviderId(MetadataProvider.Tmdb, reader.GetAttribute("TMDB")); + item.TrySetProviderId(MetadataProvider.Tvdb, reader.GetAttribute("TVDB")); + string? imdbId = reader.GetAttribute("IMDB"); - string? tvdbId = reader.GetAttribute("TVDB"); - if (string.IsNullOrWhiteSpace(tvdbId)) + // Read id from content + // Content can be arbitrary according to Kodi wiki, so only parse if we are sure it matches a provider-specific schema + var contentId = reader.ReadElementContentAsString(); + if (string.IsNullOrEmpty(imdbId) && contentId.StartsWith("tt", StringComparison.Ordinal)) { - tvdbId = reader.ReadElementContentAsString(); + imdbId = contentId; } - item.TrySetProviderId(MetadataProvider.Tvdb, tvdbId); + item.TrySetProviderId(MetadataProvider.Imdb, imdbId); break; } diff --git a/MediaBrowser.XbmcMetadata/Savers/AlbumNfoSaver.cs b/MediaBrowser.XbmcMetadata/Savers/AlbumNfoSaver.cs index 2385e70485..440296f095 100644 --- a/MediaBrowser.XbmcMetadata/Savers/AlbumNfoSaver.cs +++ b/MediaBrowser.XbmcMetadata/Savers/AlbumNfoSaver.cs @@ -4,6 +4,7 @@ using System.Globalization; using System.IO; using System.Linq; using System.Xml; +using Jellyfin.Extensions; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; @@ -55,12 +56,12 @@ namespace MediaBrowser.XbmcMetadata.Savers { var album = (MusicAlbum)item; - foreach (var artist in album.Artists) + foreach (var artist in album.Artists.Trimmed().OrderBy(artist => artist)) { writer.WriteElementString("artist", artist); } - foreach (var artist in album.AlbumArtists) + foreach (var artist in album.AlbumArtists.Trimmed().OrderBy(artist => artist)) { writer.WriteElementString("albumartist", artist); } @@ -70,11 +71,20 @@ namespace MediaBrowser.XbmcMetadata.Savers private void AddTracks(IEnumerable tracks, XmlWriter writer) { - foreach (var track in tracks.OrderBy(i => i.ParentIndexNumber ?? 0).ThenBy(i => i.IndexNumber ?? 0)) + foreach (var track in tracks + .OrderBy(i => i.ParentIndexNumber ?? 0) + .ThenBy(i => i.IndexNumber ?? 0) + .ThenBy(i => SortNameOrName(i)) + .ThenBy(i => i.Name?.Trim())) { writer.WriteStartElement("track"); - if (track.IndexNumber.HasValue) + if (track.ParentIndexNumber.HasValue && track.ParentIndexNumber.Value != 0) + { + writer.WriteElementString("disc", track.ParentIndexNumber.Value.ToString(CultureInfo.InvariantCulture)); + } + + if (track.IndexNumber.HasValue && track.IndexNumber.Value != 0) { writer.WriteElementString("position", track.IndexNumber.Value.ToString(CultureInfo.InvariantCulture)); } diff --git a/MediaBrowser.XbmcMetadata/Savers/ArtistNfoSaver.cs b/MediaBrowser.XbmcMetadata/Savers/ArtistNfoSaver.cs index 813d75f6c1..b5ba2d24f2 100644 --- a/MediaBrowser.XbmcMetadata/Savers/ArtistNfoSaver.cs +++ b/MediaBrowser.XbmcMetadata/Savers/ArtistNfoSaver.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Globalization; using System.IO; +using System.Linq; using System.Xml; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; @@ -13,7 +14,7 @@ using Microsoft.Extensions.Logging; namespace MediaBrowser.XbmcMetadata.Savers { /// - /// Nfo saver for artsist. + /// Nfo saver for artist. /// public class ArtistNfoSaver : BaseNfoSaver { @@ -67,9 +68,12 @@ namespace MediaBrowser.XbmcMetadata.Savers AddAlbums(albums, writer); } - private void AddAlbums(IList albums, XmlWriter writer) + private void AddAlbums(IReadOnlyList albums, XmlWriter writer) { - foreach (var album in albums) + foreach (var album in albums + .OrderBy(album => album.ProductionYear ?? 0) + .ThenBy(album => SortNameOrName(album)) + .ThenBy(album => album.Name?.Trim())) { writer.WriteStartElement("album"); diff --git a/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs b/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs index 2afec3f6cd..0217bded13 100644 --- a/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs +++ b/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs @@ -488,7 +488,9 @@ namespace MediaBrowser.XbmcMetadata.Savers var directors = people .Where(i => i.IsType(PersonKind.Director)) - .Select(i => i.Name) + .Select(i => i.Name?.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(i => i) .ToList(); foreach (var person in directors) @@ -498,8 +500,9 @@ namespace MediaBrowser.XbmcMetadata.Savers var writers = people .Where(i => i.IsType(PersonKind.Writer)) - .Select(i => i.Name) + .Select(i => i.Name?.Trim()) .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(i => i) .ToList(); foreach (var person in writers) @@ -512,7 +515,7 @@ namespace MediaBrowser.XbmcMetadata.Savers writer.WriteElementString("credits", person); } - foreach (var trailer in item.RemoteTrailers) + foreach (var trailer in item.RemoteTrailers.OrderBy(t => t.Url?.Trim())) { writer.WriteElementString("trailer", GetOutputTrailerUrl(trailer.Url)); } @@ -544,16 +547,13 @@ namespace MediaBrowser.XbmcMetadata.Savers writer.WriteElementString("aspectratio", hasAspectRatio.AspectRatio); } - var tmdbCollection = item.GetProviderId(MetadataProvider.TmdbCollection); - - if (!string.IsNullOrEmpty(tmdbCollection)) + if (item.TryGetProviderId(MetadataProvider.Tmdb, out var tmdbCollection)) { writer.WriteElementString("collectionnumber", tmdbCollection); writtenProviderIds.Add(MetadataProvider.TmdbCollection.ToString()); } - var imdb = item.GetProviderId(MetadataProvider.Imdb); - if (!string.IsNullOrEmpty(imdb)) + if (item.TryGetProviderId(MetadataProvider.Imdb, out var imdb)) { if (item is Series) { @@ -570,16 +570,14 @@ namespace MediaBrowser.XbmcMetadata.Savers // Series xml saver already saves this if (item is not Series) { - var tvdb = item.GetProviderId(MetadataProvider.Tvdb); - if (!string.IsNullOrEmpty(tvdb)) + if (item.TryGetProviderId(MetadataProvider.Tvdb, out var tvdb)) { writer.WriteElementString("tvdbid", tvdb); writtenProviderIds.Add(MetadataProvider.Tvdb.ToString()); } } - var tmdb = item.GetProviderId(MetadataProvider.Tmdb); - if (!string.IsNullOrEmpty(tmdb)) + if (item.TryGetProviderId(MetadataProvider.Tmdb, out var tmdb)) { writer.WriteElementString("tmdbid", tmdb); writtenProviderIds.Add(MetadataProvider.Tmdb.ToString()); @@ -660,22 +658,22 @@ namespace MediaBrowser.XbmcMetadata.Savers writer.WriteElementString("tagline", item.Tagline); } - foreach (var country in item.ProductionLocations) + foreach (var country in item.ProductionLocations.Trimmed().OrderBy(country => country)) { writer.WriteElementString("country", country); } - foreach (var genre in item.Genres) + foreach (var genre in item.Genres.Trimmed().OrderBy(genre => genre)) { writer.WriteElementString("genre", genre); } - foreach (var studio in item.Studios) + foreach (var studio in item.Studios.Trimmed().OrderBy(studio => studio)) { writer.WriteElementString("studio", studio); } - foreach (var tag in item.Tags) + foreach (var tag in item.Tags.Trimmed().OrderBy(tag => tag)) { if (item is MusicAlbum || item is MusicArtist) { @@ -687,64 +685,49 @@ namespace MediaBrowser.XbmcMetadata.Savers } } - var externalId = item.GetProviderId(MetadataProvider.AudioDbArtist); - - if (!string.IsNullOrEmpty(externalId)) + if (item.TryGetProviderId(MetadataProvider.AudioDbArtist, out var externalId)) { writer.WriteElementString("audiodbartistid", externalId); writtenProviderIds.Add(MetadataProvider.AudioDbArtist.ToString()); } - externalId = item.GetProviderId(MetadataProvider.AudioDbAlbum); - - if (!string.IsNullOrEmpty(externalId)) + if (item.TryGetProviderId(MetadataProvider.AudioDbAlbum, out externalId)) { writer.WriteElementString("audiodbalbumid", externalId); writtenProviderIds.Add(MetadataProvider.AudioDbAlbum.ToString()); } - externalId = item.GetProviderId(MetadataProvider.Zap2It); - - if (!string.IsNullOrEmpty(externalId)) + if (item.TryGetProviderId(MetadataProvider.Zap2It, out externalId)) { writer.WriteElementString("zap2itid", externalId); writtenProviderIds.Add(MetadataProvider.Zap2It.ToString()); } - externalId = item.GetProviderId(MetadataProvider.MusicBrainzAlbum); - - if (!string.IsNullOrEmpty(externalId)) + if (item.TryGetProviderId(MetadataProvider.MusicBrainzAlbum, out externalId)) { writer.WriteElementString("musicbrainzalbumid", externalId); writtenProviderIds.Add(MetadataProvider.MusicBrainzAlbum.ToString()); } - externalId = item.GetProviderId(MetadataProvider.MusicBrainzAlbumArtist); - - if (!string.IsNullOrEmpty(externalId)) + if (item.TryGetProviderId(MetadataProvider.MusicBrainzAlbumArtist, out externalId)) { writer.WriteElementString("musicbrainzalbumartistid", externalId); writtenProviderIds.Add(MetadataProvider.MusicBrainzAlbumArtist.ToString()); } - externalId = item.GetProviderId(MetadataProvider.MusicBrainzArtist); - - if (!string.IsNullOrEmpty(externalId)) + if (item.TryGetProviderId(MetadataProvider.MusicBrainzArtist, out externalId)) { writer.WriteElementString("musicbrainzartistid", externalId); writtenProviderIds.Add(MetadataProvider.MusicBrainzArtist.ToString()); } - externalId = item.GetProviderId(MetadataProvider.MusicBrainzReleaseGroup); - - if (!string.IsNullOrEmpty(externalId)) + if (item.TryGetProviderId(MetadataProvider.MusicBrainzReleaseGroup, out externalId)) { writer.WriteElementString("musicbrainzreleasegroupid", externalId); writtenProviderIds.Add(MetadataProvider.MusicBrainzReleaseGroup.ToString()); } - externalId = item.GetProviderId(MetadataProvider.TvRage); - if (!string.IsNullOrEmpty(externalId)) + if (item.TryGetProviderId(MetadataProvider.TvRage, out externalId)) { writer.WriteElementString("tvrageid", externalId); writtenProviderIds.Add(MetadataProvider.TvRage.ToString()); @@ -752,7 +735,7 @@ namespace MediaBrowser.XbmcMetadata.Savers if (item.ProviderIds is not null) { - foreach (var providerKey in item.ProviderIds.Keys) + foreach (var providerKey in item.ProviderIds.Keys.OrderBy(providerKey => providerKey)) { var providerId = item.ProviderIds[providerKey]; if (!string.IsNullOrEmpty(providerId) && !writtenProviderIds.Contains(providerKey)) @@ -764,7 +747,7 @@ namespace MediaBrowser.XbmcMetadata.Savers XmlConvert.VerifyName(tagName); Logger.LogDebug("Saving custom provider tagname {0}", tagName); - writer.WriteElementString(GetTagForProviderKey(providerKey), providerId); + writer.WriteElementString(tagName, providerId); } catch (ArgumentException) { @@ -785,7 +768,10 @@ namespace MediaBrowser.XbmcMetadata.Savers AddUserData(item, writer, userManager, userDataRepo, options); - AddActors(people, writer, libraryManager, options.SaveImagePathsInNfo); + if (item is not MusicAlbum && item is not MusicArtist) + { + AddActors(people, writer, libraryManager, options.SaveImagePathsInNfo); + } if (item is BoxSet folder) { @@ -797,6 +783,8 @@ namespace MediaBrowser.XbmcMetadata.Savers { var items = item.LinkedChildren .Where(i => i.Type == LinkedChildType.Manual) + .OrderBy(i => i.Path?.Trim()) + .ThenBy(i => i.LibraryItemId?.Trim()) .ToList(); foreach (var link in items) @@ -839,7 +827,7 @@ namespace MediaBrowser.XbmcMetadata.Savers writer.WriteElementString("poster", GetImagePathToSave(image, libraryManager)); } - foreach (var backdrop in item.GetImages(ImageType.Backdrop)) + foreach (var backdrop in item.GetImages(ImageType.Backdrop).OrderBy(b => b.Path?.Trim())) { writer.WriteElementString("fanart", GetImagePathToSave(backdrop, libraryManager)); } @@ -869,51 +857,56 @@ namespace MediaBrowser.XbmcMetadata.Savers var userdata = userDataRepo.GetUserData(user, item); - writer.WriteElementString( + if (userdata is not null) + { + writer.WriteElementString( "isuserfavorite", userdata.IsFavorite.ToString(CultureInfo.InvariantCulture).ToLowerInvariant()); - if (userdata.Rating.HasValue) - { - writer.WriteElementString( - "userrating", - userdata.Rating.Value.ToString(CultureInfo.InvariantCulture).ToLowerInvariant()); - } - - if (!item.IsFolder) - { - writer.WriteElementString( - "playcount", - userdata.PlayCount.ToString(CultureInfo.InvariantCulture)); - writer.WriteElementString( - "watched", - userdata.Played.ToString(CultureInfo.InvariantCulture).ToLowerInvariant()); - - if (userdata.LastPlayedDate.HasValue) + if (userdata.Rating.HasValue) { writer.WriteElementString( - "lastplayed", - userdata.LastPlayedDate.Value.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture).ToLowerInvariant()); + "userrating", + userdata.Rating.Value.ToString(CultureInfo.InvariantCulture).ToLowerInvariant()); } - writer.WriteStartElement("resume"); + if (!item.IsFolder) + { + writer.WriteElementString( + "playcount", + userdata.PlayCount.ToString(CultureInfo.InvariantCulture)); + writer.WriteElementString( + "watched", + userdata.Played.ToString(CultureInfo.InvariantCulture).ToLowerInvariant()); - var runTimeTicks = item.RunTimeTicks ?? 0; + if (userdata.LastPlayedDate.HasValue) + { + writer.WriteElementString( + "lastplayed", + userdata.LastPlayedDate.Value.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture).ToLowerInvariant()); + } - writer.WriteElementString( - "position", - TimeSpan.FromTicks(userdata.PlaybackPositionTicks).TotalSeconds.ToString(CultureInfo.InvariantCulture)); - writer.WriteElementString( - "total", - TimeSpan.FromTicks(runTimeTicks).TotalSeconds.ToString(CultureInfo.InvariantCulture)); + writer.WriteStartElement("resume"); + + var runTimeTicks = item.RunTimeTicks ?? 0; + + writer.WriteElementString( + "position", + TimeSpan.FromTicks(userdata.PlaybackPositionTicks).TotalSeconds.ToString(CultureInfo.InvariantCulture)); + writer.WriteElementString( + "total", + TimeSpan.FromTicks(runTimeTicks).TotalSeconds.ToString(CultureInfo.InvariantCulture)); + } } writer.WriteEndElement(); } - private void AddActors(List people, XmlWriter writer, ILibraryManager libraryManager, bool saveImagePath) + private void AddActors(IReadOnlyList people, XmlWriter writer, ILibraryManager libraryManager, bool saveImagePath) { - foreach (var person in people) + foreach (var person in people + .OrderBy(person => person.SortOrder ?? 0) + .ThenBy(person => person.Name?.Trim())) { if (person.IsType(PersonKind.Director) || person.IsType(PersonKind.Writer)) { @@ -1024,5 +1017,24 @@ namespace MediaBrowser.XbmcMetadata.Savers private string GetTagForProviderKey(string providerKey) => providerKey.ToLowerInvariant() + "id"; + + protected static string SortNameOrName(BaseItem item) + { + if (item is null) + { + return string.Empty; + } + + if (item.SortName is not null) + { + string trimmed = item.SortName.Trim(); + if (trimmed.Length > 0) + { + return trimmed; + } + } + + return (item.Name ?? string.Empty).Trim(); + } } } diff --git a/MediaBrowser.XbmcMetadata/Savers/MovieNfoSaver.cs b/MediaBrowser.XbmcMetadata/Savers/MovieNfoSaver.cs index bc344d87e0..099537de7a 100644 --- a/MediaBrowser.XbmcMetadata/Savers/MovieNfoSaver.cs +++ b/MediaBrowser.XbmcMetadata/Savers/MovieNfoSaver.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Xml; +using Jellyfin.Extensions; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; @@ -91,16 +92,14 @@ namespace MediaBrowser.XbmcMetadata.Savers /// protected override void WriteCustomElements(BaseItem item, XmlWriter writer) { - var imdb = item.GetProviderId(MetadataProvider.Imdb); - - if (!string.IsNullOrEmpty(imdb)) + if (item.TryGetProviderId(MetadataProvider.Imdb, out var imdb)) { writer.WriteElementString("id", imdb); } if (item is MusicVideo musicVideo) { - foreach (var artist in musicVideo.Artists) + foreach (var artist in musicVideo.Artists.Trimmed().OrderBy(artist => artist)) { writer.WriteElementString("artist", artist); } @@ -115,7 +114,9 @@ namespace MediaBrowser.XbmcMetadata.Savers { if (!string.IsNullOrEmpty(movie.CollectionName)) { - writer.WriteElementString("set", movie.CollectionName); + writer.WriteStartElement("set"); + writer.WriteElementString("name", movie.CollectionName); + writer.WriteEndElement(); } } } diff --git a/MediaBrowser.XbmcMetadata/Savers/SeriesNfoSaver.cs b/MediaBrowser.XbmcMetadata/Savers/SeriesNfoSaver.cs index 083f22e5d2..1ac6768a16 100644 --- a/MediaBrowser.XbmcMetadata/Savers/SeriesNfoSaver.cs +++ b/MediaBrowser.XbmcMetadata/Savers/SeriesNfoSaver.cs @@ -54,9 +54,7 @@ namespace MediaBrowser.XbmcMetadata.Savers { var series = (Series)item; - var tvdb = item.GetProviderId(MetadataProvider.Tvdb); - - if (!string.IsNullOrEmpty(tvdb)) + if (item.TryGetProviderId(MetadataProvider.Tvdb, out var tvdb)) { writer.WriteElementString("id", tvdb); diff --git a/README.md b/README.md index 7da0cb30d0..a07fd32ceb 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ These instructions will help you get set up with a local development environment ### Prerequisites -Before the project can be built, you must first install the [.NET 8.0 SDK](https://dotnet.microsoft.com/download/dotnet) on your system. +Before the project can be built, you must first install the [.NET 9.0 SDK](https://dotnet.microsoft.com/download/dotnet) on your system. Instructions to run this project from the command line are included here, but you will also need to install an IDE if you want to debug the server while it is running. Any IDE that supports .NET 6 development will work, but two options are recent versions of [Visual Studio](https://visualstudio.microsoft.com/downloads/) (at least 2022) and [Visual Studio Code](https://code.visualstudio.com/Download). @@ -131,7 +131,7 @@ A second option is to build the project and then run the resulting executable fi ```bash dotnet build # Build the project -cd Jellyfin.Server/bin/Debug/net8.0 # Change into the build output directory +cd Jellyfin.Server/bin/Debug/net9.0 # Change into the build output directory ``` 2. Execute the build output. On Linux, Mac, etc. use `./jellyfin` and on Windows use `jellyfin.exe`. diff --git a/SharedVersion.cs b/SharedVersion.cs index f98cfbc74e..d26eb31aec 100644 --- a/SharedVersion.cs +++ b/SharedVersion.cs @@ -1,4 +1,4 @@ using System.Reflection; -[assembly: AssemblyVersion("10.10.0")] -[assembly: AssemblyFileVersion("10.10.0")] +[assembly: AssemblyVersion("10.11.0")] +[assembly: AssemblyFileVersion("10.11.0")] diff --git a/bump_version b/bump_version index 72bbbfbf51..6d08dc72fe 100755 --- a/bump_version +++ b/bump_version @@ -28,6 +28,7 @@ jellyfin_subprojects=( Emby.Naming/Emby.Naming.csproj src/Jellyfin.Extensions/Jellyfin.Extensions.csproj ) +issue_template_file="./.github/ISSUE_TEMPLATE/issue report.yml" new_version="$1" new_version_sed="$( cut -f1 -d'-' <<<"${new_version}" )" @@ -56,6 +57,9 @@ for subproject in ${jellyfin_subprojects[@]}; do sed -i "s|${old_version}|${new_version_sed}|g" ${subproject} done +# Set the version in the GitHub issue template file +sed -i "s|${old_version}|${new_version_sed}|g" ${issue_template_file} + # Stage the changed files for commit git add . git status -v diff --git a/fuzz/Emby.Server.Implementations.Fuzz/Emby.Server.Implementations.Fuzz.csproj b/fuzz/Emby.Server.Implementations.Fuzz/Emby.Server.Implementations.Fuzz.csproj index 73aae3f3df..1373d2fe05 100644 --- a/fuzz/Emby.Server.Implementations.Fuzz/Emby.Server.Implementations.Fuzz.csproj +++ b/fuzz/Emby.Server.Implementations.Fuzz/Emby.Server.Implementations.Fuzz.csproj @@ -2,7 +2,7 @@ Exe - net8.0 + net9.0 diff --git a/fuzz/Emby.Server.Implementations.Fuzz/fuzz.sh b/fuzz/Emby.Server.Implementations.Fuzz/fuzz.sh index 80a5cd7c1f..8183bb37ad 100755 --- a/fuzz/Emby.Server.Implementations.Fuzz/fuzz.sh +++ b/fuzz/Emby.Server.Implementations.Fuzz/fuzz.sh @@ -8,4 +8,4 @@ cp bin/Emby.Server.Implementations.dll . dotnet build mkdir -p Findings -AFL_SKIP_BIN_CHECK=1 afl-fuzz -i "Testcases/$1" -o "Findings/$1" -t 5000 ./bin/Debug/net8.0/Emby.Server.Implementations.Fuzz "$1" +AFL_SKIP_BIN_CHECK=1 afl-fuzz -i "Testcases/$1" -o "Findings/$1" -t 5000 ./bin/Debug/net9.0/Emby.Server.Implementations.Fuzz "$1" diff --git a/fuzz/Jellyfin.Api.Fuzz/Jellyfin.Api.Fuzz.csproj b/fuzz/Jellyfin.Api.Fuzz/Jellyfin.Api.Fuzz.csproj index faac7d976f..04c7be11d5 100644 --- a/fuzz/Jellyfin.Api.Fuzz/Jellyfin.Api.Fuzz.csproj +++ b/fuzz/Jellyfin.Api.Fuzz/Jellyfin.Api.Fuzz.csproj @@ -2,7 +2,7 @@ Exe - net8.0 + net9.0 diff --git a/fuzz/Jellyfin.Api.Fuzz/fuzz.sh b/fuzz/Jellyfin.Api.Fuzz/fuzz.sh index 96b0192cf7..15148e1bb2 100755 --- a/fuzz/Jellyfin.Api.Fuzz/fuzz.sh +++ b/fuzz/Jellyfin.Api.Fuzz/fuzz.sh @@ -8,4 +8,4 @@ cp bin/Jellyfin.Api.dll . dotnet build mkdir -p Findings -AFL_SKIP_BIN_CHECK=1 afl-fuzz -i "Testcases/$1" -o "Findings/$1" -t 5000 ./bin/Debug/net8.0/Jellyfin.Api.Fuzz "$1" +AFL_SKIP_BIN_CHECK=1 afl-fuzz -i "Testcases/$1" -o "Findings/$1" -t 5000 ./bin/Debug/net9.0/Jellyfin.Api.Fuzz "$1" diff --git a/global.json b/global.json index dbf2988d57..2e13a6387d 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "8.0.0", + "version": "9.0.0", "rollForward": "latestMinor" } } diff --git a/jellyfin.code-workspace b/jellyfin.code-workspace index 7882b38219..844c69dbc9 100644 --- a/jellyfin.code-workspace +++ b/jellyfin.code-workspace @@ -12,6 +12,6 @@ "**/jellyfin-web": true, "**/obj": true }, - "formatFiles.excludePattern": "**/node_modules,**/.vscode,**/dist/**,**/.chrome,ThirdParty,RSSDP,Mono.Nat,unRaid,debian" + "formatFiles.excludePattern": "**/node_modules,**/.vscode,**/dist/**,**/.chrome,ThirdParty,unRaid,debian" } } diff --git a/jellyfin.ruleset b/jellyfin.ruleset deleted file mode 100644 index 67ffd9a37b..0000000000 --- a/jellyfin.ruleset +++ /dev/null @@ -1,221 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DbConfiguration/DatabaseConfigurationOptions.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DbConfiguration/DatabaseConfigurationOptions.cs new file mode 100644 index 0000000000..b481a106fd --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DbConfiguration/DatabaseConfigurationOptions.cs @@ -0,0 +1,12 @@ +namespace Jellyfin.Database.Implementations.DbConfiguration; + +/// +/// Options to configure jellyfins managed database. +/// +public class DatabaseConfigurationOptions +{ + /// + /// Gets or Sets the type of database jellyfin should use. + /// + public required string DatabaseType { get; set; } +} diff --git a/Jellyfin.Data/Entities/AccessSchedule.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/AccessSchedule.cs similarity index 94% rename from Jellyfin.Data/Entities/AccessSchedule.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/AccessSchedule.cs index f534e49f3f..e23ac86aaa 100644 --- a/Jellyfin.Data/Entities/AccessSchedule.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/AccessSchedule.cs @@ -1,9 +1,9 @@ using System; using System.ComponentModel.DataAnnotations.Schema; using System.Xml.Serialization; -using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; -namespace Jellyfin.Data.Entities +namespace Jellyfin.Database.Implementations.Entities { /// /// An entity representing a user's access schedule. diff --git a/Jellyfin.Data/Entities/ActivityLog.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/ActivityLog.cs similarity index 97% rename from Jellyfin.Data/Entities/ActivityLog.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/ActivityLog.cs index 51dd0ffb8e..bf623be7ed 100644 --- a/Jellyfin.Data/Entities/ActivityLog.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/ActivityLog.cs @@ -1,10 +1,10 @@ using System; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -using Jellyfin.Data.Interfaces; +using Jellyfin.Database.Implementations.Interfaces; using Microsoft.Extensions.Logging; -namespace Jellyfin.Data.Entities +namespace Jellyfin.Database.Implementations.Entities { /// /// An entity referencing an activity log entry. diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/AncestorId.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/AncestorId.cs new file mode 100644 index 0000000000..3d25ae4f45 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/AncestorId.cs @@ -0,0 +1,29 @@ +using System; + +namespace Jellyfin.Database.Implementations.Entities; + +/// +/// Represents the relational information for an . +/// +public class AncestorId +{ + /// + /// Gets or Sets the AncestorId. + /// + public required Guid ParentItemId { get; set; } + + /// + /// Gets or Sets the related BaseItem. + /// + public required Guid ItemId { get; set; } + + /// + /// Gets or Sets the ParentItem. + /// + public required BaseItemEntity ParentItem { get; set; } + + /// + /// Gets or Sets the Child item. + /// + public required BaseItemEntity Item { get; set; } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/AttachmentStreamInfo.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/AttachmentStreamInfo.cs new file mode 100644 index 0000000000..2f27d9389a --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/AttachmentStreamInfo.cs @@ -0,0 +1,49 @@ +using System; + +namespace Jellyfin.Database.Implementations.Entities; + +/// +/// Provides information about an Attachment to an . +/// +public class AttachmentStreamInfo +{ + /// + /// Gets or Sets the reference. + /// + public required Guid ItemId { get; set; } + + /// + /// Gets or Sets the reference. + /// + public required BaseItemEntity Item { get; set; } + + /// + /// Gets or Sets the index within the source file. + /// + public required int Index { get; set; } + + /// + /// Gets or Sets the codec of the attachment. + /// + public string? Codec { get; set; } + + /// + /// Gets or Sets the codec tag of the attachment. + /// + public string? CodecTag { get; set; } + + /// + /// Gets or Sets the comment of the attachment. + /// + public string? Comment { get; set; } + + /// + /// Gets or Sets the filename of the attachment. + /// + public string? Filename { get; set; } + + /// + /// Gets or Sets the attachments mimetype. + /// + public string? MimeType { get; set; } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/BaseItemEntity.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/BaseItemEntity.cs new file mode 100644 index 0000000000..a09a96317c --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/BaseItemEntity.cs @@ -0,0 +1,186 @@ +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member +#pragma warning disable CA2227 // Collection properties should be read only + +using System; +using System.Collections.Generic; + +namespace Jellyfin.Database.Implementations.Entities; + +public class BaseItemEntity +{ + public required Guid Id { get; set; } + + public required string Type { get; set; } + + public string? Data { get; set; } + + public string? Path { get; set; } + + public DateTime? StartDate { get; set; } + + public DateTime? EndDate { get; set; } + + public Guid? ChannelId { get; set; } + + public bool IsMovie { get; set; } + + public float? CommunityRating { get; set; } + + public string? CustomRating { get; set; } + + public int? IndexNumber { get; set; } + + public bool IsLocked { get; set; } + + public string? Name { get; set; } + + public string? OfficialRating { get; set; } + + public string? MediaType { get; set; } + + public string? Overview { get; set; } + + public int? ParentIndexNumber { get; set; } + + public DateTime? PremiereDate { get; set; } + + public int? ProductionYear { get; set; } + + public string? Genres { get; set; } + + public string? SortName { get; set; } + + public string? ForcedSortName { get; set; } + + public long? RunTimeTicks { get; set; } + + public DateTime? DateCreated { get; set; } + + public DateTime? DateModified { get; set; } + + public bool IsSeries { get; set; } + + public string? EpisodeTitle { get; set; } + + public bool IsRepeat { get; set; } + + public string? PreferredMetadataLanguage { get; set; } + + public string? PreferredMetadataCountryCode { get; set; } + + public DateTime? DateLastRefreshed { get; set; } + + public DateTime? DateLastSaved { get; set; } + + public bool IsInMixedFolder { get; set; } + + public string? Studios { get; set; } + + public string? ExternalServiceId { get; set; } + + public string? Tags { get; set; } + + public bool IsFolder { get; set; } + + public int? InheritedParentalRatingValue { get; set; } + + public int? InheritedParentalRatingSubValue { get; set; } + + public string? UnratedType { get; set; } + + public float? CriticRating { get; set; } + + public string? CleanName { get; set; } + + public string? PresentationUniqueKey { get; set; } + + public string? OriginalTitle { get; set; } + + public string? PrimaryVersionId { get; set; } + + public DateTime? DateLastMediaAdded { get; set; } + + public string? Album { get; set; } + + public float? LUFS { get; set; } + + public float? NormalizationGain { get; set; } + + public bool IsVirtualItem { get; set; } + + public string? SeriesName { get; set; } + + public string? SeasonName { get; set; } + + public string? ExternalSeriesId { get; set; } + + public string? Tagline { get; set; } + + public string? ProductionLocations { get; set; } + + public string? ExtraIds { get; set; } + + public int? TotalBitrate { get; set; } + + public BaseItemExtraType? ExtraType { get; set; } + + public string? Artists { get; set; } + + public string? AlbumArtists { get; set; } + + public string? ExternalId { get; set; } + + public string? SeriesPresentationUniqueKey { get; set; } + + public string? ShowId { get; set; } + + public string? OwnerId { get; set; } + + public int? Width { get; set; } + + public int? Height { get; set; } + + public long? Size { get; set; } + + public ProgramAudioEntity? Audio { get; set; } + + public Guid? ParentId { get; set; } + + public Guid? TopParentId { get; set; } + + public Guid? SeasonId { get; set; } + + public Guid? SeriesId { get; set; } + + public ICollection? Peoples { get; set; } + + public ICollection? UserData { get; set; } + + public ICollection? ItemValues { get; set; } + + public ICollection? MediaStreams { get; set; } + + public ICollection? Chapters { get; set; } + + public ICollection? Provider { get; set; } + + public ICollection? Parents { get; set; } + + public ICollection? Children { get; set; } + + public ICollection? LockedFields { get; set; } + + public ICollection? TrailerTypes { get; set; } + + public ICollection? Images { get; set; } + + // those are references to __LOCAL__ ids not DB ids ... TODO: Bring the whole folder structure into the DB + // public ICollection? SeriesEpisodes { get; set; } + // public BaseItemEntity? Series { get; set; } + // public BaseItemEntity? Season { get; set; } + // public BaseItemEntity? Parent { get; set; } + // public ICollection? DirectChildren { get; set; } + // public BaseItemEntity? TopParent { get; set; } + // public ICollection? AllChildren { get; set; } + // public ICollection? SeasonEpisodes { get; set; } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/BaseItemExtraType.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/BaseItemExtraType.cs new file mode 100644 index 0000000000..46a59f7903 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/BaseItemExtraType.cs @@ -0,0 +1,18 @@ +#pragma warning disable CS1591 +namespace Jellyfin.Database.Implementations.Entities; + +public enum BaseItemExtraType +{ + Unknown = 0, + Clip = 1, + Trailer = 2, + BehindTheScenes = 3, + DeletedScene = 4, + Interview = 5, + Scene = 6, + Sample = 7, + ThemeSong = 8, + ThemeVideo = 9, + Featurette = 10, + Short = 11 +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/BaseItemImageInfo.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/BaseItemImageInfo.cs new file mode 100644 index 0000000000..71d60fc25d --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/BaseItemImageInfo.cs @@ -0,0 +1,58 @@ +#pragma warning disable CA2227 + +using System; + +namespace Jellyfin.Database.Implementations.Entities; + +/// +/// Enum TrailerTypes. +/// +public class BaseItemImageInfo +{ + /// + /// Gets or Sets. + /// + public required Guid Id { get; set; } + + /// + /// Gets or Sets the path to the original image. + /// + public required string Path { get; set; } + + /// + /// Gets or Sets the time the image was last modified. + /// + public DateTime DateModified { get; set; } + + /// + /// Gets or Sets the imagetype. + /// + public ImageInfoImageType ImageType { get; set; } + + /// + /// Gets or Sets the width of the original image. + /// + public int Width { get; set; } + + /// + /// Gets or Sets the height of the original image. + /// + public int Height { get; set; } + +#pragma warning disable CA1819 // Properties should not return arrays + /// + /// Gets or Sets the blurhash. + /// + public byte[]? Blurhash { get; set; } +#pragma warning restore CA1819 + + /// + /// Gets or Sets the reference id to the BaseItem. + /// + public required Guid ItemId { get; set; } + + /// + /// Gets or Sets the referenced Item. + /// + public required BaseItemEntity Item { get; set; } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/BaseItemMetadataField.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/BaseItemMetadataField.cs new file mode 100644 index 0000000000..e7dbc8e9fd --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/BaseItemMetadataField.cs @@ -0,0 +1,24 @@ +using System; + +namespace Jellyfin.Database.Implementations.Entities; + +/// +/// Enum MetadataFields. +/// +public class BaseItemMetadataField +{ + /// + /// Gets or Sets Numerical ID of this enumerable. + /// + public required int Id { get; set; } + + /// + /// Gets or Sets all referenced . + /// + public required Guid ItemId { get; set; } + + /// + /// Gets or Sets all referenced . + /// + public required BaseItemEntity Item { get; set; } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/BaseItemProvider.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/BaseItemProvider.cs new file mode 100644 index 0000000000..73bb583e31 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/BaseItemProvider.cs @@ -0,0 +1,29 @@ +using System; + +namespace Jellyfin.Database.Implementations.Entities; + +/// +/// Represents a Key-Value relation of an BaseItem's provider. +/// +public class BaseItemProvider +{ + /// + /// Gets or Sets the reference ItemId. + /// + public Guid ItemId { get; set; } + + /// + /// Gets or Sets the reference BaseItem. + /// + public required BaseItemEntity Item { get; set; } + + /// + /// Gets or Sets the ProvidersId. + /// + public required string ProviderId { get; set; } + + /// + /// Gets or Sets the Providers Value. + /// + public required string ProviderValue { get; set; } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/BaseItemTrailerType.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/BaseItemTrailerType.cs new file mode 100644 index 0000000000..db329ad2af --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/BaseItemTrailerType.cs @@ -0,0 +1,24 @@ +using System; + +namespace Jellyfin.Database.Implementations.Entities; + +/// +/// Enum TrailerTypes. +/// +public class BaseItemTrailerType +{ + /// + /// Gets or Sets Numerical ID of this enumerable. + /// + public required int Id { get; set; } + + /// + /// Gets or Sets all referenced . + /// + public required Guid ItemId { get; set; } + + /// + /// Gets or Sets all referenced . + /// + public required BaseItemEntity Item { get; set; } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Chapter.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Chapter.cs new file mode 100644 index 0000000000..f9b9813281 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Chapter.cs @@ -0,0 +1,44 @@ +using System; + +namespace Jellyfin.Database.Implementations.Entities; + +/// +/// The Chapter entity. +/// +public class Chapter +{ + /// + /// Gets or Sets the reference id. + /// + public required Guid ItemId { get; set; } + + /// + /// Gets or Sets the reference. + /// + public required BaseItemEntity Item { get; set; } + + /// + /// Gets or Sets the chapters index in Item. + /// + public required int ChapterIndex { get; set; } + + /// + /// Gets or Sets the position within the source file. + /// + public required long StartPositionTicks { get; set; } + + /// + /// Gets or Sets the common name. + /// + public string? Name { get; set; } + + /// + /// Gets or Sets the image path. + /// + public string? ImagePath { get; set; } + + /// + /// Gets or Sets the time the image was last modified. + /// + public DateTime? ImageDateModified { get; set; } +} diff --git a/Jellyfin.Data/Entities/CustomItemDisplayPreferences.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/CustomItemDisplayPreferences.cs similarity index 97% rename from Jellyfin.Data/Entities/CustomItemDisplayPreferences.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/CustomItemDisplayPreferences.cs index a606595122..b3d4b16bc6 100644 --- a/Jellyfin.Data/Entities/CustomItemDisplayPreferences.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/CustomItemDisplayPreferences.cs @@ -2,7 +2,7 @@ using System; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -namespace Jellyfin.Data.Entities +namespace Jellyfin.Database.Implementations.Entities { /// /// An entity that represents a user's custom display preferences for a specific item. diff --git a/Jellyfin.Data/Entities/DisplayPreferences.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/DisplayPreferences.cs similarity index 97% rename from Jellyfin.Data/Entities/DisplayPreferences.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/DisplayPreferences.cs index f0be657691..ae6966e594 100644 --- a/Jellyfin.Data/Entities/DisplayPreferences.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/DisplayPreferences.cs @@ -2,9 +2,9 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; -namespace Jellyfin.Data.Entities +namespace Jellyfin.Database.Implementations.Entities { /// /// An entity representing a user's display preferences. diff --git a/Jellyfin.Data/Entities/Group.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Group.cs similarity index 79% rename from Jellyfin.Data/Entities/Group.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Group.cs index 1be6f986a1..9dd2486464 100644 --- a/Jellyfin.Data/Entities/Group.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Group.cs @@ -1,11 +1,9 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -using System.Linq; -using Jellyfin.Data.Enums; -using Jellyfin.Data.Interfaces; +using Jellyfin.Database.Implementations.Interfaces; -namespace Jellyfin.Data.Entities +namespace Jellyfin.Database.Implementations.Entities { /// /// An entity representing a group. @@ -59,18 +57,6 @@ namespace Jellyfin.Data.Entities /// public virtual ICollection Preferences { get; private set; } - /// - public bool HasPermission(PermissionKind kind) - { - return Permissions.First(p => p.Kind == kind).Value; - } - - /// - public void SetPermission(PermissionKind kind, bool value) - { - Permissions.First(p => p.Kind == kind).Value = value; - } - /// public void OnSavingChanges() { diff --git a/Jellyfin.Data/Entities/HomeSection.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/HomeSection.cs similarity index 91% rename from Jellyfin.Data/Entities/HomeSection.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/HomeSection.cs index 8dd6e647e2..584550ac56 100644 --- a/Jellyfin.Data/Entities/HomeSection.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/HomeSection.cs @@ -1,7 +1,7 @@ using System.ComponentModel.DataAnnotations.Schema; -using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; -namespace Jellyfin.Data.Entities +namespace Jellyfin.Database.Implementations.Entities { /// /// An entity representing a section on the user's home page. diff --git a/Jellyfin.Data/Entities/ImageInfo.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/ImageInfo.cs similarity index 96% rename from Jellyfin.Data/Entities/ImageInfo.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/ImageInfo.cs index 935a53a263..9c0b36852e 100644 --- a/Jellyfin.Data/Entities/ImageInfo.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/ImageInfo.cs @@ -2,7 +2,7 @@ using System; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -namespace Jellyfin.Data.Entities +namespace Jellyfin.Database.Implementations.Entities { /// /// An entity representing an image. diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/ImageInfoImageType.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/ImageInfoImageType.cs new file mode 100644 index 0000000000..6052a95bb2 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/ImageInfoImageType.cs @@ -0,0 +1,76 @@ +namespace Jellyfin.Database.Implementations.Entities; + +/// +/// Enum ImageType. +/// +public enum ImageInfoImageType +{ + /// + /// The primary. + /// + Primary = 0, + + /// + /// The art. + /// + Art = 1, + + /// + /// The backdrop. + /// + Backdrop = 2, + + /// + /// The banner. + /// + Banner = 3, + + /// + /// The logo. + /// + Logo = 4, + + /// + /// The thumb. + /// + Thumb = 5, + + /// + /// The disc. + /// + Disc = 6, + + /// + /// The box. + /// + Box = 7, + + /// + /// The screenshot. + /// + /// + /// This enum value is obsolete. + /// XmlSerializer does not serialize/deserialize objects that are marked as [Obsolete]. + /// + Screenshot = 8, + + /// + /// The menu. + /// + Menu = 9, + + /// + /// The chapter image. + /// + Chapter = 10, + + /// + /// The box rear. + /// + BoxRear = 11, + + /// + /// The user profile image. + /// + Profile = 12 +} diff --git a/Jellyfin.Data/Entities/ItemDisplayPreferences.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/ItemDisplayPreferences.cs similarity index 96% rename from Jellyfin.Data/Entities/ItemDisplayPreferences.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/ItemDisplayPreferences.cs index 93e6664ea4..6770531143 100644 --- a/Jellyfin.Data/Entities/ItemDisplayPreferences.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/ItemDisplayPreferences.cs @@ -1,9 +1,9 @@ using System; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; -namespace Jellyfin.Data.Entities +namespace Jellyfin.Database.Implementations.Entities { /// /// An entity that represents a user's display preferences for a specific item. diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/ItemValue.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/ItemValue.cs new file mode 100644 index 0000000000..b5a31921d8 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/ItemValue.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; + +namespace Jellyfin.Database.Implementations.Entities; + +/// +/// Represents an ItemValue for a BaseItem. +/// +public class ItemValue +{ + /// + /// Gets or Sets the ItemValueId. + /// + public required Guid ItemValueId { get; set; } + + /// + /// Gets or Sets the Type. + /// + public required ItemValueType Type { get; set; } + + /// + /// Gets or Sets the Value. + /// + public required string Value { get; set; } + + /// + /// Gets or Sets the sanitized Value. + /// + public required string CleanValue { get; set; } + + /// + /// Gets or Sets all associated BaseItems. + /// +#pragma warning disable CA2227 // Collection properties should be read only + public ICollection? BaseItemsMap { get; set; } +#pragma warning restore CA2227 // Collection properties should be read only +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/ItemValueMap.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/ItemValueMap.cs new file mode 100644 index 0000000000..23f6e0f7bc --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/ItemValueMap.cs @@ -0,0 +1,29 @@ +using System; + +namespace Jellyfin.Database.Implementations.Entities; + +/// +/// Mapping table for the ItemValue BaseItem relation. +/// +public class ItemValueMap +{ + /// + /// Gets or Sets the ItemId. + /// + public required Guid ItemId { get; set; } + + /// + /// Gets or Sets the ItemValueId. + /// + public required Guid ItemValueId { get; set; } + + /// + /// Gets or Sets the referenced . + /// + public required BaseItemEntity Item { get; set; } + + /// + /// Gets or Sets the referenced . + /// + public required ItemValue ItemValue { get; set; } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/ItemValueType.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/ItemValueType.cs new file mode 100644 index 0000000000..9e2e11c006 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/ItemValueType.cs @@ -0,0 +1,38 @@ +#pragma warning disable CA1027 // Mark enums with FlagsAttribute +namespace Jellyfin.Database.Implementations.Entities; + +/// +/// Provides the Value types for an . +/// +public enum ItemValueType +{ + /// + /// Artists. + /// + Artist = 0, + + /// + /// Album. + /// + AlbumArtist = 1, + + /// + /// Genre. + /// + Genre = 2, + + /// + /// Studios. + /// + Studios = 3, + + /// + /// Tags. + /// + Tags = 4, + + /// + /// InheritedTags. + /// + InheritedTags = 6, +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/KeyframeData.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/KeyframeData.cs new file mode 100644 index 0000000000..c34110c4f2 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/KeyframeData.cs @@ -0,0 +1,32 @@ +#pragma warning disable CA2227 // Collection properties should be read only + +using System; +using System.Collections.Generic; + +namespace Jellyfin.Database.Implementations.Entities; + +/// +/// Keyframe information for a specific file. +/// +public class KeyframeData +{ + /// + /// Gets or Sets the ItemId. + /// + public required Guid ItemId { get; set; } + + /// + /// Gets or sets the total duration of the stream in ticks. + /// + public long TotalDuration { get; set; } + + /// + /// Gets or sets the keyframes in ticks. + /// + public ICollection? KeyframeTicks { get; set; } + + /// + /// Gets or sets the item reference. + /// + public BaseItemEntity? Item { get; set; } +} diff --git a/Jellyfin.Data/Entities/Libraries/Artwork.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/Artwork.cs similarity index 90% rename from Jellyfin.Data/Entities/Libraries/Artwork.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/Artwork.cs index fc3c1036f0..f3083a96ba 100644 --- a/Jellyfin.Data/Entities/Libraries/Artwork.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/Artwork.cs @@ -1,10 +1,10 @@ using System; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -using Jellyfin.Data.Enums; -using Jellyfin.Data.Interfaces; +using Jellyfin.Database.Implementations.Enums; +using Jellyfin.Database.Implementations.Interfaces; -namespace Jellyfin.Data.Entities.Libraries +namespace Jellyfin.Database.Implementations.Entities.Libraries { /// /// An entity representing artwork. diff --git a/Jellyfin.Data/Entities/Libraries/Book.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/Book.cs similarity index 87% rename from Jellyfin.Data/Entities/Libraries/Book.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/Book.cs index a838686d05..b56c1f9403 100644 --- a/Jellyfin.Data/Entities/Libraries/Book.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/Book.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; -using Jellyfin.Data.Interfaces; +using Jellyfin.Database.Implementations.Interfaces; -namespace Jellyfin.Data.Entities.Libraries +namespace Jellyfin.Database.Implementations.Entities.Libraries { /// /// An entity representing a book. diff --git a/Jellyfin.Data/Entities/Libraries/BookMetadata.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/BookMetadata.cs similarity index 89% rename from Jellyfin.Data/Entities/Libraries/BookMetadata.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/BookMetadata.cs index 4a350d200e..a284d563a3 100644 --- a/Jellyfin.Data/Entities/Libraries/BookMetadata.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/BookMetadata.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; -using Jellyfin.Data.Interfaces; +using Jellyfin.Database.Implementations.Interfaces; -namespace Jellyfin.Data.Entities.Libraries +namespace Jellyfin.Database.Implementations.Entities.Libraries { /// /// An entity containing metadata for a book. diff --git a/Jellyfin.Data/Entities/Libraries/Chapter.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/Chapter.cs similarity index 94% rename from Jellyfin.Data/Entities/Libraries/Chapter.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/Chapter.cs index f068338f92..eac973060e 100644 --- a/Jellyfin.Data/Entities/Libraries/Chapter.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/Chapter.cs @@ -1,9 +1,9 @@ using System; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -using Jellyfin.Data.Interfaces; +using Jellyfin.Database.Implementations.Interfaces; -namespace Jellyfin.Data.Entities.Libraries +namespace Jellyfin.Database.Implementations.Entities.Libraries { /// /// An entity representing a chapter. diff --git a/Jellyfin.Data/Entities/Libraries/Collection.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/Collection.cs similarity index 92% rename from Jellyfin.Data/Entities/Libraries/Collection.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/Collection.cs index 7de6019692..03b68317a6 100644 --- a/Jellyfin.Data/Entities/Libraries/Collection.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/Collection.cs @@ -3,9 +3,9 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -using Jellyfin.Data.Interfaces; +using Jellyfin.Database.Implementations.Interfaces; -namespace Jellyfin.Data.Entities.Libraries +namespace Jellyfin.Database.Implementations.Entities.Libraries { /// /// An entity representing a collection. diff --git a/Jellyfin.Data/Entities/Libraries/CollectionItem.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/CollectionItem.cs similarity index 87% rename from Jellyfin.Data/Entities/Libraries/CollectionItem.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/CollectionItem.cs index 0cb4716dbe..3777c705b8 100644 --- a/Jellyfin.Data/Entities/Libraries/CollectionItem.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/CollectionItem.cs @@ -1,8 +1,8 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -using Jellyfin.Data.Interfaces; +using Jellyfin.Database.Implementations.Interfaces; -namespace Jellyfin.Data.Entities.Libraries +namespace Jellyfin.Database.Implementations.Entities.Libraries { /// /// An entity representing a collection item. @@ -43,7 +43,7 @@ namespace Jellyfin.Data.Entities.Libraries /// Gets or sets the next item in the collection. /// /// - /// TODO check if this properly updated Dependant and has the proper principal relationship. + /// TODO check if this properly updated Dependent and has the proper principal relationship. /// public virtual CollectionItem? Next { get; set; } @@ -51,7 +51,7 @@ namespace Jellyfin.Data.Entities.Libraries /// Gets or sets the previous item in the collection. /// /// - /// TODO check if this properly updated Dependant and has the proper principal relationship. + /// TODO check if this properly updated Dependent and has the proper principal relationship. /// public virtual CollectionItem? Previous { get; set; } diff --git a/Jellyfin.Data/Entities/Libraries/Company.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/Company.cs similarity index 93% rename from Jellyfin.Data/Entities/Libraries/Company.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/Company.cs index 1abbee4458..c686751ab7 100644 --- a/Jellyfin.Data/Entities/Libraries/Company.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/Company.cs @@ -1,9 +1,9 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -using Jellyfin.Data.Interfaces; +using Jellyfin.Database.Implementations.Interfaces; -namespace Jellyfin.Data.Entities.Libraries +namespace Jellyfin.Database.Implementations.Entities.Libraries { /// /// An entity representing a company. diff --git a/Jellyfin.Data/Entities/Libraries/CompanyMetadata.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/CompanyMetadata.cs similarity index 96% rename from Jellyfin.Data/Entities/Libraries/CompanyMetadata.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/CompanyMetadata.cs index a29f08c7f6..fdf1f274fb 100644 --- a/Jellyfin.Data/Entities/Libraries/CompanyMetadata.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/CompanyMetadata.cs @@ -1,6 +1,6 @@ using System.ComponentModel.DataAnnotations; -namespace Jellyfin.Data.Entities.Libraries +namespace Jellyfin.Database.Implementations.Entities.Libraries { /// /// An entity holding metadata for a . diff --git a/Jellyfin.Data/Entities/Libraries/CustomItem.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/CustomItem.cs similarity index 88% rename from Jellyfin.Data/Entities/Libraries/CustomItem.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/CustomItem.cs index e27d01d860..70e47d6bc6 100644 --- a/Jellyfin.Data/Entities/Libraries/CustomItem.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/CustomItem.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; -using Jellyfin.Data.Interfaces; +using Jellyfin.Database.Implementations.Interfaces; -namespace Jellyfin.Data.Entities.Libraries +namespace Jellyfin.Database.Implementations.Entities.Libraries { /// /// An entity representing a custom item. diff --git a/Jellyfin.Data/Entities/Libraries/CustomItemMetadata.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/CustomItemMetadata.cs similarity index 89% rename from Jellyfin.Data/Entities/Libraries/CustomItemMetadata.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/CustomItemMetadata.cs index af2393870f..660e535e3a 100644 --- a/Jellyfin.Data/Entities/Libraries/CustomItemMetadata.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/CustomItemMetadata.cs @@ -1,4 +1,4 @@ -namespace Jellyfin.Data.Entities.Libraries +namespace Jellyfin.Database.Implementations.Entities.Libraries { /// /// An entity containing metadata for a custom item. diff --git a/Jellyfin.Data/Entities/Libraries/Episode.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/Episode.cs similarity index 89% rename from Jellyfin.Data/Entities/Libraries/Episode.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/Episode.cs index ce2f0c6178..7cb71f06d9 100644 --- a/Jellyfin.Data/Entities/Libraries/Episode.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/Episode.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; -using Jellyfin.Data.Interfaces; +using Jellyfin.Database.Implementations.Interfaces; -namespace Jellyfin.Data.Entities.Libraries +namespace Jellyfin.Database.Implementations.Entities.Libraries { /// /// An entity representing an episode. diff --git a/Jellyfin.Data/Entities/Libraries/EpisodeMetadata.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/EpisodeMetadata.cs similarity index 95% rename from Jellyfin.Data/Entities/Libraries/EpisodeMetadata.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/EpisodeMetadata.cs index b0ef11e0f2..b5c2c3c2a3 100644 --- a/Jellyfin.Data/Entities/Libraries/EpisodeMetadata.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/EpisodeMetadata.cs @@ -1,6 +1,6 @@ using System.ComponentModel.DataAnnotations; -namespace Jellyfin.Data.Entities.Libraries +namespace Jellyfin.Database.Implementations.Entities.Libraries { /// /// An entity containing metadata for an . diff --git a/Jellyfin.Data/Entities/Libraries/Genre.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/Genre.cs similarity index 91% rename from Jellyfin.Data/Entities/Libraries/Genre.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/Genre.cs index 3b822ee828..442dced2f4 100644 --- a/Jellyfin.Data/Entities/Libraries/Genre.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/Genre.cs @@ -1,8 +1,8 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -using Jellyfin.Data.Interfaces; +using Jellyfin.Database.Implementations.Interfaces; -namespace Jellyfin.Data.Entities.Libraries +namespace Jellyfin.Database.Implementations.Entities.Libraries { /// /// An entity representing a genre. diff --git a/Jellyfin.Data/Entities/Libraries/ItemMetadata.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/ItemMetadata.cs similarity index 97% rename from Jellyfin.Data/Entities/Libraries/ItemMetadata.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/ItemMetadata.cs index fa9276c669..e5cbab7e45 100644 --- a/Jellyfin.Data/Entities/Libraries/ItemMetadata.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/ItemMetadata.cs @@ -2,9 +2,9 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -using Jellyfin.Data.Interfaces; +using Jellyfin.Database.Implementations.Interfaces; -namespace Jellyfin.Data.Entities.Libraries +namespace Jellyfin.Database.Implementations.Entities.Libraries { /// /// An abstract class that holds metadata. diff --git a/Jellyfin.Data/Entities/Libraries/Library.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/Library.cs similarity index 93% rename from Jellyfin.Data/Entities/Libraries/Library.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/Library.cs index 0db42a1c7b..d1877ef438 100644 --- a/Jellyfin.Data/Entities/Libraries/Library.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/Library.cs @@ -1,8 +1,8 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -using Jellyfin.Data.Interfaces; +using Jellyfin.Database.Implementations.Interfaces; -namespace Jellyfin.Data.Entities.Libraries +namespace Jellyfin.Database.Implementations.Entities.Libraries { /// /// An entity representing a library. diff --git a/Jellyfin.Data/Entities/Libraries/LibraryItem.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/LibraryItem.cs similarity index 92% rename from Jellyfin.Data/Entities/Libraries/LibraryItem.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/LibraryItem.cs index d889b871ed..4fccf6d731 100644 --- a/Jellyfin.Data/Entities/Libraries/LibraryItem.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/LibraryItem.cs @@ -1,9 +1,9 @@ using System; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -using Jellyfin.Data.Interfaces; +using Jellyfin.Database.Implementations.Interfaces; -namespace Jellyfin.Data.Entities.Libraries +namespace Jellyfin.Database.Implementations.Entities.Libraries { /// /// An entity representing a library item. diff --git a/Jellyfin.Data/Entities/Libraries/MediaFile.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/MediaFile.cs similarity index 92% rename from Jellyfin.Data/Entities/Libraries/MediaFile.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/MediaFile.cs index 7b5a3af64c..6e435579c7 100644 --- a/Jellyfin.Data/Entities/Libraries/MediaFile.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/MediaFile.cs @@ -2,10 +2,10 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -using Jellyfin.Data.Enums; -using Jellyfin.Data.Interfaces; +using Jellyfin.Database.Implementations.Enums; +using Jellyfin.Database.Implementations.Interfaces; -namespace Jellyfin.Data.Entities.Libraries +namespace Jellyfin.Database.Implementations.Entities.Libraries { /// /// An entity representing a file on disk. diff --git a/Jellyfin.Data/Entities/Libraries/MediaFileStream.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/MediaFileStream.cs similarity index 92% rename from Jellyfin.Data/Entities/Libraries/MediaFileStream.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/MediaFileStream.cs index e24e73ecb7..4552386fee 100644 --- a/Jellyfin.Data/Entities/Libraries/MediaFileStream.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/MediaFileStream.cs @@ -2,9 +2,9 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -using Jellyfin.Data.Interfaces; +using Jellyfin.Database.Implementations.Interfaces; -namespace Jellyfin.Data.Entities.Libraries +namespace Jellyfin.Database.Implementations.Entities.Libraries { /// /// An entity representing a stream in a media file. diff --git a/Jellyfin.Data/Entities/Libraries/MetadataProvider.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/MetadataProvider.cs similarity index 92% rename from Jellyfin.Data/Entities/Libraries/MetadataProvider.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/MetadataProvider.cs index b38d6a4f1f..dc8f153500 100644 --- a/Jellyfin.Data/Entities/Libraries/MetadataProvider.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/MetadataProvider.cs @@ -1,9 +1,9 @@ using System; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -using Jellyfin.Data.Interfaces; +using Jellyfin.Database.Implementations.Interfaces; -namespace Jellyfin.Data.Entities.Libraries +namespace Jellyfin.Database.Implementations.Entities.Libraries { /// /// An entity representing a metadata provider. diff --git a/Jellyfin.Data/Entities/Libraries/MetadataProviderId.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/MetadataProviderId.cs similarity index 94% rename from Jellyfin.Data/Entities/Libraries/MetadataProviderId.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/MetadataProviderId.cs index a198f53ba3..b7c9313a26 100644 --- a/Jellyfin.Data/Entities/Libraries/MetadataProviderId.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/MetadataProviderId.cs @@ -1,9 +1,9 @@ using System; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -using Jellyfin.Data.Interfaces; +using Jellyfin.Database.Implementations.Interfaces; -namespace Jellyfin.Data.Entities.Libraries +namespace Jellyfin.Database.Implementations.Entities.Libraries { /// /// An entity representing a unique identifier for a metadata provider. diff --git a/Jellyfin.Data/Entities/Libraries/Movie.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/Movie.cs similarity index 87% rename from Jellyfin.Data/Entities/Libraries/Movie.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/Movie.cs index 499fafd0e1..afc0e0f435 100644 --- a/Jellyfin.Data/Entities/Libraries/Movie.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/Movie.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; -using Jellyfin.Data.Interfaces; +using Jellyfin.Database.Implementations.Interfaces; -namespace Jellyfin.Data.Entities.Libraries +namespace Jellyfin.Database.Implementations.Entities.Libraries { /// /// An entity representing a movie. diff --git a/Jellyfin.Data/Entities/Libraries/MovieMetadata.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/MovieMetadata.cs similarity index 94% rename from Jellyfin.Data/Entities/Libraries/MovieMetadata.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/MovieMetadata.cs index 44b5f34d7f..3d797d97e7 100644 --- a/Jellyfin.Data/Entities/Libraries/MovieMetadata.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/MovieMetadata.cs @@ -1,8 +1,8 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -using Jellyfin.Data.Interfaces; +using Jellyfin.Database.Implementations.Interfaces; -namespace Jellyfin.Data.Entities.Libraries +namespace Jellyfin.Database.Implementations.Entities.Libraries { /// /// An entity holding the metadata for a movie. diff --git a/Jellyfin.Data/Entities/Libraries/MusicAlbum.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/MusicAlbum.cs similarity index 93% rename from Jellyfin.Data/Entities/Libraries/MusicAlbum.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/MusicAlbum.cs index d6231bbf02..51f77ce0be 100644 --- a/Jellyfin.Data/Entities/Libraries/MusicAlbum.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/MusicAlbum.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace Jellyfin.Data.Entities.Libraries +namespace Jellyfin.Database.Implementations.Entities.Libraries { /// /// An entity representing a music album. diff --git a/Jellyfin.Data/Entities/Libraries/MusicAlbumMetadata.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/MusicAlbumMetadata.cs similarity index 96% rename from Jellyfin.Data/Entities/Libraries/MusicAlbumMetadata.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/MusicAlbumMetadata.cs index 691f3504fa..bfb94c44d6 100644 --- a/Jellyfin.Data/Entities/Libraries/MusicAlbumMetadata.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/MusicAlbumMetadata.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -namespace Jellyfin.Data.Entities.Libraries +namespace Jellyfin.Database.Implementations.Entities.Libraries { /// /// An entity holding the metadata for a music album. diff --git a/Jellyfin.Data/Entities/Libraries/Person.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/Person.cs similarity index 95% rename from Jellyfin.Data/Entities/Libraries/Person.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/Person.cs index 90dc55b70d..25cdfdc2ee 100644 --- a/Jellyfin.Data/Entities/Libraries/Person.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/Person.cs @@ -2,9 +2,9 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -using Jellyfin.Data.Interfaces; +using Jellyfin.Database.Implementations.Interfaces; -namespace Jellyfin.Data.Entities.Libraries +namespace Jellyfin.Database.Implementations.Entities.Libraries { /// /// An entity representing a person. diff --git a/Jellyfin.Data/Entities/Libraries/PersonRole.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/PersonRole.cs similarity index 93% rename from Jellyfin.Data/Entities/Libraries/PersonRole.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/PersonRole.cs index 7d40bdf448..e1c2113908 100644 --- a/Jellyfin.Data/Entities/Libraries/PersonRole.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/PersonRole.cs @@ -1,10 +1,10 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -using Jellyfin.Data.Enums; -using Jellyfin.Data.Interfaces; +using Jellyfin.Database.Implementations.Enums; +using Jellyfin.Database.Implementations.Interfaces; -namespace Jellyfin.Data.Entities.Libraries +namespace Jellyfin.Database.Implementations.Entities.Libraries { /// /// An entity representing a person's role in media. diff --git a/Jellyfin.Data/Entities/Libraries/Photo.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/Photo.cs similarity index 87% rename from Jellyfin.Data/Entities/Libraries/Photo.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/Photo.cs index 4b459432bc..b113170e1d 100644 --- a/Jellyfin.Data/Entities/Libraries/Photo.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/Photo.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; -using Jellyfin.Data.Interfaces; +using Jellyfin.Database.Implementations.Interfaces; -namespace Jellyfin.Data.Entities.Libraries +namespace Jellyfin.Database.Implementations.Entities.Libraries { /// /// An entity representing a photo. diff --git a/Jellyfin.Data/Entities/Libraries/PhotoMetadata.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/PhotoMetadata.cs similarity index 89% rename from Jellyfin.Data/Entities/Libraries/PhotoMetadata.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/PhotoMetadata.cs index 6c284307d7..6fae4a024f 100644 --- a/Jellyfin.Data/Entities/Libraries/PhotoMetadata.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/PhotoMetadata.cs @@ -1,4 +1,4 @@ -namespace Jellyfin.Data.Entities.Libraries +namespace Jellyfin.Database.Implementations.Entities.Libraries { /// /// An entity that holds metadata for a photo. diff --git a/Jellyfin.Data/Entities/Libraries/Rating.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/Rating.cs similarity index 93% rename from Jellyfin.Data/Entities/Libraries/Rating.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/Rating.cs index 58c8fa49ef..627575024e 100644 --- a/Jellyfin.Data/Entities/Libraries/Rating.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/Rating.cs @@ -1,8 +1,8 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -using Jellyfin.Data.Interfaces; +using Jellyfin.Database.Implementations.Interfaces; -namespace Jellyfin.Data.Entities.Libraries +namespace Jellyfin.Database.Implementations.Entities.Libraries { /// /// An entity representing a rating for an entity. diff --git a/Jellyfin.Data/Entities/Libraries/RatingSource.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/RatingSource.cs similarity index 94% rename from Jellyfin.Data/Entities/Libraries/RatingSource.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/RatingSource.cs index 0f3a073244..8322855991 100644 --- a/Jellyfin.Data/Entities/Libraries/RatingSource.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/RatingSource.cs @@ -1,8 +1,8 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -using Jellyfin.Data.Interfaces; +using Jellyfin.Database.Implementations.Interfaces; -namespace Jellyfin.Data.Entities.Libraries +namespace Jellyfin.Database.Implementations.Entities.Libraries { /// /// This is the entity to store review ratings, not age ratings. diff --git a/Jellyfin.Data/Entities/Libraries/Release.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/Release.cs similarity index 94% rename from Jellyfin.Data/Entities/Libraries/Release.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/Release.cs index e68ab9105a..db148338e2 100644 --- a/Jellyfin.Data/Entities/Libraries/Release.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/Release.cs @@ -2,9 +2,9 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -using Jellyfin.Data.Interfaces; +using Jellyfin.Database.Implementations.Interfaces; -namespace Jellyfin.Data.Entities.Libraries +namespace Jellyfin.Database.Implementations.Entities.Libraries { /// /// An entity representing a release for a library item, eg. Director's cut vs. standard. diff --git a/Jellyfin.Data/Entities/Libraries/Season.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/Season.cs similarity index 94% rename from Jellyfin.Data/Entities/Libraries/Season.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/Season.cs index fc110b49da..dc9f695d98 100644 --- a/Jellyfin.Data/Entities/Libraries/Season.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/Season.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace Jellyfin.Data.Entities.Libraries +namespace Jellyfin.Database.Implementations.Entities.Libraries { /// /// An entity representing a season. diff --git a/Jellyfin.Data/Entities/Libraries/SeasonMetadata.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/SeasonMetadata.cs similarity index 93% rename from Jellyfin.Data/Entities/Libraries/SeasonMetadata.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/SeasonMetadata.cs index da40a075f5..af1e9fa2b7 100644 --- a/Jellyfin.Data/Entities/Libraries/SeasonMetadata.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/SeasonMetadata.cs @@ -1,6 +1,6 @@ using System.ComponentModel.DataAnnotations; -namespace Jellyfin.Data.Entities.Libraries +namespace Jellyfin.Database.Implementations.Entities.Libraries { /// /// An entity that holds metadata for seasons. diff --git a/Jellyfin.Data/Entities/Libraries/Series.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/Series.cs similarity index 92% rename from Jellyfin.Data/Entities/Libraries/Series.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/Series.cs index 0354433e08..1e1633248f 100644 --- a/Jellyfin.Data/Entities/Libraries/Series.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/Series.cs @@ -1,10 +1,10 @@ using System; using System.Collections.Generic; -namespace Jellyfin.Data.Entities.Libraries +namespace Jellyfin.Database.Implementations.Entities.Libraries { /// - /// An entity representing a a series. + /// An entity representing a series. /// public class Series : LibraryItem { diff --git a/Jellyfin.Data/Entities/Libraries/SeriesMetadata.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/SeriesMetadata.cs similarity index 94% rename from Jellyfin.Data/Entities/Libraries/SeriesMetadata.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/SeriesMetadata.cs index 42115802c5..b1b2b10bea 100644 --- a/Jellyfin.Data/Entities/Libraries/SeriesMetadata.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/SeriesMetadata.cs @@ -1,8 +1,8 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -using Jellyfin.Data.Interfaces; +using Jellyfin.Database.Implementations.Interfaces; -namespace Jellyfin.Data.Entities.Libraries +namespace Jellyfin.Database.Implementations.Entities.Libraries { /// /// An entity representing series metadata. diff --git a/Jellyfin.Data/Entities/Libraries/Track.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/Track.cs similarity index 89% rename from Jellyfin.Data/Entities/Libraries/Track.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/Track.cs index d354000337..f0bd88963a 100644 --- a/Jellyfin.Data/Entities/Libraries/Track.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/Track.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; -using Jellyfin.Data.Interfaces; +using Jellyfin.Database.Implementations.Interfaces; -namespace Jellyfin.Data.Entities.Libraries +namespace Jellyfin.Database.Implementations.Entities.Libraries { /// /// An entity representing a track. diff --git a/Jellyfin.Data/Entities/Libraries/TrackMetadata.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/TrackMetadata.cs similarity index 89% rename from Jellyfin.Data/Entities/Libraries/TrackMetadata.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/TrackMetadata.cs index 042d2b90db..d9b4736a7c 100644 --- a/Jellyfin.Data/Entities/Libraries/TrackMetadata.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/TrackMetadata.cs @@ -1,4 +1,4 @@ -namespace Jellyfin.Data.Entities.Libraries +namespace Jellyfin.Database.Implementations.Entities.Libraries { /// /// An entity holding metadata for a track. diff --git a/Jellyfin.Data/Entities/MediaSegment.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/MediaSegment.cs similarity index 91% rename from Jellyfin.Data/Entities/MediaSegment.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/MediaSegment.cs index 90120d7721..c34369d889 100644 --- a/Jellyfin.Data/Entities/MediaSegment.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/MediaSegment.cs @@ -1,8 +1,8 @@ using System; using System.ComponentModel.DataAnnotations.Schema; -using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; -namespace Jellyfin.Data.Entities; +namespace Jellyfin.Database.Implementations.Entities; /// /// An entity representing the metadata for a group of trickplay tiles. diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/MediaStreamInfo.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/MediaStreamInfo.cs new file mode 100644 index 0000000000..b80b764ba3 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/MediaStreamInfo.cs @@ -0,0 +1,104 @@ +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + +using System; + +namespace Jellyfin.Database.Implementations.Entities; + +public class MediaStreamInfo +{ + public required Guid ItemId { get; set; } + + public required BaseItemEntity Item { get; set; } + + public int StreamIndex { get; set; } + + public required MediaStreamTypeEntity StreamType { get; set; } + + public string? Codec { get; set; } + + public string? Language { get; set; } + + public string? ChannelLayout { get; set; } + + public string? Profile { get; set; } + + public string? AspectRatio { get; set; } + + public string? Path { get; set; } + + public bool? IsInterlaced { get; set; } + + public int? BitRate { get; set; } + + public int? Channels { get; set; } + + public int? SampleRate { get; set; } + + public bool IsDefault { get; set; } + + public bool IsForced { get; set; } + + public bool IsExternal { get; set; } + + public int? Height { get; set; } + + public int? Width { get; set; } + + public float? AverageFrameRate { get; set; } + + public float? RealFrameRate { get; set; } + + public float? Level { get; set; } + + public string? PixelFormat { get; set; } + + public int? BitDepth { get; set; } + + public bool? IsAnamorphic { get; set; } + + public int? RefFrames { get; set; } + + public string? CodecTag { get; set; } + + public string? Comment { get; set; } + + public string? NalLengthSize { get; set; } + + public bool? IsAvc { get; set; } + + public string? Title { get; set; } + + public string? TimeBase { get; set; } + + public string? CodecTimeBase { get; set; } + + public string? ColorPrimaries { get; set; } + + public string? ColorSpace { get; set; } + + public string? ColorTransfer { get; set; } + + public int? DvVersionMajor { get; set; } + + public int? DvVersionMinor { get; set; } + + public int? DvProfile { get; set; } + + public int? DvLevel { get; set; } + + public int? RpuPresentFlag { get; set; } + + public int? ElPresentFlag { get; set; } + + public int? BlPresentFlag { get; set; } + + public int? DvBlSignalCompatibilityId { get; set; } + + public bool? IsHearingImpaired { get; set; } + + public int? Rotation { get; set; } + + public string? KeyFrames { get; set; } + + public bool? Hdr10PlusPresentFlag { get; set; } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/MediaStreamTypeEntity.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/MediaStreamTypeEntity.cs new file mode 100644 index 0000000000..33dd81bdd6 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/MediaStreamTypeEntity.cs @@ -0,0 +1,37 @@ +namespace Jellyfin.Database.Implementations.Entities; + +/// +/// Enum MediaStreamType. +/// +public enum MediaStreamTypeEntity +{ + /// + /// The audio. + /// + Audio = 0, + + /// + /// The video. + /// + Video = 1, + + /// + /// The subtitle. + /// + Subtitle = 2, + + /// + /// The embedded image. + /// + EmbeddedImage = 3, + + /// + /// The data. + /// + Data = 4, + + /// + /// The lyric. + /// + Lyric = 5 +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/People.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/People.cs new file mode 100644 index 0000000000..20cf3e2d9e --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/People.cs @@ -0,0 +1,32 @@ +#pragma warning disable CA2227 // Collection properties should be read only + +using System; +using System.Collections.Generic; + +namespace Jellyfin.Database.Implementations.Entities; + +/// +/// People entity. +/// +public class People +{ + /// + /// Gets or Sets the PeopleId. + /// + public required Guid Id { get; set; } + + /// + /// Gets or Sets the Persons Name. + /// + public required string Name { get; set; } + + /// + /// Gets or Sets the Type. + /// + public string? PersonType { get; set; } + + /// + /// Gets or Sets the mapping of People to BaseItems. + /// + public ICollection? BaseItems { get; set; } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/PeopleBaseItemMap.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/PeopleBaseItemMap.cs new file mode 100644 index 0000000000..c719a185c4 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/PeopleBaseItemMap.cs @@ -0,0 +1,44 @@ +using System; + +namespace Jellyfin.Database.Implementations.Entities; + +/// +/// Mapping table for People to BaseItems. +/// +public class PeopleBaseItemMap +{ + /// + /// Gets or Sets the SortOrder. + /// + public int? SortOrder { get; set; } + + /// + /// Gets or Sets the ListOrder. + /// + public int? ListOrder { get; set; } + + /// + /// Gets or Sets the Role name the associated actor played in the . + /// + public string? Role { get; set; } + + /// + /// Gets or Sets The ItemId. + /// + public required Guid ItemId { get; set; } + + /// + /// Gets or Sets Reference Item. + /// + public required BaseItemEntity Item { get; set; } + + /// + /// Gets or Sets The PeopleId. + /// + public required Guid PeopleId { get; set; } + + /// + /// Gets or Sets Reference People. + /// + public required People People { get; set; } +} diff --git a/Jellyfin.Data/Entities/Permission.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Permission.cs similarity index 92% rename from Jellyfin.Data/Entities/Permission.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Permission.cs index 6d2e68077c..84b86574cc 100644 --- a/Jellyfin.Data/Entities/Permission.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Permission.cs @@ -3,10 +3,10 @@ using System; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -using Jellyfin.Data.Enums; -using Jellyfin.Data.Interfaces; +using Jellyfin.Database.Implementations.Enums; +using Jellyfin.Database.Implementations.Interfaces; -namespace Jellyfin.Data.Entities +namespace Jellyfin.Database.Implementations.Entities { /// /// An entity representing whether the associated user has a specific permission. diff --git a/Jellyfin.Data/Entities/Preference.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Preference.cs similarity index 92% rename from Jellyfin.Data/Entities/Preference.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Preference.cs index a6ab275d31..c02ea7375a 100644 --- a/Jellyfin.Data/Entities/Preference.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Preference.cs @@ -1,10 +1,10 @@ using System; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -using Jellyfin.Data.Enums; -using Jellyfin.Data.Interfaces; +using Jellyfin.Database.Implementations.Enums; +using Jellyfin.Database.Implementations.Interfaces; -namespace Jellyfin.Data.Entities +namespace Jellyfin.Database.Implementations.Entities { /// /// An entity representing a preference attached to a user or group. diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/ProgramAudioEntity.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/ProgramAudioEntity.cs new file mode 100644 index 0000000000..cb7255c19f --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/ProgramAudioEntity.cs @@ -0,0 +1,37 @@ +namespace Jellyfin.Database.Implementations.Entities; + +/// +/// Lists types of Audio. +/// +public enum ProgramAudioEntity +{ + /// + /// Mono. + /// + Mono = 0, + + /// + /// Stereo. + /// + Stereo = 1, + + /// + /// Dolby. + /// + Dolby = 2, + + /// + /// DolbyDigital. + /// + DolbyDigital = 3, + + /// + /// Thx. + /// + Thx = 4, + + /// + /// Atmos. + /// + Atmos = 5 +} diff --git a/Jellyfin.Data/Entities/Security/ApiKey.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Security/ApiKey.cs similarity index 96% rename from Jellyfin.Data/Entities/Security/ApiKey.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Security/ApiKey.cs index 1fcbe0f5e9..25a1d5ce90 100644 --- a/Jellyfin.Data/Entities/Security/ApiKey.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Security/ApiKey.cs @@ -3,7 +3,7 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Globalization; -namespace Jellyfin.Data.Entities.Security +namespace Jellyfin.Database.Implementations.Entities.Security { /// /// An entity representing an API key. diff --git a/Jellyfin.Data/Entities/Security/Device.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Security/Device.cs similarity index 98% rename from Jellyfin.Data/Entities/Security/Device.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Security/Device.cs index 67d7f78eda..b0f9b2d569 100644 --- a/Jellyfin.Data/Entities/Security/Device.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Security/Device.cs @@ -3,7 +3,7 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Globalization; -namespace Jellyfin.Data.Entities.Security +namespace Jellyfin.Database.Implementations.Entities.Security { /// /// An entity representing a device. diff --git a/Jellyfin.Data/Entities/Security/DeviceOptions.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Security/DeviceOptions.cs similarity index 93% rename from Jellyfin.Data/Entities/Security/DeviceOptions.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Security/DeviceOptions.cs index 531f66c627..8ac3e364c4 100644 --- a/Jellyfin.Data/Entities/Security/DeviceOptions.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Security/DeviceOptions.cs @@ -1,6 +1,6 @@ using System.ComponentModel.DataAnnotations.Schema; -namespace Jellyfin.Data.Entities.Security +namespace Jellyfin.Database.Implementations.Entities.Security { /// /// An entity representing custom options for a device. diff --git a/Jellyfin.Data/Entities/TrickplayInfo.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/TrickplayInfo.cs similarity index 93% rename from Jellyfin.Data/Entities/TrickplayInfo.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/TrickplayInfo.cs index 64e7da1b5d..06b290e4f2 100644 --- a/Jellyfin.Data/Entities/TrickplayInfo.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/TrickplayInfo.cs @@ -1,7 +1,7 @@ using System; using System.Text.Json.Serialization; -namespace Jellyfin.Data.Entities; +namespace Jellyfin.Database.Implementations.Entities; /// /// An entity representing the metadata for a group of trickplay tiles. @@ -66,7 +66,7 @@ public class TrickplayInfo public int Interval { get; set; } /// - /// Gets or sets peak bandwith usage in bits per second. + /// Gets or sets peak bandwidth usage in bits per second. /// /// /// Required. diff --git a/Jellyfin.Data/Entities/User.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs similarity index 55% rename from Jellyfin.Data/Entities/User.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs index 2c9cc8d785..4da7074ec7 100644 --- a/Jellyfin.Data/Entities/User.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs @@ -1,25 +1,18 @@ using System; using System.Collections.Generic; -using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -using System.Linq; using System.Text.Json.Serialization; -using Jellyfin.Data.Enums; -using Jellyfin.Data.Interfaces; +using Jellyfin.Database.Implementations.Enums; +using Jellyfin.Database.Implementations.Interfaces; -namespace Jellyfin.Data.Entities +namespace Jellyfin.Database.Implementations.Entities { /// /// An entity representing a user. /// public class User : IHasPermissions, IHasConcurrencyToken { - /// - /// The values being delimited here are Guids, so commas work as they do not appear in Guids. - /// - private const char Delimiter = ','; - /// /// Initializes a new instance of the class. /// Public constructor with required data. @@ -256,9 +249,14 @@ namespace Jellyfin.Data.Entities public bool EnableUserPreferenceAccess { get; set; } /// - /// Gets or sets the maximum parental age rating. + /// Gets or sets the maximum parental rating score. /// - public int? MaxParentalAgeRating { get; set; } + public int? MaxParentalRatingScore { get; set; } + + /// + /// Gets or sets the maximum parental rating sub score. + /// + public int? MaxParentalRatingSubScore { get; set; } /// /// Gets or sets the remote client bitrate limit. @@ -339,196 +337,5 @@ namespace Jellyfin.Data.Entities { RowVersion++; } - - /// - /// Checks whether the user has the specified permission. - /// - /// The permission kind. - /// True if the user has the specified permission. - public bool HasPermission(PermissionKind kind) - { - return Permissions.FirstOrDefault(p => p.Kind == kind)?.Value ?? false; - } - - /// - /// Sets the given permission kind to the provided value. - /// - /// The permission kind. - /// The value to set. - public void SetPermission(PermissionKind kind, bool value) - { - var currentPermission = Permissions.FirstOrDefault(p => p.Kind == kind); - if (currentPermission is null) - { - Permissions.Add(new Permission(kind, value)); - } - else - { - currentPermission.Value = value; - } - } - - /// - /// Gets the user's preferences for the given preference kind. - /// - /// The preference kind. - /// A string array containing the user's preferences. - public string[] GetPreference(PreferenceKind preference) - { - var val = Preferences.FirstOrDefault(p => p.Kind == preference)?.Value; - - return string.IsNullOrEmpty(val) ? Array.Empty() : val.Split(Delimiter); - } - - /// - /// Gets the user's preferences for the given preference kind. - /// - /// The preference kind. - /// Type of preference. - /// A {T} array containing the user's preference. - public T[] GetPreferenceValues(PreferenceKind preference) - { - var val = Preferences.FirstOrDefault(p => p.Kind == preference)?.Value; - if (string.IsNullOrEmpty(val)) - { - return Array.Empty(); - } - - // Convert array of {string} to array of {T} - var converter = TypeDescriptor.GetConverter(typeof(T)); - var stringValues = val.Split(Delimiter); - var convertedCount = 0; - var parsedValues = new T[stringValues.Length]; - for (var i = 0; i < stringValues.Length; i++) - { - try - { - var parsedValue = converter.ConvertFromString(stringValues[i].Trim()); - if (parsedValue is not null) - { - parsedValues[convertedCount++] = (T)parsedValue; - } - } - catch (FormatException) - { - // Unable to convert value - } - } - - return parsedValues[..convertedCount]; - } - - /// - /// Sets the specified preference to the given value. - /// - /// The preference kind. - /// The values. - public void SetPreference(PreferenceKind preference, string[] values) - { - var value = string.Join(Delimiter, values); - var currentPreference = Preferences.FirstOrDefault(p => p.Kind == preference); - if (currentPreference is null) - { - Preferences.Add(new Preference(preference, value)); - } - else - { - currentPreference.Value = value; - } - } - - /// - /// Sets the specified preference to the given value. - /// - /// The preference kind. - /// The values. - /// The type of value. - public void SetPreference(PreferenceKind preference, T[] values) - { - var value = string.Join(Delimiter, values); - var currentPreference = Preferences.FirstOrDefault(p => p.Kind == preference); - if (currentPreference is null) - { - Preferences.Add(new Preference(preference, value)); - } - else - { - currentPreference.Value = value; - } - } - - /// - /// Checks whether this user is currently allowed to use the server. - /// - /// True if the current time is within an access schedule, or there are no access schedules. - public bool IsParentalScheduleAllowed() - { - return AccessSchedules.Count == 0 - || AccessSchedules.Any(i => IsParentalScheduleAllowed(i, DateTime.UtcNow)); - } - - /// - /// Checks whether the provided folder is in this user's grouped folders. - /// - /// The Guid of the folder. - /// True if the folder is in the user's grouped folders. - public bool IsFolderGrouped(Guid id) - { - return Array.IndexOf(GetPreferenceValues(PreferenceKind.GroupedFolders), id) != -1; - } - - /// - /// Initializes the default permissions for a user. Should only be called on user creation. - /// - // TODO: make these user configurable? - public void AddDefaultPermissions() - { - Permissions.Add(new Permission(PermissionKind.IsAdministrator, false)); - Permissions.Add(new Permission(PermissionKind.IsDisabled, false)); - Permissions.Add(new Permission(PermissionKind.IsHidden, true)); - Permissions.Add(new Permission(PermissionKind.EnableAllChannels, true)); - Permissions.Add(new Permission(PermissionKind.EnableAllDevices, true)); - Permissions.Add(new Permission(PermissionKind.EnableAllFolders, true)); - Permissions.Add(new Permission(PermissionKind.EnableContentDeletion, false)); - Permissions.Add(new Permission(PermissionKind.EnableContentDownloading, true)); - Permissions.Add(new Permission(PermissionKind.EnableMediaConversion, true)); - Permissions.Add(new Permission(PermissionKind.EnableMediaPlayback, true)); - Permissions.Add(new Permission(PermissionKind.EnablePlaybackRemuxing, true)); - Permissions.Add(new Permission(PermissionKind.EnablePublicSharing, true)); - Permissions.Add(new Permission(PermissionKind.EnableRemoteAccess, true)); - Permissions.Add(new Permission(PermissionKind.EnableSyncTranscoding, true)); - Permissions.Add(new Permission(PermissionKind.EnableAudioPlaybackTranscoding, true)); - Permissions.Add(new Permission(PermissionKind.EnableLiveTvAccess, true)); - Permissions.Add(new Permission(PermissionKind.EnableLiveTvManagement, true)); - Permissions.Add(new Permission(PermissionKind.EnableSharedDeviceControl, true)); - Permissions.Add(new Permission(PermissionKind.EnableVideoPlaybackTranscoding, true)); - Permissions.Add(new Permission(PermissionKind.ForceRemoteSourceTranscoding, false)); - Permissions.Add(new Permission(PermissionKind.EnableRemoteControlOfOtherUsers, false)); - Permissions.Add(new Permission(PermissionKind.EnableCollectionManagement, false)); - Permissions.Add(new Permission(PermissionKind.EnableSubtitleManagement, false)); - Permissions.Add(new Permission(PermissionKind.EnableLyricManagement, false)); - } - - /// - /// Initializes the default preferences. Should only be called on user creation. - /// - public void AddDefaultPreferences() - { - foreach (var val in Enum.GetValues(typeof(PreferenceKind)).Cast()) - { - Preferences.Add(new Preference(val, string.Empty)); - } - } - - private static bool IsParentalScheduleAllowed(AccessSchedule schedule, DateTime date) - { - var localTime = date.ToLocalTime(); - var hour = localTime.TimeOfDay.TotalHours; - var currentDayOfWeek = localTime.DayOfWeek; - - return schedule.DayOfWeek.Contains(currentDayOfWeek) - && hour >= schedule.StartHour - && hour <= schedule.EndHour; - } } } diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/UserData.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/UserData.cs new file mode 100644 index 0000000000..cd80686615 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/UserData.cs @@ -0,0 +1,91 @@ +using System; + +namespace Jellyfin.Database.Implementations.Entities; + +/// +/// Provides and related data. +/// +public class UserData +{ + /// + /// Gets or sets the custom data key. + /// + /// The rating. + public required string CustomDataKey { get; set; } + + /// + /// Gets or sets the users 0-10 rating. + /// + /// The rating. + public double? Rating { get; set; } + + /// + /// Gets or sets the playback position ticks. + /// + /// The playback position ticks. + public long PlaybackPositionTicks { get; set; } + + /// + /// Gets or sets the play count. + /// + /// The play count. + public int PlayCount { get; set; } + + /// + /// Gets or sets a value indicating whether this instance is favorite. + /// + /// true if this instance is favorite; otherwise, false. + public bool IsFavorite { get; set; } + + /// + /// Gets or sets the last played date. + /// + /// The last played date. + public DateTime? LastPlayedDate { get; set; } + + /// + /// Gets or sets a value indicating whether this is played. + /// + /// true if played; otherwise, false. + public bool Played { get; set; } + + /// + /// Gets or sets the index of the audio stream. + /// + /// The index of the audio stream. + public int? AudioStreamIndex { get; set; } + + /// + /// Gets or sets the index of the subtitle stream. + /// + /// The index of the subtitle stream. + public int? SubtitleStreamIndex { get; set; } + + /// + /// Gets or sets a value indicating whether the item is liked or not. + /// This should never be serialized. + /// + /// null if [likes] contains no value, true if [likes]; otherwise, false. + public bool? Likes { get; set; } + + /// + /// Gets or sets the key. + /// + /// The key. + public required Guid ItemId { get; set; } + + /// + /// Gets or Sets the BaseItem. + /// + public required BaseItemEntity? Item { get; set; } + + /// + /// Gets or Sets the UserId. + /// + public required Guid UserId { get; set; } + + /// + /// Gets or Sets the User. + /// + public required User? User { get; set; } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/ArtKind.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/ArtKind.cs new file mode 100644 index 0000000000..218e97bcc0 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/ArtKind.cs @@ -0,0 +1,32 @@ +namespace Jellyfin.Database.Implementations.Enums; + +/// +/// An enum representing types of art. +/// +public enum ArtKind +{ + /// + /// Another type of art, not covered by the other members. + /// + Other = 0, + + /// + /// A poster. + /// + Poster = 1, + + /// + /// A banner. + /// + Banner = 2, + + /// + /// A thumbnail. + /// + Thumbnail = 3, + + /// + /// A logo. + /// + Logo = 4 +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/ChromecastVersion.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/ChromecastVersion.cs new file mode 100644 index 0000000000..123f2fe438 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/ChromecastVersion.cs @@ -0,0 +1,17 @@ +namespace Jellyfin.Database.Implementations.Enums; + +/// +/// An enum representing the version of Chromecast to be used by clients. +/// +public enum ChromecastVersion +{ + /// + /// Stable Chromecast version. + /// + Stable = 0, + + /// + /// Unstable Chromecast version. + /// + Unstable = 1 +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/DynamicDayOfWeek.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/DynamicDayOfWeek.cs new file mode 100644 index 0000000000..69a9b5816a --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/DynamicDayOfWeek.cs @@ -0,0 +1,57 @@ +namespace Jellyfin.Database.Implementations.Enums; + +/// +/// An enum that represents a day of the week, weekdays, weekends, or all days. +/// +public enum DynamicDayOfWeek +{ + /// + /// Sunday. + /// + Sunday = 0, + + /// + /// Monday. + /// + Monday = 1, + + /// + /// Tuesday. + /// + Tuesday = 2, + + /// + /// Wednesday. + /// + Wednesday = 3, + + /// + /// Thursday. + /// + Thursday = 4, + + /// + /// Friday. + /// + Friday = 5, + + /// + /// Saturday. + /// + Saturday = 6, + + /// + /// All days of the week. + /// + Everyday = 7, + + /// + /// A week day, or Monday-Friday. + /// + Weekday = 8, + + /// + /// Saturday and Sunday. + /// + Weekend = 9 +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/HomeSectionType.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/HomeSectionType.cs new file mode 100644 index 0000000000..6ba57e74d5 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/HomeSectionType.cs @@ -0,0 +1,57 @@ +namespace Jellyfin.Database.Implementations.Enums; + +/// +/// An enum representing the different options for the home screen sections. +/// +public enum HomeSectionType +{ + /// + /// None. + /// + None = 0, + + /// + /// My Media. + /// + SmallLibraryTiles = 1, + + /// + /// My Media Small. + /// + LibraryButtons = 2, + + /// + /// Active Recordings. + /// + ActiveRecordings = 3, + + /// + /// Continue Watching. + /// + Resume = 4, + + /// + /// Continue Listening. + /// + ResumeAudio = 5, + + /// + /// Latest Media. + /// + LatestMedia = 6, + + /// + /// Next Up. + /// + NextUp = 7, + + /// + /// Live TV. + /// + LiveTv = 8, + + /// + /// Continue Reading. + /// + ResumeBook = 9 +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/IndexingKind.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/IndexingKind.cs new file mode 100644 index 0000000000..72ac1140cb --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/IndexingKind.cs @@ -0,0 +1,22 @@ +namespace Jellyfin.Database.Implementations.Enums; + +/// +/// An enum representing a type of indexing in a user's display preferences. +/// +public enum IndexingKind +{ + /// + /// Index by the premiere date. + /// + PremiereDate = 0, + + /// + /// Index by the production year. + /// + ProductionYear = 1, + + /// + /// Index by the community rating. + /// + CommunityRating = 2 +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/MediaFileKind.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/MediaFileKind.cs new file mode 100644 index 0000000000..8e6f677dca --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/MediaFileKind.cs @@ -0,0 +1,32 @@ +namespace Jellyfin.Database.Implementations.Enums; + +/// +/// An enum representing the type of media file. +/// +public enum MediaFileKind +{ + /// + /// The main file. + /// + Main = 0, + + /// + /// A sidecar file. + /// + Sidecar = 1, + + /// + /// An additional part to the main file. + /// + AdditionalPart = 2, + + /// + /// An alternative format to the main file. + /// + AlternativeFormat = 3, + + /// + /// An additional stream for the main file. + /// + AdditionalStream = 4 +} diff --git a/Jellyfin.Data/Enums/MediaSegmentType.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/MediaSegmentType.cs similarity index 85% rename from Jellyfin.Data/Enums/MediaSegmentType.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/MediaSegmentType.cs index 4586354504..a6e8732fff 100644 --- a/Jellyfin.Data/Enums/MediaSegmentType.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/MediaSegmentType.cs @@ -1,6 +1,6 @@ -using Jellyfin.Data.Entities; +using Jellyfin.Database.Implementations.Entities; -namespace Jellyfin.Data.Enums; +namespace Jellyfin.Database.Implementations.Enums; /// /// Defines the types of content an individual represents. diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/PermissionKind.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/PermissionKind.cs new file mode 100644 index 0000000000..0818639636 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/PermissionKind.cs @@ -0,0 +1,127 @@ +namespace Jellyfin.Database.Implementations.Enums; + +/// +/// The types of user permissions. +/// +public enum PermissionKind +{ + /// + /// Whether the user is an administrator. + /// + IsAdministrator = 0, + + /// + /// Whether the user is hidden. + /// + IsHidden = 1, + + /// + /// Whether the user is disabled. + /// + IsDisabled = 2, + + /// + /// Whether the user can control shared devices. + /// + EnableSharedDeviceControl = 3, + + /// + /// Whether the user can access the server remotely. + /// + EnableRemoteAccess = 4, + + /// + /// Whether the user can manage live tv. + /// + EnableLiveTvManagement = 5, + + /// + /// Whether the user can access live tv. + /// + EnableLiveTvAccess = 6, + + /// + /// Whether the user can play media. + /// + EnableMediaPlayback = 7, + + /// + /// Whether the server should transcode audio for the user if requested. + /// + EnableAudioPlaybackTranscoding = 8, + + /// + /// Whether the server should transcode video for the user if requested. + /// + EnableVideoPlaybackTranscoding = 9, + + /// + /// Whether the user can delete content. + /// + EnableContentDeletion = 10, + + /// + /// Whether the user can download content. + /// + EnableContentDownloading = 11, + + /// + /// Whether to enable sync transcoding for the user. + /// + EnableSyncTranscoding = 12, + + /// + /// Whether the user can do media conversion. + /// + EnableMediaConversion = 13, + + /// + /// Whether the user has access to all devices. + /// + EnableAllDevices = 14, + + /// + /// Whether the user has access to all channels. + /// + EnableAllChannels = 15, + + /// + /// Whether the user has access to all folders. + /// + EnableAllFolders = 16, + + /// + /// Whether to enable public sharing for the user. + /// + EnablePublicSharing = 17, + + /// + /// Whether the user can remotely control other users. + /// + EnableRemoteControlOfOtherUsers = 18, + + /// + /// Whether the user is permitted to do playback remuxing. + /// + EnablePlaybackRemuxing = 19, + + /// + /// Whether the server should force transcoding on remote connections for the user. + /// + ForceRemoteSourceTranscoding = 20, + + /// + /// Whether the user can create, modify and delete collections. + /// + EnableCollectionManagement = 21, + + /// + /// Whether the user can edit subtitles. + /// + EnableSubtitleManagement = 22, + + /// + /// Whether the user can edit lyrics. + /// + EnableLyricManagement = 23, +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/PersonRoleType.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/PersonRoleType.cs new file mode 100644 index 0000000000..5b913385e8 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/PersonRoleType.cs @@ -0,0 +1,67 @@ +namespace Jellyfin.Database.Implementations.Enums; + +/// +/// An enum representing a person's role in a specific media item. +/// +public enum PersonRoleType +{ + /// + /// Another role, not covered by the other types. + /// + Other = 0, + + /// + /// The director of the media. + /// + Director = 1, + + /// + /// An artist. + /// + Artist = 2, + + /// + /// The original artist. + /// + OriginalArtist = 3, + + /// + /// An actor. + /// + Actor = 4, + + /// + /// A voice actor. + /// + VoiceActor = 5, + + /// + /// A producer. + /// + Producer = 6, + + /// + /// A remixer. + /// + Remixer = 7, + + /// + /// A conductor. + /// + Conductor = 8, + + /// + /// A composer. + /// + Composer = 9, + + /// + /// An author. + /// + Author = 10, + + /// + /// An editor. + /// + Editor = 11 +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/PreferenceKind.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/PreferenceKind.cs new file mode 100644 index 0000000000..f70e3e2c23 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/PreferenceKind.cs @@ -0,0 +1,72 @@ +namespace Jellyfin.Database.Implementations.Enums; + +/// +/// The types of user preferences. +/// +public enum PreferenceKind +{ + /// + /// A list of blocked tags. + /// + BlockedTags = 0, + + /// + /// A list of blocked channels. + /// + BlockedChannels = 1, + + /// + /// A list of blocked media folders. + /// + BlockedMediaFolders = 2, + + /// + /// A list of enabled devices. + /// + EnabledDevices = 3, + + /// + /// A list of enabled channels. + /// + EnabledChannels = 4, + + /// + /// A list of enabled folders. + /// + EnabledFolders = 5, + + /// + /// A list of folders to allow content deletion from. + /// + EnableContentDeletionFromFolders = 6, + + /// + /// A list of latest items to exclude. + /// + LatestItemExcludes = 7, + + /// + /// A list of media to exclude. + /// + MyMediaExcludes = 8, + + /// + /// A list of grouped folders. + /// + GroupedFolders = 9, + + /// + /// A list of unrated items to block. + /// + BlockUnratedItems = 10, + + /// + /// A list of ordered views. + /// + OrderedViews = 11, + + /// + /// A list of allowed tags. + /// + AllowedTags = 12 +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/ScrollDirection.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/ScrollDirection.cs new file mode 100644 index 0000000000..3ff3c45faf --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/ScrollDirection.cs @@ -0,0 +1,17 @@ +namespace Jellyfin.Database.Implementations.Enums; + +/// +/// An enum representing the axis that should be scrolled. +/// +public enum ScrollDirection +{ + /// + /// Horizontal scrolling direction. + /// + Horizontal = 0, + + /// + /// Vertical scrolling direction. + /// + Vertical = 1 +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/SortOrder.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/SortOrder.cs new file mode 100644 index 0000000000..c865b75f11 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/SortOrder.cs @@ -0,0 +1,17 @@ +namespace Jellyfin.Database.Implementations.Enums; + +/// +/// An enum representing the sorting order. +/// +public enum SortOrder +{ + /// + /// Sort in increasing order. + /// + Ascending = 0, + + /// + /// Sort in decreasing order. + /// + Descending = 1 +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/SubtitlePlaybackMode.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/SubtitlePlaybackMode.cs new file mode 100644 index 0000000000..c394c209bb --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/SubtitlePlaybackMode.cs @@ -0,0 +1,32 @@ +namespace Jellyfin.Database.Implementations.Enums; + +/// +/// An enum representing a subtitle playback mode. +/// +public enum SubtitlePlaybackMode +{ + /// + /// The default subtitle playback mode. + /// + Default = 0, + + /// + /// Always show subtitles. + /// + Always = 1, + + /// + /// Only show forced subtitles. + /// + OnlyForced = 2, + + /// + /// Don't show subtitles. + /// + None = 3, + + /// + /// Only show subtitles when the current audio stream is in a different language. + /// + Smart = 4 +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/SyncPlayUserAccessType.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/SyncPlayUserAccessType.cs new file mode 100644 index 0000000000..311642e0d7 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/SyncPlayUserAccessType.cs @@ -0,0 +1,22 @@ +namespace Jellyfin.Database.Implementations.Enums; + +/// +/// Enum SyncPlayUserAccessType. +/// +public enum SyncPlayUserAccessType +{ + /// + /// User can create groups and join them. + /// + CreateAndJoinGroups = 0, + + /// + /// User can only join already existing groups. + /// + JoinGroups = 1, + + /// + /// SyncPlay is disabled for the user. + /// + None = 2 +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/ViewType.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/ViewType.cs new file mode 100644 index 0000000000..b2bcbf2bb6 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/ViewType.cs @@ -0,0 +1,112 @@ +namespace Jellyfin.Database.Implementations.Enums; + +/// +/// An enum representing the type of view for a library or collection. +/// +public enum ViewType +{ + /// + /// Shows albums. + /// + Albums = 0, + + /// + /// Shows album artists. + /// + AlbumArtists = 1, + + /// + /// Shows artists. + /// + Artists = 2, + + /// + /// Shows channels. + /// + Channels = 3, + + /// + /// Shows collections. + /// + Collections = 4, + + /// + /// Shows episodes. + /// + Episodes = 5, + + /// + /// Shows favorites. + /// + Favorites = 6, + + /// + /// Shows genres. + /// + Genres = 7, + + /// + /// Shows guide. + /// + Guide = 8, + + /// + /// Shows movies. + /// + Movies = 9, + + /// + /// Shows networks. + /// + Networks = 10, + + /// + /// Shows playlists. + /// + Playlists = 11, + + /// + /// Shows programs. + /// + Programs = 12, + + /// + /// Shows recordings. + /// + Recordings = 13, + + /// + /// Shows schedule. + /// + Schedule = 14, + + /// + /// Shows series. + /// + Series = 15, + + /// + /// Shows shows. + /// + Shows = 16, + + /// + /// Shows songs. + /// + Songs = 17, + + /// + /// Shows songs. + /// + Suggestions = 18, + + /// + /// Shows trailers. + /// + Trailers = 19, + + /// + /// Shows upcoming. + /// + Upcoming = 20 +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs new file mode 100644 index 0000000000..566b521dd0 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs @@ -0,0 +1,65 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; + +namespace Jellyfin.Database.Implementations; + +/// +/// Defines the type and extension points for multi database support. +/// +public interface IJellyfinDatabaseProvider +{ + /// + /// Gets or Sets the Database Factory when initialisaition is done. + /// + IDbContextFactory? DbContextFactory { get; set; } + + /// + /// Initialises jellyfins EFCore database access. + /// + /// The EFCore database options. + void Initialise(DbContextOptionsBuilder options); + + /// + /// Will be invoked when EFCore wants to build its model. + /// + /// The ModelBuilder from EFCore. + void OnModelCreating(ModelBuilder modelBuilder); + + /// + /// Will be invoked when EFCore wants to configure its model. + /// + /// The ModelConfigurationBuilder from EFCore. + void ConfigureConventions(ModelConfigurationBuilder configurationBuilder); + + /// + /// If supported this should run any periodic maintaince tasks. + /// + /// The token to abort the operation. + /// A representing the asynchronous operation. + Task RunScheduledOptimisation(CancellationToken cancellationToken); + + /// + /// If supported this should perform any actions that are required on stopping the jellyfin server. + /// + /// The token that will be used to abort the operation. + /// A representing the asynchronous operation. + Task RunShutdownTask(CancellationToken cancellationToken); + + /// + /// Runs a full Database backup that can later be restored to. + /// + /// A cancelation token. + /// A key to identify the backup. + /// May throw an NotImplementException if this operation is not supported for this database. + Task MigrationBackupFast(CancellationToken cancellationToken); + + /// + /// Restores a backup that has been previously created by . + /// + /// The key to the backup from which the current database should be restored from. + /// A cancelation token. + /// A representing the result of the asynchronous operation. + Task RestoreBackupFast(string key, CancellationToken cancellationToken); +} diff --git a/Jellyfin.Data/Interfaces/IHasArtwork.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Interfaces/IHasArtwork.cs similarity index 74% rename from Jellyfin.Data/Interfaces/IHasArtwork.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Interfaces/IHasArtwork.cs index a4d9c54af4..46007472a2 100644 --- a/Jellyfin.Data/Interfaces/IHasArtwork.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Interfaces/IHasArtwork.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; -using Jellyfin.Data.Entities.Libraries; +using Jellyfin.Database.Implementations.Entities.Libraries; -namespace Jellyfin.Data.Interfaces +namespace Jellyfin.Database.Implementations.Interfaces { /// /// An interface abstracting an entity that has artwork. diff --git a/Jellyfin.Data/Interfaces/IHasCompanies.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Interfaces/IHasCompanies.cs similarity index 75% rename from Jellyfin.Data/Interfaces/IHasCompanies.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/Interfaces/IHasCompanies.cs index 8f19ce04fa..5cfefa456a 100644 --- a/Jellyfin.Data/Interfaces/IHasCompanies.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Interfaces/IHasCompanies.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; -using Jellyfin.Data.Entities.Libraries; +using Jellyfin.Database.Implementations.Entities.Libraries; -namespace Jellyfin.Data.Interfaces +namespace Jellyfin.Database.Implementations.Interfaces { /// /// An abstraction representing an entity that has companies. diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Interfaces/IHasConcurrencyToken.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Interfaces/IHasConcurrencyToken.cs new file mode 100644 index 0000000000..196d2680db --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Interfaces/IHasConcurrencyToken.cs @@ -0,0 +1,17 @@ +namespace Jellyfin.Database.Implementations.Interfaces; + +/// +/// An interface abstracting an entity that has a concurrency token. +/// +public interface IHasConcurrencyToken +{ + /// + /// Gets the version of this row. Acts as a concurrency token. + /// + uint RowVersion { get; } + + /// + /// Called when saving changes to this entity. + /// + void OnSavingChanges(); +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Interfaces/IHasPermissions.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Interfaces/IHasPermissions.cs new file mode 100644 index 0000000000..99b29e6d35 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Interfaces/IHasPermissions.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using Jellyfin.Database.Implementations.Entities; + +namespace Jellyfin.Database.Implementations.Interfaces; + +/// +/// An abstraction representing an entity that has permissions. +/// +public interface IHasPermissions +{ + /// + /// Gets a collection containing this entity's permissions. + /// + ICollection Permissions { get; } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Interfaces/IHasReleases.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Interfaces/IHasReleases.cs new file mode 100644 index 0000000000..742a6a3867 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Interfaces/IHasReleases.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using Jellyfin.Database.Implementations.Entities.Libraries; + +namespace Jellyfin.Database.Implementations.Interfaces; + +/// +/// An abstraction representing an entity that has releases. +/// +public interface IHasReleases +{ + /// + /// Gets a collection containing this entity's releases. + /// + ICollection Releases { get; } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Jellyfin.Database.Implementations.csproj b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Jellyfin.Database.Implementations.csproj new file mode 100644 index 0000000000..3b619cce61 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Jellyfin.Database.Implementations.csproj @@ -0,0 +1,25 @@ + + + + net9.0 + false + true + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinDatabaseProviderKeyAttribute.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinDatabaseProviderKeyAttribute.cs new file mode 100644 index 0000000000..778aca373e --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinDatabaseProviderKeyAttribute.cs @@ -0,0 +1,29 @@ +namespace Jellyfin.Database.Implementations; + +/// +/// Defines the key of the database provider. +/// +[System.AttributeUsage(System.AttributeTargets.Class, Inherited = true, AllowMultiple = true)] +public sealed class JellyfinDatabaseProviderKeyAttribute : System.Attribute +{ + // See the attribute guidelines at + // http://go.microsoft.com/fwlink/?LinkId=85236 + private readonly string _databaseProviderKey; + + /// + /// Initializes a new instance of the class. + /// + /// The key on which to identify the annotated provider. + public JellyfinDatabaseProviderKeyAttribute(string databaseProviderKey) + { + _databaseProviderKey = databaseProviderKey; + } + + /// + /// Gets the key on which to identify the annotated provider. + /// + public string DatabaseProviderKey + { + get { return _databaseProviderKey; } + } +} diff --git a/Jellyfin.Server.Implementations/JellyfinDbContext.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinDbContext.cs similarity index 61% rename from Jellyfin.Server.Implementations/JellyfinDbContext.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinDbContext.cs index 150bc8bb4e..35ad461ec7 100644 --- a/Jellyfin.Server.Implementations/JellyfinDbContext.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinDbContext.cs @@ -1,23 +1,22 @@ using System; using System.Linq; -using Jellyfin.Data.Entities; -using Jellyfin.Data.Entities.Security; -using Jellyfin.Data.Interfaces; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Entities.Security; +using Jellyfin.Database.Implementations.Interfaces; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; -namespace Jellyfin.Server.Implementations; +namespace Jellyfin.Database.Implementations; /// -public class JellyfinDbContext : DbContext +/// +/// Initializes a new instance of the class. +/// +/// The database context options. +/// Logger. +/// The provider for the database engine specific operations. +public class JellyfinDbContext(DbContextOptions options, ILogger logger, IJellyfinDatabaseProvider jellyfinDatabaseProvider) : DbContext(options) { - /// - /// Initializes a new instance of the class. - /// - /// The database context options. - public JellyfinDbContext(DbContextOptions options) : base(options) - { - } - /// /// Gets the containing the access schedules. /// @@ -88,6 +87,81 @@ public class JellyfinDbContext : DbContext /// public DbSet MediaSegments => Set(); + /// + /// Gets the containing the user data. + /// + public DbSet UserData => Set(); + + /// + /// Gets the containing the user data. + /// + public DbSet AncestorIds => Set(); + + /// + /// Gets the containing the user data. + /// + public DbSet AttachmentStreamInfos => Set(); + + /// + /// Gets the containing the user data. + /// + public DbSet BaseItems => Set(); + + /// + /// Gets the containing the user data. + /// + public DbSet Chapters => Set(); + + /// + /// Gets the . + /// + public DbSet ItemValues => Set(); + + /// + /// Gets the . + /// + public DbSet ItemValuesMap => Set(); + + /// + /// Gets the . + /// + public DbSet MediaStreamInfos => Set(); + + /// + /// Gets the . + /// + public DbSet Peoples => Set(); + + /// + /// Gets the . + /// + public DbSet PeopleBaseItemMap => Set(); + + /// + /// Gets the containing the referenced Providers with ids. + /// + public DbSet BaseItemProviders => Set(); + + /// + /// Gets the . + /// + public DbSet BaseItemImageInfos => Set(); + + /// + /// Gets the . + /// + public DbSet BaseItemMetadataFields => Set(); + + /// + /// Gets the . + /// + public DbSet BaseItemTrailerTypes => Set(); + + /// + /// Gets the . + /// + public DbSet KeyframeData => Set(); + /*public DbSet Artwork => Set(); public DbSet Books => Set(); @@ -183,16 +257,31 @@ public class JellyfinDbContext : DbContext saveEntity.OnSavingChanges(); } - return base.SaveChanges(); + try + { + return base.SaveChanges(); + } + catch (Exception e) + { + logger.LogError(e, "Error trying to save changes."); + throw; + } } /// protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder.SetDefaultDateTimeKind(DateTimeKind.Utc); + jellyfinDatabaseProvider.OnModelCreating(modelBuilder); base.OnModelCreating(modelBuilder); - // Configuration for each entity is in it's own class inside 'ModelConfiguration'. + // Configuration for each entity is in its own class inside 'ModelConfiguration'. modelBuilder.ApplyConfigurationsFromAssembly(typeof(JellyfinDbContext).Assembly); } + + /// + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + jellyfinDatabaseProvider.ConfigureConventions(configurationBuilder); + base.ConfigureConventions(configurationBuilder); + } } diff --git a/Jellyfin.Server.Implementations/ModelConfiguration/ActivityLogConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/ActivityLogConfiguration.cs similarity index 78% rename from Jellyfin.Server.Implementations/ModelConfiguration/ActivityLogConfiguration.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/ActivityLogConfiguration.cs index 9a63ed9f21..a209c5b90d 100644 --- a/Jellyfin.Server.Implementations/ModelConfiguration/ActivityLogConfiguration.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/ActivityLogConfiguration.cs @@ -1,8 +1,8 @@ -using Jellyfin.Data.Entities; +using Jellyfin.Database.Implementations.Entities; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; -namespace Jellyfin.Server.Implementations.ModelConfiguration; +namespace Jellyfin.Database.Implementations.ModelConfiguration; /// /// FluentAPI configuration for the ActivityLog entity. diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/AncestorIdConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/AncestorIdConfiguration.cs new file mode 100644 index 0000000000..67269153d3 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/AncestorIdConfiguration.cs @@ -0,0 +1,20 @@ +using Jellyfin.Database.Implementations.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Jellyfin.Database.Implementations.ModelConfiguration; + +/// +/// AncestorId configuration. +/// +public class AncestorIdConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => new { e.ItemId, e.ParentItemId }); + builder.HasIndex(e => e.ParentItemId); + builder.HasOne(e => e.ParentItem).WithMany(e => e.Children).HasForeignKey(f => f.ParentItemId); + builder.HasOne(e => e.Item).WithMany(e => e.Parents).HasForeignKey(f => f.ItemId); + } +} diff --git a/Jellyfin.Server.Implementations/ModelConfiguration/ApiKeyConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/ApiKeyConfiguration.cs similarity index 80% rename from Jellyfin.Server.Implementations/ModelConfiguration/ApiKeyConfiguration.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/ApiKeyConfiguration.cs index 3f19b6986a..ea382c7183 100644 --- a/Jellyfin.Server.Implementations/ModelConfiguration/ApiKeyConfiguration.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/ApiKeyConfiguration.cs @@ -1,8 +1,8 @@ -using Jellyfin.Data.Entities.Security; +using Jellyfin.Database.Implementations.Entities.Security; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; -namespace Jellyfin.Server.Implementations.ModelConfiguration +namespace Jellyfin.Database.Implementations.ModelConfiguration { /// /// FluentAPI configuration for the ApiKey entity. diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/AttachmentStreamInfoConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/AttachmentStreamInfoConfiguration.cs new file mode 100644 index 0000000000..66cafc83cf --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/AttachmentStreamInfoConfiguration.cs @@ -0,0 +1,17 @@ +using Jellyfin.Database.Implementations.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Jellyfin.Database.Implementations.ModelConfiguration; + +/// +/// FluentAPI configuration for the AttachmentStreamInfo entity. +/// +public class AttachmentStreamInfoConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => new { e.ItemId, e.Index }); + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/BaseItemConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/BaseItemConfiguration.cs new file mode 100644 index 0000000000..4a76113bf6 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/BaseItemConfiguration.cs @@ -0,0 +1,57 @@ +using Jellyfin.Database.Implementations.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Jellyfin.Database.Implementations.ModelConfiguration; + +/// +/// Configuration for BaseItem. +/// +public class BaseItemConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => e.Id); + // TODO: See rant in entity file. + // builder.HasOne(e => e.Parent).WithMany(e => e.DirectChildren).HasForeignKey(e => e.ParentId); + // builder.HasOne(e => e.TopParent).WithMany(e => e.AllChildren).HasForeignKey(e => e.TopParentId); + // builder.HasOne(e => e.Season).WithMany(e => e.SeasonEpisodes).HasForeignKey(e => e.SeasonId); + // builder.HasOne(e => e.Series).WithMany(e => e.SeriesEpisodes).HasForeignKey(e => e.SeriesId); + builder.HasMany(e => e.Peoples); + builder.HasMany(e => e.UserData); + builder.HasMany(e => e.ItemValues); + builder.HasMany(e => e.MediaStreams); + builder.HasMany(e => e.Chapters); + builder.HasMany(e => e.Provider); + builder.HasMany(e => e.Parents); + builder.HasMany(e => e.Children); + builder.HasMany(e => e.LockedFields); + builder.HasMany(e => e.TrailerTypes); + builder.HasMany(e => e.Images); + + builder.HasIndex(e => e.Path); + builder.HasIndex(e => e.ParentId); + builder.HasIndex(e => e.PresentationUniqueKey); + builder.HasIndex(e => new { e.Id, e.Type, e.IsFolder, e.IsVirtualItem }); + + // covering index + builder.HasIndex(e => new { e.TopParentId, e.Id }); + // series + builder.HasIndex(e => new { e.Type, e.SeriesPresentationUniqueKey, e.PresentationUniqueKey, e.SortName }); + // series counts + // seriesdateplayed sort order + builder.HasIndex(e => new { e.Type, e.SeriesPresentationUniqueKey, e.IsFolder, e.IsVirtualItem }); + // live tv programs + builder.HasIndex(e => new { e.Type, e.TopParentId, e.StartDate }); + // covering index for getitemvalues + builder.HasIndex(e => new { e.Type, e.TopParentId, e.Id }); + // used by movie suggestions + builder.HasIndex(e => new { e.Type, e.TopParentId, e.PresentationUniqueKey }); + // latest items + builder.HasIndex(e => new { e.Type, e.TopParentId, e.IsVirtualItem, e.PresentationUniqueKey, e.DateCreated }); + builder.HasIndex(e => new { e.IsFolder, e.TopParentId, e.IsVirtualItem, e.PresentationUniqueKey, e.DateCreated }); + // resume + builder.HasIndex(e => new { e.MediaType, e.TopParentId, e.IsVirtualItem, e.PresentationUniqueKey }); + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/BaseItemMetadataFieldConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/BaseItemMetadataFieldConfiguration.cs new file mode 100644 index 0000000000..a602ea65f4 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/BaseItemMetadataFieldConfiguration.cs @@ -0,0 +1,18 @@ +using Jellyfin.Database.Implementations.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Jellyfin.Database.Implementations.ModelConfiguration; + +/// +/// Provides configuration for the BaseItemMetadataField entity. +/// +public class BaseItemMetadataFieldConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => new { e.Id, e.ItemId }); + builder.HasOne(e => e.Item); + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/BaseItemProviderConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/BaseItemProviderConfiguration.cs new file mode 100644 index 0000000000..dd28000ba6 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/BaseItemProviderConfiguration.cs @@ -0,0 +1,19 @@ +using Jellyfin.Database.Implementations.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Jellyfin.Database.Implementations.ModelConfiguration; + +/// +/// BaseItemProvider configuration. +/// +public class BaseItemProviderConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => new { e.ItemId, e.ProviderId }); + builder.HasOne(e => e.Item); + builder.HasIndex(e => new { e.ProviderId, e.ProviderValue, e.ItemId }); + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/BaseItemTrailerTypeConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/BaseItemTrailerTypeConfiguration.cs new file mode 100644 index 0000000000..2a888b7de8 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/BaseItemTrailerTypeConfiguration.cs @@ -0,0 +1,18 @@ +using Jellyfin.Database.Implementations.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Jellyfin.Database.Implementations.ModelConfiguration; + +/// +/// Provides configuration for the BaseItemMetadataField entity. +/// +public class BaseItemTrailerTypeConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => new { e.Id, e.ItemId }); + builder.HasOne(e => e.Item); + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/ChapterConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/ChapterConfiguration.cs new file mode 100644 index 0000000000..d97a39f4d2 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/ChapterConfiguration.cs @@ -0,0 +1,18 @@ +using Jellyfin.Database.Implementations.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Jellyfin.Database.Implementations.ModelConfiguration; + +/// +/// Chapter configuration. +/// +public class ChapterConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => new { e.ItemId, e.ChapterIndex }); + builder.HasOne(e => e.Item); + } +} diff --git a/Jellyfin.Server.Implementations/ModelConfiguration/CustomItemDisplayPreferencesConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/CustomItemDisplayPreferencesConfiguration.cs similarity index 84% rename from Jellyfin.Server.Implementations/ModelConfiguration/CustomItemDisplayPreferencesConfiguration.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/CustomItemDisplayPreferencesConfiguration.cs index 779aec986b..e8a510ab97 100644 --- a/Jellyfin.Server.Implementations/ModelConfiguration/CustomItemDisplayPreferencesConfiguration.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/CustomItemDisplayPreferencesConfiguration.cs @@ -1,8 +1,8 @@ -using Jellyfin.Data.Entities; +using Jellyfin.Database.Implementations.Entities; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; -namespace Jellyfin.Server.Implementations.ModelConfiguration +namespace Jellyfin.Database.Implementations.ModelConfiguration { /// /// FluentAPI configuration for the CustomItemDisplayPreferences entity. diff --git a/Jellyfin.Server.Implementations/ModelConfiguration/DeviceConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/DeviceConfiguration.cs similarity index 86% rename from Jellyfin.Server.Implementations/ModelConfiguration/DeviceConfiguration.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/DeviceConfiguration.cs index a750b65c03..3551f76863 100644 --- a/Jellyfin.Server.Implementations/ModelConfiguration/DeviceConfiguration.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/DeviceConfiguration.cs @@ -1,8 +1,8 @@ -using Jellyfin.Data.Entities.Security; +using Jellyfin.Database.Implementations.Entities.Security; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; -namespace Jellyfin.Server.Implementations.ModelConfiguration +namespace Jellyfin.Database.Implementations.ModelConfiguration { /// /// FluentAPI configuration for the Device entity. diff --git a/Jellyfin.Server.Implementations/ModelConfiguration/DeviceOptionsConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/DeviceOptionsConfiguration.cs similarity index 80% rename from Jellyfin.Server.Implementations/ModelConfiguration/DeviceOptionsConfiguration.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/DeviceOptionsConfiguration.cs index 038afd7524..9055e80253 100644 --- a/Jellyfin.Server.Implementations/ModelConfiguration/DeviceOptionsConfiguration.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/DeviceOptionsConfiguration.cs @@ -1,8 +1,8 @@ -using Jellyfin.Data.Entities.Security; +using Jellyfin.Database.Implementations.Entities.Security; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; -namespace Jellyfin.Server.Implementations.ModelConfiguration +namespace Jellyfin.Database.Implementations.ModelConfiguration { /// /// FluentAPI configuration for the DeviceOptions entity. diff --git a/Jellyfin.Server.Implementations/ModelConfiguration/DisplayPreferencesConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/DisplayPreferencesConfiguration.cs similarity index 86% rename from Jellyfin.Server.Implementations/ModelConfiguration/DisplayPreferencesConfiguration.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/DisplayPreferencesConfiguration.cs index 9b437861bb..45e0c64824 100644 --- a/Jellyfin.Server.Implementations/ModelConfiguration/DisplayPreferencesConfiguration.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/DisplayPreferencesConfiguration.cs @@ -1,8 +1,8 @@ -using Jellyfin.Data.Entities; +using Jellyfin.Database.Implementations.Entities; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; -namespace Jellyfin.Server.Implementations.ModelConfiguration +namespace Jellyfin.Database.Implementations.ModelConfiguration { /// /// FluentAPI configuration for the DisplayPreferencesConfiguration entity. diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/ItemValuesConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/ItemValuesConfiguration.cs new file mode 100644 index 0000000000..97ebc2e01b --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/ItemValuesConfiguration.cs @@ -0,0 +1,19 @@ +using Jellyfin.Database.Implementations.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Jellyfin.Database.Implementations.ModelConfiguration; + +/// +/// itemvalues Configuration. +/// +public class ItemValuesConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => e.ItemValueId); + builder.HasIndex(e => new { e.Type, e.CleanValue }); + builder.HasIndex(e => new { e.Type, e.Value }).IsUnique(); + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/ItemValuesMapConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/ItemValuesMapConfiguration.cs new file mode 100644 index 0000000000..42ef235326 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/ItemValuesMapConfiguration.cs @@ -0,0 +1,19 @@ +using Jellyfin.Database.Implementations.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Jellyfin.Database.Implementations.ModelConfiguration; + +/// +/// itemvalues Configuration. +/// +public class ItemValuesMapConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => new { e.ItemValueId, e.ItemId }); + builder.HasOne(e => e.Item); + builder.HasOne(e => e.ItemValue); + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/KeyframeDataConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/KeyframeDataConfiguration.cs new file mode 100644 index 0000000000..3f5d458ca2 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/KeyframeDataConfiguration.cs @@ -0,0 +1,18 @@ +using Jellyfin.Database.Implementations.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Jellyfin.Database.Implementations.ModelConfiguration; + +/// +/// KeyframeData Configuration. +/// +public class KeyframeDataConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => e.ItemId); + builder.HasOne(e => e.Item).WithMany().HasForeignKey(e => e.ItemId); + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/MediaStreamInfoConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/MediaStreamInfoConfiguration.cs new file mode 100644 index 0000000000..075af2c053 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/MediaStreamInfoConfiguration.cs @@ -0,0 +1,21 @@ +using Jellyfin.Database.Implementations.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Jellyfin.Database.Implementations.ModelConfiguration; + +/// +/// People configuration. +/// +public class MediaStreamInfoConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => new { e.ItemId, e.StreamIndex }); + builder.HasIndex(e => e.StreamIndex); + builder.HasIndex(e => e.StreamType); + builder.HasIndex(e => new { e.StreamIndex, e.StreamType }); + builder.HasIndex(e => new { e.StreamIndex, e.StreamType, e.Language }); + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PeopleBaseItemMapConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PeopleBaseItemMapConfiguration.cs new file mode 100644 index 0000000000..5e3ab44433 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PeopleBaseItemMapConfiguration.cs @@ -0,0 +1,21 @@ +using Jellyfin.Database.Implementations.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Jellyfin.Database.Implementations.ModelConfiguration; + +/// +/// People configuration. +/// +public class PeopleBaseItemMapConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => new { e.ItemId, e.PeopleId }); + builder.HasIndex(e => new { e.ItemId, e.SortOrder }); + builder.HasIndex(e => new { e.ItemId, e.ListOrder }); + builder.HasOne(e => e.Item); + builder.HasOne(e => e.People); + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PeopleConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PeopleConfiguration.cs new file mode 100644 index 0000000000..e8f77a8067 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PeopleConfiguration.cs @@ -0,0 +1,19 @@ +using Jellyfin.Database.Implementations.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Jellyfin.Database.Implementations.ModelConfiguration; + +/// +/// People configuration. +/// +public class PeopleConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => e.Id); + builder.HasIndex(e => e.Name); + builder.HasMany(e => e.BaseItems); + } +} diff --git a/Jellyfin.Server.Implementations/ModelConfiguration/PermissionConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PermissionConfiguration.cs similarity index 87% rename from Jellyfin.Server.Implementations/ModelConfiguration/PermissionConfiguration.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PermissionConfiguration.cs index 240e284c0c..d2aed54eb1 100644 --- a/Jellyfin.Server.Implementations/ModelConfiguration/PermissionConfiguration.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PermissionConfiguration.cs @@ -1,8 +1,8 @@ -using Jellyfin.Data.Entities; +using Jellyfin.Database.Implementations.Entities; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; -namespace Jellyfin.Server.Implementations.ModelConfiguration +namespace Jellyfin.Database.Implementations.ModelConfiguration { /// /// FluentAPI configuration for the Permission entity. diff --git a/Jellyfin.Server.Implementations/ModelConfiguration/PreferenceConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PreferenceConfiguration.cs similarity index 83% rename from Jellyfin.Server.Implementations/ModelConfiguration/PreferenceConfiguration.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PreferenceConfiguration.cs index 49c869c6a8..207051bcd1 100644 --- a/Jellyfin.Server.Implementations/ModelConfiguration/PreferenceConfiguration.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PreferenceConfiguration.cs @@ -1,8 +1,8 @@ -using Jellyfin.Data.Entities; +using Jellyfin.Database.Implementations.Entities; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; -namespace Jellyfin.Server.Implementations.ModelConfiguration +namespace Jellyfin.Database.Implementations.ModelConfiguration { /// /// FluentAPI configuration for the Permission entity. diff --git a/Jellyfin.Server.Implementations/ModelConfiguration/TrickplayInfoConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/TrickplayInfoConfiguration.cs similarity index 80% rename from Jellyfin.Server.Implementations/ModelConfiguration/TrickplayInfoConfiguration.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/TrickplayInfoConfiguration.cs index dc1c17e5ef..1b364a05ed 100644 --- a/Jellyfin.Server.Implementations/ModelConfiguration/TrickplayInfoConfiguration.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/TrickplayInfoConfiguration.cs @@ -1,8 +1,8 @@ -using Jellyfin.Data.Entities; +using Jellyfin.Database.Implementations.Entities; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; -namespace Jellyfin.Server.Implementations.ModelConfiguration +namespace Jellyfin.Database.Implementations.ModelConfiguration { /// /// FluentAPI configuration for the TrickplayInfo entity. diff --git a/Jellyfin.Server.Implementations/ModelConfiguration/UserConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/UserConfiguration.cs similarity index 89% rename from Jellyfin.Server.Implementations/ModelConfiguration/UserConfiguration.cs rename to src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/UserConfiguration.cs index a369cf6562..61b5e06e8a 100644 --- a/Jellyfin.Server.Implementations/ModelConfiguration/UserConfiguration.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/UserConfiguration.cs @@ -1,8 +1,8 @@ -using Jellyfin.Data.Entities; +using Jellyfin.Database.Implementations.Entities; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; -namespace Jellyfin.Server.Implementations.ModelConfiguration +namespace Jellyfin.Database.Implementations.ModelConfiguration { /// /// FluentAPI configuration for the User entity. @@ -13,8 +13,7 @@ namespace Jellyfin.Server.Implementations.ModelConfiguration public void Configure(EntityTypeBuilder builder) { builder - .Property(user => user.Username) - .UseCollation("NOCASE"); + .Property(user => user.Username); builder .HasOne(u => u.ProfileImage) diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/UserDataConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/UserDataConfiguration.cs new file mode 100644 index 0000000000..47604d3217 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/UserDataConfiguration.cs @@ -0,0 +1,22 @@ +using Jellyfin.Database.Implementations.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Jellyfin.Database.Implementations.ModelConfiguration; + +/// +/// FluentAPI configuration for the UserData entity. +/// +public class UserDataConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(d => new { d.ItemId, d.UserId, d.CustomDataKey }); + builder.HasIndex(d => new { d.ItemId, d.UserId, d.Played }); + builder.HasIndex(d => new { d.ItemId, d.UserId, d.PlaybackPositionTicks }); + builder.HasIndex(d => new { d.ItemId, d.UserId, d.IsFavorite }); + builder.HasIndex(d => new { d.ItemId, d.UserId, d.LastPlayedDate }); + builder.HasOne(e => e.Item); + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/DoNotUseReturningClauseConvention.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/DoNotUseReturningClauseConvention.cs new file mode 100644 index 0000000000..18f336dda8 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/DoNotUseReturningClauseConvention.cs @@ -0,0 +1,20 @@ +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Microsoft.EntityFrameworkCore.Metadata.Conventions; + +namespace Jellyfin.Database.Providers.Sqlite; + +internal class DoNotUseReturningClauseConvention : IModelFinalizingConvention +{ + /// + public void ProcessModelFinalizing( + IConventionModelBuilder modelBuilder, + IConventionContext context) + { + foreach (var entityType in modelBuilder.Metadata.GetEntityTypes()) + { + entityType.UseSqlReturningClause(false); + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Jellyfin.Database.Providers.Sqlite.csproj b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Jellyfin.Database.Providers.Sqlite.csproj new file mode 100644 index 0000000000..03e5fc4958 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Jellyfin.Database.Providers.Sqlite.csproj @@ -0,0 +1,31 @@ + + + + net9.0 + false + true + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/.gitattributes b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/.gitattributes new file mode 100644 index 0000000000..da5c26f400 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/.gitattributes @@ -0,0 +1 @@ +JellyfinDbModelSnapshot.cs binary diff --git a/Jellyfin.Server.Implementations/Migrations/20200514181226_AddActivityLog.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20200514181226_AddActivityLog.Designer.cs similarity index 98% rename from Jellyfin.Server.Implementations/Migrations/20200514181226_AddActivityLog.Designer.cs rename to src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20200514181226_AddActivityLog.Designer.cs index 80fe784dd7..7891006430 100644 --- a/Jellyfin.Server.Implementations/Migrations/20200514181226_AddActivityLog.Designer.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20200514181226_AddActivityLog.Designer.cs @@ -2,7 +2,7 @@ // using System; -using Jellyfin.Server.Implementations; +using Jellyfin.Database.Implementations; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; diff --git a/Jellyfin.Server.Implementations/Migrations/20200514181226_AddActivityLog.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20200514181226_AddActivityLog.cs similarity index 100% rename from Jellyfin.Server.Implementations/Migrations/20200514181226_AddActivityLog.cs rename to src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20200514181226_AddActivityLog.cs diff --git a/Jellyfin.Server.Implementations/Migrations/20200613202153_AddUsers.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20200613202153_AddUsers.Designer.cs similarity index 99% rename from Jellyfin.Server.Implementations/Migrations/20200613202153_AddUsers.Designer.cs rename to src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20200613202153_AddUsers.Designer.cs index 7aa4479b3d..eab3cd3e7e 100644 --- a/Jellyfin.Server.Implementations/Migrations/20200613202153_AddUsers.Designer.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20200613202153_AddUsers.Designer.cs @@ -2,7 +2,7 @@ // using System; -using Jellyfin.Server.Implementations; +using Jellyfin.Database.Implementations; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; diff --git a/Jellyfin.Server.Implementations/Migrations/20200613202153_AddUsers.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20200613202153_AddUsers.cs similarity index 100% rename from Jellyfin.Server.Implementations/Migrations/20200613202153_AddUsers.cs rename to src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20200613202153_AddUsers.cs diff --git a/Jellyfin.Server.Implementations/Migrations/20200728005145_AddDisplayPreferences.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20200728005145_AddDisplayPreferences.Designer.cs similarity index 99% rename from Jellyfin.Server.Implementations/Migrations/20200728005145_AddDisplayPreferences.Designer.cs rename to src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20200728005145_AddDisplayPreferences.Designer.cs index 3860c851d0..91dd0ff7ab 100644 --- a/Jellyfin.Server.Implementations/Migrations/20200728005145_AddDisplayPreferences.Designer.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20200728005145_AddDisplayPreferences.Designer.cs @@ -2,7 +2,7 @@ // using System; -using Jellyfin.Server.Implementations; +using Jellyfin.Database.Implementations; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; diff --git a/Jellyfin.Server.Implementations/Migrations/20200728005145_AddDisplayPreferences.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20200728005145_AddDisplayPreferences.cs similarity index 100% rename from Jellyfin.Server.Implementations/Migrations/20200728005145_AddDisplayPreferences.cs rename to src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20200728005145_AddDisplayPreferences.cs diff --git a/Jellyfin.Server.Implementations/Migrations/20200905220533_FixDisplayPreferencesIndex.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20200905220533_FixDisplayPreferencesIndex.Designer.cs similarity index 99% rename from Jellyfin.Server.Implementations/Migrations/20200905220533_FixDisplayPreferencesIndex.Designer.cs rename to src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20200905220533_FixDisplayPreferencesIndex.Designer.cs index 1134f7aa42..8ec9231030 100644 --- a/Jellyfin.Server.Implementations/Migrations/20200905220533_FixDisplayPreferencesIndex.Designer.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20200905220533_FixDisplayPreferencesIndex.Designer.cs @@ -2,7 +2,7 @@ // using System; -using Jellyfin.Server.Implementations; +using Jellyfin.Database.Implementations; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; diff --git a/Jellyfin.Server.Implementations/Migrations/20200905220533_FixDisplayPreferencesIndex.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20200905220533_FixDisplayPreferencesIndex.cs similarity index 100% rename from Jellyfin.Server.Implementations/Migrations/20200905220533_FixDisplayPreferencesIndex.cs rename to src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20200905220533_FixDisplayPreferencesIndex.cs diff --git a/Jellyfin.Server.Implementations/Migrations/20201004171403_AddMaxActiveSessions.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20201004171403_AddMaxActiveSessions.Designer.cs similarity index 99% rename from Jellyfin.Server.Implementations/Migrations/20201004171403_AddMaxActiveSessions.Designer.cs rename to src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20201004171403_AddMaxActiveSessions.Designer.cs index 607310caae..499faa9c47 100644 --- a/Jellyfin.Server.Implementations/Migrations/20201004171403_AddMaxActiveSessions.Designer.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20201004171403_AddMaxActiveSessions.Designer.cs @@ -2,7 +2,7 @@ // using System; -using Jellyfin.Server.Implementations; +using Jellyfin.Database.Implementations; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; diff --git a/Jellyfin.Server.Implementations/Migrations/20201004171403_AddMaxActiveSessions.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20201004171403_AddMaxActiveSessions.cs similarity index 100% rename from Jellyfin.Server.Implementations/Migrations/20201004171403_AddMaxActiveSessions.cs rename to src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20201004171403_AddMaxActiveSessions.cs diff --git a/Jellyfin.Server.Implementations/Migrations/20201204223655_AddCustomDisplayPreferences.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20201204223655_AddCustomDisplayPreferences.Designer.cs similarity index 99% rename from Jellyfin.Server.Implementations/Migrations/20201204223655_AddCustomDisplayPreferences.Designer.cs rename to src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20201204223655_AddCustomDisplayPreferences.Designer.cs index 02c3fc7532..7ab851689a 100644 --- a/Jellyfin.Server.Implementations/Migrations/20201204223655_AddCustomDisplayPreferences.Designer.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20201204223655_AddCustomDisplayPreferences.Designer.cs @@ -1,7 +1,7 @@ #pragma warning disable CS1591 // using System; -using Jellyfin.Server.Implementations; +using Jellyfin.Database.Implementations; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; diff --git a/Jellyfin.Server.Implementations/Migrations/20201204223655_AddCustomDisplayPreferences.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20201204223655_AddCustomDisplayPreferences.cs similarity index 100% rename from Jellyfin.Server.Implementations/Migrations/20201204223655_AddCustomDisplayPreferences.cs rename to src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20201204223655_AddCustomDisplayPreferences.cs diff --git a/Jellyfin.Server.Implementations/Migrations/20210320181425_AddIndexesAndCollations.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20210320181425_AddIndexesAndCollations.Designer.cs similarity index 99% rename from Jellyfin.Server.Implementations/Migrations/20210320181425_AddIndexesAndCollations.Designer.cs rename to src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20210320181425_AddIndexesAndCollations.Designer.cs index 1cfd7112ce..e14ed9380e 100644 --- a/Jellyfin.Server.Implementations/Migrations/20210320181425_AddIndexesAndCollations.Designer.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20210320181425_AddIndexesAndCollations.Designer.cs @@ -2,7 +2,7 @@ // using System; -using Jellyfin.Server.Implementations; +using Jellyfin.Database.Implementations; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; diff --git a/Jellyfin.Server.Implementations/Migrations/20210320181425_AddIndexesAndCollations.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20210320181425_AddIndexesAndCollations.cs similarity index 100% rename from Jellyfin.Server.Implementations/Migrations/20210320181425_AddIndexesAndCollations.cs rename to src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20210320181425_AddIndexesAndCollations.cs diff --git a/Jellyfin.Server.Implementations/Migrations/20210407110544_NullableCustomPrefValue.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20210407110544_NullableCustomPrefValue.Designer.cs similarity index 99% rename from Jellyfin.Server.Implementations/Migrations/20210407110544_NullableCustomPrefValue.Designer.cs rename to src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20210407110544_NullableCustomPrefValue.Designer.cs index ecf7af4952..05f2c80a25 100644 --- a/Jellyfin.Server.Implementations/Migrations/20210407110544_NullableCustomPrefValue.Designer.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20210407110544_NullableCustomPrefValue.Designer.cs @@ -1,7 +1,7 @@ #pragma warning disable CS1591 // using System; -using Jellyfin.Server.Implementations; +using Jellyfin.Database.Implementations; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; diff --git a/Jellyfin.Server.Implementations/Migrations/20210407110544_NullableCustomPrefValue.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20210407110544_NullableCustomPrefValue.cs similarity index 100% rename from Jellyfin.Server.Implementations/Migrations/20210407110544_NullableCustomPrefValue.cs rename to src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20210407110544_NullableCustomPrefValue.cs diff --git a/Jellyfin.Server.Implementations/Migrations/20210814002109_AddDevices.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20210814002109_AddDevices.Designer.cs similarity index 99% rename from Jellyfin.Server.Implementations/Migrations/20210814002109_AddDevices.Designer.cs rename to src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20210814002109_AddDevices.Designer.cs index dccba6f773..c9f3cf696c 100644 --- a/Jellyfin.Server.Implementations/Migrations/20210814002109_AddDevices.Designer.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20210814002109_AddDevices.Designer.cs @@ -2,7 +2,7 @@ // using System; -using Jellyfin.Server.Implementations; +using Jellyfin.Database.Implementations; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; diff --git a/Jellyfin.Server.Implementations/Migrations/20210814002109_AddDevices.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20210814002109_AddDevices.cs similarity index 100% rename from Jellyfin.Server.Implementations/Migrations/20210814002109_AddDevices.cs rename to src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20210814002109_AddDevices.cs diff --git a/Jellyfin.Server.Implementations/Migrations/20221022080052_AddIndexActivityLogsDateCreated.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20221022080052_AddIndexActivityLogsDateCreated.Designer.cs similarity index 99% rename from Jellyfin.Server.Implementations/Migrations/20221022080052_AddIndexActivityLogsDateCreated.Designer.cs rename to src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20221022080052_AddIndexActivityLogsDateCreated.Designer.cs index e821c106e3..ab7781d155 100644 --- a/Jellyfin.Server.Implementations/Migrations/20221022080052_AddIndexActivityLogsDateCreated.Designer.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20221022080052_AddIndexActivityLogsDateCreated.Designer.cs @@ -2,7 +2,7 @@ // using System; -using Jellyfin.Server.Implementations; +using Jellyfin.Database.Implementations; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; diff --git a/Jellyfin.Server.Implementations/Migrations/20221022080052_AddIndexActivityLogsDateCreated.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20221022080052_AddIndexActivityLogsDateCreated.cs similarity index 100% rename from Jellyfin.Server.Implementations/Migrations/20221022080052_AddIndexActivityLogsDateCreated.cs rename to src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20221022080052_AddIndexActivityLogsDateCreated.cs diff --git a/Jellyfin.Server.Implementations/Migrations/20230526173516_RemoveEasyPassword.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20230526173516_RemoveEasyPassword.Designer.cs similarity index 99% rename from Jellyfin.Server.Implementations/Migrations/20230526173516_RemoveEasyPassword.Designer.cs rename to src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20230526173516_RemoveEasyPassword.Designer.cs index 360fa03768..8a2806113f 100644 --- a/Jellyfin.Server.Implementations/Migrations/20230526173516_RemoveEasyPassword.Designer.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20230526173516_RemoveEasyPassword.Designer.cs @@ -1,6 +1,6 @@ // using System; -using Jellyfin.Server.Implementations; +using Jellyfin.Database.Implementations; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; diff --git a/Jellyfin.Server.Implementations/Migrations/20230526173516_RemoveEasyPassword.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20230526173516_RemoveEasyPassword.cs similarity index 100% rename from Jellyfin.Server.Implementations/Migrations/20230526173516_RemoveEasyPassword.cs rename to src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20230526173516_RemoveEasyPassword.cs diff --git a/Jellyfin.Server.Implementations/Migrations/20230626233818_AddTrickplayInfos.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20230626233818_AddTrickplayInfos.Designer.cs similarity index 99% rename from Jellyfin.Server.Implementations/Migrations/20230626233818_AddTrickplayInfos.Designer.cs rename to src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20230626233818_AddTrickplayInfos.Designer.cs index 17d33845f5..a11507bd5d 100644 --- a/Jellyfin.Server.Implementations/Migrations/20230626233818_AddTrickplayInfos.Designer.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20230626233818_AddTrickplayInfos.Designer.cs @@ -1,6 +1,6 @@ // using System; -using Jellyfin.Server.Implementations; +using Jellyfin.Database.Implementations; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; diff --git a/Jellyfin.Server.Implementations/Migrations/20230626233818_AddTrickplayInfos.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20230626233818_AddTrickplayInfos.cs similarity index 100% rename from Jellyfin.Server.Implementations/Migrations/20230626233818_AddTrickplayInfos.cs rename to src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20230626233818_AddTrickplayInfos.cs diff --git a/Jellyfin.Server.Implementations/Migrations/20230923170422_UserCastReceiver.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20230923170422_UserCastReceiver.Designer.cs similarity index 99% rename from Jellyfin.Server.Implementations/Migrations/20230923170422_UserCastReceiver.Designer.cs rename to src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20230923170422_UserCastReceiver.Designer.cs index 4c09176691..ddea37f6dd 100644 --- a/Jellyfin.Server.Implementations/Migrations/20230923170422_UserCastReceiver.Designer.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20230923170422_UserCastReceiver.Designer.cs @@ -1,6 +1,6 @@ // using System; -using Jellyfin.Server.Implementations; +using Jellyfin.Database.Implementations; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; diff --git a/Jellyfin.Server.Implementations/Migrations/20230923170422_UserCastReceiver.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20230923170422_UserCastReceiver.cs similarity index 100% rename from Jellyfin.Server.Implementations/Migrations/20230923170422_UserCastReceiver.cs rename to src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20230923170422_UserCastReceiver.cs diff --git a/Jellyfin.Server.Implementations/Migrations/20240729140605_AddMediaSegments.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20240729140605_AddMediaSegments.Designer.cs similarity index 99% rename from Jellyfin.Server.Implementations/Migrations/20240729140605_AddMediaSegments.Designer.cs rename to src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20240729140605_AddMediaSegments.Designer.cs index 35a3cdad2d..ab7065ee65 100644 --- a/Jellyfin.Server.Implementations/Migrations/20240729140605_AddMediaSegments.Designer.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20240729140605_AddMediaSegments.Designer.cs @@ -1,6 +1,6 @@ // using System; -using Jellyfin.Server.Implementations; +using Jellyfin.Database.Implementations; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; diff --git a/Jellyfin.Server.Implementations/Migrations/20240729140605_AddMediaSegments.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20240729140605_AddMediaSegments.cs similarity index 100% rename from Jellyfin.Server.Implementations/Migrations/20240729140605_AddMediaSegments.cs rename to src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20240729140605_AddMediaSegments.cs diff --git a/Jellyfin.Server.Implementations/Migrations/20240928082930_MarkSegmentProviderIdNonNullable.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20240928082930_MarkSegmentProviderIdNonNullable.Designer.cs similarity index 99% rename from Jellyfin.Server.Implementations/Migrations/20240928082930_MarkSegmentProviderIdNonNullable.Designer.cs rename to src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20240928082930_MarkSegmentProviderIdNonNullable.Designer.cs index 8dba31a67f..aa60bff324 100644 --- a/Jellyfin.Server.Implementations/Migrations/20240928082930_MarkSegmentProviderIdNonNullable.Designer.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20240928082930_MarkSegmentProviderIdNonNullable.Designer.cs @@ -1,6 +1,6 @@ // using System; -using Jellyfin.Server.Implementations; +using Jellyfin.Database.Implementations; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; diff --git a/Jellyfin.Server.Implementations/Migrations/20240928082930_MarkSegmentProviderIdNonNullable.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20240928082930_MarkSegmentProviderIdNonNullable.cs similarity index 100% rename from Jellyfin.Server.Implementations/Migrations/20240928082930_MarkSegmentProviderIdNonNullable.cs rename to src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20240928082930_MarkSegmentProviderIdNonNullable.cs diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241020103111_LibraryDbMigration.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241020103111_LibraryDbMigration.Designer.cs new file mode 100644 index 0000000000..2ea6dafe13 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241020103111_LibraryDbMigration.Designer.cs @@ -0,0 +1,1607 @@ +// +using System; +using Jellyfin.Database.Implementations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + [DbContext(typeof(JellyfinDbContext))] + [Migration("20241020103111_LibraryDbMigration")] + partial class LibraryDbMigration + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.10"); + + modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property("EndHour") + .HasColumnType("REAL"); + + b.Property("StartHour") + .HasColumnType("REAL"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AccessSchedules"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ActivityLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LogSeverity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ShortOverview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DateCreated"); + + b.ToTable("ActivityLogs"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AncestorId", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ParentItemId") + .HasColumnType("TEXT"); + + b.Property("BaseItemEntityId") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ParentItemId"); + + b.HasIndex("BaseItemEntityId"); + + b.HasIndex("ParentItemId"); + + b.ToTable("AncestorIds"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AttachmentStreamInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CodecTag") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Filename") + .HasColumnType("TEXT"); + + b.Property("MimeType") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "Index"); + + b.ToTable("AttachmentStreamInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Album") + .HasColumnType("TEXT"); + + b.Property("AlbumArtists") + .HasColumnType("TEXT"); + + b.Property("Artists") + .HasColumnType("TEXT"); + + b.Property("Audio") + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("CleanName") + .HasColumnType("TEXT"); + + b.Property("CommunityRating") + .HasColumnType("REAL"); + + b.Property("CriticRating") + .HasColumnType("REAL"); + + b.Property("CustomRating") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastMediaAdded") + .HasColumnType("TEXT"); + + b.Property("DateLastRefreshed") + .HasColumnType("TEXT"); + + b.Property("DateLastSaved") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("EpisodeTitle") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasColumnType("TEXT"); + + b.Property("ExternalSeriesId") + .HasColumnType("TEXT"); + + b.Property("ExternalServiceId") + .HasColumnType("TEXT"); + + b.Property("ExtraIds") + .HasColumnType("TEXT"); + + b.Property("ExtraType") + .HasColumnType("INTEGER"); + + b.Property("ForcedSortName") + .HasColumnType("TEXT"); + + b.Property("Genres") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("IndexNumber") + .HasColumnType("INTEGER"); + + b.Property("InheritedParentalRatingValue") + .HasColumnType("INTEGER"); + + b.Property("IsFolder") + .HasColumnType("INTEGER"); + + b.Property("IsInMixedFolder") + .HasColumnType("INTEGER"); + + b.Property("IsLocked") + .HasColumnType("INTEGER"); + + b.Property("IsMovie") + .HasColumnType("INTEGER"); + + b.Property("IsRepeat") + .HasColumnType("INTEGER"); + + b.Property("IsSeries") + .HasColumnType("INTEGER"); + + b.Property("IsVirtualItem") + .HasColumnType("INTEGER"); + + b.Property("LUFS") + .HasColumnType("REAL"); + + b.Property("MediaType") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizationGain") + .HasColumnType("REAL"); + + b.Property("OfficialRating") + .HasColumnType("TEXT"); + + b.Property("OriginalTitle") + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("OwnerId") + .HasColumnType("TEXT"); + + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("ParentIndexNumber") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PreferredMetadataCountryCode") + .HasColumnType("TEXT"); + + b.Property("PreferredMetadataLanguage") + .HasColumnType("TEXT"); + + b.Property("PremiereDate") + .HasColumnType("TEXT"); + + b.Property("PresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property("PrimaryVersionId") + .HasColumnType("TEXT"); + + b.Property("ProductionLocations") + .HasColumnType("TEXT"); + + b.Property("ProductionYear") + .HasColumnType("INTEGER"); + + b.Property("RunTimeTicks") + .HasColumnType("INTEGER"); + + b.Property("SeasonId") + .HasColumnType("TEXT"); + + b.Property("SeasonName") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("SeriesName") + .HasColumnType("TEXT"); + + b.Property("SeriesPresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property("ShowId") + .HasColumnType("TEXT"); + + b.Property("Size") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("Studios") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("TopParentId") + .HasColumnType("TEXT"); + + b.Property("TotalBitrate") + .HasColumnType("INTEGER"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UnratedType") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.HasIndex("Path"); + + b.HasIndex("PresentationUniqueKey"); + + b.HasIndex("TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "PresentationUniqueKey"); + + b.HasIndex("Type", "TopParentId", "StartDate"); + + b.HasIndex("Id", "Type", "IsFolder", "IsVirtualItem"); + + b.HasIndex("MediaType", "TopParentId", "IsVirtualItem", "PresentationUniqueKey"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "IsFolder", "IsVirtualItem"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "PresentationUniqueKey", "SortName"); + + b.HasIndex("IsFolder", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.HasIndex("Type", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.ToTable("BaseItems"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemImageInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Blurhash") + .HasColumnType("BLOB"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("ImageType") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemImageInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemMetadataField", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemMetadataFields"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemProvider", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("ProviderValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ProviderId"); + + b.HasIndex("ProviderId", "ProviderValue", "ItemId"); + + b.ToTable("BaseItemProviders"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemTrailerType", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemTrailerTypes"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Chapter", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ChapterIndex") + .HasColumnType("INTEGER"); + + b.Property("ImageDateModified") + .HasColumnType("TEXT"); + + b.Property("ImagePath") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("StartPositionTicks") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "ChapterIndex"); + + b.ToTable("Chapters"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.CustomItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client", "Key") + .IsUnique(); + + b.ToTable("CustomItemDisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChromecastVersion") + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DashboardTheme") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("EnableNextVideoInfoOverlay") + .HasColumnType("INTEGER"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ScrollDirection") + .HasColumnType("INTEGER"); + + b.Property("ShowBackdrop") + .HasColumnType("INTEGER"); + + b.Property("ShowSidebar") + .HasColumnType("INTEGER"); + + b.Property("SkipBackwardLength") + .HasColumnType("INTEGER"); + + b.Property("SkipForwardLength") + .HasColumnType("INTEGER"); + + b.Property("TvHome") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client") + .IsUnique(); + + b.ToTable("DisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DisplayPreferencesId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DisplayPreferencesId"); + + b.ToTable("HomeSection"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("ImageInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("RememberIndexing") + .HasColumnType("INTEGER"); + + b.Property("RememberSorting") + .HasColumnType("INTEGER"); + + b.Property("SortBy") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("ViewType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("ItemDisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemValue", b => + { + b.Property("ItemValueId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CleanValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId"); + + b.HasIndex("Type", "CleanValue"); + + b.ToTable("ItemValues"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemValueMap", b => + { + b.Property("ItemValueId") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("ItemValuesMap"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.MediaSegment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EndTicks") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("SegmentProviderId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StartTicks") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("MediaSegments"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.MediaStreamInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("StreamIndex") + .HasColumnType("INTEGER"); + + b.Property("AspectRatio") + .HasColumnType("TEXT"); + + b.Property("AverageFrameRate") + .HasColumnType("REAL"); + + b.Property("BitDepth") + .HasColumnType("INTEGER"); + + b.Property("BitRate") + .HasColumnType("INTEGER"); + + b.Property("BlPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("ChannelLayout") + .HasColumnType("TEXT"); + + b.Property("Channels") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasColumnType("TEXT"); + + b.Property("CodecTag") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CodecTimeBase") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ColorPrimaries") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ColorSpace") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ColorTransfer") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DvBlSignalCompatibilityId") + .HasColumnType("INTEGER"); + + b.Property("DvLevel") + .HasColumnType("INTEGER"); + + b.Property("DvProfile") + .HasColumnType("INTEGER"); + + b.Property("DvVersionMajor") + .HasColumnType("INTEGER"); + + b.Property("DvVersionMinor") + .HasColumnType("INTEGER"); + + b.Property("ElPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("IsAnamorphic") + .HasColumnType("INTEGER"); + + b.Property("IsAvc") + .HasColumnType("INTEGER"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("IsExternal") + .HasColumnType("INTEGER"); + + b.Property("IsForced") + .HasColumnType("INTEGER"); + + b.Property("IsHearingImpaired") + .HasColumnType("INTEGER"); + + b.Property("IsInterlaced") + .HasColumnType("INTEGER"); + + b.Property("KeyFrames") + .HasColumnType("TEXT"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("REAL"); + + b.Property("NalLengthSize") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PixelFormat") + .HasColumnType("TEXT"); + + b.Property("Profile") + .HasColumnType("TEXT"); + + b.Property("RealFrameRate") + .HasColumnType("REAL"); + + b.Property("RefFrames") + .HasColumnType("INTEGER"); + + b.Property("Rotation") + .HasColumnType("INTEGER"); + + b.Property("RpuPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("SampleRate") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .HasColumnType("INTEGER"); + + b.Property("TimeBase") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "StreamIndex"); + + b.HasIndex("StreamIndex"); + + b.HasIndex("StreamType"); + + b.HasIndex("StreamIndex", "StreamType"); + + b.HasIndex("StreamIndex", "StreamType", "Language"); + + b.ToTable("MediaStreamInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.People", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PersonType") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("Peoples"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.PeopleBaseItemMap", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("PeopleId") + .HasColumnType("TEXT"); + + b.Property("ListOrder") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.HasIndex("ItemId", "ListOrder"); + + b.HasIndex("ItemId", "SortOrder"); + + b.ToTable("PeopleBaseItemMap"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Permission_Permissions_Guid") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Permissions"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Preference_Preferences_Guid") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Preferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccessToken") + .IsUnique(); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AppName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AppVersion") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("AccessToken", "DateLastActivity"); + + b.HasIndex("DeviceId", "DateLastActivity"); + + b.HasIndex("UserId", "DeviceId"); + + b.ToTable("Devices"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.DeviceOptions", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CustomName") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId") + .IsUnique(); + + b.ToTable("DeviceOptions"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.TrickplayInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.Property("Bandwidth") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Interval") + .HasColumnType("INTEGER"); + + b.Property("ThumbnailCount") + .HasColumnType("INTEGER"); + + b.Property("TileHeight") + .HasColumnType("INTEGER"); + + b.Property("TileWidth") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "Width"); + + b.ToTable("TrickplayInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AudioLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("AuthenticationProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("CastReceiverId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DisplayCollectionsView") + .HasColumnType("INTEGER"); + + b.Property("DisplayMissingEpisodes") + .HasColumnType("INTEGER"); + + b.Property("EnableAutoLogin") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalPassword") + .HasColumnType("INTEGER"); + + b.Property("EnableNextEpisodeAutoPlay") + .HasColumnType("INTEGER"); + + b.Property("EnableUserPreferenceAccess") + .HasColumnType("INTEGER"); + + b.Property("HidePlayedInLatest") + .HasColumnType("INTEGER"); + + b.Property("InternalId") + .HasColumnType("INTEGER"); + + b.Property("InvalidLoginAttemptCount") + .HasColumnType("INTEGER"); + + b.Property("LastActivityDate") + .HasColumnType("TEXT"); + + b.Property("LastLoginDate") + .HasColumnType("TEXT"); + + b.Property("LoginAttemptsBeforeLockout") + .HasColumnType("INTEGER"); + + b.Property("MaxActiveSessions") + .HasColumnType("INTEGER"); + + b.Property("MaxParentalAgeRating") + .HasColumnType("INTEGER"); + + b.Property("MustUpdatePassword") + .HasColumnType("INTEGER"); + + b.Property("Password") + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.Property("PasswordResetProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("PlayDefaultAudioTrack") + .HasColumnType("INTEGER"); + + b.Property("RememberAudioSelections") + .HasColumnType("INTEGER"); + + b.Property("RememberSubtitleSelections") + .HasColumnType("INTEGER"); + + b.Property("RemoteClientBitrateLimit") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SubtitleLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("SubtitleMode") + .HasColumnType("INTEGER"); + + b.Property("SyncPlayAccess") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.UserData", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("AudioStreamIndex") + .HasColumnType("INTEGER"); + + b.Property("IsFavorite") + .HasColumnType("INTEGER"); + + b.Property("LastPlayedDate") + .HasColumnType("TEXT"); + + b.Property("Likes") + .HasColumnType("INTEGER"); + + b.Property("PlayCount") + .HasColumnType("INTEGER"); + + b.Property("PlaybackPositionTicks") + .HasColumnType("INTEGER"); + + b.Property("Played") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("SubtitleStreamIndex") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "UserId"); + + b.HasIndex("UserId"); + + b.HasIndex("ItemId", "UserId", "IsFavorite"); + + b.HasIndex("ItemId", "UserId", "LastPlayedDate"); + + b.HasIndex("ItemId", "UserId", "PlaybackPositionTicks"); + + b.HasIndex("ItemId", "UserId", "Played"); + + b.ToTable("UserData"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("AccessSchedules") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AncestorId", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", null) + .WithMany("AncestorIds") + .HasForeignKey("BaseItemEntityId"); + + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "ParentItem") + .WithMany() + .HasForeignKey("ParentItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ParentItem"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AttachmentStreamInfo", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemImageInfo", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("Images") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemMetadataField", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("LockedFields") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemProvider", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("Provider") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemTrailerType", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("TrailerTypes") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Chapter", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("Chapters") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("DisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b => + { + b.HasOne("Jellyfin.Data.Entities.DisplayPreferences", null) + .WithMany("HomeSections") + .HasForeignKey("DisplayPreferencesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithOne("ProfileImage") + .HasForeignKey("Jellyfin.Data.Entities.ImageInfo", "UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("ItemDisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemValueMap", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("ItemValues") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Data.Entities.ItemValue", "ItemValue") + .WithMany("BaseItemsMap") + .HasForeignKey("ItemValueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ItemValue"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.MediaStreamInfo", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("MediaStreams") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.PeopleBaseItemMap", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("Peoples") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Data.Entities.People", "People") + .WithMany("BaseItems") + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("People"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("Permissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("Preferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.Device", b => + { + b.HasOne("Jellyfin.Data.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.UserData", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("UserData") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Data.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemEntity", b => + { + b.Navigation("AncestorIds"); + + b.Navigation("Chapters"); + + b.Navigation("Images"); + + b.Navigation("ItemValues"); + + b.Navigation("LockedFields"); + + b.Navigation("MediaStreams"); + + b.Navigation("Peoples"); + + b.Navigation("Provider"); + + b.Navigation("TrailerTypes"); + + b.Navigation("UserData"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.Navigation("HomeSections"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemValue", b => + { + b.Navigation("BaseItemsMap"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.People", b => + { + b.Navigation("BaseItems"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.User", b => + { + b.Navigation("AccessSchedules"); + + b.Navigation("DisplayPreferences"); + + b.Navigation("ItemDisplayPreferences"); + + b.Navigation("Permissions"); + + b.Navigation("Preferences"); + + b.Navigation("ProfileImage"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241020103111_LibraryDbMigration.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241020103111_LibraryDbMigration.cs new file mode 100644 index 0000000000..8cc7fb452d --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241020103111_LibraryDbMigration.cs @@ -0,0 +1,639 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + /// + public partial class LibraryDbMigration : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "BaseItems", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Type = table.Column(type: "TEXT", nullable: false), + Data = table.Column(type: "TEXT", nullable: true), + Path = table.Column(type: "TEXT", nullable: true), + StartDate = table.Column(type: "TEXT", nullable: false), + EndDate = table.Column(type: "TEXT", nullable: false), + ChannelId = table.Column(type: "TEXT", nullable: true), + IsMovie = table.Column(type: "INTEGER", nullable: false), + CommunityRating = table.Column(type: "REAL", nullable: true), + CustomRating = table.Column(type: "TEXT", nullable: true), + IndexNumber = table.Column(type: "INTEGER", nullable: true), + IsLocked = table.Column(type: "INTEGER", nullable: false), + Name = table.Column(type: "TEXT", nullable: true), + OfficialRating = table.Column(type: "TEXT", nullable: true), + MediaType = table.Column(type: "TEXT", nullable: true), + Overview = table.Column(type: "TEXT", nullable: true), + ParentIndexNumber = table.Column(type: "INTEGER", nullable: true), + PremiereDate = table.Column(type: "TEXT", nullable: true), + ProductionYear = table.Column(type: "INTEGER", nullable: true), + Genres = table.Column(type: "TEXT", nullable: true), + SortName = table.Column(type: "TEXT", nullable: true), + ForcedSortName = table.Column(type: "TEXT", nullable: true), + RunTimeTicks = table.Column(type: "INTEGER", nullable: true), + DateCreated = table.Column(type: "TEXT", nullable: true), + DateModified = table.Column(type: "TEXT", nullable: true), + IsSeries = table.Column(type: "INTEGER", nullable: false), + EpisodeTitle = table.Column(type: "TEXT", nullable: true), + IsRepeat = table.Column(type: "INTEGER", nullable: false), + PreferredMetadataLanguage = table.Column(type: "TEXT", nullable: true), + PreferredMetadataCountryCode = table.Column(type: "TEXT", nullable: true), + DateLastRefreshed = table.Column(type: "TEXT", nullable: true), + DateLastSaved = table.Column(type: "TEXT", nullable: true), + IsInMixedFolder = table.Column(type: "INTEGER", nullable: false), + Studios = table.Column(type: "TEXT", nullable: true), + ExternalServiceId = table.Column(type: "TEXT", nullable: true), + Tags = table.Column(type: "TEXT", nullable: true), + IsFolder = table.Column(type: "INTEGER", nullable: false), + InheritedParentalRatingValue = table.Column(type: "INTEGER", nullable: true), + UnratedType = table.Column(type: "TEXT", nullable: true), + CriticRating = table.Column(type: "REAL", nullable: true), + CleanName = table.Column(type: "TEXT", nullable: true), + PresentationUniqueKey = table.Column(type: "TEXT", nullable: true), + OriginalTitle = table.Column(type: "TEXT", nullable: true), + PrimaryVersionId = table.Column(type: "TEXT", nullable: true), + DateLastMediaAdded = table.Column(type: "TEXT", nullable: true), + Album = table.Column(type: "TEXT", nullable: true), + LUFS = table.Column(type: "REAL", nullable: true), + NormalizationGain = table.Column(type: "REAL", nullable: true), + IsVirtualItem = table.Column(type: "INTEGER", nullable: false), + SeriesName = table.Column(type: "TEXT", nullable: true), + SeasonName = table.Column(type: "TEXT", nullable: true), + ExternalSeriesId = table.Column(type: "TEXT", nullable: true), + Tagline = table.Column(type: "TEXT", nullable: true), + ProductionLocations = table.Column(type: "TEXT", nullable: true), + ExtraIds = table.Column(type: "TEXT", nullable: true), + TotalBitrate = table.Column(type: "INTEGER", nullable: true), + ExtraType = table.Column(type: "INTEGER", nullable: true), + Artists = table.Column(type: "TEXT", nullable: true), + AlbumArtists = table.Column(type: "TEXT", nullable: true), + ExternalId = table.Column(type: "TEXT", nullable: true), + SeriesPresentationUniqueKey = table.Column(type: "TEXT", nullable: true), + ShowId = table.Column(type: "TEXT", nullable: true), + OwnerId = table.Column(type: "TEXT", nullable: true), + Width = table.Column(type: "INTEGER", nullable: true), + Height = table.Column(type: "INTEGER", nullable: true), + Size = table.Column(type: "INTEGER", nullable: true), + Audio = table.Column(type: "INTEGER", nullable: true), + ParentId = table.Column(type: "TEXT", nullable: true), + TopParentId = table.Column(type: "TEXT", nullable: true), + SeasonId = table.Column(type: "TEXT", nullable: true), + SeriesId = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_BaseItems", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "ItemValues", + columns: table => new + { + ItemValueId = table.Column(type: "TEXT", nullable: false), + Type = table.Column(type: "INTEGER", nullable: false), + Value = table.Column(type: "TEXT", nullable: false), + CleanValue = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ItemValues", x => x.ItemValueId); + }); + + migrationBuilder.CreateTable( + name: "Peoples", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", nullable: false), + PersonType = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Peoples", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AncestorIds", + columns: table => new + { + ParentItemId = table.Column(type: "TEXT", nullable: false), + ItemId = table.Column(type: "TEXT", nullable: false), + BaseItemEntityId = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AncestorIds", x => new { x.ItemId, x.ParentItemId }); + table.ForeignKey( + name: "FK_AncestorIds_BaseItems_BaseItemEntityId", + column: x => x.BaseItemEntityId, + principalTable: "BaseItems", + principalColumn: "Id"); + table.ForeignKey( + name: "FK_AncestorIds_BaseItems_ItemId", + column: x => x.ItemId, + principalTable: "BaseItems", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AncestorIds_BaseItems_ParentItemId", + column: x => x.ParentItemId, + principalTable: "BaseItems", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AttachmentStreamInfos", + columns: table => new + { + ItemId = table.Column(type: "TEXT", nullable: false), + Index = table.Column(type: "INTEGER", nullable: false), + Codec = table.Column(type: "TEXT", nullable: false), + CodecTag = table.Column(type: "TEXT", nullable: true), + Comment = table.Column(type: "TEXT", nullable: true), + Filename = table.Column(type: "TEXT", nullable: true), + MimeType = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AttachmentStreamInfos", x => new { x.ItemId, x.Index }); + table.ForeignKey( + name: "FK_AttachmentStreamInfos_BaseItems_ItemId", + column: x => x.ItemId, + principalTable: "BaseItems", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "BaseItemImageInfos", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Path = table.Column(type: "TEXT", nullable: false), + DateModified = table.Column(type: "TEXT", nullable: false), + ImageType = table.Column(type: "INTEGER", nullable: false), + Width = table.Column(type: "INTEGER", nullable: false), + Height = table.Column(type: "INTEGER", nullable: false), + Blurhash = table.Column(type: "BLOB", nullable: true), + ItemId = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_BaseItemImageInfos", x => x.Id); + table.ForeignKey( + name: "FK_BaseItemImageInfos_BaseItems_ItemId", + column: x => x.ItemId, + principalTable: "BaseItems", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "BaseItemMetadataFields", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false), + ItemId = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_BaseItemMetadataFields", x => new { x.Id, x.ItemId }); + table.ForeignKey( + name: "FK_BaseItemMetadataFields_BaseItems_ItemId", + column: x => x.ItemId, + principalTable: "BaseItems", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "BaseItemProviders", + columns: table => new + { + ItemId = table.Column(type: "TEXT", nullable: false), + ProviderId = table.Column(type: "TEXT", nullable: false), + ProviderValue = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_BaseItemProviders", x => new { x.ItemId, x.ProviderId }); + table.ForeignKey( + name: "FK_BaseItemProviders_BaseItems_ItemId", + column: x => x.ItemId, + principalTable: "BaseItems", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "BaseItemTrailerTypes", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false), + ItemId = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_BaseItemTrailerTypes", x => new { x.Id, x.ItemId }); + table.ForeignKey( + name: "FK_BaseItemTrailerTypes_BaseItems_ItemId", + column: x => x.ItemId, + principalTable: "BaseItems", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Chapters", + columns: table => new + { + ItemId = table.Column(type: "TEXT", nullable: false), + ChapterIndex = table.Column(type: "INTEGER", nullable: false), + StartPositionTicks = table.Column(type: "INTEGER", nullable: false), + Name = table.Column(type: "TEXT", nullable: true), + ImagePath = table.Column(type: "TEXT", nullable: true), + ImageDateModified = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Chapters", x => new { x.ItemId, x.ChapterIndex }); + table.ForeignKey( + name: "FK_Chapters_BaseItems_ItemId", + column: x => x.ItemId, + principalTable: "BaseItems", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "MediaStreamInfos", + columns: table => new + { + ItemId = table.Column(type: "TEXT", nullable: false), + StreamIndex = table.Column(type: "INTEGER", nullable: false), + StreamType = table.Column(type: "INTEGER", nullable: true), + Codec = table.Column(type: "TEXT", nullable: true), + Language = table.Column(type: "TEXT", nullable: true), + ChannelLayout = table.Column(type: "TEXT", nullable: true), + Profile = table.Column(type: "TEXT", nullable: true), + AspectRatio = table.Column(type: "TEXT", nullable: true), + Path = table.Column(type: "TEXT", nullable: true), + IsInterlaced = table.Column(type: "INTEGER", nullable: false), + BitRate = table.Column(type: "INTEGER", nullable: false), + Channels = table.Column(type: "INTEGER", nullable: false), + SampleRate = table.Column(type: "INTEGER", nullable: false), + IsDefault = table.Column(type: "INTEGER", nullable: false), + IsForced = table.Column(type: "INTEGER", nullable: false), + IsExternal = table.Column(type: "INTEGER", nullable: false), + Height = table.Column(type: "INTEGER", nullable: false), + Width = table.Column(type: "INTEGER", nullable: false), + AverageFrameRate = table.Column(type: "REAL", nullable: false), + RealFrameRate = table.Column(type: "REAL", nullable: false), + Level = table.Column(type: "REAL", nullable: false), + PixelFormat = table.Column(type: "TEXT", nullable: true), + BitDepth = table.Column(type: "INTEGER", nullable: false), + IsAnamorphic = table.Column(type: "INTEGER", nullable: false), + RefFrames = table.Column(type: "INTEGER", nullable: false), + CodecTag = table.Column(type: "TEXT", nullable: false), + Comment = table.Column(type: "TEXT", nullable: false), + NalLengthSize = table.Column(type: "TEXT", nullable: false), + IsAvc = table.Column(type: "INTEGER", nullable: false), + Title = table.Column(type: "TEXT", nullable: false), + TimeBase = table.Column(type: "TEXT", nullable: false), + CodecTimeBase = table.Column(type: "TEXT", nullable: false), + ColorPrimaries = table.Column(type: "TEXT", nullable: false), + ColorSpace = table.Column(type: "TEXT", nullable: false), + ColorTransfer = table.Column(type: "TEXT", nullable: false), + DvVersionMajor = table.Column(type: "INTEGER", nullable: false), + DvVersionMinor = table.Column(type: "INTEGER", nullable: false), + DvProfile = table.Column(type: "INTEGER", nullable: false), + DvLevel = table.Column(type: "INTEGER", nullable: false), + RpuPresentFlag = table.Column(type: "INTEGER", nullable: false), + ElPresentFlag = table.Column(type: "INTEGER", nullable: false), + BlPresentFlag = table.Column(type: "INTEGER", nullable: false), + DvBlSignalCompatibilityId = table.Column(type: "INTEGER", nullable: false), + IsHearingImpaired = table.Column(type: "INTEGER", nullable: false), + Rotation = table.Column(type: "INTEGER", nullable: false), + KeyFrames = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_MediaStreamInfos", x => new { x.ItemId, x.StreamIndex }); + table.ForeignKey( + name: "FK_MediaStreamInfos_BaseItems_ItemId", + column: x => x.ItemId, + principalTable: "BaseItems", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "UserData", + columns: table => new + { + ItemId = table.Column(type: "TEXT", nullable: false), + UserId = table.Column(type: "TEXT", nullable: false), + Rating = table.Column(type: "REAL", nullable: true), + PlaybackPositionTicks = table.Column(type: "INTEGER", nullable: false), + PlayCount = table.Column(type: "INTEGER", nullable: false), + IsFavorite = table.Column(type: "INTEGER", nullable: false), + LastPlayedDate = table.Column(type: "TEXT", nullable: true), + Played = table.Column(type: "INTEGER", nullable: false), + AudioStreamIndex = table.Column(type: "INTEGER", nullable: true), + SubtitleStreamIndex = table.Column(type: "INTEGER", nullable: true), + Likes = table.Column(type: "INTEGER", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_UserData", x => new { x.ItemId, x.UserId }); + table.ForeignKey( + name: "FK_UserData_BaseItems_ItemId", + column: x => x.ItemId, + principalTable: "BaseItems", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_UserData_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "ItemValuesMap", + columns: table => new + { + ItemId = table.Column(type: "TEXT", nullable: false), + ItemValueId = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ItemValuesMap", x => new { x.ItemValueId, x.ItemId }); + table.ForeignKey( + name: "FK_ItemValuesMap_BaseItems_ItemId", + column: x => x.ItemId, + principalTable: "BaseItems", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_ItemValuesMap_ItemValues_ItemValueId", + column: x => x.ItemValueId, + principalTable: "ItemValues", + principalColumn: "ItemValueId", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "PeopleBaseItemMap", + columns: table => new + { + ItemId = table.Column(type: "TEXT", nullable: false), + PeopleId = table.Column(type: "TEXT", nullable: false), + SortOrder = table.Column(type: "INTEGER", nullable: true), + ListOrder = table.Column(type: "INTEGER", nullable: true), + Role = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_PeopleBaseItemMap", x => new { x.ItemId, x.PeopleId }); + table.ForeignKey( + name: "FK_PeopleBaseItemMap_BaseItems_ItemId", + column: x => x.ItemId, + principalTable: "BaseItems", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_PeopleBaseItemMap_Peoples_PeopleId", + column: x => x.PeopleId, + principalTable: "Peoples", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AncestorIds_BaseItemEntityId", + table: "AncestorIds", + column: "BaseItemEntityId"); + + migrationBuilder.CreateIndex( + name: "IX_AncestorIds_ParentItemId", + table: "AncestorIds", + column: "ParentItemId"); + + migrationBuilder.CreateIndex( + name: "IX_BaseItemImageInfos_ItemId", + table: "BaseItemImageInfos", + column: "ItemId"); + + migrationBuilder.CreateIndex( + name: "IX_BaseItemMetadataFields_ItemId", + table: "BaseItemMetadataFields", + column: "ItemId"); + + migrationBuilder.CreateIndex( + name: "IX_BaseItemProviders_ProviderId_ProviderValue_ItemId", + table: "BaseItemProviders", + columns: new[] { "ProviderId", "ProviderValue", "ItemId" }); + + migrationBuilder.CreateIndex( + name: "IX_BaseItems_Id_Type_IsFolder_IsVirtualItem", + table: "BaseItems", + columns: new[] { "Id", "Type", "IsFolder", "IsVirtualItem" }); + + migrationBuilder.CreateIndex( + name: "IX_BaseItems_IsFolder_TopParentId_IsVirtualItem_PresentationUniqueKey_DateCreated", + table: "BaseItems", + columns: new[] { "IsFolder", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated" }); + + migrationBuilder.CreateIndex( + name: "IX_BaseItems_MediaType_TopParentId_IsVirtualItem_PresentationUniqueKey", + table: "BaseItems", + columns: new[] { "MediaType", "TopParentId", "IsVirtualItem", "PresentationUniqueKey" }); + + migrationBuilder.CreateIndex( + name: "IX_BaseItems_ParentId", + table: "BaseItems", + column: "ParentId"); + + migrationBuilder.CreateIndex( + name: "IX_BaseItems_Path", + table: "BaseItems", + column: "Path"); + + migrationBuilder.CreateIndex( + name: "IX_BaseItems_PresentationUniqueKey", + table: "BaseItems", + column: "PresentationUniqueKey"); + + migrationBuilder.CreateIndex( + name: "IX_BaseItems_TopParentId_Id", + table: "BaseItems", + columns: new[] { "TopParentId", "Id" }); + + migrationBuilder.CreateIndex( + name: "IX_BaseItems_Type_SeriesPresentationUniqueKey_IsFolder_IsVirtualItem", + table: "BaseItems", + columns: new[] { "Type", "SeriesPresentationUniqueKey", "IsFolder", "IsVirtualItem" }); + + migrationBuilder.CreateIndex( + name: "IX_BaseItems_Type_SeriesPresentationUniqueKey_PresentationUniqueKey_SortName", + table: "BaseItems", + columns: new[] { "Type", "SeriesPresentationUniqueKey", "PresentationUniqueKey", "SortName" }); + + migrationBuilder.CreateIndex( + name: "IX_BaseItems_Type_TopParentId_Id", + table: "BaseItems", + columns: new[] { "Type", "TopParentId", "Id" }); + + migrationBuilder.CreateIndex( + name: "IX_BaseItems_Type_TopParentId_IsVirtualItem_PresentationUniqueKey_DateCreated", + table: "BaseItems", + columns: new[] { "Type", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated" }); + + migrationBuilder.CreateIndex( + name: "IX_BaseItems_Type_TopParentId_PresentationUniqueKey", + table: "BaseItems", + columns: new[] { "Type", "TopParentId", "PresentationUniqueKey" }); + + migrationBuilder.CreateIndex( + name: "IX_BaseItems_Type_TopParentId_StartDate", + table: "BaseItems", + columns: new[] { "Type", "TopParentId", "StartDate" }); + + migrationBuilder.CreateIndex( + name: "IX_BaseItemTrailerTypes_ItemId", + table: "BaseItemTrailerTypes", + column: "ItemId"); + + migrationBuilder.CreateIndex( + name: "IX_ItemValues_Type_CleanValue", + table: "ItemValues", + columns: new[] { "Type", "CleanValue" }); + + migrationBuilder.CreateIndex( + name: "IX_ItemValuesMap_ItemId", + table: "ItemValuesMap", + column: "ItemId"); + + migrationBuilder.CreateIndex( + name: "IX_MediaStreamInfos_StreamIndex", + table: "MediaStreamInfos", + column: "StreamIndex"); + + migrationBuilder.CreateIndex( + name: "IX_MediaStreamInfos_StreamIndex_StreamType", + table: "MediaStreamInfos", + columns: new[] { "StreamIndex", "StreamType" }); + + migrationBuilder.CreateIndex( + name: "IX_MediaStreamInfos_StreamIndex_StreamType_Language", + table: "MediaStreamInfos", + columns: new[] { "StreamIndex", "StreamType", "Language" }); + + migrationBuilder.CreateIndex( + name: "IX_MediaStreamInfos_StreamType", + table: "MediaStreamInfos", + column: "StreamType"); + + migrationBuilder.CreateIndex( + name: "IX_PeopleBaseItemMap_ItemId_ListOrder", + table: "PeopleBaseItemMap", + columns: new[] { "ItemId", "ListOrder" }); + + migrationBuilder.CreateIndex( + name: "IX_PeopleBaseItemMap_ItemId_SortOrder", + table: "PeopleBaseItemMap", + columns: new[] { "ItemId", "SortOrder" }); + + migrationBuilder.CreateIndex( + name: "IX_PeopleBaseItemMap_PeopleId", + table: "PeopleBaseItemMap", + column: "PeopleId"); + + migrationBuilder.CreateIndex( + name: "IX_Peoples_Name", + table: "Peoples", + column: "Name"); + + migrationBuilder.CreateIndex( + name: "IX_UserData_ItemId_UserId_IsFavorite", + table: "UserData", + columns: new[] { "ItemId", "UserId", "IsFavorite" }); + + migrationBuilder.CreateIndex( + name: "IX_UserData_ItemId_UserId_LastPlayedDate", + table: "UserData", + columns: new[] { "ItemId", "UserId", "LastPlayedDate" }); + + migrationBuilder.CreateIndex( + name: "IX_UserData_ItemId_UserId_PlaybackPositionTicks", + table: "UserData", + columns: new[] { "ItemId", "UserId", "PlaybackPositionTicks" }); + + migrationBuilder.CreateIndex( + name: "IX_UserData_ItemId_UserId_Played", + table: "UserData", + columns: new[] { "ItemId", "UserId", "Played" }); + + migrationBuilder.CreateIndex( + name: "IX_UserData_UserId", + table: "UserData", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AncestorIds"); + + migrationBuilder.DropTable( + name: "AttachmentStreamInfos"); + + migrationBuilder.DropTable( + name: "BaseItemImageInfos"); + + migrationBuilder.DropTable( + name: "BaseItemMetadataFields"); + + migrationBuilder.DropTable( + name: "BaseItemProviders"); + + migrationBuilder.DropTable( + name: "BaseItemTrailerTypes"); + + migrationBuilder.DropTable( + name: "Chapters"); + + migrationBuilder.DropTable( + name: "ItemValuesMap"); + + migrationBuilder.DropTable( + name: "MediaStreamInfos"); + + migrationBuilder.DropTable( + name: "PeopleBaseItemMap"); + + migrationBuilder.DropTable( + name: "UserData"); + + migrationBuilder.DropTable( + name: "ItemValues"); + + migrationBuilder.DropTable( + name: "Peoples"); + + migrationBuilder.DropTable( + name: "BaseItems"); + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241111131257_AddedCustomDataKey.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241111131257_AddedCustomDataKey.Designer.cs new file mode 100644 index 0000000000..d589a4afd7 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241111131257_AddedCustomDataKey.Designer.cs @@ -0,0 +1,1610 @@ +// +using System; +using Jellyfin.Database.Implementations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + [DbContext(typeof(JellyfinDbContext))] + [Migration("20241111131257_AddedCustomDataKey")] + partial class AddedCustomDataKey + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.10"); + + modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property("EndHour") + .HasColumnType("REAL"); + + b.Property("StartHour") + .HasColumnType("REAL"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AccessSchedules"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ActivityLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LogSeverity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ShortOverview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DateCreated"); + + b.ToTable("ActivityLogs"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AncestorId", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ParentItemId") + .HasColumnType("TEXT"); + + b.Property("BaseItemEntityId") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ParentItemId"); + + b.HasIndex("BaseItemEntityId"); + + b.HasIndex("ParentItemId"); + + b.ToTable("AncestorIds"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AttachmentStreamInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CodecTag") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Filename") + .HasColumnType("TEXT"); + + b.Property("MimeType") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "Index"); + + b.ToTable("AttachmentStreamInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Album") + .HasColumnType("TEXT"); + + b.Property("AlbumArtists") + .HasColumnType("TEXT"); + + b.Property("Artists") + .HasColumnType("TEXT"); + + b.Property("Audio") + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("CleanName") + .HasColumnType("TEXT"); + + b.Property("CommunityRating") + .HasColumnType("REAL"); + + b.Property("CriticRating") + .HasColumnType("REAL"); + + b.Property("CustomRating") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastMediaAdded") + .HasColumnType("TEXT"); + + b.Property("DateLastRefreshed") + .HasColumnType("TEXT"); + + b.Property("DateLastSaved") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("EpisodeTitle") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasColumnType("TEXT"); + + b.Property("ExternalSeriesId") + .HasColumnType("TEXT"); + + b.Property("ExternalServiceId") + .HasColumnType("TEXT"); + + b.Property("ExtraIds") + .HasColumnType("TEXT"); + + b.Property("ExtraType") + .HasColumnType("INTEGER"); + + b.Property("ForcedSortName") + .HasColumnType("TEXT"); + + b.Property("Genres") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("IndexNumber") + .HasColumnType("INTEGER"); + + b.Property("InheritedParentalRatingValue") + .HasColumnType("INTEGER"); + + b.Property("IsFolder") + .HasColumnType("INTEGER"); + + b.Property("IsInMixedFolder") + .HasColumnType("INTEGER"); + + b.Property("IsLocked") + .HasColumnType("INTEGER"); + + b.Property("IsMovie") + .HasColumnType("INTEGER"); + + b.Property("IsRepeat") + .HasColumnType("INTEGER"); + + b.Property("IsSeries") + .HasColumnType("INTEGER"); + + b.Property("IsVirtualItem") + .HasColumnType("INTEGER"); + + b.Property("LUFS") + .HasColumnType("REAL"); + + b.Property("MediaType") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizationGain") + .HasColumnType("REAL"); + + b.Property("OfficialRating") + .HasColumnType("TEXT"); + + b.Property("OriginalTitle") + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("OwnerId") + .HasColumnType("TEXT"); + + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("ParentIndexNumber") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PreferredMetadataCountryCode") + .HasColumnType("TEXT"); + + b.Property("PreferredMetadataLanguage") + .HasColumnType("TEXT"); + + b.Property("PremiereDate") + .HasColumnType("TEXT"); + + b.Property("PresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property("PrimaryVersionId") + .HasColumnType("TEXT"); + + b.Property("ProductionLocations") + .HasColumnType("TEXT"); + + b.Property("ProductionYear") + .HasColumnType("INTEGER"); + + b.Property("RunTimeTicks") + .HasColumnType("INTEGER"); + + b.Property("SeasonId") + .HasColumnType("TEXT"); + + b.Property("SeasonName") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("SeriesName") + .HasColumnType("TEXT"); + + b.Property("SeriesPresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property("ShowId") + .HasColumnType("TEXT"); + + b.Property("Size") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("Studios") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("TopParentId") + .HasColumnType("TEXT"); + + b.Property("TotalBitrate") + .HasColumnType("INTEGER"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UnratedType") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.HasIndex("Path"); + + b.HasIndex("PresentationUniqueKey"); + + b.HasIndex("TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "PresentationUniqueKey"); + + b.HasIndex("Type", "TopParentId", "StartDate"); + + b.HasIndex("Id", "Type", "IsFolder", "IsVirtualItem"); + + b.HasIndex("MediaType", "TopParentId", "IsVirtualItem", "PresentationUniqueKey"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "IsFolder", "IsVirtualItem"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "PresentationUniqueKey", "SortName"); + + b.HasIndex("IsFolder", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.HasIndex("Type", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.ToTable("BaseItems"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemImageInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Blurhash") + .HasColumnType("BLOB"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("ImageType") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemImageInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemMetadataField", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemMetadataFields"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemProvider", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("ProviderValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ProviderId"); + + b.HasIndex("ProviderId", "ProviderValue", "ItemId"); + + b.ToTable("BaseItemProviders"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemTrailerType", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemTrailerTypes"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Chapter", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ChapterIndex") + .HasColumnType("INTEGER"); + + b.Property("ImageDateModified") + .HasColumnType("TEXT"); + + b.Property("ImagePath") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("StartPositionTicks") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "ChapterIndex"); + + b.ToTable("Chapters"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.CustomItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client", "Key") + .IsUnique(); + + b.ToTable("CustomItemDisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChromecastVersion") + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DashboardTheme") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("EnableNextVideoInfoOverlay") + .HasColumnType("INTEGER"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ScrollDirection") + .HasColumnType("INTEGER"); + + b.Property("ShowBackdrop") + .HasColumnType("INTEGER"); + + b.Property("ShowSidebar") + .HasColumnType("INTEGER"); + + b.Property("SkipBackwardLength") + .HasColumnType("INTEGER"); + + b.Property("SkipForwardLength") + .HasColumnType("INTEGER"); + + b.Property("TvHome") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client") + .IsUnique(); + + b.ToTable("DisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DisplayPreferencesId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DisplayPreferencesId"); + + b.ToTable("HomeSection"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("ImageInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("RememberIndexing") + .HasColumnType("INTEGER"); + + b.Property("RememberSorting") + .HasColumnType("INTEGER"); + + b.Property("SortBy") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("ViewType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("ItemDisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemValue", b => + { + b.Property("ItemValueId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CleanValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId"); + + b.HasIndex("Type", "CleanValue"); + + b.ToTable("ItemValues"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemValueMap", b => + { + b.Property("ItemValueId") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("ItemValuesMap"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.MediaSegment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EndTicks") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("SegmentProviderId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StartTicks") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("MediaSegments"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.MediaStreamInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("StreamIndex") + .HasColumnType("INTEGER"); + + b.Property("AspectRatio") + .HasColumnType("TEXT"); + + b.Property("AverageFrameRate") + .HasColumnType("REAL"); + + b.Property("BitDepth") + .HasColumnType("INTEGER"); + + b.Property("BitRate") + .HasColumnType("INTEGER"); + + b.Property("BlPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("ChannelLayout") + .HasColumnType("TEXT"); + + b.Property("Channels") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasColumnType("TEXT"); + + b.Property("CodecTag") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CodecTimeBase") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ColorPrimaries") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ColorSpace") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ColorTransfer") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DvBlSignalCompatibilityId") + .HasColumnType("INTEGER"); + + b.Property("DvLevel") + .HasColumnType("INTEGER"); + + b.Property("DvProfile") + .HasColumnType("INTEGER"); + + b.Property("DvVersionMajor") + .HasColumnType("INTEGER"); + + b.Property("DvVersionMinor") + .HasColumnType("INTEGER"); + + b.Property("ElPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("IsAnamorphic") + .HasColumnType("INTEGER"); + + b.Property("IsAvc") + .HasColumnType("INTEGER"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("IsExternal") + .HasColumnType("INTEGER"); + + b.Property("IsForced") + .HasColumnType("INTEGER"); + + b.Property("IsHearingImpaired") + .HasColumnType("INTEGER"); + + b.Property("IsInterlaced") + .HasColumnType("INTEGER"); + + b.Property("KeyFrames") + .HasColumnType("TEXT"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("REAL"); + + b.Property("NalLengthSize") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PixelFormat") + .HasColumnType("TEXT"); + + b.Property("Profile") + .HasColumnType("TEXT"); + + b.Property("RealFrameRate") + .HasColumnType("REAL"); + + b.Property("RefFrames") + .HasColumnType("INTEGER"); + + b.Property("Rotation") + .HasColumnType("INTEGER"); + + b.Property("RpuPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("SampleRate") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .HasColumnType("INTEGER"); + + b.Property("TimeBase") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "StreamIndex"); + + b.HasIndex("StreamIndex"); + + b.HasIndex("StreamType"); + + b.HasIndex("StreamIndex", "StreamType"); + + b.HasIndex("StreamIndex", "StreamType", "Language"); + + b.ToTable("MediaStreamInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.People", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PersonType") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("Peoples"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.PeopleBaseItemMap", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("PeopleId") + .HasColumnType("TEXT"); + + b.Property("ListOrder") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.HasIndex("ItemId", "ListOrder"); + + b.HasIndex("ItemId", "SortOrder"); + + b.ToTable("PeopleBaseItemMap"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Permission_Permissions_Guid") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Permissions"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Preference_Preferences_Guid") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Preferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccessToken") + .IsUnique(); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AppName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AppVersion") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("AccessToken", "DateLastActivity"); + + b.HasIndex("DeviceId", "DateLastActivity"); + + b.HasIndex("UserId", "DeviceId"); + + b.ToTable("Devices"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.DeviceOptions", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CustomName") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId") + .IsUnique(); + + b.ToTable("DeviceOptions"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.TrickplayInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.Property("Bandwidth") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Interval") + .HasColumnType("INTEGER"); + + b.Property("ThumbnailCount") + .HasColumnType("INTEGER"); + + b.Property("TileHeight") + .HasColumnType("INTEGER"); + + b.Property("TileWidth") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "Width"); + + b.ToTable("TrickplayInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AudioLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("AuthenticationProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("CastReceiverId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DisplayCollectionsView") + .HasColumnType("INTEGER"); + + b.Property("DisplayMissingEpisodes") + .HasColumnType("INTEGER"); + + b.Property("EnableAutoLogin") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalPassword") + .HasColumnType("INTEGER"); + + b.Property("EnableNextEpisodeAutoPlay") + .HasColumnType("INTEGER"); + + b.Property("EnableUserPreferenceAccess") + .HasColumnType("INTEGER"); + + b.Property("HidePlayedInLatest") + .HasColumnType("INTEGER"); + + b.Property("InternalId") + .HasColumnType("INTEGER"); + + b.Property("InvalidLoginAttemptCount") + .HasColumnType("INTEGER"); + + b.Property("LastActivityDate") + .HasColumnType("TEXT"); + + b.Property("LastLoginDate") + .HasColumnType("TEXT"); + + b.Property("LoginAttemptsBeforeLockout") + .HasColumnType("INTEGER"); + + b.Property("MaxActiveSessions") + .HasColumnType("INTEGER"); + + b.Property("MaxParentalAgeRating") + .HasColumnType("INTEGER"); + + b.Property("MustUpdatePassword") + .HasColumnType("INTEGER"); + + b.Property("Password") + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.Property("PasswordResetProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("PlayDefaultAudioTrack") + .HasColumnType("INTEGER"); + + b.Property("RememberAudioSelections") + .HasColumnType("INTEGER"); + + b.Property("RememberSubtitleSelections") + .HasColumnType("INTEGER"); + + b.Property("RemoteClientBitrateLimit") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SubtitleLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("SubtitleMode") + .HasColumnType("INTEGER"); + + b.Property("SyncPlayAccess") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.UserData", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("AudioStreamIndex") + .HasColumnType("INTEGER"); + + b.Property("CustomDataKey") + .HasColumnType("TEXT"); + + b.Property("IsFavorite") + .HasColumnType("INTEGER"); + + b.Property("LastPlayedDate") + .HasColumnType("TEXT"); + + b.Property("Likes") + .HasColumnType("INTEGER"); + + b.Property("PlayCount") + .HasColumnType("INTEGER"); + + b.Property("PlaybackPositionTicks") + .HasColumnType("INTEGER"); + + b.Property("Played") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("SubtitleStreamIndex") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "UserId"); + + b.HasIndex("UserId"); + + b.HasIndex("ItemId", "UserId", "IsFavorite"); + + b.HasIndex("ItemId", "UserId", "LastPlayedDate"); + + b.HasIndex("ItemId", "UserId", "PlaybackPositionTicks"); + + b.HasIndex("ItemId", "UserId", "Played"); + + b.ToTable("UserData"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("AccessSchedules") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AncestorId", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", null) + .WithMany("AncestorIds") + .HasForeignKey("BaseItemEntityId"); + + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "ParentItem") + .WithMany() + .HasForeignKey("ParentItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ParentItem"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AttachmentStreamInfo", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemImageInfo", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("Images") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemMetadataField", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("LockedFields") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemProvider", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("Provider") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemTrailerType", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("TrailerTypes") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Chapter", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("Chapters") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("DisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b => + { + b.HasOne("Jellyfin.Data.Entities.DisplayPreferences", null) + .WithMany("HomeSections") + .HasForeignKey("DisplayPreferencesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithOne("ProfileImage") + .HasForeignKey("Jellyfin.Data.Entities.ImageInfo", "UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("ItemDisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemValueMap", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("ItemValues") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Data.Entities.ItemValue", "ItemValue") + .WithMany("BaseItemsMap") + .HasForeignKey("ItemValueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ItemValue"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.MediaStreamInfo", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("MediaStreams") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.PeopleBaseItemMap", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("Peoples") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Data.Entities.People", "People") + .WithMany("BaseItems") + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("People"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("Permissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("Preferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.Device", b => + { + b.HasOne("Jellyfin.Data.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.UserData", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("UserData") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Data.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemEntity", b => + { + b.Navigation("AncestorIds"); + + b.Navigation("Chapters"); + + b.Navigation("Images"); + + b.Navigation("ItemValues"); + + b.Navigation("LockedFields"); + + b.Navigation("MediaStreams"); + + b.Navigation("Peoples"); + + b.Navigation("Provider"); + + b.Navigation("TrailerTypes"); + + b.Navigation("UserData"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.Navigation("HomeSections"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemValue", b => + { + b.Navigation("BaseItemsMap"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.People", b => + { + b.Navigation("BaseItems"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.User", b => + { + b.Navigation("AccessSchedules"); + + b.Navigation("DisplayPreferences"); + + b.Navigation("ItemDisplayPreferences"); + + b.Navigation("Permissions"); + + b.Navigation("Preferences"); + + b.Navigation("ProfileImage"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241111131257_AddedCustomDataKey.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241111131257_AddedCustomDataKey.cs new file mode 100644 index 0000000000..ac78019eda --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241111131257_AddedCustomDataKey.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + /// + public partial class AddedCustomDataKey : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CustomDataKey", + table: "UserData", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CustomDataKey", + table: "UserData"); + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241111135439_AddedCustomDataKeyKey.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241111135439_AddedCustomDataKeyKey.Designer.cs new file mode 100644 index 0000000000..3d70bb0296 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241111135439_AddedCustomDataKeyKey.Designer.cs @@ -0,0 +1,1610 @@ +// +using System; +using Jellyfin.Database.Implementations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + [DbContext(typeof(JellyfinDbContext))] + [Migration("20241111135439_AddedCustomDataKeyKey")] + partial class AddedCustomDataKeyKey + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.10"); + + modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property("EndHour") + .HasColumnType("REAL"); + + b.Property("StartHour") + .HasColumnType("REAL"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AccessSchedules"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ActivityLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LogSeverity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ShortOverview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DateCreated"); + + b.ToTable("ActivityLogs"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AncestorId", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ParentItemId") + .HasColumnType("TEXT"); + + b.Property("BaseItemEntityId") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ParentItemId"); + + b.HasIndex("BaseItemEntityId"); + + b.HasIndex("ParentItemId"); + + b.ToTable("AncestorIds"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AttachmentStreamInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CodecTag") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Filename") + .HasColumnType("TEXT"); + + b.Property("MimeType") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "Index"); + + b.ToTable("AttachmentStreamInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Album") + .HasColumnType("TEXT"); + + b.Property("AlbumArtists") + .HasColumnType("TEXT"); + + b.Property("Artists") + .HasColumnType("TEXT"); + + b.Property("Audio") + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("CleanName") + .HasColumnType("TEXT"); + + b.Property("CommunityRating") + .HasColumnType("REAL"); + + b.Property("CriticRating") + .HasColumnType("REAL"); + + b.Property("CustomRating") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastMediaAdded") + .HasColumnType("TEXT"); + + b.Property("DateLastRefreshed") + .HasColumnType("TEXT"); + + b.Property("DateLastSaved") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("EpisodeTitle") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasColumnType("TEXT"); + + b.Property("ExternalSeriesId") + .HasColumnType("TEXT"); + + b.Property("ExternalServiceId") + .HasColumnType("TEXT"); + + b.Property("ExtraIds") + .HasColumnType("TEXT"); + + b.Property("ExtraType") + .HasColumnType("INTEGER"); + + b.Property("ForcedSortName") + .HasColumnType("TEXT"); + + b.Property("Genres") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("IndexNumber") + .HasColumnType("INTEGER"); + + b.Property("InheritedParentalRatingValue") + .HasColumnType("INTEGER"); + + b.Property("IsFolder") + .HasColumnType("INTEGER"); + + b.Property("IsInMixedFolder") + .HasColumnType("INTEGER"); + + b.Property("IsLocked") + .HasColumnType("INTEGER"); + + b.Property("IsMovie") + .HasColumnType("INTEGER"); + + b.Property("IsRepeat") + .HasColumnType("INTEGER"); + + b.Property("IsSeries") + .HasColumnType("INTEGER"); + + b.Property("IsVirtualItem") + .HasColumnType("INTEGER"); + + b.Property("LUFS") + .HasColumnType("REAL"); + + b.Property("MediaType") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizationGain") + .HasColumnType("REAL"); + + b.Property("OfficialRating") + .HasColumnType("TEXT"); + + b.Property("OriginalTitle") + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("OwnerId") + .HasColumnType("TEXT"); + + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("ParentIndexNumber") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PreferredMetadataCountryCode") + .HasColumnType("TEXT"); + + b.Property("PreferredMetadataLanguage") + .HasColumnType("TEXT"); + + b.Property("PremiereDate") + .HasColumnType("TEXT"); + + b.Property("PresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property("PrimaryVersionId") + .HasColumnType("TEXT"); + + b.Property("ProductionLocations") + .HasColumnType("TEXT"); + + b.Property("ProductionYear") + .HasColumnType("INTEGER"); + + b.Property("RunTimeTicks") + .HasColumnType("INTEGER"); + + b.Property("SeasonId") + .HasColumnType("TEXT"); + + b.Property("SeasonName") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("SeriesName") + .HasColumnType("TEXT"); + + b.Property("SeriesPresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property("ShowId") + .HasColumnType("TEXT"); + + b.Property("Size") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("Studios") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("TopParentId") + .HasColumnType("TEXT"); + + b.Property("TotalBitrate") + .HasColumnType("INTEGER"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UnratedType") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.HasIndex("Path"); + + b.HasIndex("PresentationUniqueKey"); + + b.HasIndex("TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "PresentationUniqueKey"); + + b.HasIndex("Type", "TopParentId", "StartDate"); + + b.HasIndex("Id", "Type", "IsFolder", "IsVirtualItem"); + + b.HasIndex("MediaType", "TopParentId", "IsVirtualItem", "PresentationUniqueKey"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "IsFolder", "IsVirtualItem"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "PresentationUniqueKey", "SortName"); + + b.HasIndex("IsFolder", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.HasIndex("Type", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.ToTable("BaseItems"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemImageInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Blurhash") + .HasColumnType("BLOB"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("ImageType") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemImageInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemMetadataField", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemMetadataFields"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemProvider", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("ProviderValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ProviderId"); + + b.HasIndex("ProviderId", "ProviderValue", "ItemId"); + + b.ToTable("BaseItemProviders"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemTrailerType", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemTrailerTypes"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Chapter", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ChapterIndex") + .HasColumnType("INTEGER"); + + b.Property("ImageDateModified") + .HasColumnType("TEXT"); + + b.Property("ImagePath") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("StartPositionTicks") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "ChapterIndex"); + + b.ToTable("Chapters"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.CustomItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client", "Key") + .IsUnique(); + + b.ToTable("CustomItemDisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChromecastVersion") + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DashboardTheme") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("EnableNextVideoInfoOverlay") + .HasColumnType("INTEGER"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ScrollDirection") + .HasColumnType("INTEGER"); + + b.Property("ShowBackdrop") + .HasColumnType("INTEGER"); + + b.Property("ShowSidebar") + .HasColumnType("INTEGER"); + + b.Property("SkipBackwardLength") + .HasColumnType("INTEGER"); + + b.Property("SkipForwardLength") + .HasColumnType("INTEGER"); + + b.Property("TvHome") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client") + .IsUnique(); + + b.ToTable("DisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DisplayPreferencesId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DisplayPreferencesId"); + + b.ToTable("HomeSection"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("ImageInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("RememberIndexing") + .HasColumnType("INTEGER"); + + b.Property("RememberSorting") + .HasColumnType("INTEGER"); + + b.Property("SortBy") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("ViewType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("ItemDisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemValue", b => + { + b.Property("ItemValueId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CleanValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId"); + + b.HasIndex("Type", "CleanValue"); + + b.ToTable("ItemValues"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemValueMap", b => + { + b.Property("ItemValueId") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("ItemValuesMap"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.MediaSegment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EndTicks") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("SegmentProviderId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StartTicks") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("MediaSegments"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.MediaStreamInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("StreamIndex") + .HasColumnType("INTEGER"); + + b.Property("AspectRatio") + .HasColumnType("TEXT"); + + b.Property("AverageFrameRate") + .HasColumnType("REAL"); + + b.Property("BitDepth") + .HasColumnType("INTEGER"); + + b.Property("BitRate") + .HasColumnType("INTEGER"); + + b.Property("BlPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("ChannelLayout") + .HasColumnType("TEXT"); + + b.Property("Channels") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasColumnType("TEXT"); + + b.Property("CodecTag") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CodecTimeBase") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ColorPrimaries") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ColorSpace") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ColorTransfer") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DvBlSignalCompatibilityId") + .HasColumnType("INTEGER"); + + b.Property("DvLevel") + .HasColumnType("INTEGER"); + + b.Property("DvProfile") + .HasColumnType("INTEGER"); + + b.Property("DvVersionMajor") + .HasColumnType("INTEGER"); + + b.Property("DvVersionMinor") + .HasColumnType("INTEGER"); + + b.Property("ElPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("IsAnamorphic") + .HasColumnType("INTEGER"); + + b.Property("IsAvc") + .HasColumnType("INTEGER"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("IsExternal") + .HasColumnType("INTEGER"); + + b.Property("IsForced") + .HasColumnType("INTEGER"); + + b.Property("IsHearingImpaired") + .HasColumnType("INTEGER"); + + b.Property("IsInterlaced") + .HasColumnType("INTEGER"); + + b.Property("KeyFrames") + .HasColumnType("TEXT"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("REAL"); + + b.Property("NalLengthSize") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PixelFormat") + .HasColumnType("TEXT"); + + b.Property("Profile") + .HasColumnType("TEXT"); + + b.Property("RealFrameRate") + .HasColumnType("REAL"); + + b.Property("RefFrames") + .HasColumnType("INTEGER"); + + b.Property("Rotation") + .HasColumnType("INTEGER"); + + b.Property("RpuPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("SampleRate") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .HasColumnType("INTEGER"); + + b.Property("TimeBase") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "StreamIndex"); + + b.HasIndex("StreamIndex"); + + b.HasIndex("StreamType"); + + b.HasIndex("StreamIndex", "StreamType"); + + b.HasIndex("StreamIndex", "StreamType", "Language"); + + b.ToTable("MediaStreamInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.People", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PersonType") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("Peoples"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.PeopleBaseItemMap", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("PeopleId") + .HasColumnType("TEXT"); + + b.Property("ListOrder") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.HasIndex("ItemId", "ListOrder"); + + b.HasIndex("ItemId", "SortOrder"); + + b.ToTable("PeopleBaseItemMap"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Permission_Permissions_Guid") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Permissions"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Preference_Preferences_Guid") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Preferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccessToken") + .IsUnique(); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AppName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AppVersion") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("AccessToken", "DateLastActivity"); + + b.HasIndex("DeviceId", "DateLastActivity"); + + b.HasIndex("UserId", "DeviceId"); + + b.ToTable("Devices"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.DeviceOptions", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CustomName") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId") + .IsUnique(); + + b.ToTable("DeviceOptions"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.TrickplayInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.Property("Bandwidth") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Interval") + .HasColumnType("INTEGER"); + + b.Property("ThumbnailCount") + .HasColumnType("INTEGER"); + + b.Property("TileHeight") + .HasColumnType("INTEGER"); + + b.Property("TileWidth") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "Width"); + + b.ToTable("TrickplayInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AudioLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("AuthenticationProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("CastReceiverId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DisplayCollectionsView") + .HasColumnType("INTEGER"); + + b.Property("DisplayMissingEpisodes") + .HasColumnType("INTEGER"); + + b.Property("EnableAutoLogin") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalPassword") + .HasColumnType("INTEGER"); + + b.Property("EnableNextEpisodeAutoPlay") + .HasColumnType("INTEGER"); + + b.Property("EnableUserPreferenceAccess") + .HasColumnType("INTEGER"); + + b.Property("HidePlayedInLatest") + .HasColumnType("INTEGER"); + + b.Property("InternalId") + .HasColumnType("INTEGER"); + + b.Property("InvalidLoginAttemptCount") + .HasColumnType("INTEGER"); + + b.Property("LastActivityDate") + .HasColumnType("TEXT"); + + b.Property("LastLoginDate") + .HasColumnType("TEXT"); + + b.Property("LoginAttemptsBeforeLockout") + .HasColumnType("INTEGER"); + + b.Property("MaxActiveSessions") + .HasColumnType("INTEGER"); + + b.Property("MaxParentalAgeRating") + .HasColumnType("INTEGER"); + + b.Property("MustUpdatePassword") + .HasColumnType("INTEGER"); + + b.Property("Password") + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.Property("PasswordResetProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("PlayDefaultAudioTrack") + .HasColumnType("INTEGER"); + + b.Property("RememberAudioSelections") + .HasColumnType("INTEGER"); + + b.Property("RememberSubtitleSelections") + .HasColumnType("INTEGER"); + + b.Property("RemoteClientBitrateLimit") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SubtitleLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("SubtitleMode") + .HasColumnType("INTEGER"); + + b.Property("SyncPlayAccess") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.UserData", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("CustomDataKey") + .HasColumnType("TEXT"); + + b.Property("AudioStreamIndex") + .HasColumnType("INTEGER"); + + b.Property("IsFavorite") + .HasColumnType("INTEGER"); + + b.Property("LastPlayedDate") + .HasColumnType("TEXT"); + + b.Property("Likes") + .HasColumnType("INTEGER"); + + b.Property("PlayCount") + .HasColumnType("INTEGER"); + + b.Property("PlaybackPositionTicks") + .HasColumnType("INTEGER"); + + b.Property("Played") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("SubtitleStreamIndex") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "UserId", "CustomDataKey"); + + b.HasIndex("UserId"); + + b.HasIndex("ItemId", "UserId", "IsFavorite"); + + b.HasIndex("ItemId", "UserId", "LastPlayedDate"); + + b.HasIndex("ItemId", "UserId", "PlaybackPositionTicks"); + + b.HasIndex("ItemId", "UserId", "Played"); + + b.ToTable("UserData"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("AccessSchedules") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AncestorId", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", null) + .WithMany("AncestorIds") + .HasForeignKey("BaseItemEntityId"); + + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "ParentItem") + .WithMany() + .HasForeignKey("ParentItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ParentItem"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AttachmentStreamInfo", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemImageInfo", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("Images") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemMetadataField", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("LockedFields") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemProvider", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("Provider") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemTrailerType", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("TrailerTypes") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Chapter", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("Chapters") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("DisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b => + { + b.HasOne("Jellyfin.Data.Entities.DisplayPreferences", null) + .WithMany("HomeSections") + .HasForeignKey("DisplayPreferencesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithOne("ProfileImage") + .HasForeignKey("Jellyfin.Data.Entities.ImageInfo", "UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("ItemDisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemValueMap", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("ItemValues") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Data.Entities.ItemValue", "ItemValue") + .WithMany("BaseItemsMap") + .HasForeignKey("ItemValueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ItemValue"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.MediaStreamInfo", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("MediaStreams") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.PeopleBaseItemMap", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("Peoples") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Data.Entities.People", "People") + .WithMany("BaseItems") + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("People"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("Permissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("Preferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.Device", b => + { + b.HasOne("Jellyfin.Data.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.UserData", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("UserData") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Data.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemEntity", b => + { + b.Navigation("AncestorIds"); + + b.Navigation("Chapters"); + + b.Navigation("Images"); + + b.Navigation("ItemValues"); + + b.Navigation("LockedFields"); + + b.Navigation("MediaStreams"); + + b.Navigation("Peoples"); + + b.Navigation("Provider"); + + b.Navigation("TrailerTypes"); + + b.Navigation("UserData"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.Navigation("HomeSections"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemValue", b => + { + b.Navigation("BaseItemsMap"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.People", b => + { + b.Navigation("BaseItems"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.User", b => + { + b.Navigation("AccessSchedules"); + + b.Navigation("DisplayPreferences"); + + b.Navigation("ItemDisplayPreferences"); + + b.Navigation("Permissions"); + + b.Navigation("Preferences"); + + b.Navigation("ProfileImage"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241111135439_AddedCustomDataKeyKey.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241111135439_AddedCustomDataKeyKey.cs new file mode 100644 index 0000000000..4558d7c49c --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241111135439_AddedCustomDataKeyKey.cs @@ -0,0 +1,54 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + /// + public partial class AddedCustomDataKeyKey : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropPrimaryKey( + name: "PK_UserData", + table: "UserData"); + + migrationBuilder.AlterColumn( + name: "CustomDataKey", + table: "UserData", + type: "TEXT", + nullable: false, + defaultValue: string.Empty, + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.AddPrimaryKey( + name: "PK_UserData", + table: "UserData", + columns: new[] { "ItemId", "UserId", "CustomDataKey" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropPrimaryKey( + name: "PK_UserData", + table: "UserData"); + + migrationBuilder.AlterColumn( + name: "CustomDataKey", + table: "UserData", + type: "TEXT", + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT"); + + migrationBuilder.AddPrimaryKey( + name: "PK_UserData", + table: "UserData", + columns: new[] { "ItemId", "UserId" }); + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241112152323_FixAncestorIdConfig.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241112152323_FixAncestorIdConfig.Designer.cs new file mode 100644 index 0000000000..1e0d3b129f --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241112152323_FixAncestorIdConfig.Designer.cs @@ -0,0 +1,1603 @@ +// +using System; +using Jellyfin.Database.Implementations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + [DbContext(typeof(JellyfinDbContext))] + [Migration("20241112152323_FixAncestorIdConfig")] + partial class FixAncestorIdConfig + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.10"); + + modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property("EndHour") + .HasColumnType("REAL"); + + b.Property("StartHour") + .HasColumnType("REAL"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AccessSchedules"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ActivityLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LogSeverity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ShortOverview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DateCreated"); + + b.ToTable("ActivityLogs"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AncestorId", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ParentItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ParentItemId"); + + b.HasIndex("ParentItemId"); + + b.ToTable("AncestorIds"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AttachmentStreamInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CodecTag") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Filename") + .HasColumnType("TEXT"); + + b.Property("MimeType") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "Index"); + + b.ToTable("AttachmentStreamInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Album") + .HasColumnType("TEXT"); + + b.Property("AlbumArtists") + .HasColumnType("TEXT"); + + b.Property("Artists") + .HasColumnType("TEXT"); + + b.Property("Audio") + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("CleanName") + .HasColumnType("TEXT"); + + b.Property("CommunityRating") + .HasColumnType("REAL"); + + b.Property("CriticRating") + .HasColumnType("REAL"); + + b.Property("CustomRating") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastMediaAdded") + .HasColumnType("TEXT"); + + b.Property("DateLastRefreshed") + .HasColumnType("TEXT"); + + b.Property("DateLastSaved") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("EpisodeTitle") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasColumnType("TEXT"); + + b.Property("ExternalSeriesId") + .HasColumnType("TEXT"); + + b.Property("ExternalServiceId") + .HasColumnType("TEXT"); + + b.Property("ExtraIds") + .HasColumnType("TEXT"); + + b.Property("ExtraType") + .HasColumnType("INTEGER"); + + b.Property("ForcedSortName") + .HasColumnType("TEXT"); + + b.Property("Genres") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("IndexNumber") + .HasColumnType("INTEGER"); + + b.Property("InheritedParentalRatingValue") + .HasColumnType("INTEGER"); + + b.Property("IsFolder") + .HasColumnType("INTEGER"); + + b.Property("IsInMixedFolder") + .HasColumnType("INTEGER"); + + b.Property("IsLocked") + .HasColumnType("INTEGER"); + + b.Property("IsMovie") + .HasColumnType("INTEGER"); + + b.Property("IsRepeat") + .HasColumnType("INTEGER"); + + b.Property("IsSeries") + .HasColumnType("INTEGER"); + + b.Property("IsVirtualItem") + .HasColumnType("INTEGER"); + + b.Property("LUFS") + .HasColumnType("REAL"); + + b.Property("MediaType") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizationGain") + .HasColumnType("REAL"); + + b.Property("OfficialRating") + .HasColumnType("TEXT"); + + b.Property("OriginalTitle") + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("OwnerId") + .HasColumnType("TEXT"); + + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("ParentIndexNumber") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PreferredMetadataCountryCode") + .HasColumnType("TEXT"); + + b.Property("PreferredMetadataLanguage") + .HasColumnType("TEXT"); + + b.Property("PremiereDate") + .HasColumnType("TEXT"); + + b.Property("PresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property("PrimaryVersionId") + .HasColumnType("TEXT"); + + b.Property("ProductionLocations") + .HasColumnType("TEXT"); + + b.Property("ProductionYear") + .HasColumnType("INTEGER"); + + b.Property("RunTimeTicks") + .HasColumnType("INTEGER"); + + b.Property("SeasonId") + .HasColumnType("TEXT"); + + b.Property("SeasonName") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("SeriesName") + .HasColumnType("TEXT"); + + b.Property("SeriesPresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property("ShowId") + .HasColumnType("TEXT"); + + b.Property("Size") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("Studios") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("TopParentId") + .HasColumnType("TEXT"); + + b.Property("TotalBitrate") + .HasColumnType("INTEGER"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UnratedType") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.HasIndex("Path"); + + b.HasIndex("PresentationUniqueKey"); + + b.HasIndex("TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "PresentationUniqueKey"); + + b.HasIndex("Type", "TopParentId", "StartDate"); + + b.HasIndex("Id", "Type", "IsFolder", "IsVirtualItem"); + + b.HasIndex("MediaType", "TopParentId", "IsVirtualItem", "PresentationUniqueKey"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "IsFolder", "IsVirtualItem"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "PresentationUniqueKey", "SortName"); + + b.HasIndex("IsFolder", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.HasIndex("Type", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.ToTable("BaseItems"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemImageInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Blurhash") + .HasColumnType("BLOB"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("ImageType") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemImageInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemMetadataField", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemMetadataFields"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemProvider", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("ProviderValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ProviderId"); + + b.HasIndex("ProviderId", "ProviderValue", "ItemId"); + + b.ToTable("BaseItemProviders"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemTrailerType", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemTrailerTypes"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Chapter", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ChapterIndex") + .HasColumnType("INTEGER"); + + b.Property("ImageDateModified") + .HasColumnType("TEXT"); + + b.Property("ImagePath") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("StartPositionTicks") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "ChapterIndex"); + + b.ToTable("Chapters"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.CustomItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client", "Key") + .IsUnique(); + + b.ToTable("CustomItemDisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChromecastVersion") + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DashboardTheme") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("EnableNextVideoInfoOverlay") + .HasColumnType("INTEGER"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ScrollDirection") + .HasColumnType("INTEGER"); + + b.Property("ShowBackdrop") + .HasColumnType("INTEGER"); + + b.Property("ShowSidebar") + .HasColumnType("INTEGER"); + + b.Property("SkipBackwardLength") + .HasColumnType("INTEGER"); + + b.Property("SkipForwardLength") + .HasColumnType("INTEGER"); + + b.Property("TvHome") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client") + .IsUnique(); + + b.ToTable("DisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DisplayPreferencesId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DisplayPreferencesId"); + + b.ToTable("HomeSection"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("ImageInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("RememberIndexing") + .HasColumnType("INTEGER"); + + b.Property("RememberSorting") + .HasColumnType("INTEGER"); + + b.Property("SortBy") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("ViewType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("ItemDisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemValue", b => + { + b.Property("ItemValueId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CleanValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId"); + + b.HasIndex("Type", "CleanValue"); + + b.ToTable("ItemValues"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemValueMap", b => + { + b.Property("ItemValueId") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("ItemValuesMap"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.MediaSegment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EndTicks") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("SegmentProviderId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StartTicks") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("MediaSegments"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.MediaStreamInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("StreamIndex") + .HasColumnType("INTEGER"); + + b.Property("AspectRatio") + .HasColumnType("TEXT"); + + b.Property("AverageFrameRate") + .HasColumnType("REAL"); + + b.Property("BitDepth") + .HasColumnType("INTEGER"); + + b.Property("BitRate") + .HasColumnType("INTEGER"); + + b.Property("BlPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("ChannelLayout") + .HasColumnType("TEXT"); + + b.Property("Channels") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasColumnType("TEXT"); + + b.Property("CodecTag") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CodecTimeBase") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ColorPrimaries") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ColorSpace") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ColorTransfer") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DvBlSignalCompatibilityId") + .HasColumnType("INTEGER"); + + b.Property("DvLevel") + .HasColumnType("INTEGER"); + + b.Property("DvProfile") + .HasColumnType("INTEGER"); + + b.Property("DvVersionMajor") + .HasColumnType("INTEGER"); + + b.Property("DvVersionMinor") + .HasColumnType("INTEGER"); + + b.Property("ElPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("IsAnamorphic") + .HasColumnType("INTEGER"); + + b.Property("IsAvc") + .HasColumnType("INTEGER"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("IsExternal") + .HasColumnType("INTEGER"); + + b.Property("IsForced") + .HasColumnType("INTEGER"); + + b.Property("IsHearingImpaired") + .HasColumnType("INTEGER"); + + b.Property("IsInterlaced") + .HasColumnType("INTEGER"); + + b.Property("KeyFrames") + .HasColumnType("TEXT"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("REAL"); + + b.Property("NalLengthSize") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PixelFormat") + .HasColumnType("TEXT"); + + b.Property("Profile") + .HasColumnType("TEXT"); + + b.Property("RealFrameRate") + .HasColumnType("REAL"); + + b.Property("RefFrames") + .HasColumnType("INTEGER"); + + b.Property("Rotation") + .HasColumnType("INTEGER"); + + b.Property("RpuPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("SampleRate") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .HasColumnType("INTEGER"); + + b.Property("TimeBase") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "StreamIndex"); + + b.HasIndex("StreamIndex"); + + b.HasIndex("StreamType"); + + b.HasIndex("StreamIndex", "StreamType"); + + b.HasIndex("StreamIndex", "StreamType", "Language"); + + b.ToTable("MediaStreamInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.People", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PersonType") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("Peoples"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.PeopleBaseItemMap", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("PeopleId") + .HasColumnType("TEXT"); + + b.Property("ListOrder") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.HasIndex("ItemId", "ListOrder"); + + b.HasIndex("ItemId", "SortOrder"); + + b.ToTable("PeopleBaseItemMap"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Permission_Permissions_Guid") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Permissions"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Preference_Preferences_Guid") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Preferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccessToken") + .IsUnique(); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AppName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AppVersion") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("AccessToken", "DateLastActivity"); + + b.HasIndex("DeviceId", "DateLastActivity"); + + b.HasIndex("UserId", "DeviceId"); + + b.ToTable("Devices"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.DeviceOptions", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CustomName") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId") + .IsUnique(); + + b.ToTable("DeviceOptions"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.TrickplayInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.Property("Bandwidth") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Interval") + .HasColumnType("INTEGER"); + + b.Property("ThumbnailCount") + .HasColumnType("INTEGER"); + + b.Property("TileHeight") + .HasColumnType("INTEGER"); + + b.Property("TileWidth") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "Width"); + + b.ToTable("TrickplayInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AudioLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("AuthenticationProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("CastReceiverId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DisplayCollectionsView") + .HasColumnType("INTEGER"); + + b.Property("DisplayMissingEpisodes") + .HasColumnType("INTEGER"); + + b.Property("EnableAutoLogin") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalPassword") + .HasColumnType("INTEGER"); + + b.Property("EnableNextEpisodeAutoPlay") + .HasColumnType("INTEGER"); + + b.Property("EnableUserPreferenceAccess") + .HasColumnType("INTEGER"); + + b.Property("HidePlayedInLatest") + .HasColumnType("INTEGER"); + + b.Property("InternalId") + .HasColumnType("INTEGER"); + + b.Property("InvalidLoginAttemptCount") + .HasColumnType("INTEGER"); + + b.Property("LastActivityDate") + .HasColumnType("TEXT"); + + b.Property("LastLoginDate") + .HasColumnType("TEXT"); + + b.Property("LoginAttemptsBeforeLockout") + .HasColumnType("INTEGER"); + + b.Property("MaxActiveSessions") + .HasColumnType("INTEGER"); + + b.Property("MaxParentalAgeRating") + .HasColumnType("INTEGER"); + + b.Property("MustUpdatePassword") + .HasColumnType("INTEGER"); + + b.Property("Password") + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.Property("PasswordResetProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("PlayDefaultAudioTrack") + .HasColumnType("INTEGER"); + + b.Property("RememberAudioSelections") + .HasColumnType("INTEGER"); + + b.Property("RememberSubtitleSelections") + .HasColumnType("INTEGER"); + + b.Property("RemoteClientBitrateLimit") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SubtitleLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("SubtitleMode") + .HasColumnType("INTEGER"); + + b.Property("SyncPlayAccess") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.UserData", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("CustomDataKey") + .HasColumnType("TEXT"); + + b.Property("AudioStreamIndex") + .HasColumnType("INTEGER"); + + b.Property("IsFavorite") + .HasColumnType("INTEGER"); + + b.Property("LastPlayedDate") + .HasColumnType("TEXT"); + + b.Property("Likes") + .HasColumnType("INTEGER"); + + b.Property("PlayCount") + .HasColumnType("INTEGER"); + + b.Property("PlaybackPositionTicks") + .HasColumnType("INTEGER"); + + b.Property("Played") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("SubtitleStreamIndex") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "UserId", "CustomDataKey"); + + b.HasIndex("UserId"); + + b.HasIndex("ItemId", "UserId", "IsFavorite"); + + b.HasIndex("ItemId", "UserId", "LastPlayedDate"); + + b.HasIndex("ItemId", "UserId", "PlaybackPositionTicks"); + + b.HasIndex("ItemId", "UserId", "Played"); + + b.ToTable("UserData"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("AccessSchedules") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AncestorId", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("Children") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "ParentItem") + .WithMany("ParentAncestors") + .HasForeignKey("ParentItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ParentItem"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AttachmentStreamInfo", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemImageInfo", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("Images") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemMetadataField", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("LockedFields") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemProvider", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("Provider") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemTrailerType", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("TrailerTypes") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Chapter", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("Chapters") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("DisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b => + { + b.HasOne("Jellyfin.Data.Entities.DisplayPreferences", null) + .WithMany("HomeSections") + .HasForeignKey("DisplayPreferencesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithOne("ProfileImage") + .HasForeignKey("Jellyfin.Data.Entities.ImageInfo", "UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("ItemDisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemValueMap", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("ItemValues") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Data.Entities.ItemValue", "ItemValue") + .WithMany("BaseItemsMap") + .HasForeignKey("ItemValueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ItemValue"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.MediaStreamInfo", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("MediaStreams") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.PeopleBaseItemMap", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("Peoples") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Data.Entities.People", "People") + .WithMany("BaseItems") + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("People"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("Permissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("Preferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.Device", b => + { + b.HasOne("Jellyfin.Data.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.UserData", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("UserData") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Data.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemEntity", b => + { + b.Navigation("Chapters"); + + b.Navigation("Children"); + + b.Navigation("Images"); + + b.Navigation("ItemValues"); + + b.Navigation("LockedFields"); + + b.Navigation("MediaStreams"); + + b.Navigation("ParentAncestors"); + + b.Navigation("Peoples"); + + b.Navigation("Provider"); + + b.Navigation("TrailerTypes"); + + b.Navigation("UserData"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.Navigation("HomeSections"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemValue", b => + { + b.Navigation("BaseItemsMap"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.People", b => + { + b.Navigation("BaseItems"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.User", b => + { + b.Navigation("AccessSchedules"); + + b.Navigation("DisplayPreferences"); + + b.Navigation("ItemDisplayPreferences"); + + b.Navigation("Permissions"); + + b.Navigation("Preferences"); + + b.Navigation("ProfileImage"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241112152323_FixAncestorIdConfig.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241112152323_FixAncestorIdConfig.cs new file mode 100644 index 0000000000..70e81f3676 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241112152323_FixAncestorIdConfig.cs @@ -0,0 +1,49 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + /// + public partial class FixAncestorIdConfig : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_AncestorIds_BaseItems_BaseItemEntityId", + table: "AncestorIds"); + + migrationBuilder.DropIndex( + name: "IX_AncestorIds_BaseItemEntityId", + table: "AncestorIds"); + + migrationBuilder.DropColumn( + name: "BaseItemEntityId", + table: "AncestorIds"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "BaseItemEntityId", + table: "AncestorIds", + type: "TEXT", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_AncestorIds_BaseItemEntityId", + table: "AncestorIds", + column: "BaseItemEntityId"); + + migrationBuilder.AddForeignKey( + name: "FK_AncestorIds_BaseItems_BaseItemEntityId", + table: "AncestorIds", + column: "BaseItemEntityId", + principalTable: "BaseItems", + principalColumn: "Id"); + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241112232041_fixMediaStreams.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241112232041_fixMediaStreams.Designer.cs new file mode 100644 index 0000000000..ccf67d899f --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241112232041_fixMediaStreams.Designer.cs @@ -0,0 +1,1600 @@ +// +using System; +using Jellyfin.Database.Implementations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + [DbContext(typeof(JellyfinDbContext))] + [Migration("20241112232041_FixMediaStreams")] + partial class FixMediaStreams + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.10"); + + modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property("EndHour") + .HasColumnType("REAL"); + + b.Property("StartHour") + .HasColumnType("REAL"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AccessSchedules"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ActivityLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LogSeverity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ShortOverview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DateCreated"); + + b.ToTable("ActivityLogs"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AncestorId", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ParentItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ParentItemId"); + + b.HasIndex("ParentItemId"); + + b.ToTable("AncestorIds"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AttachmentStreamInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CodecTag") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Filename") + .HasColumnType("TEXT"); + + b.Property("MimeType") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "Index"); + + b.ToTable("AttachmentStreamInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Album") + .HasColumnType("TEXT"); + + b.Property("AlbumArtists") + .HasColumnType("TEXT"); + + b.Property("Artists") + .HasColumnType("TEXT"); + + b.Property("Audio") + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("CleanName") + .HasColumnType("TEXT"); + + b.Property("CommunityRating") + .HasColumnType("REAL"); + + b.Property("CriticRating") + .HasColumnType("REAL"); + + b.Property("CustomRating") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastMediaAdded") + .HasColumnType("TEXT"); + + b.Property("DateLastRefreshed") + .HasColumnType("TEXT"); + + b.Property("DateLastSaved") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("EpisodeTitle") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasColumnType("TEXT"); + + b.Property("ExternalSeriesId") + .HasColumnType("TEXT"); + + b.Property("ExternalServiceId") + .HasColumnType("TEXT"); + + b.Property("ExtraIds") + .HasColumnType("TEXT"); + + b.Property("ExtraType") + .HasColumnType("INTEGER"); + + b.Property("ForcedSortName") + .HasColumnType("TEXT"); + + b.Property("Genres") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("IndexNumber") + .HasColumnType("INTEGER"); + + b.Property("InheritedParentalRatingValue") + .HasColumnType("INTEGER"); + + b.Property("IsFolder") + .HasColumnType("INTEGER"); + + b.Property("IsInMixedFolder") + .HasColumnType("INTEGER"); + + b.Property("IsLocked") + .HasColumnType("INTEGER"); + + b.Property("IsMovie") + .HasColumnType("INTEGER"); + + b.Property("IsRepeat") + .HasColumnType("INTEGER"); + + b.Property("IsSeries") + .HasColumnType("INTEGER"); + + b.Property("IsVirtualItem") + .HasColumnType("INTEGER"); + + b.Property("LUFS") + .HasColumnType("REAL"); + + b.Property("MediaType") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizationGain") + .HasColumnType("REAL"); + + b.Property("OfficialRating") + .HasColumnType("TEXT"); + + b.Property("OriginalTitle") + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("OwnerId") + .HasColumnType("TEXT"); + + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("ParentIndexNumber") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PreferredMetadataCountryCode") + .HasColumnType("TEXT"); + + b.Property("PreferredMetadataLanguage") + .HasColumnType("TEXT"); + + b.Property("PremiereDate") + .HasColumnType("TEXT"); + + b.Property("PresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property("PrimaryVersionId") + .HasColumnType("TEXT"); + + b.Property("ProductionLocations") + .HasColumnType("TEXT"); + + b.Property("ProductionYear") + .HasColumnType("INTEGER"); + + b.Property("RunTimeTicks") + .HasColumnType("INTEGER"); + + b.Property("SeasonId") + .HasColumnType("TEXT"); + + b.Property("SeasonName") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("SeriesName") + .HasColumnType("TEXT"); + + b.Property("SeriesPresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property("ShowId") + .HasColumnType("TEXT"); + + b.Property("Size") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("Studios") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("TopParentId") + .HasColumnType("TEXT"); + + b.Property("TotalBitrate") + .HasColumnType("INTEGER"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UnratedType") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.HasIndex("Path"); + + b.HasIndex("PresentationUniqueKey"); + + b.HasIndex("TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "PresentationUniqueKey"); + + b.HasIndex("Type", "TopParentId", "StartDate"); + + b.HasIndex("Id", "Type", "IsFolder", "IsVirtualItem"); + + b.HasIndex("MediaType", "TopParentId", "IsVirtualItem", "PresentationUniqueKey"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "IsFolder", "IsVirtualItem"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "PresentationUniqueKey", "SortName"); + + b.HasIndex("IsFolder", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.HasIndex("Type", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.ToTable("BaseItems"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemImageInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Blurhash") + .HasColumnType("BLOB"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("ImageType") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemImageInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemMetadataField", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemMetadataFields"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemProvider", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("ProviderValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ProviderId"); + + b.HasIndex("ProviderId", "ProviderValue", "ItemId"); + + b.ToTable("BaseItemProviders"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemTrailerType", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemTrailerTypes"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Chapter", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ChapterIndex") + .HasColumnType("INTEGER"); + + b.Property("ImageDateModified") + .HasColumnType("TEXT"); + + b.Property("ImagePath") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("StartPositionTicks") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "ChapterIndex"); + + b.ToTable("Chapters"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.CustomItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client", "Key") + .IsUnique(); + + b.ToTable("CustomItemDisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChromecastVersion") + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DashboardTheme") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("EnableNextVideoInfoOverlay") + .HasColumnType("INTEGER"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ScrollDirection") + .HasColumnType("INTEGER"); + + b.Property("ShowBackdrop") + .HasColumnType("INTEGER"); + + b.Property("ShowSidebar") + .HasColumnType("INTEGER"); + + b.Property("SkipBackwardLength") + .HasColumnType("INTEGER"); + + b.Property("SkipForwardLength") + .HasColumnType("INTEGER"); + + b.Property("TvHome") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client") + .IsUnique(); + + b.ToTable("DisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DisplayPreferencesId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DisplayPreferencesId"); + + b.ToTable("HomeSection"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("ImageInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("RememberIndexing") + .HasColumnType("INTEGER"); + + b.Property("RememberSorting") + .HasColumnType("INTEGER"); + + b.Property("SortBy") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("ViewType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("ItemDisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemValue", b => + { + b.Property("ItemValueId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CleanValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId"); + + b.HasIndex("Type", "CleanValue"); + + b.ToTable("ItemValues"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemValueMap", b => + { + b.Property("ItemValueId") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("ItemValuesMap"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.MediaSegment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EndTicks") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("SegmentProviderId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StartTicks") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("MediaSegments"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.MediaStreamInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("StreamIndex") + .HasColumnType("INTEGER"); + + b.Property("AspectRatio") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AverageFrameRate") + .HasColumnType("REAL"); + + b.Property("BitDepth") + .HasColumnType("INTEGER"); + + b.Property("BitRate") + .HasColumnType("INTEGER"); + + b.Property("BlPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("ChannelLayout") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Channels") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CodecTag") + .HasColumnType("TEXT"); + + b.Property("CodecTimeBase") + .HasColumnType("TEXT"); + + b.Property("ColorPrimaries") + .HasColumnType("TEXT"); + + b.Property("ColorSpace") + .HasColumnType("TEXT"); + + b.Property("ColorTransfer") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("DvBlSignalCompatibilityId") + .HasColumnType("INTEGER"); + + b.Property("DvLevel") + .HasColumnType("INTEGER"); + + b.Property("DvProfile") + .HasColumnType("INTEGER"); + + b.Property("DvVersionMajor") + .HasColumnType("INTEGER"); + + b.Property("DvVersionMinor") + .HasColumnType("INTEGER"); + + b.Property("ElPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("IsAnamorphic") + .HasColumnType("INTEGER"); + + b.Property("IsAvc") + .HasColumnType("INTEGER"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("IsExternal") + .HasColumnType("INTEGER"); + + b.Property("IsForced") + .HasColumnType("INTEGER"); + + b.Property("IsHearingImpaired") + .HasColumnType("INTEGER"); + + b.Property("IsInterlaced") + .HasColumnType("INTEGER"); + + b.Property("KeyFrames") + .HasColumnType("TEXT"); + + b.Property("Language") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("REAL"); + + b.Property("NalLengthSize") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PixelFormat") + .HasColumnType("TEXT"); + + b.Property("Profile") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RealFrameRate") + .HasColumnType("REAL"); + + b.Property("RefFrames") + .HasColumnType("INTEGER"); + + b.Property("Rotation") + .HasColumnType("INTEGER"); + + b.Property("RpuPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("SampleRate") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .HasColumnType("INTEGER"); + + b.Property("TimeBase") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "StreamIndex"); + + b.HasIndex("StreamIndex"); + + b.HasIndex("StreamType"); + + b.HasIndex("StreamIndex", "StreamType"); + + b.HasIndex("StreamIndex", "StreamType", "Language"); + + b.ToTable("MediaStreamInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.People", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PersonType") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("Peoples"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.PeopleBaseItemMap", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("PeopleId") + .HasColumnType("TEXT"); + + b.Property("ListOrder") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.HasIndex("ItemId", "ListOrder"); + + b.HasIndex("ItemId", "SortOrder"); + + b.ToTable("PeopleBaseItemMap"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Permission_Permissions_Guid") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Permissions"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Preference_Preferences_Guid") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Preferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccessToken") + .IsUnique(); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AppName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AppVersion") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("AccessToken", "DateLastActivity"); + + b.HasIndex("DeviceId", "DateLastActivity"); + + b.HasIndex("UserId", "DeviceId"); + + b.ToTable("Devices"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.DeviceOptions", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CustomName") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId") + .IsUnique(); + + b.ToTable("DeviceOptions"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.TrickplayInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.Property("Bandwidth") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Interval") + .HasColumnType("INTEGER"); + + b.Property("ThumbnailCount") + .HasColumnType("INTEGER"); + + b.Property("TileHeight") + .HasColumnType("INTEGER"); + + b.Property("TileWidth") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "Width"); + + b.ToTable("TrickplayInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AudioLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("AuthenticationProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("CastReceiverId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DisplayCollectionsView") + .HasColumnType("INTEGER"); + + b.Property("DisplayMissingEpisodes") + .HasColumnType("INTEGER"); + + b.Property("EnableAutoLogin") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalPassword") + .HasColumnType("INTEGER"); + + b.Property("EnableNextEpisodeAutoPlay") + .HasColumnType("INTEGER"); + + b.Property("EnableUserPreferenceAccess") + .HasColumnType("INTEGER"); + + b.Property("HidePlayedInLatest") + .HasColumnType("INTEGER"); + + b.Property("InternalId") + .HasColumnType("INTEGER"); + + b.Property("InvalidLoginAttemptCount") + .HasColumnType("INTEGER"); + + b.Property("LastActivityDate") + .HasColumnType("TEXT"); + + b.Property("LastLoginDate") + .HasColumnType("TEXT"); + + b.Property("LoginAttemptsBeforeLockout") + .HasColumnType("INTEGER"); + + b.Property("MaxActiveSessions") + .HasColumnType("INTEGER"); + + b.Property("MaxParentalAgeRating") + .HasColumnType("INTEGER"); + + b.Property("MustUpdatePassword") + .HasColumnType("INTEGER"); + + b.Property("Password") + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.Property("PasswordResetProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("PlayDefaultAudioTrack") + .HasColumnType("INTEGER"); + + b.Property("RememberAudioSelections") + .HasColumnType("INTEGER"); + + b.Property("RememberSubtitleSelections") + .HasColumnType("INTEGER"); + + b.Property("RemoteClientBitrateLimit") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SubtitleLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("SubtitleMode") + .HasColumnType("INTEGER"); + + b.Property("SyncPlayAccess") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.UserData", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("CustomDataKey") + .HasColumnType("TEXT"); + + b.Property("AudioStreamIndex") + .HasColumnType("INTEGER"); + + b.Property("IsFavorite") + .HasColumnType("INTEGER"); + + b.Property("LastPlayedDate") + .HasColumnType("TEXT"); + + b.Property("Likes") + .HasColumnType("INTEGER"); + + b.Property("PlayCount") + .HasColumnType("INTEGER"); + + b.Property("PlaybackPositionTicks") + .HasColumnType("INTEGER"); + + b.Property("Played") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("SubtitleStreamIndex") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "UserId", "CustomDataKey"); + + b.HasIndex("UserId"); + + b.HasIndex("ItemId", "UserId", "IsFavorite"); + + b.HasIndex("ItemId", "UserId", "LastPlayedDate"); + + b.HasIndex("ItemId", "UserId", "PlaybackPositionTicks"); + + b.HasIndex("ItemId", "UserId", "Played"); + + b.ToTable("UserData"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("AccessSchedules") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AncestorId", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("Children") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "ParentItem") + .WithMany("ParentAncestors") + .HasForeignKey("ParentItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ParentItem"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AttachmentStreamInfo", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemImageInfo", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("Images") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemMetadataField", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("LockedFields") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemProvider", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("Provider") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemTrailerType", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("TrailerTypes") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Chapter", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("Chapters") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("DisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b => + { + b.HasOne("Jellyfin.Data.Entities.DisplayPreferences", null) + .WithMany("HomeSections") + .HasForeignKey("DisplayPreferencesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithOne("ProfileImage") + .HasForeignKey("Jellyfin.Data.Entities.ImageInfo", "UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("ItemDisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemValueMap", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("ItemValues") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Data.Entities.ItemValue", "ItemValue") + .WithMany("BaseItemsMap") + .HasForeignKey("ItemValueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ItemValue"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.MediaStreamInfo", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("MediaStreams") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.PeopleBaseItemMap", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("Peoples") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Data.Entities.People", "People") + .WithMany("BaseItems") + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("People"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("Permissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("Preferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.Device", b => + { + b.HasOne("Jellyfin.Data.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.UserData", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("UserData") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Data.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemEntity", b => + { + b.Navigation("Chapters"); + + b.Navigation("Children"); + + b.Navigation("Images"); + + b.Navigation("ItemValues"); + + b.Navigation("LockedFields"); + + b.Navigation("MediaStreams"); + + b.Navigation("ParentAncestors"); + + b.Navigation("Peoples"); + + b.Navigation("Provider"); + + b.Navigation("TrailerTypes"); + + b.Navigation("UserData"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.Navigation("HomeSections"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemValue", b => + { + b.Navigation("BaseItemsMap"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.People", b => + { + b.Navigation("BaseItems"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.User", b => + { + b.Navigation("AccessSchedules"); + + b.Navigation("DisplayPreferences"); + + b.Navigation("ItemDisplayPreferences"); + + b.Navigation("Permissions"); + + b.Navigation("Preferences"); + + b.Navigation("ProfileImage"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241112232041_fixMediaStreams.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241112232041_fixMediaStreams.cs new file mode 100644 index 0000000000..d57ea81b3a --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241112232041_fixMediaStreams.cs @@ -0,0 +1,702 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + /// + public partial class FixMediaStreams : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Width", + table: "MediaStreamInfos", + type: "INTEGER", + nullable: true, + oldClrType: typeof(int), + oldType: "INTEGER"); + + migrationBuilder.AlterColumn( + name: "Title", + table: "MediaStreamInfos", + type: "TEXT", + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT"); + + migrationBuilder.AlterColumn( + name: "TimeBase", + table: "MediaStreamInfos", + type: "TEXT", + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT"); + + migrationBuilder.AlterColumn( + name: "StreamType", + table: "MediaStreamInfos", + type: "INTEGER", + nullable: false, + defaultValue: 0, + oldClrType: typeof(int), + oldType: "INTEGER", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "SampleRate", + table: "MediaStreamInfos", + type: "INTEGER", + nullable: true, + oldClrType: typeof(int), + oldType: "INTEGER"); + + migrationBuilder.AlterColumn( + name: "RpuPresentFlag", + table: "MediaStreamInfos", + type: "INTEGER", + nullable: true, + oldClrType: typeof(int), + oldType: "INTEGER"); + + migrationBuilder.AlterColumn( + name: "Rotation", + table: "MediaStreamInfos", + type: "INTEGER", + nullable: true, + oldClrType: typeof(int), + oldType: "INTEGER"); + + migrationBuilder.AlterColumn( + name: "RefFrames", + table: "MediaStreamInfos", + type: "INTEGER", + nullable: true, + oldClrType: typeof(int), + oldType: "INTEGER"); + + migrationBuilder.AlterColumn( + name: "RealFrameRate", + table: "MediaStreamInfos", + type: "REAL", + nullable: true, + oldClrType: typeof(float), + oldType: "REAL"); + + migrationBuilder.AlterColumn( + name: "Profile", + table: "MediaStreamInfos", + type: "TEXT", + nullable: false, + defaultValue: string.Empty, + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Path", + table: "MediaStreamInfos", + type: "TEXT", + nullable: false, + defaultValue: string.Empty, + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "NalLengthSize", + table: "MediaStreamInfos", + type: "TEXT", + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT"); + + migrationBuilder.AlterColumn( + name: "Level", + table: "MediaStreamInfos", + type: "REAL", + nullable: true, + oldClrType: typeof(float), + oldType: "REAL"); + + migrationBuilder.AlterColumn( + name: "Language", + table: "MediaStreamInfos", + type: "TEXT", + nullable: false, + defaultValue: string.Empty, + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "IsHearingImpaired", + table: "MediaStreamInfos", + type: "INTEGER", + nullable: true, + oldClrType: typeof(bool), + oldType: "INTEGER"); + + migrationBuilder.AlterColumn( + name: "IsAvc", + table: "MediaStreamInfos", + type: "INTEGER", + nullable: true, + oldClrType: typeof(bool), + oldType: "INTEGER"); + + migrationBuilder.AlterColumn( + name: "IsAnamorphic", + table: "MediaStreamInfos", + type: "INTEGER", + nullable: true, + oldClrType: typeof(bool), + oldType: "INTEGER"); + + migrationBuilder.AlterColumn( + name: "Height", + table: "MediaStreamInfos", + type: "INTEGER", + nullable: true, + oldClrType: typeof(int), + oldType: "INTEGER"); + + migrationBuilder.AlterColumn( + name: "ElPresentFlag", + table: "MediaStreamInfos", + type: "INTEGER", + nullable: true, + oldClrType: typeof(int), + oldType: "INTEGER"); + + migrationBuilder.AlterColumn( + name: "DvVersionMinor", + table: "MediaStreamInfos", + type: "INTEGER", + nullable: true, + oldClrType: typeof(int), + oldType: "INTEGER"); + + migrationBuilder.AlterColumn( + name: "DvVersionMajor", + table: "MediaStreamInfos", + type: "INTEGER", + nullable: true, + oldClrType: typeof(int), + oldType: "INTEGER"); + + migrationBuilder.AlterColumn( + name: "DvProfile", + table: "MediaStreamInfos", + type: "INTEGER", + nullable: true, + oldClrType: typeof(int), + oldType: "INTEGER"); + + migrationBuilder.AlterColumn( + name: "DvLevel", + table: "MediaStreamInfos", + type: "INTEGER", + nullable: true, + oldClrType: typeof(int), + oldType: "INTEGER"); + + migrationBuilder.AlterColumn( + name: "DvBlSignalCompatibilityId", + table: "MediaStreamInfos", + type: "INTEGER", + nullable: true, + oldClrType: typeof(int), + oldType: "INTEGER"); + + migrationBuilder.AlterColumn( + name: "Comment", + table: "MediaStreamInfos", + type: "TEXT", + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT"); + + migrationBuilder.AlterColumn( + name: "ColorTransfer", + table: "MediaStreamInfos", + type: "TEXT", + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT"); + + migrationBuilder.AlterColumn( + name: "ColorSpace", + table: "MediaStreamInfos", + type: "TEXT", + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT"); + + migrationBuilder.AlterColumn( + name: "ColorPrimaries", + table: "MediaStreamInfos", + type: "TEXT", + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT"); + + migrationBuilder.AlterColumn( + name: "CodecTimeBase", + table: "MediaStreamInfos", + type: "TEXT", + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT"); + + migrationBuilder.AlterColumn( + name: "CodecTag", + table: "MediaStreamInfos", + type: "TEXT", + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT"); + + migrationBuilder.AlterColumn( + name: "Codec", + table: "MediaStreamInfos", + type: "TEXT", + nullable: false, + defaultValue: string.Empty, + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Channels", + table: "MediaStreamInfos", + type: "INTEGER", + nullable: true, + oldClrType: typeof(int), + oldType: "INTEGER"); + + migrationBuilder.AlterColumn( + name: "ChannelLayout", + table: "MediaStreamInfos", + type: "TEXT", + nullable: false, + defaultValue: string.Empty, + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "BlPresentFlag", + table: "MediaStreamInfos", + type: "INTEGER", + nullable: true, + oldClrType: typeof(int), + oldType: "INTEGER"); + + migrationBuilder.AlterColumn( + name: "BitRate", + table: "MediaStreamInfos", + type: "INTEGER", + nullable: true, + oldClrType: typeof(int), + oldType: "INTEGER"); + + migrationBuilder.AlterColumn( + name: "BitDepth", + table: "MediaStreamInfos", + type: "INTEGER", + nullable: true, + oldClrType: typeof(int), + oldType: "INTEGER"); + + migrationBuilder.AlterColumn( + name: "AverageFrameRate", + table: "MediaStreamInfos", + type: "REAL", + nullable: true, + oldClrType: typeof(float), + oldType: "REAL"); + + migrationBuilder.AlterColumn( + name: "AspectRatio", + table: "MediaStreamInfos", + type: "TEXT", + nullable: false, + defaultValue: string.Empty, + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Width", + table: "MediaStreamInfos", + type: "INTEGER", + nullable: false, + defaultValue: 0, + oldClrType: typeof(int), + oldType: "INTEGER", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Title", + table: "MediaStreamInfos", + type: "TEXT", + nullable: false, + defaultValue: string.Empty, + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "TimeBase", + table: "MediaStreamInfos", + type: "TEXT", + nullable: false, + defaultValue: string.Empty, + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "StreamType", + table: "MediaStreamInfos", + type: "INTEGER", + nullable: true, + oldClrType: typeof(int), + oldType: "INTEGER"); + + migrationBuilder.AlterColumn( + name: "SampleRate", + table: "MediaStreamInfos", + type: "INTEGER", + nullable: false, + defaultValue: 0, + oldClrType: typeof(int), + oldType: "INTEGER", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "RpuPresentFlag", + table: "MediaStreamInfos", + type: "INTEGER", + nullable: false, + defaultValue: 0, + oldClrType: typeof(int), + oldType: "INTEGER", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Rotation", + table: "MediaStreamInfos", + type: "INTEGER", + nullable: false, + defaultValue: 0, + oldClrType: typeof(int), + oldType: "INTEGER", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "RefFrames", + table: "MediaStreamInfos", + type: "INTEGER", + nullable: false, + defaultValue: 0, + oldClrType: typeof(int), + oldType: "INTEGER", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "RealFrameRate", + table: "MediaStreamInfos", + type: "REAL", + nullable: false, + defaultValue: 0f, + oldClrType: typeof(float), + oldType: "REAL", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Profile", + table: "MediaStreamInfos", + type: "TEXT", + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT"); + + migrationBuilder.AlterColumn( + name: "Path", + table: "MediaStreamInfos", + type: "TEXT", + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT"); + + migrationBuilder.AlterColumn( + name: "NalLengthSize", + table: "MediaStreamInfos", + type: "TEXT", + nullable: false, + defaultValue: string.Empty, + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Level", + table: "MediaStreamInfos", + type: "REAL", + nullable: false, + defaultValue: 0f, + oldClrType: typeof(float), + oldType: "REAL", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Language", + table: "MediaStreamInfos", + type: "TEXT", + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT"); + + migrationBuilder.AlterColumn( + name: "IsHearingImpaired", + table: "MediaStreamInfos", + type: "INTEGER", + nullable: false, + defaultValue: false, + oldClrType: typeof(bool), + oldType: "INTEGER", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "IsAvc", + table: "MediaStreamInfos", + type: "INTEGER", + nullable: false, + defaultValue: false, + oldClrType: typeof(bool), + oldType: "INTEGER", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "IsAnamorphic", + table: "MediaStreamInfos", + type: "INTEGER", + nullable: false, + defaultValue: false, + oldClrType: typeof(bool), + oldType: "INTEGER", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Height", + table: "MediaStreamInfos", + type: "INTEGER", + nullable: false, + defaultValue: 0, + oldClrType: typeof(int), + oldType: "INTEGER", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "ElPresentFlag", + table: "MediaStreamInfos", + type: "INTEGER", + nullable: false, + defaultValue: 0, + oldClrType: typeof(int), + oldType: "INTEGER", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "DvVersionMinor", + table: "MediaStreamInfos", + type: "INTEGER", + nullable: false, + defaultValue: 0, + oldClrType: typeof(int), + oldType: "INTEGER", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "DvVersionMajor", + table: "MediaStreamInfos", + type: "INTEGER", + nullable: false, + defaultValue: 0, + oldClrType: typeof(int), + oldType: "INTEGER", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "DvProfile", + table: "MediaStreamInfos", + type: "INTEGER", + nullable: false, + defaultValue: 0, + oldClrType: typeof(int), + oldType: "INTEGER", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "DvLevel", + table: "MediaStreamInfos", + type: "INTEGER", + nullable: false, + defaultValue: 0, + oldClrType: typeof(int), + oldType: "INTEGER", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "DvBlSignalCompatibilityId", + table: "MediaStreamInfos", + type: "INTEGER", + nullable: false, + defaultValue: 0, + oldClrType: typeof(int), + oldType: "INTEGER", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Comment", + table: "MediaStreamInfos", + type: "TEXT", + nullable: false, + defaultValue: string.Empty, + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "ColorTransfer", + table: "MediaStreamInfos", + type: "TEXT", + nullable: false, + defaultValue: string.Empty, + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "ColorSpace", + table: "MediaStreamInfos", + type: "TEXT", + nullable: false, + defaultValue: string.Empty, + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "ColorPrimaries", + table: "MediaStreamInfos", + type: "TEXT", + nullable: false, + defaultValue: string.Empty, + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "CodecTimeBase", + table: "MediaStreamInfos", + type: "TEXT", + nullable: false, + defaultValue: string.Empty, + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "CodecTag", + table: "MediaStreamInfos", + type: "TEXT", + nullable: false, + defaultValue: string.Empty, + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Codec", + table: "MediaStreamInfos", + type: "TEXT", + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT"); + + migrationBuilder.AlterColumn( + name: "Channels", + table: "MediaStreamInfos", + type: "INTEGER", + nullable: false, + defaultValue: 0, + oldClrType: typeof(int), + oldType: "INTEGER", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "ChannelLayout", + table: "MediaStreamInfos", + type: "TEXT", + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT"); + + migrationBuilder.AlterColumn( + name: "BlPresentFlag", + table: "MediaStreamInfos", + type: "INTEGER", + nullable: false, + defaultValue: 0, + oldClrType: typeof(int), + oldType: "INTEGER", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "BitRate", + table: "MediaStreamInfos", + type: "INTEGER", + nullable: false, + defaultValue: 0, + oldClrType: typeof(int), + oldType: "INTEGER", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "BitDepth", + table: "MediaStreamInfos", + type: "INTEGER", + nullable: false, + defaultValue: 0, + oldClrType: typeof(int), + oldType: "INTEGER", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "AverageFrameRate", + table: "MediaStreamInfos", + type: "REAL", + nullable: false, + defaultValue: 0f, + oldClrType: typeof(float), + oldType: "REAL", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "AspectRatio", + table: "MediaStreamInfos", + type: "TEXT", + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT"); + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241112234144_FixMediaStreams2.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241112234144_FixMediaStreams2.Designer.cs new file mode 100644 index 0000000000..d3ba8c96a9 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241112234144_FixMediaStreams2.Designer.cs @@ -0,0 +1,1594 @@ +// +using System; +using Jellyfin.Database.Implementations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + [DbContext(typeof(JellyfinDbContext))] + [Migration("20241112234144_FixMediaStreams2")] + partial class FixMediaStreams2 + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.10"); + + modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property("EndHour") + .HasColumnType("REAL"); + + b.Property("StartHour") + .HasColumnType("REAL"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AccessSchedules"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ActivityLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LogSeverity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ShortOverview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DateCreated"); + + b.ToTable("ActivityLogs"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AncestorId", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ParentItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ParentItemId"); + + b.HasIndex("ParentItemId"); + + b.ToTable("AncestorIds"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AttachmentStreamInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CodecTag") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Filename") + .HasColumnType("TEXT"); + + b.Property("MimeType") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "Index"); + + b.ToTable("AttachmentStreamInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Album") + .HasColumnType("TEXT"); + + b.Property("AlbumArtists") + .HasColumnType("TEXT"); + + b.Property("Artists") + .HasColumnType("TEXT"); + + b.Property("Audio") + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("CleanName") + .HasColumnType("TEXT"); + + b.Property("CommunityRating") + .HasColumnType("REAL"); + + b.Property("CriticRating") + .HasColumnType("REAL"); + + b.Property("CustomRating") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastMediaAdded") + .HasColumnType("TEXT"); + + b.Property("DateLastRefreshed") + .HasColumnType("TEXT"); + + b.Property("DateLastSaved") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("EpisodeTitle") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasColumnType("TEXT"); + + b.Property("ExternalSeriesId") + .HasColumnType("TEXT"); + + b.Property("ExternalServiceId") + .HasColumnType("TEXT"); + + b.Property("ExtraIds") + .HasColumnType("TEXT"); + + b.Property("ExtraType") + .HasColumnType("INTEGER"); + + b.Property("ForcedSortName") + .HasColumnType("TEXT"); + + b.Property("Genres") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("IndexNumber") + .HasColumnType("INTEGER"); + + b.Property("InheritedParentalRatingValue") + .HasColumnType("INTEGER"); + + b.Property("IsFolder") + .HasColumnType("INTEGER"); + + b.Property("IsInMixedFolder") + .HasColumnType("INTEGER"); + + b.Property("IsLocked") + .HasColumnType("INTEGER"); + + b.Property("IsMovie") + .HasColumnType("INTEGER"); + + b.Property("IsRepeat") + .HasColumnType("INTEGER"); + + b.Property("IsSeries") + .HasColumnType("INTEGER"); + + b.Property("IsVirtualItem") + .HasColumnType("INTEGER"); + + b.Property("LUFS") + .HasColumnType("REAL"); + + b.Property("MediaType") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizationGain") + .HasColumnType("REAL"); + + b.Property("OfficialRating") + .HasColumnType("TEXT"); + + b.Property("OriginalTitle") + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("OwnerId") + .HasColumnType("TEXT"); + + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("ParentIndexNumber") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PreferredMetadataCountryCode") + .HasColumnType("TEXT"); + + b.Property("PreferredMetadataLanguage") + .HasColumnType("TEXT"); + + b.Property("PremiereDate") + .HasColumnType("TEXT"); + + b.Property("PresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property("PrimaryVersionId") + .HasColumnType("TEXT"); + + b.Property("ProductionLocations") + .HasColumnType("TEXT"); + + b.Property("ProductionYear") + .HasColumnType("INTEGER"); + + b.Property("RunTimeTicks") + .HasColumnType("INTEGER"); + + b.Property("SeasonId") + .HasColumnType("TEXT"); + + b.Property("SeasonName") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("SeriesName") + .HasColumnType("TEXT"); + + b.Property("SeriesPresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property("ShowId") + .HasColumnType("TEXT"); + + b.Property("Size") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("Studios") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("TopParentId") + .HasColumnType("TEXT"); + + b.Property("TotalBitrate") + .HasColumnType("INTEGER"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UnratedType") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.HasIndex("Path"); + + b.HasIndex("PresentationUniqueKey"); + + b.HasIndex("TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "PresentationUniqueKey"); + + b.HasIndex("Type", "TopParentId", "StartDate"); + + b.HasIndex("Id", "Type", "IsFolder", "IsVirtualItem"); + + b.HasIndex("MediaType", "TopParentId", "IsVirtualItem", "PresentationUniqueKey"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "IsFolder", "IsVirtualItem"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "PresentationUniqueKey", "SortName"); + + b.HasIndex("IsFolder", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.HasIndex("Type", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.ToTable("BaseItems"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemImageInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Blurhash") + .HasColumnType("BLOB"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("ImageType") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemImageInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemMetadataField", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemMetadataFields"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemProvider", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("ProviderValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ProviderId"); + + b.HasIndex("ProviderId", "ProviderValue", "ItemId"); + + b.ToTable("BaseItemProviders"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemTrailerType", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemTrailerTypes"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Chapter", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ChapterIndex") + .HasColumnType("INTEGER"); + + b.Property("ImageDateModified") + .HasColumnType("TEXT"); + + b.Property("ImagePath") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("StartPositionTicks") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "ChapterIndex"); + + b.ToTable("Chapters"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.CustomItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client", "Key") + .IsUnique(); + + b.ToTable("CustomItemDisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChromecastVersion") + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DashboardTheme") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("EnableNextVideoInfoOverlay") + .HasColumnType("INTEGER"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ScrollDirection") + .HasColumnType("INTEGER"); + + b.Property("ShowBackdrop") + .HasColumnType("INTEGER"); + + b.Property("ShowSidebar") + .HasColumnType("INTEGER"); + + b.Property("SkipBackwardLength") + .HasColumnType("INTEGER"); + + b.Property("SkipForwardLength") + .HasColumnType("INTEGER"); + + b.Property("TvHome") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client") + .IsUnique(); + + b.ToTable("DisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DisplayPreferencesId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DisplayPreferencesId"); + + b.ToTable("HomeSection"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("ImageInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("RememberIndexing") + .HasColumnType("INTEGER"); + + b.Property("RememberSorting") + .HasColumnType("INTEGER"); + + b.Property("SortBy") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("ViewType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("ItemDisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemValue", b => + { + b.Property("ItemValueId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CleanValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId"); + + b.HasIndex("Type", "CleanValue"); + + b.ToTable("ItemValues"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemValueMap", b => + { + b.Property("ItemValueId") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("ItemValuesMap"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.MediaSegment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EndTicks") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("SegmentProviderId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StartTicks") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("MediaSegments"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.MediaStreamInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("StreamIndex") + .HasColumnType("INTEGER"); + + b.Property("AspectRatio") + .HasColumnType("TEXT"); + + b.Property("AverageFrameRate") + .HasColumnType("REAL"); + + b.Property("BitDepth") + .HasColumnType("INTEGER"); + + b.Property("BitRate") + .HasColumnType("INTEGER"); + + b.Property("BlPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("ChannelLayout") + .HasColumnType("TEXT"); + + b.Property("Channels") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasColumnType("TEXT"); + + b.Property("CodecTag") + .HasColumnType("TEXT"); + + b.Property("CodecTimeBase") + .HasColumnType("TEXT"); + + b.Property("ColorPrimaries") + .HasColumnType("TEXT"); + + b.Property("ColorSpace") + .HasColumnType("TEXT"); + + b.Property("ColorTransfer") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("DvBlSignalCompatibilityId") + .HasColumnType("INTEGER"); + + b.Property("DvLevel") + .HasColumnType("INTEGER"); + + b.Property("DvProfile") + .HasColumnType("INTEGER"); + + b.Property("DvVersionMajor") + .HasColumnType("INTEGER"); + + b.Property("DvVersionMinor") + .HasColumnType("INTEGER"); + + b.Property("ElPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("IsAnamorphic") + .HasColumnType("INTEGER"); + + b.Property("IsAvc") + .HasColumnType("INTEGER"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("IsExternal") + .HasColumnType("INTEGER"); + + b.Property("IsForced") + .HasColumnType("INTEGER"); + + b.Property("IsHearingImpaired") + .HasColumnType("INTEGER"); + + b.Property("IsInterlaced") + .HasColumnType("INTEGER"); + + b.Property("KeyFrames") + .HasColumnType("TEXT"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("REAL"); + + b.Property("NalLengthSize") + .HasColumnType("TEXT"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PixelFormat") + .HasColumnType("TEXT"); + + b.Property("Profile") + .HasColumnType("TEXT"); + + b.Property("RealFrameRate") + .HasColumnType("REAL"); + + b.Property("RefFrames") + .HasColumnType("INTEGER"); + + b.Property("Rotation") + .HasColumnType("INTEGER"); + + b.Property("RpuPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("SampleRate") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .HasColumnType("INTEGER"); + + b.Property("TimeBase") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "StreamIndex"); + + b.HasIndex("StreamIndex"); + + b.HasIndex("StreamType"); + + b.HasIndex("StreamIndex", "StreamType"); + + b.HasIndex("StreamIndex", "StreamType", "Language"); + + b.ToTable("MediaStreamInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.People", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PersonType") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("Peoples"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.PeopleBaseItemMap", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("PeopleId") + .HasColumnType("TEXT"); + + b.Property("ListOrder") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.HasIndex("ItemId", "ListOrder"); + + b.HasIndex("ItemId", "SortOrder"); + + b.ToTable("PeopleBaseItemMap"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Permission_Permissions_Guid") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Permissions"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Preference_Preferences_Guid") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Preferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccessToken") + .IsUnique(); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AppName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AppVersion") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("AccessToken", "DateLastActivity"); + + b.HasIndex("DeviceId", "DateLastActivity"); + + b.HasIndex("UserId", "DeviceId"); + + b.ToTable("Devices"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.DeviceOptions", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CustomName") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId") + .IsUnique(); + + b.ToTable("DeviceOptions"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.TrickplayInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.Property("Bandwidth") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Interval") + .HasColumnType("INTEGER"); + + b.Property("ThumbnailCount") + .HasColumnType("INTEGER"); + + b.Property("TileHeight") + .HasColumnType("INTEGER"); + + b.Property("TileWidth") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "Width"); + + b.ToTable("TrickplayInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AudioLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("AuthenticationProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("CastReceiverId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DisplayCollectionsView") + .HasColumnType("INTEGER"); + + b.Property("DisplayMissingEpisodes") + .HasColumnType("INTEGER"); + + b.Property("EnableAutoLogin") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalPassword") + .HasColumnType("INTEGER"); + + b.Property("EnableNextEpisodeAutoPlay") + .HasColumnType("INTEGER"); + + b.Property("EnableUserPreferenceAccess") + .HasColumnType("INTEGER"); + + b.Property("HidePlayedInLatest") + .HasColumnType("INTEGER"); + + b.Property("InternalId") + .HasColumnType("INTEGER"); + + b.Property("InvalidLoginAttemptCount") + .HasColumnType("INTEGER"); + + b.Property("LastActivityDate") + .HasColumnType("TEXT"); + + b.Property("LastLoginDate") + .HasColumnType("TEXT"); + + b.Property("LoginAttemptsBeforeLockout") + .HasColumnType("INTEGER"); + + b.Property("MaxActiveSessions") + .HasColumnType("INTEGER"); + + b.Property("MaxParentalAgeRating") + .HasColumnType("INTEGER"); + + b.Property("MustUpdatePassword") + .HasColumnType("INTEGER"); + + b.Property("Password") + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.Property("PasswordResetProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("PlayDefaultAudioTrack") + .HasColumnType("INTEGER"); + + b.Property("RememberAudioSelections") + .HasColumnType("INTEGER"); + + b.Property("RememberSubtitleSelections") + .HasColumnType("INTEGER"); + + b.Property("RemoteClientBitrateLimit") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SubtitleLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("SubtitleMode") + .HasColumnType("INTEGER"); + + b.Property("SyncPlayAccess") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.UserData", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("CustomDataKey") + .HasColumnType("TEXT"); + + b.Property("AudioStreamIndex") + .HasColumnType("INTEGER"); + + b.Property("IsFavorite") + .HasColumnType("INTEGER"); + + b.Property("LastPlayedDate") + .HasColumnType("TEXT"); + + b.Property("Likes") + .HasColumnType("INTEGER"); + + b.Property("PlayCount") + .HasColumnType("INTEGER"); + + b.Property("PlaybackPositionTicks") + .HasColumnType("INTEGER"); + + b.Property("Played") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("SubtitleStreamIndex") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "UserId", "CustomDataKey"); + + b.HasIndex("UserId"); + + b.HasIndex("ItemId", "UserId", "IsFavorite"); + + b.HasIndex("ItemId", "UserId", "LastPlayedDate"); + + b.HasIndex("ItemId", "UserId", "PlaybackPositionTicks"); + + b.HasIndex("ItemId", "UserId", "Played"); + + b.ToTable("UserData"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("AccessSchedules") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AncestorId", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("Children") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "ParentItem") + .WithMany("ParentAncestors") + .HasForeignKey("ParentItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ParentItem"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AttachmentStreamInfo", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemImageInfo", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("Images") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemMetadataField", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("LockedFields") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemProvider", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("Provider") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemTrailerType", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("TrailerTypes") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Chapter", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("Chapters") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("DisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b => + { + b.HasOne("Jellyfin.Data.Entities.DisplayPreferences", null) + .WithMany("HomeSections") + .HasForeignKey("DisplayPreferencesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithOne("ProfileImage") + .HasForeignKey("Jellyfin.Data.Entities.ImageInfo", "UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("ItemDisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemValueMap", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("ItemValues") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Data.Entities.ItemValue", "ItemValue") + .WithMany("BaseItemsMap") + .HasForeignKey("ItemValueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ItemValue"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.MediaStreamInfo", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("MediaStreams") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.PeopleBaseItemMap", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("Peoples") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Data.Entities.People", "People") + .WithMany("BaseItems") + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("People"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("Permissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("Preferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.Device", b => + { + b.HasOne("Jellyfin.Data.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.UserData", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("UserData") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Data.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemEntity", b => + { + b.Navigation("Chapters"); + + b.Navigation("Children"); + + b.Navigation("Images"); + + b.Navigation("ItemValues"); + + b.Navigation("LockedFields"); + + b.Navigation("MediaStreams"); + + b.Navigation("ParentAncestors"); + + b.Navigation("Peoples"); + + b.Navigation("Provider"); + + b.Navigation("TrailerTypes"); + + b.Navigation("UserData"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.Navigation("HomeSections"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemValue", b => + { + b.Navigation("BaseItemsMap"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.People", b => + { + b.Navigation("BaseItems"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.User", b => + { + b.Navigation("AccessSchedules"); + + b.Navigation("DisplayPreferences"); + + b.Navigation("ItemDisplayPreferences"); + + b.Navigation("Permissions"); + + b.Navigation("Preferences"); + + b.Navigation("ProfileImage"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241112234144_FixMediaStreams2.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241112234144_FixMediaStreams2.cs new file mode 100644 index 0000000000..78611b9e4c --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241112234144_FixMediaStreams2.cs @@ -0,0 +1,144 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + /// + public partial class FixMediaStreams2 : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Profile", + table: "MediaStreamInfos", + type: "TEXT", + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT"); + + migrationBuilder.AlterColumn( + name: "Path", + table: "MediaStreamInfos", + type: "TEXT", + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT"); + + migrationBuilder.AlterColumn( + name: "Language", + table: "MediaStreamInfos", + type: "TEXT", + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT"); + + migrationBuilder.AlterColumn( + name: "IsInterlaced", + table: "MediaStreamInfos", + type: "INTEGER", + nullable: true, + oldClrType: typeof(bool), + oldType: "INTEGER"); + + migrationBuilder.AlterColumn( + name: "Codec", + table: "MediaStreamInfos", + type: "TEXT", + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT"); + + migrationBuilder.AlterColumn( + name: "ChannelLayout", + table: "MediaStreamInfos", + type: "TEXT", + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT"); + + migrationBuilder.AlterColumn( + name: "AspectRatio", + table: "MediaStreamInfos", + type: "TEXT", + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Profile", + table: "MediaStreamInfos", + type: "TEXT", + nullable: false, + defaultValue: string.Empty, + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Path", + table: "MediaStreamInfos", + type: "TEXT", + nullable: false, + defaultValue: string.Empty, + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Language", + table: "MediaStreamInfos", + type: "TEXT", + nullable: false, + defaultValue: string.Empty, + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "IsInterlaced", + table: "MediaStreamInfos", + type: "INTEGER", + nullable: false, + defaultValue: false, + oldClrType: typeof(bool), + oldType: "INTEGER", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Codec", + table: "MediaStreamInfos", + type: "TEXT", + nullable: false, + defaultValue: string.Empty, + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "ChannelLayout", + table: "MediaStreamInfos", + type: "TEXT", + nullable: false, + defaultValue: string.Empty, + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "AspectRatio", + table: "MediaStreamInfos", + type: "TEXT", + nullable: false, + defaultValue: string.Empty, + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true); + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241113133548_EnforceUniqueItemValue.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241113133548_EnforceUniqueItemValue.Designer.cs new file mode 100644 index 0000000000..2c0058c720 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241113133548_EnforceUniqueItemValue.Designer.cs @@ -0,0 +1,1595 @@ +// +using System; +using Jellyfin.Database.Implementations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + [DbContext(typeof(JellyfinDbContext))] + [Migration("20241113133548_EnforceUniqueItemValue")] + partial class EnforceUniqueItemValue + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.10"); + + modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property("EndHour") + .HasColumnType("REAL"); + + b.Property("StartHour") + .HasColumnType("REAL"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AccessSchedules"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ActivityLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LogSeverity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ShortOverview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DateCreated"); + + b.ToTable("ActivityLogs"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AncestorId", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ParentItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ParentItemId"); + + b.HasIndex("ParentItemId"); + + b.ToTable("AncestorIds"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AttachmentStreamInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CodecTag") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Filename") + .HasColumnType("TEXT"); + + b.Property("MimeType") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "Index"); + + b.ToTable("AttachmentStreamInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Album") + .HasColumnType("TEXT"); + + b.Property("AlbumArtists") + .HasColumnType("TEXT"); + + b.Property("Artists") + .HasColumnType("TEXT"); + + b.Property("Audio") + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("CleanName") + .HasColumnType("TEXT"); + + b.Property("CommunityRating") + .HasColumnType("REAL"); + + b.Property("CriticRating") + .HasColumnType("REAL"); + + b.Property("CustomRating") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastMediaAdded") + .HasColumnType("TEXT"); + + b.Property("DateLastRefreshed") + .HasColumnType("TEXT"); + + b.Property("DateLastSaved") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("EpisodeTitle") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasColumnType("TEXT"); + + b.Property("ExternalSeriesId") + .HasColumnType("TEXT"); + + b.Property("ExternalServiceId") + .HasColumnType("TEXT"); + + b.Property("ExtraIds") + .HasColumnType("TEXT"); + + b.Property("ExtraType") + .HasColumnType("INTEGER"); + + b.Property("ForcedSortName") + .HasColumnType("TEXT"); + + b.Property("Genres") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("IndexNumber") + .HasColumnType("INTEGER"); + + b.Property("InheritedParentalRatingValue") + .HasColumnType("INTEGER"); + + b.Property("IsFolder") + .HasColumnType("INTEGER"); + + b.Property("IsInMixedFolder") + .HasColumnType("INTEGER"); + + b.Property("IsLocked") + .HasColumnType("INTEGER"); + + b.Property("IsMovie") + .HasColumnType("INTEGER"); + + b.Property("IsRepeat") + .HasColumnType("INTEGER"); + + b.Property("IsSeries") + .HasColumnType("INTEGER"); + + b.Property("IsVirtualItem") + .HasColumnType("INTEGER"); + + b.Property("LUFS") + .HasColumnType("REAL"); + + b.Property("MediaType") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizationGain") + .HasColumnType("REAL"); + + b.Property("OfficialRating") + .HasColumnType("TEXT"); + + b.Property("OriginalTitle") + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("OwnerId") + .HasColumnType("TEXT"); + + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("ParentIndexNumber") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PreferredMetadataCountryCode") + .HasColumnType("TEXT"); + + b.Property("PreferredMetadataLanguage") + .HasColumnType("TEXT"); + + b.Property("PremiereDate") + .HasColumnType("TEXT"); + + b.Property("PresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property("PrimaryVersionId") + .HasColumnType("TEXT"); + + b.Property("ProductionLocations") + .HasColumnType("TEXT"); + + b.Property("ProductionYear") + .HasColumnType("INTEGER"); + + b.Property("RunTimeTicks") + .HasColumnType("INTEGER"); + + b.Property("SeasonId") + .HasColumnType("TEXT"); + + b.Property("SeasonName") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("SeriesName") + .HasColumnType("TEXT"); + + b.Property("SeriesPresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property("ShowId") + .HasColumnType("TEXT"); + + b.Property("Size") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("Studios") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("TopParentId") + .HasColumnType("TEXT"); + + b.Property("TotalBitrate") + .HasColumnType("INTEGER"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UnratedType") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.HasIndex("Path"); + + b.HasIndex("PresentationUniqueKey"); + + b.HasIndex("TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "PresentationUniqueKey"); + + b.HasIndex("Type", "TopParentId", "StartDate"); + + b.HasIndex("Id", "Type", "IsFolder", "IsVirtualItem"); + + b.HasIndex("MediaType", "TopParentId", "IsVirtualItem", "PresentationUniqueKey"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "IsFolder", "IsVirtualItem"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "PresentationUniqueKey", "SortName"); + + b.HasIndex("IsFolder", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.HasIndex("Type", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.ToTable("BaseItems"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemImageInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Blurhash") + .HasColumnType("BLOB"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("ImageType") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemImageInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemMetadataField", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemMetadataFields"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemProvider", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("ProviderValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ProviderId"); + + b.HasIndex("ProviderId", "ProviderValue", "ItemId"); + + b.ToTable("BaseItemProviders"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemTrailerType", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemTrailerTypes"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Chapter", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ChapterIndex") + .HasColumnType("INTEGER"); + + b.Property("ImageDateModified") + .HasColumnType("TEXT"); + + b.Property("ImagePath") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("StartPositionTicks") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "ChapterIndex"); + + b.ToTable("Chapters"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.CustomItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client", "Key") + .IsUnique(); + + b.ToTable("CustomItemDisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChromecastVersion") + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DashboardTheme") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("EnableNextVideoInfoOverlay") + .HasColumnType("INTEGER"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ScrollDirection") + .HasColumnType("INTEGER"); + + b.Property("ShowBackdrop") + .HasColumnType("INTEGER"); + + b.Property("ShowSidebar") + .HasColumnType("INTEGER"); + + b.Property("SkipBackwardLength") + .HasColumnType("INTEGER"); + + b.Property("SkipForwardLength") + .HasColumnType("INTEGER"); + + b.Property("TvHome") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client") + .IsUnique(); + + b.ToTable("DisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DisplayPreferencesId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DisplayPreferencesId"); + + b.ToTable("HomeSection"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("ImageInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("RememberIndexing") + .HasColumnType("INTEGER"); + + b.Property("RememberSorting") + .HasColumnType("INTEGER"); + + b.Property("SortBy") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("ViewType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("ItemDisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemValue", b => + { + b.Property("ItemValueId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CleanValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId"); + + b.HasIndex("Type", "CleanValue") + .IsUnique(); + + b.ToTable("ItemValues"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemValueMap", b => + { + b.Property("ItemValueId") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("ItemValuesMap"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.MediaSegment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EndTicks") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("SegmentProviderId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StartTicks") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("MediaSegments"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.MediaStreamInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("StreamIndex") + .HasColumnType("INTEGER"); + + b.Property("AspectRatio") + .HasColumnType("TEXT"); + + b.Property("AverageFrameRate") + .HasColumnType("REAL"); + + b.Property("BitDepth") + .HasColumnType("INTEGER"); + + b.Property("BitRate") + .HasColumnType("INTEGER"); + + b.Property("BlPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("ChannelLayout") + .HasColumnType("TEXT"); + + b.Property("Channels") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasColumnType("TEXT"); + + b.Property("CodecTag") + .HasColumnType("TEXT"); + + b.Property("CodecTimeBase") + .HasColumnType("TEXT"); + + b.Property("ColorPrimaries") + .HasColumnType("TEXT"); + + b.Property("ColorSpace") + .HasColumnType("TEXT"); + + b.Property("ColorTransfer") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("DvBlSignalCompatibilityId") + .HasColumnType("INTEGER"); + + b.Property("DvLevel") + .HasColumnType("INTEGER"); + + b.Property("DvProfile") + .HasColumnType("INTEGER"); + + b.Property("DvVersionMajor") + .HasColumnType("INTEGER"); + + b.Property("DvVersionMinor") + .HasColumnType("INTEGER"); + + b.Property("ElPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("IsAnamorphic") + .HasColumnType("INTEGER"); + + b.Property("IsAvc") + .HasColumnType("INTEGER"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("IsExternal") + .HasColumnType("INTEGER"); + + b.Property("IsForced") + .HasColumnType("INTEGER"); + + b.Property("IsHearingImpaired") + .HasColumnType("INTEGER"); + + b.Property("IsInterlaced") + .HasColumnType("INTEGER"); + + b.Property("KeyFrames") + .HasColumnType("TEXT"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("REAL"); + + b.Property("NalLengthSize") + .HasColumnType("TEXT"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PixelFormat") + .HasColumnType("TEXT"); + + b.Property("Profile") + .HasColumnType("TEXT"); + + b.Property("RealFrameRate") + .HasColumnType("REAL"); + + b.Property("RefFrames") + .HasColumnType("INTEGER"); + + b.Property("Rotation") + .HasColumnType("INTEGER"); + + b.Property("RpuPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("SampleRate") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .HasColumnType("INTEGER"); + + b.Property("TimeBase") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "StreamIndex"); + + b.HasIndex("StreamIndex"); + + b.HasIndex("StreamType"); + + b.HasIndex("StreamIndex", "StreamType"); + + b.HasIndex("StreamIndex", "StreamType", "Language"); + + b.ToTable("MediaStreamInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.People", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PersonType") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("Peoples"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.PeopleBaseItemMap", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("PeopleId") + .HasColumnType("TEXT"); + + b.Property("ListOrder") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.HasIndex("ItemId", "ListOrder"); + + b.HasIndex("ItemId", "SortOrder"); + + b.ToTable("PeopleBaseItemMap"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Permission_Permissions_Guid") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Permissions"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Preference_Preferences_Guid") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Preferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccessToken") + .IsUnique(); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AppName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AppVersion") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("AccessToken", "DateLastActivity"); + + b.HasIndex("DeviceId", "DateLastActivity"); + + b.HasIndex("UserId", "DeviceId"); + + b.ToTable("Devices"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.DeviceOptions", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CustomName") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId") + .IsUnique(); + + b.ToTable("DeviceOptions"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.TrickplayInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.Property("Bandwidth") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Interval") + .HasColumnType("INTEGER"); + + b.Property("ThumbnailCount") + .HasColumnType("INTEGER"); + + b.Property("TileHeight") + .HasColumnType("INTEGER"); + + b.Property("TileWidth") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "Width"); + + b.ToTable("TrickplayInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AudioLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("AuthenticationProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("CastReceiverId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DisplayCollectionsView") + .HasColumnType("INTEGER"); + + b.Property("DisplayMissingEpisodes") + .HasColumnType("INTEGER"); + + b.Property("EnableAutoLogin") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalPassword") + .HasColumnType("INTEGER"); + + b.Property("EnableNextEpisodeAutoPlay") + .HasColumnType("INTEGER"); + + b.Property("EnableUserPreferenceAccess") + .HasColumnType("INTEGER"); + + b.Property("HidePlayedInLatest") + .HasColumnType("INTEGER"); + + b.Property("InternalId") + .HasColumnType("INTEGER"); + + b.Property("InvalidLoginAttemptCount") + .HasColumnType("INTEGER"); + + b.Property("LastActivityDate") + .HasColumnType("TEXT"); + + b.Property("LastLoginDate") + .HasColumnType("TEXT"); + + b.Property("LoginAttemptsBeforeLockout") + .HasColumnType("INTEGER"); + + b.Property("MaxActiveSessions") + .HasColumnType("INTEGER"); + + b.Property("MaxParentalAgeRating") + .HasColumnType("INTEGER"); + + b.Property("MustUpdatePassword") + .HasColumnType("INTEGER"); + + b.Property("Password") + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.Property("PasswordResetProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("PlayDefaultAudioTrack") + .HasColumnType("INTEGER"); + + b.Property("RememberAudioSelections") + .HasColumnType("INTEGER"); + + b.Property("RememberSubtitleSelections") + .HasColumnType("INTEGER"); + + b.Property("RemoteClientBitrateLimit") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SubtitleLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("SubtitleMode") + .HasColumnType("INTEGER"); + + b.Property("SyncPlayAccess") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.UserData", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("CustomDataKey") + .HasColumnType("TEXT"); + + b.Property("AudioStreamIndex") + .HasColumnType("INTEGER"); + + b.Property("IsFavorite") + .HasColumnType("INTEGER"); + + b.Property("LastPlayedDate") + .HasColumnType("TEXT"); + + b.Property("Likes") + .HasColumnType("INTEGER"); + + b.Property("PlayCount") + .HasColumnType("INTEGER"); + + b.Property("PlaybackPositionTicks") + .HasColumnType("INTEGER"); + + b.Property("Played") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("SubtitleStreamIndex") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "UserId", "CustomDataKey"); + + b.HasIndex("UserId"); + + b.HasIndex("ItemId", "UserId", "IsFavorite"); + + b.HasIndex("ItemId", "UserId", "LastPlayedDate"); + + b.HasIndex("ItemId", "UserId", "PlaybackPositionTicks"); + + b.HasIndex("ItemId", "UserId", "Played"); + + b.ToTable("UserData"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("AccessSchedules") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AncestorId", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("Children") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "ParentItem") + .WithMany("ParentAncestors") + .HasForeignKey("ParentItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ParentItem"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AttachmentStreamInfo", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemImageInfo", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("Images") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemMetadataField", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("LockedFields") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemProvider", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("Provider") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemTrailerType", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("TrailerTypes") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Chapter", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("Chapters") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("DisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b => + { + b.HasOne("Jellyfin.Data.Entities.DisplayPreferences", null) + .WithMany("HomeSections") + .HasForeignKey("DisplayPreferencesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithOne("ProfileImage") + .HasForeignKey("Jellyfin.Data.Entities.ImageInfo", "UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("ItemDisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemValueMap", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("ItemValues") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Data.Entities.ItemValue", "ItemValue") + .WithMany("BaseItemsMap") + .HasForeignKey("ItemValueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ItemValue"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.MediaStreamInfo", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("MediaStreams") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.PeopleBaseItemMap", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("Peoples") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Data.Entities.People", "People") + .WithMany("BaseItems") + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("People"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("Permissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("Preferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.Device", b => + { + b.HasOne("Jellyfin.Data.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.UserData", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("UserData") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Data.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemEntity", b => + { + b.Navigation("Chapters"); + + b.Navigation("Children"); + + b.Navigation("Images"); + + b.Navigation("ItemValues"); + + b.Navigation("LockedFields"); + + b.Navigation("MediaStreams"); + + b.Navigation("ParentAncestors"); + + b.Navigation("Peoples"); + + b.Navigation("Provider"); + + b.Navigation("TrailerTypes"); + + b.Navigation("UserData"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.Navigation("HomeSections"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemValue", b => + { + b.Navigation("BaseItemsMap"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.People", b => + { + b.Navigation("BaseItems"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.User", b => + { + b.Navigation("AccessSchedules"); + + b.Navigation("DisplayPreferences"); + + b.Navigation("ItemDisplayPreferences"); + + b.Navigation("Permissions"); + + b.Navigation("Preferences"); + + b.Navigation("ProfileImage"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241113133548_EnforceUniqueItemValue.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241113133548_EnforceUniqueItemValue.cs new file mode 100644 index 0000000000..d1b06ceaec --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241113133548_EnforceUniqueItemValue.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + /// + public partial class EnforceUniqueItemValue : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_ItemValues_Type_CleanValue", + table: "ItemValues"); + + migrationBuilder.CreateIndex( + name: "IX_ItemValues_Type_CleanValue", + table: "ItemValues", + columns: new[] { "Type", "CleanValue" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_ItemValues_Type_CleanValue", + table: "ItemValues"); + + migrationBuilder.CreateIndex( + name: "IX_ItemValues_Type_CleanValue", + table: "ItemValues", + columns: new[] { "Type", "CleanValue" }); + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250202021306_FixedCollation.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250202021306_FixedCollation.Designer.cs new file mode 100644 index 0000000000..da4bab3fd3 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250202021306_FixedCollation.Designer.cs @@ -0,0 +1,1594 @@ +// +using System; +using Jellyfin.Database.Implementations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + [DbContext(typeof(JellyfinDbContext))] + [Migration("20250202021306_FixedCollation")] + partial class FixedCollation + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.1"); + + modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property("EndHour") + .HasColumnType("REAL"); + + b.Property("StartHour") + .HasColumnType("REAL"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AccessSchedules"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ActivityLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LogSeverity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ShortOverview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DateCreated"); + + b.ToTable("ActivityLogs"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AncestorId", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ParentItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ParentItemId"); + + b.HasIndex("ParentItemId"); + + b.ToTable("AncestorIds"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AttachmentStreamInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CodecTag") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Filename") + .HasColumnType("TEXT"); + + b.Property("MimeType") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "Index"); + + b.ToTable("AttachmentStreamInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Album") + .HasColumnType("TEXT"); + + b.Property("AlbumArtists") + .HasColumnType("TEXT"); + + b.Property("Artists") + .HasColumnType("TEXT"); + + b.Property("Audio") + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("CleanName") + .HasColumnType("TEXT"); + + b.Property("CommunityRating") + .HasColumnType("REAL"); + + b.Property("CriticRating") + .HasColumnType("REAL"); + + b.Property("CustomRating") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastMediaAdded") + .HasColumnType("TEXT"); + + b.Property("DateLastRefreshed") + .HasColumnType("TEXT"); + + b.Property("DateLastSaved") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("EpisodeTitle") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasColumnType("TEXT"); + + b.Property("ExternalSeriesId") + .HasColumnType("TEXT"); + + b.Property("ExternalServiceId") + .HasColumnType("TEXT"); + + b.Property("ExtraIds") + .HasColumnType("TEXT"); + + b.Property("ExtraType") + .HasColumnType("INTEGER"); + + b.Property("ForcedSortName") + .HasColumnType("TEXT"); + + b.Property("Genres") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("IndexNumber") + .HasColumnType("INTEGER"); + + b.Property("InheritedParentalRatingValue") + .HasColumnType("INTEGER"); + + b.Property("IsFolder") + .HasColumnType("INTEGER"); + + b.Property("IsInMixedFolder") + .HasColumnType("INTEGER"); + + b.Property("IsLocked") + .HasColumnType("INTEGER"); + + b.Property("IsMovie") + .HasColumnType("INTEGER"); + + b.Property("IsRepeat") + .HasColumnType("INTEGER"); + + b.Property("IsSeries") + .HasColumnType("INTEGER"); + + b.Property("IsVirtualItem") + .HasColumnType("INTEGER"); + + b.Property("LUFS") + .HasColumnType("REAL"); + + b.Property("MediaType") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizationGain") + .HasColumnType("REAL"); + + b.Property("OfficialRating") + .HasColumnType("TEXT"); + + b.Property("OriginalTitle") + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("OwnerId") + .HasColumnType("TEXT"); + + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("ParentIndexNumber") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PreferredMetadataCountryCode") + .HasColumnType("TEXT"); + + b.Property("PreferredMetadataLanguage") + .HasColumnType("TEXT"); + + b.Property("PremiereDate") + .HasColumnType("TEXT"); + + b.Property("PresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property("PrimaryVersionId") + .HasColumnType("TEXT"); + + b.Property("ProductionLocations") + .HasColumnType("TEXT"); + + b.Property("ProductionYear") + .HasColumnType("INTEGER"); + + b.Property("RunTimeTicks") + .HasColumnType("INTEGER"); + + b.Property("SeasonId") + .HasColumnType("TEXT"); + + b.Property("SeasonName") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("SeriesName") + .HasColumnType("TEXT"); + + b.Property("SeriesPresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property("ShowId") + .HasColumnType("TEXT"); + + b.Property("Size") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("Studios") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("TopParentId") + .HasColumnType("TEXT"); + + b.Property("TotalBitrate") + .HasColumnType("INTEGER"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UnratedType") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.HasIndex("Path"); + + b.HasIndex("PresentationUniqueKey"); + + b.HasIndex("TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "PresentationUniqueKey"); + + b.HasIndex("Type", "TopParentId", "StartDate"); + + b.HasIndex("Id", "Type", "IsFolder", "IsVirtualItem"); + + b.HasIndex("MediaType", "TopParentId", "IsVirtualItem", "PresentationUniqueKey"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "IsFolder", "IsVirtualItem"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "PresentationUniqueKey", "SortName"); + + b.HasIndex("IsFolder", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.HasIndex("Type", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.ToTable("BaseItems"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemImageInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Blurhash") + .HasColumnType("BLOB"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("ImageType") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemImageInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemMetadataField", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemMetadataFields"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemProvider", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("ProviderValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ProviderId"); + + b.HasIndex("ProviderId", "ProviderValue", "ItemId"); + + b.ToTable("BaseItemProviders"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemTrailerType", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemTrailerTypes"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Chapter", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ChapterIndex") + .HasColumnType("INTEGER"); + + b.Property("ImageDateModified") + .HasColumnType("TEXT"); + + b.Property("ImagePath") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("StartPositionTicks") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "ChapterIndex"); + + b.ToTable("Chapters"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.CustomItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client", "Key") + .IsUnique(); + + b.ToTable("CustomItemDisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChromecastVersion") + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DashboardTheme") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("EnableNextVideoInfoOverlay") + .HasColumnType("INTEGER"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ScrollDirection") + .HasColumnType("INTEGER"); + + b.Property("ShowBackdrop") + .HasColumnType("INTEGER"); + + b.Property("ShowSidebar") + .HasColumnType("INTEGER"); + + b.Property("SkipBackwardLength") + .HasColumnType("INTEGER"); + + b.Property("SkipForwardLength") + .HasColumnType("INTEGER"); + + b.Property("TvHome") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client") + .IsUnique(); + + b.ToTable("DisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DisplayPreferencesId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DisplayPreferencesId"); + + b.ToTable("HomeSection"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("ImageInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("RememberIndexing") + .HasColumnType("INTEGER"); + + b.Property("RememberSorting") + .HasColumnType("INTEGER"); + + b.Property("SortBy") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("ViewType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("ItemDisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemValue", b => + { + b.Property("ItemValueId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CleanValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId"); + + b.HasIndex("Type", "CleanValue") + .IsUnique(); + + b.ToTable("ItemValues"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemValueMap", b => + { + b.Property("ItemValueId") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("ItemValuesMap"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.MediaSegment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EndTicks") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("SegmentProviderId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StartTicks") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("MediaSegments"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.MediaStreamInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("StreamIndex") + .HasColumnType("INTEGER"); + + b.Property("AspectRatio") + .HasColumnType("TEXT"); + + b.Property("AverageFrameRate") + .HasColumnType("REAL"); + + b.Property("BitDepth") + .HasColumnType("INTEGER"); + + b.Property("BitRate") + .HasColumnType("INTEGER"); + + b.Property("BlPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("ChannelLayout") + .HasColumnType("TEXT"); + + b.Property("Channels") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasColumnType("TEXT"); + + b.Property("CodecTag") + .HasColumnType("TEXT"); + + b.Property("CodecTimeBase") + .HasColumnType("TEXT"); + + b.Property("ColorPrimaries") + .HasColumnType("TEXT"); + + b.Property("ColorSpace") + .HasColumnType("TEXT"); + + b.Property("ColorTransfer") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("DvBlSignalCompatibilityId") + .HasColumnType("INTEGER"); + + b.Property("DvLevel") + .HasColumnType("INTEGER"); + + b.Property("DvProfile") + .HasColumnType("INTEGER"); + + b.Property("DvVersionMajor") + .HasColumnType("INTEGER"); + + b.Property("DvVersionMinor") + .HasColumnType("INTEGER"); + + b.Property("ElPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("IsAnamorphic") + .HasColumnType("INTEGER"); + + b.Property("IsAvc") + .HasColumnType("INTEGER"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("IsExternal") + .HasColumnType("INTEGER"); + + b.Property("IsForced") + .HasColumnType("INTEGER"); + + b.Property("IsHearingImpaired") + .HasColumnType("INTEGER"); + + b.Property("IsInterlaced") + .HasColumnType("INTEGER"); + + b.Property("KeyFrames") + .HasColumnType("TEXT"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("REAL"); + + b.Property("NalLengthSize") + .HasColumnType("TEXT"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PixelFormat") + .HasColumnType("TEXT"); + + b.Property("Profile") + .HasColumnType("TEXT"); + + b.Property("RealFrameRate") + .HasColumnType("REAL"); + + b.Property("RefFrames") + .HasColumnType("INTEGER"); + + b.Property("Rotation") + .HasColumnType("INTEGER"); + + b.Property("RpuPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("SampleRate") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .HasColumnType("INTEGER"); + + b.Property("TimeBase") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "StreamIndex"); + + b.HasIndex("StreamIndex"); + + b.HasIndex("StreamType"); + + b.HasIndex("StreamIndex", "StreamType"); + + b.HasIndex("StreamIndex", "StreamType", "Language"); + + b.ToTable("MediaStreamInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.People", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PersonType") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("Peoples"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.PeopleBaseItemMap", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("PeopleId") + .HasColumnType("TEXT"); + + b.Property("ListOrder") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.HasIndex("ItemId", "ListOrder"); + + b.HasIndex("ItemId", "SortOrder"); + + b.ToTable("PeopleBaseItemMap"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Permission_Permissions_Guid") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Permissions"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Preference_Preferences_Guid") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Preferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccessToken") + .IsUnique(); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AppName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AppVersion") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("AccessToken", "DateLastActivity"); + + b.HasIndex("DeviceId", "DateLastActivity"); + + b.HasIndex("UserId", "DeviceId"); + + b.ToTable("Devices"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.DeviceOptions", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CustomName") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId") + .IsUnique(); + + b.ToTable("DeviceOptions"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.TrickplayInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.Property("Bandwidth") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Interval") + .HasColumnType("INTEGER"); + + b.Property("ThumbnailCount") + .HasColumnType("INTEGER"); + + b.Property("TileHeight") + .HasColumnType("INTEGER"); + + b.Property("TileWidth") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "Width"); + + b.ToTable("TrickplayInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AudioLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("AuthenticationProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("CastReceiverId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DisplayCollectionsView") + .HasColumnType("INTEGER"); + + b.Property("DisplayMissingEpisodes") + .HasColumnType("INTEGER"); + + b.Property("EnableAutoLogin") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalPassword") + .HasColumnType("INTEGER"); + + b.Property("EnableNextEpisodeAutoPlay") + .HasColumnType("INTEGER"); + + b.Property("EnableUserPreferenceAccess") + .HasColumnType("INTEGER"); + + b.Property("HidePlayedInLatest") + .HasColumnType("INTEGER"); + + b.Property("InternalId") + .HasColumnType("INTEGER"); + + b.Property("InvalidLoginAttemptCount") + .HasColumnType("INTEGER"); + + b.Property("LastActivityDate") + .HasColumnType("TEXT"); + + b.Property("LastLoginDate") + .HasColumnType("TEXT"); + + b.Property("LoginAttemptsBeforeLockout") + .HasColumnType("INTEGER"); + + b.Property("MaxActiveSessions") + .HasColumnType("INTEGER"); + + b.Property("MaxParentalAgeRating") + .HasColumnType("INTEGER"); + + b.Property("MustUpdatePassword") + .HasColumnType("INTEGER"); + + b.Property("Password") + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.Property("PasswordResetProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("PlayDefaultAudioTrack") + .HasColumnType("INTEGER"); + + b.Property("RememberAudioSelections") + .HasColumnType("INTEGER"); + + b.Property("RememberSubtitleSelections") + .HasColumnType("INTEGER"); + + b.Property("RemoteClientBitrateLimit") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SubtitleLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("SubtitleMode") + .HasColumnType("INTEGER"); + + b.Property("SyncPlayAccess") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.UserData", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("CustomDataKey") + .HasColumnType("TEXT"); + + b.Property("AudioStreamIndex") + .HasColumnType("INTEGER"); + + b.Property("IsFavorite") + .HasColumnType("INTEGER"); + + b.Property("LastPlayedDate") + .HasColumnType("TEXT"); + + b.Property("Likes") + .HasColumnType("INTEGER"); + + b.Property("PlayCount") + .HasColumnType("INTEGER"); + + b.Property("PlaybackPositionTicks") + .HasColumnType("INTEGER"); + + b.Property("Played") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("SubtitleStreamIndex") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "UserId", "CustomDataKey"); + + b.HasIndex("UserId"); + + b.HasIndex("ItemId", "UserId", "IsFavorite"); + + b.HasIndex("ItemId", "UserId", "LastPlayedDate"); + + b.HasIndex("ItemId", "UserId", "PlaybackPositionTicks"); + + b.HasIndex("ItemId", "UserId", "Played"); + + b.ToTable("UserData"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("AccessSchedules") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AncestorId", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("Children") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "ParentItem") + .WithMany("ParentAncestors") + .HasForeignKey("ParentItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ParentItem"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AttachmentStreamInfo", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemImageInfo", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("Images") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemMetadataField", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("LockedFields") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemProvider", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("Provider") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemTrailerType", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("TrailerTypes") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Chapter", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("Chapters") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("DisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b => + { + b.HasOne("Jellyfin.Data.Entities.DisplayPreferences", null) + .WithMany("HomeSections") + .HasForeignKey("DisplayPreferencesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithOne("ProfileImage") + .HasForeignKey("Jellyfin.Data.Entities.ImageInfo", "UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("ItemDisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemValueMap", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("ItemValues") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Data.Entities.ItemValue", "ItemValue") + .WithMany("BaseItemsMap") + .HasForeignKey("ItemValueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ItemValue"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.MediaStreamInfo", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("MediaStreams") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.PeopleBaseItemMap", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("Peoples") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Data.Entities.People", "People") + .WithMany("BaseItems") + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("People"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("Permissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("Preferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.Device", b => + { + b.HasOne("Jellyfin.Data.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.UserData", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("UserData") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Data.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemEntity", b => + { + b.Navigation("Chapters"); + + b.Navigation("Children"); + + b.Navigation("Images"); + + b.Navigation("ItemValues"); + + b.Navigation("LockedFields"); + + b.Navigation("MediaStreams"); + + b.Navigation("ParentAncestors"); + + b.Navigation("Peoples"); + + b.Navigation("Provider"); + + b.Navigation("TrailerTypes"); + + b.Navigation("UserData"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.Navigation("HomeSections"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemValue", b => + { + b.Navigation("BaseItemsMap"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.People", b => + { + b.Navigation("BaseItems"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.User", b => + { + b.Navigation("AccessSchedules"); + + b.Navigation("DisplayPreferences"); + + b.Navigation("ItemDisplayPreferences"); + + b.Navigation("Permissions"); + + b.Navigation("Preferences"); + + b.Navigation("ProfileImage"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250202021306_FixedCollation.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250202021306_FixedCollation.cs new file mode 100644 index 0000000000..e82575e418 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250202021306_FixedCollation.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + /// + public partial class FixedCollation : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Username", + table: "Users", + type: "TEXT", + maxLength: 255, + nullable: false, + oldClrType: typeof(string), + oldType: "TEXT", + oldMaxLength: 255, + oldCollation: "NOCASE"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Username", + table: "Users", + type: "TEXT", + maxLength: 255, + nullable: false, + collation: "NOCASE", + oldClrType: typeof(string), + oldType: "TEXT", + oldMaxLength: 255); + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250204092455_MakeStartEndDateNullable.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250204092455_MakeStartEndDateNullable.Designer.cs new file mode 100644 index 0000000000..9b72d9688a --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250204092455_MakeStartEndDateNullable.Designer.cs @@ -0,0 +1,1595 @@ +// +using System; +using Jellyfin.Database.Implementations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + [DbContext(typeof(JellyfinDbContext))] + [Migration("20250204092455_MakeStartEndDateNullable")] + partial class MakeStartEndDateNullable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.1"); + + modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property("EndHour") + .HasColumnType("REAL"); + + b.Property("StartHour") + .HasColumnType("REAL"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AccessSchedules"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ActivityLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LogSeverity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ShortOverview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DateCreated"); + + b.ToTable("ActivityLogs"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AncestorId", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ParentItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ParentItemId"); + + b.HasIndex("ParentItemId"); + + b.ToTable("AncestorIds"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AttachmentStreamInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CodecTag") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Filename") + .HasColumnType("TEXT"); + + b.Property("MimeType") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "Index"); + + b.ToTable("AttachmentStreamInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Album") + .HasColumnType("TEXT"); + + b.Property("AlbumArtists") + .HasColumnType("TEXT"); + + b.Property("Artists") + .HasColumnType("TEXT"); + + b.Property("Audio") + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("CleanName") + .HasColumnType("TEXT"); + + b.Property("CommunityRating") + .HasColumnType("REAL"); + + b.Property("CriticRating") + .HasColumnType("REAL"); + + b.Property("CustomRating") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastMediaAdded") + .HasColumnType("TEXT"); + + b.Property("DateLastRefreshed") + .HasColumnType("TEXT"); + + b.Property("DateLastSaved") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("EpisodeTitle") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasColumnType("TEXT"); + + b.Property("ExternalSeriesId") + .HasColumnType("TEXT"); + + b.Property("ExternalServiceId") + .HasColumnType("TEXT"); + + b.Property("ExtraIds") + .HasColumnType("TEXT"); + + b.Property("ExtraType") + .HasColumnType("INTEGER"); + + b.Property("ForcedSortName") + .HasColumnType("TEXT"); + + b.Property("Genres") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("IndexNumber") + .HasColumnType("INTEGER"); + + b.Property("InheritedParentalRatingValue") + .HasColumnType("INTEGER"); + + b.Property("IsFolder") + .HasColumnType("INTEGER"); + + b.Property("IsInMixedFolder") + .HasColumnType("INTEGER"); + + b.Property("IsLocked") + .HasColumnType("INTEGER"); + + b.Property("IsMovie") + .HasColumnType("INTEGER"); + + b.Property("IsRepeat") + .HasColumnType("INTEGER"); + + b.Property("IsSeries") + .HasColumnType("INTEGER"); + + b.Property("IsVirtualItem") + .HasColumnType("INTEGER"); + + b.Property("LUFS") + .HasColumnType("REAL"); + + b.Property("MediaType") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizationGain") + .HasColumnType("REAL"); + + b.Property("OfficialRating") + .HasColumnType("TEXT"); + + b.Property("OriginalTitle") + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("OwnerId") + .HasColumnType("TEXT"); + + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("ParentIndexNumber") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PreferredMetadataCountryCode") + .HasColumnType("TEXT"); + + b.Property("PreferredMetadataLanguage") + .HasColumnType("TEXT"); + + b.Property("PremiereDate") + .HasColumnType("TEXT"); + + b.Property("PresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property("PrimaryVersionId") + .HasColumnType("TEXT"); + + b.Property("ProductionLocations") + .HasColumnType("TEXT"); + + b.Property("ProductionYear") + .HasColumnType("INTEGER"); + + b.Property("RunTimeTicks") + .HasColumnType("INTEGER"); + + b.Property("SeasonId") + .HasColumnType("TEXT"); + + b.Property("SeasonName") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("SeriesName") + .HasColumnType("TEXT"); + + b.Property("SeriesPresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property("ShowId") + .HasColumnType("TEXT"); + + b.Property("Size") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("Studios") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("TopParentId") + .HasColumnType("TEXT"); + + b.Property("TotalBitrate") + .HasColumnType("INTEGER"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UnratedType") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.HasIndex("Path"); + + b.HasIndex("PresentationUniqueKey"); + + b.HasIndex("TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "PresentationUniqueKey"); + + b.HasIndex("Type", "TopParentId", "StartDate"); + + b.HasIndex("Id", "Type", "IsFolder", "IsVirtualItem"); + + b.HasIndex("MediaType", "TopParentId", "IsVirtualItem", "PresentationUniqueKey"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "IsFolder", "IsVirtualItem"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "PresentationUniqueKey", "SortName"); + + b.HasIndex("IsFolder", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.HasIndex("Type", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.ToTable("BaseItems"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemImageInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Blurhash") + .HasColumnType("BLOB"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("ImageType") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemImageInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemMetadataField", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemMetadataFields"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemProvider", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("ProviderValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ProviderId"); + + b.HasIndex("ProviderId", "ProviderValue", "ItemId"); + + b.ToTable("BaseItemProviders"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemTrailerType", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemTrailerTypes"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Chapter", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ChapterIndex") + .HasColumnType("INTEGER"); + + b.Property("ImageDateModified") + .HasColumnType("TEXT"); + + b.Property("ImagePath") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("StartPositionTicks") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "ChapterIndex"); + + b.ToTable("Chapters"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.CustomItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client", "Key") + .IsUnique(); + + b.ToTable("CustomItemDisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChromecastVersion") + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DashboardTheme") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("EnableNextVideoInfoOverlay") + .HasColumnType("INTEGER"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ScrollDirection") + .HasColumnType("INTEGER"); + + b.Property("ShowBackdrop") + .HasColumnType("INTEGER"); + + b.Property("ShowSidebar") + .HasColumnType("INTEGER"); + + b.Property("SkipBackwardLength") + .HasColumnType("INTEGER"); + + b.Property("SkipForwardLength") + .HasColumnType("INTEGER"); + + b.Property("TvHome") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client") + .IsUnique(); + + b.ToTable("DisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DisplayPreferencesId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DisplayPreferencesId"); + + b.ToTable("HomeSection"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("ImageInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("RememberIndexing") + .HasColumnType("INTEGER"); + + b.Property("RememberSorting") + .HasColumnType("INTEGER"); + + b.Property("SortBy") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("ViewType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("ItemDisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemValue", b => + { + b.Property("ItemValueId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CleanValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId"); + + b.HasIndex("Type", "CleanValue") + .IsUnique(); + + b.ToTable("ItemValues"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemValueMap", b => + { + b.Property("ItemValueId") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("ItemValuesMap"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.MediaSegment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EndTicks") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("SegmentProviderId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StartTicks") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("MediaSegments"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.MediaStreamInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("StreamIndex") + .HasColumnType("INTEGER"); + + b.Property("AspectRatio") + .HasColumnType("TEXT"); + + b.Property("AverageFrameRate") + .HasColumnType("REAL"); + + b.Property("BitDepth") + .HasColumnType("INTEGER"); + + b.Property("BitRate") + .HasColumnType("INTEGER"); + + b.Property("BlPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("ChannelLayout") + .HasColumnType("TEXT"); + + b.Property("Channels") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasColumnType("TEXT"); + + b.Property("CodecTag") + .HasColumnType("TEXT"); + + b.Property("CodecTimeBase") + .HasColumnType("TEXT"); + + b.Property("ColorPrimaries") + .HasColumnType("TEXT"); + + b.Property("ColorSpace") + .HasColumnType("TEXT"); + + b.Property("ColorTransfer") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("DvBlSignalCompatibilityId") + .HasColumnType("INTEGER"); + + b.Property("DvLevel") + .HasColumnType("INTEGER"); + + b.Property("DvProfile") + .HasColumnType("INTEGER"); + + b.Property("DvVersionMajor") + .HasColumnType("INTEGER"); + + b.Property("DvVersionMinor") + .HasColumnType("INTEGER"); + + b.Property("ElPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("IsAnamorphic") + .HasColumnType("INTEGER"); + + b.Property("IsAvc") + .HasColumnType("INTEGER"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("IsExternal") + .HasColumnType("INTEGER"); + + b.Property("IsForced") + .HasColumnType("INTEGER"); + + b.Property("IsHearingImpaired") + .HasColumnType("INTEGER"); + + b.Property("IsInterlaced") + .HasColumnType("INTEGER"); + + b.Property("KeyFrames") + .HasColumnType("TEXT"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("REAL"); + + b.Property("NalLengthSize") + .HasColumnType("TEXT"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PixelFormat") + .HasColumnType("TEXT"); + + b.Property("Profile") + .HasColumnType("TEXT"); + + b.Property("RealFrameRate") + .HasColumnType("REAL"); + + b.Property("RefFrames") + .HasColumnType("INTEGER"); + + b.Property("Rotation") + .HasColumnType("INTEGER"); + + b.Property("RpuPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("SampleRate") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .HasColumnType("INTEGER"); + + b.Property("TimeBase") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "StreamIndex"); + + b.HasIndex("StreamIndex"); + + b.HasIndex("StreamType"); + + b.HasIndex("StreamIndex", "StreamType"); + + b.HasIndex("StreamIndex", "StreamType", "Language"); + + b.ToTable("MediaStreamInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.People", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PersonType") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("Peoples"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.PeopleBaseItemMap", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("PeopleId") + .HasColumnType("TEXT"); + + b.Property("ListOrder") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.HasIndex("ItemId", "ListOrder"); + + b.HasIndex("ItemId", "SortOrder"); + + b.ToTable("PeopleBaseItemMap"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Permission_Permissions_Guid") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Permissions"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Preference_Preferences_Guid") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Preferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccessToken") + .IsUnique(); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AppName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AppVersion") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("AccessToken", "DateLastActivity"); + + b.HasIndex("DeviceId", "DateLastActivity"); + + b.HasIndex("UserId", "DeviceId"); + + b.ToTable("Devices"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.DeviceOptions", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CustomName") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId") + .IsUnique(); + + b.ToTable("DeviceOptions"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.TrickplayInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.Property("Bandwidth") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Interval") + .HasColumnType("INTEGER"); + + b.Property("ThumbnailCount") + .HasColumnType("INTEGER"); + + b.Property("TileHeight") + .HasColumnType("INTEGER"); + + b.Property("TileWidth") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "Width"); + + b.ToTable("TrickplayInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AudioLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("AuthenticationProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("CastReceiverId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DisplayCollectionsView") + .HasColumnType("INTEGER"); + + b.Property("DisplayMissingEpisodes") + .HasColumnType("INTEGER"); + + b.Property("EnableAutoLogin") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalPassword") + .HasColumnType("INTEGER"); + + b.Property("EnableNextEpisodeAutoPlay") + .HasColumnType("INTEGER"); + + b.Property("EnableUserPreferenceAccess") + .HasColumnType("INTEGER"); + + b.Property("HidePlayedInLatest") + .HasColumnType("INTEGER"); + + b.Property("InternalId") + .HasColumnType("INTEGER"); + + b.Property("InvalidLoginAttemptCount") + .HasColumnType("INTEGER"); + + b.Property("LastActivityDate") + .HasColumnType("TEXT"); + + b.Property("LastLoginDate") + .HasColumnType("TEXT"); + + b.Property("LoginAttemptsBeforeLockout") + .HasColumnType("INTEGER"); + + b.Property("MaxActiveSessions") + .HasColumnType("INTEGER"); + + b.Property("MaxParentalAgeRating") + .HasColumnType("INTEGER"); + + b.Property("MustUpdatePassword") + .HasColumnType("INTEGER"); + + b.Property("Password") + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.Property("PasswordResetProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("PlayDefaultAudioTrack") + .HasColumnType("INTEGER"); + + b.Property("RememberAudioSelections") + .HasColumnType("INTEGER"); + + b.Property("RememberSubtitleSelections") + .HasColumnType("INTEGER"); + + b.Property("RemoteClientBitrateLimit") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SubtitleLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("SubtitleMode") + .HasColumnType("INTEGER"); + + b.Property("SyncPlayAccess") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.UserData", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("CustomDataKey") + .HasColumnType("TEXT"); + + b.Property("AudioStreamIndex") + .HasColumnType("INTEGER"); + + b.Property("IsFavorite") + .HasColumnType("INTEGER"); + + b.Property("LastPlayedDate") + .HasColumnType("TEXT"); + + b.Property("Likes") + .HasColumnType("INTEGER"); + + b.Property("PlayCount") + .HasColumnType("INTEGER"); + + b.Property("PlaybackPositionTicks") + .HasColumnType("INTEGER"); + + b.Property("Played") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("SubtitleStreamIndex") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "UserId", "CustomDataKey"); + + b.HasIndex("UserId"); + + b.HasIndex("ItemId", "UserId", "IsFavorite"); + + b.HasIndex("ItemId", "UserId", "LastPlayedDate"); + + b.HasIndex("ItemId", "UserId", "PlaybackPositionTicks"); + + b.HasIndex("ItemId", "UserId", "Played"); + + b.ToTable("UserData"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("AccessSchedules") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AncestorId", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("Children") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "ParentItem") + .WithMany("ParentAncestors") + .HasForeignKey("ParentItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ParentItem"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AttachmentStreamInfo", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemImageInfo", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("Images") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemMetadataField", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("LockedFields") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemProvider", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("Provider") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemTrailerType", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("TrailerTypes") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Chapter", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("Chapters") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("DisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b => + { + b.HasOne("Jellyfin.Data.Entities.DisplayPreferences", null) + .WithMany("HomeSections") + .HasForeignKey("DisplayPreferencesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithOne("ProfileImage") + .HasForeignKey("Jellyfin.Data.Entities.ImageInfo", "UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("ItemDisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemValueMap", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("ItemValues") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Data.Entities.ItemValue", "ItemValue") + .WithMany("BaseItemsMap") + .HasForeignKey("ItemValueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ItemValue"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.MediaStreamInfo", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("MediaStreams") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.PeopleBaseItemMap", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("Peoples") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Data.Entities.People", "People") + .WithMany("BaseItems") + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("People"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("Permissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("Preferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.Device", b => + { + b.HasOne("Jellyfin.Data.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.UserData", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("UserData") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Data.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemEntity", b => + { + b.Navigation("Chapters"); + + b.Navigation("Children"); + + b.Navigation("Images"); + + b.Navigation("ItemValues"); + + b.Navigation("LockedFields"); + + b.Navigation("MediaStreams"); + + b.Navigation("ParentAncestors"); + + b.Navigation("Peoples"); + + b.Navigation("Provider"); + + b.Navigation("TrailerTypes"); + + b.Navigation("UserData"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.Navigation("HomeSections"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemValue", b => + { + b.Navigation("BaseItemsMap"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.People", b => + { + b.Navigation("BaseItems"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.User", b => + { + b.Navigation("AccessSchedules"); + + b.Navigation("DisplayPreferences"); + + b.Navigation("ItemDisplayPreferences"); + + b.Navigation("Permissions"); + + b.Navigation("Preferences"); + + b.Navigation("ProfileImage"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250204092455_MakeStartEndDateNullable.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250204092455_MakeStartEndDateNullable.cs new file mode 100644 index 0000000000..2c60dd7a62 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250204092455_MakeStartEndDateNullable.cs @@ -0,0 +1,55 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + /// + public partial class MakeStartEndDateNullable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "StartDate", + table: "BaseItems", + type: "TEXT", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "TEXT"); + + migrationBuilder.AlterColumn( + name: "EndDate", + table: "BaseItems", + type: "TEXT", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "TEXT"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "StartDate", + table: "BaseItems", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + oldClrType: typeof(DateTime), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "EndDate", + table: "BaseItems", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + oldClrType: typeof(DateTime), + oldType: "TEXT", + oldNullable: true); + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250214031148_ChannelIdGuid.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250214031148_ChannelIdGuid.Designer.cs new file mode 100644 index 0000000000..f5cfe86c44 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250214031148_ChannelIdGuid.Designer.cs @@ -0,0 +1,1595 @@ +// +using System; +using Jellyfin.Database.Implementations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + [DbContext(typeof(JellyfinDbContext))] + [Migration("20250214031148_ChannelIdGuid")] + partial class ChannelIdGuid + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.2"); + + modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property("EndHour") + .HasColumnType("REAL"); + + b.Property("StartHour") + .HasColumnType("REAL"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AccessSchedules"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ActivityLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LogSeverity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ShortOverview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DateCreated"); + + b.ToTable("ActivityLogs"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AncestorId", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ParentItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ParentItemId"); + + b.HasIndex("ParentItemId"); + + b.ToTable("AncestorIds"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AttachmentStreamInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CodecTag") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Filename") + .HasColumnType("TEXT"); + + b.Property("MimeType") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "Index"); + + b.ToTable("AttachmentStreamInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Album") + .HasColumnType("TEXT"); + + b.Property("AlbumArtists") + .HasColumnType("TEXT"); + + b.Property("Artists") + .HasColumnType("TEXT"); + + b.Property("Audio") + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("CleanName") + .HasColumnType("TEXT"); + + b.Property("CommunityRating") + .HasColumnType("REAL"); + + b.Property("CriticRating") + .HasColumnType("REAL"); + + b.Property("CustomRating") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastMediaAdded") + .HasColumnType("TEXT"); + + b.Property("DateLastRefreshed") + .HasColumnType("TEXT"); + + b.Property("DateLastSaved") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("EpisodeTitle") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasColumnType("TEXT"); + + b.Property("ExternalSeriesId") + .HasColumnType("TEXT"); + + b.Property("ExternalServiceId") + .HasColumnType("TEXT"); + + b.Property("ExtraIds") + .HasColumnType("TEXT"); + + b.Property("ExtraType") + .HasColumnType("INTEGER"); + + b.Property("ForcedSortName") + .HasColumnType("TEXT"); + + b.Property("Genres") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("IndexNumber") + .HasColumnType("INTEGER"); + + b.Property("InheritedParentalRatingValue") + .HasColumnType("INTEGER"); + + b.Property("IsFolder") + .HasColumnType("INTEGER"); + + b.Property("IsInMixedFolder") + .HasColumnType("INTEGER"); + + b.Property("IsLocked") + .HasColumnType("INTEGER"); + + b.Property("IsMovie") + .HasColumnType("INTEGER"); + + b.Property("IsRepeat") + .HasColumnType("INTEGER"); + + b.Property("IsSeries") + .HasColumnType("INTEGER"); + + b.Property("IsVirtualItem") + .HasColumnType("INTEGER"); + + b.Property("LUFS") + .HasColumnType("REAL"); + + b.Property("MediaType") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizationGain") + .HasColumnType("REAL"); + + b.Property("OfficialRating") + .HasColumnType("TEXT"); + + b.Property("OriginalTitle") + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("OwnerId") + .HasColumnType("TEXT"); + + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("ParentIndexNumber") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PreferredMetadataCountryCode") + .HasColumnType("TEXT"); + + b.Property("PreferredMetadataLanguage") + .HasColumnType("TEXT"); + + b.Property("PremiereDate") + .HasColumnType("TEXT"); + + b.Property("PresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property("PrimaryVersionId") + .HasColumnType("TEXT"); + + b.Property("ProductionLocations") + .HasColumnType("TEXT"); + + b.Property("ProductionYear") + .HasColumnType("INTEGER"); + + b.Property("RunTimeTicks") + .HasColumnType("INTEGER"); + + b.Property("SeasonId") + .HasColumnType("TEXT"); + + b.Property("SeasonName") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("SeriesName") + .HasColumnType("TEXT"); + + b.Property("SeriesPresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property("ShowId") + .HasColumnType("TEXT"); + + b.Property("Size") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("Studios") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("TopParentId") + .HasColumnType("TEXT"); + + b.Property("TotalBitrate") + .HasColumnType("INTEGER"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UnratedType") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.HasIndex("Path"); + + b.HasIndex("PresentationUniqueKey"); + + b.HasIndex("TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "PresentationUniqueKey"); + + b.HasIndex("Type", "TopParentId", "StartDate"); + + b.HasIndex("Id", "Type", "IsFolder", "IsVirtualItem"); + + b.HasIndex("MediaType", "TopParentId", "IsVirtualItem", "PresentationUniqueKey"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "IsFolder", "IsVirtualItem"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "PresentationUniqueKey", "SortName"); + + b.HasIndex("IsFolder", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.HasIndex("Type", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.ToTable("BaseItems"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemImageInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Blurhash") + .HasColumnType("BLOB"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("ImageType") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemImageInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemMetadataField", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemMetadataFields"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemProvider", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("ProviderValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ProviderId"); + + b.HasIndex("ProviderId", "ProviderValue", "ItemId"); + + b.ToTable("BaseItemProviders"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemTrailerType", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemTrailerTypes"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Chapter", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ChapterIndex") + .HasColumnType("INTEGER"); + + b.Property("ImageDateModified") + .HasColumnType("TEXT"); + + b.Property("ImagePath") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("StartPositionTicks") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "ChapterIndex"); + + b.ToTable("Chapters"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.CustomItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client", "Key") + .IsUnique(); + + b.ToTable("CustomItemDisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChromecastVersion") + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DashboardTheme") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("EnableNextVideoInfoOverlay") + .HasColumnType("INTEGER"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ScrollDirection") + .HasColumnType("INTEGER"); + + b.Property("ShowBackdrop") + .HasColumnType("INTEGER"); + + b.Property("ShowSidebar") + .HasColumnType("INTEGER"); + + b.Property("SkipBackwardLength") + .HasColumnType("INTEGER"); + + b.Property("SkipForwardLength") + .HasColumnType("INTEGER"); + + b.Property("TvHome") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client") + .IsUnique(); + + b.ToTable("DisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DisplayPreferencesId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DisplayPreferencesId"); + + b.ToTable("HomeSection"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("ImageInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("RememberIndexing") + .HasColumnType("INTEGER"); + + b.Property("RememberSorting") + .HasColumnType("INTEGER"); + + b.Property("SortBy") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("ViewType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("ItemDisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemValue", b => + { + b.Property("ItemValueId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CleanValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId"); + + b.HasIndex("Type", "CleanValue") + .IsUnique(); + + b.ToTable("ItemValues"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemValueMap", b => + { + b.Property("ItemValueId") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("ItemValuesMap"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.MediaSegment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EndTicks") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("SegmentProviderId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StartTicks") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("MediaSegments"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.MediaStreamInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("StreamIndex") + .HasColumnType("INTEGER"); + + b.Property("AspectRatio") + .HasColumnType("TEXT"); + + b.Property("AverageFrameRate") + .HasColumnType("REAL"); + + b.Property("BitDepth") + .HasColumnType("INTEGER"); + + b.Property("BitRate") + .HasColumnType("INTEGER"); + + b.Property("BlPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("ChannelLayout") + .HasColumnType("TEXT"); + + b.Property("Channels") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasColumnType("TEXT"); + + b.Property("CodecTag") + .HasColumnType("TEXT"); + + b.Property("CodecTimeBase") + .HasColumnType("TEXT"); + + b.Property("ColorPrimaries") + .HasColumnType("TEXT"); + + b.Property("ColorSpace") + .HasColumnType("TEXT"); + + b.Property("ColorTransfer") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("DvBlSignalCompatibilityId") + .HasColumnType("INTEGER"); + + b.Property("DvLevel") + .HasColumnType("INTEGER"); + + b.Property("DvProfile") + .HasColumnType("INTEGER"); + + b.Property("DvVersionMajor") + .HasColumnType("INTEGER"); + + b.Property("DvVersionMinor") + .HasColumnType("INTEGER"); + + b.Property("ElPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("IsAnamorphic") + .HasColumnType("INTEGER"); + + b.Property("IsAvc") + .HasColumnType("INTEGER"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("IsExternal") + .HasColumnType("INTEGER"); + + b.Property("IsForced") + .HasColumnType("INTEGER"); + + b.Property("IsHearingImpaired") + .HasColumnType("INTEGER"); + + b.Property("IsInterlaced") + .HasColumnType("INTEGER"); + + b.Property("KeyFrames") + .HasColumnType("TEXT"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("REAL"); + + b.Property("NalLengthSize") + .HasColumnType("TEXT"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PixelFormat") + .HasColumnType("TEXT"); + + b.Property("Profile") + .HasColumnType("TEXT"); + + b.Property("RealFrameRate") + .HasColumnType("REAL"); + + b.Property("RefFrames") + .HasColumnType("INTEGER"); + + b.Property("Rotation") + .HasColumnType("INTEGER"); + + b.Property("RpuPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("SampleRate") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .HasColumnType("INTEGER"); + + b.Property("TimeBase") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "StreamIndex"); + + b.HasIndex("StreamIndex"); + + b.HasIndex("StreamType"); + + b.HasIndex("StreamIndex", "StreamType"); + + b.HasIndex("StreamIndex", "StreamType", "Language"); + + b.ToTable("MediaStreamInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.People", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PersonType") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("Peoples"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.PeopleBaseItemMap", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("PeopleId") + .HasColumnType("TEXT"); + + b.Property("ListOrder") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.HasIndex("ItemId", "ListOrder"); + + b.HasIndex("ItemId", "SortOrder"); + + b.ToTable("PeopleBaseItemMap"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Permission_Permissions_Guid") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Permissions"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Preference_Preferences_Guid") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Preferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccessToken") + .IsUnique(); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AppName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AppVersion") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("AccessToken", "DateLastActivity"); + + b.HasIndex("DeviceId", "DateLastActivity"); + + b.HasIndex("UserId", "DeviceId"); + + b.ToTable("Devices"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.DeviceOptions", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CustomName") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId") + .IsUnique(); + + b.ToTable("DeviceOptions"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.TrickplayInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.Property("Bandwidth") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Interval") + .HasColumnType("INTEGER"); + + b.Property("ThumbnailCount") + .HasColumnType("INTEGER"); + + b.Property("TileHeight") + .HasColumnType("INTEGER"); + + b.Property("TileWidth") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "Width"); + + b.ToTable("TrickplayInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AudioLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("AuthenticationProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("CastReceiverId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DisplayCollectionsView") + .HasColumnType("INTEGER"); + + b.Property("DisplayMissingEpisodes") + .HasColumnType("INTEGER"); + + b.Property("EnableAutoLogin") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalPassword") + .HasColumnType("INTEGER"); + + b.Property("EnableNextEpisodeAutoPlay") + .HasColumnType("INTEGER"); + + b.Property("EnableUserPreferenceAccess") + .HasColumnType("INTEGER"); + + b.Property("HidePlayedInLatest") + .HasColumnType("INTEGER"); + + b.Property("InternalId") + .HasColumnType("INTEGER"); + + b.Property("InvalidLoginAttemptCount") + .HasColumnType("INTEGER"); + + b.Property("LastActivityDate") + .HasColumnType("TEXT"); + + b.Property("LastLoginDate") + .HasColumnType("TEXT"); + + b.Property("LoginAttemptsBeforeLockout") + .HasColumnType("INTEGER"); + + b.Property("MaxActiveSessions") + .HasColumnType("INTEGER"); + + b.Property("MaxParentalAgeRating") + .HasColumnType("INTEGER"); + + b.Property("MustUpdatePassword") + .HasColumnType("INTEGER"); + + b.Property("Password") + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.Property("PasswordResetProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("PlayDefaultAudioTrack") + .HasColumnType("INTEGER"); + + b.Property("RememberAudioSelections") + .HasColumnType("INTEGER"); + + b.Property("RememberSubtitleSelections") + .HasColumnType("INTEGER"); + + b.Property("RemoteClientBitrateLimit") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SubtitleLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("SubtitleMode") + .HasColumnType("INTEGER"); + + b.Property("SyncPlayAccess") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.UserData", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("CustomDataKey") + .HasColumnType("TEXT"); + + b.Property("AudioStreamIndex") + .HasColumnType("INTEGER"); + + b.Property("IsFavorite") + .HasColumnType("INTEGER"); + + b.Property("LastPlayedDate") + .HasColumnType("TEXT"); + + b.Property("Likes") + .HasColumnType("INTEGER"); + + b.Property("PlayCount") + .HasColumnType("INTEGER"); + + b.Property("PlaybackPositionTicks") + .HasColumnType("INTEGER"); + + b.Property("Played") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("SubtitleStreamIndex") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "UserId", "CustomDataKey"); + + b.HasIndex("UserId"); + + b.HasIndex("ItemId", "UserId", "IsFavorite"); + + b.HasIndex("ItemId", "UserId", "LastPlayedDate"); + + b.HasIndex("ItemId", "UserId", "PlaybackPositionTicks"); + + b.HasIndex("ItemId", "UserId", "Played"); + + b.ToTable("UserData"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("AccessSchedules") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AncestorId", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("Children") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "ParentItem") + .WithMany("ParentAncestors") + .HasForeignKey("ParentItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ParentItem"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AttachmentStreamInfo", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemImageInfo", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("Images") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemMetadataField", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("LockedFields") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemProvider", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("Provider") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemTrailerType", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("TrailerTypes") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Chapter", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("Chapters") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("DisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b => + { + b.HasOne("Jellyfin.Data.Entities.DisplayPreferences", null) + .WithMany("HomeSections") + .HasForeignKey("DisplayPreferencesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithOne("ProfileImage") + .HasForeignKey("Jellyfin.Data.Entities.ImageInfo", "UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("ItemDisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemValueMap", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("ItemValues") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Data.Entities.ItemValue", "ItemValue") + .WithMany("BaseItemsMap") + .HasForeignKey("ItemValueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ItemValue"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.MediaStreamInfo", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("MediaStreams") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.PeopleBaseItemMap", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("Peoples") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Data.Entities.People", "People") + .WithMany("BaseItems") + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("People"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("Permissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("Preferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.Device", b => + { + b.HasOne("Jellyfin.Data.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.UserData", b => + { + b.HasOne("Jellyfin.Data.Entities.BaseItemEntity", "Item") + .WithMany("UserData") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Data.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.BaseItemEntity", b => + { + b.Navigation("Chapters"); + + b.Navigation("Children"); + + b.Navigation("Images"); + + b.Navigation("ItemValues"); + + b.Navigation("LockedFields"); + + b.Navigation("MediaStreams"); + + b.Navigation("ParentAncestors"); + + b.Navigation("Peoples"); + + b.Navigation("Provider"); + + b.Navigation("TrailerTypes"); + + b.Navigation("UserData"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.Navigation("HomeSections"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemValue", b => + { + b.Navigation("BaseItemsMap"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.People", b => + { + b.Navigation("BaseItems"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.User", b => + { + b.Navigation("AccessSchedules"); + + b.Navigation("DisplayPreferences"); + + b.Navigation("ItemDisplayPreferences"); + + b.Navigation("Permissions"); + + b.Navigation("Preferences"); + + b.Navigation("ProfileImage"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250214031148_ChannelIdGuid.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250214031148_ChannelIdGuid.cs new file mode 100644 index 0000000000..1e904e833e --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250214031148_ChannelIdGuid.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + /// + public partial class ChannelIdGuid : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // NOOP, Guids and strings are stored the same in SQLite. + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // NOOP, Guids and strings are stored the same in SQLite. + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250326065026_AddInheritedParentalRatingSubValue.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250326065026_AddInheritedParentalRatingSubValue.Designer.cs new file mode 100644 index 0000000000..d6befbe5eb --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250326065026_AddInheritedParentalRatingSubValue.Designer.cs @@ -0,0 +1,1658 @@ +// +using System; +using Jellyfin.Database.Implementations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + [DbContext(typeof(JellyfinDbContext))] + [Migration("20250326065026_AddInheritedParentalRatingSubValue")] + partial class AddInheritedParentalRatingSubValue + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.3"); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property("EndHour") + .HasColumnType("REAL"); + + b.Property("StartHour") + .HasColumnType("REAL"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AccessSchedules"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ActivityLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LogSeverity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ShortOverview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DateCreated"); + + b.ToTable("ActivityLogs"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ParentItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ParentItemId"); + + b.HasIndex("ParentItemId"); + + b.ToTable("AncestorIds"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CodecTag") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Filename") + .HasColumnType("TEXT"); + + b.Property("MimeType") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "Index"); + + b.ToTable("AttachmentStreamInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Album") + .HasColumnType("TEXT"); + + b.Property("AlbumArtists") + .HasColumnType("TEXT"); + + b.Property("Artists") + .HasColumnType("TEXT"); + + b.Property("Audio") + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("CleanName") + .HasColumnType("TEXT"); + + b.Property("CommunityRating") + .HasColumnType("REAL"); + + b.Property("CriticRating") + .HasColumnType("REAL"); + + b.Property("CustomRating") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastMediaAdded") + .HasColumnType("TEXT"); + + b.Property("DateLastRefreshed") + .HasColumnType("TEXT"); + + b.Property("DateLastSaved") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("EpisodeTitle") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasColumnType("TEXT"); + + b.Property("ExternalSeriesId") + .HasColumnType("TEXT"); + + b.Property("ExternalServiceId") + .HasColumnType("TEXT"); + + b.Property("ExtraIds") + .HasColumnType("TEXT"); + + b.Property("ExtraType") + .HasColumnType("INTEGER"); + + b.Property("ForcedSortName") + .HasColumnType("TEXT"); + + b.Property("Genres") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("IndexNumber") + .HasColumnType("INTEGER"); + + b.Property("InheritedParentalRatingSubValue") + .HasColumnType("INTEGER"); + + b.Property("InheritedParentalRatingValue") + .HasColumnType("INTEGER"); + + b.Property("IsFolder") + .HasColumnType("INTEGER"); + + b.Property("IsInMixedFolder") + .HasColumnType("INTEGER"); + + b.Property("IsLocked") + .HasColumnType("INTEGER"); + + b.Property("IsMovie") + .HasColumnType("INTEGER"); + + b.Property("IsRepeat") + .HasColumnType("INTEGER"); + + b.Property("IsSeries") + .HasColumnType("INTEGER"); + + b.Property("IsVirtualItem") + .HasColumnType("INTEGER"); + + b.Property("LUFS") + .HasColumnType("REAL"); + + b.Property("MediaType") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizationGain") + .HasColumnType("REAL"); + + b.Property("OfficialRating") + .HasColumnType("TEXT"); + + b.Property("OriginalTitle") + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("OwnerId") + .HasColumnType("TEXT"); + + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("ParentIndexNumber") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PreferredMetadataCountryCode") + .HasColumnType("TEXT"); + + b.Property("PreferredMetadataLanguage") + .HasColumnType("TEXT"); + + b.Property("PremiereDate") + .HasColumnType("TEXT"); + + b.Property("PresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property("PrimaryVersionId") + .HasColumnType("TEXT"); + + b.Property("ProductionLocations") + .HasColumnType("TEXT"); + + b.Property("ProductionYear") + .HasColumnType("INTEGER"); + + b.Property("RunTimeTicks") + .HasColumnType("INTEGER"); + + b.Property("SeasonId") + .HasColumnType("TEXT"); + + b.Property("SeasonName") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("SeriesName") + .HasColumnType("TEXT"); + + b.Property("SeriesPresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property("ShowId") + .HasColumnType("TEXT"); + + b.Property("Size") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("Studios") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("TopParentId") + .HasColumnType("TEXT"); + + b.Property("TotalBitrate") + .HasColumnType("INTEGER"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UnratedType") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.HasIndex("Path"); + + b.HasIndex("PresentationUniqueKey"); + + b.HasIndex("TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "PresentationUniqueKey"); + + b.HasIndex("Type", "TopParentId", "StartDate"); + + b.HasIndex("Id", "Type", "IsFolder", "IsVirtualItem"); + + b.HasIndex("MediaType", "TopParentId", "IsVirtualItem", "PresentationUniqueKey"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "IsFolder", "IsVirtualItem"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "PresentationUniqueKey", "SortName"); + + b.HasIndex("IsFolder", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.HasIndex("Type", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.ToTable("BaseItems"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Blurhash") + .HasColumnType("BLOB"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("ImageType") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemImageInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemMetadataFields"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("ProviderValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ProviderId"); + + b.HasIndex("ProviderId", "ProviderValue", "ItemId"); + + b.ToTable("BaseItemProviders"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemTrailerTypes"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ChapterIndex") + .HasColumnType("INTEGER"); + + b.Property("ImageDateModified") + .HasColumnType("TEXT"); + + b.Property("ImagePath") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("StartPositionTicks") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "ChapterIndex"); + + b.ToTable("Chapters"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.CustomItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client", "Key") + .IsUnique(); + + b.ToTable("CustomItemDisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChromecastVersion") + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DashboardTheme") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("EnableNextVideoInfoOverlay") + .HasColumnType("INTEGER"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ScrollDirection") + .HasColumnType("INTEGER"); + + b.Property("ShowBackdrop") + .HasColumnType("INTEGER"); + + b.Property("ShowSidebar") + .HasColumnType("INTEGER"); + + b.Property("SkipBackwardLength") + .HasColumnType("INTEGER"); + + b.Property("SkipForwardLength") + .HasColumnType("INTEGER"); + + b.Property("TvHome") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client") + .IsUnique(); + + b.ToTable("DisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DisplayPreferencesId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DisplayPreferencesId"); + + b.ToTable("HomeSection"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("ImageInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("RememberIndexing") + .HasColumnType("INTEGER"); + + b.Property("RememberSorting") + .HasColumnType("INTEGER"); + + b.Property("SortBy") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("ViewType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("ItemDisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b => + { + b.Property("ItemValueId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CleanValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId"); + + b.HasIndex("Type", "CleanValue") + .IsUnique(); + + b.ToTable("ItemValues"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b => + { + b.Property("ItemValueId") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("ItemValuesMap"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaSegment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EndTicks") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("SegmentProviderId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StartTicks") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("MediaSegments"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("StreamIndex") + .HasColumnType("INTEGER"); + + b.Property("AspectRatio") + .HasColumnType("TEXT"); + + b.Property("AverageFrameRate") + .HasColumnType("REAL"); + + b.Property("BitDepth") + .HasColumnType("INTEGER"); + + b.Property("BitRate") + .HasColumnType("INTEGER"); + + b.Property("BlPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("ChannelLayout") + .HasColumnType("TEXT"); + + b.Property("Channels") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasColumnType("TEXT"); + + b.Property("CodecTag") + .HasColumnType("TEXT"); + + b.Property("CodecTimeBase") + .HasColumnType("TEXT"); + + b.Property("ColorPrimaries") + .HasColumnType("TEXT"); + + b.Property("ColorSpace") + .HasColumnType("TEXT"); + + b.Property("ColorTransfer") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("DvBlSignalCompatibilityId") + .HasColumnType("INTEGER"); + + b.Property("DvLevel") + .HasColumnType("INTEGER"); + + b.Property("DvProfile") + .HasColumnType("INTEGER"); + + b.Property("DvVersionMajor") + .HasColumnType("INTEGER"); + + b.Property("DvVersionMinor") + .HasColumnType("INTEGER"); + + b.Property("ElPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("IsAnamorphic") + .HasColumnType("INTEGER"); + + b.Property("IsAvc") + .HasColumnType("INTEGER"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("IsExternal") + .HasColumnType("INTEGER"); + + b.Property("IsForced") + .HasColumnType("INTEGER"); + + b.Property("IsHearingImpaired") + .HasColumnType("INTEGER"); + + b.Property("IsInterlaced") + .HasColumnType("INTEGER"); + + b.Property("KeyFrames") + .HasColumnType("TEXT"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("REAL"); + + b.Property("NalLengthSize") + .HasColumnType("TEXT"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PixelFormat") + .HasColumnType("TEXT"); + + b.Property("Profile") + .HasColumnType("TEXT"); + + b.Property("RealFrameRate") + .HasColumnType("REAL"); + + b.Property("RefFrames") + .HasColumnType("INTEGER"); + + b.Property("Rotation") + .HasColumnType("INTEGER"); + + b.Property("RpuPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("SampleRate") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .HasColumnType("INTEGER"); + + b.Property("TimeBase") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "StreamIndex"); + + b.HasIndex("StreamIndex"); + + b.HasIndex("StreamType"); + + b.HasIndex("StreamIndex", "StreamType"); + + b.HasIndex("StreamIndex", "StreamType", "Language"); + + b.ToTable("MediaStreamInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PersonType") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("Peoples"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("PeopleId") + .HasColumnType("TEXT"); + + b.Property("ListOrder") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.HasIndex("ItemId", "ListOrder"); + + b.HasIndex("ItemId", "SortOrder"); + + b.ToTable("PeopleBaseItemMap"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Permission_Permissions_Guid") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Permissions"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Preference_Preferences_Guid") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Preferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccessToken") + .IsUnique(); + + b.ToTable("ApiKeys"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AppName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AppVersion") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("AccessToken", "DateLastActivity"); + + b.HasIndex("DeviceId", "DateLastActivity"); + + b.HasIndex("UserId", "DeviceId"); + + b.ToTable("Devices"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.DeviceOptions", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CustomName") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId") + .IsUnique(); + + b.ToTable("DeviceOptions"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.TrickplayInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.Property("Bandwidth") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Interval") + .HasColumnType("INTEGER"); + + b.Property("ThumbnailCount") + .HasColumnType("INTEGER"); + + b.Property("TileHeight") + .HasColumnType("INTEGER"); + + b.Property("TileWidth") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "Width"); + + b.ToTable("TrickplayInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AudioLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("AuthenticationProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("CastReceiverId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DisplayCollectionsView") + .HasColumnType("INTEGER"); + + b.Property("DisplayMissingEpisodes") + .HasColumnType("INTEGER"); + + b.Property("EnableAutoLogin") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalPassword") + .HasColumnType("INTEGER"); + + b.Property("EnableNextEpisodeAutoPlay") + .HasColumnType("INTEGER"); + + b.Property("EnableUserPreferenceAccess") + .HasColumnType("INTEGER"); + + b.Property("HidePlayedInLatest") + .HasColumnType("INTEGER"); + + b.Property("InternalId") + .HasColumnType("INTEGER"); + + b.Property("InvalidLoginAttemptCount") + .HasColumnType("INTEGER"); + + b.Property("LastActivityDate") + .HasColumnType("TEXT"); + + b.Property("LastLoginDate") + .HasColumnType("TEXT"); + + b.Property("LoginAttemptsBeforeLockout") + .HasColumnType("INTEGER"); + + b.Property("MaxActiveSessions") + .HasColumnType("INTEGER"); + + b.Property("MaxParentalRatingScore") + .HasColumnType("INTEGER"); + + b.Property("MaxParentalRatingSubScore") + .HasColumnType("INTEGER"); + + b.Property("MustUpdatePassword") + .HasColumnType("INTEGER"); + + b.Property("Password") + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.Property("PasswordResetProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("PlayDefaultAudioTrack") + .HasColumnType("INTEGER"); + + b.Property("RememberAudioSelections") + .HasColumnType("INTEGER"); + + b.Property("RememberSubtitleSelections") + .HasColumnType("INTEGER"); + + b.Property("RemoteClientBitrateLimit") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SubtitleLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("SubtitleMode") + .HasColumnType("INTEGER"); + + b.Property("SyncPlayAccess") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("CustomDataKey") + .HasColumnType("TEXT"); + + b.Property("AudioStreamIndex") + .HasColumnType("INTEGER"); + + b.Property("IsFavorite") + .HasColumnType("INTEGER"); + + b.Property("LastPlayedDate") + .HasColumnType("TEXT"); + + b.Property("Likes") + .HasColumnType("INTEGER"); + + b.Property("PlayCount") + .HasColumnType("INTEGER"); + + b.Property("PlaybackPositionTicks") + .HasColumnType("INTEGER"); + + b.Property("Played") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("SubtitleStreamIndex") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "UserId", "CustomDataKey"); + + b.HasIndex("UserId"); + + b.HasIndex("ItemId", "UserId", "IsFavorite"); + + b.HasIndex("ItemId", "UserId", "LastPlayedDate"); + + b.HasIndex("ItemId", "UserId", "PlaybackPositionTicks"); + + b.HasIndex("ItemId", "UserId", "Played"); + + b.ToTable("UserData"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("AccessSchedules") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Children") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "ParentItem") + .WithMany("ParentAncestors") + .HasForeignKey("ParentItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ParentItem"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Images") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("LockedFields") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Provider") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("TrailerTypes") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Chapters") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("DisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.DisplayPreferences", null) + .WithMany("HomeSections") + .HasForeignKey("DisplayPreferencesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithOne("ProfileImage") + .HasForeignKey("Jellyfin.Database.Implementations.Entities.ImageInfo", "UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("ItemDisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("ItemValues") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.ItemValue", "ItemValue") + .WithMany("BaseItemsMap") + .HasForeignKey("ItemValueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ItemValue"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("MediaStreams") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Peoples") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.People", "People") + .WithMany("BaseItems") + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("People"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("Permissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("Preferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("UserData") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.Navigation("Chapters"); + + b.Navigation("Children"); + + b.Navigation("Images"); + + b.Navigation("ItemValues"); + + b.Navigation("LockedFields"); + + b.Navigation("MediaStreams"); + + b.Navigation("ParentAncestors"); + + b.Navigation("Peoples"); + + b.Navigation("Provider"); + + b.Navigation("TrailerTypes"); + + b.Navigation("UserData"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.Navigation("HomeSections"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b => + { + b.Navigation("BaseItemsMap"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b => + { + b.Navigation("BaseItems"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b => + { + b.Navigation("AccessSchedules"); + + b.Navigation("DisplayPreferences"); + + b.Navigation("ItemDisplayPreferences"); + + b.Navigation("Permissions"); + + b.Navigation("Preferences"); + + b.Navigation("ProfileImage"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250326065026_AddInheritedParentalRatingSubValue.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250326065026_AddInheritedParentalRatingSubValue.cs new file mode 100644 index 0000000000..71f56a1492 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250326065026_AddInheritedParentalRatingSubValue.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + /// + public partial class AddInheritedParentalRatingSubValue : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "MaxParentalAgeRating", + table: "Users", + newName: "MaxParentalRatingScore"); + + migrationBuilder.AddColumn( + name: "MaxParentalRatingSubScore", + table: "Users", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "InheritedParentalRatingSubValue", + table: "BaseItems", + type: "INTEGER", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "MaxParentalRatingSubScore", + table: "Users"); + + migrationBuilder.DropColumn( + name: "InheritedParentalRatingValue", + table: "BaseItems"); + + migrationBuilder.RenameColumn( + name: "MaxParentalRatingScore", + table: "Users", + newName: "MaxParentalAgeRating"); + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250327101120_AddKeyframeData.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250327101120_AddKeyframeData.Designer.cs new file mode 100644 index 0000000000..434ea820af --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250327101120_AddKeyframeData.Designer.cs @@ -0,0 +1,1681 @@ +// +using System; +using Jellyfin.Database.Implementations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + [DbContext(typeof(JellyfinDbContext))] + [Migration("20250327101120_AddKeyframeData")] + partial class AddKeyframeData + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.3"); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property("EndHour") + .HasColumnType("REAL"); + + b.Property("StartHour") + .HasColumnType("REAL"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AccessSchedules"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ActivityLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LogSeverity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ShortOverview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DateCreated"); + + b.ToTable("ActivityLogs"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ParentItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ParentItemId"); + + b.HasIndex("ParentItemId"); + + b.ToTable("AncestorIds"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CodecTag") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Filename") + .HasColumnType("TEXT"); + + b.Property("MimeType") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "Index"); + + b.ToTable("AttachmentStreamInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Album") + .HasColumnType("TEXT"); + + b.Property("AlbumArtists") + .HasColumnType("TEXT"); + + b.Property("Artists") + .HasColumnType("TEXT"); + + b.Property("Audio") + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("CleanName") + .HasColumnType("TEXT"); + + b.Property("CommunityRating") + .HasColumnType("REAL"); + + b.Property("CriticRating") + .HasColumnType("REAL"); + + b.Property("CustomRating") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastMediaAdded") + .HasColumnType("TEXT"); + + b.Property("DateLastRefreshed") + .HasColumnType("TEXT"); + + b.Property("DateLastSaved") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("EpisodeTitle") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasColumnType("TEXT"); + + b.Property("ExternalSeriesId") + .HasColumnType("TEXT"); + + b.Property("ExternalServiceId") + .HasColumnType("TEXT"); + + b.Property("ExtraIds") + .HasColumnType("TEXT"); + + b.Property("ExtraType") + .HasColumnType("INTEGER"); + + b.Property("ForcedSortName") + .HasColumnType("TEXT"); + + b.Property("Genres") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("IndexNumber") + .HasColumnType("INTEGER"); + + b.Property("InheritedParentalRatingValue") + .HasColumnType("INTEGER"); + + b.Property("IsFolder") + .HasColumnType("INTEGER"); + + b.Property("IsInMixedFolder") + .HasColumnType("INTEGER"); + + b.Property("IsLocked") + .HasColumnType("INTEGER"); + + b.Property("IsMovie") + .HasColumnType("INTEGER"); + + b.Property("IsRepeat") + .HasColumnType("INTEGER"); + + b.Property("IsSeries") + .HasColumnType("INTEGER"); + + b.Property("IsVirtualItem") + .HasColumnType("INTEGER"); + + b.Property("LUFS") + .HasColumnType("REAL"); + + b.Property("MediaType") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizationGain") + .HasColumnType("REAL"); + + b.Property("OfficialRating") + .HasColumnType("TEXT"); + + b.Property("OriginalTitle") + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("OwnerId") + .HasColumnType("TEXT"); + + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("ParentIndexNumber") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PreferredMetadataCountryCode") + .HasColumnType("TEXT"); + + b.Property("PreferredMetadataLanguage") + .HasColumnType("TEXT"); + + b.Property("PremiereDate") + .HasColumnType("TEXT"); + + b.Property("PresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property("PrimaryVersionId") + .HasColumnType("TEXT"); + + b.Property("ProductionLocations") + .HasColumnType("TEXT"); + + b.Property("ProductionYear") + .HasColumnType("INTEGER"); + + b.Property("RunTimeTicks") + .HasColumnType("INTEGER"); + + b.Property("SeasonId") + .HasColumnType("TEXT"); + + b.Property("SeasonName") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("SeriesName") + .HasColumnType("TEXT"); + + b.Property("SeriesPresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property("ShowId") + .HasColumnType("TEXT"); + + b.Property("Size") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("Studios") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("TopParentId") + .HasColumnType("TEXT"); + + b.Property("TotalBitrate") + .HasColumnType("INTEGER"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UnratedType") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.HasIndex("Path"); + + b.HasIndex("PresentationUniqueKey"); + + b.HasIndex("TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "PresentationUniqueKey"); + + b.HasIndex("Type", "TopParentId", "StartDate"); + + b.HasIndex("Id", "Type", "IsFolder", "IsVirtualItem"); + + b.HasIndex("MediaType", "TopParentId", "IsVirtualItem", "PresentationUniqueKey"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "IsFolder", "IsVirtualItem"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "PresentationUniqueKey", "SortName"); + + b.HasIndex("IsFolder", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.HasIndex("Type", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.ToTable("BaseItems"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Blurhash") + .HasColumnType("BLOB"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("ImageType") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemImageInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemMetadataFields"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("ProviderValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ProviderId"); + + b.HasIndex("ProviderId", "ProviderValue", "ItemId"); + + b.ToTable("BaseItemProviders"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemTrailerTypes"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ChapterIndex") + .HasColumnType("INTEGER"); + + b.Property("ImageDateModified") + .HasColumnType("TEXT"); + + b.Property("ImagePath") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("StartPositionTicks") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "ChapterIndex"); + + b.ToTable("Chapters"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.CustomItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client", "Key") + .IsUnique(); + + b.ToTable("CustomItemDisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChromecastVersion") + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DashboardTheme") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("EnableNextVideoInfoOverlay") + .HasColumnType("INTEGER"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ScrollDirection") + .HasColumnType("INTEGER"); + + b.Property("ShowBackdrop") + .HasColumnType("INTEGER"); + + b.Property("ShowSidebar") + .HasColumnType("INTEGER"); + + b.Property("SkipBackwardLength") + .HasColumnType("INTEGER"); + + b.Property("SkipForwardLength") + .HasColumnType("INTEGER"); + + b.Property("TvHome") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client") + .IsUnique(); + + b.ToTable("DisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DisplayPreferencesId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DisplayPreferencesId"); + + b.ToTable("HomeSection"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("ImageInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("RememberIndexing") + .HasColumnType("INTEGER"); + + b.Property("RememberSorting") + .HasColumnType("INTEGER"); + + b.Property("SortBy") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("ViewType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("ItemDisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b => + { + b.Property("ItemValueId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CleanValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId"); + + b.HasIndex("Type", "CleanValue") + .IsUnique(); + + b.ToTable("ItemValues"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b => + { + b.Property("ItemValueId") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("ItemValuesMap"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("KeyframeTicks") + .HasColumnType("TEXT"); + + b.Property("TotalDuration") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId"); + + b.ToTable("KeyframeData"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaSegment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EndTicks") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("SegmentProviderId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StartTicks") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("MediaSegments"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("StreamIndex") + .HasColumnType("INTEGER"); + + b.Property("AspectRatio") + .HasColumnType("TEXT"); + + b.Property("AverageFrameRate") + .HasColumnType("REAL"); + + b.Property("BitDepth") + .HasColumnType("INTEGER"); + + b.Property("BitRate") + .HasColumnType("INTEGER"); + + b.Property("BlPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("ChannelLayout") + .HasColumnType("TEXT"); + + b.Property("Channels") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasColumnType("TEXT"); + + b.Property("CodecTag") + .HasColumnType("TEXT"); + + b.Property("CodecTimeBase") + .HasColumnType("TEXT"); + + b.Property("ColorPrimaries") + .HasColumnType("TEXT"); + + b.Property("ColorSpace") + .HasColumnType("TEXT"); + + b.Property("ColorTransfer") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("DvBlSignalCompatibilityId") + .HasColumnType("INTEGER"); + + b.Property("DvLevel") + .HasColumnType("INTEGER"); + + b.Property("DvProfile") + .HasColumnType("INTEGER"); + + b.Property("DvVersionMajor") + .HasColumnType("INTEGER"); + + b.Property("DvVersionMinor") + .HasColumnType("INTEGER"); + + b.Property("ElPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("IsAnamorphic") + .HasColumnType("INTEGER"); + + b.Property("IsAvc") + .HasColumnType("INTEGER"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("IsExternal") + .HasColumnType("INTEGER"); + + b.Property("IsForced") + .HasColumnType("INTEGER"); + + b.Property("IsHearingImpaired") + .HasColumnType("INTEGER"); + + b.Property("IsInterlaced") + .HasColumnType("INTEGER"); + + b.Property("KeyFrames") + .HasColumnType("TEXT"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("REAL"); + + b.Property("NalLengthSize") + .HasColumnType("TEXT"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PixelFormat") + .HasColumnType("TEXT"); + + b.Property("Profile") + .HasColumnType("TEXT"); + + b.Property("RealFrameRate") + .HasColumnType("REAL"); + + b.Property("RefFrames") + .HasColumnType("INTEGER"); + + b.Property("Rotation") + .HasColumnType("INTEGER"); + + b.Property("RpuPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("SampleRate") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .HasColumnType("INTEGER"); + + b.Property("TimeBase") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "StreamIndex"); + + b.HasIndex("StreamIndex"); + + b.HasIndex("StreamType"); + + b.HasIndex("StreamIndex", "StreamType"); + + b.HasIndex("StreamIndex", "StreamType", "Language"); + + b.ToTable("MediaStreamInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PersonType") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("Peoples"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("PeopleId") + .HasColumnType("TEXT"); + + b.Property("ListOrder") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.HasIndex("ItemId", "ListOrder"); + + b.HasIndex("ItemId", "SortOrder"); + + b.ToTable("PeopleBaseItemMap"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Permission_Permissions_Guid") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Permissions"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Preference_Preferences_Guid") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Preferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccessToken") + .IsUnique(); + + b.ToTable("ApiKeys"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AppName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AppVersion") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("AccessToken", "DateLastActivity"); + + b.HasIndex("DeviceId", "DateLastActivity"); + + b.HasIndex("UserId", "DeviceId"); + + b.ToTable("Devices"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.DeviceOptions", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CustomName") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId") + .IsUnique(); + + b.ToTable("DeviceOptions"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.TrickplayInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.Property("Bandwidth") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Interval") + .HasColumnType("INTEGER"); + + b.Property("ThumbnailCount") + .HasColumnType("INTEGER"); + + b.Property("TileHeight") + .HasColumnType("INTEGER"); + + b.Property("TileWidth") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "Width"); + + b.ToTable("TrickplayInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AudioLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("AuthenticationProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("CastReceiverId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DisplayCollectionsView") + .HasColumnType("INTEGER"); + + b.Property("DisplayMissingEpisodes") + .HasColumnType("INTEGER"); + + b.Property("EnableAutoLogin") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalPassword") + .HasColumnType("INTEGER"); + + b.Property("EnableNextEpisodeAutoPlay") + .HasColumnType("INTEGER"); + + b.Property("EnableUserPreferenceAccess") + .HasColumnType("INTEGER"); + + b.Property("HidePlayedInLatest") + .HasColumnType("INTEGER"); + + b.Property("InternalId") + .HasColumnType("INTEGER"); + + b.Property("InvalidLoginAttemptCount") + .HasColumnType("INTEGER"); + + b.Property("LastActivityDate") + .HasColumnType("TEXT"); + + b.Property("LastLoginDate") + .HasColumnType("TEXT"); + + b.Property("LoginAttemptsBeforeLockout") + .HasColumnType("INTEGER"); + + b.Property("MaxActiveSessions") + .HasColumnType("INTEGER"); + + b.Property("MaxParentalAgeRating") + .HasColumnType("INTEGER"); + + b.Property("MustUpdatePassword") + .HasColumnType("INTEGER"); + + b.Property("Password") + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.Property("PasswordResetProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("PlayDefaultAudioTrack") + .HasColumnType("INTEGER"); + + b.Property("RememberAudioSelections") + .HasColumnType("INTEGER"); + + b.Property("RememberSubtitleSelections") + .HasColumnType("INTEGER"); + + b.Property("RemoteClientBitrateLimit") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SubtitleLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("SubtitleMode") + .HasColumnType("INTEGER"); + + b.Property("SyncPlayAccess") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("CustomDataKey") + .HasColumnType("TEXT"); + + b.Property("AudioStreamIndex") + .HasColumnType("INTEGER"); + + b.Property("IsFavorite") + .HasColumnType("INTEGER"); + + b.Property("LastPlayedDate") + .HasColumnType("TEXT"); + + b.Property("Likes") + .HasColumnType("INTEGER"); + + b.Property("PlayCount") + .HasColumnType("INTEGER"); + + b.Property("PlaybackPositionTicks") + .HasColumnType("INTEGER"); + + b.Property("Played") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("SubtitleStreamIndex") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "UserId", "CustomDataKey"); + + b.HasIndex("UserId"); + + b.HasIndex("ItemId", "UserId", "IsFavorite"); + + b.HasIndex("ItemId", "UserId", "LastPlayedDate"); + + b.HasIndex("ItemId", "UserId", "PlaybackPositionTicks"); + + b.HasIndex("ItemId", "UserId", "Played"); + + b.ToTable("UserData"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("AccessSchedules") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Children") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "ParentItem") + .WithMany("ParentAncestors") + .HasForeignKey("ParentItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ParentItem"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Images") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("LockedFields") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Provider") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("TrailerTypes") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Chapters") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("DisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.DisplayPreferences", null) + .WithMany("HomeSections") + .HasForeignKey("DisplayPreferencesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithOne("ProfileImage") + .HasForeignKey("Jellyfin.Database.Implementations.Entities.ImageInfo", "UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("ItemDisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("ItemValues") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.ItemValue", "ItemValue") + .WithMany("BaseItemsMap") + .HasForeignKey("ItemValueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ItemValue"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("MediaStreams") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Peoples") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.People", "People") + .WithMany("BaseItems") + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("People"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("Permissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("Preferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("UserData") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.Navigation("Chapters"); + + b.Navigation("Children"); + + b.Navigation("Images"); + + b.Navigation("ItemValues"); + + b.Navigation("LockedFields"); + + b.Navigation("MediaStreams"); + + b.Navigation("ParentAncestors"); + + b.Navigation("Peoples"); + + b.Navigation("Provider"); + + b.Navigation("TrailerTypes"); + + b.Navigation("UserData"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.Navigation("HomeSections"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b => + { + b.Navigation("BaseItemsMap"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b => + { + b.Navigation("BaseItems"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b => + { + b.Navigation("AccessSchedules"); + + b.Navigation("DisplayPreferences"); + + b.Navigation("ItemDisplayPreferences"); + + b.Navigation("Permissions"); + + b.Navigation("Preferences"); + + b.Navigation("ProfileImage"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250327101120_AddKeyframeData.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250327101120_AddKeyframeData.cs new file mode 100644 index 0000000000..c17b35b404 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250327101120_AddKeyframeData.cs @@ -0,0 +1,41 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + /// + public partial class AddKeyframeData : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "KeyframeData", + columns: table => new + { + ItemId = table.Column(type: "TEXT", nullable: false), + TotalDuration = table.Column(type: "INTEGER", nullable: false), + KeyframeTicks = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_KeyframeData", x => x.ItemId); + table.ForeignKey( + name: "FK_KeyframeData_BaseItems_ItemId", + column: x => x.ItemId, + principalTable: "BaseItems", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "KeyframeData"); + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250327171413_AddHdr10PlusFlag.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250327171413_AddHdr10PlusFlag.Designer.cs new file mode 100644 index 0000000000..bad01778da --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250327171413_AddHdr10PlusFlag.Designer.cs @@ -0,0 +1,1655 @@ +// +using System; +using Jellyfin.Database.Implementations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + [DbContext(typeof(JellyfinDbContext))] + [Migration("20250327171413_AddHdr10PlusFlag")] + partial class AddHdr10PlusFlag + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.3"); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property("EndHour") + .HasColumnType("REAL"); + + b.Property("StartHour") + .HasColumnType("REAL"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AccessSchedules"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ActivityLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LogSeverity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ShortOverview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DateCreated"); + + b.ToTable("ActivityLogs"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ParentItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ParentItemId"); + + b.HasIndex("ParentItemId"); + + b.ToTable("AncestorIds"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CodecTag") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Filename") + .HasColumnType("TEXT"); + + b.Property("MimeType") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "Index"); + + b.ToTable("AttachmentStreamInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Album") + .HasColumnType("TEXT"); + + b.Property("AlbumArtists") + .HasColumnType("TEXT"); + + b.Property("Artists") + .HasColumnType("TEXT"); + + b.Property("Audio") + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("CleanName") + .HasColumnType("TEXT"); + + b.Property("CommunityRating") + .HasColumnType("REAL"); + + b.Property("CriticRating") + .HasColumnType("REAL"); + + b.Property("CustomRating") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastMediaAdded") + .HasColumnType("TEXT"); + + b.Property("DateLastRefreshed") + .HasColumnType("TEXT"); + + b.Property("DateLastSaved") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("EpisodeTitle") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasColumnType("TEXT"); + + b.Property("ExternalSeriesId") + .HasColumnType("TEXT"); + + b.Property("ExternalServiceId") + .HasColumnType("TEXT"); + + b.Property("ExtraIds") + .HasColumnType("TEXT"); + + b.Property("ExtraType") + .HasColumnType("INTEGER"); + + b.Property("ForcedSortName") + .HasColumnType("TEXT"); + + b.Property("Genres") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("IndexNumber") + .HasColumnType("INTEGER"); + + b.Property("InheritedParentalRatingValue") + .HasColumnType("INTEGER"); + + b.Property("IsFolder") + .HasColumnType("INTEGER"); + + b.Property("IsInMixedFolder") + .HasColumnType("INTEGER"); + + b.Property("IsLocked") + .HasColumnType("INTEGER"); + + b.Property("IsMovie") + .HasColumnType("INTEGER"); + + b.Property("IsRepeat") + .HasColumnType("INTEGER"); + + b.Property("IsSeries") + .HasColumnType("INTEGER"); + + b.Property("IsVirtualItem") + .HasColumnType("INTEGER"); + + b.Property("LUFS") + .HasColumnType("REAL"); + + b.Property("MediaType") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizationGain") + .HasColumnType("REAL"); + + b.Property("OfficialRating") + .HasColumnType("TEXT"); + + b.Property("OriginalTitle") + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("OwnerId") + .HasColumnType("TEXT"); + + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("ParentIndexNumber") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PreferredMetadataCountryCode") + .HasColumnType("TEXT"); + + b.Property("PreferredMetadataLanguage") + .HasColumnType("TEXT"); + + b.Property("PremiereDate") + .HasColumnType("TEXT"); + + b.Property("PresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property("PrimaryVersionId") + .HasColumnType("TEXT"); + + b.Property("ProductionLocations") + .HasColumnType("TEXT"); + + b.Property("ProductionYear") + .HasColumnType("INTEGER"); + + b.Property("RunTimeTicks") + .HasColumnType("INTEGER"); + + b.Property("SeasonId") + .HasColumnType("TEXT"); + + b.Property("SeasonName") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("SeriesName") + .HasColumnType("TEXT"); + + b.Property("SeriesPresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property("ShowId") + .HasColumnType("TEXT"); + + b.Property("Size") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("Studios") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("TopParentId") + .HasColumnType("TEXT"); + + b.Property("TotalBitrate") + .HasColumnType("INTEGER"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UnratedType") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.HasIndex("Path"); + + b.HasIndex("PresentationUniqueKey"); + + b.HasIndex("TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "PresentationUniqueKey"); + + b.HasIndex("Type", "TopParentId", "StartDate"); + + b.HasIndex("Id", "Type", "IsFolder", "IsVirtualItem"); + + b.HasIndex("MediaType", "TopParentId", "IsVirtualItem", "PresentationUniqueKey"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "IsFolder", "IsVirtualItem"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "PresentationUniqueKey", "SortName"); + + b.HasIndex("IsFolder", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.HasIndex("Type", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.ToTable("BaseItems"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Blurhash") + .HasColumnType("BLOB"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("ImageType") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemImageInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemMetadataFields"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("ProviderValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ProviderId"); + + b.HasIndex("ProviderId", "ProviderValue", "ItemId"); + + b.ToTable("BaseItemProviders"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemTrailerTypes"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ChapterIndex") + .HasColumnType("INTEGER"); + + b.Property("ImageDateModified") + .HasColumnType("TEXT"); + + b.Property("ImagePath") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("StartPositionTicks") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "ChapterIndex"); + + b.ToTable("Chapters"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.CustomItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client", "Key") + .IsUnique(); + + b.ToTable("CustomItemDisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChromecastVersion") + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DashboardTheme") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("EnableNextVideoInfoOverlay") + .HasColumnType("INTEGER"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ScrollDirection") + .HasColumnType("INTEGER"); + + b.Property("ShowBackdrop") + .HasColumnType("INTEGER"); + + b.Property("ShowSidebar") + .HasColumnType("INTEGER"); + + b.Property("SkipBackwardLength") + .HasColumnType("INTEGER"); + + b.Property("SkipForwardLength") + .HasColumnType("INTEGER"); + + b.Property("TvHome") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client") + .IsUnique(); + + b.ToTable("DisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DisplayPreferencesId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DisplayPreferencesId"); + + b.ToTable("HomeSection"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("ImageInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("RememberIndexing") + .HasColumnType("INTEGER"); + + b.Property("RememberSorting") + .HasColumnType("INTEGER"); + + b.Property("SortBy") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("ViewType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("ItemDisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b => + { + b.Property("ItemValueId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CleanValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId"); + + b.HasIndex("Type", "CleanValue") + .IsUnique(); + + b.ToTable("ItemValues"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b => + { + b.Property("ItemValueId") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("ItemValuesMap"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaSegment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EndTicks") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("SegmentProviderId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StartTicks") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("MediaSegments"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("StreamIndex") + .HasColumnType("INTEGER"); + + b.Property("AspectRatio") + .HasColumnType("TEXT"); + + b.Property("AverageFrameRate") + .HasColumnType("REAL"); + + b.Property("BitDepth") + .HasColumnType("INTEGER"); + + b.Property("BitRate") + .HasColumnType("INTEGER"); + + b.Property("BlPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("ChannelLayout") + .HasColumnType("TEXT"); + + b.Property("Channels") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasColumnType("TEXT"); + + b.Property("CodecTag") + .HasColumnType("TEXT"); + + b.Property("CodecTimeBase") + .HasColumnType("TEXT"); + + b.Property("ColorPrimaries") + .HasColumnType("TEXT"); + + b.Property("ColorSpace") + .HasColumnType("TEXT"); + + b.Property("ColorTransfer") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("DvBlSignalCompatibilityId") + .HasColumnType("INTEGER"); + + b.Property("DvLevel") + .HasColumnType("INTEGER"); + + b.Property("DvProfile") + .HasColumnType("INTEGER"); + + b.Property("DvVersionMajor") + .HasColumnType("INTEGER"); + + b.Property("DvVersionMinor") + .HasColumnType("INTEGER"); + + b.Property("ElPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("Hdr10PlusPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("IsAnamorphic") + .HasColumnType("INTEGER"); + + b.Property("IsAvc") + .HasColumnType("INTEGER"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("IsExternal") + .HasColumnType("INTEGER"); + + b.Property("IsForced") + .HasColumnType("INTEGER"); + + b.Property("IsHearingImpaired") + .HasColumnType("INTEGER"); + + b.Property("IsInterlaced") + .HasColumnType("INTEGER"); + + b.Property("KeyFrames") + .HasColumnType("TEXT"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("REAL"); + + b.Property("NalLengthSize") + .HasColumnType("TEXT"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PixelFormat") + .HasColumnType("TEXT"); + + b.Property("Profile") + .HasColumnType("TEXT"); + + b.Property("RealFrameRate") + .HasColumnType("REAL"); + + b.Property("RefFrames") + .HasColumnType("INTEGER"); + + b.Property("Rotation") + .HasColumnType("INTEGER"); + + b.Property("RpuPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("SampleRate") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .HasColumnType("INTEGER"); + + b.Property("TimeBase") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "StreamIndex"); + + b.HasIndex("StreamIndex"); + + b.HasIndex("StreamType"); + + b.HasIndex("StreamIndex", "StreamType"); + + b.HasIndex("StreamIndex", "StreamType", "Language"); + + b.ToTable("MediaStreamInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PersonType") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("Peoples"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("PeopleId") + .HasColumnType("TEXT"); + + b.Property("ListOrder") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.HasIndex("ItemId", "ListOrder"); + + b.HasIndex("ItemId", "SortOrder"); + + b.ToTable("PeopleBaseItemMap"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Permission_Permissions_Guid") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Permissions"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Preference_Preferences_Guid") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Preferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccessToken") + .IsUnique(); + + b.ToTable("ApiKeys"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AppName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AppVersion") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("AccessToken", "DateLastActivity"); + + b.HasIndex("DeviceId", "DateLastActivity"); + + b.HasIndex("UserId", "DeviceId"); + + b.ToTable("Devices"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.DeviceOptions", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CustomName") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId") + .IsUnique(); + + b.ToTable("DeviceOptions"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.TrickplayInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.Property("Bandwidth") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Interval") + .HasColumnType("INTEGER"); + + b.Property("ThumbnailCount") + .HasColumnType("INTEGER"); + + b.Property("TileHeight") + .HasColumnType("INTEGER"); + + b.Property("TileWidth") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "Width"); + + b.ToTable("TrickplayInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AudioLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("AuthenticationProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("CastReceiverId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DisplayCollectionsView") + .HasColumnType("INTEGER"); + + b.Property("DisplayMissingEpisodes") + .HasColumnType("INTEGER"); + + b.Property("EnableAutoLogin") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalPassword") + .HasColumnType("INTEGER"); + + b.Property("EnableNextEpisodeAutoPlay") + .HasColumnType("INTEGER"); + + b.Property("EnableUserPreferenceAccess") + .HasColumnType("INTEGER"); + + b.Property("HidePlayedInLatest") + .HasColumnType("INTEGER"); + + b.Property("InternalId") + .HasColumnType("INTEGER"); + + b.Property("InvalidLoginAttemptCount") + .HasColumnType("INTEGER"); + + b.Property("LastActivityDate") + .HasColumnType("TEXT"); + + b.Property("LastLoginDate") + .HasColumnType("TEXT"); + + b.Property("LoginAttemptsBeforeLockout") + .HasColumnType("INTEGER"); + + b.Property("MaxActiveSessions") + .HasColumnType("INTEGER"); + + b.Property("MaxParentalAgeRating") + .HasColumnType("INTEGER"); + + b.Property("MustUpdatePassword") + .HasColumnType("INTEGER"); + + b.Property("Password") + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.Property("PasswordResetProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("PlayDefaultAudioTrack") + .HasColumnType("INTEGER"); + + b.Property("RememberAudioSelections") + .HasColumnType("INTEGER"); + + b.Property("RememberSubtitleSelections") + .HasColumnType("INTEGER"); + + b.Property("RemoteClientBitrateLimit") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SubtitleLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("SubtitleMode") + .HasColumnType("INTEGER"); + + b.Property("SyncPlayAccess") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("CustomDataKey") + .HasColumnType("TEXT"); + + b.Property("AudioStreamIndex") + .HasColumnType("INTEGER"); + + b.Property("IsFavorite") + .HasColumnType("INTEGER"); + + b.Property("LastPlayedDate") + .HasColumnType("TEXT"); + + b.Property("Likes") + .HasColumnType("INTEGER"); + + b.Property("PlayCount") + .HasColumnType("INTEGER"); + + b.Property("PlaybackPositionTicks") + .HasColumnType("INTEGER"); + + b.Property("Played") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("SubtitleStreamIndex") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "UserId", "CustomDataKey"); + + b.HasIndex("UserId"); + + b.HasIndex("ItemId", "UserId", "IsFavorite"); + + b.HasIndex("ItemId", "UserId", "LastPlayedDate"); + + b.HasIndex("ItemId", "UserId", "PlaybackPositionTicks"); + + b.HasIndex("ItemId", "UserId", "Played"); + + b.ToTable("UserData"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("AccessSchedules") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Children") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "ParentItem") + .WithMany("ParentAncestors") + .HasForeignKey("ParentItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ParentItem"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Images") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("LockedFields") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Provider") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("TrailerTypes") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Chapters") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("DisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.DisplayPreferences", null) + .WithMany("HomeSections") + .HasForeignKey("DisplayPreferencesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithOne("ProfileImage") + .HasForeignKey("Jellyfin.Database.Implementations.Entities.ImageInfo", "UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("ItemDisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("ItemValues") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.ItemValue", "ItemValue") + .WithMany("BaseItemsMap") + .HasForeignKey("ItemValueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ItemValue"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("MediaStreams") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Peoples") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.People", "People") + .WithMany("BaseItems") + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("People"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("Permissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("Preferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("UserData") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.Navigation("Chapters"); + + b.Navigation("Children"); + + b.Navigation("Images"); + + b.Navigation("ItemValues"); + + b.Navigation("LockedFields"); + + b.Navigation("MediaStreams"); + + b.Navigation("ParentAncestors"); + + b.Navigation("Peoples"); + + b.Navigation("Provider"); + + b.Navigation("TrailerTypes"); + + b.Navigation("UserData"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.Navigation("HomeSections"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b => + { + b.Navigation("BaseItemsMap"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b => + { + b.Navigation("BaseItems"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b => + { + b.Navigation("AccessSchedules"); + + b.Navigation("DisplayPreferences"); + + b.Navigation("ItemDisplayPreferences"); + + b.Navigation("Permissions"); + + b.Navigation("Preferences"); + + b.Navigation("ProfileImage"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250327171413_AddHdr10PlusFlag.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250327171413_AddHdr10PlusFlag.cs new file mode 100644 index 0000000000..5766cd3825 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250327171413_AddHdr10PlusFlag.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + /// + public partial class AddHdr10PlusFlag : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Hdr10PlusPresentFlag", + table: "MediaStreamInfos", + type: "INTEGER", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Hdr10PlusPresentFlag", + table: "MediaStreamInfos"); + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250331182844_FixAttachmentMigration.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250331182844_FixAttachmentMigration.Designer.cs new file mode 100644 index 0000000000..d668eea92f --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250331182844_FixAttachmentMigration.Designer.cs @@ -0,0 +1,1657 @@ +// +using System; +using Jellyfin.Database.Implementations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + [DbContext(typeof(JellyfinDbContext))] + [Migration("20250331182844_FixAttachmentMigration")] + partial class FixAttachmentMigration + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.3"); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property("EndHour") + .HasColumnType("REAL"); + + b.Property("StartHour") + .HasColumnType("REAL"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AccessSchedules"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ActivityLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LogSeverity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ShortOverview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DateCreated"); + + b.ToTable("ActivityLogs"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ParentItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ParentItemId"); + + b.HasIndex("ParentItemId"); + + b.ToTable("AncestorIds"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasColumnType("TEXT"); + + b.Property("CodecTag") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Filename") + .HasColumnType("TEXT"); + + b.Property("MimeType") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "Index"); + + b.ToTable("AttachmentStreamInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Album") + .HasColumnType("TEXT"); + + b.Property("AlbumArtists") + .HasColumnType("TEXT"); + + b.Property("Artists") + .HasColumnType("TEXT"); + + b.Property("Audio") + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("CleanName") + .HasColumnType("TEXT"); + + b.Property("CommunityRating") + .HasColumnType("REAL"); + + b.Property("CriticRating") + .HasColumnType("REAL"); + + b.Property("CustomRating") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastMediaAdded") + .HasColumnType("TEXT"); + + b.Property("DateLastRefreshed") + .HasColumnType("TEXT"); + + b.Property("DateLastSaved") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("EpisodeTitle") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasColumnType("TEXT"); + + b.Property("ExternalSeriesId") + .HasColumnType("TEXT"); + + b.Property("ExternalServiceId") + .HasColumnType("TEXT"); + + b.Property("ExtraIds") + .HasColumnType("TEXT"); + + b.Property("ExtraType") + .HasColumnType("INTEGER"); + + b.Property("ForcedSortName") + .HasColumnType("TEXT"); + + b.Property("Genres") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("IndexNumber") + .HasColumnType("INTEGER"); + + b.Property("InheritedParentalRatingSubValue") + .HasColumnType("INTEGER"); + + b.Property("InheritedParentalRatingValue") + .HasColumnType("INTEGER"); + + b.Property("IsFolder") + .HasColumnType("INTEGER"); + + b.Property("IsInMixedFolder") + .HasColumnType("INTEGER"); + + b.Property("IsLocked") + .HasColumnType("INTEGER"); + + b.Property("IsMovie") + .HasColumnType("INTEGER"); + + b.Property("IsRepeat") + .HasColumnType("INTEGER"); + + b.Property("IsSeries") + .HasColumnType("INTEGER"); + + b.Property("IsVirtualItem") + .HasColumnType("INTEGER"); + + b.Property("LUFS") + .HasColumnType("REAL"); + + b.Property("MediaType") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizationGain") + .HasColumnType("REAL"); + + b.Property("OfficialRating") + .HasColumnType("TEXT"); + + b.Property("OriginalTitle") + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("OwnerId") + .HasColumnType("TEXT"); + + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("ParentIndexNumber") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PreferredMetadataCountryCode") + .HasColumnType("TEXT"); + + b.Property("PreferredMetadataLanguage") + .HasColumnType("TEXT"); + + b.Property("PremiereDate") + .HasColumnType("TEXT"); + + b.Property("PresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property("PrimaryVersionId") + .HasColumnType("TEXT"); + + b.Property("ProductionLocations") + .HasColumnType("TEXT"); + + b.Property("ProductionYear") + .HasColumnType("INTEGER"); + + b.Property("RunTimeTicks") + .HasColumnType("INTEGER"); + + b.Property("SeasonId") + .HasColumnType("TEXT"); + + b.Property("SeasonName") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("SeriesName") + .HasColumnType("TEXT"); + + b.Property("SeriesPresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property("ShowId") + .HasColumnType("TEXT"); + + b.Property("Size") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("Studios") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("TopParentId") + .HasColumnType("TEXT"); + + b.Property("TotalBitrate") + .HasColumnType("INTEGER"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UnratedType") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.HasIndex("Path"); + + b.HasIndex("PresentationUniqueKey"); + + b.HasIndex("TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "PresentationUniqueKey"); + + b.HasIndex("Type", "TopParentId", "StartDate"); + + b.HasIndex("Id", "Type", "IsFolder", "IsVirtualItem"); + + b.HasIndex("MediaType", "TopParentId", "IsVirtualItem", "PresentationUniqueKey"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "IsFolder", "IsVirtualItem"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "PresentationUniqueKey", "SortName"); + + b.HasIndex("IsFolder", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.HasIndex("Type", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.ToTable("BaseItems"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Blurhash") + .HasColumnType("BLOB"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("ImageType") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemImageInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemMetadataFields"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("ProviderValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ProviderId"); + + b.HasIndex("ProviderId", "ProviderValue", "ItemId"); + + b.ToTable("BaseItemProviders"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemTrailerTypes"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ChapterIndex") + .HasColumnType("INTEGER"); + + b.Property("ImageDateModified") + .HasColumnType("TEXT"); + + b.Property("ImagePath") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("StartPositionTicks") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "ChapterIndex"); + + b.ToTable("Chapters"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.CustomItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client", "Key") + .IsUnique(); + + b.ToTable("CustomItemDisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChromecastVersion") + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DashboardTheme") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("EnableNextVideoInfoOverlay") + .HasColumnType("INTEGER"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ScrollDirection") + .HasColumnType("INTEGER"); + + b.Property("ShowBackdrop") + .HasColumnType("INTEGER"); + + b.Property("ShowSidebar") + .HasColumnType("INTEGER"); + + b.Property("SkipBackwardLength") + .HasColumnType("INTEGER"); + + b.Property("SkipForwardLength") + .HasColumnType("INTEGER"); + + b.Property("TvHome") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client") + .IsUnique(); + + b.ToTable("DisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DisplayPreferencesId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DisplayPreferencesId"); + + b.ToTable("HomeSection"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("ImageInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("RememberIndexing") + .HasColumnType("INTEGER"); + + b.Property("RememberSorting") + .HasColumnType("INTEGER"); + + b.Property("SortBy") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("ViewType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("ItemDisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b => + { + b.Property("ItemValueId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CleanValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId"); + + b.HasIndex("Type", "CleanValue") + .IsUnique(); + + b.ToTable("ItemValues"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b => + { + b.Property("ItemValueId") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("ItemValuesMap"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaSegment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EndTicks") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("SegmentProviderId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StartTicks") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("MediaSegments"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("StreamIndex") + .HasColumnType("INTEGER"); + + b.Property("AspectRatio") + .HasColumnType("TEXT"); + + b.Property("AverageFrameRate") + .HasColumnType("REAL"); + + b.Property("BitDepth") + .HasColumnType("INTEGER"); + + b.Property("BitRate") + .HasColumnType("INTEGER"); + + b.Property("BlPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("ChannelLayout") + .HasColumnType("TEXT"); + + b.Property("Channels") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasColumnType("TEXT"); + + b.Property("CodecTag") + .HasColumnType("TEXT"); + + b.Property("CodecTimeBase") + .HasColumnType("TEXT"); + + b.Property("ColorPrimaries") + .HasColumnType("TEXT"); + + b.Property("ColorSpace") + .HasColumnType("TEXT"); + + b.Property("ColorTransfer") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("DvBlSignalCompatibilityId") + .HasColumnType("INTEGER"); + + b.Property("DvLevel") + .HasColumnType("INTEGER"); + + b.Property("DvProfile") + .HasColumnType("INTEGER"); + + b.Property("DvVersionMajor") + .HasColumnType("INTEGER"); + + b.Property("DvVersionMinor") + .HasColumnType("INTEGER"); + + b.Property("ElPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("IsAnamorphic") + .HasColumnType("INTEGER"); + + b.Property("IsAvc") + .HasColumnType("INTEGER"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("IsExternal") + .HasColumnType("INTEGER"); + + b.Property("IsForced") + .HasColumnType("INTEGER"); + + b.Property("IsHearingImpaired") + .HasColumnType("INTEGER"); + + b.Property("IsInterlaced") + .HasColumnType("INTEGER"); + + b.Property("KeyFrames") + .HasColumnType("TEXT"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("REAL"); + + b.Property("NalLengthSize") + .HasColumnType("TEXT"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PixelFormat") + .HasColumnType("TEXT"); + + b.Property("Profile") + .HasColumnType("TEXT"); + + b.Property("RealFrameRate") + .HasColumnType("REAL"); + + b.Property("RefFrames") + .HasColumnType("INTEGER"); + + b.Property("Rotation") + .HasColumnType("INTEGER"); + + b.Property("RpuPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("SampleRate") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .HasColumnType("INTEGER"); + + b.Property("TimeBase") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "StreamIndex"); + + b.HasIndex("StreamIndex"); + + b.HasIndex("StreamType"); + + b.HasIndex("StreamIndex", "StreamType"); + + b.HasIndex("StreamIndex", "StreamType", "Language"); + + b.ToTable("MediaStreamInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PersonType") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("Peoples"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("PeopleId") + .HasColumnType("TEXT"); + + b.Property("ListOrder") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.HasIndex("ItemId", "ListOrder"); + + b.HasIndex("ItemId", "SortOrder"); + + b.ToTable("PeopleBaseItemMap"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Permission_Permissions_Guid") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Permissions"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Preference_Preferences_Guid") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Preferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccessToken") + .IsUnique(); + + b.ToTable("ApiKeys"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AppName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AppVersion") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("AccessToken", "DateLastActivity"); + + b.HasIndex("DeviceId", "DateLastActivity"); + + b.HasIndex("UserId", "DeviceId"); + + b.ToTable("Devices"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.DeviceOptions", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CustomName") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId") + .IsUnique(); + + b.ToTable("DeviceOptions"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.TrickplayInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.Property("Bandwidth") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Interval") + .HasColumnType("INTEGER"); + + b.Property("ThumbnailCount") + .HasColumnType("INTEGER"); + + b.Property("TileHeight") + .HasColumnType("INTEGER"); + + b.Property("TileWidth") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "Width"); + + b.ToTable("TrickplayInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AudioLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("AuthenticationProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("CastReceiverId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DisplayCollectionsView") + .HasColumnType("INTEGER"); + + b.Property("DisplayMissingEpisodes") + .HasColumnType("INTEGER"); + + b.Property("EnableAutoLogin") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalPassword") + .HasColumnType("INTEGER"); + + b.Property("EnableNextEpisodeAutoPlay") + .HasColumnType("INTEGER"); + + b.Property("EnableUserPreferenceAccess") + .HasColumnType("INTEGER"); + + b.Property("HidePlayedInLatest") + .HasColumnType("INTEGER"); + + b.Property("InternalId") + .HasColumnType("INTEGER"); + + b.Property("InvalidLoginAttemptCount") + .HasColumnType("INTEGER"); + + b.Property("LastActivityDate") + .HasColumnType("TEXT"); + + b.Property("LastLoginDate") + .HasColumnType("TEXT"); + + b.Property("LoginAttemptsBeforeLockout") + .HasColumnType("INTEGER"); + + b.Property("MaxActiveSessions") + .HasColumnType("INTEGER"); + + b.Property("MaxParentalRatingScore") + .HasColumnType("INTEGER"); + + b.Property("MaxParentalRatingSubScore") + .HasColumnType("INTEGER"); + + b.Property("MustUpdatePassword") + .HasColumnType("INTEGER"); + + b.Property("Password") + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.Property("PasswordResetProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("PlayDefaultAudioTrack") + .HasColumnType("INTEGER"); + + b.Property("RememberAudioSelections") + .HasColumnType("INTEGER"); + + b.Property("RememberSubtitleSelections") + .HasColumnType("INTEGER"); + + b.Property("RemoteClientBitrateLimit") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SubtitleLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("SubtitleMode") + .HasColumnType("INTEGER"); + + b.Property("SyncPlayAccess") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("CustomDataKey") + .HasColumnType("TEXT"); + + b.Property("AudioStreamIndex") + .HasColumnType("INTEGER"); + + b.Property("IsFavorite") + .HasColumnType("INTEGER"); + + b.Property("LastPlayedDate") + .HasColumnType("TEXT"); + + b.Property("Likes") + .HasColumnType("INTEGER"); + + b.Property("PlayCount") + .HasColumnType("INTEGER"); + + b.Property("PlaybackPositionTicks") + .HasColumnType("INTEGER"); + + b.Property("Played") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("SubtitleStreamIndex") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "UserId", "CustomDataKey"); + + b.HasIndex("UserId"); + + b.HasIndex("ItemId", "UserId", "IsFavorite"); + + b.HasIndex("ItemId", "UserId", "LastPlayedDate"); + + b.HasIndex("ItemId", "UserId", "PlaybackPositionTicks"); + + b.HasIndex("ItemId", "UserId", "Played"); + + b.ToTable("UserData"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("AccessSchedules") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Children") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "ParentItem") + .WithMany("ParentAncestors") + .HasForeignKey("ParentItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ParentItem"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Images") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("LockedFields") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Provider") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("TrailerTypes") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Chapters") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("DisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.DisplayPreferences", null) + .WithMany("HomeSections") + .HasForeignKey("DisplayPreferencesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithOne("ProfileImage") + .HasForeignKey("Jellyfin.Database.Implementations.Entities.ImageInfo", "UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("ItemDisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("ItemValues") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.ItemValue", "ItemValue") + .WithMany("BaseItemsMap") + .HasForeignKey("ItemValueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ItemValue"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("MediaStreams") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Peoples") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.People", "People") + .WithMany("BaseItems") + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("People"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("Permissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("Preferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("UserData") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.Navigation("Chapters"); + + b.Navigation("Children"); + + b.Navigation("Images"); + + b.Navigation("ItemValues"); + + b.Navigation("LockedFields"); + + b.Navigation("MediaStreams"); + + b.Navigation("ParentAncestors"); + + b.Navigation("Peoples"); + + b.Navigation("Provider"); + + b.Navigation("TrailerTypes"); + + b.Navigation("UserData"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.Navigation("HomeSections"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b => + { + b.Navigation("BaseItemsMap"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b => + { + b.Navigation("BaseItems"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b => + { + b.Navigation("AccessSchedules"); + + b.Navigation("DisplayPreferences"); + + b.Navigation("ItemDisplayPreferences"); + + b.Navigation("Permissions"); + + b.Navigation("Preferences"); + + b.Navigation("ProfileImage"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250331182844_FixAttachmentMigration.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250331182844_FixAttachmentMigration.cs new file mode 100644 index 0000000000..f921856a20 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250331182844_FixAttachmentMigration.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + /// + public partial class FixAttachmentMigration : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Codec", + table: "AttachmentStreamInfos", + type: "TEXT", + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Codec", + table: "AttachmentStreamInfos", + type: "TEXT", + nullable: false, + defaultValue: string.Empty, + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true); + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250401142247_FixAncestors.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250401142247_FixAncestors.Designer.cs new file mode 100644 index 0000000000..d7672b1379 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250401142247_FixAncestors.Designer.cs @@ -0,0 +1,1658 @@ +// +using System; +using Jellyfin.Database.Implementations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + [DbContext(typeof(JellyfinDbContext))] + [Migration("20250401142247_FixAncestors")] + partial class FixAncestors + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.3"); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property("EndHour") + .HasColumnType("REAL"); + + b.Property("StartHour") + .HasColumnType("REAL"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AccessSchedules"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ActivityLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LogSeverity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ShortOverview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DateCreated"); + + b.ToTable("ActivityLogs"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ParentItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ParentItemId"); + + b.HasIndex("ParentItemId"); + + b.ToTable("AncestorIds"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CodecTag") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Filename") + .HasColumnType("TEXT"); + + b.Property("MimeType") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "Index"); + + b.ToTable("AttachmentStreamInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Album") + .HasColumnType("TEXT"); + + b.Property("AlbumArtists") + .HasColumnType("TEXT"); + + b.Property("Artists") + .HasColumnType("TEXT"); + + b.Property("Audio") + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("CleanName") + .HasColumnType("TEXT"); + + b.Property("CommunityRating") + .HasColumnType("REAL"); + + b.Property("CriticRating") + .HasColumnType("REAL"); + + b.Property("CustomRating") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastMediaAdded") + .HasColumnType("TEXT"); + + b.Property("DateLastRefreshed") + .HasColumnType("TEXT"); + + b.Property("DateLastSaved") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("EpisodeTitle") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasColumnType("TEXT"); + + b.Property("ExternalSeriesId") + .HasColumnType("TEXT"); + + b.Property("ExternalServiceId") + .HasColumnType("TEXT"); + + b.Property("ExtraIds") + .HasColumnType("TEXT"); + + b.Property("ExtraType") + .HasColumnType("INTEGER"); + + b.Property("ForcedSortName") + .HasColumnType("TEXT"); + + b.Property("Genres") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("IndexNumber") + .HasColumnType("INTEGER"); + + b.Property("InheritedParentalRatingSubValue") + .HasColumnType("INTEGER"); + + b.Property("InheritedParentalRatingValue") + .HasColumnType("INTEGER"); + + b.Property("IsFolder") + .HasColumnType("INTEGER"); + + b.Property("IsInMixedFolder") + .HasColumnType("INTEGER"); + + b.Property("IsLocked") + .HasColumnType("INTEGER"); + + b.Property("IsMovie") + .HasColumnType("INTEGER"); + + b.Property("IsRepeat") + .HasColumnType("INTEGER"); + + b.Property("IsSeries") + .HasColumnType("INTEGER"); + + b.Property("IsVirtualItem") + .HasColumnType("INTEGER"); + + b.Property("LUFS") + .HasColumnType("REAL"); + + b.Property("MediaType") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizationGain") + .HasColumnType("REAL"); + + b.Property("OfficialRating") + .HasColumnType("TEXT"); + + b.Property("OriginalTitle") + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("OwnerId") + .HasColumnType("TEXT"); + + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("ParentIndexNumber") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PreferredMetadataCountryCode") + .HasColumnType("TEXT"); + + b.Property("PreferredMetadataLanguage") + .HasColumnType("TEXT"); + + b.Property("PremiereDate") + .HasColumnType("TEXT"); + + b.Property("PresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property("PrimaryVersionId") + .HasColumnType("TEXT"); + + b.Property("ProductionLocations") + .HasColumnType("TEXT"); + + b.Property("ProductionYear") + .HasColumnType("INTEGER"); + + b.Property("RunTimeTicks") + .HasColumnType("INTEGER"); + + b.Property("SeasonId") + .HasColumnType("TEXT"); + + b.Property("SeasonName") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("SeriesName") + .HasColumnType("TEXT"); + + b.Property("SeriesPresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property("ShowId") + .HasColumnType("TEXT"); + + b.Property("Size") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("Studios") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("TopParentId") + .HasColumnType("TEXT"); + + b.Property("TotalBitrate") + .HasColumnType("INTEGER"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UnratedType") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.HasIndex("Path"); + + b.HasIndex("PresentationUniqueKey"); + + b.HasIndex("TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "PresentationUniqueKey"); + + b.HasIndex("Type", "TopParentId", "StartDate"); + + b.HasIndex("Id", "Type", "IsFolder", "IsVirtualItem"); + + b.HasIndex("MediaType", "TopParentId", "IsVirtualItem", "PresentationUniqueKey"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "IsFolder", "IsVirtualItem"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "PresentationUniqueKey", "SortName"); + + b.HasIndex("IsFolder", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.HasIndex("Type", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.ToTable("BaseItems"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Blurhash") + .HasColumnType("BLOB"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("ImageType") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemImageInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemMetadataFields"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("ProviderValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ProviderId"); + + b.HasIndex("ProviderId", "ProviderValue", "ItemId"); + + b.ToTable("BaseItemProviders"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemTrailerTypes"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ChapterIndex") + .HasColumnType("INTEGER"); + + b.Property("ImageDateModified") + .HasColumnType("TEXT"); + + b.Property("ImagePath") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("StartPositionTicks") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "ChapterIndex"); + + b.ToTable("Chapters"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.CustomItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client", "Key") + .IsUnique(); + + b.ToTable("CustomItemDisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChromecastVersion") + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DashboardTheme") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("EnableNextVideoInfoOverlay") + .HasColumnType("INTEGER"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ScrollDirection") + .HasColumnType("INTEGER"); + + b.Property("ShowBackdrop") + .HasColumnType("INTEGER"); + + b.Property("ShowSidebar") + .HasColumnType("INTEGER"); + + b.Property("SkipBackwardLength") + .HasColumnType("INTEGER"); + + b.Property("SkipForwardLength") + .HasColumnType("INTEGER"); + + b.Property("TvHome") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client") + .IsUnique(); + + b.ToTable("DisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DisplayPreferencesId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DisplayPreferencesId"); + + b.ToTable("HomeSection"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("ImageInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("RememberIndexing") + .HasColumnType("INTEGER"); + + b.Property("RememberSorting") + .HasColumnType("INTEGER"); + + b.Property("SortBy") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("ViewType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("ItemDisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b => + { + b.Property("ItemValueId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CleanValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId"); + + b.HasIndex("Type", "CleanValue") + .IsUnique(); + + b.ToTable("ItemValues"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b => + { + b.Property("ItemValueId") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("ItemValuesMap"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaSegment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EndTicks") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("SegmentProviderId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StartTicks") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("MediaSegments"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("StreamIndex") + .HasColumnType("INTEGER"); + + b.Property("AspectRatio") + .HasColumnType("TEXT"); + + b.Property("AverageFrameRate") + .HasColumnType("REAL"); + + b.Property("BitDepth") + .HasColumnType("INTEGER"); + + b.Property("BitRate") + .HasColumnType("INTEGER"); + + b.Property("BlPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("ChannelLayout") + .HasColumnType("TEXT"); + + b.Property("Channels") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasColumnType("TEXT"); + + b.Property("CodecTag") + .HasColumnType("TEXT"); + + b.Property("CodecTimeBase") + .HasColumnType("TEXT"); + + b.Property("ColorPrimaries") + .HasColumnType("TEXT"); + + b.Property("ColorSpace") + .HasColumnType("TEXT"); + + b.Property("ColorTransfer") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("DvBlSignalCompatibilityId") + .HasColumnType("INTEGER"); + + b.Property("DvLevel") + .HasColumnType("INTEGER"); + + b.Property("DvProfile") + .HasColumnType("INTEGER"); + + b.Property("DvVersionMajor") + .HasColumnType("INTEGER"); + + b.Property("DvVersionMinor") + .HasColumnType("INTEGER"); + + b.Property("ElPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("IsAnamorphic") + .HasColumnType("INTEGER"); + + b.Property("IsAvc") + .HasColumnType("INTEGER"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("IsExternal") + .HasColumnType("INTEGER"); + + b.Property("IsForced") + .HasColumnType("INTEGER"); + + b.Property("IsHearingImpaired") + .HasColumnType("INTEGER"); + + b.Property("IsInterlaced") + .HasColumnType("INTEGER"); + + b.Property("KeyFrames") + .HasColumnType("TEXT"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("REAL"); + + b.Property("NalLengthSize") + .HasColumnType("TEXT"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PixelFormat") + .HasColumnType("TEXT"); + + b.Property("Profile") + .HasColumnType("TEXT"); + + b.Property("RealFrameRate") + .HasColumnType("REAL"); + + b.Property("RefFrames") + .HasColumnType("INTEGER"); + + b.Property("Rotation") + .HasColumnType("INTEGER"); + + b.Property("RpuPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("SampleRate") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .HasColumnType("INTEGER"); + + b.Property("TimeBase") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "StreamIndex"); + + b.HasIndex("StreamIndex"); + + b.HasIndex("StreamType"); + + b.HasIndex("StreamIndex", "StreamType"); + + b.HasIndex("StreamIndex", "StreamType", "Language"); + + b.ToTable("MediaStreamInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PersonType") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("Peoples"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("PeopleId") + .HasColumnType("TEXT"); + + b.Property("ListOrder") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.HasIndex("ItemId", "ListOrder"); + + b.HasIndex("ItemId", "SortOrder"); + + b.ToTable("PeopleBaseItemMap"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Permission_Permissions_Guid") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Permissions"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Preference_Preferences_Guid") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Preferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccessToken") + .IsUnique(); + + b.ToTable("ApiKeys"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AppName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AppVersion") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("AccessToken", "DateLastActivity"); + + b.HasIndex("DeviceId", "DateLastActivity"); + + b.HasIndex("UserId", "DeviceId"); + + b.ToTable("Devices"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.DeviceOptions", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CustomName") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId") + .IsUnique(); + + b.ToTable("DeviceOptions"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.TrickplayInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.Property("Bandwidth") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Interval") + .HasColumnType("INTEGER"); + + b.Property("ThumbnailCount") + .HasColumnType("INTEGER"); + + b.Property("TileHeight") + .HasColumnType("INTEGER"); + + b.Property("TileWidth") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "Width"); + + b.ToTable("TrickplayInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AudioLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("AuthenticationProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("CastReceiverId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DisplayCollectionsView") + .HasColumnType("INTEGER"); + + b.Property("DisplayMissingEpisodes") + .HasColumnType("INTEGER"); + + b.Property("EnableAutoLogin") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalPassword") + .HasColumnType("INTEGER"); + + b.Property("EnableNextEpisodeAutoPlay") + .HasColumnType("INTEGER"); + + b.Property("EnableUserPreferenceAccess") + .HasColumnType("INTEGER"); + + b.Property("HidePlayedInLatest") + .HasColumnType("INTEGER"); + + b.Property("InternalId") + .HasColumnType("INTEGER"); + + b.Property("InvalidLoginAttemptCount") + .HasColumnType("INTEGER"); + + b.Property("LastActivityDate") + .HasColumnType("TEXT"); + + b.Property("LastLoginDate") + .HasColumnType("TEXT"); + + b.Property("LoginAttemptsBeforeLockout") + .HasColumnType("INTEGER"); + + b.Property("MaxActiveSessions") + .HasColumnType("INTEGER"); + + b.Property("MaxParentalRatingScore") + .HasColumnType("INTEGER"); + + b.Property("MaxParentalRatingSubScore") + .HasColumnType("INTEGER"); + + b.Property("MustUpdatePassword") + .HasColumnType("INTEGER"); + + b.Property("Password") + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.Property("PasswordResetProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("PlayDefaultAudioTrack") + .HasColumnType("INTEGER"); + + b.Property("RememberAudioSelections") + .HasColumnType("INTEGER"); + + b.Property("RememberSubtitleSelections") + .HasColumnType("INTEGER"); + + b.Property("RemoteClientBitrateLimit") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SubtitleLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("SubtitleMode") + .HasColumnType("INTEGER"); + + b.Property("SyncPlayAccess") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("CustomDataKey") + .HasColumnType("TEXT"); + + b.Property("AudioStreamIndex") + .HasColumnType("INTEGER"); + + b.Property("IsFavorite") + .HasColumnType("INTEGER"); + + b.Property("LastPlayedDate") + .HasColumnType("TEXT"); + + b.Property("Likes") + .HasColumnType("INTEGER"); + + b.Property("PlayCount") + .HasColumnType("INTEGER"); + + b.Property("PlaybackPositionTicks") + .HasColumnType("INTEGER"); + + b.Property("Played") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("SubtitleStreamIndex") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "UserId", "CustomDataKey"); + + b.HasIndex("UserId"); + + b.HasIndex("ItemId", "UserId", "IsFavorite"); + + b.HasIndex("ItemId", "UserId", "LastPlayedDate"); + + b.HasIndex("ItemId", "UserId", "PlaybackPositionTicks"); + + b.HasIndex("ItemId", "UserId", "Played"); + + b.ToTable("UserData"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("AccessSchedules") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Parents") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "ParentItem") + .WithMany("Children") + .HasForeignKey("ParentItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ParentItem"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Images") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("LockedFields") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Provider") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("TrailerTypes") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Chapters") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("DisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.DisplayPreferences", null) + .WithMany("HomeSections") + .HasForeignKey("DisplayPreferencesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithOne("ProfileImage") + .HasForeignKey("Jellyfin.Database.Implementations.Entities.ImageInfo", "UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("ItemDisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("ItemValues") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.ItemValue", "ItemValue") + .WithMany("BaseItemsMap") + .HasForeignKey("ItemValueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ItemValue"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("MediaStreams") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Peoples") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.People", "People") + .WithMany("BaseItems") + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("People"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("Permissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("Preferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("UserData") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.Navigation("Chapters"); + + b.Navigation("Children"); + + b.Navigation("Images"); + + b.Navigation("ItemValues"); + + b.Navigation("LockedFields"); + + b.Navigation("MediaStreams"); + + b.Navigation("Parents"); + + b.Navigation("Peoples"); + + b.Navigation("Provider"); + + b.Navigation("TrailerTypes"); + + b.Navigation("UserData"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.Navigation("HomeSections"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b => + { + b.Navigation("BaseItemsMap"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b => + { + b.Navigation("BaseItems"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b => + { + b.Navigation("AccessSchedules"); + + b.Navigation("DisplayPreferences"); + + b.Navigation("ItemDisplayPreferences"); + + b.Navigation("Permissions"); + + b.Navigation("Preferences"); + + b.Navigation("ProfileImage"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250401142247_FixAncestors.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250401142247_FixAncestors.cs new file mode 100644 index 0000000000..e1220bfcf7 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250401142247_FixAncestors.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + /// + public partial class FixAncestors : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250405075612_FixItemValuesIndices.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250405075612_FixItemValuesIndices.Designer.cs new file mode 100644 index 0000000000..4ba3352edc --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250405075612_FixItemValuesIndices.Designer.cs @@ -0,0 +1,1694 @@ +// +using System; +using Jellyfin.Database.Implementations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + [DbContext(typeof(JellyfinDbContext))] + [Migration("20250405075612_FixItemValuesIndices")] + partial class FixItemValuesIndices + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.3"); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property("EndHour") + .HasColumnType("REAL"); + + b.Property("StartHour") + .HasColumnType("REAL"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AccessSchedules"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ActivityLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LogSeverity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ShortOverview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DateCreated"); + + b.ToTable("ActivityLogs"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ParentItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ParentItemId"); + + b.HasIndex("ParentItemId"); + + b.ToTable("AncestorIds"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasColumnType("TEXT"); + + b.Property("CodecTag") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Filename") + .HasColumnType("TEXT"); + + b.Property("MimeType") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "Index"); + + b.ToTable("AttachmentStreamInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Album") + .HasColumnType("TEXT"); + + b.Property("AlbumArtists") + .HasColumnType("TEXT"); + + b.Property("Artists") + .HasColumnType("TEXT"); + + b.Property("Audio") + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("CleanName") + .HasColumnType("TEXT"); + + b.Property("CommunityRating") + .HasColumnType("REAL"); + + b.Property("CriticRating") + .HasColumnType("REAL"); + + b.Property("CustomRating") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateCreatedFilesystem") + .HasColumnType("TEXT"); + + b.Property("DateLastMediaAdded") + .HasColumnType("TEXT"); + + b.Property("DateLastRefreshed") + .HasColumnType("TEXT"); + + b.Property("DateLastSaved") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("EpisodeTitle") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasColumnType("TEXT"); + + b.Property("ExternalSeriesId") + .HasColumnType("TEXT"); + + b.Property("ExternalServiceId") + .HasColumnType("TEXT"); + + b.Property("ExtraIds") + .HasColumnType("TEXT"); + + b.Property("ExtraType") + .HasColumnType("INTEGER"); + + b.Property("ForcedSortName") + .HasColumnType("TEXT"); + + b.Property("Genres") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("IndexNumber") + .HasColumnType("INTEGER"); + + b.Property("InheritedParentalRatingSubValue") + .HasColumnType("INTEGER"); + + b.Property("InheritedParentalRatingValue") + .HasColumnType("INTEGER"); + + b.Property("IsFolder") + .HasColumnType("INTEGER"); + + b.Property("IsInMixedFolder") + .HasColumnType("INTEGER"); + + b.Property("IsLocked") + .HasColumnType("INTEGER"); + + b.Property("IsMovie") + .HasColumnType("INTEGER"); + + b.Property("IsRepeat") + .HasColumnType("INTEGER"); + + b.Property("IsSeries") + .HasColumnType("INTEGER"); + + b.Property("IsVirtualItem") + .HasColumnType("INTEGER"); + + b.Property("LUFS") + .HasColumnType("REAL"); + + b.Property("MediaType") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizationGain") + .HasColumnType("REAL"); + + b.Property("OfficialRating") + .HasColumnType("TEXT"); + + b.Property("OriginalTitle") + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("OwnerId") + .HasColumnType("TEXT"); + + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("ParentIndexNumber") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PreferredMetadataCountryCode") + .HasColumnType("TEXT"); + + b.Property("PreferredMetadataLanguage") + .HasColumnType("TEXT"); + + b.Property("PremiereDate") + .HasColumnType("TEXT"); + + b.Property("PresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property("PrimaryVersionId") + .HasColumnType("TEXT"); + + b.Property("ProductionLocations") + .HasColumnType("TEXT"); + + b.Property("ProductionYear") + .HasColumnType("INTEGER"); + + b.Property("RunTimeTicks") + .HasColumnType("INTEGER"); + + b.Property("SeasonId") + .HasColumnType("TEXT"); + + b.Property("SeasonName") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("SeriesName") + .HasColumnType("TEXT"); + + b.Property("SeriesPresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property("ShowId") + .HasColumnType("TEXT"); + + b.Property("Size") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("Studios") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("TopParentId") + .HasColumnType("TEXT"); + + b.Property("TotalBitrate") + .HasColumnType("INTEGER"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UnratedType") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.HasIndex("Path"); + + b.HasIndex("PresentationUniqueKey"); + + b.HasIndex("TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "PresentationUniqueKey"); + + b.HasIndex("Type", "TopParentId", "StartDate"); + + b.HasIndex("Id", "Type", "IsFolder", "IsVirtualItem"); + + b.HasIndex("MediaType", "TopParentId", "IsVirtualItem", "PresentationUniqueKey"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "IsFolder", "IsVirtualItem"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "PresentationUniqueKey", "SortName"); + + b.HasIndex("IsFolder", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.HasIndex("Type", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.ToTable("BaseItems"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Blurhash") + .HasColumnType("BLOB"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("ImageType") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemImageInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemMetadataFields"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("ProviderValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ProviderId"); + + b.HasIndex("ProviderId", "ProviderValue", "ItemId"); + + b.ToTable("BaseItemProviders"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemTrailerTypes"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ChapterIndex") + .HasColumnType("INTEGER"); + + b.Property("ImageDateModified") + .HasColumnType("TEXT"); + + b.Property("ImagePath") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("StartPositionTicks") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "ChapterIndex"); + + b.ToTable("Chapters"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.CustomItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client", "Key") + .IsUnique(); + + b.ToTable("CustomItemDisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChromecastVersion") + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DashboardTheme") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("EnableNextVideoInfoOverlay") + .HasColumnType("INTEGER"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ScrollDirection") + .HasColumnType("INTEGER"); + + b.Property("ShowBackdrop") + .HasColumnType("INTEGER"); + + b.Property("ShowSidebar") + .HasColumnType("INTEGER"); + + b.Property("SkipBackwardLength") + .HasColumnType("INTEGER"); + + b.Property("SkipForwardLength") + .HasColumnType("INTEGER"); + + b.Property("TvHome") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client") + .IsUnique(); + + b.ToTable("DisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DisplayPreferencesId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DisplayPreferencesId"); + + b.ToTable("HomeSection"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("ImageInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("RememberIndexing") + .HasColumnType("INTEGER"); + + b.Property("RememberSorting") + .HasColumnType("INTEGER"); + + b.Property("SortBy") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("ViewType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("ItemDisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b => + { + b.Property("ItemValueId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CleanValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId"); + + b.HasIndex("Type", "CleanValue"); + + b.HasIndex("Type", "Value") + .IsUnique(); + + b.ToTable("ItemValues"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b => + { + b.Property("ItemValueId") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("ItemValuesMap"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("KeyframeTicks") + .HasColumnType("TEXT"); + + b.Property("TotalDuration") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId"); + + b.ToTable("KeyframeData"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaSegment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EndTicks") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("SegmentProviderId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StartTicks") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("MediaSegments"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("StreamIndex") + .HasColumnType("INTEGER"); + + b.Property("AspectRatio") + .HasColumnType("TEXT"); + + b.Property("AverageFrameRate") + .HasColumnType("REAL"); + + b.Property("BitDepth") + .HasColumnType("INTEGER"); + + b.Property("BitRate") + .HasColumnType("INTEGER"); + + b.Property("BlPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("ChannelLayout") + .HasColumnType("TEXT"); + + b.Property("Channels") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasColumnType("TEXT"); + + b.Property("CodecTag") + .HasColumnType("TEXT"); + + b.Property("CodecTimeBase") + .HasColumnType("TEXT"); + + b.Property("ColorPrimaries") + .HasColumnType("TEXT"); + + b.Property("ColorSpace") + .HasColumnType("TEXT"); + + b.Property("ColorTransfer") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("DvBlSignalCompatibilityId") + .HasColumnType("INTEGER"); + + b.Property("DvLevel") + .HasColumnType("INTEGER"); + + b.Property("DvProfile") + .HasColumnType("INTEGER"); + + b.Property("DvVersionMajor") + .HasColumnType("INTEGER"); + + b.Property("DvVersionMinor") + .HasColumnType("INTEGER"); + + b.Property("ElPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("Hdr10PlusPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("IsAnamorphic") + .HasColumnType("INTEGER"); + + b.Property("IsAvc") + .HasColumnType("INTEGER"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("IsExternal") + .HasColumnType("INTEGER"); + + b.Property("IsForced") + .HasColumnType("INTEGER"); + + b.Property("IsHearingImpaired") + .HasColumnType("INTEGER"); + + b.Property("IsInterlaced") + .HasColumnType("INTEGER"); + + b.Property("KeyFrames") + .HasColumnType("TEXT"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("REAL"); + + b.Property("NalLengthSize") + .HasColumnType("TEXT"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PixelFormat") + .HasColumnType("TEXT"); + + b.Property("Profile") + .HasColumnType("TEXT"); + + b.Property("RealFrameRate") + .HasColumnType("REAL"); + + b.Property("RefFrames") + .HasColumnType("INTEGER"); + + b.Property("Rotation") + .HasColumnType("INTEGER"); + + b.Property("RpuPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("SampleRate") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .HasColumnType("INTEGER"); + + b.Property("TimeBase") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "StreamIndex"); + + b.HasIndex("StreamIndex"); + + b.HasIndex("StreamType"); + + b.HasIndex("StreamIndex", "StreamType"); + + b.HasIndex("StreamIndex", "StreamType", "Language"); + + b.ToTable("MediaStreamInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PersonType") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("Peoples"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("PeopleId") + .HasColumnType("TEXT"); + + b.Property("ListOrder") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.HasIndex("ItemId", "ListOrder"); + + b.HasIndex("ItemId", "SortOrder"); + + b.ToTable("PeopleBaseItemMap"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Permission_Permissions_Guid") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Permissions"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Preference_Preferences_Guid") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Preferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccessToken") + .IsUnique(); + + b.ToTable("ApiKeys"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AppName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AppVersion") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("AccessToken", "DateLastActivity"); + + b.HasIndex("DeviceId", "DateLastActivity"); + + b.HasIndex("UserId", "DeviceId"); + + b.ToTable("Devices"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.DeviceOptions", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CustomName") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId") + .IsUnique(); + + b.ToTable("DeviceOptions"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.TrickplayInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.Property("Bandwidth") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Interval") + .HasColumnType("INTEGER"); + + b.Property("ThumbnailCount") + .HasColumnType("INTEGER"); + + b.Property("TileHeight") + .HasColumnType("INTEGER"); + + b.Property("TileWidth") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "Width"); + + b.ToTable("TrickplayInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AudioLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("AuthenticationProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("CastReceiverId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DisplayCollectionsView") + .HasColumnType("INTEGER"); + + b.Property("DisplayMissingEpisodes") + .HasColumnType("INTEGER"); + + b.Property("EnableAutoLogin") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalPassword") + .HasColumnType("INTEGER"); + + b.Property("EnableNextEpisodeAutoPlay") + .HasColumnType("INTEGER"); + + b.Property("EnableUserPreferenceAccess") + .HasColumnType("INTEGER"); + + b.Property("HidePlayedInLatest") + .HasColumnType("INTEGER"); + + b.Property("InternalId") + .HasColumnType("INTEGER"); + + b.Property("InvalidLoginAttemptCount") + .HasColumnType("INTEGER"); + + b.Property("LastActivityDate") + .HasColumnType("TEXT"); + + b.Property("LastLoginDate") + .HasColumnType("TEXT"); + + b.Property("LoginAttemptsBeforeLockout") + .HasColumnType("INTEGER"); + + b.Property("MaxActiveSessions") + .HasColumnType("INTEGER"); + + b.Property("MaxParentalRatingScore") + .HasColumnType("INTEGER"); + + b.Property("MaxParentalRatingSubScore") + .HasColumnType("INTEGER"); + + b.Property("MustUpdatePassword") + .HasColumnType("INTEGER"); + + b.Property("Password") + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.Property("PasswordResetProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("PlayDefaultAudioTrack") + .HasColumnType("INTEGER"); + + b.Property("RememberAudioSelections") + .HasColumnType("INTEGER"); + + b.Property("RememberSubtitleSelections") + .HasColumnType("INTEGER"); + + b.Property("RemoteClientBitrateLimit") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SubtitleLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("SubtitleMode") + .HasColumnType("INTEGER"); + + b.Property("SyncPlayAccess") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("CustomDataKey") + .HasColumnType("TEXT"); + + b.Property("AudioStreamIndex") + .HasColumnType("INTEGER"); + + b.Property("IsFavorite") + .HasColumnType("INTEGER"); + + b.Property("LastPlayedDate") + .HasColumnType("TEXT"); + + b.Property("Likes") + .HasColumnType("INTEGER"); + + b.Property("PlayCount") + .HasColumnType("INTEGER"); + + b.Property("PlaybackPositionTicks") + .HasColumnType("INTEGER"); + + b.Property("Played") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("SubtitleStreamIndex") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "UserId", "CustomDataKey"); + + b.HasIndex("UserId"); + + b.HasIndex("ItemId", "UserId", "IsFavorite"); + + b.HasIndex("ItemId", "UserId", "LastPlayedDate"); + + b.HasIndex("ItemId", "UserId", "PlaybackPositionTicks"); + + b.HasIndex("ItemId", "UserId", "Played"); + + b.ToTable("UserData"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("AccessSchedules") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Parents") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "ParentItem") + .WithMany("Children") + .HasForeignKey("ParentItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ParentItem"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Images") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("LockedFields") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Provider") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("TrailerTypes") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Chapters") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("DisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.DisplayPreferences", null) + .WithMany("HomeSections") + .HasForeignKey("DisplayPreferencesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithOne("ProfileImage") + .HasForeignKey("Jellyfin.Database.Implementations.Entities.ImageInfo", "UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("ItemDisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("ItemValues") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.ItemValue", "ItemValue") + .WithMany("BaseItemsMap") + .HasForeignKey("ItemValueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ItemValue"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("MediaStreams") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Peoples") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.People", "People") + .WithMany("BaseItems") + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("People"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("Permissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("Preferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("UserData") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.Navigation("Chapters"); + + b.Navigation("Children"); + + b.Navigation("Images"); + + b.Navigation("ItemValues"); + + b.Navigation("LockedFields"); + + b.Navigation("MediaStreams"); + + b.Navigation("Parents"); + + b.Navigation("Peoples"); + + b.Navigation("Provider"); + + b.Navigation("TrailerTypes"); + + b.Navigation("UserData"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.Navigation("HomeSections"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b => + { + b.Navigation("BaseItemsMap"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b => + { + b.Navigation("BaseItems"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b => + { + b.Navigation("AccessSchedules"); + + b.Navigation("DisplayPreferences"); + + b.Navigation("ItemDisplayPreferences"); + + b.Navigation("Permissions"); + + b.Navigation("Preferences"); + + b.Navigation("ProfileImage"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250405075612_FixItemValuesIndices.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250405075612_FixItemValuesIndices.cs new file mode 100644 index 0000000000..aa667bafd4 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250405075612_FixItemValuesIndices.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + /// + public partial class FixItemValuesIndices : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_ItemValues_Type_CleanValue", + table: "ItemValues"); + + migrationBuilder.CreateIndex( + name: "IX_ItemValues_Type_CleanValue", + table: "ItemValues", + columns: new[] { "Type", "CleanValue" }); + + migrationBuilder.CreateIndex( + name: "IX_ItemValues_Type_Value", + table: "ItemValues", + columns: new[] { "Type", "Value" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_ItemValues_Type_CleanValue", + table: "ItemValues"); + + migrationBuilder.DropIndex( + name: "IX_ItemValues_Type_Value", + table: "ItemValues"); + + migrationBuilder.CreateIndex( + name: "IX_ItemValues_Type_CleanValue", + table: "ItemValues", + columns: new[] { "Type", "CleanValue" }, + unique: true); + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs new file mode 100644 index 0000000000..dcdc5dd3ec --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs @@ -0,0 +1,1688 @@ +// +using System; +using Jellyfin.Database.Implementations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + [DbContext(typeof(JellyfinDbContext))] + partial class JellyfinDbModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.3"); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property("EndHour") + .HasColumnType("REAL"); + + b.Property("StartHour") + .HasColumnType("REAL"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AccessSchedules"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ActivityLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LogSeverity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ShortOverview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DateCreated"); + + b.ToTable("ActivityLogs"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ParentItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ParentItemId"); + + b.HasIndex("ParentItemId"); + + b.ToTable("AncestorIds"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasColumnType("TEXT"); + + b.Property("CodecTag") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Filename") + .HasColumnType("TEXT"); + + b.Property("MimeType") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "Index"); + + b.ToTable("AttachmentStreamInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Album") + .HasColumnType("TEXT"); + + b.Property("AlbumArtists") + .HasColumnType("TEXT"); + + b.Property("Artists") + .HasColumnType("TEXT"); + + b.Property("Audio") + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("CleanName") + .HasColumnType("TEXT"); + + b.Property("CommunityRating") + .HasColumnType("REAL"); + + b.Property("CriticRating") + .HasColumnType("REAL"); + + b.Property("CustomRating") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastMediaAdded") + .HasColumnType("TEXT"); + + b.Property("DateLastRefreshed") + .HasColumnType("TEXT"); + + b.Property("DateLastSaved") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("EpisodeTitle") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasColumnType("TEXT"); + + b.Property("ExternalSeriesId") + .HasColumnType("TEXT"); + + b.Property("ExternalServiceId") + .HasColumnType("TEXT"); + + b.Property("ExtraIds") + .HasColumnType("TEXT"); + + b.Property("ExtraType") + .HasColumnType("INTEGER"); + + b.Property("ForcedSortName") + .HasColumnType("TEXT"); + + b.Property("Genres") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("IndexNumber") + .HasColumnType("INTEGER"); + + b.Property("InheritedParentalRatingSubValue") + .HasColumnType("INTEGER"); + + b.Property("InheritedParentalRatingValue") + .HasColumnType("INTEGER"); + + b.Property("IsFolder") + .HasColumnType("INTEGER"); + + b.Property("IsInMixedFolder") + .HasColumnType("INTEGER"); + + b.Property("IsLocked") + .HasColumnType("INTEGER"); + + b.Property("IsMovie") + .HasColumnType("INTEGER"); + + b.Property("IsRepeat") + .HasColumnType("INTEGER"); + + b.Property("IsSeries") + .HasColumnType("INTEGER"); + + b.Property("IsVirtualItem") + .HasColumnType("INTEGER"); + + b.Property("LUFS") + .HasColumnType("REAL"); + + b.Property("MediaType") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizationGain") + .HasColumnType("REAL"); + + b.Property("OfficialRating") + .HasColumnType("TEXT"); + + b.Property("OriginalTitle") + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("OwnerId") + .HasColumnType("TEXT"); + + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("ParentIndexNumber") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PreferredMetadataCountryCode") + .HasColumnType("TEXT"); + + b.Property("PreferredMetadataLanguage") + .HasColumnType("TEXT"); + + b.Property("PremiereDate") + .HasColumnType("TEXT"); + + b.Property("PresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property("PrimaryVersionId") + .HasColumnType("TEXT"); + + b.Property("ProductionLocations") + .HasColumnType("TEXT"); + + b.Property("ProductionYear") + .HasColumnType("INTEGER"); + + b.Property("RunTimeTicks") + .HasColumnType("INTEGER"); + + b.Property("SeasonId") + .HasColumnType("TEXT"); + + b.Property("SeasonName") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("SeriesName") + .HasColumnType("TEXT"); + + b.Property("SeriesPresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property("ShowId") + .HasColumnType("TEXT"); + + b.Property("Size") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("Studios") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("TopParentId") + .HasColumnType("TEXT"); + + b.Property("TotalBitrate") + .HasColumnType("INTEGER"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UnratedType") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.HasIndex("Path"); + + b.HasIndex("PresentationUniqueKey"); + + b.HasIndex("TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "PresentationUniqueKey"); + + b.HasIndex("Type", "TopParentId", "StartDate"); + + b.HasIndex("Id", "Type", "IsFolder", "IsVirtualItem"); + + b.HasIndex("MediaType", "TopParentId", "IsVirtualItem", "PresentationUniqueKey"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "IsFolder", "IsVirtualItem"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "PresentationUniqueKey", "SortName"); + + b.HasIndex("IsFolder", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.HasIndex("Type", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.ToTable("BaseItems"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Blurhash") + .HasColumnType("BLOB"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("ImageType") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemImageInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemMetadataFields"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("ProviderValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ProviderId"); + + b.HasIndex("ProviderId", "ProviderValue", "ItemId"); + + b.ToTable("BaseItemProviders"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemTrailerTypes"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ChapterIndex") + .HasColumnType("INTEGER"); + + b.Property("ImageDateModified") + .HasColumnType("TEXT"); + + b.Property("ImagePath") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("StartPositionTicks") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "ChapterIndex"); + + b.ToTable("Chapters"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.CustomItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client", "Key") + .IsUnique(); + + b.ToTable("CustomItemDisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChromecastVersion") + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DashboardTheme") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("EnableNextVideoInfoOverlay") + .HasColumnType("INTEGER"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ScrollDirection") + .HasColumnType("INTEGER"); + + b.Property("ShowBackdrop") + .HasColumnType("INTEGER"); + + b.Property("ShowSidebar") + .HasColumnType("INTEGER"); + + b.Property("SkipBackwardLength") + .HasColumnType("INTEGER"); + + b.Property("SkipForwardLength") + .HasColumnType("INTEGER"); + + b.Property("TvHome") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client") + .IsUnique(); + + b.ToTable("DisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DisplayPreferencesId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DisplayPreferencesId"); + + b.ToTable("HomeSection"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("ImageInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("RememberIndexing") + .HasColumnType("INTEGER"); + + b.Property("RememberSorting") + .HasColumnType("INTEGER"); + + b.Property("SortBy") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("ViewType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("ItemDisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b => + { + b.Property("ItemValueId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CleanValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId"); + + b.HasIndex("Type", "CleanValue"); + + b.HasIndex("Type", "Value") + .IsUnique(); + + b.ToTable("ItemValues"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b => + { + b.Property("ItemValueId") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("ItemValuesMap"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("KeyframeTicks") + .HasColumnType("TEXT"); + + b.Property("TotalDuration") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId"); + + b.ToTable("KeyframeData"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaSegment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EndTicks") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("SegmentProviderId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StartTicks") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("MediaSegments"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("StreamIndex") + .HasColumnType("INTEGER"); + + b.Property("AspectRatio") + .HasColumnType("TEXT"); + + b.Property("AverageFrameRate") + .HasColumnType("REAL"); + + b.Property("BitDepth") + .HasColumnType("INTEGER"); + + b.Property("BitRate") + .HasColumnType("INTEGER"); + + b.Property("BlPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("ChannelLayout") + .HasColumnType("TEXT"); + + b.Property("Channels") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasColumnType("TEXT"); + + b.Property("CodecTag") + .HasColumnType("TEXT"); + + b.Property("CodecTimeBase") + .HasColumnType("TEXT"); + + b.Property("ColorPrimaries") + .HasColumnType("TEXT"); + + b.Property("ColorSpace") + .HasColumnType("TEXT"); + + b.Property("ColorTransfer") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("DvBlSignalCompatibilityId") + .HasColumnType("INTEGER"); + + b.Property("DvLevel") + .HasColumnType("INTEGER"); + + b.Property("DvProfile") + .HasColumnType("INTEGER"); + + b.Property("DvVersionMajor") + .HasColumnType("INTEGER"); + + b.Property("DvVersionMinor") + .HasColumnType("INTEGER"); + + b.Property("ElPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("Hdr10PlusPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("IsAnamorphic") + .HasColumnType("INTEGER"); + + b.Property("IsAvc") + .HasColumnType("INTEGER"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("IsExternal") + .HasColumnType("INTEGER"); + + b.Property("IsForced") + .HasColumnType("INTEGER"); + + b.Property("IsHearingImpaired") + .HasColumnType("INTEGER"); + + b.Property("IsInterlaced") + .HasColumnType("INTEGER"); + + b.Property("KeyFrames") + .HasColumnType("TEXT"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("REAL"); + + b.Property("NalLengthSize") + .HasColumnType("TEXT"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PixelFormat") + .HasColumnType("TEXT"); + + b.Property("Profile") + .HasColumnType("TEXT"); + + b.Property("RealFrameRate") + .HasColumnType("REAL"); + + b.Property("RefFrames") + .HasColumnType("INTEGER"); + + b.Property("Rotation") + .HasColumnType("INTEGER"); + + b.Property("RpuPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("SampleRate") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .HasColumnType("INTEGER"); + + b.Property("TimeBase") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "StreamIndex"); + + b.HasIndex("StreamIndex"); + + b.HasIndex("StreamType"); + + b.HasIndex("StreamIndex", "StreamType"); + + b.HasIndex("StreamIndex", "StreamType", "Language"); + + b.ToTable("MediaStreamInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PersonType") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("Peoples"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("PeopleId") + .HasColumnType("TEXT"); + + b.Property("ListOrder") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.HasIndex("ItemId", "ListOrder"); + + b.HasIndex("ItemId", "SortOrder"); + + b.ToTable("PeopleBaseItemMap"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Permission_Permissions_Guid") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Permissions"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Preference_Preferences_Guid") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Preferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccessToken") + .IsUnique(); + + b.ToTable("ApiKeys"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AppName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AppVersion") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("AccessToken", "DateLastActivity"); + + b.HasIndex("DeviceId", "DateLastActivity"); + + b.HasIndex("UserId", "DeviceId"); + + b.ToTable("Devices"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.DeviceOptions", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CustomName") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId") + .IsUnique(); + + b.ToTable("DeviceOptions"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.TrickplayInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.Property("Bandwidth") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Interval") + .HasColumnType("INTEGER"); + + b.Property("ThumbnailCount") + .HasColumnType("INTEGER"); + + b.Property("TileHeight") + .HasColumnType("INTEGER"); + + b.Property("TileWidth") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "Width"); + + b.ToTable("TrickplayInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AudioLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("AuthenticationProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("CastReceiverId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DisplayCollectionsView") + .HasColumnType("INTEGER"); + + b.Property("DisplayMissingEpisodes") + .HasColumnType("INTEGER"); + + b.Property("EnableAutoLogin") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalPassword") + .HasColumnType("INTEGER"); + + b.Property("EnableNextEpisodeAutoPlay") + .HasColumnType("INTEGER"); + + b.Property("EnableUserPreferenceAccess") + .HasColumnType("INTEGER"); + + b.Property("HidePlayedInLatest") + .HasColumnType("INTEGER"); + + b.Property("InternalId") + .HasColumnType("INTEGER"); + + b.Property("InvalidLoginAttemptCount") + .HasColumnType("INTEGER"); + + b.Property("LastActivityDate") + .HasColumnType("TEXT"); + + b.Property("LastLoginDate") + .HasColumnType("TEXT"); + + b.Property("LoginAttemptsBeforeLockout") + .HasColumnType("INTEGER"); + + b.Property("MaxActiveSessions") + .HasColumnType("INTEGER"); + + b.Property("MaxParentalRatingScore") + .HasColumnType("INTEGER"); + + b.Property("MaxParentalRatingSubScore") + .HasColumnType("INTEGER"); + + b.Property("MustUpdatePassword") + .HasColumnType("INTEGER"); + + b.Property("Password") + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.Property("PasswordResetProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("PlayDefaultAudioTrack") + .HasColumnType("INTEGER"); + + b.Property("RememberAudioSelections") + .HasColumnType("INTEGER"); + + b.Property("RememberSubtitleSelections") + .HasColumnType("INTEGER"); + + b.Property("RemoteClientBitrateLimit") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SubtitleLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("SubtitleMode") + .HasColumnType("INTEGER"); + + b.Property("SyncPlayAccess") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("CustomDataKey") + .HasColumnType("TEXT"); + + b.Property("AudioStreamIndex") + .HasColumnType("INTEGER"); + + b.Property("IsFavorite") + .HasColumnType("INTEGER"); + + b.Property("LastPlayedDate") + .HasColumnType("TEXT"); + + b.Property("Likes") + .HasColumnType("INTEGER"); + + b.Property("PlayCount") + .HasColumnType("INTEGER"); + + b.Property("PlaybackPositionTicks") + .HasColumnType("INTEGER"); + + b.Property("Played") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("SubtitleStreamIndex") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "UserId", "CustomDataKey"); + + b.HasIndex("UserId"); + + b.HasIndex("ItemId", "UserId", "IsFavorite"); + + b.HasIndex("ItemId", "UserId", "LastPlayedDate"); + + b.HasIndex("ItemId", "UserId", "PlaybackPositionTicks"); + + b.HasIndex("ItemId", "UserId", "Played"); + + b.ToTable("UserData"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("AccessSchedules") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Parents") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "ParentItem") + .WithMany("Children") + .HasForeignKey("ParentItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ParentItem"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Images") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("LockedFields") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Provider") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("TrailerTypes") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Chapters") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("DisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.DisplayPreferences", null) + .WithMany("HomeSections") + .HasForeignKey("DisplayPreferencesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithOne("ProfileImage") + .HasForeignKey("Jellyfin.Database.Implementations.Entities.ImageInfo", "UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("ItemDisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("ItemValues") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.ItemValue", "ItemValue") + .WithMany("BaseItemsMap") + .HasForeignKey("ItemValueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ItemValue"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("MediaStreams") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Peoples") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.People", "People") + .WithMany("BaseItems") + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("People"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("Permissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("Preferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("UserData") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.Navigation("Chapters"); + + b.Navigation("Children"); + + b.Navigation("Images"); + + b.Navigation("ItemValues"); + + b.Navigation("LockedFields"); + + b.Navigation("MediaStreams"); + + b.Navigation("Parents"); + + b.Navigation("Peoples"); + + b.Navigation("Provider"); + + b.Navigation("TrailerTypes"); + + b.Navigation("UserData"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.Navigation("HomeSections"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b => + { + b.Navigation("BaseItemsMap"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b => + { + b.Navigation("BaseItems"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b => + { + b.Navigation("AccessSchedules"); + + b.Navigation("DisplayPreferences"); + + b.Navigation("ItemDisplayPreferences"); + + b.Navigation("Permissions"); + + b.Navigation("Preferences"); + + b.Navigation("ProfileImage"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/SqliteDesignTimeJellyfinDbFactory.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/SqliteDesignTimeJellyfinDbFactory.cs new file mode 100644 index 0000000000..78815c3118 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/SqliteDesignTimeJellyfinDbFactory.cs @@ -0,0 +1,25 @@ +using Jellyfin.Database.Implementations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Jellyfin.Database.Providers.Sqlite.Migrations +{ + /// + /// The design time factory for . + /// This is only used for the creation of migrations and not during runtime. + /// + internal sealed class SqliteDesignTimeJellyfinDbFactory : IDesignTimeDbContextFactory + { + public JellyfinDbContext CreateDbContext(string[] args) + { + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseSqlite("Data Source=jellyfin.db", f => f.MigrationsAssembly(GetType().Assembly)); + + return new JellyfinDbContext( + optionsBuilder.Options, + NullLogger.Instance, + new SqliteDatabaseProvider(null!, NullLogger.Instance)); + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/ModelBuilderExtensions.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/ModelBuilderExtensions.cs new file mode 100644 index 0000000000..41375874d9 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/ModelBuilderExtensions.cs @@ -0,0 +1,47 @@ +using System; +using Jellyfin.Database.Providers.Sqlite.ValueConverters; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace Jellyfin.Database.Providers.Sqlite; + +/// +/// Model builder extensions. +/// +public static class ModelBuilderExtensions +{ + /// + /// Specify value converter for the object type. + /// + /// The model builder. + /// The . + /// The type to convert. + /// The modified . + public static ModelBuilder UseValueConverterForType(this ModelBuilder modelBuilder, ValueConverter converter) + { + var type = typeof(T); + foreach (var entityType in modelBuilder.Model.GetEntityTypes()) + { + foreach (var property in entityType.GetProperties()) + { + if (property.ClrType == type) + { + property.SetValueConverter(converter); + } + } + } + + return modelBuilder; + } + + /// + /// Specify the default . + /// + /// The model builder to extend. + /// The to specify. + public static void SetDefaultDateTimeKind(this ModelBuilder modelBuilder, DateTimeKind kind) + { + modelBuilder.UseValueConverterForType(new DateTimeKindValueConverter(kind)); + modelBuilder.UseValueConverterForType(new DateTimeKindValueConverter(kind)); + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Properties/AssemblyInfo.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..6c5c7107ea --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Properties/AssemblyInfo.cs @@ -0,0 +1,23 @@ +using System.Reflection; +using System.Resources; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("Jellyfin.Database.Providers.Sqlite")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("Jellyfin Project")] +[assembly: AssemblyProduct("Jellyfin Server")] +[assembly: AssemblyCopyright("Copyright © 2019 Jellyfin Contributors. Code released under the GNU General Public License")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] +[assembly: NeutralResourcesLanguage("en")] +[assembly: InternalsVisibleTo("Jellyfin.Server.Implementations.Tests")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs new file mode 100644 index 0000000000..bd5631074f --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs @@ -0,0 +1,123 @@ +using System; +using System.Globalization; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using MediaBrowser.Common.Configuration; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Database.Providers.Sqlite; + +/// +/// Configures jellyfin to use an SQLite database. +/// +[JellyfinDatabaseProviderKey("Jellyfin-SQLite")] +public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider +{ + private const string BackupFolderName = "SQLiteBackups"; + private readonly IApplicationPaths _applicationPaths; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// Service to construct the fallback when the old data path configuration is used. + /// A logger. + public SqliteDatabaseProvider(IApplicationPaths applicationPaths, ILogger logger) + { + _applicationPaths = applicationPaths; + _logger = logger; + } + + /// + public IDbContextFactory? DbContextFactory { get; set; } + + /// + public void Initialise(DbContextOptionsBuilder options) + { + options.UseSqlite( + $"Filename={Path.Combine(_applicationPaths.DataPath, "jellyfin.db")};Pooling=false", + sqLiteOptions => sqLiteOptions.MigrationsAssembly(GetType().Assembly)); + } + + /// + public async Task RunScheduledOptimisation(CancellationToken cancellationToken) + { + var context = await DbContextFactory!.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (context.ConfigureAwait(false)) + { + if (context.Database.IsSqlite()) + { + await context.Database.ExecuteSqlRawAsync("PRAGMA optimize", cancellationToken).ConfigureAwait(false); + await context.Database.ExecuteSqlRawAsync("VACUUM", cancellationToken).ConfigureAwait(false); + _logger.LogInformation("jellyfin.db optimized successfully!"); + } + else + { + _logger.LogInformation("This database doesn't support optimization"); + } + } + } + + /// + public void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.SetDefaultDateTimeKind(DateTimeKind.Utc); + } + + /// + public async Task RunShutdownTask(CancellationToken cancellationToken) + { + // Run before disposing the application + var context = await DbContextFactory!.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (context.ConfigureAwait(false)) + { + await context.Database.ExecuteSqlRawAsync("PRAGMA optimize", cancellationToken).ConfigureAwait(false); + } + + SqliteConnection.ClearAllPools(); + } + + /// + public void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + configurationBuilder.Conventions.Add(_ => new DoNotUseReturningClauseConvention()); + } + + /// + public Task MigrationBackupFast(CancellationToken cancellationToken) + { + var key = DateTime.UtcNow.ToString("yyyyMMddhhmmss", CultureInfo.InvariantCulture); + var path = Path.Combine(_applicationPaths.DataPath, "jellyfin.db"); + var backupFile = Path.Combine(_applicationPaths.DataPath, BackupFolderName); + if (!Directory.Exists(backupFile)) + { + Directory.CreateDirectory(backupFile); + } + + backupFile = Path.Combine(backupFile, $"{key}_jellyfin.db"); + File.Copy(path, backupFile); + return Task.FromResult(key); + } + + /// + public Task RestoreBackupFast(string key, CancellationToken cancellationToken) + { + // ensure there are absolutly no dangling Sqlite connections. + SqliteConnection.ClearAllPools(); + var path = Path.Combine(_applicationPaths.DataPath, "jellyfin.db"); + var backupFile = Path.Combine(_applicationPaths.DataPath, BackupFolderName, $"{key}_jellyfin.db"); + + if (!File.Exists(backupFile)) + { + _logger.LogCritical("Tried to restore a backup that does not exist."); + return Task.CompletedTask; + } + + File.Copy(backupFile, path, true); + return Task.CompletedTask; + } +} diff --git a/Jellyfin.Server.Implementations/ValueConverters/DateTimeKindValueConverter.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/ValueConverters/DateTimeKindValueConverter.cs similarity index 92% rename from Jellyfin.Server.Implementations/ValueConverters/DateTimeKindValueConverter.cs rename to src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/ValueConverters/DateTimeKindValueConverter.cs index 2e585c92df..d4a9407b0d 100644 --- a/Jellyfin.Server.Implementations/ValueConverters/DateTimeKindValueConverter.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/ValueConverters/DateTimeKindValueConverter.cs @@ -1,7 +1,7 @@ using System; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -namespace Jellyfin.Server.Implementations.ValueConverters +namespace Jellyfin.Database.Providers.Sqlite.ValueConverters { /// /// ValueConverter to specify kind. diff --git a/src/Jellyfin.Database/readme.md b/src/Jellyfin.Database/readme.md new file mode 100644 index 0000000000..d320b4d5e8 --- /dev/null +++ b/src/Jellyfin.Database/readme.md @@ -0,0 +1,25 @@ +# How to run EFCore migrations + +This shall provide context on how to work with entity frameworks multi provider migration feature. + +Jellyfin will support multiple database providers in the future, namely SQLite as its default and the experimental PostgreSQL. + +Each provider has its own set of migrations, as they contain provider specific instructions to migrate the specific changes to their respective systems. + +When creating a new migration, you always have to create migrations for all providers. This is supported via the following syntax: + +```cmd +dotnet ef migrations add MIGRATION_NAME --project "PATH_TO_PROJECT" -- --provider PROVIDER_KEY +``` + +with SQLite currently being the only supported provider, you need to run the Entity Framework tool with the correct project to tell EFCore where to store the migrations and the correct provider key to tell Jellyfin to load that provider. + +The example is made from the root folder of the project e.g for codespaces `/workspaces/jellyfin` + +```cmd +dotnet ef migrations add {MIGRATION_NAME} --project "src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite" -- --migration-provider Jellyfin-SQLite +``` + +If you get the error: `Run "dotnet tool restore" to make the "dotnet-ef" command available.` Run `dotnet restore`. + +in the event that you get the error: `System.UnauthorizedAccessException: Access to the path '/src/Jellyfin.Database' is denied.` you have to restore as sudo and then run `ef migrations` as sudo too. diff --git a/src/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj b/src/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj index 0590ded32a..ba402dfe09 100644 --- a/src/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj +++ b/src/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj @@ -6,7 +6,7 @@ - net8.0 + net9.0 false true diff --git a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs index c5aadc8901..73c8c39663 100644 --- a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs +++ b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Globalization; using System.IO; +using System.Linq; using BlurHashSharp.SkiaSharp; using Jellyfin.Extensions; using MediaBrowser.Common.Configuration; @@ -24,6 +25,7 @@ public class SkiaEncoder : IImageEncoder private readonly ILogger _logger; private readonly IApplicationPaths _appPaths; private static readonly SKImageFilter _imageFilter; + private static readonly SKTypeface[] _typefaces; #pragma warning disable CA1810 static SkiaEncoder() @@ -46,6 +48,21 @@ public class SkiaEncoder : IImageEncoder kernelOffset, SKShaderTileMode.Clamp, true); + + // Initialize the list of typefaces + // We have to statically build a list of typefaces because MatchCharacter only accepts a single character or code point + // But in reality a human-readable character (grapheme cluster) could be multiple code points. For example, 🚵🏻‍♀️ is a single emoji but 5 code points (U+1F6B5 + U+1F3FB + U+200D + U+2640 + U+FE0F) + _typefaces = + [ + SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, '鸡'), // CJK Simplified Chinese + SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, '雞'), // CJK Traditional Chinese + SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, 'ノ'), // CJK Japanese + SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, '각'), // CJK Korean + SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, 128169), // Emojis, 128169 is the 💩emoji + SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, 'ז'), // Hebrew + SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, 'ي'), // Arabic + SKTypeface.FromFamilyName("sans-serif", SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright) // Default font + ]; } /// @@ -97,6 +114,11 @@ public class SkiaEncoder : IImageEncoder public IReadOnlyCollection SupportedOutputFormats => new HashSet { ImageFormat.Webp, ImageFormat.Jpg, ImageFormat.Png, ImageFormat.Svg }; + /// + /// Gets the default typeface to use. + /// + public static SKTypeface DefaultTypeFace => _typefaces.Last(); + /// /// Check if the native lib is available. /// @@ -195,8 +217,10 @@ public class SkiaEncoder : IImageEncoder return string.Empty; } + // Use FileStream with FileShare.Read instead of having Skia open the file to allow concurrent read access + using var fileStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); // Any larger than 128x128 is too slow and there's no visually discernible difference - return BlurHashEncoder.Encode(xComp, yComp, path, 128, 128); + return BlurHashEncoder.Encode(xComp, yComp, fileStream, 128, 128); } private bool RequiresSpecialCharacterHack(string path) @@ -533,20 +557,14 @@ public class SkiaEncoder : IImageEncoder canvas.Clear(SKColor.Parse(options.BackgroundColor)); } + using var paint = new SKPaint(); // Add blur if option is present - if (blur > 0) - { - // create image from resized bitmap to apply blur - using var paint = new SKPaint(); - using var filter = SKImageFilter.CreateBlur(blur, blur); - paint.ImageFilter = filter; - canvas.DrawBitmap(resizedBitmap, SKRect.Create(width, height), paint); - } - else - { - // draw resized bitmap onto canvas - canvas.DrawBitmap(resizedBitmap, SKRect.Create(width, height)); - } + using var filter = blur > 0 ? SKImageFilter.CreateBlur(blur, blur) : null; + paint.FilterQuality = SKFilterQuality.High; + paint.ImageFilter = filter; + + // create image from resized bitmap to apply blur + canvas.DrawBitmap(resizedBitmap, SKRect.Create(width, height), paint); // If foreground layer present then draw if (hasForegroundColor) @@ -703,4 +721,22 @@ public class SkiaEncoder : IImageEncoder _logger.LogError(ex, "Error drawing indicator overlay"); } } + + /// + /// Return the typeface that contains the glyph for the given character. + /// + /// The text character. + /// The typeface contains the character. + public static SKTypeface? GetFontForCharacter(string c) + { + foreach (var typeface in _typefaces) + { + if (typeface.ContainsGlyphs(c)) + { + return typeface; + } + } + + return null; + } } diff --git a/src/Jellyfin.Drawing.Skia/SplashscreenBuilder.cs b/src/Jellyfin.Drawing.Skia/SplashscreenBuilder.cs index 7af77758b4..03733d4f84 100644 --- a/src/Jellyfin.Drawing.Skia/SplashscreenBuilder.cs +++ b/src/Jellyfin.Drawing.Skia/SplashscreenBuilder.cs @@ -12,7 +12,7 @@ public class SplashscreenBuilder { private const int FinalWidth = 1920; private const int FinalHeight = 1080; - // generated collage resolution should be higher than the final resolution + // generated collage resolution should be greater than the final resolution private const int WallWidth = FinalWidth * 3; private const int WallHeight = FinalHeight * 2; private const int Rows = 6; diff --git a/src/Jellyfin.Drawing.Skia/StripCollageBuilder.cs b/src/Jellyfin.Drawing.Skia/StripCollageBuilder.cs index 4aff26c16b..03e202e5a4 100644 --- a/src/Jellyfin.Drawing.Skia/StripCollageBuilder.cs +++ b/src/Jellyfin.Drawing.Skia/StripCollageBuilder.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Text.RegularExpressions; using SkiaSharp; @@ -23,9 +24,6 @@ public partial class StripCollageBuilder _skiaEncoder = skiaEncoder; } - [GeneratedRegex(@"[^\p{IsCJKUnifiedIdeographs}\p{IsCJKUnifiedIdeographsExtensionA}\p{IsKatakana}\p{IsHiragana}\p{IsHangulSyllables}\p{IsHangulJamo}]")] - private static partial Regex NonCjkPatternRegex(); - [GeneratedRegex(@"\p{IsArabic}|\p{IsArmenian}|\p{IsHebrew}|\p{IsSyriac}|\p{IsThaana}")] private static partial Regex IsRtlTextRegex(); @@ -111,26 +109,22 @@ public partial class StripCollageBuilder // resize to the same aspect as the original var backdropHeight = Math.Abs(width * backdrop.Height / backdrop.Width); - using var residedBackdrop = SkiaEncoder.ResizeImage(backdrop, new SKImageInfo(width, backdropHeight, backdrop.ColorType, backdrop.AlphaType, backdrop.ColorSpace)); + using var resizedBackdrop = SkiaEncoder.ResizeImage(backdrop, new SKImageInfo(width, backdropHeight, backdrop.ColorType, backdrop.AlphaType, backdrop.ColorSpace)); + using var paint = new SKPaint(); + paint.FilterQuality = SKFilterQuality.High; // draw the backdrop - canvas.DrawImage(residedBackdrop, 0, 0); + canvas.DrawImage(resizedBackdrop, 0, 0, paint); // draw shadow rectangle using var paintColor = new SKPaint { Color = SKColors.Black.WithAlpha(0x78), - Style = SKPaintStyle.Fill + Style = SKPaintStyle.Fill, + FilterQuality = SKFilterQuality.High }; canvas.DrawRect(0, 0, width, height, paintColor); - var typeFace = SKTypeface.FromFamilyName("sans-serif", SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright); - - // use the system fallback to find a typeface for the given CJK character - var filteredName = NonCjkPatternRegex().Replace(libraryName ?? string.Empty, string.Empty); - if (!string.IsNullOrEmpty(filteredName)) - { - typeFace = SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, filteredName[0]); - } + var typeFace = SkiaEncoder.DefaultTypeFace; // draw library name using var textPaint = new SKPaint @@ -138,9 +132,10 @@ public partial class StripCollageBuilder Color = SKColors.White, Style = SKPaintStyle.Fill, TextSize = 112, - TextAlign = SKTextAlign.Center, + TextAlign = SKTextAlign.Left, Typeface = typeFace, - IsAntialias = true + IsAntialias = true, + FilterQuality = SKFilterQuality.High }; // scale down text to 90% of the width if text is larger than 95% of the width @@ -155,13 +150,23 @@ public partial class StripCollageBuilder return bitmap; } + var realWidth = DrawText(null, 0, (height / 2f) + (textPaint.FontMetrics.XHeight / 2), libraryName, textPaint); + if (realWidth > width * 0.95) + { + textPaint.TextSize = 0.9f * width * textPaint.TextSize / realWidth; + realWidth = DrawText(null, 0, (height / 2f) + (textPaint.FontMetrics.XHeight / 2), libraryName, textPaint); + } + + var padding = (width - realWidth) / 2; + if (IsRtlTextRegex().IsMatch(libraryName)) { - canvas.DrawShapedText(libraryName, width / 2f, (height / 2f) + (textPaint.FontMetrics.XHeight / 2), textPaint); + textPaint.TextAlign = SKTextAlign.Right; + DrawText(canvas, width - padding, (height / 2f) + (textPaint.FontMetrics.XHeight / 2), libraryName, textPaint, true); } else { - canvas.DrawText(libraryName, width / 2f, (height / 2f) + (textPaint.FontMetrics.XHeight / 2), textPaint); + DrawText(canvas, padding, (height / 2f) + (textPaint.FontMetrics.XHeight / 2), libraryName, textPaint); } return bitmap; @@ -187,17 +192,125 @@ public partial class StripCollageBuilder continue; } - // Scale image. The FromBitmap creates a copy + // Scale image var imageInfo = new SKImageInfo(cellWidth, cellHeight, currentBitmap.ColorType, currentBitmap.AlphaType, currentBitmap.ColorSpace); using var resizeImage = SkiaEncoder.ResizeImage(currentBitmap, imageInfo); + using var paint = new SKPaint(); + paint.FilterQuality = SKFilterQuality.High; // draw this image into the strip at the next position var xPos = x * cellWidth; var yPos = y * cellHeight; - canvas.DrawImage(resizeImage, xPos, yPos); + canvas.DrawImage(resizeImage, xPos, yPos, paint); } } return bitmap; } + + /// + /// Draw shaped text with given SKPaint. + /// + /// If not null, draw text to this canvas, otherwise only measure the text width. + /// x position of the canvas to draw text. + /// y position of the canvas to draw text. + /// The text to draw. + /// The SKPaint to style the text. + /// The width of the text. + private static float MeasureAndDrawText(SKCanvas? canvas, float x, float y, string text, SKPaint textPaint) + { + var width = textPaint.MeasureText(text); + canvas?.DrawShapedText(text, x, y, textPaint); + return width; + } + + /// + /// Draw shaped text with given SKPaint, search defined type faces to render as many texts as possible. + /// + /// If not null, draw text to this canvas, otherwise only measure the text width. + /// x position of the canvas to draw text. + /// y position of the canvas to draw text. + /// The text to draw. + /// The SKPaint to style the text. + /// If true, render from right to left. + /// The width of the text. + private static float DrawText(SKCanvas? canvas, float x, float y, string text, SKPaint textPaint, bool isRtl = false) + { + float width = 0; + + if (textPaint.ContainsGlyphs(text)) + { + // Current font can render all characters in text + return MeasureAndDrawText(canvas, x, y, text, textPaint); + } + + // Iterate over all text elements using TextElementEnumerator + // We cannot use foreach here because a human-readable character (grapheme cluster) can be multiple code points + // We cannot render character by character because glyphs do not always have same width + // And the result will look very unnatural due to the width difference and missing natural spacing + var start = 0; + var enumerator = StringInfo.GetTextElementEnumerator(text); + while (enumerator.MoveNext()) + { + bool notAtEnd; + var textElement = enumerator.GetTextElement(); + if (textPaint.ContainsGlyphs(textElement)) + { + continue; + } + + // If we get here, we have a text element which cannot be rendered with current font + // Draw previous characters which can be rendered with current font + if (start != enumerator.ElementIndex) + { + var regularText = text.Substring(start, enumerator.ElementIndex - start); + width += MeasureAndDrawText(canvas, MoveX(x, width), y, regularText, textPaint); + start = enumerator.ElementIndex; + } + + // Search for next point where current font can render the character there + while ((notAtEnd = enumerator.MoveNext()) && !textPaint.ContainsGlyphs(enumerator.GetTextElement())) + { + // Do nothing, just move enumerator to the point where current font can render the character + } + + // Now we have a substring that should pick another font + // The enumerator may or may not be already at the end of the string + var subtext = notAtEnd + ? text.Substring(start, enumerator.ElementIndex - start) + : text[start..]; + + var fallback = SkiaEncoder.GetFontForCharacter(textElement); + + if (fallback is not null) + { + using var fallbackTextPaint = new SKPaint(); + fallbackTextPaint.Color = textPaint.Color; + fallbackTextPaint.Style = textPaint.Style; + fallbackTextPaint.TextSize = textPaint.TextSize; + fallbackTextPaint.TextAlign = textPaint.TextAlign; + fallbackTextPaint.Typeface = fallback; + fallbackTextPaint.IsAntialias = textPaint.IsAntialias; + + // Do the search recursively to select all possible fonts + width += DrawText(canvas, MoveX(x, width), y, subtext, fallbackTextPaint, isRtl); + } + else + { + // Used up all fonts and no fonts can be found, just use current font + width += MeasureAndDrawText(canvas, MoveX(x, width), y, text[start..], textPaint); + } + + start = notAtEnd ? enumerator.ElementIndex : text.Length; + } + + // Render the remaining text that current fonts can render + if (start < text.Length) + { + width += MeasureAndDrawText(canvas, MoveX(x, width), y, text[start..], textPaint); + } + + return width; + float MoveX(float currentX, float dWidth) => isRtl ? currentX - dWidth : currentX + dWidth; + } } diff --git a/src/Jellyfin.Drawing/ImageProcessor.cs b/src/Jellyfin.Drawing/ImageProcessor.cs index 5d4732234d..7718f6c6a5 100644 --- a/src/Jellyfin.Drawing/ImageProcessor.cs +++ b/src/Jellyfin.Drawing/ImageProcessor.cs @@ -4,17 +4,19 @@ using System.Globalization; using System.IO; using System.Linq; using System.Net.Mime; +using System.Reflection.Metadata.Ecma335; using System.Text; using System.Threading; using System.Threading.Tasks; using AsyncKeyedLock; -using Jellyfin.Data.Entities; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Drawing; +using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.Net; @@ -66,7 +68,7 @@ public sealed class ImageProcessor : IImageProcessor, IDisposable var semaphoreCount = config.Configuration.ParallelImageEncodingLimit; if (semaphoreCount < 1) { - semaphoreCount = 2 * Environment.ProcessorCount; + semaphoreCount = Environment.ProcessorCount; } _parallelEncodingLimit = new(semaphoreCount); @@ -403,9 +405,28 @@ public sealed class ImageProcessor : IImageProcessor, IDisposable return _imageEncoder.GetImageBlurHash(xComp, yComp, path); } + /// + public string GetImageCacheTag(string baseItemPath, DateTime imageDateModified) + => (baseItemPath + imageDateModified.Ticks).GetMD5().ToString("N", CultureInfo.InvariantCulture); + /// public string GetImageCacheTag(BaseItem item, ItemImageInfo image) - => (item.Path + image.DateModified.Ticks).GetMD5().ToString("N", CultureInfo.InvariantCulture); + => GetImageCacheTag(item.Path, image.DateModified); + + /// + public string GetImageCacheTag(BaseItemDto item, ItemImageInfo image) + => GetImageCacheTag(item.Path, image.DateModified); + + /// + public string? GetImageCacheTag(BaseItemDto item, ChapterInfo chapter) + { + if (chapter.ImagePath is null) + { + return null; + } + + return GetImageCacheTag(item.Path, chapter.ImageDateModified); + } /// public string? GetImageCacheTag(BaseItem item, ChapterInfo chapter) @@ -431,8 +452,7 @@ public sealed class ImageProcessor : IImageProcessor, IDisposable return null; } - return (user.ProfileImage.Path + user.ProfileImage.LastModified.Ticks).GetMD5() - .ToString("N", CultureInfo.InvariantCulture); + return GetImageCacheTag(user.ProfileImage.Path, user.ProfileImage.LastModified); } private Task<(string Path, DateTime DateModified)> GetSupportedImage(string originalImagePath, DateTime dateModified) diff --git a/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj b/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj index 4a02f90f95..5f4b3fe8d4 100644 --- a/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj +++ b/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj @@ -6,7 +6,7 @@ - net8.0 + net9.0 false true diff --git a/src/Jellyfin.Extensions/EnumerableExtensions.cs b/src/Jellyfin.Extensions/EnumerableExtensions.cs index fd46358a4f..3eb9da01f2 100644 --- a/src/Jellyfin.Extensions/EnumerableExtensions.cs +++ b/src/Jellyfin.Extensions/EnumerableExtensions.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; namespace Jellyfin.Extensions; @@ -55,4 +56,22 @@ public static class EnumerableExtensions { yield return item; } + + /// + /// Gets an IEnumerable consisting of all flags of an enum. + /// + /// The flags enum. + /// The type of item. + /// The IEnumerable{Enum}. + public static IEnumerable GetUniqueFlags(this T flags) + where T : Enum + { + foreach (Enum value in Enum.GetValues(flags.GetType())) + { + if (flags.HasFlag(value)) + { + yield return (T)value; + } + } + } } diff --git a/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj b/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj index f786cc3b40..1613d83bc3 100644 --- a/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj +++ b/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj @@ -1,7 +1,7 @@ - net8.0 + net9.0 false true true @@ -15,7 +15,7 @@ Jellyfin Contributors Jellyfin.Extensions - 10.10.0 + 10.11.0 https://github.com/jellyfin/jellyfin GPL-3.0-only diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonCommaDelimitedArrayConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonCommaDelimitedCollectionConverter.cs similarity index 57% rename from src/Jellyfin.Extensions/Json/Converters/JsonCommaDelimitedArrayConverter.cs rename to src/Jellyfin.Extensions/Json/Converters/JsonCommaDelimitedCollectionConverter.cs index ccbc296fd9..b1946143dd 100644 --- a/src/Jellyfin.Extensions/Json/Converters/JsonCommaDelimitedArrayConverter.cs +++ b/src/Jellyfin.Extensions/Json/Converters/JsonCommaDelimitedCollectionConverter.cs @@ -1,15 +1,15 @@ namespace Jellyfin.Extensions.Json.Converters { /// - /// Convert comma delimited string to array of type. + /// Convert comma delimited string to collection of type. /// /// Type to convert to. - public sealed class JsonCommaDelimitedArrayConverter : JsonDelimitedArrayConverter + public sealed class JsonCommaDelimitedCollectionConverter : JsonDelimitedCollectionConverter { /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// - public JsonCommaDelimitedArrayConverter() : base() + public JsonCommaDelimitedCollectionConverter() : base() { } diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonPipeDelimitedArrayConverterFactory.cs b/src/Jellyfin.Extensions/Json/Converters/JsonCommaDelimitedCollectionConverterFactory.cs similarity index 57% rename from src/Jellyfin.Extensions/Json/Converters/JsonPipeDelimitedArrayConverterFactory.cs rename to src/Jellyfin.Extensions/Json/Converters/JsonCommaDelimitedCollectionConverterFactory.cs index ae9e1f67a0..daa79b2b5a 100644 --- a/src/Jellyfin.Extensions/Json/Converters/JsonPipeDelimitedArrayConverterFactory.cs +++ b/src/Jellyfin.Extensions/Json/Converters/JsonCommaDelimitedCollectionConverterFactory.cs @@ -1,28 +1,31 @@ using System; +using System.Collections.Generic; using System.Text.Json; using System.Text.Json.Serialization; namespace Jellyfin.Extensions.Json.Converters { /// - /// Json Pipe delimited array converter factory. + /// Json comma delimited collection converter factory. /// /// /// This must be applied as an attribute, adding to the JsonConverter list causes stack overflow. /// - public class JsonPipeDelimitedArrayConverterFactory : JsonConverterFactory + public class JsonCommaDelimitedCollectionConverterFactory : JsonConverterFactory { /// public override bool CanConvert(Type typeToConvert) { - return true; + return typeToConvert.IsArray + || (typeToConvert.IsGenericType + && (typeToConvert.GetGenericTypeDefinition().IsAssignableFrom(typeof(IReadOnlyCollection<>)) || typeToConvert.GetGenericTypeDefinition().IsAssignableFrom(typeof(IReadOnlyList<>)))); } /// public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) { var structType = typeToConvert.GetElementType() ?? typeToConvert.GenericTypeArguments[0]; - return (JsonConverter?)Activator.CreateInstance(typeof(JsonPipeDelimitedArrayConverter<>).MakeGenericType(structType)); + return (JsonConverter?)Activator.CreateInstance(typeof(JsonCommaDelimitedCollectionConverter<>).MakeGenericType(structType)); } } } diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonDelimitedArrayConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonDelimitedCollectionConverter.cs similarity index 56% rename from src/Jellyfin.Extensions/Json/Converters/JsonDelimitedArrayConverter.cs rename to src/Jellyfin.Extensions/Json/Converters/JsonDelimitedCollectionConverter.cs index 936a5a97c4..fe85d7f73f 100644 --- a/src/Jellyfin.Extensions/Json/Converters/JsonDelimitedArrayConverter.cs +++ b/src/Jellyfin.Extensions/Json/Converters/JsonDelimitedCollectionConverter.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.ComponentModel; -using System.Linq; using System.Text.Json; using System.Text.Json.Serialization; @@ -11,14 +10,14 @@ namespace Jellyfin.Extensions.Json.Converters /// Convert delimited string to array of type. /// /// Type to convert to. - public abstract class JsonDelimitedArrayConverter : JsonConverter + public abstract class JsonDelimitedCollectionConverter : JsonConverter> { private readonly TypeConverter _typeConverter; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// - protected JsonDelimitedArrayConverter() + protected JsonDelimitedCollectionConverter() { _typeConverter = TypeDescriptor.GetConverter(typeof(T)); } @@ -29,7 +28,7 @@ namespace Jellyfin.Extensions.Json.Converters protected virtual char Delimiter { get; } /// - public override T[]? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + public override IReadOnlyCollection? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { if (reader.TokenType == JsonTokenType.String) { @@ -53,52 +52,25 @@ namespace Jellyfin.Extensions.Json.Converters } catch (FormatException) { - // Ignore unconvertable inputs + // Ignore unconvertible inputs } } - return typedValues.ToArray(); + if (typeToConvert.IsArray) + { + return typedValues.ToArray(); + } + + return typedValues; } return JsonSerializer.Deserialize(ref reader, options); } /// - public override void Write(Utf8JsonWriter writer, T[]? value, JsonSerializerOptions options) + public override void Write(Utf8JsonWriter writer, IReadOnlyCollection? value, JsonSerializerOptions options) { - if (value is not null) - { - writer.WriteStartArray(); - if (value.Length > 0) - { - var toWrite = value.Length - 1; - foreach (var it in value) - { - var wrote = false; - if (it is not null) - { - writer.WriteStringValue(it.ToString()); - wrote = true; - } - - if (toWrite > 0) - { - if (wrote) - { - writer.WriteStringValue(Delimiter.ToString()); - } - - toWrite--; - } - } - } - - writer.WriteEndArray(); - } - else - { - writer.WriteNullValue(); - } + JsonSerializer.Serialize(writer, value, options); } } } diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonPipeDelimitedArrayConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonPipeDelimitedCollectionConverter.cs similarity index 66% rename from src/Jellyfin.Extensions/Json/Converters/JsonPipeDelimitedArrayConverter.cs rename to src/Jellyfin.Extensions/Json/Converters/JsonPipeDelimitedCollectionConverter.cs index 55720ee4f3..57378a360a 100644 --- a/src/Jellyfin.Extensions/Json/Converters/JsonPipeDelimitedArrayConverter.cs +++ b/src/Jellyfin.Extensions/Json/Converters/JsonPipeDelimitedCollectionConverter.cs @@ -4,12 +4,12 @@ namespace Jellyfin.Extensions.Json.Converters /// Convert Pipe delimited string to array of type. /// /// Type to convert to. - public sealed class JsonPipeDelimitedArrayConverter : JsonDelimitedArrayConverter + public sealed class JsonPipeDelimitedCollectionConverter : JsonDelimitedCollectionConverter { /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// - public JsonPipeDelimitedArrayConverter() : base() + public JsonPipeDelimitedCollectionConverter() : base() { } diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonCommaDelimitedArrayConverterFactory.cs b/src/Jellyfin.Extensions/Json/Converters/JsonPipeDelimitedCollectionConverterFactory.cs similarity index 57% rename from src/Jellyfin.Extensions/Json/Converters/JsonCommaDelimitedArrayConverterFactory.cs rename to src/Jellyfin.Extensions/Json/Converters/JsonPipeDelimitedCollectionConverterFactory.cs index a95e493dbb..f487fcaca4 100644 --- a/src/Jellyfin.Extensions/Json/Converters/JsonCommaDelimitedArrayConverterFactory.cs +++ b/src/Jellyfin.Extensions/Json/Converters/JsonPipeDelimitedCollectionConverterFactory.cs @@ -1,28 +1,31 @@ using System; +using System.Collections.Generic; using System.Text.Json; using System.Text.Json.Serialization; namespace Jellyfin.Extensions.Json.Converters { /// - /// Json comma delimited array converter factory. + /// Json Pipe delimited collection converter factory. /// /// /// This must be applied as an attribute, adding to the JsonConverter list causes stack overflow. /// - public class JsonCommaDelimitedArrayConverterFactory : JsonConverterFactory + public class JsonPipeDelimitedCollectionConverterFactory : JsonConverterFactory { /// public override bool CanConvert(Type typeToConvert) { - return true; + return typeToConvert.IsArray + || (typeToConvert.IsGenericType + && (typeToConvert.GetGenericTypeDefinition().IsAssignableFrom(typeof(IReadOnlyCollection<>)) || typeToConvert.GetGenericTypeDefinition().IsAssignableFrom(typeof(IReadOnlyList<>)))); } /// public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) { var structType = typeToConvert.GetElementType() ?? typeToConvert.GenericTypeArguments[0]; - return (JsonConverter?)Activator.CreateInstance(typeof(JsonCommaDelimitedArrayConverter<>).MakeGenericType(structType)); + return (JsonConverter?)Activator.CreateInstance(typeof(JsonPipeDelimitedCollectionConverter<>).MakeGenericType(structType)); } } } diff --git a/src/Jellyfin.Extensions/StringExtensions.cs b/src/Jellyfin.Extensions/StringExtensions.cs index 4b9677d9f4..715cbf2209 100644 --- a/src/Jellyfin.Extensions/StringExtensions.cs +++ b/src/Jellyfin.Extensions/StringExtensions.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Linq; using System.Text.RegularExpressions; using ICU4N.Text; @@ -123,5 +125,15 @@ namespace Jellyfin.Extensions { return (_transliterator.Value is null) ? text : _transliterator.Value.Transliterate(text); } + + /// + /// Ensures all strings are non-null and trimmed of leading an trailing blanks. + /// + /// The enumerable of strings to trim. + /// The enumeration of trimmed strings. + public static IEnumerable Trimmed(this IEnumerable values) + { + return values.Select(i => (i ?? string.Empty).Trim()); + } } } diff --git a/src/Jellyfin.LiveTv/Channels/ChannelManager.cs b/src/Jellyfin.LiveTv/Channels/ChannelManager.cs index 83f68ab509..0ca294a289 100644 --- a/src/Jellyfin.LiveTv/Channels/ChannelManager.cs +++ b/src/Jellyfin.LiveTv/Channels/ChannelManager.cs @@ -9,8 +9,9 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; using AsyncKeyedLock; -using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using Jellyfin.Extensions.Json; using MediaBrowser.Common.Extensions; diff --git a/src/Jellyfin.LiveTv/Channels/RefreshChannelsScheduledTask.cs b/src/Jellyfin.LiveTv/Channels/RefreshChannelsScheduledTask.cs index 79c5873d51..71e46764ad 100644 --- a/src/Jellyfin.LiveTv/Channels/RefreshChannelsScheduledTask.cs +++ b/src/Jellyfin.LiveTv/Channels/RefreshChannelsScheduledTask.cs @@ -79,7 +79,7 @@ namespace Jellyfin.LiveTv.Channels // Every so often new TaskTriggerInfo { - Type = TaskTriggerInfo.TriggerInterval, IntervalTicks = TimeSpan.FromHours(24).Ticks + Type = TaskTriggerInfoType.IntervalTrigger, IntervalTicks = TimeSpan.FromHours(24).Ticks } }; } diff --git a/src/Jellyfin.LiveTv/DefaultLiveTvService.cs b/src/Jellyfin.LiveTv/DefaultLiveTvService.cs index 318cc7acd0..d8f873abe6 100644 --- a/src/Jellyfin.LiveTv/DefaultLiveTvService.cs +++ b/src/Jellyfin.LiveTv/DefaultLiveTvService.cs @@ -11,6 +11,7 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; using Jellyfin.Data.Events; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using Jellyfin.LiveTv.Configuration; using Jellyfin.LiveTv.Timers; diff --git a/src/Jellyfin.LiveTv/Guide/GuideManager.cs b/src/Jellyfin.LiveTv/Guide/GuideManager.cs index f657422a04..ac59a6d125 100644 --- a/src/Jellyfin.LiveTv/Guide/GuideManager.cs +++ b/src/Jellyfin.LiveTv/Guide/GuideManager.cs @@ -39,6 +39,11 @@ public class GuideManager : IGuideManager private readonly IRecordingsManager _recordingsManager; private readonly LiveTvDtoService _tvDtoService; + /// + /// Amount of days images are pre-cached from external sources. + /// + public const int MaxCacheDays = 2; + /// /// Initializes a new instance of the class. /// @@ -204,14 +209,14 @@ public class GuideManager : IGuideManager progress.Report(15); numComplete = 0; - var programs = new List(); + var programIds = new List(); var channels = new List(); var guideDays = GetGuideDays(); - _logger.LogInformation("Refreshing guide with {0} days of guide data", guideDays); + _logger.LogInformation("Refreshing guide with {Days} days of guide data", guideDays); - var maxCacheDate = DateTime.UtcNow.AddDays(2); + var maxCacheDate = DateTime.UtcNow.AddDays(MaxCacheDays); foreach (var currentChannel in list) { cancellationToken.ThrowIfCancellationRequested(); @@ -238,11 +243,12 @@ public class GuideManager : IGuideManager }).Cast().ToDictionary(i => i.Id); var newPrograms = new List(); - var updatedPrograms = new List(); + var updatedPrograms = new List(); foreach (var program in channelPrograms) { var (programItem, isNew, isUpdated) = GetProgram(program, existingPrograms, currentChannel); + var id = programItem.Id; if (isNew) { newPrograms.Add(programItem); @@ -252,7 +258,7 @@ public class GuideManager : IGuideManager updatedPrograms.Add(programItem); } - programs.Add(programItem.Id); + programIds.Add(programItem.Id); isMovie |= program.IsMovie; isSeries |= program.IsSeries; @@ -261,12 +267,17 @@ public class GuideManager : IGuideManager isKids |= program.IsKids; } - _logger.LogDebug("Channel {0} has {1} new programs and {2} updated programs", currentChannel.Name, newPrograms.Count, updatedPrograms.Count); + _logger.LogDebug( + "Channel {Name} has {NewCount} new programs and {UpdatedCount} updated programs", + currentChannel.Name, + newPrograms.Count, + updatedPrograms.Count); if (newPrograms.Count > 0) { - _libraryManager.CreateItems(newPrograms, null, cancellationToken); - await PrecacheImages(newPrograms, maxCacheDate).ConfigureAwait(false); + _libraryManager.CreateItems(newPrograms, currentChannel, cancellationToken); + + await PreCacheImages(newPrograms, maxCacheDate).ConfigureAwait(false); } if (updatedPrograms.Count > 0) @@ -276,7 +287,8 @@ public class GuideManager : IGuideManager currentChannel, ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false); - await PrecacheImages(updatedPrograms, maxCacheDate).ConfigureAwait(false); + + await PreCacheImages(updatedPrograms, maxCacheDate).ConfigureAwait(false); } currentChannel.IsMovie = isMovie; @@ -313,7 +325,7 @@ public class GuideManager : IGuideManager } progress.Report(100); - return new Tuple, List>(channels, programs); + return new Tuple, List>(channels, programIds); } private void CleanDatabase(Guid[] currentIdList, BaseItemKind[] validTypes, IProgress progress, CancellationToken cancellationToken) @@ -488,35 +500,27 @@ public class GuideManager : IGuideManager forceUpdate = true; } - var seriesId = info.SeriesId; - - if (!item.ParentId.Equals(channel.Id)) + var channelId = channel.Id; + if (!item.ParentId.Equals(channelId)) { + item.ParentId = channel.Id; forceUpdate = true; } - item.ParentId = channel.Id; - item.Audio = info.Audio; - item.ChannelId = channel.Id; - item.CommunityRating ??= info.CommunityRating; - if ((item.CommunityRating ?? 0).Equals(0)) - { - item.CommunityRating = null; - } - + item.ChannelId = channelId; + item.CommunityRating = info.CommunityRating; item.EpisodeTitle = info.EpisodeTitle; item.ExternalId = info.Id; - if (!string.IsNullOrWhiteSpace(seriesId) && !string.Equals(item.ExternalSeriesId, seriesId, StringComparison.Ordinal)) + var seriesId = info.SeriesId; + if (!string.IsNullOrWhiteSpace(seriesId) && !string.Equals(item.ExternalSeriesId, seriesId, StringComparison.OrdinalIgnoreCase)) { + item.ExternalSeriesId = seriesId; forceUpdate = true; } - item.ExternalSeriesId = seriesId; - var isSeries = info.IsSeries || !string.IsNullOrEmpty(info.EpisodeTitle); - if (isSeries || !string.IsNullOrEmpty(info.EpisodeTitle)) { item.SeriesName = info.Name; @@ -564,7 +568,6 @@ public class GuideManager : IGuideManager } item.Tags = tags.ToArray(); - item.Genres = info.Genres.ToArray(); if (info.IsHD ?? false) @@ -575,41 +578,35 @@ public class GuideManager : IGuideManager item.IsMovie = info.IsMovie; item.IsRepeat = info.IsRepeat; - if (item.IsSeries != isSeries) { + item.IsSeries = isSeries; forceUpdate = true; } - item.IsSeries = isSeries; - item.Name = info.Name; - item.OfficialRating ??= info.OfficialRating; - item.Overview ??= info.Overview; + item.OfficialRating = info.OfficialRating; + item.Overview = info.Overview; item.RunTimeTicks = (info.EndDate - info.StartDate).Ticks; - item.ProviderIds = info.ProviderIds; - foreach (var providerId in info.SeriesProviderIds) { info.ProviderIds["Series" + providerId.Key] = providerId.Value; } + item.ProviderIds = info.ProviderIds; if (item.StartDate != info.StartDate) { + item.StartDate = info.StartDate; forceUpdate = true; } - item.StartDate = info.StartDate; - if (item.EndDate != info.EndDate) { + item.EndDate = info.EndDate; forceUpdate = true; } - item.EndDate = info.EndDate; - item.ProductionYear = info.ProductionYear; - if (!isSeries || info.IsRepeat) { item.PremiereDate = info.OriginalAirDate; @@ -618,100 +615,113 @@ public class GuideManager : IGuideManager item.IndexNumber = info.EpisodeNumber; item.ParentIndexNumber = info.SeasonNumber; - if (!item.HasImage(ImageType.Primary)) - { - if (!string.IsNullOrWhiteSpace(info.ImagePath)) - { - item.SetImage( - new ItemImageInfo - { - Path = info.ImagePath, - Type = ImageType.Primary - }, - 0); - } - else if (!string.IsNullOrWhiteSpace(info.ImageUrl)) - { - item.SetImage( - new ItemImageInfo - { - Path = info.ImageUrl, - Type = ImageType.Primary - }, - 0); - } - } + forceUpdate |= UpdateImages(item, info); - if (!item.HasImage(ImageType.Thumb)) - { - if (!string.IsNullOrWhiteSpace(info.ThumbImageUrl)) - { - item.SetImage( - new ItemImageInfo - { - Path = info.ThumbImageUrl, - Type = ImageType.Thumb - }, - 0); - } - } - - if (!item.HasImage(ImageType.Logo)) - { - if (!string.IsNullOrWhiteSpace(info.LogoImageUrl)) - { - item.SetImage( - new ItemImageInfo - { - Path = info.LogoImageUrl, - Type = ImageType.Logo - }, - 0); - } - } - - if (!item.HasImage(ImageType.Backdrop)) - { - if (!string.IsNullOrWhiteSpace(info.BackdropImageUrl)) - { - item.SetImage( - new ItemImageInfo - { - Path = info.BackdropImageUrl, - Type = ImageType.Backdrop - }, - 0); - } - } - - var isUpdated = false; if (isNew) { + item.OnMetadataChanged(); + + return (item, true, false); } - else if (forceUpdate || string.IsNullOrWhiteSpace(info.Etag)) + + var isUpdated = forceUpdate; + var etag = info.Etag; + if (string.IsNullOrWhiteSpace(etag)) { isUpdated = true; } - else + else if (!string.Equals(etag, item.GetProviderId(EtagKey), StringComparison.OrdinalIgnoreCase)) { - var etag = info.Etag; - - if (!string.Equals(etag, item.GetProviderId(EtagKey), StringComparison.OrdinalIgnoreCase)) - { - item.SetProviderId(EtagKey, etag); - isUpdated = true; - } + item.SetProviderId(EtagKey, etag); + isUpdated = true; } - if (isNew || isUpdated) + if (isUpdated) { item.OnMetadataChanged(); + + return (item, false, true); } - return (item, isNew, isUpdated); + return (item, false, false); } - private async Task PrecacheImages(IReadOnlyList programs, DateTime maxCacheDate) + private static bool UpdateImages(BaseItem item, ProgramInfo info) + { + var updated = false; + + // Primary + updated |= UpdateImage(ImageType.Primary, item, info); + + // Thumbnail + updated |= UpdateImage(ImageType.Thumb, item, info); + + // Logo + updated |= UpdateImage(ImageType.Logo, item, info); + + // Backdrop + updated |= UpdateImage(ImageType.Backdrop, item, info); + + return updated; + } + + private static bool UpdateImage(ImageType imageType, BaseItem item, ProgramInfo info) + { + var image = item.GetImages(imageType).FirstOrDefault(); + var currentImagePath = image?.Path; + var newImagePath = imageType switch + { + ImageType.Primary => info.ImagePath, + _ => null + }; + var newImageUrl = imageType switch + { + ImageType.Backdrop => info.BackdropImageUrl, + ImageType.Logo => info.LogoImageUrl, + ImageType.Primary => info.ImageUrl, + ImageType.Thumb => info.ThumbImageUrl, + _ => null + }; + + var sameImage = (currentImagePath?.Equals(newImageUrl, StringComparison.OrdinalIgnoreCase) ?? false) + || (currentImagePath?.Equals(newImagePath, StringComparison.OrdinalIgnoreCase) ?? false); + if (sameImage) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(newImagePath)) + { + item.SetImage( + new ItemImageInfo + { + Path = newImagePath, + Type = imageType + }, + 0); + + return true; + } + + if (!string.IsNullOrWhiteSpace(newImageUrl)) + { + item.SetImage( + new ItemImageInfo + { + Path = newImageUrl, + Type = imageType + }, + 0); + + return true; + } + + item.RemoveImage(image); + + return false; + } + + private async Task PreCacheImages(IReadOnlyList programs, DateTime maxCacheDate) { await Parallel.ForEachAsync( programs @@ -730,6 +740,7 @@ public class GuideManager : IGuideManager var imageInfo = program.ImageInfos[i]; if (!imageInfo.IsLocalFile) { + _logger.LogDebug("Caching image locally: {Url}", imageInfo.Path); try { program.ImageInfos[i] = await _libraryManager.ConvertImageToLocal( @@ -741,7 +752,7 @@ public class GuideManager : IGuideManager } catch (Exception ex) { - _logger.LogWarning(ex, "Unable to precache {Url}", imageInfo.Path); + _logger.LogWarning(ex, "Unable to pre-cache {Url}", imageInfo.Path); } } } diff --git a/src/Jellyfin.LiveTv/Guide/RefreshGuideScheduledTask.cs b/src/Jellyfin.LiveTv/Guide/RefreshGuideScheduledTask.cs index a9fde08501..5164d695f8 100644 --- a/src/Jellyfin.LiveTv/Guide/RefreshGuideScheduledTask.cs +++ b/src/Jellyfin.LiveTv/Guide/RefreshGuideScheduledTask.cs @@ -66,7 +66,7 @@ public class RefreshGuideScheduledTask : IScheduledTask, IConfigurableScheduledT { new TaskTriggerInfo { - Type = TaskTriggerInfo.TriggerInterval, + Type = TaskTriggerInfoType.IntervalTrigger, IntervalTicks = TimeSpan.FromHours(24).Ticks } }; diff --git a/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs b/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs index 0c660637fd..c04954207b 100644 --- a/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs +++ b/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs @@ -124,22 +124,7 @@ namespace Jellyfin.LiveTv.IO private string GetCommandLineArgs(MediaSourceInfo mediaSource, string inputTempFile, string targetFile) { - string videoArgs; - if (EncodeVideo(mediaSource)) - { - const int MaxBitrate = 25000000; - videoArgs = string.Format( - CultureInfo.InvariantCulture, - "-codec:v:0 libx264 -force_key_frames \"expr:gte(t,n_forced*5)\" {0} -pix_fmt yuv420p -preset superfast -crf 23 -b:v {1} -maxrate {1} -bufsize ({1}*2) -profile:v high -level 41", - GetOutputSizeParam(), - MaxBitrate); - } - else - { - videoArgs = "-codec:v:0 copy"; - } - - videoArgs += " -fflags +genpts"; + string videoArgs = "-codec:v:0 copy -fflags +genpts"; var flags = new List(); if (mediaSource.IgnoreDts) @@ -205,19 +190,6 @@ namespace Jellyfin.LiveTv.IO private static string GetAudioArgs(MediaSourceInfo mediaSource) { return "-codec:a:0 copy"; - - // var audioChannels = 2; - // var audioStream = mediaStreams.FirstOrDefault(i => i.Type == MediaStreamType.Audio); - // if (audioStream is not null) - // { - // audioChannels = audioStream.Channels ?? audioChannels; - // } - // return "-codec:a:0 aac -strict experimental -ab 320000"; - } - - private static bool EncodeVideo(MediaSourceInfo mediaSource) - { - return false; } protected string GetOutputSizeParam() diff --git a/src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj b/src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj index c58889740a..f04c02504c 100644 --- a/src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj +++ b/src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj @@ -1,6 +1,6 @@ - net8.0 + net9.0 true diff --git a/src/Jellyfin.LiveTv/Listings/ListingsManager.cs b/src/Jellyfin.LiveTv/Listings/ListingsManager.cs index 3df2d0d2cf..39c2bd375b 100644 --- a/src/Jellyfin.LiveTv/Listings/ListingsManager.cs +++ b/src/Jellyfin.LiveTv/Listings/ListingsManager.cs @@ -230,10 +230,15 @@ public class ListingsManager : IListingsManager var listingsProviderInfo = config.ListingProviders .First(info => string.Equals(providerId, info.Id, StringComparison.OrdinalIgnoreCase)); + var channelMappingExists = listingsProviderInfo.ChannelMappings + .Any(pair => string.Equals(pair.Name, tunerChannelNumber, StringComparison.OrdinalIgnoreCase) + && string.Equals(pair.Value, providerChannelNumber, StringComparison.OrdinalIgnoreCase)); + listingsProviderInfo.ChannelMappings = listingsProviderInfo.ChannelMappings .Where(pair => !string.Equals(pair.Name, tunerChannelNumber, StringComparison.OrdinalIgnoreCase)).ToArray(); - if (!string.Equals(tunerChannelNumber, providerChannelNumber, StringComparison.OrdinalIgnoreCase)) + if (!string.Equals(tunerChannelNumber, providerChannelNumber, StringComparison.OrdinalIgnoreCase) + && !channelMappingExists) { var newItem = new NameValuePair { diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs index c7a57859e8..d6f15906ef 100644 --- a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs @@ -19,6 +19,7 @@ using System.Threading.Tasks; using AsyncKeyedLock; using Jellyfin.Extensions; using Jellyfin.Extensions.Json; +using Jellyfin.LiveTv.Guide; using Jellyfin.LiveTv.Listings.SchedulesDirectDtos; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Authentication; @@ -38,7 +39,7 @@ namespace Jellyfin.LiveTv.Listings private readonly IHttpClientFactory _httpClientFactory; private readonly AsyncNonKeyedLocker _tokenLock = new(1); - private readonly ConcurrentDictionary _tokens = new ConcurrentDictionary(); + private readonly ConcurrentDictionary _tokens = new(); private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; private DateTime _lastErrorResponse; private bool _disposed = false; @@ -86,7 +87,7 @@ namespace Jellyfin.LiveTv.Listings { _logger.LogWarning("SchedulesDirect token is empty, returning empty program list"); - return Enumerable.Empty(); + return []; } var dates = GetScheduleRequestDates(startDateUtc, endDateUtc); @@ -94,7 +95,7 @@ namespace Jellyfin.LiveTv.Listings _logger.LogInformation("Channel Station ID is: {ChannelID}", channelId); var requestList = new List() { - new RequestScheduleForChannelDto() + new() { StationId = channelId, Date = dates @@ -109,7 +110,7 @@ namespace Jellyfin.LiveTv.Listings var dailySchedules = await Request>(options, true, info, cancellationToken).ConfigureAwait(false); if (dailySchedules is null) { - return Array.Empty(); + return []; } _logger.LogDebug("Found {ScheduleCount} programs on {ChannelID} ScheduleDirect", dailySchedules.Count, channelId); @@ -120,17 +121,17 @@ namespace Jellyfin.LiveTv.Listings var programIds = dailySchedules.SelectMany(d => d.Programs.Select(s => s.ProgramId)).Distinct(); programRequestOptions.Content = JsonContent.Create(programIds, options: _jsonOptions); - var programDetails = await Request>(programRequestOptions, true, info, cancellationToken) - .ConfigureAwait(false); + var programDetails = await Request>(programRequestOptions, true, info, cancellationToken).ConfigureAwait(false); if (programDetails is null) { - return Array.Empty(); + return []; } var programDict = programDetails.ToDictionary(p => p.ProgramId, y => y); var programIdsWithImages = programDetails - .Where(p => p.HasImageArtwork).Select(p => p.ProgramId) + .Where(p => p.HasImageArtwork) + .Select(p => p.ProgramId) .ToList(); var images = await GetImageForPrograms(info, programIdsWithImages, cancellationToken).ConfigureAwait(false); @@ -138,17 +139,15 @@ namespace Jellyfin.LiveTv.Listings var programsInfo = new List(); foreach (ProgramDto schedule in dailySchedules.SelectMany(d => d.Programs)) { - // _logger.LogDebug("Proccesing Schedule for statio ID " + stationID + - // " which corresponds to channel " + channelNumber + " and program id " + - // schedule.ProgramId + " which says it has images? " + - // programDict[schedule.ProgramId].hasImageArtwork); - if (string.IsNullOrEmpty(schedule.ProgramId)) { continue; } - if (images is not null) + // Only add images which will be pre-cached until we can implement dynamic token fetching + var endDate = schedule.AirDateTime?.AddSeconds(schedule.Duration); + var willBeCached = endDate.HasValue && endDate.Value < DateTime.UtcNow.AddDays(GuideManager.MaxCacheDays); + if (willBeCached && images is not null) { var imageIndex = images.FindIndex(i => i.ProgramId == schedule.ProgramId[..10]); if (imageIndex > -1) @@ -456,7 +455,7 @@ namespace Jellyfin.LiveTv.Listings if (programIds.Count == 0) { - return Array.Empty(); + return []; } StringBuilder str = new StringBuilder("[", 1 + (programIds.Count * 13)); @@ -483,7 +482,7 @@ namespace Jellyfin.LiveTv.Listings { _logger.LogError(ex, "Error getting image info from schedules direct"); - return Array.Empty(); + return []; } } diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/LineupDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/LineupDto.cs index 856b7a89b0..79bcbe6497 100644 --- a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/LineupDto.cs +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/LineupDto.cs @@ -8,7 +8,7 @@ namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos public class LineupDto { /// - /// Gets or sets the linup. + /// Gets or sets the lineup. /// [JsonPropertyName("lineup")] public string? Lineup { get; set; } diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MapDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MapDto.cs index ea583a1cea..89c4ee5a89 100644 --- a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MapDto.cs +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MapDto.cs @@ -23,7 +23,7 @@ namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos /// Gets or sets the provider callsign. /// [JsonPropertyName("providerCallsign")] - public string? ProvderCallsign { get; set; } + public string? ProviderCallsign { get; set; } /// /// Gets or sets the logical channel number. diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MetadataDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MetadataDto.cs index cafc8e2738..7998a7a92e 100644 --- a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MetadataDto.cs +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MetadataDto.cs @@ -8,7 +8,7 @@ namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos public class MetadataDto { /// - /// Gets or sets the linup. + /// Gets or sets the lineup. /// [JsonPropertyName("lineup")] public string? Lineup { get; set; } diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ProgramDetailsDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ProgramDetailsDto.cs index 8c3906f863..7bfc4bc8be 100644 --- a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ProgramDetailsDto.cs +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ProgramDetailsDto.cs @@ -64,7 +64,7 @@ namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos public IReadOnlyList Metadata { get; set; } = Array.Empty(); /// - /// Gets or sets the list of content raitings. + /// Gets or sets the list of content ratings. /// [JsonPropertyName("contentRating")] public IReadOnlyList ContentRating { get; set; } = Array.Empty(); diff --git a/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs b/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs index 7dc30f7275..7938b7a6e4 100644 --- a/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs +++ b/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs @@ -84,10 +84,11 @@ namespace Jellyfin.LiveTv.Listings _logger.LogInformation("Downloading xmltv listings from {Path}", info.Path); using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(info.Path, cancellationToken).ConfigureAwait(false); + var redirectedUrl = response.RequestMessage?.RequestUri?.ToString() ?? info.Path; var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); await using (stream.ConfigureAwait(false)) { - return await UnzipIfNeededAndCopy(info.Path, stream, cacheFile, cancellationToken).ConfigureAwait(false); + return await UnzipIfNeededAndCopy(redirectedUrl, stream, cacheFile, cancellationToken).ConfigureAwait(false); } } else @@ -112,7 +113,8 @@ namespace Jellyfin.LiveTv.Listings await using (fileStream.ConfigureAwait(false)) { - if (Path.GetExtension(originalUrl.AsSpan().LeftPart('?')).Equals(".gz", StringComparison.OrdinalIgnoreCase)) + if (Path.GetExtension(originalUrl.AsSpan().LeftPart('?')).Equals(".gz", StringComparison.OrdinalIgnoreCase) || + Path.GetExtension(originalUrl.AsSpan().LeftPart('?')).Equals(".gzip", StringComparison.OrdinalIgnoreCase)) { try { diff --git a/src/Jellyfin.LiveTv/LiveTvDtoService.cs b/src/Jellyfin.LiveTv/LiveTvDtoService.cs index 55b056d3d8..acf168cf11 100644 --- a/src/Jellyfin.LiveTv/LiveTvDtoService.cs +++ b/src/Jellyfin.LiveTv/LiveTvDtoService.cs @@ -221,7 +221,7 @@ namespace Jellyfin.LiveTv try { dto.ParentPrimaryImageTag = _imageProcessor.GetImageCacheTag(program, image); - dto.ParentPrimaryImageItemId = program.Id.ToString("N", CultureInfo.InvariantCulture); + dto.ParentPrimaryImageItemId = program.Id; } catch (Exception ex) { @@ -327,7 +327,7 @@ namespace Jellyfin.LiveTv try { dto.ParentPrimaryImageTag = _imageProcessor.GetImageCacheTag(program, image); - dto.ParentPrimaryImageItemId = program.Id.ToString("N", CultureInfo.InvariantCulture); + dto.ParentPrimaryImageItemId = program.Id; } catch (Exception ex) { diff --git a/src/Jellyfin.LiveTv/LiveTvManager.cs b/src/Jellyfin.LiveTv/LiveTvManager.cs index 0c85dc434b..40adb51a58 100644 --- a/src/Jellyfin.LiveTv/LiveTvManager.cs +++ b/src/Jellyfin.LiveTv/LiveTvManager.cs @@ -8,9 +8,11 @@ using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Data.Entities; +using Jellyfin.Data; using Jellyfin.Data.Enums; using Jellyfin.Data.Events; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.LiveTv.Configuration; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Channels; diff --git a/src/Jellyfin.LiveTv/Recordings/RecordingNotifier.cs b/src/Jellyfin.LiveTv/Recordings/RecordingNotifier.cs index e63afa6260..a5d186ce18 100644 --- a/src/Jellyfin.LiveTv/Recordings/RecordingNotifier.cs +++ b/src/Jellyfin.LiveTv/Recordings/RecordingNotifier.cs @@ -2,8 +2,9 @@ using System; using System.Linq; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Data.Enums; +using Jellyfin.Data; using Jellyfin.Data.Events; +using Jellyfin.Database.Implementations.Enums; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.Session; diff --git a/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs b/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs index 2f4caa3867..9ca5d7420b 100644 --- a/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs +++ b/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs @@ -10,6 +10,7 @@ using System.Threading; using System.Threading.Tasks; using AsyncKeyedLock; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.LiveTv.Configuration; using Jellyfin.LiveTv.IO; using Jellyfin.LiveTv.Timers; diff --git a/src/Jellyfin.LiveTv/Recordings/RecordingsMetadataManager.cs b/src/Jellyfin.LiveTv/Recordings/RecordingsMetadataManager.cs index b2b82332df..3a2c463695 100644 --- a/src/Jellyfin.LiveTv/Recordings/RecordingsMetadataManager.cs +++ b/src/Jellyfin.LiveTv/Recordings/RecordingsMetadataManager.cs @@ -344,15 +344,12 @@ public class RecordingsMetadataManager await writer.WriteElementStringAsync(null, "credits", null, person).ConfigureAwait(false); } - var tmdbCollection = item.GetProviderId(MetadataProvider.TmdbCollection); - - if (!string.IsNullOrEmpty(tmdbCollection)) + if (item.TryGetProviderId(MetadataProvider.TmdbCollection, out var tmdbCollection)) { await writer.WriteElementStringAsync(null, "collectionnumber", null, tmdbCollection).ConfigureAwait(false); } - var imdb = item.GetProviderId(MetadataProvider.Imdb); - if (!string.IsNullOrEmpty(imdb)) + if (item.TryGetProviderId(MetadataProvider.Imdb, out var imdb)) { if (!isSeriesEpisode) { @@ -365,8 +362,7 @@ public class RecordingsMetadataManager lockData = false; } - var tvdb = item.GetProviderId(MetadataProvider.Tvdb); - if (!string.IsNullOrEmpty(tvdb)) + if (item.TryGetProviderId(MetadataProvider.Tvdb, out var tvdb)) { await writer.WriteElementStringAsync(null, "tvdbid", null, tvdb).ConfigureAwait(false); @@ -374,8 +370,7 @@ public class RecordingsMetadataManager lockData = false; } - var tmdb = item.GetProviderId(MetadataProvider.Tmdb); - if (!string.IsNullOrEmpty(tmdb)) + if (item.TryGetProviderId(MetadataProvider.Tmdb, out var tmdb)) { await writer.WriteElementStringAsync(null, "tmdbid", null, tmdb).ConfigureAwait(false); diff --git a/src/Jellyfin.LiveTv/Timers/ItemDataProvider.cs b/src/Jellyfin.LiveTv/Timers/ItemDataProvider.cs index 9e7323f5b9..6a68b8c25c 100644 --- a/src/Jellyfin.LiveTv/Timers/ItemDataProvider.cs +++ b/src/Jellyfin.LiveTv/Timers/ItemDataProvider.cs @@ -6,6 +6,7 @@ using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Text.Json; +using System.Threading; using Jellyfin.Extensions.Json; using Microsoft.Extensions.Logging; @@ -15,7 +16,7 @@ namespace Jellyfin.LiveTv.Timers where T : class { private readonly string _dataPath; - private readonly object _fileDataLock = new object(); + private readonly Lock _fileDataLock = new(); private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; private T[]? _items; diff --git a/src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs b/src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs index be81171a03..fb606be0ef 100644 --- a/src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs +++ b/src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs @@ -190,7 +190,7 @@ namespace Jellyfin.LiveTv.TunerHosts RequiresClosing = true, RequiresLooping = info.EnableStreamLooping, - ReadAtNativeFramerate = false, + ReadAtNativeFramerate = info.ReadAtNativeFramerate, Id = channel.Path.GetMD5().ToString("N", CultureInfo.InvariantCulture), IsInfiniteStream = true, diff --git a/src/Jellyfin.LiveTv/TunerHosts/M3uParser.cs b/src/Jellyfin.LiveTv/TunerHosts/M3uParser.cs index c8d678e2fc..e3afe15131 100644 --- a/src/Jellyfin.LiveTv/TunerHosts/M3uParser.cs +++ b/src/Jellyfin.LiveTv/TunerHosts/M3uParser.cs @@ -93,7 +93,7 @@ namespace Jellyfin.LiveTv.TunerHosts } else if (!string.IsNullOrWhiteSpace(extInf) && !trimmedLine.StartsWith('#')) { - var channel = GetChannelnfo(extInf, tunerHostId, trimmedLine); + var channel = GetChannelInfo(extInf, tunerHostId, trimmedLine); channel.Id = channelIdPrefix + trimmedLine.GetMD5().ToString("N", CultureInfo.InvariantCulture); channel.Path = trimmedLine; @@ -106,7 +106,7 @@ namespace Jellyfin.LiveTv.TunerHosts return channels; } - private ChannelInfo GetChannelnfo(string extInf, string tunerHostId, string mediaUrl) + private ChannelInfo GetChannelInfo(string extInf, string tunerHostId, string mediaUrl) { var channel = new ChannelInfo() { diff --git a/src/Jellyfin.MediaEncoding.Hls/Cache/CacheDecorator.cs b/src/Jellyfin.MediaEncoding.Hls/Cache/CacheDecorator.cs index 127f4079c5..8ca0e869a5 100644 --- a/src/Jellyfin.MediaEncoding.Hls/Cache/CacheDecorator.cs +++ b/src/Jellyfin.MediaEncoding.Hls/Cache/CacheDecorator.cs @@ -1,13 +1,12 @@ +#pragma warning disable CA1826 // Do not use Enumerable methods on indexable collections + using System; using System.Diagnostics.CodeAnalysis; -using System.Globalization; -using System.IO; -using System.Text.Json; -using Jellyfin.Extensions.Json; +using System.Linq; +using System.Threading; using Jellyfin.MediaEncoding.Hls.Extractors; using Jellyfin.MediaEncoding.Keyframes; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Persistence; using Microsoft.Extensions.Logging; namespace Jellyfin.MediaEncoding.Hls.Cache; @@ -15,82 +14,48 @@ namespace Jellyfin.MediaEncoding.Hls.Cache; /// public class CacheDecorator : IKeyframeExtractor { + private readonly IKeyframeRepository _keyframeRepository; private readonly IKeyframeExtractor _keyframeExtractor; private readonly ILogger _logger; private readonly string _keyframeExtractorName; - private static readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; - private readonly string _keyframeCachePath; /// /// Initializes a new instance of the class. /// - /// An instance of the interface. + /// An instance of the interface. /// An instance of the interface. /// An instance of the interface. - public CacheDecorator(IApplicationPaths applicationPaths, IKeyframeExtractor keyframeExtractor, ILogger logger) + public CacheDecorator(IKeyframeRepository keyframeRepository, IKeyframeExtractor keyframeExtractor, ILogger logger) { - ArgumentNullException.ThrowIfNull(applicationPaths); + ArgumentNullException.ThrowIfNull(keyframeRepository); ArgumentNullException.ThrowIfNull(keyframeExtractor); + _keyframeRepository = keyframeRepository; _keyframeExtractor = keyframeExtractor; _logger = logger; _keyframeExtractorName = keyframeExtractor.GetType().Name; - // TODO make the dir configurable - _keyframeCachePath = Path.Combine(applicationPaths.DataPath, "keyframes"); } /// public bool IsMetadataBased => _keyframeExtractor.IsMetadataBased; /// - public bool TryExtractKeyframes(string filePath, [NotNullWhen(true)] out KeyframeData? keyframeData) + public bool TryExtractKeyframes(Guid itemId, string filePath, [NotNullWhen(true)] out KeyframeData? keyframeData) { - keyframeData = null; - var cachePath = GetCachePath(_keyframeCachePath, filePath); - if (TryReadFromCache(cachePath, out var cachedResult)) + keyframeData = _keyframeRepository.GetKeyframeData(itemId).FirstOrDefault(); + if (keyframeData is null) { - keyframeData = cachedResult; - return true; + if (!_keyframeExtractor.TryExtractKeyframes(itemId, filePath, out var result)) + { + _logger.LogDebug("Failed to extract keyframes using {ExtractorName}", _keyframeExtractorName); + return false; + } + + _logger.LogDebug("Successfully extracted keyframes using {ExtractorName}", _keyframeExtractorName); + keyframeData = result; + _keyframeRepository.SaveKeyframeDataAsync(itemId, keyframeData, CancellationToken.None).GetAwaiter().GetResult(); } - if (!_keyframeExtractor.TryExtractKeyframes(filePath, out var result)) - { - _logger.LogDebug("Failed to extract keyframes using {ExtractorName}", _keyframeExtractorName); - return false; - } - - _logger.LogDebug("Successfully extracted keyframes using {ExtractorName}", _keyframeExtractorName); - keyframeData = result; - SaveToCache(cachePath, keyframeData); return true; } - - private static void SaveToCache(string cachePath, KeyframeData keyframeData) - { - var json = JsonSerializer.Serialize(keyframeData, _jsonOptions); - Directory.CreateDirectory(Path.GetDirectoryName(cachePath) ?? throw new ArgumentException($"Provided path ({cachePath}) is not valid.", nameof(cachePath))); - File.WriteAllText(cachePath, json); - } - - private static string GetCachePath(string keyframeCachePath, string filePath) - { - var lastWriteTimeUtc = File.GetLastWriteTimeUtc(filePath); - ReadOnlySpan filename = (filePath + "_" + lastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture)).GetMD5() + ".json"; - var prefix = filename[..1]; - - return Path.Join(keyframeCachePath, prefix, filename); - } - - private static bool TryReadFromCache(string cachePath, [NotNullWhen(true)] out KeyframeData? cachedResult) - { - if (File.Exists(cachePath)) - { - var bytes = File.ReadAllBytes(cachePath); - cachedResult = JsonSerializer.Deserialize(bytes, _jsonOptions); - return cachedResult is not null; - } - - cachedResult = null; - return false; - } } diff --git a/src/Jellyfin.MediaEncoding.Hls/Extractors/FfProbeKeyframeExtractor.cs b/src/Jellyfin.MediaEncoding.Hls/Extractors/FfProbeKeyframeExtractor.cs index a8daeeb787..a69746fe0d 100644 --- a/src/Jellyfin.MediaEncoding.Hls/Extractors/FfProbeKeyframeExtractor.cs +++ b/src/Jellyfin.MediaEncoding.Hls/Extractors/FfProbeKeyframeExtractor.cs @@ -34,7 +34,7 @@ public class FfProbeKeyframeExtractor : IKeyframeExtractor public bool IsMetadataBased => false; /// - public bool TryExtractKeyframes(string filePath, [NotNullWhen(true)] out KeyframeData? keyframeData) + public bool TryExtractKeyframes(Guid itemId, string filePath, [NotNullWhen(true)] out KeyframeData? keyframeData) { if (!_namingOptions.VideoFileExtensions.Contains(Path.GetExtension(filePath.AsSpan()), StringComparison.OrdinalIgnoreCase)) { diff --git a/src/Jellyfin.MediaEncoding.Hls/Extractors/IKeyframeExtractor.cs b/src/Jellyfin.MediaEncoding.Hls/Extractors/IKeyframeExtractor.cs index 083e93de1a..84bccbc721 100644 --- a/src/Jellyfin.MediaEncoding.Hls/Extractors/IKeyframeExtractor.cs +++ b/src/Jellyfin.MediaEncoding.Hls/Extractors/IKeyframeExtractor.cs @@ -1,3 +1,4 @@ +using System; using System.Diagnostics.CodeAnalysis; using Jellyfin.MediaEncoding.Keyframes; @@ -16,8 +17,9 @@ public interface IKeyframeExtractor /// /// Attempt to extract keyframes. /// + /// The item id. /// The path to the file. /// The keyframes. /// A value indicating whether the keyframe extraction was successful. - bool TryExtractKeyframes(string filePath, [NotNullWhen(true)] out KeyframeData? keyframeData); + bool TryExtractKeyframes(Guid itemId, string filePath, [NotNullWhen(true)] out KeyframeData? keyframeData); } diff --git a/src/Jellyfin.MediaEncoding.Hls/Extractors/MatroskaKeyframeExtractor.cs b/src/Jellyfin.MediaEncoding.Hls/Extractors/MatroskaKeyframeExtractor.cs index 1100f8cd5d..c7758e9193 100644 --- a/src/Jellyfin.MediaEncoding.Hls/Extractors/MatroskaKeyframeExtractor.cs +++ b/src/Jellyfin.MediaEncoding.Hls/Extractors/MatroskaKeyframeExtractor.cs @@ -24,7 +24,7 @@ public class MatroskaKeyframeExtractor : IKeyframeExtractor public bool IsMetadataBased => true; /// - public bool TryExtractKeyframes(string filePath, [NotNullWhen(true)] out KeyframeData? keyframeData) + public bool TryExtractKeyframes(Guid itemId, string filePath, [NotNullWhen(true)] out KeyframeData? keyframeData) { if (!filePath.AsSpan().EndsWith(".mkv", StringComparison.OrdinalIgnoreCase)) { diff --git a/src/Jellyfin.MediaEncoding.Hls/Jellyfin.MediaEncoding.Hls.csproj b/src/Jellyfin.MediaEncoding.Hls/Jellyfin.MediaEncoding.Hls.csproj index ee79802a1e..80b5aa84e4 100644 --- a/src/Jellyfin.MediaEncoding.Hls/Jellyfin.MediaEncoding.Hls.csproj +++ b/src/Jellyfin.MediaEncoding.Hls/Jellyfin.MediaEncoding.Hls.csproj @@ -1,12 +1,13 @@ - net8.0 + net9.0 true + diff --git a/src/Jellyfin.MediaEncoding.Hls/Playlist/CreateMainPlaylistRequest.cs b/src/Jellyfin.MediaEncoding.Hls/Playlist/CreateMainPlaylistRequest.cs index 21d9bb6588..f5af500626 100644 --- a/src/Jellyfin.MediaEncoding.Hls/Playlist/CreateMainPlaylistRequest.cs +++ b/src/Jellyfin.MediaEncoding.Hls/Playlist/CreateMainPlaylistRequest.cs @@ -1,3 +1,5 @@ +using System; + namespace Jellyfin.MediaEncoding.Hls.Playlist; /// @@ -8,6 +10,7 @@ public class CreateMainPlaylistRequest /// /// Initializes a new instance of the class. /// + /// The media source id. /// The absolute file path to the file. /// The desired segment length in milliseconds. /// The total duration of the file in ticks. @@ -15,8 +18,9 @@ public class CreateMainPlaylistRequest /// The URI prefix for the relative URL in the playlist. /// The desired query string to append (must start with ?). /// Whether the video is being remuxed. - public CreateMainPlaylistRequest(string filePath, int desiredSegmentLengthMs, long totalRuntimeTicks, string segmentContainer, string endpointPrefix, string queryString, bool isRemuxingVideo) + public CreateMainPlaylistRequest(Guid? mediaSourceId, string filePath, int desiredSegmentLengthMs, long totalRuntimeTicks, string segmentContainer, string endpointPrefix, string queryString, bool isRemuxingVideo) { + MediaSourceId = mediaSourceId; FilePath = filePath; DesiredSegmentLengthMs = desiredSegmentLengthMs; TotalRuntimeTicks = totalRuntimeTicks; @@ -26,6 +30,11 @@ public class CreateMainPlaylistRequest IsRemuxingVideo = isRemuxingVideo; } + /// + /// Gets the media source id. + /// + public Guid? MediaSourceId { get; } + /// /// Gets the file path. /// diff --git a/src/Jellyfin.MediaEncoding.Hls/Playlist/DynamicHlsPlaylistGenerator.cs b/src/Jellyfin.MediaEncoding.Hls/Playlist/DynamicHlsPlaylistGenerator.cs index 1846ba26bf..fb5027e5b5 100644 --- a/src/Jellyfin.MediaEncoding.Hls/Playlist/DynamicHlsPlaylistGenerator.cs +++ b/src/Jellyfin.MediaEncoding.Hls/Playlist/DynamicHlsPlaylistGenerator.cs @@ -35,7 +35,9 @@ public class DynamicHlsPlaylistGenerator : IDynamicHlsPlaylistGenerator { IReadOnlyList segments; // For video transcodes it is sufficient with equal length segments as ffmpeg will create new keyframes - if (request.IsRemuxingVideo && TryExtractKeyframes(request.FilePath, out var keyframeData)) + if (request.IsRemuxingVideo + && request.MediaSourceId is not null + && TryExtractKeyframes(request.MediaSourceId.Value, request.FilePath, out var keyframeData)) { segments = ComputeSegments(keyframeData, request.DesiredSegmentLengthMs); } @@ -104,7 +106,7 @@ public class DynamicHlsPlaylistGenerator : IDynamicHlsPlaylistGenerator return builder.ToString(); } - private bool TryExtractKeyframes(string filePath, [NotNullWhen(true)] out KeyframeData? keyframeData) + private bool TryExtractKeyframes(Guid itemId, string filePath, [NotNullWhen(true)] out KeyframeData? keyframeData) { keyframeData = null; if (!IsExtractionAllowedForFile(filePath, _serverConfigurationManager.GetEncodingOptions().AllowOnDemandMetadataBasedKeyframeExtractionForExtensions)) @@ -116,7 +118,7 @@ public class DynamicHlsPlaylistGenerator : IDynamicHlsPlaylistGenerator for (var i = 0; i < len; i++) { var extractor = _extractors[i]; - if (!extractor.TryExtractKeyframes(filePath, out var result)) + if (!extractor.TryExtractKeyframes(itemId, filePath, out var result)) { continue; } diff --git a/src/Jellyfin.MediaEncoding.Hls/ScheduledTasks/KeyframeExtractionScheduledTask.cs b/src/Jellyfin.MediaEncoding.Hls/ScheduledTasks/KeyframeExtractionScheduledTask.cs index caf6a2aae3..d63ee67774 100644 --- a/src/Jellyfin.MediaEncoding.Hls/ScheduledTasks/KeyframeExtractionScheduledTask.cs +++ b/src/Jellyfin.MediaEncoding.Hls/ScheduledTasks/KeyframeExtractionScheduledTask.cs @@ -9,7 +9,6 @@ using Jellyfin.MediaEncoding.Hls.Extractors; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; -using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; using MediaBrowser.Model.Tasks; @@ -23,7 +22,7 @@ public class KeyframeExtractionScheduledTask : IScheduledTask private readonly ILocalizationManager _localizationManager; private readonly ILibraryManager _libraryManager; private readonly IKeyframeExtractor[] _keyframeExtractors; - private static readonly BaseItemKind[] _itemTypes = { BaseItemKind.Episode, BaseItemKind.Movie }; + private static readonly BaseItemKind[] _itemTypes = [BaseItemKind.Episode, BaseItemKind.Movie]; /// /// Initializes a new instance of the class. @@ -55,11 +54,11 @@ public class KeyframeExtractionScheduledTask : IScheduledTask { var query = new InternalItemsQuery { - MediaTypes = new[] { MediaType.Video }, + MediaTypes = [MediaType.Video], IsVirtualItem = false, IncludeItemTypes = _itemTypes, DtoOptions = new DtoOptions(true), - SourceTypes = new[] { SourceType.Library }, + SourceTypes = [SourceType.Library], Recursive = true, Limit = Pagesize }; @@ -74,19 +73,16 @@ public class KeyframeExtractionScheduledTask : IScheduledTask query.StartIndex = startIndex; var videos = _libraryManager.GetItemList(query); - var currentPageCount = videos.Count; - // TODO parallelize with Parallel.ForEach? - for (var i = 0; i < currentPageCount; i++) + foreach (var video in videos) { - var video = videos[i]; // Only local files supported - if (video.IsFileProtocol && File.Exists(video.Path)) + var path = video.Path; + if (File.Exists(path)) { - for (var j = 0; j < _keyframeExtractors.Length; j++) + foreach (var extractor in _keyframeExtractors) { - var extractor = _keyframeExtractors[j]; - // The cache decorator will make sure to save them in the data dir - if (extractor.TryExtractKeyframes(video.Path, out _)) + // The cache decorator will make sure to save the keyframes + if (extractor.TryExtractKeyframes(video.Id, path, out _)) { break; } @@ -107,5 +103,5 @@ public class KeyframeExtractionScheduledTask : IScheduledTask } /// - public IEnumerable GetDefaultTriggers() => Enumerable.Empty(); + public IEnumerable GetDefaultTriggers() => []; } diff --git a/src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj b/src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj index c79dcee3c4..c826d3d9cc 100644 --- a/src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj +++ b/src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj @@ -1,7 +1,7 @@ - net8.0 + net9.0 true diff --git a/src/Jellyfin.Networking/Jellyfin.Networking.csproj b/src/Jellyfin.Networking/Jellyfin.Networking.csproj index 24b3ecaab9..1a146549de 100644 --- a/src/Jellyfin.Networking/Jellyfin.Networking.csproj +++ b/src/Jellyfin.Networking/Jellyfin.Networking.csproj @@ -1,6 +1,6 @@ - net8.0 + net9.0 false true @@ -14,7 +14,4 @@ - - - diff --git a/src/Jellyfin.Networking/Manager/NetworkManager.cs b/src/Jellyfin.Networking/Manager/NetworkManager.cs index 5a13cc4173..80a5741df9 100644 --- a/src/Jellyfin.Networking/Manager/NetworkManager.cs +++ b/src/Jellyfin.Networking/Manager/NetworkManager.cs @@ -7,6 +7,7 @@ using System.Net; using System.Net.NetworkInformation; using System.Net.Sockets; using System.Threading; +using J2N.Collections.Generic.Extensions; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Net; using MediaBrowser.Model.Net; @@ -27,7 +28,7 @@ public class NetworkManager : INetworkManager, IDisposable /// /// Threading lock for network properties. /// - private readonly object _initLock; + private readonly Lock _initLock; private readonly ILogger _logger; @@ -35,7 +36,7 @@ public class NetworkManager : INetworkManager, IDisposable private readonly IConfiguration _startupConfig; - private readonly object _networkEventLock; + private readonly Lock _networkEventLock; /// /// Holds the published server URLs and the IPs to use them on. @@ -49,15 +50,10 @@ public class NetworkManager : INetworkManager, IDisposable /// private bool _eventfire; - /// - /// List of all interface MAC addresses. - /// - private IReadOnlyList _macAddresses; - /// /// Dictionary containing interface addresses and their subnets. /// - private IReadOnlyList _interfaces; + private List _interfaces; /// /// Unfiltered user defined LAN subnets () @@ -91,9 +87,8 @@ public class NetworkManager : INetworkManager, IDisposable _startupConfig = startupConfig; _initLock = new(); _interfaces = new List(); - _macAddresses = new List(); _publishedServerUrls = new List(); - _networkEventLock = new object(); + _networkEventLock = new(); _remoteAddressFilter = new List(); _ = bool.TryParse(startupConfig[DetectNetworkChangeKey], out var detectNetworkChange); @@ -215,98 +210,99 @@ public class NetworkManager : INetworkManager, IDisposable /// /// Generate a list of all the interface ip addresses and submasks where that are in the active/unknown state. - /// Generate a list of all active mac addresses that aren't loopback addresses. /// private void InitializeInterfaces() { lock (_initLock) { - _logger.LogDebug("Refreshing interfaces."); - - var interfaces = new List(); - var macAddresses = new List(); - - try - { - var nics = NetworkInterface.GetAllNetworkInterfaces() - .Where(i => i.OperationalStatus == OperationalStatus.Up); - - foreach (NetworkInterface adapter in nics) - { - try - { - var ipProperties = adapter.GetIPProperties(); - var mac = adapter.GetPhysicalAddress(); - - // Populate MAC list - if (adapter.NetworkInterfaceType != NetworkInterfaceType.Loopback && !PhysicalAddress.None.Equals(mac)) - { - macAddresses.Add(mac); - } - - // Populate interface list - foreach (var info in ipProperties.UnicastAddresses) - { - if (IsIPv4Enabled && info.Address.AddressFamily == AddressFamily.InterNetwork) - { - var interfaceObject = new IPData(info.Address, new IPNetwork(info.Address, info.PrefixLength), adapter.Name) - { - Index = ipProperties.GetIPv4Properties().Index, - Name = adapter.Name, - SupportsMulticast = adapter.SupportsMulticast - }; - - interfaces.Add(interfaceObject); - } - else if (IsIPv6Enabled && info.Address.AddressFamily == AddressFamily.InterNetworkV6) - { - var interfaceObject = new IPData(info.Address, new IPNetwork(info.Address, info.PrefixLength), adapter.Name) - { - Index = ipProperties.GetIPv6Properties().Index, - Name = adapter.Name, - SupportsMulticast = adapter.SupportsMulticast - }; - - interfaces.Add(interfaceObject); - } - } - } - catch (Exception ex) - { - // Ignore error, and attempt to continue. - _logger.LogError(ex, "Error encountered parsing interfaces."); - } - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error obtaining interfaces."); - } - - // If no interfaces are found, fallback to loopback interfaces. - if (interfaces.Count == 0) - { - _logger.LogWarning("No interface information available. Using loopback interface(s)."); - - if (IsIPv4Enabled) - { - interfaces.Add(new IPData(IPAddress.Loopback, NetworkConstants.IPv4RFC5735Loopback, "lo")); - } - - if (IsIPv6Enabled) - { - interfaces.Add(new IPData(IPAddress.IPv6Loopback, NetworkConstants.IPv6RFC4291Loopback, "lo")); - } - } - - _logger.LogDebug("Discovered {NumberOfInterfaces} interfaces.", interfaces.Count); - _logger.LogDebug("Interfaces addresses: {Addresses}", interfaces.OrderByDescending(s => s.AddressFamily == AddressFamily.InterNetwork).Select(s => s.Address.ToString())); - - _macAddresses = macAddresses; - _interfaces = interfaces; + _interfaces = GetInterfacesCore(_logger, IsIPv4Enabled, IsIPv6Enabled).ToList(); } } + /// + /// Generate a list of all the interface ip addresses and submasks where that are in the active/unknown state. + /// + /// The logger. + /// If true evaluates IPV4 type ip addresses. + /// If true evaluates IPV6 type ip addresses. + /// A list of all locally known up addresses and submasks that are to be considered usable. + public static IReadOnlyList GetInterfacesCore(ILogger logger, bool isIPv4Enabled, bool isIPv6Enabled) + { + logger.LogDebug("Refreshing interfaces."); + + var interfaces = new List(); + + try + { + var nics = NetworkInterface.GetAllNetworkInterfaces() + .Where(i => i.OperationalStatus == OperationalStatus.Up); + + foreach (NetworkInterface adapter in nics) + { + try + { + var ipProperties = adapter.GetIPProperties(); + + // Populate interface list + foreach (var info in ipProperties.UnicastAddresses) + { + if (isIPv4Enabled && info.Address.AddressFamily == AddressFamily.InterNetwork) + { + var interfaceObject = new IPData(info.Address, new IPNetwork(info.Address, info.PrefixLength), adapter.Name) + { + Index = ipProperties.GetIPv4Properties().Index, + Name = adapter.Name, + SupportsMulticast = adapter.SupportsMulticast + }; + + interfaces.Add(interfaceObject); + } + else if (isIPv6Enabled && info.Address.AddressFamily == AddressFamily.InterNetworkV6) + { + var interfaceObject = new IPData(info.Address, new IPNetwork(info.Address, info.PrefixLength), adapter.Name) + { + Index = ipProperties.GetIPv6Properties().Index, + Name = adapter.Name, + SupportsMulticast = adapter.SupportsMulticast + }; + + interfaces.Add(interfaceObject); + } + } + } + catch (Exception ex) + { + // Ignore error, and attempt to continue. + logger.LogError(ex, "Error encountered parsing interfaces."); + } + } + } + catch (Exception ex) + { + logger.LogError(ex, "Error obtaining interfaces."); + } + + // If no interfaces are found, fallback to loopback interfaces. + if (interfaces.Count == 0) + { + logger.LogWarning("No interface information available. Using loopback interface(s)."); + + if (isIPv4Enabled) + { + interfaces.Add(new IPData(IPAddress.Loopback, NetworkConstants.IPv4RFC5735Loopback, "lo")); + } + + if (isIPv6Enabled) + { + interfaces.Add(new IPData(IPAddress.IPv6Loopback, NetworkConstants.IPv6RFC4291Loopback, "lo")); + } + } + + logger.LogDebug("Discovered {NumberOfInterfaces} interfaces.", interfaces.Count); + logger.LogDebug("Interfaces addresses: {Addresses}", interfaces.OrderByDescending(s => s.AddressFamily == AddressFamily.InterNetwork).Select(s => s.Address.ToString())); + return interfaces; + } + /// /// Initializes internal LAN cache. /// @@ -361,66 +357,78 @@ public class NetworkManager : INetworkManager, IDisposable { lock (_initLock) { - // Respect explicit bind addresses - var interfaces = _interfaces.ToList(); - var localNetworkAddresses = config.LocalNetworkAddresses; - if (localNetworkAddresses.Length > 0 && !string.IsNullOrWhiteSpace(localNetworkAddresses[0])) - { - var bindAddresses = localNetworkAddresses.Select(p => NetworkUtils.TryParseToSubnet(p, out var network) - ? network.Prefix - : (interfaces.Where(x => x.Name.Equals(p, StringComparison.OrdinalIgnoreCase)) - .Select(x => x.Address) - .FirstOrDefault() ?? IPAddress.None)) - .Where(x => x != IPAddress.None) - .ToHashSet(); - interfaces = interfaces.Where(x => bindAddresses.Contains(x.Address)).ToList(); - - if (bindAddresses.Contains(IPAddress.Loopback) && !interfaces.Any(i => i.Address.Equals(IPAddress.Loopback))) - { - interfaces.Add(new IPData(IPAddress.Loopback, NetworkConstants.IPv4RFC5735Loopback, "lo")); - } - - if (bindAddresses.Contains(IPAddress.IPv6Loopback) && !interfaces.Any(i => i.Address.Equals(IPAddress.IPv6Loopback))) - { - interfaces.Add(new IPData(IPAddress.IPv6Loopback, NetworkConstants.IPv6RFC4291Loopback, "lo")); - } - } - - // Remove all interfaces matching any virtual machine interface prefix - if (config.IgnoreVirtualInterfaces) - { - // Remove potentially existing * and split config string into prefixes - var virtualInterfacePrefixes = config.VirtualInterfaceNames - .Select(i => i.Replace("*", string.Empty, StringComparison.OrdinalIgnoreCase)); - - // Check all interfaces for matches against the prefixes and remove them - if (_interfaces.Count > 0) - { - foreach (var virtualInterfacePrefix in virtualInterfacePrefixes) - { - interfaces.RemoveAll(x => x.Name.StartsWith(virtualInterfacePrefix, StringComparison.OrdinalIgnoreCase)); - } - } - } - - // Remove all IPv4 interfaces if IPv4 is disabled - if (!IsIPv4Enabled) - { - interfaces.RemoveAll(x => x.AddressFamily == AddressFamily.InterNetwork); - } - - // Remove all IPv6 interfaces if IPv6 is disabled - if (!IsIPv6Enabled) - { - interfaces.RemoveAll(x => x.AddressFamily == AddressFamily.InterNetworkV6); - } - - // Users may have complex networking configuration that multiple interfaces sharing the same IP address - // Only return one IP for binding, and let the OS handle the rest - _interfaces = interfaces.DistinctBy(iface => iface.Address).ToList(); + _interfaces = FilterBindSettings(config, _interfaces, IsIPv4Enabled, IsIPv6Enabled).ToList(); } } + /// + /// Filteres a list of bind addresses and exclusions on available interfaces. + /// + /// The network config to be filtered by. + /// A list of possible interfaces to be filtered. + /// If true evaluates IPV4 type ip addresses. + /// If true evaluates IPV6 type ip addresses. + /// A list of all locally known up addresses and submasks that are to be considered usable. + public static IReadOnlyList FilterBindSettings(NetworkConfiguration config, IList interfaces, bool isIPv4Enabled, bool isIPv6Enabled) + { + // Respect explicit bind addresses + var localNetworkAddresses = config.LocalNetworkAddresses; + if (localNetworkAddresses.Length > 0 && !string.IsNullOrWhiteSpace(localNetworkAddresses[0])) + { + var bindAddresses = localNetworkAddresses.Select(p => NetworkUtils.TryParseToSubnet(p, out var network) + ? network.Prefix + : (interfaces.Where(x => x.Name.Equals(p, StringComparison.OrdinalIgnoreCase)) + .Select(x => x.Address) + .FirstOrDefault() ?? IPAddress.None)) + .Where(x => x != IPAddress.None) + .ToHashSet(); + interfaces = interfaces.Where(x => bindAddresses.Contains(x.Address)).ToList(); + + if (bindAddresses.Contains(IPAddress.Loopback) && !interfaces.Any(i => i.Address.Equals(IPAddress.Loopback))) + { + interfaces.Add(new IPData(IPAddress.Loopback, NetworkConstants.IPv4RFC5735Loopback, "lo")); + } + + if (bindAddresses.Contains(IPAddress.IPv6Loopback) && !interfaces.Any(i => i.Address.Equals(IPAddress.IPv6Loopback))) + { + interfaces.Add(new IPData(IPAddress.IPv6Loopback, NetworkConstants.IPv6RFC4291Loopback, "lo")); + } + } + + // Remove all interfaces matching any virtual machine interface prefix + if (config.IgnoreVirtualInterfaces) + { + // Remove potentially existing * and split config string into prefixes + var virtualInterfacePrefixes = config.VirtualInterfaceNames + .Select(i => i.Replace("*", string.Empty, StringComparison.OrdinalIgnoreCase)); + + // Check all interfaces for matches against the prefixes and remove them + if (interfaces.Count > 0) + { + foreach (var virtualInterfacePrefix in virtualInterfacePrefixes) + { + interfaces.RemoveAll(x => x.Name.StartsWith(virtualInterfacePrefix, StringComparison.OrdinalIgnoreCase)); + } + } + } + + // Remove all IPv4 interfaces if IPv4 is disabled + if (!isIPv4Enabled) + { + interfaces.RemoveAll(x => x.AddressFamily == AddressFamily.InterNetwork); + } + + // Remove all IPv6 interfaces if IPv6 is disabled + if (!isIPv6Enabled) + { + interfaces.RemoveAll(x => x.AddressFamily == AddressFamily.InterNetworkV6); + } + + // Users may have complex networking configuration that multiple interfaces sharing the same IP address + // Only return one IP for binding, and let the OS handle the rest + return interfaces.DistinctBy(iface => iface.Address).ToList(); + } + /// /// Initializes the remote address values. /// @@ -689,10 +697,10 @@ public class NetworkManager : INetworkManager, IDisposable { // Comma separated list of IP addresses or IP/netmask entries for networks that will be allowed to connect remotely. // If left blank, all remote addresses will be allowed. - if (_remoteAddressFilter.Any() && !_lanSubnets.Any(x => x.Contains(remoteIP))) + if (_remoteAddressFilter.Any() && !IsInLocalNetwork(remoteIP)) { // remoteAddressFilter is a whitelist or blacklist. - var matches = _remoteAddressFilter.Count(remoteNetwork => remoteNetwork.Contains(remoteIP)); + var matches = _remoteAddressFilter.Count(remoteNetwork => NetworkUtils.SubnetContainsAddress(remoteNetwork, remoteIP)); if ((!config.IsRemoteIPFilterBlacklist && matches > 0) || (config.IsRemoteIPFilterBlacklist && matches == 0)) { @@ -702,7 +710,7 @@ public class NetworkManager : INetworkManager, IDisposable return false; } } - else if (!_lanSubnets.Any(x => x.Contains(remoteIP))) + else if (!IsInLocalNetwork(remoteIP)) { // Remote not enabled. So everyone should be LAN. return false; @@ -711,13 +719,6 @@ public class NetworkManager : INetworkManager, IDisposable return true; } - /// - public IReadOnlyList GetMacAddresses() - { - // Populated in construction - so always has values. - return _macAddresses; - } - /// public IReadOnlyList GetLoopbacks() { @@ -743,28 +744,47 @@ public class NetworkManager : INetworkManager, IDisposable /// public IReadOnlyList GetAllBindInterfaces(bool individualInterfaces = false) { - var config = _configurationManager.GetNetworkConfiguration(); + return NetworkManager.GetAllBindInterfaces(individualInterfaces, _configurationManager, _interfaces, IsIPv4Enabled, IsIPv6Enabled); + } + + /// + /// Reads the jellyfin configuration of the configuration manager and produces a list of interfaces that should be bound. + /// + /// Defines that only known interfaces should be used. + /// The ConfigurationManager. + /// The known interfaces that gets returned if possible or instructed. + /// Include IPV4 type interfaces. + /// Include IPV6 type interfaces. + /// A list of ip address of which jellyfin should bind to. + public static IReadOnlyList GetAllBindInterfaces( + bool individualInterfaces, + IConfigurationManager configurationManager, + IReadOnlyList knownInterfaces, + bool readIpv4, + bool readIpv6) + { + var config = configurationManager.GetNetworkConfiguration(); var localNetworkAddresses = config.LocalNetworkAddresses; - if ((localNetworkAddresses.Length > 0 && !string.IsNullOrWhiteSpace(localNetworkAddresses[0]) && _interfaces.Count > 0) || individualInterfaces) + if ((localNetworkAddresses.Length > 0 && !string.IsNullOrWhiteSpace(localNetworkAddresses[0]) && knownInterfaces.Count > 0) || individualInterfaces) { - return _interfaces; + return knownInterfaces; } // No bind address and no exclusions, so listen on all interfaces. var result = new List(); - if (IsIPv4Enabled && IsIPv6Enabled) + if (readIpv4 && readIpv6) { // Kestrel source code shows it uses Sockets.DualMode - so this also covers IPAddress.Any by default result.Add(new IPData(IPAddress.IPv6Any, NetworkConstants.IPv6Any)); } - else if (IsIPv4Enabled) + else if (readIpv4) { result.Add(new IPData(IPAddress.Any, NetworkConstants.IPv4Any)); } - else if (IsIPv6Enabled) + else if (readIpv6) { // Cannot use IPv6Any as Kestrel will bind to IPv4 addresses too. - foreach (var iface in _interfaces) + foreach (var iface in knownInterfaces) { if (iface.AddressFamily == AddressFamily.InterNetworkV6) { @@ -816,7 +836,7 @@ public class NetworkManager : INetworkManager, IDisposable _logger.LogWarning("IPv4 is disabled in Jellyfin, but enabled in the OS. This may affect how the interface is selected."); } - bool isExternal = !_lanSubnets.Any(network => network.Contains(source)); + bool isExternal = !IsInLocalNetwork(source); _logger.LogDebug("Trying to get bind address for source {Source} - External: {IsExternal}", source, isExternal); if (!skipOverrides && MatchesPublishedServerUrl(source, isExternal, out result)) @@ -863,7 +883,7 @@ public class NetworkManager : INetworkManager, IDisposable // (For systems with multiple internal network cards, and multiple subnets) foreach (var intf in availableInterfaces) { - if (intf.Subnet.Contains(source)) + if (NetworkUtils.SubnetContainsAddress(intf.Subnet, source)) { result = NetworkUtils.FormatIPString(intf.Address); _logger.LogDebug("{Source}: Found interface with matching subnet, using it as bind address: {Result}", source, result); @@ -891,21 +911,11 @@ public class NetworkManager : INetworkManager, IDisposable { if (NetworkUtils.TryParseToSubnet(address, out var subnet)) { - return IPAddress.IsLoopback(subnet.Prefix) || (_lanSubnets.Any(x => x.Contains(subnet.Prefix)) && !_excludedSubnets.Any(x => x.Contains(subnet.Prefix))); + return IsInLocalNetwork(subnet.Prefix); } - if (NetworkUtils.TryParseHost(address, out var addresses, IsIPv4Enabled, IsIPv6Enabled)) - { - foreach (var ept in addresses) - { - if (IPAddress.IsLoopback(ept) || (_lanSubnets.Any(x => x.Contains(ept)) && !_excludedSubnets.Any(x => x.Contains(ept)))) - { - return true; - } - } - } - - return false; + return NetworkUtils.TryParseHost(address, out var addresses, IsIPv4Enabled, IsIPv6Enabled) + && addresses.Any(IsInLocalNetwork); } /// @@ -940,6 +950,11 @@ public class NetworkManager : INetworkManager, IDisposable return CheckIfLanAndNotExcluded(address); } + /// + /// Check if the address is in the LAN and not excluded. + /// + /// The IP address to check. The caller should make sure this is not an IPv4MappedToIPv6 address. + /// Boolean indicates whether the address is in LAN. private bool CheckIfLanAndNotExcluded(IPAddress address) { foreach (var lanSubnet in _lanSubnets) @@ -973,13 +988,13 @@ public class NetworkManager : INetworkManager, IDisposable bindPreference = string.Empty; int? port = null; - // Only consider subnets including the source IP, prefering specific overrides + // Only consider subnets including the source IP, preferring specific overrides List validPublishedServerUrls; if (!isInExternalSubnet) { // Only use matching internal subnets // Prefer more specific (bigger subnet prefix) overrides - validPublishedServerUrls = _publishedServerUrls.Where(x => x.IsInternalOverride && x.Data.Subnet.Contains(source)) + validPublishedServerUrls = _publishedServerUrls.Where(x => x.IsInternalOverride && NetworkUtils.SubnetContainsAddress(x.Data.Subnet, source)) .OrderByDescending(x => x.Data.Subnet.PrefixLength) .ToList(); } @@ -987,7 +1002,7 @@ public class NetworkManager : INetworkManager, IDisposable { // Only use matching external subnets // Prefer more specific (bigger subnet prefix) overrides - validPublishedServerUrls = _publishedServerUrls.Where(x => x.IsExternalOverride && x.Data.Subnet.Contains(source)) + validPublishedServerUrls = _publishedServerUrls.Where(x => x.IsExternalOverride && NetworkUtils.SubnetContainsAddress(x.Data.Subnet, source)) .OrderByDescending(x => x.Data.Subnet.PrefixLength) .ToList(); } @@ -995,9 +1010,11 @@ public class NetworkManager : INetworkManager, IDisposable foreach (var data in validPublishedServerUrls) { // Get interface matching override subnet - var intf = _interfaces.OrderBy(x => x.Index).FirstOrDefault(x => data.Data.Subnet.Contains(x.Address)); + var intf = _interfaces.OrderBy(x => x.Index).FirstOrDefault(x => NetworkUtils.SubnetContainsAddress(data.Data.Subnet, x.Address)); - if (intf?.Address is not null) + if (intf?.Address is not null + || (data.Data.AddressFamily == AddressFamily.InterNetwork && data.Data.Address.Equals(IPAddress.Any)) + || (data.Data.AddressFamily == AddressFamily.InterNetworkV6 && data.Data.Address.Equals(IPAddress.IPv6Any))) { // If matching interface is found, use override bindPreference = data.OverrideUri; @@ -1025,6 +1042,7 @@ public class NetworkManager : INetworkManager, IDisposable } _logger.LogDebug("{Source}: Matching bind address override found: {Address}", source, bindPreference); + return true; } @@ -1055,6 +1073,7 @@ public class NetworkManager : INetworkManager, IDisposable if (isInExternalSubnet) { var externalInterfaces = _interfaces.Where(x => !IsInLocalNetwork(x.Address)) + .Where(x => !IsLinkLocalAddress(x.Address)) .OrderBy(x => x.Index) .ToList(); if (externalInterfaces.Count > 0) @@ -1062,7 +1081,8 @@ public class NetworkManager : INetworkManager, IDisposable // Check to see if any of the external bind interfaces are in the same subnet as the source. // If none exists, this will select the first external interface if there is one. bindAddress = externalInterfaces - .OrderByDescending(x => x.Subnet.Contains(source)) + .OrderByDescending(x => NetworkUtils.SubnetContainsAddress(x.Subnet, source)) + .ThenByDescending(x => x.Subnet.PrefixLength) .ThenBy(x => x.Index) .Select(x => x.Address) .First(); @@ -1079,7 +1099,8 @@ public class NetworkManager : INetworkManager, IDisposable // Check to see if any of the internal bind interfaces are in the same subnet as the source. // If none exists, this will select the first internal interface if there is one. bindAddress = _interfaces.Where(x => IsInLocalNetwork(x.Address)) - .OrderByDescending(x => x.Subnet.Contains(source)) + .OrderByDescending(x => NetworkUtils.SubnetContainsAddress(x.Subnet, source)) + .ThenByDescending(x => x.Subnet.PrefixLength) .ThenBy(x => x.Index) .Select(x => x.Address) .FirstOrDefault(); @@ -1122,7 +1143,7 @@ public class NetworkManager : INetworkManager, IDisposable // (For systems with multiple network cards and/or multiple subnets) foreach (var intf in extResult) { - if (intf.Subnet.Contains(source)) + if (NetworkUtils.SubnetContainsAddress(intf.Subnet, source)) { result = NetworkUtils.FormatIPString(intf.Address); _logger.LogDebug("{Source}: Found external interface with matching subnet, using it as bind address: {Result}", source, result); diff --git a/src/Jellyfin.Networking/PortForwardingHost.cs b/src/Jellyfin.Networking/PortForwardingHost.cs deleted file mode 100644 index d01343624e..0000000000 --- a/src/Jellyfin.Networking/PortForwardingHost.cs +++ /dev/null @@ -1,192 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Net; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller; -using MediaBrowser.Controller.Configuration; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Mono.Nat; - -namespace Jellyfin.Networking; - -/// -/// responsible for UPnP port forwarding. -/// -public sealed class PortForwardingHost : IHostedService, IDisposable -{ - private readonly IServerApplicationHost _appHost; - private readonly ILogger _logger; - private readonly IServerConfigurationManager _config; - private readonly ConcurrentDictionary _createdRules = new(); - - private Timer? _timer; - private string? _configIdentifier; - private bool _disposed; - - /// - /// Initializes a new instance of the class. - /// - /// The logger. - /// The application host. - /// The configuration manager. - public PortForwardingHost( - ILogger logger, - IServerApplicationHost appHost, - IServerConfigurationManager config) - { - _logger = logger; - _appHost = appHost; - _config = config; - } - - private string GetConfigIdentifier() - { - const char Separator = '|'; - var config = _config.GetNetworkConfiguration(); - - return new StringBuilder(32) - .Append(config.EnableUPnP).Append(Separator) - .Append(config.PublicHttpPort).Append(Separator) - .Append(config.PublicHttpsPort).Append(Separator) - .Append(_appHost.HttpPort).Append(Separator) - .Append(_appHost.HttpsPort).Append(Separator) - .Append(_appHost.ListenWithHttps).Append(Separator) - .Append(config.EnableRemoteAccess).Append(Separator) - .ToString(); - } - - private void OnConfigurationUpdated(object? sender, EventArgs e) - { - var oldConfigIdentifier = _configIdentifier; - _configIdentifier = GetConfigIdentifier(); - - if (!string.Equals(_configIdentifier, oldConfigIdentifier, StringComparison.OrdinalIgnoreCase)) - { - Stop(); - Start(); - } - } - - /// - public Task StartAsync(CancellationToken cancellationToken) - { - Start(); - - _config.ConfigurationUpdated += OnConfigurationUpdated; - - return Task.CompletedTask; - } - - /// - public Task StopAsync(CancellationToken cancellationToken) - { - Stop(); - - return Task.CompletedTask; - } - - private void Start() - { - var config = _config.GetNetworkConfiguration(); - if (!config.EnableUPnP || !config.EnableRemoteAccess) - { - return; - } - - _logger.LogInformation("Starting NAT discovery"); - - NatUtility.DeviceFound += OnNatUtilityDeviceFound; - NatUtility.StartDiscovery(); - - _timer?.Dispose(); - _timer = new Timer(_ => _createdRules.Clear(), null, TimeSpan.FromMinutes(10), TimeSpan.FromMinutes(10)); - } - - private void Stop() - { - _logger.LogInformation("Stopping NAT discovery"); - - NatUtility.StopDiscovery(); - NatUtility.DeviceFound -= OnNatUtilityDeviceFound; - - _timer?.Dispose(); - _timer = null; - } - - private async void OnNatUtilityDeviceFound(object? sender, DeviceEventArgs e) - { - ObjectDisposedException.ThrowIf(_disposed, this); - - try - { - // On some systems the device discovered event seems to fire repeatedly - // This check will help ensure we're not trying to port map the same device over and over - if (!_createdRules.TryAdd(e.Device.DeviceEndpoint, 0)) - { - return; - } - - await Task.WhenAll(CreatePortMaps(e.Device)).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error creating port forwarding rules"); - } - } - - private IEnumerable CreatePortMaps(INatDevice device) - { - var config = _config.GetNetworkConfiguration(); - yield return CreatePortMap(device, _appHost.HttpPort, config.PublicHttpPort); - - if (_appHost.ListenWithHttps) - { - yield return CreatePortMap(device, _appHost.HttpsPort, config.PublicHttpsPort); - } - } - - private async Task CreatePortMap(INatDevice device, int privatePort, int publicPort) - { - _logger.LogDebug( - "Creating port map on local port {LocalPort} to public port {PublicPort} with device {DeviceEndpoint}", - privatePort, - publicPort, - device.DeviceEndpoint); - - try - { - var mapping = new Mapping(Protocol.Tcp, privatePort, publicPort, 0, _appHost.Name); - await device.CreatePortMapAsync(mapping).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError( - ex, - "Error creating port map on local port {LocalPort} to public port {PublicPort} with device {DeviceEndpoint}.", - privatePort, - publicPort, - device.DeviceEndpoint); - } - } - - /// - public void Dispose() - { - if (_disposed) - { - return; - } - - _config.ConfigurationUpdated -= OnConfigurationUpdated; - - _timer?.Dispose(); - _timer = null; - - _disposed = true; - } -} diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props index bec3481cb7..6b851021f9 100644 --- a/tests/Directory.Build.props +++ b/tests/Directory.Build.props @@ -4,9 +4,8 @@ - net8.0 + net9.0 false - $(MSBuildThisFileDirectory)/jellyfin-tests.ruleset diff --git a/tests/Jellyfin.Api.Tests/Auth/CustomAuthenticationHandlerTests.cs b/tests/Jellyfin.Api.Tests/Auth/CustomAuthenticationHandlerTests.cs index 6f5c0ed0c8..7e44b062cf 100644 --- a/tests/Jellyfin.Api.Tests/Auth/CustomAuthenticationHandlerTests.cs +++ b/tests/Jellyfin.Api.Tests/Auth/CustomAuthenticationHandlerTests.cs @@ -6,8 +6,9 @@ using AutoFixture; using AutoFixture.AutoMoq; using Jellyfin.Api.Auth; using Jellyfin.Api.Constants; -using Jellyfin.Data.Entities; -using Jellyfin.Data.Enums; +using Jellyfin.Data; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using MediaBrowser.Controller.Authentication; using MediaBrowser.Controller.Net; using Microsoft.AspNetCore.Authentication; @@ -100,6 +101,7 @@ namespace Jellyfin.Api.Tests.Auth var authorizationInfo = SetupUser(); var authenticateResult = await _sut.AuthenticateAsync(); + Assert.NotNull(authorizationInfo.User); Assert.True(authenticateResult.Principal?.HasClaim(ClaimTypes.Name, authorizationInfo.User.Username)); } @@ -111,6 +113,7 @@ namespace Jellyfin.Api.Tests.Auth var authorizationInfo = SetupUser(isAdmin); var authenticateResult = await _sut.AuthenticateAsync(); + Assert.NotNull(authorizationInfo.User); var expectedRole = authorizationInfo.User.HasPermission(PermissionKind.IsAdministrator) ? UserRoles.Administrator : UserRoles.User; Assert.True(authenticateResult.Principal?.HasClaim(ClaimTypes.Role, expectedRole)); } @@ -132,7 +135,6 @@ namespace Jellyfin.Api.Tests.Auth authorizationInfo.User.AddDefaultPreferences(); authorizationInfo.User.SetPermission(PermissionKind.IsAdministrator, isAdmin); authorizationInfo.IsApiKey = false; - authorizationInfo.HasToken = true; authorizationInfo.Token = "fake-token"; _jellyfinAuthServiceMock.Setup( diff --git a/tests/Jellyfin.Api.Tests/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandlerTests.cs b/tests/Jellyfin.Api.Tests/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandlerTests.cs index 162a022f5b..bfc7016d2a 100644 --- a/tests/Jellyfin.Api.Tests/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandlerTests.cs +++ b/tests/Jellyfin.Api.Tests/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandlerTests.cs @@ -7,7 +7,7 @@ using AutoFixture; using AutoFixture.AutoMoq; using Jellyfin.Api.Auth.DefaultAuthorizationPolicy; using Jellyfin.Api.Constants; -using Jellyfin.Data.Entities; +using Jellyfin.Database.Implementations.Entities; using Jellyfin.Server.Implementations.Security; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Library; diff --git a/tests/Jellyfin.Api.Tests/Auth/FirstTimeSetupPolicy/FirstTimeSetupHandlerTests.cs b/tests/Jellyfin.Api.Tests/Auth/FirstTimeSetupPolicy/FirstTimeSetupHandlerTests.cs index 31d2b486b3..fc243a8730 100644 --- a/tests/Jellyfin.Api.Tests/Auth/FirstTimeSetupPolicy/FirstTimeSetupHandlerTests.cs +++ b/tests/Jellyfin.Api.Tests/Auth/FirstTimeSetupPolicy/FirstTimeSetupHandlerTests.cs @@ -7,8 +7,8 @@ using AutoFixture.AutoMoq; using Jellyfin.Api.Auth.DefaultAuthorizationPolicy; using Jellyfin.Api.Auth.FirstTimeSetupPolicy; using Jellyfin.Api.Constants; -using Jellyfin.Data.Entities; -using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Library; using Microsoft.AspNetCore.Authorization; diff --git a/tests/Jellyfin.Api.Tests/Auth/IgnoreSchedulePolicy/IgnoreScheduleHandlerTests.cs b/tests/Jellyfin.Api.Tests/Auth/IgnoreSchedulePolicy/IgnoreScheduleHandlerTests.cs index 534d1863ca..6e63c0450b 100644 --- a/tests/Jellyfin.Api.Tests/Auth/IgnoreSchedulePolicy/IgnoreScheduleHandlerTests.cs +++ b/tests/Jellyfin.Api.Tests/Auth/IgnoreSchedulePolicy/IgnoreScheduleHandlerTests.cs @@ -5,8 +5,8 @@ using AutoFixture; using AutoFixture.AutoMoq; using Jellyfin.Api.Auth.DefaultAuthorizationPolicy; using Jellyfin.Api.Constants; -using Jellyfin.Data.Entities; -using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Library; using Microsoft.AspNetCore.Authorization; diff --git a/tests/Jellyfin.Api.Tests/Controllers/UserControllerTests.cs b/tests/Jellyfin.Api.Tests/Controllers/UserControllerTests.cs index c7331c7181..e95df16354 100644 --- a/tests/Jellyfin.Api.Tests/Controllers/UserControllerTests.cs +++ b/tests/Jellyfin.Api.Tests/Controllers/UserControllerTests.cs @@ -5,7 +5,7 @@ using System.Linq; using System.Threading.Tasks; using AutoFixture.Xunit2; using Jellyfin.Api.Controllers; -using Jellyfin.Data.Entities; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Devices; @@ -87,7 +87,7 @@ public class UserControllerTests Assert.Contains( Validate(userPolicy), v => v.MemberNames.Contains("PasswordResetProviderId") && - v.ErrorMessage != null && + v.ErrorMessage is not null && v.ErrorMessage.Contains("required", StringComparison.CurrentCultureIgnoreCase)); } @@ -105,7 +105,7 @@ public class UserControllerTests Assert.Contains(Validate(userPolicy), v => v.MemberNames.Contains("AuthenticationProviderId") && - v.ErrorMessage != null && + v.ErrorMessage is not null && v.ErrorMessage.Contains("required", StringComparison.CurrentCultureIgnoreCase)); } diff --git a/tests/Jellyfin.Api.Tests/Helpers/RequestHelpersTests.cs b/tests/Jellyfin.Api.Tests/Helpers/RequestHelpersTests.cs index a2d1b3607b..2851b08e6c 100644 --- a/tests/Jellyfin.Api.Tests/Helpers/RequestHelpersTests.cs +++ b/tests/Jellyfin.Api.Tests/Helpers/RequestHelpersTests.cs @@ -5,6 +5,7 @@ using System.Security.Claims; using Jellyfin.Api.Constants; using Jellyfin.Api.Helpers; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; using MediaBrowser.Controller.Net; using Xunit; diff --git a/tests/Jellyfin.Api.Tests/ModelBinders/CommaDelimitedArrayModelBinderTests.cs b/tests/Jellyfin.Api.Tests/ModelBinders/CommaDelimitedCollectionModelBinderTests.cs similarity index 91% rename from tests/Jellyfin.Api.Tests/ModelBinders/CommaDelimitedArrayModelBinderTests.cs rename to tests/Jellyfin.Api.Tests/ModelBinders/CommaDelimitedCollectionModelBinderTests.cs index e37c9d91f3..e6b9acfe19 100644 --- a/tests/Jellyfin.Api.Tests/ModelBinders/CommaDelimitedArrayModelBinderTests.cs +++ b/tests/Jellyfin.Api.Tests/ModelBinders/CommaDelimitedCollectionModelBinderTests.cs @@ -12,7 +12,7 @@ using Xunit; namespace Jellyfin.Api.Tests.ModelBinders { - public sealed class CommaDelimitedArrayModelBinderTests + public sealed class CommaDelimitedCollectionModelBinderTests { [Fact] public async Task BindModelAsync_CorrectlyBindsValidCommaDelimitedStringArrayQuery() @@ -22,7 +22,7 @@ namespace Jellyfin.Api.Tests.ModelBinders var queryParamString = "lol,xd"; var queryParamType = typeof(string[]); - var modelBinder = new CommaDelimitedArrayModelBinder(new NullLogger()); + var modelBinder = new CommaDelimitedCollectionModelBinder(new NullLogger()); var valueProvider = new QueryStringValueProvider( new BindingSource(string.Empty, string.Empty, false, false), new QueryCollection(new Dictionary { { queryParamName, new StringValues(queryParamString) } }), @@ -47,7 +47,7 @@ namespace Jellyfin.Api.Tests.ModelBinders var queryParamString = "42,0"; var queryParamType = typeof(int[]); - var modelBinder = new CommaDelimitedArrayModelBinder(new NullLogger()); + var modelBinder = new CommaDelimitedCollectionModelBinder(new NullLogger()); var valueProvider = new QueryStringValueProvider( new BindingSource(string.Empty, string.Empty, false, false), new QueryCollection(new Dictionary { { queryParamName, new StringValues(queryParamString) } }), @@ -72,7 +72,7 @@ namespace Jellyfin.Api.Tests.ModelBinders var queryParamString = "How,Much"; var queryParamType = typeof(TestType[]); - var modelBinder = new CommaDelimitedArrayModelBinder(new NullLogger()); + var modelBinder = new CommaDelimitedCollectionModelBinder(new NullLogger()); var valueProvider = new QueryStringValueProvider( new BindingSource(string.Empty, string.Empty, false, false), new QueryCollection(new Dictionary { { queryParamName, new StringValues(queryParamString) } }), @@ -97,7 +97,7 @@ namespace Jellyfin.Api.Tests.ModelBinders var queryParamString = "How,,Much"; var queryParamType = typeof(TestType[]); - var modelBinder = new CommaDelimitedArrayModelBinder(new NullLogger()); + var modelBinder = new CommaDelimitedCollectionModelBinder(new NullLogger()); var valueProvider = new QueryStringValueProvider( new BindingSource(string.Empty, string.Empty, false, false), new QueryCollection(new Dictionary { { queryParamName, new StringValues(queryParamString) } }), @@ -123,7 +123,7 @@ namespace Jellyfin.Api.Tests.ModelBinders var queryParamString2 = "Much"; var queryParamType = typeof(TestType[]); - var modelBinder = new CommaDelimitedArrayModelBinder(new NullLogger()); + var modelBinder = new CommaDelimitedCollectionModelBinder(new NullLogger()); var valueProvider = new QueryStringValueProvider( new BindingSource(string.Empty, string.Empty, false, false), @@ -151,7 +151,7 @@ namespace Jellyfin.Api.Tests.ModelBinders IReadOnlyList queryParamValues = Array.Empty(); var queryParamType = typeof(TestType[]); - var modelBinder = new CommaDelimitedArrayModelBinder(new NullLogger()); + var modelBinder = new CommaDelimitedCollectionModelBinder(new NullLogger()); var valueProvider = new QueryStringValueProvider( new BindingSource(string.Empty, string.Empty, false, false), @@ -179,7 +179,7 @@ namespace Jellyfin.Api.Tests.ModelBinders var queryParamString = "🔥,😢"; var queryParamType = typeof(IReadOnlyList); - var modelBinder = new CommaDelimitedArrayModelBinder(new NullLogger()); + var modelBinder = new CommaDelimitedCollectionModelBinder(new NullLogger()); var valueProvider = new QueryStringValueProvider( new BindingSource(string.Empty, string.Empty, false, false), new QueryCollection(new Dictionary { { queryParamName, new StringValues(queryParamString) } }), @@ -205,7 +205,7 @@ namespace Jellyfin.Api.Tests.ModelBinders var queryParamString2 = "😱"; var queryParamType = typeof(IReadOnlyList); - var modelBinder = new CommaDelimitedArrayModelBinder(new NullLogger()); + var modelBinder = new CommaDelimitedCollectionModelBinder(new NullLogger()); var valueProvider = new QueryStringValueProvider( new BindingSource(string.Empty, string.Empty, false, false), diff --git a/tests/Jellyfin.Api.Tests/ModelBinders/PipeDelimitedArrayModelBinderTests.cs b/tests/Jellyfin.Api.Tests/ModelBinders/PipeDelimitedCollectionModelBinderTests.cs similarity index 91% rename from tests/Jellyfin.Api.Tests/ModelBinders/PipeDelimitedArrayModelBinderTests.cs rename to tests/Jellyfin.Api.Tests/ModelBinders/PipeDelimitedCollectionModelBinderTests.cs index 7c05ee0362..941f4f12cc 100644 --- a/tests/Jellyfin.Api.Tests/ModelBinders/PipeDelimitedArrayModelBinderTests.cs +++ b/tests/Jellyfin.Api.Tests/ModelBinders/PipeDelimitedCollectionModelBinderTests.cs @@ -12,7 +12,7 @@ using Xunit; namespace Jellyfin.Api.Tests.ModelBinders { - public sealed class PipeDelimitedArrayModelBinderTests + public sealed class PipeDelimitedCollectionModelBinderTests { [Fact] public async Task BindModelAsync_CorrectlyBindsValidPipeDelimitedStringArrayQuery() @@ -22,7 +22,7 @@ namespace Jellyfin.Api.Tests.ModelBinders var queryParamString = "lol|xd"; var queryParamType = typeof(string[]); - var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger()); + var modelBinder = new PipeDelimitedCollectionModelBinder(new NullLogger()); var valueProvider = new QueryStringValueProvider( new BindingSource(string.Empty, string.Empty, false, false), new QueryCollection(new Dictionary { { queryParamName, new StringValues(queryParamString) } }), @@ -47,7 +47,7 @@ namespace Jellyfin.Api.Tests.ModelBinders var queryParamString = "42|0"; var queryParamType = typeof(int[]); - var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger()); + var modelBinder = new PipeDelimitedCollectionModelBinder(new NullLogger()); var valueProvider = new QueryStringValueProvider( new BindingSource(string.Empty, string.Empty, false, false), new QueryCollection(new Dictionary { { queryParamName, new StringValues(queryParamString) } }), @@ -72,7 +72,7 @@ namespace Jellyfin.Api.Tests.ModelBinders var queryParamString = "How|Much"; var queryParamType = typeof(TestType[]); - var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger()); + var modelBinder = new PipeDelimitedCollectionModelBinder(new NullLogger()); var valueProvider = new QueryStringValueProvider( new BindingSource(string.Empty, string.Empty, false, false), new QueryCollection(new Dictionary { { queryParamName, new StringValues(queryParamString) } }), @@ -97,7 +97,7 @@ namespace Jellyfin.Api.Tests.ModelBinders var queryParamString = "How||Much"; var queryParamType = typeof(TestType[]); - var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger()); + var modelBinder = new PipeDelimitedCollectionModelBinder(new NullLogger()); var valueProvider = new QueryStringValueProvider( new BindingSource(string.Empty, string.Empty, false, false), new QueryCollection(new Dictionary { { queryParamName, new StringValues(queryParamString) } }), @@ -123,7 +123,7 @@ namespace Jellyfin.Api.Tests.ModelBinders var queryParamString2 = "Much"; var queryParamType = typeof(TestType[]); - var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger()); + var modelBinder = new PipeDelimitedCollectionModelBinder(new NullLogger()); var valueProvider = new QueryStringValueProvider( new BindingSource(string.Empty, string.Empty, false, false), @@ -151,7 +151,7 @@ namespace Jellyfin.Api.Tests.ModelBinders IReadOnlyList queryParamValues = Array.Empty(); var queryParamType = typeof(TestType[]); - var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger()); + var modelBinder = new PipeDelimitedCollectionModelBinder(new NullLogger()); var valueProvider = new QueryStringValueProvider( new BindingSource(string.Empty, string.Empty, false, false), @@ -179,7 +179,7 @@ namespace Jellyfin.Api.Tests.ModelBinders var queryParamString = "🔥|😢"; var queryParamType = typeof(IReadOnlyList); - var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger()); + var modelBinder = new PipeDelimitedCollectionModelBinder(new NullLogger()); var valueProvider = new QueryStringValueProvider( new BindingSource(string.Empty, string.Empty, false, false), new QueryCollection(new Dictionary { { queryParamName, new StringValues(queryParamString) } }), @@ -205,7 +205,7 @@ namespace Jellyfin.Api.Tests.ModelBinders var queryParamString2 = "😱"; var queryParamType = typeof(IReadOnlyList); - var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger()); + var modelBinder = new PipeDelimitedCollectionModelBinder(new NullLogger()); var valueProvider = new QueryStringValueProvider( new BindingSource(string.Empty, string.Empty, false, false), diff --git a/tests/Jellyfin.Api.Tests/TestHelpers.cs b/tests/Jellyfin.Api.Tests/TestHelpers.cs index 12cf025bc5..eff14e5f15 100644 --- a/tests/Jellyfin.Api.Tests/TestHelpers.cs +++ b/tests/Jellyfin.Api.Tests/TestHelpers.cs @@ -4,15 +4,16 @@ using System.Globalization; using System.Net; using System.Security.Claims; using Jellyfin.Api.Constants; -using Jellyfin.Data.Entities; -using Jellyfin.Data.Enums; +using Jellyfin.Data; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Server.Implementations.Users; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Configuration; using Microsoft.AspNetCore.Http; using Moq; -using AccessSchedule = Jellyfin.Data.Entities.AccessSchedule; +using AccessSchedule = Jellyfin.Database.Implementations.Entities.AccessSchedule; namespace Jellyfin.Api.Tests { diff --git a/tests/Jellyfin.Controller.Tests/DirectoryServiceTests.cs b/tests/Jellyfin.Controller.Tests/DirectoryServiceTests.cs index 07b53bf74c..1f59908a86 100644 --- a/tests/Jellyfin.Controller.Tests/DirectoryServiceTests.cs +++ b/tests/Jellyfin.Controller.Tests/DirectoryServiceTests.cs @@ -181,8 +181,8 @@ namespace Jellyfin.Controller.Tests fileSystemMock.Setup(f => f.GetFileSystemInfo(It.Is(x => x == path))).Returns(newFileSystemMetadata); var secondResult = directoryService.GetFile(path); - Assert.Equal(cachedFileSystemMetadata, result); - Assert.Equal(cachedFileSystemMetadata, secondResult); + Assert.Equivalent(cachedFileSystemMetadata, result); + Assert.Equivalent(cachedFileSystemMetadata, secondResult); } [Fact] diff --git a/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonBoolNumberTests.cs b/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonBoolNumberTests.cs index 125229ff92..d58a62cc8c 100644 --- a/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonBoolNumberTests.cs +++ b/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonBoolNumberTests.cs @@ -1,5 +1,6 @@ using System.Text.Json; using FsCheck; +using FsCheck.Fluent; using FsCheck.Xunit; using Jellyfin.Extensions.Json.Converters; using Xunit; diff --git a/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonCommaDelimitedArrayTests.cs b/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonCommaDelimitedCollectionTests.cs similarity index 61% rename from tests/Jellyfin.Extensions.Tests/Json/Converters/JsonCommaDelimitedArrayTests.cs rename to tests/Jellyfin.Extensions.Tests/Json/Converters/JsonCommaDelimitedCollectionTests.cs index 9fc0158235..83f917c17f 100644 --- a/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonCommaDelimitedArrayTests.cs +++ b/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonCommaDelimitedCollectionTests.cs @@ -1,4 +1,7 @@ using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; using System.Text.Json; using System.Text.Json.Serialization; using Jellyfin.Extensions.Tests.Json.Models; @@ -7,7 +10,7 @@ using Xunit; namespace Jellyfin.Extensions.Tests.Json.Converters { - public class JsonCommaDelimitedArrayTests + public class JsonCommaDelimitedCollectionTests { private readonly JsonSerializerOptions _jsonOptions = new JsonSerializerOptions() { @@ -36,6 +39,29 @@ namespace Jellyfin.Extensions.Tests.Json.Converters Assert.Equal(desiredValue.Value, value?.Value); } + [Fact] + public void Deserialize_EmptyList_Success() + { + var desiredValue = new GenericBodyListModel + { + Value = [] + }; + + Assert.Throws(() => JsonSerializer.Deserialize>(@"{ ""Value"": """" }", _jsonOptions)); + } + + [Fact] + public void Deserialize_EmptyIReadOnlyList_Success() + { + var desiredValue = new GenericBodyIReadOnlyListModel + { + Value = [] + }; + + var value = JsonSerializer.Deserialize>(@"{ ""Value"": """" }", _jsonOptions); + Assert.Equal(desiredValue.Value, value?.Value); + } + [Fact] public void Deserialize_String_Valid_Success() { @@ -48,6 +74,17 @@ namespace Jellyfin.Extensions.Tests.Json.Converters Assert.Equal(desiredValue.Value, value?.Value); } + [Fact] + public void Deserialize_StringList_Valid_Success() + { + var desiredValue = new GenericBodyListModel + { + Value = ["a", "b", "c"] + }; + + Assert.Throws(() => JsonSerializer.Deserialize>(@"{ ""Value"": ""a,b,c"" }", _jsonOptions)); + } + [Fact] public void Deserialize_String_Space_Valid_Success() { @@ -92,7 +129,7 @@ namespace Jellyfin.Extensions.Tests.Json.Converters Value = [GeneralCommandType.MoveUp, GeneralCommandType.MoveDown] }; - var value = JsonSerializer.Deserialize>(@"{ ""Value"": ""MoveUp,TotallyNotAVallidCommand,MoveDown"" }", _jsonOptions); + var value = JsonSerializer.Deserialize>(@"{ ""Value"": ""MoveUp,TotallyNotAValidCommand,MoveDown"" }", _jsonOptions); Assert.Equal(desiredValue.Value, value?.Value); } @@ -131,5 +168,41 @@ namespace Jellyfin.Extensions.Tests.Json.Converters var value = JsonSerializer.Deserialize>(@"{ ""Value"": [""MoveUp"", ""MoveDown""] }", _jsonOptions); Assert.Equal(desiredValue.Value, value?.Value); } + + [Fact] + public void Serialize_GenericCommandType_ReadOnlyArray_Valid_Success() + { + var valueToSerialize = new GenericBodyIReadOnlyCollectionModel + { + Value = new[] { GeneralCommandType.MoveUp, GeneralCommandType.MoveDown }.AsReadOnly() + }; + + string value = JsonSerializer.Serialize>(valueToSerialize, _jsonOptions); + Assert.Equal(@"{""Value"":[""MoveUp"",""MoveDown""]}", value); + } + + [Fact] + public void Serialize_GenericCommandType_ImmutableArrayArray_Valid_Success() + { + var valueToSerialize = new GenericBodyIReadOnlyCollectionModel + { + Value = ImmutableArray.Create(new[] { GeneralCommandType.MoveUp, GeneralCommandType.MoveDown }) + }; + + string value = JsonSerializer.Serialize>(valueToSerialize, _jsonOptions); + Assert.Equal(@"{""Value"":[""MoveUp"",""MoveDown""]}", value); + } + + [Fact] + public void Serialize_GenericCommandType_List_Valid_Success() + { + var valueToSerialize = new GenericBodyIReadOnlyListModel + { + Value = new List { GeneralCommandType.MoveUp, GeneralCommandType.MoveDown } + }; + + string value = JsonSerializer.Serialize>(valueToSerialize, _jsonOptions); + Assert.Equal(@"{""Value"":[""MoveUp"",""MoveDown""]}", value); + } } } diff --git a/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonCommaDelimitedIReadOnlyListTests.cs b/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonCommaDelimitedIReadOnlyListTests.cs index 9b977b9a5d..26989d59b2 100644 --- a/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonCommaDelimitedIReadOnlyListTests.cs +++ b/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonCommaDelimitedIReadOnlyListTests.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Text.Json; using System.Text.Json.Serialization; using Jellyfin.Extensions.Tests.Json.Models; @@ -87,5 +88,17 @@ namespace Jellyfin.Extensions.Tests.Json.Converters var value = JsonSerializer.Deserialize>(@"{ ""Value"": [""MoveUp"", ""MoveDown""] }", _jsonOptions); Assert.Equal(desiredValue.Value, value?.Value); } + + [Fact] + public void Serialize_GenericCommandType_IReadOnlyList_Valid_Success() + { + var valueToSerialize = new GenericBodyIReadOnlyListModel + { + Value = new List { GeneralCommandType.MoveUp, GeneralCommandType.MoveDown } + }; + + string value = JsonSerializer.Serialize>(valueToSerialize, _jsonOptions); + Assert.Equal(@"{""Value"":[""MoveUp"",""MoveDown""]}", value); + } } } diff --git a/tests/Jellyfin.Extensions.Tests/Json/Models/GenericBodyArrayModel.cs b/tests/Jellyfin.Extensions.Tests/Json/Models/GenericBodyArrayModel.cs index ef135278fd..a698c9c92b 100644 --- a/tests/Jellyfin.Extensions.Tests/Json/Models/GenericBodyArrayModel.cs +++ b/tests/Jellyfin.Extensions.Tests/Json/Models/GenericBodyArrayModel.cs @@ -8,13 +8,13 @@ namespace Jellyfin.Extensions.Tests.Json.Models /// The generic body model. /// /// The value type. - public class GenericBodyArrayModel + public sealed class GenericBodyArrayModel { /// /// Gets or sets the value. /// [SuppressMessage("Microsoft.Performance", "CA1819:Properties should not return arrays", MessageId = "Value", Justification = "Imported from ServiceStack")] - [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + [JsonConverter(typeof(JsonCommaDelimitedCollectionConverterFactory))] public T[] Value { get; set; } = default!; } } diff --git a/tests/Jellyfin.Extensions.Tests/Json/Models/GenericBodyIReadOnlyCollectionModel.cs b/tests/Jellyfin.Extensions.Tests/Json/Models/GenericBodyIReadOnlyCollectionModel.cs new file mode 100644 index 0000000000..14cbc0f501 --- /dev/null +++ b/tests/Jellyfin.Extensions.Tests/Json/Models/GenericBodyIReadOnlyCollectionModel.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; +using Jellyfin.Extensions.Json.Converters; + +namespace Jellyfin.Extensions.Tests.Json.Models +{ + /// + /// The generic body IReadOnlyCollection model. + /// + /// The value type. + public sealed class GenericBodyIReadOnlyCollectionModel + { + /// + /// Gets or sets the value. + /// + [JsonConverter(typeof(JsonCommaDelimitedCollectionConverterFactory))] + public IReadOnlyCollection Value { get; set; } = default!; + } +} diff --git a/tests/Jellyfin.Extensions.Tests/Json/Models/GenericBodyIReadOnlyListModel.cs b/tests/Jellyfin.Extensions.Tests/Json/Models/GenericBodyIReadOnlyListModel.cs index 8e7b5a35b4..eaa06a5dd4 100644 --- a/tests/Jellyfin.Extensions.Tests/Json/Models/GenericBodyIReadOnlyListModel.cs +++ b/tests/Jellyfin.Extensions.Tests/Json/Models/GenericBodyIReadOnlyListModel.cs @@ -8,12 +8,12 @@ namespace Jellyfin.Extensions.Tests.Json.Models /// The generic body IReadOnlyList model. /// /// The value type. - public class GenericBodyIReadOnlyListModel + public sealed class GenericBodyIReadOnlyListModel { /// /// Gets or sets the value. /// - [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + [JsonConverter(typeof(JsonCommaDelimitedCollectionConverterFactory))] public IReadOnlyList Value { get; set; } = default!; } } diff --git a/tests/Jellyfin.Extensions.Tests/Json/Models/GenericBodyListModel.cs b/tests/Jellyfin.Extensions.Tests/Json/Models/GenericBodyListModel.cs new file mode 100644 index 0000000000..463f9922f6 --- /dev/null +++ b/tests/Jellyfin.Extensions.Tests/Json/Models/GenericBodyListModel.cs @@ -0,0 +1,22 @@ +#pragma warning disable CA1002 // Do not expose generic lists +#pragma warning disable CA2227 // Collection properties should be read only + +using System.Collections.Generic; +using System.Text.Json.Serialization; +using Jellyfin.Extensions.Json.Converters; + +namespace Jellyfin.Extensions.Tests.Json.Models +{ + /// + /// The generic body List model. + /// + /// The value type. + public sealed class GenericBodyListModel + { + /// + /// Gets or sets the value. + /// + [JsonConverter(typeof(JsonCommaDelimitedCollectionConverterFactory))] + public List Value { get; set; } = default!; + } +} diff --git a/tests/Jellyfin.Extensions.Tests/StringExtensionsTests.cs b/tests/Jellyfin.Extensions.Tests/StringExtensionsTests.cs index 69d20bd3fe..028f12afa7 100644 --- a/tests/Jellyfin.Extensions.Tests/StringExtensionsTests.cs +++ b/tests/Jellyfin.Extensions.Tests/StringExtensionsTests.cs @@ -6,8 +6,8 @@ namespace Jellyfin.Extensions.Tests public class StringExtensionsTests { [Theory] - [InlineData("", "")] // Identity edge-case (no diactritics) - [InlineData("Indiana Jones", "Indiana Jones")] // Identity (no diactritics) + [InlineData("", "")] // Identity edge-case (no diacritics) + [InlineData("Indiana Jones", "Indiana Jones")] // Identity (no diacritics) [InlineData("a\ud800b", "ab")] // Invalid UTF-16 char stripping [InlineData("åäö", "aao")] // Issue #7484 [InlineData("Jön", "Jon")] // Issue #7484 @@ -25,8 +25,8 @@ namespace Jellyfin.Extensions.Tests } [Theory] - [InlineData("", false)] // Identity edge-case (no diactritics) - [InlineData("Indiana Jones", false)] // Identity (no diactritics) + [InlineData("", false)] // Identity edge-case (no diacritics) + [InlineData("Indiana Jones", false)] // Identity (no diacritics) [InlineData("a\ud800b", true)] // Invalid UTF-16 char stripping [InlineData("åäö", true)] // Issue #7484 [InlineData("Jön", true)] // Issue #7484 diff --git a/tests/Jellyfin.LiveTv.Tests/Jellyfin.LiveTv.Tests.csproj b/tests/Jellyfin.LiveTv.Tests/Jellyfin.LiveTv.Tests.csproj index cf967b84ce..fdcf7d61e0 100644 --- a/tests/Jellyfin.LiveTv.Tests/Jellyfin.LiveTv.Tests.csproj +++ b/tests/Jellyfin.LiveTv.Tests/Jellyfin.LiveTv.Tests.csproj @@ -1,6 +1,6 @@ - net8.0 + net9.0 @@ -22,7 +22,7 @@ - + diff --git a/tests/Jellyfin.LiveTv.Tests/Listings/ListingsManagerTests.cs b/tests/Jellyfin.LiveTv.Tests/Listings/ListingsManagerTests.cs new file mode 100644 index 0000000000..40934d9c6b --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Listings/ListingsManagerTests.cs @@ -0,0 +1,50 @@ +using System; +using Jellyfin.LiveTv.Configuration; +using Jellyfin.LiveTv.Listings; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.LiveTv; +using MediaBrowser.Model.Tasks; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace Jellyfin.LiveTv.Tests.Listings; + +public class ListingsManagerTests +{ + private readonly IConfigurationManager _config; + private readonly IListingsProvider[] _listingsProviders; + private readonly ILogger _logger; + private readonly ITaskManager _taskManager; + private readonly ITunerHostManager _tunerHostManager; + + public ListingsManagerTests() + { + _logger = Mock.Of>(); + _config = Mock.Of(); + _taskManager = Mock.Of(); + _tunerHostManager = Mock.Of(); + _listingsProviders = new[] { Mock.Of() }; + } + + [Fact] + public void DeleteListingsProvider_DeletesProvider() + { + // Arrange + var id = "MockId"; + var manager = new ListingsManager(_logger, _config, _taskManager, _tunerHostManager, _listingsProviders); + + Mock.Get(_config) + .Setup(x => x.GetConfiguration(It.IsAny())) + .Returns(new LiveTvOptions { ListingProviders = [new ListingsProviderInfo { Id = id }] }); + + // Act + manager.DeleteListingsProvider(id); + + // Assert + Assert.DoesNotContain( + _config.GetLiveTvConfiguration().ListingProviders, + p => p.Id.Equals(id, StringComparison.Ordinal)); + } +} diff --git a/tests/Jellyfin.LiveTv.Tests/SchedulesDirect/SchedulesDirectDeserializeTests.cs b/tests/Jellyfin.LiveTv.Tests/SchedulesDirect/SchedulesDirectDeserializeTests.cs index 6975d56d9e..59cd42c05b 100644 --- a/tests/Jellyfin.LiveTv.Tests/SchedulesDirect/SchedulesDirectDeserializeTests.cs +++ b/tests/Jellyfin.LiveTv.Tests/SchedulesDirect/SchedulesDirectDeserializeTests.cs @@ -232,7 +232,7 @@ namespace Jellyfin.LiveTv.Tests.SchedulesDirect Assert.Equal(2, channelDto!.Map.Count); Assert.Equal("24326", channelDto.Map[0].StationId); Assert.Equal("001", channelDto.Map[0].Channel); - Assert.Equal("BBC ONE South", channelDto.Map[0].ProvderCallsign); + Assert.Equal("BBC ONE South", channelDto.Map[0].ProviderCallsign); Assert.Equal("1", channelDto.Map[0].LogicalChannelNumber); Assert.Equal("providerCallsign", channelDto.Map[0].MatchType); } diff --git a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs index df51d39cb7..94710a0957 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs +++ b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs @@ -288,6 +288,40 @@ namespace Jellyfin.MediaEncoding.Tests.Probing Assert.True(res.VideoStream.IsDefault); } + [Fact] + public void GetMediaInfo_VideoWithSingleFrameMjpeg_Success() + { + var bytes = File.ReadAllBytes("Test Data/Probing/video_single_frame_mjpeg.json"); + + var internalMediaInfoResult = JsonSerializer.Deserialize(bytes, _jsonOptions); + MediaInfo res = _probeResultNormalizer.GetMediaInfo(internalMediaInfoResult, VideoType.VideoFile, false, "Test Data/Probing/video_interlaced.mp4", MediaProtocol.File); + + Assert.Equal(3, res.MediaStreams.Count); + + Assert.NotNull(res.VideoStream); + Assert.Equal(res.MediaStreams[0], res.VideoStream); + Assert.Equal(0, res.VideoStream.Index); + Assert.Equal("h264", res.VideoStream.Codec); + Assert.Equal("High", res.VideoStream.Profile); + Assert.Equal(MediaStreamType.Video, res.VideoStream.Type); + Assert.Equal(1080, res.VideoStream.Height); + Assert.Equal(1920, res.VideoStream.Width); + Assert.False(res.VideoStream.IsInterlaced); + Assert.Equal("16:9", res.VideoStream.AspectRatio); + Assert.Equal("yuv420p", res.VideoStream.PixelFormat); + Assert.Equal(42d, res.VideoStream.Level); + Assert.Equal(1, res.VideoStream.RefFrames); + Assert.True(res.VideoStream.IsAVC); + Assert.Equal(50f, res.VideoStream.RealFrameRate); + Assert.Equal("1/1000", res.VideoStream.TimeBase); + Assert.Equal(8, res.VideoStream.BitDepth); + Assert.True(res.VideoStream.IsDefault); + + var mjpeg = res.MediaStreams[2]; + Assert.NotNull(mjpeg); + Assert.Equal("mjpeg", mjpeg.Codec); + } + [Fact] public void GetMediaInfo_MusicVideo_Success() { diff --git a/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_single_frame_mjpeg.json b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_single_frame_mjpeg.json new file mode 100644 index 0000000000..261f044bb9 --- /dev/null +++ b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_single_frame_mjpeg.json @@ -0,0 +1,209 @@ +{ + "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, + "closed_captions": 0, + "film_grain": 0, + "has_b_frames": 0, + "sample_aspect_ratio": "1:1", + "display_aspect_ratio": "16:9", + "pix_fmt": "yuv420p", + "level": 42, + "chroma_location": "left", + "field_order": "progressive", + "refs": 1, + "is_avc": "true", + "nal_length_size": "4", + "r_frame_rate": "50/1", + "avg_frame_rate": "50/1", + "time_base": "1/1000", + "start_pts": 0, + "start_time": "0.000000", + "bits_per_raw_sample": "8", + "extradata_size": 55, + "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, + "non_diegetic": 0, + "captions": 0, + "descriptions": 0, + "metadata": 0, + "dependent": 0, + "still_image": 0 + }, + "tags": { + "language": "deu", + "HANDLER_NAME": "VideoHandler", + "VENDOR_ID": "[0][0][0][0]", + "BPS": "3950584", + "DURATION": "00:00:10.000000000", + "NUMBER_OF_FRAMES": "500", + "NUMBER_OF_BYTES": "4938231", + "_STATISTICS_WRITING_APP": "mkvpropedit v90.0 ('Hanging On') 64-bit", + "_STATISTICS_WRITING_DATE_UTC": "2025-04-19 10:37:57", + "_STATISTICS_TAGS": "BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES" + } + }, + { + "index": 1, + "codec_name": "aac", + "codec_long_name": "AAC (Advanced Audio Coding)", + "profile": "LC", + "codec_type": "audio", + "codec_tag_string": "[0][0][0][0]", + "codec_tag": "0x0000", + "sample_fmt": "fltp", + "sample_rate": "48000", + "channels": 2, + "channel_layout": "stereo", + "bits_per_sample": 0, + "initial_padding": 0, + "r_frame_rate": "0/0", + "avg_frame_rate": "0/0", + "time_base": "1/1000", + "start_pts": 0, + "start_time": "0.000000", + "extradata_size": 2, + "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, + "non_diegetic": 0, + "captions": 0, + "descriptions": 0, + "metadata": 0, + "dependent": 0, + "still_image": 0 + }, + "tags": { + "language": "deu", + "HANDLER_NAME": "SoundHandler", + "VENDOR_ID": "[0][0][0][0]", + "BPS": "255785", + "DURATION": "00:00:09.984000000", + "NUMBER_OF_FRAMES": "469", + "NUMBER_OF_BYTES": "319220", + "_STATISTICS_WRITING_APP": "mkvpropedit v90.0 ('Hanging On') 64-bit", + "_STATISTICS_WRITING_DATE_UTC": "2025-04-19 10:37:57", + "_STATISTICS_TAGS": "BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES" + } + }, + { + "index": 2, + "codec_name": "mjpeg", + "codec_long_name": "Motion JPEG", + "profile": "Baseline", + "codec_type": "video", + "codec_tag_string": "[0][0][0][0]", + "codec_tag": "0x0000", + "width": 960, + "height": 540, + "coded_width": 960, + "coded_height": 540, + "closed_captions": 0, + "film_grain": 0, + "has_b_frames": 0, + "sample_aspect_ratio": "1:1", + "display_aspect_ratio": "16:9", + "pix_fmt": "yuvj420p", + "level": -99, + "color_range": "pc", + "color_space": "bt470bg", + "chroma_location": "center", + "refs": 1, + "r_frame_rate": "1000/1", + "avg_frame_rate": "30000/1", + "time_base": "1/1000", + "start_pts": 0, + "start_time": "0.000000", + "bits_per_raw_sample": "8", + "disposition": { + "default": 0, + "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, + "non_diegetic": 0, + "captions": 0, + "descriptions": 0, + "metadata": 0, + "dependent": 0, + "still_image": 0 + }, + "tags": { + "BPS": "0", + "DURATION": "00:00:00.000011111", + "NUMBER_OF_FRAMES": "1", + "NUMBER_OF_BYTES": "155034", + "_STATISTICS_WRITING_APP": "mkvpropedit v90.0 ('Hanging On') 64-bit", + "_STATISTICS_WRITING_DATE_UTC": "2025-04-19 10:37:57", + "_STATISTICS_TAGS": "BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES" + } + } + ], + "chapters": [], + "format": { + "filename": "file:broken_mkv_covers - 01x03 - statistics added using mkvpropedit.mkv", + "nb_streams": 3, + "nb_programs": 0, + "nb_stream_groups": 0, + "format_name": "matroska,webm", + "format_long_name": "Matroska / WebM", + "start_time": "0.000000", + "duration": "10.005000", + "size": "5425928", + "bit_rate": "4338573", + "probe_score": 100, + "tags": { + "title": "Folge 1: Jackpot Immobilie · Wie wir klug vererben", + "EPISODE_SORT": "1", + "MAJOR_BRAND": "isom", + "MINOR_VERSION": "512", + "COMPATIBLE_BRANDS": "isomiso2avc1mp41", + "DATE": "20250318", + "SEASON_NUMBER": "1", + "COMMENT": "https://www.ardmediathek.de/video/Y3JpZDovL2JyLmRlL2Jyb2FkY2FzdC8zZWFmYTcxOC1mZDJmLTRmZTMtYWE4Ny03ZjdlNWViNTk1NDhfb25saW5lYnJvYWRjYXN0", + "DESCRIPTION": "Jeder kennt es: Enge Familienmitglieder sprechen plötzlich nicht mehr miteinander, der Streit ums Erbe entzweit die Familie. Was steht im Testament? Kann sich die Erbengemeinschaft einigen? Wie läuft das mit der Erbschaftssteuer und was sagt das Erbrecht?\n\nDas \"Lohnt sich das?\"-Team hat zwei Familien gefunden, die das Tabu rund ums Erben brechen. Sie erzählen, um wie viel Geld es geht, aber auch, was dieses Immobilienerbe mit ihrer Familie gemacht hat. Warum es einmal gelingt, Häuser in Millionenhöhe ohne jegliche Erbschaftssteuer zu vererben, während andere Geschwister sich über ihr Elternhaus komplett zerstritten haben. Erbfolge, Freibeträge, Nießbrauch - für alle Basics rund ums Erben ist Ralph Caspers zuständig, der kompetent erklärt. ", + "SYNOPSIS": "Jeder kennt es: Enge Familienmitglieder sprechen plötzlich nicht mehr miteinander, der Streit ums Erbe entzweit die Familie. Was steht im Testament? Kann sich die Erbengemeinschaft einigen? Wie läuft das mit der Erbschaftssteuer und was sagt das Erbrecht?\n\nDas \"Lohnt sich das?\"-Team hat zwei Familien gefunden, die das Tabu rund ums Erben brechen. Sie erzählen, um wie viel Geld es geht, aber auch, was dieses Immobilienerbe mit ihrer Familie gemacht hat. Warum es einmal gelingt, Häuser in Millionenhöhe ohne jegliche Erbschaftssteuer zu vererben, während andere Geschwister sich über ihr Elternhaus komplett zerstritten haben. Erbfolge, Freibeträge, Nießbrauch - für alle Basics rund ums Erben ist Ralph Caspers zuständig, der kompetent erklärt. ", + "SHOW": "Generation Wohnkrise. Lohnt sich das?", + "EPISODE_ID": "Folge 1: Jackpot Immobilie · Wie wir klug vererben", + "ENCODER": "Lavf61.7.100" + } + } +} diff --git a/tests/Jellyfin.Model.Tests/Dlna/LegacyStreamInfo.cs b/tests/Jellyfin.Model.Tests/Dlna/LegacyStreamInfo.cs new file mode 100644 index 0000000000..e32baef55d --- /dev/null +++ b/tests/Jellyfin.Model.Tests/Dlna/LegacyStreamInfo.cs @@ -0,0 +1,215 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Jellyfin.Data.Enums; +using Jellyfin.Extensions; +using MediaBrowser.Model.Dlna; +using MediaBrowser.Model.Dto; + +namespace Jellyfin.Model.Tests.Dlna; + +public class LegacyStreamInfo : StreamInfo +{ + public LegacyStreamInfo(Guid itemId, DlnaProfileType mediaType) + { + ItemId = itemId; + MediaType = mediaType; + } + + /// + /// The 10.6 ToUrl code from StreamInfo.cs with which to compare new version. + /// + /// The base url to use. + /// The Access token. + /// A url. + public string ToUrl_Original(string baseUrl, string? accessToken) + { + ArgumentException.ThrowIfNullOrEmpty(baseUrl); + + var list = new List(); + foreach (NameValuePair pair in BuildParams(this, accessToken)) + { + if (string.IsNullOrEmpty(pair.Value)) + { + continue; + } + + // Try to keep the url clean by omitting defaults + if (string.Equals(pair.Name, "StartTimeTicks", StringComparison.OrdinalIgnoreCase) + && string.Equals(pair.Value, "0", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (string.Equals(pair.Name, "SubtitleStreamIndex", StringComparison.OrdinalIgnoreCase) + && string.Equals(pair.Value, "-1", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (string.Equals(pair.Name, "Static", StringComparison.OrdinalIgnoreCase) + && string.Equals(pair.Value, "false", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var encodedValue = pair.Value.Replace(" ", "%20", StringComparison.Ordinal); + + list.Add(string.Format(CultureInfo.InvariantCulture, "{0}={1}", pair.Name, encodedValue)); + } + + string queryString = string.Join('&', list); + + return GetUrl(baseUrl, queryString); + } + + private string GetUrl(string baseUrl, string queryString) + { + ArgumentException.ThrowIfNullOrEmpty(baseUrl); + + string extension = string.IsNullOrEmpty(Container) ? string.Empty : "." + Container; + + baseUrl = baseUrl.TrimEnd('/'); + + if (MediaType == DlnaProfileType.Audio) + { + if (SubProtocol == MediaStreamProtocol.hls) + { + return string.Format(CultureInfo.InvariantCulture, "{0}/audio/{1}/master.m3u8?{2}", baseUrl, ItemId, queryString); + } + + return string.Format(CultureInfo.InvariantCulture, "{0}/audio/{1}/stream{2}?{3}", baseUrl, ItemId, extension, queryString); + } + + if (SubProtocol == MediaStreamProtocol.hls) + { + return string.Format(CultureInfo.InvariantCulture, "{0}/videos/{1}/master.m3u8?{2}", baseUrl, ItemId, queryString); + } + + return string.Format(CultureInfo.InvariantCulture, "{0}/videos/{1}/stream{2}?{3}", baseUrl, ItemId, extension, queryString); + } + + private static List BuildParams(StreamInfo item, string? accessToken) + { + List list = []; + + string audioCodecs = item.AudioCodecs.Count == 0 ? + string.Empty : + string.Join(',', item.AudioCodecs); + + string videoCodecs = item.VideoCodecs.Count == 0 ? + string.Empty : + string.Join(',', item.VideoCodecs); + + list.Add(new NameValuePair("DeviceProfileId", item.DeviceProfileId ?? string.Empty)); + list.Add(new NameValuePair("DeviceId", item.DeviceId ?? string.Empty)); + list.Add(new NameValuePair("MediaSourceId", item.MediaSourceId ?? string.Empty)); + list.Add(new NameValuePair("Static", item.IsDirectStream.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); + list.Add(new NameValuePair("VideoCodec", videoCodecs)); + list.Add(new NameValuePair("AudioCodec", audioCodecs)); + list.Add(new NameValuePair("AudioStreamIndex", item.AudioStreamIndex.HasValue ? item.AudioStreamIndex.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); + list.Add(new NameValuePair("SubtitleStreamIndex", item.SubtitleStreamIndex.HasValue && (item.AlwaysBurnInSubtitleWhenTranscoding || item.SubtitleDeliveryMethod != SubtitleDeliveryMethod.External) ? item.SubtitleStreamIndex.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); + list.Add(new NameValuePair("VideoBitrate", item.VideoBitrate.HasValue ? item.VideoBitrate.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); + list.Add(new NameValuePair("AudioBitrate", item.AudioBitrate.HasValue ? item.AudioBitrate.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); + list.Add(new NameValuePair("AudioSampleRate", item.AudioSampleRate.HasValue ? item.AudioSampleRate.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); + + list.Add(new NameValuePair("MaxFramerate", item.MaxFramerate.HasValue ? item.MaxFramerate.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); + list.Add(new NameValuePair("MaxWidth", item.MaxWidth.HasValue ? item.MaxWidth.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); + list.Add(new NameValuePair("MaxHeight", item.MaxHeight.HasValue ? item.MaxHeight.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); + + long startPositionTicks = item.StartPositionTicks; + + if (item.SubProtocol == MediaStreamProtocol.hls) + { + list.Add(new NameValuePair("StartTimeTicks", string.Empty)); + list.Add(new NameValuePair("SegmentContainer", item.Container ?? string.Empty)); + + if (item.SegmentLength.HasValue) + { + list.Add(new NameValuePair("SegmentLength", item.SegmentLength.Value.ToString(CultureInfo.InvariantCulture))); + } + + if (item.MinSegments.HasValue) + { + list.Add(new NameValuePair("MinSegments", item.MinSegments.Value.ToString(CultureInfo.InvariantCulture))); + } + + list.Add(new NameValuePair("BreakOnNonKeyFrames", item.BreakOnNonKeyFrames.ToString(CultureInfo.InvariantCulture))); + } + else + { + list.Add(new NameValuePair("StartTimeTicks", startPositionTicks.ToString(CultureInfo.InvariantCulture))); + } + + list.Add(new NameValuePair("PlaySessionId", item.PlaySessionId ?? string.Empty)); + list.Add(new NameValuePair("ApiKey", accessToken ?? string.Empty)); + + string? liveStreamId = item.MediaSource?.LiveStreamId; + list.Add(new NameValuePair("LiveStreamId", liveStreamId ?? string.Empty)); + + if (!item.IsDirectStream) + { + if (item.RequireNonAnamorphic) + { + list.Add(new NameValuePair("RequireNonAnamorphic", item.RequireNonAnamorphic.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); + } + + list.Add(new NameValuePair("TranscodingMaxAudioChannels", item.TranscodingMaxAudioChannels.HasValue ? item.TranscodingMaxAudioChannels.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); + + if (item.EnableSubtitlesInManifest) + { + list.Add(new NameValuePair("EnableSubtitlesInManifest", item.EnableSubtitlesInManifest.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); + } + + if (item.EnableMpegtsM2TsMode) + { + list.Add(new NameValuePair("EnableMpegtsM2TsMode", item.EnableMpegtsM2TsMode.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); + } + + if (item.EstimateContentLength) + { + list.Add(new NameValuePair("EstimateContentLength", item.EstimateContentLength.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); + } + + if (item.TranscodeSeekInfo != TranscodeSeekInfo.Auto) + { + list.Add(new NameValuePair("TranscodeSeekInfo", item.TranscodeSeekInfo.ToString().ToLowerInvariant())); + } + + if (item.CopyTimestamps) + { + list.Add(new NameValuePair("CopyTimestamps", item.CopyTimestamps.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); + } + + list.Add(new NameValuePair("RequireAvc", item.RequireAvc.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); + + list.Add(new NameValuePair("EnableAudioVbrEncoding", item.EnableAudioVbrEncoding.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); + } + + list.Add(new NameValuePair("Tag", item.MediaSource?.ETag ?? string.Empty)); + + string subtitleCodecs = item.SubtitleCodecs.Count == 0 ? string.Empty : string.Join(",", item.SubtitleCodecs); + list.Add(new NameValuePair("SubtitleCodec", item.SubtitleStreamIndex.HasValue && item.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Embed ? subtitleCodecs : string.Empty)); + list.Add(new NameValuePair("SubtitleMethod", item.SubtitleStreamIndex.HasValue && item.SubtitleDeliveryMethod != SubtitleDeliveryMethod.External ? item.SubtitleDeliveryMethod.ToString() : string.Empty)); + + foreach (var pair in item.StreamOptions) + { + if (string.IsNullOrEmpty(pair.Value)) + { + continue; + } + + // strip spaces to avoid having to encode h264 profile names + list.Add(new NameValuePair(pair.Key, pair.Value.Replace(" ", string.Empty, StringComparison.Ordinal))); + } + + var transcodeReasonsValues = item.TranscodeReasons.GetUniqueFlags().ToArray(); + if (!item.IsDirectStream && transcodeReasonsValues.Length > 0) + { + list.Add(new NameValuePair("TranscodeReasons", item.TranscodeReasons.ToString())); + } + + return list; + } +} diff --git a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs index bd2143f252..2c1080ffe3 100644 --- a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs +++ b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs @@ -39,6 +39,8 @@ namespace Jellyfin.Model.Tests [InlineData("Chrome", "mkv-dvhe.05-eac3-28000k", PlayMethod.Transcode, TranscodeReason.ContainerNotSupported | TranscodeReason.VideoRangeTypeNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode", "HLS.mp4")] [InlineData("Chrome", "mkv-dvhe.08-eac3-15200k", PlayMethod.Transcode, TranscodeReason.ContainerNotSupported | TranscodeReason.VideoRangeTypeNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode", "HLS.mp4")] [InlineData("Chrome", "mp4-dvhe.08-eac3-15200k", PlayMethod.Transcode, TranscodeReason.VideoRangeTypeNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode", "HLS.mp4")] + [InlineData("Chrome", "numstreams-32", PlayMethod.DirectPlay)] + [InlineData("Chrome", "numstreams-33", PlayMethod.DirectPlay)] // Firefox [InlineData("Firefox", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay)] // #6450 [InlineData("Firefox", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "DirectStream", "HLS.mp4")] // #6450 @@ -180,6 +182,12 @@ namespace Jellyfin.Model.Tests [InlineData("Tizen3-stereo", "mkv-vp9-aac-srt-2600k", PlayMethod.DirectPlay)] [InlineData("Tizen3-stereo", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectPlay)] [InlineData("Tizen3-stereo", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.DirectPlay)] + [InlineData("Tizen3-stereo", "mkv-dvhe.08-eac3-15200k", PlayMethod.DirectPlay)] + [InlineData("Tizen3-stereo", "mp4-dvh1.05-eac3-15200k", PlayMethod.Transcode, TranscodeReason.VideoRangeTypeNotSupported | TranscodeReason.AudioChannelsNotSupported, "Transcode")] + [InlineData("Tizen3-stereo", "mp4-dvhe.08-eac3-15200k", PlayMethod.DirectPlay)] + [InlineData("Tizen3-stereo", "mkv-dvhe.05-eac3-28000k", PlayMethod.Transcode, TranscodeReason.VideoBitrateNotSupported | TranscodeReason.VideoRangeTypeNotSupported | TranscodeReason.AudioChannelsNotSupported, "Transcode")] + [InlineData("Tizen3-stereo", "numstreams-32", PlayMethod.DirectPlay)] + [InlineData("Tizen3-stereo", "numstreams-33", PlayMethod.Transcode, TranscodeReason.StreamCountExceedsLimit, "Remux")] // Tizen 4 4K 5.1 [InlineData("Tizen4-4K-5.1", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay)] [InlineData("Tizen4-4K-5.1", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectPlay)] @@ -191,6 +199,12 @@ namespace Jellyfin.Model.Tests [InlineData("Tizen4-4K-5.1", "mkv-vp9-aac-srt-2600k", PlayMethod.DirectPlay)] [InlineData("Tizen4-4K-5.1", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectPlay)] [InlineData("Tizen4-4K-5.1", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.DirectPlay)] + [InlineData("Tizen4-4K-5.1", "mkv-dvhe.08-eac3-15200k", PlayMethod.DirectPlay)] + [InlineData("Tizen4-4K-5.1", "mp4-dvh1.05-eac3-15200k", PlayMethod.Transcode, TranscodeReason.VideoRangeTypeNotSupported, "Transcode")] + [InlineData("Tizen4-4K-5.1", "mp4-dvhe.08-eac3-15200k", PlayMethod.DirectPlay)] + [InlineData("Tizen4-4K-5.1", "mkv-dvhe.05-eac3-28000k", PlayMethod.Transcode, TranscodeReason.VideoRangeTypeNotSupported, "Transcode")] + [InlineData("Tizen4-4K-5.1", "numstreams-32", PlayMethod.DirectPlay)] + [InlineData("Tizen4-4K-5.1", "numstreams-33", PlayMethod.Transcode, TranscodeReason.StreamCountExceedsLimit, "Remux")] // WebOS 23 [InlineData("WebOS-23", "mkv-dvhe.08-eac3-15200k", PlayMethod.Transcode, TranscodeReason.VideoRangeTypeNotSupported, "Remux")] [InlineData("WebOS-23", "mp4-dvh1.05-eac3-15200k", PlayMethod.DirectPlay)] @@ -588,7 +602,7 @@ namespace Jellyfin.Model.Tests private static (string Path, NameValueCollection Query, string Filename, string Extension) ParseUri(StreamInfo val) { - var href = val.ToUrl("media:", "ACCESSTOKEN").Split("?", 2); + var href = val.ToUrl("media:", "ACCESSTOKEN", null).Split("?", 2); var path = href[0]; var queryString = href.ElementAtOrDefault(1); diff --git a/tests/Jellyfin.Model.Tests/Dlna/StreamInfoTests.cs b/tests/Jellyfin.Model.Tests/Dlna/StreamInfoTests.cs new file mode 100644 index 0000000000..8dea468064 --- /dev/null +++ b/tests/Jellyfin.Model.Tests/Dlna/StreamInfoTests.cs @@ -0,0 +1,243 @@ +using System; +using System.Collections.Generic; +using System.Text; +using MediaBrowser.Model.Dlna; +using Xunit; + +namespace Jellyfin.Model.Tests.Dlna; + +public class StreamInfoTests +{ + private const string BaseUrl = "/test/"; + private const int RandomSeed = 298347823; + + /// + /// Returns a random float. + /// + /// The instance. + /// A random . + private static float RandomFloat(Random random) + { + var buffer = new byte[4]; + random.NextBytes(buffer); + return BitConverter.ToSingle(buffer, 0); + } + + /// + /// Creates a random array. + /// + /// The instance. + /// The element of the array. + /// An of . + private static object? RandomArray(Random random, Type? elementType) + { + if (elementType is null) + { + return null; + } + + if (elementType == typeof(string)) + { + return RandomStringArray(random); + } + + if (elementType == typeof(int)) + { + return RandomIntArray(random); + } + + if (elementType.IsEnum) + { + var values = Enum.GetValues(elementType); + return RandomIntArray(random, 0, values.Length - 1); + } + + throw new ArgumentException("Unsupported array type " + elementType.ToString()); + } + + /// + /// Creates a random length string. + /// + /// The instance. + /// The minimum length of the string. + /// The maximum length of the string. + /// The string. + private static string RandomString(Random random, int minLength = 0, int maxLength = 256) + { + var len = random.Next(minLength, maxLength); + var sb = new StringBuilder(len); + + while (len > 0) + { + sb.Append((char)random.Next(65, 97)); + len--; + } + + return sb.ToString(); + } + + /// + /// Creates a random long. + /// + /// The instance. + /// Min value. + /// Max value. + /// A random between and . + private static long RandomLong(Random random, long min = -9223372036854775808, long max = 9223372036854775807) + { + long result = random.Next((int)(min >> 32), (int)(max >> 32)); + result <<= 32; + result |= (long)random.Next((int)(min >> 32) << 32, (int)(max >> 32) << 32); + return result; + } + + /// + /// Creates a random string array containing between and . + /// + /// The instance. + /// The minimum number of elements. + /// The maximum number of elements. + /// A random instance. + private static string[] RandomStringArray(Random random, int minLength = 0, int maxLength = 9) + { + var len = random.Next(minLength, maxLength); + var arr = new List(len); + while (len > 0) + { + arr.Add(RandomString(random, 1, 30)); + len--; + } + + return arr.ToArray(); + } + + /// + /// Creates a random int array containing between and . + /// + /// The instance. + /// The minimum number of elements. + /// The maximum number of elements. + /// A random instance. + private static int[] RandomIntArray(Random random, int minLength = 0, int maxLength = 9) + { + var len = random.Next(minLength, maxLength); + var arr = new List(len); + while (len > 0) + { + arr.Add(random.Next()); + len--; + } + + return arr.ToArray(); + } + + /// + /// Fills most properties with random data. + /// + /// The instance to fill with data. + private static void FillAllProperties(T destination) + { + var random = new Random(RandomSeed); + var objectType = destination!.GetType(); + foreach (var property in objectType.GetProperties()) + { + if (!(property.CanRead && property.CanWrite)) + { + continue; + } + + var type = property.PropertyType; + // If nullable, then set it to null, 25% of the time. + if (Nullable.GetUnderlyingType(type) is not null) + { + if (random.Next(0, 4) == 0) + { + // Set it to null. + property.SetValue(destination, null); + continue; + } + } + + if (type == typeof(Guid)) + { + property.SetValue(destination, Guid.NewGuid()); + continue; + } + + if (type.IsEnum) + { + Array values = Enum.GetValues(property.PropertyType); + property.SetValue(destination, values.GetValue(random.Next(0, values.Length - 1))); + continue; + } + + if (type == typeof(long)) + { + property.SetValue(destination, RandomLong(random)); + continue; + } + + if (type == typeof(string)) + { + property.SetValue(destination, RandomString(random)); + continue; + } + + if (type == typeof(bool)) + { + property.SetValue(destination, random.Next(0, 1) == 1); + continue; + } + + if (type == typeof(float)) + { + property.SetValue(destination, RandomFloat(random)); + continue; + } + + if (type.IsArray) + { + property.SetValue(destination, RandomArray(random, type.GetElementType())); + continue; + } + } + } + + [InlineData(DlnaProfileType.Audio)] + [InlineData(DlnaProfileType.Video)] + [InlineData(DlnaProfileType.Photo)] + [Theory] + public void Test_Blank_Url_Method(DlnaProfileType type) + { + var streamInfo = new LegacyStreamInfo(Guid.Empty, type) + { + DeviceProfile = new DeviceProfile() + }; + + string legacyUrl = streamInfo.ToUrl_Original(BaseUrl, "123"); + + // New version will return and & after the ? due to optional parameters. + string newUrl = streamInfo.ToUrl(BaseUrl, "123", null).Replace("?&", "?", StringComparison.OrdinalIgnoreCase); + + Assert.Equal(legacyUrl, newUrl, ignoreCase: true); + } + + [Fact] + public void Fuzzy_Comparison() + { + var streamInfo = new LegacyStreamInfo(Guid.Empty, DlnaProfileType.Video) + { + DeviceProfile = new DeviceProfile() + }; + for (int i = 0; i < 100000; i++) + { + FillAllProperties(streamInfo); + string legacyUrl = streamInfo.ToUrl_Original(BaseUrl, "123"); + + // New version will return and & after the ? due to optional parameters. + string newUrl = streamInfo.ToUrl(BaseUrl, "123", null).Replace("?&", "?", StringComparison.OrdinalIgnoreCase); + + Assert.Equal(legacyUrl, newUrl, ignoreCase: true); + } + } +} diff --git a/tests/Jellyfin.Model.Tests/Extensions/StringHelperTests.cs b/tests/Jellyfin.Model.Tests/Extensions/StringHelperTests.cs index 0a4e060df6..c710df0823 100644 --- a/tests/Jellyfin.Model.Tests/Extensions/StringHelperTests.cs +++ b/tests/Jellyfin.Model.Tests/Extensions/StringHelperTests.cs @@ -1,5 +1,6 @@ using System; using FsCheck; +using FsCheck.Fluent; using FsCheck.Xunit; using MediaBrowser.Model.Extensions; using Xunit; diff --git a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Tizen3-stereo.json b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Tizen3-stereo.json index 2e3e6e6de4..9d43d2166d 100644 --- a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Tizen3-stereo.json +++ b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Tizen3-stereo.json @@ -439,7 +439,14 @@ { "Condition": "EqualsAny", "Property": "VideoProfile", - "Value": "high|main|baseline|constrained baseline|high 10", + "Value": "high|main|baseline|constrained baseline", + "IsRequired": false, + "$type": "ProfileCondition" + }, + { + "Condition": "EqualsAny", + "Property": "VideoRangeType", + "Value": "SDR", "IsRequired": false, "$type": "ProfileCondition" }, @@ -478,6 +485,13 @@ "IsRequired": false, "$type": "ProfileCondition" }, + { + "Condition": "EqualsAny", + "Property": "VideoRangeType", + "Value": "SDR|DOVIWithSDR|HDR10|DOVIWithHDR10|HLG|DOVIWithHLG", + "IsRequired": false, + "$type": "ProfileCondition" + }, { "Condition": "LessThanEqual", "Property": "VideoLevel", @@ -510,6 +524,21 @@ "$type": "CodecProfile" } ], + "ContainerProfiles": [ + { + "Type": "Video", + "Conditions": [ + { + "Condition": "LessThanEqual", + "Property": "NumStreams", + "Value": "32", + "IsRequired": false, + "$type": "ProfileCondition" + } + ], + "$type": "ContainerProfile" + } + ], "ResponseProfiles": [ { "Container": "m4v", diff --git a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Tizen4-4K-5.1.json b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Tizen4-4K-5.1.json index 1562304718..3859ef9941 100644 --- a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Tizen4-4K-5.1.json +++ b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Tizen4-4K-5.1.json @@ -439,7 +439,14 @@ { "Condition": "EqualsAny", "Property": "VideoProfile", - "Value": "high|main|baseline|constrained baseline|high 10", + "Value": "high|main|baseline|constrained baseline", + "IsRequired": false, + "$type": "ProfileCondition" + }, + { + "Condition": "EqualsAny", + "Property": "VideoRangeType", + "Value": "SDR", "IsRequired": false, "$type": "ProfileCondition" }, @@ -471,6 +478,13 @@ "IsRequired": false, "$type": "ProfileCondition" }, + { + "Condition": "EqualsAny", + "Property": "VideoRangeType", + "Value": "SDR|DOVIWithSDR|HDR10|DOVIWithHDR10|HLG|DOVIWithHLG", + "IsRequired": false, + "$type": "ProfileCondition" + }, { "Condition": "LessThanEqual", "Property": "VideoLevel", @@ -483,6 +497,21 @@ "$type": "CodecProfile" } ], + "ContainerProfiles": [ + { + "Type": "Video", + "Conditions": [ + { + "Condition": "LessThanEqual", + "Property": "NumStreams", + "Value": "32", + "IsRequired": false, + "$type": "ProfileCondition" + } + ], + "$type": "ContainerProfile" + } + ], "ResponseProfiles": [ { "Container": "m4v", diff --git a/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-numstreams-32.json b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-numstreams-32.json new file mode 100644 index 0000000000..6d01f81536 --- /dev/null +++ b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-numstreams-32.json @@ -0,0 +1,565 @@ +{ + "Id": "a766d122b58e45d9492d17af77748bf5", + "Path": "/Media/MyVideo-720p.mp4", + "Container": "mov,mp4,m4a,3gp,3g2,mj2", + "Size": 835317696, + "Name": "MyVideo-720p", + "ETag": "579a34c6d5dfb21d81539a51220b6a23", + "RunTimeTicks": 25801230336, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "SupportsProbing": true, + "MediaStreams": [ + { + "Codec": "h264", + "CodecTag": "avc1", + "Language": "eng", + "TimeBase": "1/11988", + "VideoRange": "SDR", + "DisplayTitle": "720p H264 SDR", + "NalLengthSize": "0", + "BitRate": 2032876, + "BitDepth": 8, + "RefFrames": 1, + "IsDefault": true, + "Height": 720, + "Width": 1280, + "AverageFrameRate": 23.976, + "RealFrameRate": 23.976, + "Profile": "High", + "Type": 1, + "AspectRatio": "16:9", + "PixelFormat": "yuv420p", + "Level": 41 + }, + { + "Codec": "aac", + "CodecTag": "mp4a", + "Language": "eng", + "TimeBase": "1/48000", + "DisplayTitle": "En - AAC - Stereo - Default", + "ChannelLayout": "stereo", + "BitRate": 164741, + "Channels": 2, + "SampleRate": 48000, + "IsDefault": true, + "Profile": "LC", + "Index": 1, + "Score": 203 + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 2, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 3, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 4, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 5, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 6, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 7, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 8, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 9, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 10, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 11, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 12, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 13, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 14, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 15, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 16, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 17, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 18, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 19, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 20, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 21, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 22, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 23, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 24, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 25, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 26, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 27, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 28, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 29, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 30, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 31, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + } + ], + "Bitrate": 2590008, + "DefaultAudioStreamIndex": 1, + "DefaultSubtitleStreamIndex": 2 +} diff --git a/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-numstreams-33.json b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-numstreams-33.json new file mode 100644 index 0000000000..ac24500fe8 --- /dev/null +++ b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-numstreams-33.json @@ -0,0 +1,582 @@ +{ + "Id": "a766d122b58e45d9492d17af77748bf5", + "Path": "/Media/MyVideo-720p.mp4", + "Container": "mov,mp4,m4a,3gp,3g2,mj2", + "Size": 835317696, + "Name": "MyVideo-720p", + "ETag": "579a34c6d5dfb21d81539a51220b6a23", + "RunTimeTicks": 25801230336, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "SupportsProbing": true, + "MediaStreams": [ + { + "Codec": "h264", + "CodecTag": "avc1", + "Language": "eng", + "TimeBase": "1/11988", + "VideoRange": "SDR", + "DisplayTitle": "720p H264 SDR", + "NalLengthSize": "0", + "BitRate": 2032876, + "BitDepth": 8, + "RefFrames": 1, + "IsDefault": true, + "Height": 720, + "Width": 1280, + "AverageFrameRate": 23.976, + "RealFrameRate": 23.976, + "Profile": "High", + "Type": 1, + "AspectRatio": "16:9", + "PixelFormat": "yuv420p", + "Level": 41 + }, + { + "Codec": "aac", + "CodecTag": "mp4a", + "Language": "eng", + "TimeBase": "1/48000", + "DisplayTitle": "En - AAC - Stereo - Default", + "ChannelLayout": "stereo", + "BitRate": 164741, + "Channels": 2, + "SampleRate": 48000, + "IsDefault": true, + "Profile": "LC", + "Index": 1, + "Score": 203 + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 2, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 3, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 4, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 5, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 6, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 7, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 8, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 9, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 10, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 11, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 12, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 13, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 14, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 15, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 16, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 17, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 18, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 19, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 20, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 21, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 22, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 23, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 24, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 25, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 26, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 27, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 28, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 29, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 30, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 31, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 32, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + } + ], + "Bitrate": 2590008, + "DefaultAudioStreamIndex": 1, + "DefaultSubtitleStreamIndex": 2 +} diff --git a/tests/Jellyfin.Naming.Tests/TV/EpisodeNumberTests.cs b/tests/Jellyfin.Naming.Tests/TV/EpisodeNumberTests.cs index 406381f142..7bfab570b7 100644 --- a/tests/Jellyfin.Naming.Tests/TV/EpisodeNumberTests.cs +++ b/tests/Jellyfin.Naming.Tests/TV/EpisodeNumberTests.cs @@ -77,6 +77,8 @@ namespace Jellyfin.Naming.Tests.TV [InlineData("Season 3/The Series S3 E9 - The title.avi", 9)] [InlineData("Season 3/S003 E009.avi", 9)] [InlineData("Season 3/Season 3 Episode 9.avi", 9)] + [InlineData("[VCB-Studio] Re Zero kara Hajimeru Isekai Seikatsu [21][Ma10p_1080p][x265_flac].mkv", 21)] + [InlineData("[CASO&Sumisora][Oda_Nobuna_no_Yabou][04][BDRIP][1920x1080][x264_AAC][7620E503].mp4", 4)] // [InlineData("Case Closed (1996-2007)/Case Closed - 317.mkv", 317)] // triple digit episode number // TODO: [InlineData("Season 2/16 12 Some Title.avi", 16)] diff --git a/tests/Jellyfin.Naming.Tests/TV/SeasonPathParserTests.cs b/tests/Jellyfin.Naming.Tests/TV/SeasonPathParserTests.cs index 3a042df683..4c8ba58d04 100644 --- a/tests/Jellyfin.Naming.Tests/TV/SeasonPathParserTests.cs +++ b/tests/Jellyfin.Naming.Tests/TV/SeasonPathParserTests.cs @@ -6,32 +6,54 @@ namespace Jellyfin.Naming.Tests.TV; public class SeasonPathParserTests { [Theory] - [InlineData("/Drive/Season 1", 1, true)] - [InlineData("/Drive/s1", 1, true)] - [InlineData("/Drive/S1", 1, true)] - [InlineData("/Drive/Season 2", 2, true)] - [InlineData("/Drive/Season 02", 2, true)] - [InlineData("/Drive/Seinfeld/S02", 2, true)] - [InlineData("/Drive/Seinfeld/2", 2, true)] - [InlineData("/Drive/Seinfeld - S02", 2, true)] - [InlineData("/Drive/Season 2009", 2009, true)] - [InlineData("/Drive/Season1", 1, true)] - [InlineData("The Wonder Years/The.Wonder.Years.S04.PDTV.x264-JCH", 4, true)] - [InlineData("/Drive/Season 7 (2016)", 7, false)] - [InlineData("/Drive/Staffel 7 (2016)", 7, false)] - [InlineData("/Drive/Stagione 7 (2016)", 7, false)] - [InlineData("/Drive/Season (8)", null, false)] - [InlineData("/Drive/3.Staffel", 3, false)] - [InlineData("/Drive/s06e05", null, false)] - [InlineData("/Drive/The.Legend.of.Condor.Heroes.2017.V2.web-dl.1080p.h264.aac-hdctv", null, false)] - [InlineData("/Drive/extras", 0, true)] - [InlineData("/Drive/specials", 0, true)] - public void GetSeasonNumberFromPathTest(string path, int? seasonNumber, bool isSeasonDirectory) + [InlineData("/Drive/Season 1", "/Drive", 1, true)] + [InlineData("/Drive/Staffel 1", "/Drive", 1, true)] + [InlineData("/Drive/Stagione 1", "/Drive", 1, true)] + [InlineData("/Drive/sæson 1", "/Drive", 1, true)] + [InlineData("/Drive/Temporada 1", "/Drive", 1, true)] + [InlineData("/Drive/series 1", "/Drive", 1, true)] + [InlineData("/Drive/Kausi 1", "/Drive", 1, true)] + [InlineData("/Drive/Säsong 1", "/Drive", 1, true)] + [InlineData("/Drive/Seizoen 1", "/Drive", 1, true)] + [InlineData("/Drive/Seasong 1", "/Drive", 1, true)] + [InlineData("/Drive/Sezon 1", "/Drive", 1, true)] + [InlineData("/Drive/sezona 1", "/Drive", 1, true)] + [InlineData("/Drive/sezóna 1", "/Drive", 1, true)] + [InlineData("/Drive/Sezonul 1", "/Drive", 1, true)] + [InlineData("/Drive/시즌 1", "/Drive", 1, true)] + [InlineData("/Drive/シーズン 1", "/Drive", 1, true)] + [InlineData("/Drive/сезон 1", "/Drive", 1, true)] + [InlineData("/Drive/Сезон 1", "/Drive", 1, true)] + [InlineData("/Drive/Season 10", "/Drive", 10, true)] + [InlineData("/Drive/Season 100", "/Drive", 100, true)] + [InlineData("/Drive/s1", "/Drive", 1, true)] + [InlineData("/Drive/S1", "/Drive", 1, true)] + [InlineData("/Drive/Season 2", "/Drive", 2, true)] + [InlineData("/Drive/Season 02", "/Drive", 2, true)] + [InlineData("/Drive/Seinfeld/S02", "/Seinfeld", 2, true)] + [InlineData("/Drive/Seinfeld/2", "/Seinfeld", 2, true)] + [InlineData("/Drive/Seinfeld Season 2", "/Drive", null, false)] + [InlineData("/Drive/Season 2009", "/Drive", 2009, true)] + [InlineData("/Drive/Season1", "/Drive", 1, true)] + [InlineData("The Wonder Years/The.Wonder.Years.S04.PDTV.x264-JCH", "/The Wonder Years", 4, true)] + [InlineData("/Drive/Season 7 (2016)", "/Drive", 7, true)] + [InlineData("/Drive/Staffel 7 (2016)", "/Drive", 7, true)] + [InlineData("/Drive/Stagione 7 (2016)", "/Drive", 7, true)] + [InlineData("/Drive/Stargate SG-1/Season 1", "/Drive/Stargate SG-1", 1, true)] + [InlineData("/Drive/Stargate SG-1/Stargate SG-1 Season 1", "/Drive/Stargate SG-1", 1, true)] + [InlineData("/Drive/Season (8)", "/Drive", null, false)] + [InlineData("/Drive/3.Staffel", "/Drive", 3, true)] + [InlineData("/Drive/s06e05", "/Drive", null, false)] + [InlineData("/Drive/The.Legend.of.Condor.Heroes.2017.V2.web-dl.1080p.h264.aac-hdctv", "/Drive", null, false)] + [InlineData("/Drive/extras", "/Drive", 0, true)] + [InlineData("/Drive/specials", "/Drive", 0, true)] + [InlineData("/Drive/Episode 1 Season 2", "/Drive", null, false)] + public void GetSeasonNumberFromPathTest(string path, string? parentPath, int? seasonNumber, bool isSeasonDirectory) { - var result = SeasonPathParser.Parse(path, true, true); + var result = SeasonPathParser.Parse(path, parentPath, true, true); Assert.Equal(result.SeasonNumber is not null, result.Success); - Assert.Equal(result.SeasonNumber, seasonNumber); + Assert.Equal(seasonNumber, result.SeasonNumber); Assert.Equal(isSeasonDirectory, result.IsSeasonFolder); } } diff --git a/tests/Jellyfin.Naming.Tests/TV/TvParserHelpersTest.cs b/tests/Jellyfin.Naming.Tests/TV/TvParserHelpersTest.cs index 2d4b5b730e..5dd004408a 100644 --- a/tests/Jellyfin.Naming.Tests/TV/TvParserHelpersTest.cs +++ b/tests/Jellyfin.Naming.Tests/TV/TvParserHelpersTest.cs @@ -15,17 +15,17 @@ public class TvParserHelpersTest [InlineData("Unreleased", SeriesStatus.Unreleased)] public void SeriesStatusParserTest_Valid(string statusString, SeriesStatus? status) { - var successful = TvParserHelpers.TryParseSeriesStatus(statusString, out var parsered); + var successful = TvParserHelpers.TryParseSeriesStatus(statusString, out var parsed); Assert.True(successful); - Assert.Equal(status, parsered); + Assert.Equal(status, parsed); } [Theory] [InlineData("XXX")] public void SeriesStatusParserTest_InValid(string statusString) { - var successful = TvParserHelpers.TryParseSeriesStatus(statusString, out var parsered); + var successful = TvParserHelpers.TryParseSeriesStatus(statusString, out var parsed); Assert.False(successful); - Assert.Null(parsered); + Assert.Null(parsed); } } diff --git a/tests/Jellyfin.Naming.Tests/Video/ExtraTests.cs b/tests/Jellyfin.Naming.Tests/Video/ExtraTests.cs index 2c33ab4929..51eb99f496 100644 --- a/tests/Jellyfin.Naming.Tests/Video/ExtraTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/ExtraTests.cs @@ -2,6 +2,7 @@ using Emby.Naming.Common; using Emby.Naming.Video; using MediaBrowser.Model.Entities; using Xunit; + using MediaType = Emby.Naming.Common.MediaType; namespace Jellyfin.Naming.Tests.Video @@ -20,6 +21,9 @@ namespace Jellyfin.Naming.Tests.Video { Test("trailer.mp4", ExtraType.Trailer); Test("300-trailer.mp4", ExtraType.Trailer); + Test("300.trailer.mp4", ExtraType.Trailer); + Test("300_trailer.mp4", ExtraType.Trailer); + Test("300 trailer.mp4", ExtraType.Trailer); Test("theme.mp3", ExtraType.ThemeSong); } @@ -43,6 +47,19 @@ namespace Jellyfin.Naming.Tests.Video Test("300-deletedscene.mp4", ExtraType.DeletedScene); Test("300-interview.mp4", ExtraType.Interview); Test("300-behindthescenes.mp4", ExtraType.BehindTheScenes); + Test("300-featurette.mp4", ExtraType.Featurette); + Test("300-short.mp4", ExtraType.Short); + Test("300-extra.mp4", ExtraType.Unknown); + Test("300-other.mp4", ExtraType.Unknown); + } + + [Theory] + [InlineData(ExtraType.ThemeSong, "theme-music")] + public void TestDirectoriesAudioExtras(ExtraType type, string dirName) + { + Test(dirName + "/300.mp3", type); + Test("300/" + dirName + "/something.mp3", type); + Test("/data/something/Movies/300/" + dirName + "/whoknows.mp3", type); } [Theory] @@ -52,11 +69,14 @@ namespace Jellyfin.Naming.Tests.Video [InlineData(ExtraType.Scene, "scenes")] [InlineData(ExtraType.Sample, "samples")] [InlineData(ExtraType.Short, "shorts")] + [InlineData(ExtraType.Trailer, "trailers")] [InlineData(ExtraType.Featurette, "featurettes")] [InlineData(ExtraType.Clip, "clips")] [InlineData(ExtraType.ThemeVideo, "backdrops")] + [InlineData(ExtraType.Unknown, "extra")] [InlineData(ExtraType.Unknown, "extras")] - public void TestDirectories(ExtraType type, string dirName) + [InlineData(ExtraType.Unknown, "other")] + public void TestDirectoriesVideoExtras(ExtraType type, string dirName) { Test(dirName + "/300.mp4", type); Test("300/" + dirName + "/something.mkv", type); @@ -75,10 +95,44 @@ namespace Jellyfin.Naming.Tests.Video Test("/data/something/Movies/" + dirName + "/" + dirName + ".mp4", null); } + [Theory] + [InlineData(ExtraType.ThemeSong, "theme-music")] + public void TestTopLevelDirectoriesWithAudioExtraNames(ExtraType typicalType, string dirName) + { + string libraryRoot = "/data/something/" + dirName; + TestWithLibraryRoot(libraryRoot + "/300.mp3", libraryRoot, null); + TestWithLibraryRoot(libraryRoot + "/300/" + dirName + "/something.mp3", libraryRoot, typicalType); + } + + [Theory] + [InlineData(ExtraType.Trailer, "trailers")] + [InlineData(ExtraType.ThemeVideo, "backdrops")] + [InlineData(ExtraType.BehindTheScenes, "behind the scenes")] + [InlineData(ExtraType.DeletedScene, "deleted scenes")] + [InlineData(ExtraType.Interview, "interviews")] + [InlineData(ExtraType.Scene, "scenes")] + [InlineData(ExtraType.Sample, "samples")] + [InlineData(ExtraType.Short, "shorts")] + [InlineData(ExtraType.Featurette, "featurettes")] + [InlineData(ExtraType.Unknown, "extras")] + [InlineData(ExtraType.Unknown, "extra")] + [InlineData(ExtraType.Unknown, "other")] + [InlineData(ExtraType.Clip, "clips")] + public void TestTopLevelDirectoriesWithVideoExtraNames(ExtraType typicalType, string dirName) + { + string libraryRoot = "/data/something/" + dirName; + TestWithLibraryRoot(libraryRoot + "/300.mp4", libraryRoot, null); + TestWithLibraryRoot(libraryRoot + "/300/" + dirName + "/something.mkv", libraryRoot, typicalType); + } + [Fact] public void TestSample() { + Test("sample.mp4", ExtraType.Sample); Test("300-sample.mp4", ExtraType.Sample); + Test("300.sample.mp4", ExtraType.Sample); + Test("300_sample.mp4", ExtraType.Sample); + Test("300 sample.mp4", ExtraType.Sample); } private void Test(string input, ExtraType? expectedType) @@ -88,6 +142,12 @@ namespace Jellyfin.Naming.Tests.Video Assert.Equal(expectedType, extraType); } + private void TestWithLibraryRoot(string input, string libraryRoot, ExtraType? expectedType) + { + var extraType = ExtraRuleResolver.GetExtraInfo(input, _videoOptions, libraryRoot).ExtraType; + Assert.Equal(expectedType, extraType); + } + [Fact] public void TestExtraInfo_InvalidRuleType() { diff --git a/tests/Jellyfin.Networking.Tests/NetworkExtensionsTests.cs b/tests/Jellyfin.Networking.Tests/NetworkExtensionsTests.cs index 01546aa2b7..4ebd547869 100644 --- a/tests/Jellyfin.Networking.Tests/NetworkExtensionsTests.cs +++ b/tests/Jellyfin.Networking.Tests/NetworkExtensionsTests.cs @@ -1,4 +1,5 @@ using FsCheck; +using FsCheck.Fluent; using FsCheck.Xunit; using MediaBrowser.Common.Net; using Xunit; diff --git a/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs b/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs index 3b7c43100f..ef87e46a78 100644 --- a/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs +++ b/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs @@ -79,7 +79,10 @@ namespace Jellyfin.Networking.Tests [InlineData("[fe80::7add:12ff:febb:c67b%16]")] [InlineData("fd23:184f:2029:0:3139:7386:67d7:d517/56")] public static void TryParseValidIPStringsTrue(string address) - => Assert.True(NetworkUtils.TryParseToSubnet(address, out _)); + { + Assert.True(NetworkUtils.TryParseToSubnet(address, out _)); + Assert.True(NetworkUtils.TryParseToSubnet('!' + address, out _, true)); + } /// /// Checks invalid IP address formats. @@ -238,7 +241,7 @@ namespace Jellyfin.Networking.Tests // User on external network, internal binding only - so assumption is a proxy forward, return external override. [InlineData("jellyfin.org", "192.168.1.0/24", "eth16", false, "external=http://helloworld.com", "http://helloworld.com")] - // User on external network, no binding - so result is the 1st external which is overriden. + // User on external network, no binding - so result is the 1st external which is overridden. [InlineData("jellyfin.org", "192.168.1.0/24", "", false, "external=http://helloworld.com", "http://helloworld.com")] // User assumed to be internal, no binding - so result is the 1st matching interface. diff --git a/tests/Jellyfin.Providers.Tests/Lyrics/LrcLyricParserTests.cs b/tests/Jellyfin.Providers.Tests/Lyrics/LrcLyricParserTests.cs new file mode 100644 index 0000000000..756a688abe --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/Lyrics/LrcLyricParserTests.cs @@ -0,0 +1,41 @@ +using System.IO; +using MediaBrowser.Model.Lyrics; +using MediaBrowser.Providers.Lyric; +using Xunit; + +namespace Jellyfin.Providers.Tests.Lyrics; + +public static class LrcLyricParserTests +{ + [Fact] + public static void ParseElrcCues() + { + var parser = new LrcLyricParser(); + var fileContents = File.ReadAllText(Path.Combine("Test Data", "Lyrics", "Fleetwood Mac - Rumors.elrc")); + var parsed = parser.ParseLyrics(new LyricFile("Fleetwood Mac - Rumors.elrc", fileContents)); + + Assert.NotNull(parsed); + Assert.Equal(31, parsed.Lyrics.Count); + + var line1 = parsed.Lyrics[0]; + Assert.Equal("Every night that goes between", line1.Text); + Assert.NotNull(line1.Cues); + Assert.Equal(9, line1.Cues.Count); + Assert.Equal(68400000, line1.Cues[0].Start); + Assert.Equal(72000000, line1.Cues[0].End); + + var line5 = parsed.Lyrics[4]; + Assert.Equal("Every night you do not come", line5.Text); + Assert.NotNull(line5.Cues); + Assert.Equal(11, line5.Cues.Count); + Assert.Equal(377300000, line5.Cues[5].Start); + Assert.Equal(380000000, line5.Cues[5].End); + + var lastLine = parsed.Lyrics[^1]; + Assert.Equal("I have always been a storm", lastLine.Text); + Assert.NotNull(lastLine.Cues); + Assert.Equal(11, lastLine.Cues.Count); + Assert.Equal(2358000000, lastLine.Cues[^1].Start); + Assert.Null(lastLine.Cues[^1].End); + } +} diff --git a/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs b/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs index 0c7d2487cb..1ec859223e 100644 --- a/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs +++ b/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs @@ -5,6 +5,7 @@ using System.IO; using System.Linq; using System.Net; using System.Net.Http; +using System.Net.Mime; using System.Text; using System.Text.RegularExpressions; using System.Threading; @@ -292,6 +293,9 @@ namespace Jellyfin.Providers.Tests.Manager providerManager.Setup(pm => pm.SaveImage(item, It.IsAny(), It.IsAny(), imageType, null, It.IsAny())) .Callback((callbackItem, _, _, callbackType, _, _) => callbackItem.SetImagePath(callbackType, 0, new FileSystemMetadata())) .Returns(Task.CompletedTask); + providerManager.Setup(pm => pm.SaveImage(item, It.IsAny(), It.IsAny(), imageType, null, null, It.IsAny())) + .Callback((callbackItem, _, _, callbackType, _, _, _) => callbackItem.SetImagePath(callbackType, 0, new FileSystemMetadata())) + .Returns(Task.CompletedTask); var itemImageProvider = GetItemImageProvider(providerManager.Object, null); var result = await itemImageProvider.RefreshImages(item, libraryOptions, new List { dynamicProvider.Object }, refreshOptions, CancellationToken.None); @@ -388,7 +392,7 @@ namespace Jellyfin.Providers.Tests.Manager { ReasonPhrase = url, StatusCode = HttpStatusCode.OK, - Content = new StringContent(Content, Encoding.UTF8, "image/jpeg") + Content = new StringContent(Content, Encoding.UTF8, MediaTypeNames.Image.Jpeg) }); var refreshOptions = fullRefresh diff --git a/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceTests.cs b/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceTests.cs index cedcaf9c0f..b32ecf6ec4 100644 --- a/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceTests.cs +++ b/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceTests.cs @@ -330,7 +330,7 @@ namespace Jellyfin.Providers.Tests.Manager MetadataService.MergeBaseItemData(source, target, lockedFields, replaceData, false); actualValue = target.People; - return newValue?.Equals(actualValue) ?? actualValue is null; + return newValue?.SequenceEqual((IEnumerable)actualValue!) ?? actualValue is null; } /// diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs index db427308c2..222e624aa2 100644 --- a/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs +++ b/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs @@ -217,68 +217,58 @@ public class MediaInfoResolverTests string file = "My.Video.srt"; data.Add( file, - new[] - { + [ CreateMediaStream(VideoDirectoryPath + "/" + file, null, null, 0) - }, - new[] - { + ], + [ CreateMediaStream(VideoDirectoryPath + "/" + file, null, null, 0) - }); + ]); // filename has metadata file = "My.Video.Title1.default.forced.sdh.en.srt"; data.Add( file, - new[] - { + [ CreateMediaStream(VideoDirectoryPath + "/" + file, null, null, 0) - }, - new[] - { + ], + [ CreateMediaStream(VideoDirectoryPath + "/" + file, "eng", "Title1", 0, true, true, true) - }); + ]); // single stream with metadata file = "My.Video.mks"; data.Add( file, - new[] - { + [ CreateMediaStream(VideoDirectoryPath + "/" + file, "eng", "Title", 0, true, true, true) - }, - new[] - { - CreateMediaStream(VideoDirectoryPath + "/" + file, "eng", "Title", 0, true, true, true) - }); + ], + [ + CreateMediaStream(VideoDirectoryPath + "/" + file, "eng", "Title", 0, true, false, true) + ]); // stream wins for title/language, filename wins for flags when conflicting file = "My.Video.Title2.default.forced.sdh.en.srt"; data.Add( file, - new[] - { + [ CreateMediaStream(VideoDirectoryPath + "/" + file, "fra", "Metadata", 0) - }, - new[] - { + ], + [ CreateMediaStream(VideoDirectoryPath + "/" + file, "fra", "Metadata", 0, true, true, true) - }); + ]); // multiple stream with metadata - filename flags ignored but other data filled in when missing from stream file = "My.Video.Title3.default.forced.en.srt"; data.Add( file, - new[] - { + [ CreateMediaStream(VideoDirectoryPath + "/" + file, null, null, 0, true, true), CreateMediaStream(VideoDirectoryPath + "/" + file, "fra", "Metadata", 1) - }, - new[] - { + ], + [ CreateMediaStream(VideoDirectoryPath + "/" + file, "eng", "Title3", 0, true, true), CreateMediaStream(VideoDirectoryPath + "/" + file, "fra", "Metadata", 1) - }); + ]); return data; } diff --git a/tests/Jellyfin.Providers.Tests/Omdb/JsonOmdbConverterTests.cs b/tests/Jellyfin.Providers.Tests/Omdb/JsonOmdbConverterTests.cs index eed9eedc78..3062cb7b42 100644 --- a/tests/Jellyfin.Providers.Tests/Omdb/JsonOmdbConverterTests.cs +++ b/tests/Jellyfin.Providers.Tests/Omdb/JsonOmdbConverterTests.cs @@ -31,7 +31,7 @@ namespace Jellyfin.Providers.Tests.Omdb [Theory] [InlineData("\"N/A\"")] [InlineData("null")] - public void Deserialization_To_Nullable_Int_Shoud_Be_Null(string input) + public void Deserialization_To_Nullable_Int_Should_Be_Null(string input) { var result = JsonSerializer.Deserialize(input, _options); Assert.Null(result); @@ -49,7 +49,7 @@ namespace Jellyfin.Providers.Tests.Omdb [Theory] [InlineData("\"N/A\"")] [InlineData("null")] - public void Deserialization_To_Nullable_String_Shoud_Be_Null(string input) + public void Deserialization_To_Nullable_String_Should_Be_Null(string input) { var result = JsonSerializer.Deserialize(input, _options); Assert.Null(result); diff --git a/tests/Jellyfin.Providers.Tests/Test Data/Lyrics/Fleetwood Mac - Rumors.elrc b/tests/Jellyfin.Providers.Tests/Test Data/Lyrics/Fleetwood Mac - Rumors.elrc new file mode 100644 index 0000000000..1df264c903 --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/Test Data/Lyrics/Fleetwood Mac - Rumors.elrc @@ -0,0 +1,31 @@ +[00:06.84] <00:06.84> Every <00:07.20> <00:07.56> night <00:07.87> <00:08.19> that <00:08.46> <00:08.79> goes <00:09.19> <00:09.59> between +[00:14.69] <00:14.69> I <00:14.78> <00:14.87> feel <00:15.15> <00:15.44> a <00:15.54> <00:15.65> little <00:15.96> <00:16.28> less +[00:20.98] <00:20.98> As <00:21.10> <00:21.22> you <00:21.27> <00:21.31> slowly <00:21.79> <00:22.27> go <00:22.57> <00:22.87> away <00:23.74> <00:24.19> from <00:24.51> <00:24.82> me +[00:28.73] <00:28.73> This <00:28.91> <00:29.09> is <00:29.22> <00:29.36> only <00:29.75> <00:30.14> another <00:30.92> <00:31.25> test +[00:36.11] <00:36.11> Every <00:36.44> <00:36.77> night <00:37.14> <00:37.52> you <00:37.73> <00:38.00> do <00:38.24> <00:38.48> not <00:38.93> <00:39.41> come +[00:43.56] <00:43.56> Your <00:43.65> <00:43.74> softness <00:44.21> <00:44.69> fades <00:45.01> <00:45.33> away +[00:50.29] <00:50.29> Did <00:50.42> <00:50.56> I <00:50.70> <00:50.85> ever <00:51.41> <00:51.97> really <00:52.48> <00:52.99> care <00:53.56> <00:53.86> that <00:54.09> <00:54.34> much +[00:58.07] <00:58.07> Is <00:58.20> <00:58.34> there <00:58.48> <00:58.63> anything <00:59.44> <00:59.69> left <00:59.94> <01:00.20> to <01:00.30> <01:00.41> say? +[01:05.59] <01:05.59> Every <01:06.58> <01:07.78> hour <01:08.05> <01:08.32> of <01:08.92> <01:09.97> fear <01:10.39> <01:10.81> I <01:11.41> <01:11.47> spend +[01:13.84] <01:13.84> My <01:13.99> <01:14.14> body <01:14.57> <01:15.01> tries <01:15.32> <01:15.64> to <01:15.71> <01:15.79> cry +[01:18.60] <01:18.60> Living <01:19.41> <01:20.79> through <01:21.12> <01:21.45> each <01:21.90> <01:23.13> empty <01:23.83> <01:24.54> night +[01:27.51] <01:27.51> A <01:27.60> <01:27.69> deadly <01:28.12> <01:28.56> call <01:28.90> <01:29.25> inside +[01:34.29] <01:34.29> I <01:34.39> <01:34.50> haven't <01:35.37> <01:35.64> felt <01:35.92> <01:36.21> this <01:36.34> <01:36.48> way <01:36.79> <01:37.11> I <01:37.35> <01:37.62> feel +[01:42.11] <01:42.11> Since <01:42.27> <01:42.44> many <01:42.71> <01:42.98> a <01:43.08> <01:43.19> years <01:43.46> <01:43.85> ago +[01:48.92] <01:48.92> But <01:49.08> <01:49.25> in <01:49.36> <01:49.48> those <01:49.78> <01:50.09> years <01:50.50> <01:50.92> and <01:51.07> <01:51.23> the <01:51.30> <01:51.38> lifetime's <01:51.99> <01:52.61> past +[01:56.60] <01:56.60> I <01:56.69> <01:56.78> did <01:56.91> <01:57.05> not <01:57.50> <01:57.92> deal <01:58.22> <01:58.52> with <01:58.68> <01:58.85> the <01:58.91> <01:58.97> road +[02:03.13] <02:03.13> And <02:03.25> <02:03.37> I <02:03.45> <02:03.55> did <02:03.68> <02:03.81> not <02:04.20> <02:04.60> deal <02:04.94> <02:05.29> with <02:05.45> <02:05.62> you, <02:05.90> <02:06.19> I <02:06.50> <02:06.82> know +[02:10.95] <02:10.95> Though <02:11.11> <02:11.28> the <02:11.35> <02:11.43> love <02:11.79> <02:12.15> has <02:12.32> <02:12.39> always <02:13.06> <02:13.74> been +[02:17.70] <02:17.70> So <02:17.91> <02:18.12> I <02:18.16> <02:18.21> search <02:18.55> <02:18.90> to <02:19.03> <02:19.17> find <02:19.51> <02:19.86> an <02:20.14> <02:20.43> answer <02:21.24> <02:21.71> there +[02:25.72] <02:25.72> So <02:25.96> <02:26.20> I <02:26.39> <02:26.59> can <02:27.04> <02:27.19> truly <02:27.65> <02:28.12> win +[02:33.02] <02:33.02> Every <02:34.04> <02:35.29> hour <02:35.77> <02:35.84> of <02:36.38> <02:37.43> fear <02:37.92> <02:38.42> I <02:38.69> <02:38.96> spend +[02:41.35] <02:41.35> My <02:41.63> <02:41.91> body <02:42.35> <02:42.79> tries <02:43.09> <02:43.39> to <02:43.48> <02:43.57> cry +[02:46.32] <02:46.32> Living <02:47.04> <02:48.54> through <02:48.88> <02:49.23> each <02:49.56> <02:50.85> empty <02:51.55> <02:52.26> night +[02:55.06] <02:55.06> A <02:55.13> <02:55.21> deadly <02:55.64> <02:56.08> call <02:56.42> <02:56.77> inside +[03:01.50] <03:01.50> So <03:01.74> <03:01.98> I <03:02.04> <03:02.10> try <03:02.42> <03:02.76> to <03:02.81> <03:02.87> say <03:03.45> <03:04.20> goodbye, <03:04.53> <03:04.86> my <03:05.02> <03:05.19> friend +[03:09.09] <03:09.09> I'd <03:09.19> <03:09.30> like <03:09.42> <03:09.54> to <03:09.70> <03:09.87> leave <03:10.02> <03:10.17> you <03:10.29> <03:10.41> with <03:10.71> <03:10.89> something <03:11.32> <03:11.76> warm +[03:16.25] <03:16.25> But <03:16.34> <03:16.43> never <03:16.77> <03:17.12> have <03:17.25> <03:17.38> I <03:17.44> <03:17.51> been <03:17.73> <03:17.96> a <03:18.14> <03:18.32> blue <03:18.62> <03:18.92> calm <03:19.58> <03:19.76> sea +[03:24.18] <03:24.18> I <03:24.31> <03:24.45> have <03:24.57> <03:24.69> always <03:25.59> <03:28.02> been <03:28.19> <03:28.38> a <03:28.51> <03:28.65> storm +[03:33.69] <03:33.69> Always <03:35.43> <03:36.99> been <03:37.18> <03:37.38> a <03:37.50> <03:37.62> storm +[03:41.45] <03:41.45> Ooh, <03:41.99> <03:42.53> always <03:44.42> <03:45.86> been <03:46.05> <03:46.25> a <03:46.41> <03:46.58> storm +[03:50.34] <03:50.34> I <03:50.49> <03:50.64> have <03:51.07> <03:51.51> always <03:53.31> <03:54.78> been <03:54.96> <03:55.14> a <03:55.47> <03:55.80> storm diff --git a/tests/Jellyfin.Server.Implementations.Tests/Data/SqliteItemRepositoryTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Data/SqliteItemRepositoryTests.cs index 0d2b488bc7..105f5d7af1 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Data/SqliteItemRepositoryTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Data/SqliteItemRepositoryTests.cs @@ -3,8 +3,10 @@ using System.Collections.Generic; using AutoFixture; using AutoFixture.AutoMoq; using Emby.Server.Implementations.Data; +using Jellyfin.Server.Implementations.Item; using MediaBrowser.Controller; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Persistence; using MediaBrowser.Model.Entities; using Microsoft.Extensions.Configuration; using Moq; @@ -18,7 +20,7 @@ namespace Jellyfin.Server.Implementations.Tests.Data public const string MetaDataPath = "/meta/data/path"; private readonly IFixture _fixture; - private readonly SqliteItemRepository _sqliteItemRepository; + private readonly BaseItemRepository _sqliteItemRepository; public SqliteItemRepositoryTests() { @@ -40,7 +42,7 @@ namespace Jellyfin.Server.Implementations.Tests.Data _fixture = new Fixture().Customize(new AutoMoqCustomization { ConfigureMembers = true }); _fixture.Inject(appHost); _fixture.Inject(config); - _sqliteItemRepository = _fixture.Create(); + _sqliteItemRepository = _fixture.Create(); } public static TheoryData ItemImageInfoFromValueString_Valid_TestData() @@ -97,31 +99,6 @@ namespace Jellyfin.Server.Implementations.Tests.Data return data; } - [Theory] - [MemberData(nameof(ItemImageInfoFromValueString_Valid_TestData))] - public void ItemImageInfoFromValueString_Valid_Success(string value, ItemImageInfo expected) - { - var result = _sqliteItemRepository.ItemImageInfoFromValueString(value); - Assert.Equal(expected.Path, result.Path); - Assert.Equal(expected.Type, result.Type); - Assert.Equal(expected.DateModified, result.DateModified); - Assert.Equal(expected.Width, result.Width); - Assert.Equal(expected.Height, result.Height); - Assert.Equal(expected.BlurHash, result.BlurHash); - } - - [Theory] - [InlineData("")] - [InlineData("*")] - [InlineData("https://image.tmdb.org/t/p/original/zhB5CHEgqqh4wnEqDNJLfWXJlcL.jpg*0")] - [InlineData("/mnt/series/Family Guy/Season 1/Family Guy - S01E01-thumb.jpg*6374520964785129080*WjQbtJtSO8nhNZ%L_Io#R/oaS DeserializeImages_Valid_TestData() { var data = new TheoryData(); @@ -202,97 +179,6 @@ namespace Jellyfin.Server.Implementations.Tests.Data return data; } - [Theory] - [MemberData(nameof(DeserializeImages_Valid_TestData))] - public void DeserializeImages_Valid_Success(string value, ItemImageInfo[] expected) - { - var result = _sqliteItemRepository.DeserializeImages(value); - Assert.Equal(expected.Length, result.Length); - for (int i = 0; i < expected.Length; i++) - { - Assert.Equal(expected[i].Path, result[i].Path); - Assert.Equal(expected[i].Type, result[i].Type); - Assert.Equal(expected[i].DateModified, result[i].DateModified); - Assert.Equal(expected[i].Width, result[i].Width); - Assert.Equal(expected[i].Height, result[i].Height); - Assert.Equal(expected[i].BlurHash, result[i].BlurHash); - } - } - - [Theory] - [MemberData(nameof(DeserializeImages_ValidAndInvalid_TestData))] - public void DeserializeImages_ValidAndInvalid_Success(string value, ItemImageInfo[] expected) - { - var result = _sqliteItemRepository.DeserializeImages(value); - Assert.Equal(expected.Length, result.Length); - for (int i = 0; i < expected.Length; i++) - { - Assert.Equal(expected[i].Path, result[i].Path); - Assert.Equal(expected[i].Type, result[i].Type); - Assert.Equal(expected[i].DateModified, result[i].DateModified); - Assert.Equal(expected[i].Width, result[i].Width); - Assert.Equal(expected[i].Height, result[i].Height); - Assert.Equal(expected[i].BlurHash, result[i].BlurHash); - } - } - - [Theory] - [MemberData(nameof(DeserializeImages_Valid_TestData))] - public void SerializeImages_Valid_Success(string expected, ItemImageInfo[] value) - { - Assert.Equal(expected, _sqliteItemRepository.SerializeImages(value)); - } - - public static TheoryData> DeserializeProviderIds_Valid_TestData() - { - var data = new TheoryData>(); - - data.Add( - "Imdb=tt0119567", - new Dictionary() - { - { "Imdb", "tt0119567" }, - }); - - data.Add( - "Imdb=tt0119567|Tmdb=330|TmdbCollection=328", - new Dictionary() - { - { "Imdb", "tt0119567" }, - { "Tmdb", "330" }, - { "TmdbCollection", "328" }, - }); - - data.Add( - "MusicBrainzAlbum=9d363e43-f24f-4b39-bc5a-7ef305c677c7|MusicBrainzReleaseGroup=63eba062-847c-3b73-8b0f-6baf27bba6fa|AudioDbArtist=111352|AudioDbAlbum=2116560|MusicBrainzAlbumArtist=20244d07-534f-4eff-b4d4-930878889970", - new Dictionary() - { - { "MusicBrainzAlbum", "9d363e43-f24f-4b39-bc5a-7ef305c677c7" }, - { "MusicBrainzReleaseGroup", "63eba062-847c-3b73-8b0f-6baf27bba6fa" }, - { "AudioDbArtist", "111352" }, - { "AudioDbAlbum", "2116560" }, - { "MusicBrainzAlbumArtist", "20244d07-534f-4eff-b4d4-930878889970" }, - }); - - return data; - } - - [Theory] - [MemberData(nameof(DeserializeProviderIds_Valid_TestData))] - public void DeserializeProviderIds_Valid_Success(string value, Dictionary expected) - { - var result = new ProviderIdsExtensionsTestsObject(); - SqliteItemRepository.DeserializeProviderIds(value, result); - Assert.Equal(expected, result.ProviderIds); - } - - [Theory] - [MemberData(nameof(DeserializeProviderIds_Valid_TestData))] - public void SerializeProviderIds_Valid_Success(string expected, Dictionary values) - { - Assert.Equal(expected, SqliteItemRepository.SerializeProviderIds(values)); - } - private sealed class ProviderIdsExtensionsTestsObject : IHasProviderIds { public Dictionary ProviderIds { get; set; } = new Dictionary(); diff --git a/tests/Jellyfin.Server.Implementations.Tests/EfMigrations/EfMigrationTests.cs b/tests/Jellyfin.Server.Implementations.Tests/EfMigrations/EfMigrationTests.cs index e6ccae1830..ba3abd5a22 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/EfMigrations/EfMigrationTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/EfMigrations/EfMigrationTests.cs @@ -1,5 +1,4 @@ -using System; -using System.Threading.Tasks; +using Jellyfin.Database.Providers.Sqlite.Migrations; using Jellyfin.Server.Implementations.Migrations; using Microsoft.EntityFrameworkCore; using Xunit; @@ -9,10 +8,10 @@ namespace Jellyfin.Server.Implementations.Tests.EfMigrations; public class EfMigrationTests { [Fact] - public void CheckForUnappliedMigrations() + public void CheckForUnappliedMigrations_SqLite() { - var dbDesignContext = new DesignTimeJellyfinDbFactory(); + var dbDesignContext = new SqliteDesignTimeJellyfinDbFactory(); var context = dbDesignContext.CreateDbContext([]); - Assert.False(context.Database.HasPendingModelChanges(), "There are unapplied changes to the EfCore model. Please create a Migration."); + Assert.False(context.Database.HasPendingModelChanges(), "There are unapplied changes to the EFCore model for SQLite. Please create a Migration."); } } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/OrderMapperTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/OrderMapperTests.cs new file mode 100644 index 0000000000..caf2b06b73 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/OrderMapperTests.cs @@ -0,0 +1,35 @@ +using System; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller.Entities; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +public class OrderMapperTests +{ + [Fact] + public void ShouldReturnMappedOrderForSortingByPremierDate() + { + var orderFunc = OrderMapper.MapOrderByField(ItemSortBy.PremiereDate, new InternalItemsQuery()).Compile(); + + var expectedDate = new DateTime(1, 2, 3); + var expectedProductionYearDate = new DateTime(4, 1, 1); + + var entityWithOnlyProductionYear = new BaseItemEntity { Id = Guid.NewGuid(), Type = "Test", ProductionYear = expectedProductionYearDate.Year }; + var entityWithOnlyPremierDate = new BaseItemEntity { Id = Guid.NewGuid(), Type = "Test", PremiereDate = expectedDate }; + var entityWithBothPremierDateAndProductionYear = new BaseItemEntity { Id = Guid.NewGuid(), Type = "Test", PremiereDate = expectedDate, ProductionYear = expectedProductionYearDate.Year }; + var entityWithoutEitherPremierDateOrProductionYear = new BaseItemEntity { Id = Guid.NewGuid(), Type = "Test" }; + + var resultWithOnlyProductionYear = orderFunc(entityWithOnlyProductionYear); + var resultWithOnlyPremierDate = orderFunc(entityWithOnlyPremierDate); + var resultWithBothPremierDateAndProductionYear = orderFunc(entityWithBothPremierDateAndProductionYear); + var resultWithoutEitherPremierDateOrProductionYear = orderFunc(entityWithoutEitherPremierDateOrProductionYear); + + Assert.Equal(resultWithOnlyProductionYear, expectedProductionYearDate); + Assert.Equal(resultWithOnlyPremierDate, expectedDate); + Assert.Equal(resultWithBothPremierDateAndProductionYear, expectedDate); + Assert.Null(resultWithoutEitherPremierDateOrProductionYear); + } +} 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 4f018ba694..4e2604e6e1 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj +++ b/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj @@ -29,6 +29,7 @@ + diff --git a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs index 0afbf7e634..a7a1e5e811 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs @@ -1,7 +1,7 @@ using System; using System.Linq; -using System.Runtime.InteropServices; using System.Threading.Tasks; +using BitFaster.Caching; using Emby.Server.Implementations.Localization; using MediaBrowser.Controller.Configuration; using MediaBrowser.Model.Configuration; @@ -45,15 +45,40 @@ namespace Jellyfin.Server.Implementations.Tests.Localization var germany = cultures.FirstOrDefault(x => x.TwoLetterISOLanguageName.Equals("de", StringComparison.Ordinal)); Assert.NotNull(germany); - Assert.Equal("ger", germany!.ThreeLetterISOLanguageName); + Assert.Equal("deu", germany!.ThreeLetterISOLanguageName); Assert.Equal("German", germany.DisplayName); Assert.Equal("German", germany.Name); Assert.Contains("deu", germany.ThreeLetterISOLanguageNames); Assert.Contains("ger", germany.ThreeLetterISOLanguageNames); } + [Fact] + public async Task TryGetISO6392TFromB_Success() + { + var localizationManager = Setup(new ServerConfiguration + { + UICulture = "de-DE" + }); + await localizationManager.LoadAll(); + + string? isoT; + + // Translation ger -> deu + Assert.True(localizationManager.TryGetISO6392TFromB("ger", out isoT)); + Assert.Equal("deu", isoT); + + // chi -> zho + Assert.True(localizationManager.TryGetISO6392TFromB("chi", out isoT)); + Assert.Equal("zho", isoT); + + // eng is already ISO 639-2/T + Assert.False(localizationManager.TryGetISO6392TFromB("eng", out isoT)); + Assert.Null(isoT); + } + [Theory] [InlineData("de")] + [InlineData("deu")] [InlineData("ger")] [InlineData("german")] public async Task FindLanguageInfo_Valid_Success(string identifier) @@ -67,7 +92,7 @@ namespace Jellyfin.Server.Implementations.Tests.Localization var germany = localizationManager.FindLanguageInfo(identifier); Assert.NotNull(germany); - Assert.Equal("ger", germany!.ThreeLetterISOLanguageName); + Assert.Equal("deu", germany!.ThreeLetterISOLanguageName); Assert.Equal("German", germany.DisplayName); Assert.Equal("German", germany.Name); Assert.Contains("deu", germany.ThreeLetterISOLanguageNames); @@ -84,11 +109,11 @@ namespace Jellyfin.Server.Implementations.Tests.Localization await localizationManager.LoadAll(); var ratings = localizationManager.GetParentalRatings().ToList(); - Assert.Equal(54, ratings.Count); + Assert.Equal(56, ratings.Count); var tvma = ratings.FirstOrDefault(x => x.Name.Equals("TV-MA", StringComparison.Ordinal)); Assert.NotNull(tvma); - Assert.Equal(17, tvma!.Value); + Assert.Equal(17, tvma!.RatingScore!.Score); } [Fact] @@ -105,43 +130,49 @@ namespace Jellyfin.Server.Implementations.Tests.Localization var fsk = ratings.FirstOrDefault(x => x.Name.Equals("FSK-12", StringComparison.Ordinal)); Assert.NotNull(fsk); - Assert.Equal(12, fsk!.Value); + Assert.Equal(12, fsk!.RatingScore!.Score); } [Theory] - [InlineData("CA-R", "CA", 18)] - [InlineData("FSK-16", "DE", 16)] - [InlineData("FSK-18", "DE", 18)] - [InlineData("FSK-18", "US", 18)] - [InlineData("TV-MA", "US", 17)] - [InlineData("XXX", "asdf", 1000)] - [InlineData("Germany: FSK-18", "DE", 18)] - public async Task GetRatingLevel_GivenValidString_Success(string value, string countryCode, int expectedLevel) + [InlineData("CA-R", "CA", 18, 1)] + [InlineData("FSK-16", "DE", 16, null)] + [InlineData("FSK-18", "DE", 18, null)] + [InlineData("FSK-18", "US", 18, null)] + [InlineData("TV-MA", "US", 17, 1)] + [InlineData("XXX", "asdf", 1000, null)] + [InlineData("Germany: FSK-18", "DE", 18, null)] + [InlineData("Rated : R", "US", 17, 0)] + [InlineData("Rated: R", "US", 17, 0)] + [InlineData("Rated R", "US", 17, 0)] + [InlineData(" PG-13 ", "US", 13, 0)] + public async Task GetRatingLevel_GivenValidString_Success(string value, string countryCode, int? expectedScore, int? expectedSubScore) { var localizationManager = Setup(new ServerConfiguration() { MetadataCountryCode = countryCode }); await localizationManager.LoadAll(); - var level = localizationManager.GetRatingLevel(value); - Assert.NotNull(level); - Assert.Equal(expectedLevel, level!); + var score = localizationManager.GetRatingScore(value); + Assert.NotNull(score); + Assert.Equal(expectedScore, score.Score); + Assert.Equal(expectedSubScore, score.SubScore); } [Theory] - [InlineData("0", 0)] - [InlineData("1", 1)] - [InlineData("6", 6)] - [InlineData("12", 12)] - [InlineData("42", 42)] - [InlineData("9999", 9999)] - public async Task GetRatingLevel_GivenValidAge_Success(string value, int expectedLevel) + [InlineData("0", 0, null)] + [InlineData("1", 1, null)] + [InlineData("6", 6, null)] + [InlineData("12", 12, null)] + [InlineData("42", 42, null)] + [InlineData("9999", 9999, null)] + public async Task GetRatingLevel_GivenValidAge_Success(string value, int? expectedScore, int? expectedSubScore) { var localizationManager = Setup(new ServerConfiguration { MetadataCountryCode = "nl" }); await localizationManager.LoadAll(); - var level = localizationManager.GetRatingLevel(value); - Assert.NotNull(level); - Assert.Equal(expectedLevel, level); + var score = localizationManager.GetRatingScore(value); + Assert.NotNull(score); + Assert.Equal(expectedScore, score.Score); + Assert.Equal(expectedSubScore, score.SubScore); } [Fact] @@ -152,10 +183,10 @@ namespace Jellyfin.Server.Implementations.Tests.Localization UICulture = "de-DE" }); await localizationManager.LoadAll(); - Assert.Null(localizationManager.GetRatingLevel("NR")); - Assert.Null(localizationManager.GetRatingLevel("unrated")); - Assert.Null(localizationManager.GetRatingLevel("Not Rated")); - Assert.Null(localizationManager.GetRatingLevel("n/a")); + Assert.Null(localizationManager.GetRatingScore("NR")); + Assert.Null(localizationManager.GetRatingScore("unrated")); + Assert.Null(localizationManager.GetRatingScore("Not Rated")); + Assert.Null(localizationManager.GetRatingScore("n/a")); } [Theory] @@ -169,7 +200,7 @@ namespace Jellyfin.Server.Implementations.Tests.Localization }); await localizationManager.LoadAll(); - Assert.Null(localizationManager.GetRatingLevel(value)); + Assert.Null(localizationManager.GetRatingScore(value)); } [Theory] diff --git a/tests/Jellyfin.Server.Implementations.Tests/Playlists/PlaylistManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Playlists/PlaylistManagerTests.cs new file mode 100644 index 0000000000..cc8ca720e7 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Playlists/PlaylistManagerTests.cs @@ -0,0 +1,40 @@ +using Emby.Server.Implementations.Playlists; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Playlists; + +public class PlaylistManagerTests +{ + [Fact] + public void DetermineAdjustedIndexMoveToFirstPositionNoPriorInAllList() + { + var priorIndexAllChildren = 0; + var newIndex = 0; + + var adjustedIndex = PlaylistManager.DetermineAdjustedIndex(priorIndexAllChildren, newIndex); + + Assert.Equal(0, adjustedIndex); + } + + [Fact] + public void DetermineAdjustedIndexPriorInMiddleOfAllList() + { + var priorIndexAllChildren = 2; + var newIndex = 0; + + var adjustedIndex = PlaylistManager.DetermineAdjustedIndex(priorIndexAllChildren, newIndex); + + Assert.Equal(1, adjustedIndex); + } + + [Fact] + public void DetermineAdjustedIndexMoveMiddleOfPlaylist() + { + var priorIndexAllChildren = 2; + var newIndex = 1; + + var adjustedIndex = PlaylistManager.DetermineAdjustedIndex(priorIndexAllChildren, newIndex); + + Assert.Equal(3, adjustedIndex); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionManagerTests.cs index 9418edc5d6..a5a67046d1 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionManagerTests.cs @@ -1,6 +1,6 @@ using System; using System.Threading.Tasks; -using Jellyfin.Data.Entities; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Devices; diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/Updates/manifest.json b/tests/Jellyfin.Server.Implementations.Tests/Test Data/Updates/manifest.json index 57367ce88c..6aa40c1dd9 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Test Data/Updates/manifest.json +++ b/tests/Jellyfin.Server.Implementations.Tests/Test Data/Updates/manifest.json @@ -540,7 +540,7 @@ { "guid": "022a3003-993f-45f1-8565-87d12af2e12a", "name": "InfuseSync", - "description": "This plugin will track all media changes while any Infuse clients are offline to decrease sync times when logging back in to your server.", + "description": "This plugin will track all media changes while any Infuse clients are offline to decrease sync times when logging back into your server.", "overview": "Blazing fast indexing for Infuse", "owner": "Firecore LLC", "category": "General", diff --git a/tests/Jellyfin.Server.Implementations.Tests/TypedBaseItem/BaseItemKindTests.cs b/tests/Jellyfin.Server.Implementations.Tests/TypedBaseItem/BaseItemKindTests.cs index 1bd51b2468..9a4389e7ac 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/TypedBaseItem/BaseItemKindTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/TypedBaseItem/BaseItemKindTests.cs @@ -35,7 +35,7 @@ namespace Jellyfin.Server.Implementations.Tests.TypedBaseItem public void EnumParse_GivenValidBaseItemType_ReturnsEnumValue(Type baseItemDescendantType) { var enumValue = Enum.Parse(baseItemDescendantType.Name); - Assert.True(Enum.IsDefined(typeof(BaseItemKind), enumValue)); + Assert.True(Enum.IsDefined(enumValue)); } [Theory] diff --git a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerTests.cs index 295f558fad..4cea53bd3d 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerTests.cs @@ -10,6 +10,9 @@ namespace Jellyfin.Server.Implementations.Tests.Users [InlineData("this_is_valid")] [InlineData("this is also valid")] [InlineData("0@_-' .")] + [InlineData("Aa0@_-' .+")] + [InlineData("thisisa+testemail@test.foo")] + [InlineData("------@@@--+++----@@--abcdefghijklmn---------@----_-_-___-_ .9foo+")] public void ThrowIfInvalidUsername_WhenValidUsername_DoesNotThrowArgumentException(string username) { var ex = Record.Exception(() => UserManager.ThrowIfInvalidUsername(username)); @@ -20,6 +23,10 @@ namespace Jellyfin.Server.Implementations.Tests.Users [InlineData(" ")] [InlineData("")] [InlineData("special characters like & $ ? are not allowed")] + [InlineData("thishasaspaceontheend ")] + [InlineData(" thishasaspaceatthestart")] + [InlineData(" thishasaspaceatbothends ")] + [InlineData(" this has a space at both ends and inbetween ")] public void ThrowIfInvalidUsername_WhenInvalidUsername_ThrowsArgumentException(string username) { Assert.Throws(() => UserManager.ThrowIfInvalidUsername(username)); diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/DashboardControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/DashboardControllerTests.cs index 39d449e27e..d92dbbd732 100644 --- a/tests/Jellyfin.Server.Integration.Tests/Controllers/DashboardControllerTests.cs +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/DashboardControllerTests.cs @@ -14,7 +14,7 @@ namespace Jellyfin.Server.Integration.Tests.Controllers public sealed class DashboardControllerTests : IClassFixture { private readonly JellyfinApplicationFactory _factory; - private readonly JsonSerializerOptions _jsonOpions = JsonDefaults.Options; + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; private static string? _accessToken; public DashboardControllerTests(JellyfinApplicationFactory factory) @@ -65,7 +65,7 @@ namespace Jellyfin.Server.Integration.Tests.Controllers Assert.Equal(HttpStatusCode.OK, response.StatusCode); - _ = await response.Content.ReadFromJsonAsync(_jsonOpions); + _ = await response.Content.ReadFromJsonAsync(_jsonOptions); // TODO: check content } @@ -81,7 +81,7 @@ namespace Jellyfin.Server.Integration.Tests.Controllers Assert.Equal(MediaTypeNames.Application.Json, response.Content.Headers.ContentType?.MediaType); Assert.Equal(Encoding.UTF8.BodyName, response.Content.Headers.ContentType?.CharSet); - var data = await response.Content.ReadFromJsonAsync(_jsonOpions); + var data = await response.Content.ReadFromJsonAsync(_jsonOptions); Assert.NotNull(data); Assert.Empty(data); } diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/ItemsControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/ItemsControllerTests.cs index 23de2489e5..64b9bd8e16 100644 --- a/tests/Jellyfin.Server.Integration.Tests/Controllers/ItemsControllerTests.cs +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/ItemsControllerTests.cs @@ -35,7 +35,7 @@ public sealed class ItemsControllerTests : IClassFixture(_jsonOptions); - Assert.Equal(config.UICulture, newConfig!.UICulture); + Assert.Equal(config.ServerName, newConfig!.ServerName); + Assert.Equal(config.UICulture, newConfig.UICulture); Assert.Equal(config.MetadataCountryCode, newConfig.MetadataCountryCode); Assert.Equal(config.PreferredMetadataLanguage, newConfig.PreferredMetadataLanguage); } @@ -88,9 +90,7 @@ namespace Jellyfin.Server.Integration.Tests.Controllers var newUser = await getResponse.Content.ReadFromJsonAsync(_jsonOptions); Assert.NotNull(newUser); Assert.Equal(user.Name, newUser.Name); - Assert.NotNull(newUser.Password); - Assert.NotEmpty(newUser.Password); - Assert.NotEqual(user.Password, newUser.Password); + Assert.Null(newUser.Password); } [Fact] diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/UserControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/UserControllerTests.cs index 4fcacd2cad..16c63ed49e 100644 --- a/tests/Jellyfin.Server.Integration.Tests/Controllers/UserControllerTests.cs +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/UserControllerTests.cs @@ -20,7 +20,7 @@ namespace Jellyfin.Server.Integration.Tests.Controllers private const string TestUsername = "testUser01"; private readonly JellyfinApplicationFactory _factory; - private readonly JsonSerializerOptions _jsonOpions = JsonDefaults.Options; + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; private static string? _accessToken; private static Guid _testUserId = Guid.Empty; @@ -30,10 +30,10 @@ namespace Jellyfin.Server.Integration.Tests.Controllers } private Task CreateUserByName(HttpClient httpClient, CreateUserByName request) - => httpClient.PostAsJsonAsync("Users/New", request, _jsonOpions); + => httpClient.PostAsJsonAsync("Users/New", request, _jsonOptions); private Task UpdateUserPassword(HttpClient httpClient, Guid userId, UpdateUserPassword request) - => httpClient.PostAsJsonAsync("Users/" + userId.ToString("N", CultureInfo.InvariantCulture) + "/Password", request, _jsonOpions); + => httpClient.PostAsJsonAsync("Users/" + userId.ToString("N", CultureInfo.InvariantCulture) + "/Password", request, _jsonOptions); [Fact] [Priority(-1)] @@ -43,7 +43,7 @@ namespace Jellyfin.Server.Integration.Tests.Controllers using var response = await client.GetAsync("Users/Public"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var users = await response.Content.ReadFromJsonAsync(_jsonOpions); + var users = await response.Content.ReadFromJsonAsync(_jsonOptions); // User are hidden by default Assert.NotNull(users); Assert.Empty(users); @@ -58,7 +58,7 @@ namespace Jellyfin.Server.Integration.Tests.Controllers using var response = await client.GetAsync("Users"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var users = await response.Content.ReadFromJsonAsync(_jsonOpions); + var users = await response.Content.ReadFromJsonAsync(_jsonOptions); Assert.NotNull(users); Assert.Single(users); Assert.False(users![0].HasConfiguredPassword); @@ -90,7 +90,7 @@ namespace Jellyfin.Server.Integration.Tests.Controllers using var response = await CreateUserByName(client, createRequest); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var user = await response.Content.ReadFromJsonAsync(_jsonOpions); + var user = await response.Content.ReadFromJsonAsync(_jsonOptions); Assert.Equal(TestUsername, user!.Name); Assert.False(user.HasPassword); Assert.False(user.HasConfiguredPassword); @@ -151,7 +151,7 @@ namespace Jellyfin.Server.Integration.Tests.Controllers Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); var users = await JsonSerializer.DeserializeAsync( - await client.GetStreamAsync("Users"), _jsonOpions); + await client.GetStreamAsync("Users"), _jsonOptions); var user = users!.First(x => x.Id.Equals(_testUserId)); Assert.True(user.HasPassword); Assert.True(user.HasConfiguredPassword); @@ -174,7 +174,7 @@ namespace Jellyfin.Server.Integration.Tests.Controllers Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); var users = await JsonSerializer.DeserializeAsync( - await client.GetStreamAsync("Users"), _jsonOpions); + await client.GetStreamAsync("Users"), _jsonOptions); var user = users!.First(x => x.Id.Equals(_testUserId)); Assert.False(user.HasPassword); Assert.False(user.HasConfiguredPassword); diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/UserLibraryControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/UserLibraryControllerTests.cs index 130281c6d2..98ad28f5bd 100644 --- a/tests/Jellyfin.Server.Integration.Tests/Controllers/UserLibraryControllerTests.cs +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/UserLibraryControllerTests.cs @@ -23,7 +23,7 @@ public sealed class UserLibraryControllerTests : IClassFixture(); appHost.ServiceProvider = host.Services; - appHost.InitializeServices().GetAwaiter().GetResult(); + appHost.InitializeServices(Mock.Of()).GetAwaiter().GetResult(); host.Start(); appHost.RunStartupTasksAsync().GetAwaiter().GetResult(); diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/EpisodeNfoProviderTests.cs b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/EpisodeNfoProviderTests.cs index 12d6e1934d..a04b37f215 100644 --- a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/EpisodeNfoProviderTests.cs +++ b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/EpisodeNfoProviderTests.cs @@ -26,7 +26,7 @@ namespace Jellyfin.XbmcMetadata.Tests.Parsers var providerManager = new Mock(); var imdbExternalId = new ImdbExternalId(); - var externalIdInfo = new ExternalIdInfo(imdbExternalId.ProviderName, imdbExternalId.Key, imdbExternalId.Type, imdbExternalId.UrlFormatString); + var externalIdInfo = new ExternalIdInfo(imdbExternalId.ProviderName, imdbExternalId.Key, imdbExternalId.Type); providerManager.Setup(x => x.GetExternalIdInfos(It.IsAny())) .Returns(new[] { externalIdInfo }); @@ -85,7 +85,7 @@ namespace Jellyfin.XbmcMetadata.Tests.Parsers Assert.Contains("Bryan Fuller", writers.Select(x => x.Name)); Assert.Contains("Michael Green", writers.Select(x => x.Name)); - // Direcotrs + // Directors var directors = result.People.Where(x => x.Type == PersonKind.Director).ToArray(); Assert.Single(directors); Assert.Contains("David Slade", directors.Select(x => x.Name)); diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs index 075c70da88..e422eb9b8b 100644 --- a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs +++ b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs @@ -1,8 +1,8 @@ using System; using System.Linq; using System.Threading; -using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; @@ -34,7 +34,7 @@ namespace Jellyfin.XbmcMetadata.Tests.Parsers var providerManager = new Mock(); var tmdbExternalId = new TmdbMovieExternalId(); - var externalIdInfo = new ExternalIdInfo(tmdbExternalId.ProviderName, tmdbExternalId.Key, tmdbExternalId.Type, tmdbExternalId.UrlFormatString); + var externalIdInfo = new ExternalIdInfo(tmdbExternalId.ProviderName, tmdbExternalId.Key, tmdbExternalId.Type); providerManager.Setup(x => x.GetExternalIdInfos(It.IsAny())) .Returns(new[] { externalIdInfo }); @@ -149,7 +149,7 @@ namespace Jellyfin.XbmcMetadata.Tests.Parsers Assert.Equal(new DateTime(2019, 8, 6, 9, 1, 18), item.DateCreated); // userData - var userData = _userDataManager.GetUserData(_testUser, item); + var userData = _userDataManager.GetUserData(_testUser, item)!; Assert.Equal(2, userData.PlayCount); Assert.True(userData.Played); Assert.Equal(new DateTime(2021, 02, 11, 07, 47, 23), userData.LastPlayedDate); @@ -257,5 +257,23 @@ namespace Jellyfin.XbmcMetadata.Tests.Parsers Assert.Throws(() => _parser.Fetch(result, string.Empty, CancellationToken.None)); } + + [Fact] + public void Parsing_Fields_With_Escaped_Xml_Special_Characters_Success() + { + var result = new MetadataResult