diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 029a48f6a1..e7d991d94a 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "dotnet-ef": { - "version": "9.0.11", + "version": "10.0.11", "commands": [ "dotnet-ef" ] diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 8b6b12c31e..4ce0d7583a 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,17 +1,31 @@ { "name": "Development Jellyfin Server", - "image": "mcr.microsoft.com/devcontainers/dotnet:9.0-bookworm", + "image": "mcr.microsoft.com/devcontainers/dotnet:10.0-noble", "service": "app", "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", // restores nuget packages, installs the dotnet workloads and installs the dev https certificate "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", + // The previous way of installing extensions via the vs command dont work on selfhosted devcontainers + "customizations": { + "vscode": { + "extensions": [ + "ms-dotnettools.csharp", + "editorconfig.editorconfig", + "github.vscode-github-actions", + "ms-dotnettools.vscode-dotnet-runtime", + "ms-dotnettools.csdevkit", + "alexcvzz.vscode-sqlite", + "streetsidesoftware.code-spell-checker", + "eamodio.gitlens", + "redhat.vscode-xml" + ] + } + }, "features": { "ghcr.io/devcontainers/features/dotnet:2": { "version": "none", - "dotnetRuntimeVersions": "9.0", - "aspNetCoreRuntimeVersions": "9.0" + "dotnetRuntimeVersions": "10.0", + "aspNetCoreRuntimeVersions": "10.0" }, "ghcr.io/devcontainers-extra/features/apt-packages:1": { "preserve_apt_list": false, @@ -19,7 +33,7 @@ "libfontconfig1" ] }, - "ghcr.io/devcontainers/features/docker-in-docker:2": { + "ghcr.io/devcontainers/features/docker-in-docker:3": { "dockerDashComposeVersion": "v2" }, "ghcr.io/devcontainers/features/github-cli:1": {}, diff --git a/.devcontainer/install-ffmpeg.sh b/.devcontainer/install-ffmpeg.sh index 1e58e6ef44..1344634630 100644 --- a/.devcontainer/install-ffmpeg.sh +++ b/.devcontainer/install-ffmpeg.sh @@ -15,7 +15,7 @@ sudo apt-get install software-properties-common -y sudo add-apt-repository universe -y sudo mkdir -p /etc/apt/keyrings -curl -fsSL https://repo.jellyfin.org/jellyfin_team.gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/jellyfin.gpg +curl -fsSL https://repo.jellyfin.org/jellyfin_team.gpg.key | sudo gpg --batch --yes --dearmor -o /etc/apt/keyrings/jellyfin.gpg export VERSION_OS="$( awk -F'=' '/^ID=/{ print $NF }' /etc/os-release )" export VERSION_CODENAME="$( awk -F'=' '/^VERSION_CODENAME=/{ print $NF }' /etc/os-release )" export DPKG_ARCHITECTURE="$( dpkg --print-architecture )" diff --git a/.editorconfig b/.editorconfig index 313b02563d..fa679f1200 100644 --- a/.editorconfig +++ b/.editorconfig @@ -379,6 +379,9 @@ dotnet_diagnostic.CA1720.severity = suggestion # disable warning CA1724: Type names should not match namespaces dotnet_diagnostic.CA1724.severity = suggestion +# disable warning CA1873: Avoid potentially expensive logging +dotnet_diagnostic.CA1873.severity = suggestion + # disable warning CA1805: Do not initialize unnecessarily dotnet_diagnostic.CA1805.severity = suggestion @@ -400,6 +403,10 @@ dotnet_diagnostic.CA1861.severity = suggestion # disable warning CA2000: Dispose objects before losing scope dotnet_diagnostic.CA2000.severity = suggestion +# TODO: Reevaluate when false positives are fixed: https://github.com/dotnet/roslyn-analyzers/issues/7699 +# disable warning CA2025: Do not pass 'IDisposable' instances into unawaited tasks +dotnet_diagnostic.CA2025.severity = suggestion + # disable warning CA2253: Named placeholders should not be numeric values dotnet_diagnostic.CA2253.severity = suggestion diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e1900d5833..ef81678ddd 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,4 +1,11 @@ -# Joshua must review all changes to deployment and build.sh -.ci/* @joshuaboniface -deployment/* @joshuaboniface -build.sh @joshuaboniface +# Joshua must review all changes to bump_version and any files it touches +bump_version @joshuaboniface +.github/ISSUE_TEMPLATE @joshuaboniface +MediaBrowser.Common/MediaBrowser.Common.csproj @joshuaboniface +Jellyfin.Data/Jellyfin.Data.csproj @joshuaboniface +MediaBrowser.Controller/MediaBrowser.Controller.csproj @joshuaboniface +MediaBrowser.Model/MediaBrowser.Model.csproj @joshuaboniface +Emby.Naming/Emby.Naming.csproj @joshuaboniface +src/Jellyfin.Extensions/Jellyfin.Extensions.csproj @joshuaboniface +# Core must approve all changes within the repo config +.github/ @jellyfin/core diff --git a/.github/ISSUE_TEMPLATE/issue report.yml b/.github/ISSUE_TEMPLATE/issue report.yml index 2699688395..7a00dedbff 100644 --- a/.github/ISSUE_TEMPLATE/issue report.yml +++ b/.github/ISSUE_TEMPLATE/issue report.yml @@ -87,7 +87,12 @@ body: label: Jellyfin Server version description: What version of Jellyfin are you using? options: - - 10.10.0+ + - 10.11.11 + - 10.11.10 + - 10.11.9 + - 10.11.8 + - 10.11.7 + - 10.11.6 - Master - Unstable - Older* @@ -136,13 +141,14 @@ body: - **FFmpeg Version**: [e.g. 5.1.2-Jellyfin] - **Playback**: [Direct Play, Remux, Direct Stream, Transcode] - **Hardware Acceleration**: [e.g. none, VAAPI, NVENC, etc.] + - **CPU Model**: [e.g. AMD Ryzen 5 9600X, Intel Core i7-8565U, etc.] - **GPU Model**: [e.g. none, UHD630, GTX1050, etc.] - **Installed Plugins**: [e.g. none, Fanart, Anime, etc.] - **Reverse Proxy**: [e.g. none, nginx, apache, etc.] - **Base URL**: [e.g. none, yes: /example] - **Networking**: [e.g. Host, Bridge/NAT] - - **Jellyfin Data Storage**: [e.g. local SATA SSD, local HDD] - - **Media Storage**: [e.g. Local HDD, SMB Share] + - **Jellyfin Data Storage & Filesystem**: [e.g. local SATA SSD - ext4, local HDD - NTFS] + - **Media Storage & Filesystem**: [e.g. Local HDD - ext4, SMB Share] - **External Integrations**: [e.g. Jellystat, Jellyseerr] value: | - OS: @@ -153,13 +159,14 @@ body: - FFmpeg Version: - Playback Method: - Hardware Acceleration: + - CPU Model: - GPU Model: - Plugins: - Reverse Proxy: - Base URL: - Networking: - - Jellyfin Data Storage: - - Media Storage: + - Jellyfin Data Storage & Filesystem: + - Media Storage & Filesystem: - External Integrations: render: markdown validations: diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index dc93d2c84e..d6833ea2be 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,11 +1,15 @@ **Changes** +**Code assistance** + + **Issues** diff --git a/.gitignore b/.gitignore index d5a0367bff..381c15909d 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ local.properties .classpath .settings/ .loadpath +*.lscache # External tool builders .externalToolBuilders/ @@ -277,3 +278,7 @@ apiclient/generated # Omnisharp crash logs mono_crash.*.json + +# Devcontainer temp files +.devcontainer/devcontainer-lock.json +dotnet/ diff --git a/.vscode/launch.json b/.vscode/launch.json index d97d8de843..681f068b9b 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/net9.0/jellyfin.dll", + "program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net10.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/net9.0/jellyfin.dll", + "program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net10.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/net9.0/jellyfin.dll", + "program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net10.0/jellyfin.dll", "args": ["--nowebclient", "--ffmpeg", "/usr/lib/jellyfin-ffmpeg/ffmpeg"], "cwd": "${workspaceFolder}/Jellyfin.Server", "console": "internalConsole", diff --git a/.woodpecker/ci.yaml b/.woodpecker/ci.yaml index 5c31e73a8f..f523258e10 100644 --- a/.woodpecker/ci.yaml +++ b/.woodpecker/ci.yaml @@ -3,15 +3,29 @@ when: - event: push steps: - # Restore, build and test the full solution. global.json pins the .NET 9 - # SDK (9.0.0, rollForward latestMinor), so build on the 9.0 SDK image. + # Restore, build and test in one step so the NuGet cache stays on the step's + # own ephemeral storage instead of the 10Gi workspace volume. + # global.json pins the .NET 10 SDK (10.0.0, rollForward latestMinor). - name: build-test - image: mcr.microsoft.com/dotnet/sdk:9.0 + image: mcr.microsoft.com/dotnet/sdk:10.0 + environment: + DOTNET_CLI_TELEMETRY_OPTOUT: "1" + DOTNET_NOLOGO: "1" commands: - dotnet --info - dotnet restore Jellyfin.sln - dotnet build Jellyfin.sln -c Release --no-restore - - dotnet test Jellyfin.sln -c Release --no-build --verbosity minimal --filter "Category!=RequiresDocker&FullyQualifiedName!~Integration" + # libSkiaSharp needs fontconfig to load and the SDK image does not ship it. + # The mirror occasionally serves a half-synced index, so update is retried. + - apt-get -o Acquire::Retries=3 update || apt-get -o Acquire::Retries=3 update + - apt-get install -y --no-install-recommends libfontconfig1 + - ldconfig -p | grep -q libfontconfig + # BackupServiceTests is excluded because BackupService refuses to write a + # backup with less than 5GiB free on its target path. The clone and build + # output fill 5.7G of the 9.8G workspace volume, leaving 4.1G. Raising the + # agent's workspace volume size is the only way to run it here. + - df -h . + - dotnet test Jellyfin.sln -c Release --no-build --verbosity minimal --filter "Category!=RequiresDocker&FullyQualifiedName!~Integration&FullyQualifiedName!~BackupServiceTests" backend_options: kubernetes: serviceAccountName: jellyfin-ha-src @@ -19,6 +33,8 @@ steps: requests: memory: 2Gi cpu: 2 + ephemeral-storage: 10Gi limits: - memory: 6Gi + memory: 8Gi cpu: 4 + ephemeral-storage: 20Gi diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 3b7d6d0a16..a97d335170 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -1,5 +1,6 @@ # Jellyfin Contributors + - [0x25CBFC4F](https://github.com/0x25CBFC4F) - [1337joe](https://github.com/1337joe) - [97carmine](https://github.com/97carmine) - [Abbe98](https://github.com/Abbe98) @@ -14,7 +15,7 @@ - [bilde2910](https://github.com/bilde2910) - [bfayers](https://github.com/bfayers) - [BnMcG](https://github.com/BnMcG) - - [Bond-009](https://github.com/Bond-009) + - [Bond_009](https://github.com/Bond-009) - [brianjmurrell](https://github.com/brianjmurrell) - [bugfixin](https://github.com/bugfixin) - [chaosinnovator](https://github.com/chaosinnovator) @@ -31,6 +32,7 @@ - [DaveChild](https://github.com/DaveChild) - [DavidFair](https://github.com/DavidFair) - [Delgan](https://github.com/Delgan) + - [DerMaddis](https://github.com/dermaddis) - [Derpipose](https://github.com/Derpipose) - [dcrdev](https://github.com/dcrdev) - [dhartung](https://github.com/dhartung) @@ -54,6 +56,7 @@ - [geilername](https://github.com/geilername) - [GermanCoding](https://github.com/GermanCoding) - [gnattu](https://github.com/gnattu) + - [gnuyent](https://github.com/gnuyent) - [GodTamIt](https://github.com/GodTamIt) - [grafixeyehero](https://github.com/grafixeyehero) - [h1nk](https://github.com/h1nk) @@ -61,6 +64,7 @@ - [HelloWorld017](https://github.com/HelloWorld017) - [ikomhoog](https://github.com/ikomhoog) - [iwalton3](https://github.com/iwalton3) + - [Jakob Kukla](https://github.com/jakobkukla) - [jftuga](https://github.com/jftuga) - [jkhsjdhjs](https://github.com/jkhsjdhjs) - [jmshrv](https://github.com/jmshrv) @@ -69,8 +73,10 @@ - [JustAMan](https://github.com/JustAMan) - [justinfenn](https://github.com/justinfenn) - [JPVenson](https://github.com/JPVenson) + - [JPUC1143](https://github.com/Jpuc1143/) - [KerryRJ](https://github.com/KerryRJ) - [Larvitar](https://github.com/Larvitar) + - [lbenini](https://github.com/lbenini) - [LeoVerto](https://github.com/LeoVerto) - [Liggy](https://github.com/Liggy) - [lmaonator](https://github.com/lmaonator) @@ -83,15 +89,20 @@ - [marius-luca-87](https://github.com/marius-luca-87) - [mark-monteiro](https://github.com/mark-monteiro) - [MarkCiliaVincenti](https://github.com/MarkCiliaVincenti) + - [Martin Reuter](https://github.com/reuterma24) + - [Matt Teahan](https://github.com/matt-teahan) - [Matt07211](https://github.com/Matt07211) + - [Matthew Jones](https://github.com/matthew-jones-uk) - [Maxr1998](https://github.com/Maxr1998) - [mcarlton00](https://github.com/mcarlton00) + - [Michael McElroy](https://github.com/mcmcelro) - [mitchfizz05](https://github.com/mitchfizz05) - [mohd-akram](https://github.com/mohd-akram) - [MrTimscampi](https://github.com/MrTimscampi) - [n8225](https://github.com/n8225) - [Nalsai](https://github.com/Nalsai) - [Narfinger](https://github.com/Narfinger) + - [Nathan McCrina](https://github.com/nfmccrina) - [NathanPickard](https://github.com/NathanPickard) - [neilsb](https://github.com/neilsb) - [nevado](https://github.com/nevado) @@ -102,16 +113,20 @@ - [OancaAndrei](https://github.com/OancaAndrei) - [obradovichv](https://github.com/obradovichv) - [oddstr13](https://github.com/oddstr13) + - [olsh](https://github.com/olsh) - [orryverducci](https://github.com/orryverducci) + - [PCEWLKR](https://github.com/PCEWLKR) - [petermcneil](https://github.com/petermcneil) - [Phlogi](https://github.com/Phlogi) - [pjeanjean](https://github.com/pjeanjean) - [ploughpuff](https://github.com/ploughpuff) + - [poytiis](https://github.com/poytiis) - [pR0Ps](https://github.com/pR0Ps) - [PrplHaz4](https://github.com/PrplHaz4) - [RazeLighter777](https://github.com/RazeLighter777) - [redSpoutnik](https://github.com/redSpoutnik) - [ringmatter](https://github.com/ringmatter) + - [Robert Lützner](https://github.com/rluetzner) - [ryan-hartzell](https://github.com/ryan-hartzell) - [s0urcelab](https://github.com/s0urcelab) - [sachk](https://github.com/sachk) @@ -127,6 +142,8 @@ - [sl1288](https://github.com/sl1288) - [Smith00101010](https://github.com/Smith00101010) - [sorinyo2004](https://github.com/sorinyo2004) + - [Soumyadip Auddy](https://github.com/SoumyadipAuddy) + - [st7105](https://github.com/st7105) - [sparky8251](https://github.com/sparky8251) - [spookbits](https://github.com/spookbits) - [ssenart](https://github.com/ssenart) @@ -149,12 +166,14 @@ - [twinkybot](https://github.com/twinkybot) - [Ullmie02](https://github.com/Ullmie02) - [Unhelpful](https://github.com/Unhelpful) + - [Utku Özdemir](https://github.com/utkuozdemir) - [viaregio](https://github.com/viaregio) - [vitorsemeano](https://github.com/vitorsemeano) - [voodoos](https://github.com/voodoos) - [whooo](https://github.com/whooo) - [WiiPlayer2](https://github.com/WiiPlayer2) - [WillWill56](https://github.com/WillWill56) + - [WizardOfYendor1](https://github.com/WizardOfYendor1) - [wtayl0r](https://github.com/wtayl0r) - [Wuerfelbecher](https://github.com/Wuerfelbecher) - [Wunax](https://github.com/Wunax) @@ -164,6 +183,7 @@ - [XVicarious](https://github.com/XVicarious) - [YouKnowBlom](https://github.com/YouKnowBlom) - [ZachPhelan](https://github.com/ZachPhelan) + - [ZeusCraft10](https://github.com/ZeusCraft10) - [KristupasSavickas](https://github.com/KristupasSavickas) - [Pusta](https://github.com/pusta) - [nielsvanvelzen](https://github.com/nielsvanvelzen) @@ -206,8 +226,21 @@ - [theshoeshiner](https://github.com/theshoeshiner) - [TokerX](https://github.com/TokerX) - [GeneMarks](https://github.com/GeneMarks) + - [Kirill Nikiforov](https://github.com/allmazz) + - [bjorntp](https://github.com/bjorntp) - [martenumberto](https://github.com/martenumberto) + - [ZeusCraft10](https://github.com/ZeusCraft10) - [MarcoCoreDuo](https://github.com/MarcoCoreDuo) + - [LiHRaM](https://github.com/LiHRaM) + - [MSalman5230](https://github.com/MSalman5230) + - [dwandw](https://github.com/dwandw) + - [Lampan-git](https://github.com/Lampan-git) + - [elio42](https://github.com/elio42) + - [rwebster85](https://github.com/rwebster85) + - [Florin-Popescu](https://github.com/Florin-Popescu) + - [m0g3r](https://github.com/m0g3r) + - [martin-77](https://github.com/martin-77) + - [Oggeb1](https://github.com/Oggeb1) # Emby Contributors @@ -218,7 +251,7 @@ - [Mark2xv](https://github.com/Mark2xv) - [ScottRapsey](https://github.com/ScottRapsey) - [skynet600](https://github.com/skynet600) - - [Cheesegeezer](https://githum.com/Cheesegeezer) + - [Cheesegeezer](https://github.com/Cheesegeezer) - [Radeon](https://github.com/radeonorama) - [gcw07](https://github.com/gcw07) - [SivaramAdhiappan](https://github.com/shivaram1190) @@ -271,16 +304,3 @@ - [tikuf](https://github.com/tikuf/) - [Tim Hobbs](https://github.com/timhobbs) - [SvenVandenbrande](https://github.com/SvenVandenbrande) - - [olsh](https://github.com/olsh) - - [lbenini](https://github.com/lbenini) - - [gnuyent](https://github.com/gnuyent) - - [Matthew Jones](https://github.com/matthew-jones-uk) - - [Jakob Kukla](https://github.com/jakobkukla) - - [Utku Özdemir](https://github.com/utkuozdemir) - - [JPUC1143](https://github.com/Jpuc1143/) - - [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) - - [Soumyadip Auddy](https://github.com/SoumyadipAuddy) diff --git a/Directory.Packages.props b/Directory.Packages.props index a59a3423cd..28376ff47d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,101 +4,94 @@ - - + - + + - + - - + + - - + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + - - + - + + + - - + + - + - - - - - + + + + + + - - - - - - - - + + + + - - - - - - - - - + + + + + + + + diff --git a/Dockerfile b/Dockerfile index 508c3c5e16..1e4459735f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -44,9 +44,9 @@ RUN dotnet publish Jellyfin.Server/Jellyfin.Server.csproj \ # ── Web client stage ────────────────────────────────────────────────────────── # Install jellyfin-web via the official Jellyfin apt repo. -# Package suffix in the bookworm repo is +deb12 (e.g. 10.11.6+deb12). +# Package suffix in the bookworm repo is +deb12 (e.g. 12.0+deb12). # Web assets land at /usr/share/jellyfin/web/ — stable, prebuilt, no npm required. -# 10.11.6 is the latest stable web client; API-compatible with the 10.12.0 server. +# The web client version must match the server version this fork is based on. FROM --platform=linux/amd64 debian:bookworm-slim AS webclient RUN apt-get update \ @@ -56,7 +56,7 @@ RUN apt-get update \ && echo "deb [arch=amd64 signed-by=/usr/share/keyrings/jellyfin.gpg] https://repo.jellyfin.org/debian bookworm main" \ > /etc/apt/sources.list.d/jellyfin.list \ && apt-get update \ - && apt-get install -y --no-install-recommends "jellyfin-web=10.11.6+deb12" \ + && apt-get install -y --no-install-recommends "jellyfin-web=12.0+deb12" \ && rm -rf /var/lib/apt/lists/* \ && echo "Web client files:" && ls /usr/share/jellyfin/web/ | head -10 diff --git a/Dockerfile.runtime b/Dockerfile.runtime index 3147cb2ae0..36f4a2b00b 100644 --- a/Dockerfile.runtime +++ b/Dockerfile.runtime @@ -4,9 +4,9 @@ # ── Web client stage ────────────────────────────────────────────────────────── # Install jellyfin-web via the official Jellyfin apt repo. -# Package suffix in the bookworm repo is +deb12 (e.g. 10.11.6+deb12). +# Package suffix in the bookworm repo is +deb12 (e.g. 12.0+deb12). # Web assets land at /usr/share/jellyfin/web/ — stable, prebuilt, no npm required. -# 10.11.6 is the latest stable web client; API-compatible with the 10.12.0 server. +# The web client version must match the server version this fork is based on. FROM --platform=linux/amd64 debian:bookworm-slim AS webclient RUN apt-get update \ @@ -16,7 +16,7 @@ RUN apt-get update \ && echo "deb [arch=amd64 signed-by=/usr/share/keyrings/jellyfin.gpg] https://repo.jellyfin.org/debian bookworm main" \ > /etc/apt/sources.list.d/jellyfin.list \ && apt-get update \ - && apt-get install -y --no-install-recommends "jellyfin-web=10.11.6+deb12" \ + && apt-get install -y --no-install-recommends "jellyfin-web=12.0+deb12" \ && rm -rf /var/lib/apt/lists/* # ── Runtime stage ───────────────────────────────────────────────────────────── diff --git a/Emby.Naming/AudioBook/AudioBookNameParserResult.cs b/Emby.Naming/AudioBook/AudioBookNameParserResult.cs index 3f2d7b2b0b..de78e75a91 100644 --- a/Emby.Naming/AudioBook/AudioBookNameParserResult.cs +++ b/Emby.Naming/AudioBook/AudioBookNameParserResult.cs @@ -1,3 +1,5 @@ +#pragma warning disable CA1815 + namespace Emby.Naming.AudioBook { /// diff --git a/Emby.Naming/Book/BookFileNameParser.cs b/Emby.Naming/Book/BookFileNameParser.cs new file mode 100644 index 0000000000..080e25969b --- /dev/null +++ b/Emby.Naming/Book/BookFileNameParser.cs @@ -0,0 +1,94 @@ +using System; +using System.Text.RegularExpressions; + +namespace Emby.Naming.Book +{ + /// + /// Helper class to retrieve basic metadata from a book filename. + /// + public static partial class BookFileNameParser + { + private const string NameMatchGroup = "name"; + private const string IndexMatchGroup = "index"; + private const string YearMatchGroup = "year"; + private const string SeriesNameMatchGroup = "seriesName"; + + private static readonly Regex[] _nameMatches = + [ + // seriesName (seriesYear) #index (of count) (year) where only seriesName and index are required + new Regex(@"^(?.+?)((\s\((?[0-9]{4})\))?)\s#(?[0-9]+)(?:\.0)?((\s\(of\s(?[0-9]+)\))?)((\s\((?[0-9]{4})\))?)$"), + new Regex(@"^(?.+?)\s\((?.+?),\s#(?[0-9]+)\)(?:\.0)?((\s\((?[0-9]{4})\))?)$"), + new Regex(@"^(?[0-9]+)(?:\.0)?\s\-\s(?.+?)((\s\((?[0-9]{4})\))?)$"), + new Regex(@"(?.*)\((?[0-9]{4})\)"), + // last resort matches the whole string as the name + new Regex(@"(?.*)") + ]; + + [GeneratedRegex(@"^(?.+?)(\sv(?[0-9]+))?(\sc(?[0-9]+))?$")] + private static partial Regex ComicRegex(); + + /// + /// Parse a filename name to retrieve the book name, series name, index, and year. + /// + /// Book filename to parse for information. + /// Returns object. + public static BookFileNameParserResult Parse(string? name) + { + var result = new BookFileNameParserResult(); + + if (name == null) + { + return result; + } + + foreach (var regex in _nameMatches) + { + var match = regex.Match(name); + + if (!match.Success) + { + continue; + } + + if (match.Groups.TryGetValue(NameMatchGroup, out Group? nameGroup) && nameGroup.Success) + { + var comicMatch = ComicRegex().Match(nameGroup.Value.Trim()); + + if (comicMatch.Success) + { + if (comicMatch.Groups.TryGetValue("volume", out Group? volumeGroup) && volumeGroup.Success && int.TryParse(volumeGroup.ValueSpan, out var volume)) + { + result.ParentIndex = volume; + } + + if (comicMatch.Groups.TryGetValue("chapter", out Group? chapterGroup) && chapterGroup.Success && int.TryParse(chapterGroup.ValueSpan, out var chapter)) + { + result.Index = chapter; + } + } + + result.Name = nameGroup.ValueSpan.Trim().ToString(); + } + + if (match.Groups.TryGetValue(IndexMatchGroup, out Group? indexGroup) && indexGroup.Success && int.TryParse(indexGroup.Value, out var index)) + { + result.Index = index; + } + + if (match.Groups.TryGetValue(YearMatchGroup, out Group? yearGroup) && yearGroup.Success && int.TryParse(yearGroup.Value, out var year)) + { + result.Year = year; + } + + if (match.Groups.TryGetValue(SeriesNameMatchGroup, out Group? seriesGroup) && seriesGroup.Success) + { + result.SeriesName = seriesGroup.Value.Trim(); + } + + break; + } + + return result; + } + } +} diff --git a/Emby.Naming/Book/BookFileNameParserResult.cs b/Emby.Naming/Book/BookFileNameParserResult.cs new file mode 100644 index 0000000000..f313b202c5 --- /dev/null +++ b/Emby.Naming/Book/BookFileNameParserResult.cs @@ -0,0 +1,45 @@ +namespace Emby.Naming.Book +{ + /// + /// Data object used to pass metadata parsed from a book filename. + /// + public class BookFileNameParserResult + { + /// + /// Initializes a new instance of the class. + /// + public BookFileNameParserResult() + { + Name = null; + Index = null; + ParentIndex = null; + Year = null; + SeriesName = null; + } + + /// + /// Gets or sets the name of the book. + /// + public string? Name { get; set; } + + /// + /// Gets or sets the book index. + /// + public int? Index { get; set; } + + /// + /// Gets or sets the parent index number. + /// + public int? ParentIndex { get; set; } + + /// + /// Gets or sets the publication year. + /// + public int? Year { get; set; } + + /// + /// Gets or sets the series name. + /// + public string? SeriesName { get; set; } + } +} diff --git a/Emby.Naming/Common/NamingOptions.cs b/Emby.Naming/Common/NamingOptions.cs index f61ca7e129..9ecefb4c7b 100644 --- a/Emby.Naming/Common/NamingOptions.cs +++ b/Emby.Naming/Common/NamingOptions.cs @@ -57,7 +57,6 @@ namespace Emby.Naming.Common ".nrg", ".nsv", ".nuv", - ".ogg", ".ogm", ".ogv", ".pva", @@ -152,8 +151,8 @@ namespace Emby.Naming.Common CleanStrings = [ - @"^\s*(?.+?)[ _\,\.\(\)\[\]\-](3d|sbs|tab|hsbs|htab|mvc|HDR|HDC|UHD|UltraHD|4k|ac3|dts|custom|dc|divx|divx5|dsr|dsrip|dutch|dvd|dvdrip|dvdscr|dvdscreener|screener|dvdivx|cam|fragment|fs|hdtv|hdrip|hdtvrip|internal|limited|multi|subs|ntsc|ogg|ogm|pal|pdtv|proper|repack|rerip|retail|cd[1-9]|r5|bd5|bd|se|svcd|swedish|german|read.nfo|nfofix|unrated|ws|telesync|ts|telecine|tc|brrip|bdrip|480p|480i|576p|576i|720p|720i|1080p|1080i|2160p|hrhd|hrhdtv|hddvd|bluray|blu-ray|x264|x265|h264|h265|xvid|xvidvd|xxx|www.www|AAC|DTS|\[.*\])([ _\,\.\(\)\[\]\-]|$)", - @"^(?.+?)(\[.*\])", + @"^\s*(?.+?)[ _\,\.\(\)\[\]\-](3d|sbs|tab|hsbs|htab|mvc|HDR|HDC|UHD|UltraHD|4k|ac3|dts|custom|dc|divx|divx5|dsr|dsrip|dutch|dvd|dvdrip|dvdscr|dvdscreener|screener|dvdivx|cam|fragment|fs|hdtv|hdrip|hdtvrip|internal|limited|multi|subs|ntsc|ogg|ogm|pal|pdtv|proper|repack|rerip|retail|cd[1-9]|r5|bd5|bd|se|svcd|swedish|german|read.nfo|nfofix|unrated|ws|web-dl|telesync|ts|telecine|tc|brrip|bdrip|480p|480i|576p|576i|720p|720i|1080p|1080i|2160p|hrhd|hrhdtv|hddvd|bluray|blu-ray|x264|x265|h264|h265|xvid|xvidvd|xxx|www.www|AAC|DTS)(?=[ _\,\.\(\)\[\]\-]|$)", + @"^\s*(?.+?)((\s*\[[^\]]+\]\s*)+)(\.[^\s]+)?$", @"^\s*(?.+?)\WE[0-9]+(-|~)E?[0-9]+(\W|$)", @"^\s*\[[^\]]+\](?!\.\w+$)\s*(?.+)", @"^\s*(?.+?)\s+-\s+[0-9]+\s*$", @@ -225,6 +224,7 @@ namespace Emby.Naming.Common ".afc", ".amf", ".aif", + ".aifc", ".aiff", ".alac", ".amr", @@ -361,7 +361,10 @@ namespace Emby.Naming.Common // Not a Kodi rule as well, but the expression below also causes false positives, // so we make sure this one gets tested first. // "Foo Bar 889" - new EpisodeExpression(@".*[\\\/](?![Ee]pisode)(?[\w\s]+?)\s(?[0-9]{1,4})(-(?[0-9]{2,4}))*[^\\\/x]*$") + // Names carrying an SxxEyy marker are excluded because the Kodi expression above already covers them. + // Without that guard this expression reads digits out of the title instead, turning + // "S01E01 1-23-45 [Bluray-1080p]" into episodes 1 through 45. + new EpisodeExpression(@".*[\\\/](?![Ee]pisode)(?![^\\\/]*[Ss][0-9]+[][ ._-]*[Ee][0-9]+)(?[\w\s]+?)\s(?[0-9]{1,4})(-(?[0-9]{2,4}))*[^\\\/x]*$") { IsNamed = true }, @@ -378,6 +381,14 @@ namespace Emby.Naming.Common IsNamed = true }, + // "Name - 101.mkv", "Name - 101 [720p].mkv", "Name - 101 (2020).mkv" + // Handles absolute episode numbers with hyphen delimiter (common in anime) + // Without brackets (bracketed version handled above) + new EpisodeExpression(@".*[\\\/](?[^\\\/]+?)[\s_]+-[\s_]+(?[0-9]+)[\s_]*(?:\[.*?\]|\(.*?\))*[\s_]*(?:\.\w+)?$") + { + IsNamed = true + }, + // /server/anything_102.mp4 // /server/james.corden.2017.04.20.anne.hathaway.720p.hdtv.x264-crooks.mkv // /server/anything_1996.11.14.mp4 diff --git a/Emby.Naming/Emby.Naming.csproj b/Emby.Naming/Emby.Naming.csproj index 2b0da1963b..9f98970df5 100644 --- a/Emby.Naming/Emby.Naming.csproj +++ b/Emby.Naming/Emby.Naming.csproj @@ -6,7 +6,7 @@ - net9.0 + net10.0 false true true @@ -36,7 +36,7 @@ Jellyfin Contributors Jellyfin.Naming - 10.11.7 + 12.0.0 https://github.com/jellyfin/jellyfin GPL-3.0-only diff --git a/Emby.Naming/ExternalFiles/ExternalPathParser.cs b/Emby.Naming/ExternalFiles/ExternalPathParser.cs index 3461b3c0d6..1f16161282 100644 --- a/Emby.Naming/ExternalFiles/ExternalPathParser.cs +++ b/Emby.Naming/ExternalFiles/ExternalPathParser.cs @@ -44,7 +44,14 @@ namespace Emby.Naming.ExternalFiles } var extension = Path.GetExtension(path.AsSpan()); - if (!(_type == DlnaProfileType.Subtitle && _namingOptions.SubtitleFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase)) + + // .idx carries VobSub per-track language metadata. Recognize it here rather + // than adding it to NamingOptions.SubtitleFileExtensions, which also gates + // subtitle uploads/saves. + var isVobSubIndex = _type == DlnaProfileType.Subtitle && extension.Equals(".idx", StringComparison.OrdinalIgnoreCase); + + if (!isVobSubIndex + && !(_type == DlnaProfileType.Subtitle && _namingOptions.SubtitleFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase)) && !(_type == DlnaProfileType.Audio && _namingOptions.AudioFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase)) && !(_type == DlnaProfileType.Lyric && _namingOptions.LyricFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))) { @@ -70,7 +77,7 @@ namespace Emby.Naming.ExternalFiles if (lastSeparator == -1) { - break; + break; } string currentSlice = languageString[lastSeparator..]; diff --git a/Emby.Naming/TV/EpisodePathParser.cs b/Emby.Naming/TV/EpisodePathParser.cs index 8cd5a126e0..f06aa909ba 100644 --- a/Emby.Naming/TV/EpisodePathParser.cs +++ b/Emby.Naming/TV/EpisodePathParser.cs @@ -125,7 +125,7 @@ namespace Emby.Naming.TV result.Success = true; } } - else if (DateTime.TryParse(match.Groups[0].ValueSpan, out date)) + else if (DateTime.TryParse(match.Groups[0].ValueSpan, CultureInfo.InvariantCulture, out date)) { result.Year = date.Year; result.Month = date.Month; @@ -158,7 +158,9 @@ namespace Emby.Naming.TV if (nextIndex >= name.Length || !"0123456789iIpP".Contains(name[nextIndex], StringComparison.Ordinal)) { - if (int.TryParse(endingNumberGroup.ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out num)) + // A range cannot end before it starts, so a lower number belongs to the episode title rather than to a range. + if (int.TryParse(endingNumberGroup.ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out num) + && num >= result.EpisodeNumber) { result.EndingEpisodeNumber = num; } @@ -226,7 +228,7 @@ namespace Emby.Naming.TV info.SeriesName = result.SeriesName; } - if (!info.EndingEpisodeNumber.HasValue && info.EpisodeNumber.HasValue) + if (!info.EndingEpisodeNumber.HasValue && result.EndingEpisodeNumber >= info.EpisodeNumber) { info.EndingEpisodeNumber = result.EndingEpisodeNumber; } diff --git a/Emby.Naming/TV/SeasonPathParser.cs b/Emby.Naming/TV/SeasonPathParser.cs index 72adfb2d96..9caebaf7ac 100644 --- a/Emby.Naming/TV/SeasonPathParser.cs +++ b/Emby.Naming/TV/SeasonPathParser.cs @@ -10,17 +10,25 @@ namespace Emby.Naming.TV /// public static partial class SeasonPathParser { + private const string SeasonKeywordPattern = + @"시즌|シーズン|сезон" + + @"|season|sæson|saison|staffel|series|stagione|säsong|seizoen|seasong" + + @"|sezon|sezona|sezóna|sezonul|série|séria|serie|seria|temporada|kausi"; + private static readonly Regex CleanNameRegex = new(@"[ ._\-\[\]]", RegexOptions.Compiled); - [GeneratedRegex(@"^\s*((?(?>\d+))(?:st|nd|rd|th|\.)*(?!\s*[Ee]\d+))\s*(?:[[시즌]*|[シーズン]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?.*)$", RegexOptions.IgnoreCase)] + [GeneratedRegex(@"^\s*((?(?>\d+))(?:st|nd|rd|th|\.)*(?!\s*[Ee]\d+))\s*(?:" + SeasonKeywordPattern + @")\s*(?.*)$", RegexOptions.IgnoreCase)] private static partial Regex ProcessPre(); - [GeneratedRegex(@"^\s*(?:[[시즌]*|[シーズン]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?\d+?)(?=\d{3,4}p|[^\d]|$)(?!\s*[Ee]\d)(?.*)$", RegexOptions.IgnoreCase)] + [GeneratedRegex(@"^\s*(?:" + SeasonKeywordPattern + @")\s*(?\d+?)(?=\d{3,4}p|[^\d]|$)(?!\s*[Ee]\d)(?.*)$", RegexOptions.IgnoreCase)] private static partial Regex ProcessPost(); [GeneratedRegex(@"[sS](\d{1,4})(?!\d|[eE]\d)(?=\.|_|-|\[|\]|\s|$)", RegexOptions.None)] private static partial Regex SeasonPrefix(); + [GeneratedRegex(SeasonKeywordPattern, RegexOptions.IgnoreCase)] + private static partial Regex SeasonKeyword(); + /// /// Attempts to parse season number from path. /// @@ -91,14 +99,25 @@ namespace Emby.Naming.TV return (val, true); } + bool isMixedLibrary = !supportNumericSeasonFolders && !supportSpecialAliases; var preMatch = ProcessPre().Match(filename); if (preMatch.Success) { + if (isMixedLibrary && !SeasonKeyword().IsMatch(fileName)) + { + return (null, false); + } + return CheckMatch(preMatch); } else { var postMatch = ProcessPost().Match(filename); + if (postMatch.Success && isMixedLibrary && !SeasonKeyword().IsMatch(fileName)) + { + return (null, false); + } + return CheckMatch(postMatch); } } diff --git a/Emby.Naming/TV/SeriesInfo.cs b/Emby.Naming/TV/SeriesInfo.cs index 5d6cb4bd37..e145ff8003 100644 --- a/Emby.Naming/TV/SeriesInfo.cs +++ b/Emby.Naming/TV/SeriesInfo.cs @@ -25,5 +25,11 @@ namespace Emby.Naming.TV /// /// The name of the series. public string? Name { get; set; } + + /// + /// Gets or sets the year of the series. + /// + /// The year of the series. + public int? Year { get; set; } } } diff --git a/Emby.Naming/TV/SeriesResolver.cs b/Emby.Naming/TV/SeriesResolver.cs index c955b8a0db..733e2418c2 100644 --- a/Emby.Naming/TV/SeriesResolver.cs +++ b/Emby.Naming/TV/SeriesResolver.cs @@ -17,6 +17,13 @@ namespace Emby.Naming.TV [GeneratedRegex(@"((?[^\._]{2,})[\._]*)|([\._](?[^\._]{2,}))")] private static partial Regex SeriesNameRegex(); + /// + /// Regex that matches titles with year in parentheses. Captures the title (which may be + /// numeric) before the year, i.e. turns "1923 (2022)" into "1923". + /// + [GeneratedRegex(@"(?.+?)\s*\((?<year>[0-9]{4})\)")] + private static partial Regex TitleWithYearRegex(); + /// <summary> /// Resolve information about series from path. /// </summary> @@ -27,6 +34,21 @@ namespace Emby.Naming.TV { string seriesName = Path.GetFileName(path); + // First check if the filename matches a title with year pattern (handles numeric titles) + if (!string.IsNullOrEmpty(seriesName)) + { + var titleWithYearMatch = TitleWithYearRegex().Match(seriesName); + if (titleWithYearMatch.Success) + { + seriesName = titleWithYearMatch.Groups["title"].Value.Trim(); + return new SeriesInfo(path) + { + Name = seriesName, + Year = int.TryParse(titleWithYearMatch.Groups["year"].ValueSpan, out var year) ? year : null + }; + } + } + SeriesPathParserResult result = SeriesPathParser.Parse(options, path); if (result.Success) { diff --git a/Emby.Naming/TV/TvParserHelpers.cs b/Emby.Naming/TV/TvParserHelpers.cs index 0299178582..706251f297 100644 --- a/Emby.Naming/TV/TvParserHelpers.cs +++ b/Emby.Naming/TV/TvParserHelpers.cs @@ -18,7 +18,7 @@ public static class TvParserHelpers /// <param name="status">The status string.</param> /// <param name="enumValue">The <see cref="SeriesStatus"/>.</param> /// <returns>Returns true if parsing was successful.</returns> - public static bool TryParseSeriesStatus(string status, out SeriesStatus? enumValue) + public static bool TryParseSeriesStatus(string? status, out SeriesStatus? enumValue) { if (Enum.TryParse(status, true, out SeriesStatus seriesStatus)) { diff --git a/Emby.Naming/Video/CleanDateTimeResult.cs b/Emby.Naming/Video/CleanDateTimeResult.cs index c675a19d0f..e367f92213 100644 --- a/Emby.Naming/Video/CleanDateTimeResult.cs +++ b/Emby.Naming/Video/CleanDateTimeResult.cs @@ -1,3 +1,5 @@ +#pragma warning disable CA1815 + namespace Emby.Naming.Video { /// <summary> diff --git a/Emby.Naming/Video/CleanStringParser.cs b/Emby.Naming/Video/CleanStringParser.cs index a336f8fbd1..f27f8bc0a4 100644 --- a/Emby.Naming/Video/CleanStringParser.cs +++ b/Emby.Naming/Video/CleanStringParser.cs @@ -44,7 +44,7 @@ namespace Emby.Naming.Video var match = expression.Match(name); if (match.Success && match.Groups.TryGetValue("cleaned", out var cleaned)) { - newName = cleaned.Value; + newName = cleaned.Value.Trim(); return true; } diff --git a/Emby.Naming/Video/Format3DParser.cs b/Emby.Naming/Video/Format3DParser.cs index eb5e71d78f..a287a2525b 100644 --- a/Emby.Naming/Video/Format3DParser.cs +++ b/Emby.Naming/Video/Format3DParser.cs @@ -52,13 +52,18 @@ namespace Emby.Naming.Video while (path.Length > 0) { var index = path.IndexOfAny(delimiters); + ReadOnlySpan<char> currentSlice; if (index == -1) { - index = path.Length - 1; + // No delimiter left, the last token is the remainder of the path + currentSlice = path; + path = default; + } + else + { + currentSlice = path[..index]; + path = path[(index + 1)..]; } - - var currentSlice = path[..index]; - path = path[(index + 1)..]; if (!foundPrefix) { diff --git a/Emby.Naming/Video/VideoInfo.cs b/Emby.Naming/Video/VideoInfo.cs index 8847ee9bc9..028b639122 100644 --- a/Emby.Naming/Video/VideoInfo.cs +++ b/Emby.Naming/Video/VideoInfo.cs @@ -17,8 +17,8 @@ namespace Emby.Naming.Video { Name = name; - Files = Array.Empty<VideoFileInfo>(); - AlternateVersions = Array.Empty<VideoFileInfo>(); + Files = []; + AlternateVersions = []; } /// <summary> @@ -40,10 +40,10 @@ namespace Emby.Naming.Video public IReadOnlyList<VideoFileInfo> Files { get; set; } /// <summary> - /// Gets or sets the alternate versions. + /// Gets or sets the alternate versions. Each alternate may itself span multiple files. /// </summary> /// <value>The alternate versions.</value> - public IReadOnlyList<VideoFileInfo> AlternateVersions { get; set; } + public IReadOnlyList<VideoInfo> AlternateVersions { get; set; } /// <summary> /// Gets or sets the extra type. diff --git a/Emby.Naming/Video/VideoListResolver.cs b/Emby.Naming/Video/VideoListResolver.cs index a3134f3f68..29330b132d 100644 --- a/Emby.Naming/Video/VideoListResolver.cs +++ b/Emby.Naming/Video/VideoListResolver.cs @@ -1,10 +1,12 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; using System.Text.RegularExpressions; using Emby.Naming.Common; -using Jellyfin.Extensions; +using Emby.Naming.TV; +using Jellyfin.Data.Enums; using MediaBrowser.Model.IO; namespace Emby.Naming.Video @@ -12,8 +14,23 @@ namespace Emby.Naming.Video /// <summary> /// Resolves alternative versions and extras from list of video files. /// </summary> - public static partial class VideoListResolver + public partial class VideoListResolver { + private static readonly StringComparer _numericOrdinalComparer = StringComparer.Create(CultureInfo.InvariantCulture, CompareOptions.NumericOrdering); + + private readonly NamingOptions _namingOptions; + private readonly EpisodePathParser _episodePathParser; + + /// <summary> + /// Initializes a new instance of the <see cref="VideoListResolver"/> class. + /// </summary> + /// <param name="namingOptions">The naming options.</param> + public VideoListResolver(NamingOptions namingOptions) + { + _namingOptions = namingOptions; + _episodePathParser = new EpisodePathParser(namingOptions); + } + [GeneratedRegex("[0-9]{2}[0-9]+[ip]", RegexOptions.IgnoreCase)] private static partial Regex ResolutionRegex(); @@ -24,12 +41,12 @@ namespace Emby.Naming.Video /// Resolves alternative versions and extras from list of video files. /// </summary> /// <param name="videoInfos">List of related video files.</param> - /// <param name="namingOptions">The naming options.</param> /// <param name="supportMultiVersion">Indication we should consider multi-versions of content.</param> /// <param name="parseName">Whether to parse the name or use the filename.</param> /// <param name="libraryRoot">Top-level folder for the containing library.</param> + /// <param name="collectionType">The type of the containing collection, if known.</param> /// <returns>Returns enumerable of <see cref="VideoInfo"/> which groups files together when related.</returns> - public static IReadOnlyList<VideoInfo> Resolve(IReadOnlyList<VideoFileInfo> videoInfos, NamingOptions namingOptions, bool supportMultiVersion = true, bool parseName = true, string? libraryRoot = "") + public IReadOnlyList<VideoInfo> Resolve(IReadOnlyList<VideoFileInfo> videoInfos, bool supportMultiVersion = true, bool parseName = true, string? libraryRoot = "", CollectionType? collectionType = null) { // Filter out all extras, otherwise they could cause stacks to not be resolved // See the unit test TestStackedWithTrailer @@ -37,7 +54,7 @@ namespace Emby.Naming.Video .Where(i => i.ExtraType is null) .Select(i => new FileSystemMetadata { FullName = i.Path, IsDirectory = i.IsDirectory }); - var stackResult = StackResolver.Resolve(nonExtras, namingOptions).ToList(); + var stackResult = StackResolver.Resolve(nonExtras, _namingOptions).ToList(); var remainingFiles = new List<VideoFileInfo>(); var standaloneMedia = new List<VideoFileInfo>(); @@ -66,7 +83,7 @@ namespace Emby.Naming.Video { var info = new VideoInfo(stack.Name) { - Files = stack.Files.Select(i => VideoResolver.Resolve(i, stack.IsDirectoryStack, namingOptions, parseName, libraryRoot)) + Files = stack.Files.Select(i => VideoResolver.Resolve(i, stack.IsDirectoryStack, _namingOptions, parseName, libraryRoot)) .OfType<VideoFileInfo>() .ToList() }; @@ -85,7 +102,9 @@ namespace Emby.Naming.Video if (supportMultiVersion) { - list = GetVideosGroupedByVersion(list, namingOptions); + list = collectionType is CollectionType.tvshows + ? GetEpisodesGroupedByVersion(list) + : GetVideosGroupedByVersion(list); } // Whatever files are left, just add them @@ -99,7 +118,7 @@ namespace Emby.Naming.Video return list; } - private static List<VideoInfo> GetVideosGroupedByVersion(List<VideoInfo> videos, NamingOptions namingOptions) + private List<VideoInfo> GetVideosGroupedByVersion(List<VideoInfo> videos) { if (videos.Count == 0) { @@ -123,7 +142,7 @@ namespace Emby.Naming.Video continue; } - if (!IsEligibleForMultiVersion(folderName, video.Files[0].FileNameWithoutExtension, namingOptions)) + if (!IsEligibleForMultiVersion(folderName, video.Files[0].FileNameWithoutExtension)) { return videos; } @@ -134,37 +153,9 @@ namespace Emby.Naming.Video } } - if (videos.Count > 1) - { - var groups = videos.GroupBy(x => ResolutionRegex().IsMatch(x.Files[0].FileNameWithoutExtension)).ToList(); - videos.Clear(); - foreach (var group in groups) - { - if (group.Key) - { - videos.InsertRange(0, group - .OrderByDescending(x => ResolutionRegex().Match(x.Files[0].FileNameWithoutExtension.ToString()).Value, new AlphanumericComparator()) - .ThenBy(x => x.Files[0].FileNameWithoutExtension.ToString(), new AlphanumericComparator())); - } - else - { - videos.AddRange(group.OrderBy(x => x.Files[0].FileNameWithoutExtension.ToString(), new AlphanumericComparator())); - } - } - } + var organized = OrganizeAlternateVersions(videos, primary, folderName.ToString()); - primary ??= videos[0]; - videos.Remove(primary); - - var list = new List<VideoInfo> - { - primary - }; - - list[0].AlternateVersions = videos.Select(x => x.Files[0]).ToArray(); - list[0].Name = folderName.ToString(); - - return list; + return [organized]; } private static bool HaveSameYear(IReadOnlyList<VideoInfo> videos) @@ -186,7 +177,7 @@ namespace Emby.Naming.Video return true; } - private static bool IsEligibleForMultiVersion(ReadOnlySpan<char> folderName, ReadOnlySpan<char> testFilename, NamingOptions namingOptions) + private bool IsEligibleForMultiVersion(ReadOnlySpan<char> folderName, ReadOnlySpan<char> testFilename) { if (!testFilename.StartsWith(folderName, StringComparison.OrdinalIgnoreCase)) { @@ -200,7 +191,7 @@ namespace Emby.Naming.Video } // There are no span overloads for regex unfortunately - if (CleanStringParser.TryClean(testFilename.ToString(), namingOptions.CleanStringRegexes, out var cleanName)) + if (CleanStringParser.TryClean(testFilename.ToString(), _namingOptions.CleanStringRegexes, out var cleanName)) { testFilename = cleanName.AsSpan().Trim(); } @@ -208,7 +199,117 @@ namespace Emby.Naming.Video // The CleanStringParser should have removed common keywords etc. return testFilename.IsEmpty || testFilename[0] == '-' + || testFilename[0] == '_' + || testFilename[0] == '.' || CheckMultiVersionRegex().IsMatch(testFilename); } + + private List<VideoInfo> GetEpisodesGroupedByVersion(List<VideoInfo> videos) + { + if (videos.Count < 2) + { + return videos; + } + + var result = new List<VideoInfo>(); + var groups = new Dictionary<string, List<VideoInfo>>(StringComparer.OrdinalIgnoreCase); + + for (var i = 0; i < videos.Count; i++) + { + var video = videos[i]; + var episodeResult = _episodePathParser.Parse(video.Files[0].Path, false); + string? key = null; + if (episodeResult.Success) + { + if (episodeResult.IsByDate + && episodeResult.Year.HasValue + && episodeResult.Month.HasValue + && episodeResult.Day.HasValue) + { + key = FormattableString.Invariant( + $"D{episodeResult.Year.Value}{episodeResult.Month.Value:D2}{episodeResult.Day.Value:D2}"); + } + else if (episodeResult.EpisodeNumber.HasValue) + { + key = FormattableString.Invariant( + $"S{episodeResult.SeasonNumber ?? 0}E{episodeResult.EpisodeNumber.Value}"); + } + } + + if (key is null) + { + result.Add(video); + continue; + } + + if (!groups.TryGetValue(key, out var group)) + { + group = []; + groups[key] = group; + } + + group.Add(video); + } + + foreach (var group in groups.Values) + { + if (group.Count == 1) + { + result.Add(group[0]); + continue; + } + + result.Add(OrganizeAlternateVersions(group)); + } + + return result; + } + + private static VideoInfo OrganizeAlternateVersions( + List<VideoInfo> videos, + VideoInfo? primaryOverride = null, + string? nameOverride = null) + { + if (videos.Count > 1) + { + var groups = videos + .Select(x => (filename: x.Files[0].FileNameWithoutExtension.ToString(), value: x)) + .Select(x => (x.filename, resolutionMatch: ResolutionRegex().Match(x.filename), x.value)) + .GroupBy(x => x.resolutionMatch.Success) + .ToList(); + + videos = []; + + foreach (var group in groups) + { + if (group.Key) + { + videos.InsertRange(0, group + .OrderByDescending(x => x.resolutionMatch.Value, _numericOrdinalComparer) + .ThenBy(x => x.filename, _numericOrdinalComparer) + .Select(x => x.value)); + } + else + { + videos.AddRange(group.OrderBy(x => x.filename, _numericOrdinalComparer).Select(x => x.value)); + } + } + } + + // Prefer a stacked entry (more than one part) as primary + var primary = primaryOverride + ?? videos.FirstOrDefault(v => v.Files.Count > 1) + ?? videos[0]; + videos.Remove(primary); + + primary.AlternateVersions = videos; + + if (nameOverride is not null) + { + primary.Name = nameOverride; + } + + return primary; + } } } diff --git a/Emby.Photos/Emby.Photos.csproj b/Emby.Photos/Emby.Photos.csproj index 645a74aea4..3faeae3803 100644 --- a/Emby.Photos/Emby.Photos.csproj +++ b/Emby.Photos/Emby.Photos.csproj @@ -19,7 +19,7 @@ </ItemGroup> <PropertyGroup> - <TargetFramework>net9.0</TargetFramework> + <TargetFramework>net10.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> </PropertyGroup> diff --git a/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs b/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs index de722332a4..56d977bbcb 100644 --- a/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs +++ b/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs @@ -90,6 +90,7 @@ namespace Emby.Server.Implementations.AppBase CreateAndCheckMarker(ProgramDataPath, "data"); CreateAndCheckMarker(CachePath, "cache"); CreateAndCheckMarker(DataPath, "data"); + CreateCacheDirTag(CachePath); } /// <inheritdoc /> @@ -100,6 +101,26 @@ namespace Emby.Server.Implementations.AppBase CheckOrCreateMarker(path, $".jellyfin-{markerName}", recursive); } + /// <summary> + /// Creates a CACHEDIR.TAG file in the specified directory per the Cache Directory Tagging specification. + /// This signals to backup tools (e.g. Restic, Borg) that the directory contains cached data + /// and can be excluded from backups. + /// </summary> + /// <param name="path">The cache directory path.</param> + internal static void CreateCacheDirTag(string path) + { + var tagPath = Path.Combine(path, "CACHEDIR.TAG"); + if (!File.Exists(tagPath)) + { + File.WriteAllText( + tagPath, + "Signature: 8a477f597d28d172789f06886806bc55\n" + + "# This file is a cache directory tag created by Jellyfin.\n" + + "# For information about cache directory tags, see:\n" + + "#\thttps://bford.info/cachedir/\n"); + } + } + private IEnumerable<string> GetMarkers(string path, bool recursive = false) { return Directory.EnumerateFiles(path, ".jellyfin-*", recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly); diff --git a/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs b/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs index 81ef0e5f9a..aa19948e36 100644 --- a/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs +++ b/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs @@ -132,7 +132,7 @@ namespace Emby.Server.Implementations.AppBase } else { - _configurationFactories = [.._configurationFactories, factory]; + _configurationFactories = [.. _configurationFactories, factory]; } _configurationStores = _configurationFactories @@ -228,6 +228,7 @@ namespace Emby.Server.Implementations.AppBase Logger.LogInformation("Setting cache path: {Path}", cachePath); ((BaseApplicationPaths)CommonApplicationPaths).CachePath = cachePath; CommonApplicationPaths.CreateAndCheckMarker(((BaseApplicationPaths)CommonApplicationPaths).CachePath, "cache"); + BaseApplicationPaths.CreateCacheDirTag(cachePath); } /// <summary> diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index cbb0f6c565..1a54565863 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -14,6 +14,7 @@ using System.Reflection; using System.Security.Cryptography.X509Certificates; using System.Threading.Tasks; using Emby.Naming.Common; +using Emby.Naming.Video; using Emby.Photos; using Emby.Server.Implementations.Chapters; using Emby.Server.Implementations.Collections; @@ -25,6 +26,8 @@ using Emby.Server.Implementations.Dto; using Emby.Server.Implementations.HttpServer.Security; using Emby.Server.Implementations.IO; using Emby.Server.Implementations.Library; +using Emby.Server.Implementations.Library.Search; +using Emby.Server.Implementations.Library.SimilarItems; using Emby.Server.Implementations.Localization; using Emby.Server.Implementations.Playlists; using Emby.Server.Implementations.Plugins; @@ -36,6 +39,8 @@ using Emby.Server.Implementations.SyncPlay; using Emby.Server.Implementations.TV; using Emby.Server.Implementations.Updates; using Jellyfin.Api.Helpers; +using Jellyfin.Data; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Drawing; using Jellyfin.MediaEncoding.Hls.Playlist; using Jellyfin.Networking.Manager; @@ -90,9 +95,16 @@ using MediaBrowser.Model.Net; using MediaBrowser.Model.Serialization; using MediaBrowser.Model.System; using MediaBrowser.Model.Tasks; +using MediaBrowser.Providers.Books; +using MediaBrowser.Providers.Books.ComicBookInfo; +using MediaBrowser.Providers.Books.ComicInfo; using MediaBrowser.Providers.Lyric; using MediaBrowser.Providers.Manager; +using MediaBrowser.Providers.Plugins.ListenBrainz; +using MediaBrowser.Providers.Plugins.ListenBrainz.Api; using MediaBrowser.Providers.Plugins.Tmdb; +using MediaBrowser.Providers.Plugins.Tmdb.Movies; +using MediaBrowser.Providers.Plugins.Tmdb.TV; using MediaBrowser.Providers.Subtitles; using MediaBrowser.XbmcMetadata.Providers; using Microsoft.AspNetCore.Http; @@ -166,8 +178,6 @@ namespace Emby.Server.Implementations ConfigurationManager.Configuration, ApplicationPaths.PluginsPath, ApplicationVersion); - - _disposableParts.Add(_pluginManager); } /// <summary> @@ -409,6 +419,8 @@ namespace Emby.Server.Implementations { Logger.LogInformation("Running startup tasks"); + EnsureStartupWizardIntegrity(); + Resolve<ITaskManager>().AddTasks(GetExports<IScheduledTask>(false)); ConfigurationManager.ConfigurationUpdated += OnConfigurationUpdated; @@ -428,6 +440,24 @@ namespace Emby.Server.Implementations return Task.CompletedTask; } + private void EnsureStartupWizardIntegrity() + { + if (ConfigurationManager.CommonConfiguration.IsStartupWizardCompleted) + { + return; + } + + var hasConfiguredAdministrator = Resolve<IUserManager>().GetUsers() + .Any(user => user.HasPermission(PermissionKind.IsAdministrator) && !string.IsNullOrEmpty(user.Password)); + + if (hasConfiguredAdministrator) + { + Logger.LogWarning("The startup wizard is marked incomplete but a configured administrator already exists. Marking setup as completed to prevent the unauthenticated setup endpoints from being reachable."); + ConfigurationManager.Configuration.IsStartupWizardCompleted = true; + ConfigurationManager.SaveConfiguration(); + } + } + /// <inheritdoc/> public void Init(IServiceCollection serviceCollection) { @@ -485,6 +515,19 @@ namespace Emby.Server.Implementations serviceCollection.AddScoped<ISystemManager, SystemManager>(); serviceCollection.AddSingleton<TmdbClientManager>(); + serviceCollection.AddSingleton<TmdbMovieSimilarProvider>(); + serviceCollection.AddSingleton<TmdbSeriesSimilarProvider>(); + + serviceCollection.AddSingleton<ListenBrainzLabsClient>(); + serviceCollection.AddSingleton<ListenBrainzSimilarArtistProvider>(); + + // register the generic local metadata provider for comic files + serviceCollection.AddSingleton<ComicProvider>(); + + // register the actual implementations of the local metadata provider for comic files + serviceCollection.AddSingleton<IComicProvider, ComicBookInfoProvider>(); + serviceCollection.AddSingleton<IComicProvider, ExternalComicInfoProvider>(); + serviceCollection.AddSingleton<IComicProvider, InternalComicInfoProvider>(); serviceCollection.AddSingleton(NetManager); @@ -507,7 +550,13 @@ namespace Emby.Server.Implementations serviceCollection.AddSingleton<IUserDataManager, UserDataManager>(); - serviceCollection.AddSingleton<IItemRepository, BaseItemRepository>(); + serviceCollection.AddSingleton<BaseItemRepository>(); + serviceCollection.AddSingleton<IItemRepository>(sp => sp.GetRequiredService<BaseItemRepository>()); + serviceCollection.AddSingleton<IItemQueryHelpers>(sp => sp.GetRequiredService<BaseItemRepository>()); + serviceCollection.AddSingleton<IItemPersistenceService, ItemPersistenceService>(); + serviceCollection.AddSingleton<INextUpService, NextUpService>(); + serviceCollection.AddSingleton<IItemCountService, ItemCountService>(); + serviceCollection.AddSingleton<ILinkedChildrenService, LinkedChildrenService>(); serviceCollection.AddSingleton<IPeopleRepository, PeopleRepository>(); serviceCollection.AddSingleton<IChapterRepository, ChapterRepository>(); serviceCollection.AddSingleton<IMediaAttachmentRepository, MediaAttachmentRepository>(); @@ -524,14 +573,20 @@ namespace Emby.Server.Implementations serviceCollection.AddTransient(provider => new Lazy<ILibraryMonitor>(provider.GetRequiredService<ILibraryMonitor>)); serviceCollection.AddTransient(provider => new Lazy<IProviderManager>(provider.GetRequiredService<IProviderManager>)); serviceCollection.AddTransient(provider => new Lazy<IUserViewManager>(provider.GetRequiredService<IUserViewManager>)); + serviceCollection.AddTransient(provider => new Lazy<IExternalDataManager>(provider.GetRequiredService<IExternalDataManager>)); serviceCollection.AddSingleton<ILibraryManager, LibraryManager>(); serviceCollection.AddSingleton<NamingOptions>(); + serviceCollection.AddSingleton<VideoListResolver>(); serviceCollection.AddSingleton<IMusicManager, MusicManager>(); serviceCollection.AddSingleton<ILibraryMonitor, LibraryMonitor>(); + serviceCollection.AddSingleton<DotIgnoreIgnoreRule>(); - serviceCollection.AddSingleton<ISearchEngine, SearchEngine>(); + serviceCollection.AddSingleton<ISimilarItemsManager, SimilarItemsManager>(); + + serviceCollection.AddSingleton<ISearchManager, SearchManager>(); + serviceCollection.AddSingleton<ISearchProvider, SqlSearchProvider>(); serviceCollection.AddSingleton<IWebSocketManager, WebSocketManager>(); @@ -641,6 +696,7 @@ namespace Emby.Server.Implementations BaseItem.ConfigurationManager = ConfigurationManager; BaseItem.FileSystem = Resolve<IFileSystem>(); BaseItem.ItemRepository = Resolve<IItemRepository>(); + BaseItem.ItemCountService = Resolve<IItemCountService>(); BaseItem.LibraryManager = Resolve<ILibraryManager>(); BaseItem.LocalizationManager = Resolve<ILocalizationManager>(); BaseItem.Logger = Resolve<ILogger<BaseItem>>(); @@ -687,6 +743,9 @@ namespace Emby.Server.Implementations GetExports<IExternalUrlProvider>()); Resolve<IMediaSourceManager>().AddParts(GetExports<IMediaSourceProvider>()); + + Resolve<ISimilarItemsManager>().AddParts(GetExports<ISimilarItemsProvider>()); + Resolve<ISearchManager>().AddParts(GetExports<ISearchProvider>()); } /// <summary> @@ -928,8 +987,9 @@ namespace Emby.Server.Implementations /// <inheritdoc/> public string GetLocalApiUrl(string hostname, string scheme = null, int? port = null) { - // If the smartAPI doesn't start with http then treat it as a host or ip. - if (hostname.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + // If the smartAPI isn't already a complete URL then treat it as a host or ip. + if (hostname.StartsWith("http://", StringComparison.OrdinalIgnoreCase) + || hostname.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) { return hostname.TrimEnd('/'); } @@ -1006,6 +1066,8 @@ namespace Emby.Server.Implementations } _disposableParts.Clear(); + + _pluginManager?.Dispose(); } _disposed = true; diff --git a/Emby.Server.Implementations/Chapters/ChapterManager.cs b/Emby.Server.Implementations/Chapters/ChapterManager.cs index d09ed30ae3..69cbe533c6 100644 --- a/Emby.Server.Implementations/Chapters/ChapterManager.cs +++ b/Emby.Server.Implementations/Chapters/ChapterManager.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using Jellyfin.Extensions; using MediaBrowser.Controller.Chapters; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; @@ -128,7 +129,7 @@ public class ChapterManager : IChapterManager var averageChapterDuration = GetAverageDurationBetweenChapters(chapters); var threshold = TimeSpan.FromSeconds(1).Ticks; - if (averageChapterDuration < threshold) + if (chapters.Count >= 2 && averageChapterDuration < threshold) { _logger.LogInformation("Skipping chapter image extraction for {Video} as the average chapter duration {AverageDuration} was lower than the minimum threshold {Threshold}", video.Name, averageChapterDuration, threshold); extractImages = false; @@ -232,11 +233,21 @@ public class ChapterManager : IChapterManager } /// <inheritdoc /> - public void SaveChapters(Video video, IReadOnlyList<ChapterInfo> chapters) + public bool Supports(BaseItem item) + => item is Video or Audio; + + /// <inheritdoc /> + public void SaveChapters(BaseItem item, IReadOnlyList<ChapterInfo> chapters) { - // Remove any chapters that are outside of the runtime of the video - var validChapters = chapters.Where(c => c.StartPositionTicks < video.RunTimeTicks).ToList(); - _chapterRepository.SaveChapters(video.Id, validChapters); + if (!Supports(item)) + { + _logger.LogWarning("Attempted to save chapters for unsupported item type {Type}: {Name} ({Id})", item.GetType().Name, item.Name, item.Id); + return; + } + + // Remove any chapters that are outside of the runtime of the item + var validChapters = chapters.Where(c => c.StartPositionTicks < item.RunTimeTicks).ToList(); + _chapterRepository.SaveChapters(item.Id, validChapters); } /// <inheritdoc /> diff --git a/Emby.Server.Implementations/Collections/CollectionManager.cs b/Emby.Server.Implementations/Collections/CollectionManager.cs index a320a774c6..84d50f5121 100644 --- a/Emby.Server.Implementations/Collections/CollectionManager.cs +++ b/Emby.Server.Implementations/Collections/CollectionManager.cs @@ -4,12 +4,15 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Extensions; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Collections; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Entities; @@ -29,6 +32,7 @@ namespace Emby.Server.Implementations.Collections private readonly ILibraryMonitor _iLibraryMonitor; private readonly ILogger<CollectionManager> _logger; private readonly IProviderManager _providerManager; + private readonly ILinkedChildrenService _linkedChildrenService; private readonly ILocalizationManager _localizationManager; private readonly IApplicationPaths _appPaths; @@ -42,6 +46,7 @@ namespace Emby.Server.Implementations.Collections /// <param name="iLibraryMonitor">The library monitor.</param> /// <param name="loggerFactory">The logger factory.</param> /// <param name="providerManager">The provider manager.</param> + /// <param name="linkedChildrenService">The linked children service.</param> public CollectionManager( ILibraryManager libraryManager, IApplicationPaths appPaths, @@ -49,13 +54,15 @@ namespace Emby.Server.Implementations.Collections IFileSystem fileSystem, ILibraryMonitor iLibraryMonitor, ILoggerFactory loggerFactory, - IProviderManager providerManager) + IProviderManager providerManager, + ILinkedChildrenService linkedChildrenService) { _libraryManager = libraryManager; _fileSystem = fileSystem; _iLibraryMonitor = iLibraryMonitor; _logger = loggerFactory.CreateLogger<CollectionManager>(); _providerManager = providerManager; + _linkedChildrenService = linkedChildrenService; _localizationManager = localizationManager; _appPaths = appPaths; } @@ -100,7 +107,8 @@ namespace Emby.Server.Implementations.Collections SaveLocalMetadata = true }; - var name = _localizationManager.GetLocalizedString("Collections"); + // This names a library for the whole server, so ignore the requesting client's language. + var name = _localizationManager.GetServerLocalizedString("Collections"); await _libraryManager.AddVirtualFolder(name, CollectionTypeOptions.boxsets, libraryOptions, true).ConfigureAwait(false); @@ -120,6 +128,22 @@ namespace Emby.Server.Implementations.Collections return EnsureLibraryFolder(GetCollectionsFolderPath(), createIfNeeded); } + /// <inheritdoc /> + public IEnumerable<BoxSet> GetCollectionsContainingItem(User user, Guid itemId) + { + ArgumentNullException.ThrowIfNull(user); + + if (itemId.IsEmpty()) + { + return Enumerable.Empty<BoxSet>(); + } + + return _linkedChildrenService + .GetManualLinkedParentIds(itemId, BaseItemKind.BoxSet) + .Select(parentId => _libraryManager.GetItemById<BoxSet>(parentId, user)) + .OfType<BoxSet>(); + } + private IEnumerable<BoxSet> GetCollections(User user) { var folder = GetCollectionsFolder(false).GetAwaiter().GetResult(); @@ -272,7 +296,7 @@ namespace Emby.Server.Implementations.Collections { var childItem = _libraryManager.GetItemById(guidId); - var child = collection.LinkedChildren.FirstOrDefault(i => (i.ItemId.HasValue && i.ItemId.Value.Equals(guidId)) || (childItem is not null && string.Equals(childItem.Path, i.Path, StringComparison.OrdinalIgnoreCase))); + var child = collection.LinkedChildren.FirstOrDefault(i => i.ItemId.HasValue && i.ItemId.Value.Equals(guidId)); if (child is null) { @@ -342,7 +366,7 @@ namespace Emby.Server.Implementations.Collections // this is kind of a performance hack because only Video has alternate versions that should be in a box set? if (item is Video video) { - foreach (var childId in video.GetLocalAlternateVersionIds()) + foreach (var childId in _libraryManager.GetLocalAlternateVersionIds(video)) { if (!results.ContainsKey(childId)) { diff --git a/Emby.Server.Implementations/Cryptography/CryptographyProvider.cs b/Emby.Server.Implementations/Cryptography/CryptographyProvider.cs index 5380c45d84..0381c4d355 100644 --- a/Emby.Server.Implementations/Cryptography/CryptographyProvider.cs +++ b/Emby.Server.Implementations/Cryptography/CryptographyProvider.cs @@ -39,22 +39,24 @@ namespace Emby.Server.Implementations.Cryptography { if (string.Equals(hash.Id, "PBKDF2", StringComparison.Ordinal)) { + var iterations = GetIterationsParameter(hash); return hash.Hash.SequenceEqual( Rfc2898DeriveBytes.Pbkdf2( password, hash.Salt, - int.Parse(hash.Parameters["iterations"], CultureInfo.InvariantCulture), + iterations, HashAlgorithmName.SHA1, 32)); } if (string.Equals(hash.Id, "PBKDF2-SHA512", StringComparison.Ordinal)) { + var iterations = GetIterationsParameter(hash); return hash.Hash.SequenceEqual( Rfc2898DeriveBytes.Pbkdf2( password, hash.Salt, - int.Parse(hash.Parameters["iterations"], CultureInfo.InvariantCulture), + iterations, HashAlgorithmName.SHA512, DefaultOutputLength)); } @@ -62,6 +64,27 @@ namespace Emby.Server.Implementations.Cryptography throw new NotSupportedException($"Can't verify hash with id: {hash.Id}"); } + /// <summary> + /// Extracts and validates the iterations parameter from a password hash. + /// </summary> + /// <param name="hash">The password hash containing parameters.</param> + /// <returns>The number of iterations.</returns> + /// <exception cref="FormatException">Thrown when iterations parameter is missing or invalid.</exception> + private static int GetIterationsParameter(PasswordHash hash) + { + if (!hash.Parameters.TryGetValue("iterations", out var iterationsStr)) + { + throw new FormatException($"Password hash with id '{hash.Id}' is missing required 'iterations' parameter."); + } + + if (!int.TryParse(iterationsStr, CultureInfo.InvariantCulture, out var iterations)) + { + throw new FormatException($"Password hash with id '{hash.Id}' has invalid 'iterations' parameter: '{iterationsStr}'."); + } + + return iterations; + } + /// <inheritdoc /> public byte[] GenerateSalt() => GenerateSalt(DefaultSaltLength); diff --git a/Emby.Server.Implementations/Data/CleanDatabaseScheduledTask.cs b/Emby.Server.Implementations/Data/CleanDatabaseScheduledTask.cs index 676bb7f816..17355960c3 100644 --- a/Emby.Server.Implementations/Data/CleanDatabaseScheduledTask.cs +++ b/Emby.Server.Implementations/Data/CleanDatabaseScheduledTask.cs @@ -5,10 +5,12 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Playlists; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -35,7 +37,11 @@ public class CleanDatabaseScheduledTask : ILibraryPostScanTask public async Task Run(IProgress<double> progress, CancellationToken cancellationToken) { - await CleanDeadItems(cancellationToken, progress).ConfigureAwait(false); + var deadItemsProgress = new Progress<double>(val => progress.Report(val * 0.8)); + await CleanDeadItems(cancellationToken, deadItemsProgress).ConfigureAwait(false); + + var playlistProgress = new Progress<double>(val => progress.Report(80 + (val * 0.2))); + await CleanOrphanedFilePlaylistsAsync(cancellationToken, playlistProgress).ConfigureAwait(false); } private async Task CleanDeadItems(CancellationToken cancellationToken, IProgress<double> progress) @@ -116,4 +122,32 @@ public class CleanDatabaseScheduledTask : ILibraryPostScanTask progress.Report(100); } + + private async Task CleanOrphanedFilePlaylistsAsync(CancellationToken cancellationToken, IProgress<double> progress) + { + var playlists = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.Playlist], + Recursive = true + }).OfType<Playlist>().ToList(); + + var numComplete = 0; + var numItems = Math.Max(playlists.Count, 1); + + foreach (var playlist in playlists) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (playlist.IsFile && !File.Exists(playlist.Path)) + { + _logger.LogInformation("Removing file-based playlist {Name} because source file {Path} no longer exists", playlist.Name, playlist.Path); + _libraryManager.DeleteItem(playlist, new DeleteOptions { DeleteFileLocation = false }); + } + + numComplete++; + progress.Report((double)numComplete / numItems * 100); + } + + progress.Report(100); + } } diff --git a/Emby.Server.Implementations/Devices/DeviceId.cs b/Emby.Server.Implementations/Devices/DeviceId.cs index 0b3c3bbd4f..4929568935 100644 --- a/Emby.Server.Implementations/Devices/DeviceId.cs +++ b/Emby.Server.Implementations/Devices/DeviceId.cs @@ -1,5 +1,3 @@ -#pragma warning disable CS1591 - using System; using System.Globalization; using System.IO; @@ -10,6 +8,9 @@ using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.Devices { + /// <summary> + /// Provides the persistent unique identifier of this server installation. + /// </summary> public class DeviceId { private readonly IApplicationPaths _appPaths; @@ -18,12 +19,20 @@ namespace Emby.Server.Implementations.Devices private string? _id; + /// <summary> + /// Initializes a new instance of the <see cref="DeviceId"/> class. + /// </summary> + /// <param name="appPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param> + /// <param name="logger">Instance of the <see cref="ILogger{DeviceId}"/> interface.</param> public DeviceId(IApplicationPaths appPaths, ILogger<DeviceId> logger) { _appPaths = appPaths; _logger = logger; } + /// <summary> + /// Gets the device id, loading it from disk or generating and persisting a new one if none exists. + /// </summary> public string Value => _id ??= GetDeviceId(); private string CachePath => Path.Combine(_appPaths.DataPath, "device.txt"); diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index b392340f71..59e75691dc 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -71,6 +71,8 @@ namespace Emby.Server.Implementations.Dto { BaseItemKind.Person, [ BaseItemKind.Audio, + BaseItemKind.AudioBook, + BaseItemKind.Book, BaseItemKind.Episode, BaseItemKind.Movie, BaseItemKind.LiveTvProgram, @@ -153,17 +155,139 @@ namespace Emby.Server.Implementations.Dto private ILiveTvManager LivetvManager => _livetvManagerFactory.Value; /// <inheritdoc /> - public IReadOnlyList<BaseItemDto> GetBaseItemDtos(IReadOnlyList<BaseItem> items, DtoOptions options, User? user = null, BaseItem? owner = null) + public IReadOnlyList<BaseItemDto> GetBaseItemDtos( + IReadOnlyList<BaseItem> items, + DtoOptions options, + User? user = null, + BaseItem? owner = null, + bool skipVisibilityCheck = false) { - var accessibleItems = user is null ? items : items.Where(x => x.IsVisible(user)).ToList(); + var accessibleItems = skipVisibilityCheck || user is null ? items : items.Where(x => x.IsVisible(user)).ToList(); var returnItems = new BaseItemDto[accessibleItems.Count]; List<(BaseItem, BaseItemDto)>? programTuples = null; List<(BaseItemDto, LiveTvChannel)>? channelTuples = null; + // Batch-fetch user data for all items + Dictionary<Guid, UserItemData>? userDataBatch = null; + IReadOnlyDictionary<Guid, VersionResumeData>? resumeDataBatch = null; + if (user is not null && options.EnableUserData) + { + userDataBatch = _userDataRepository.GetUserDataBatch(accessibleItems, user); + + // For items with alternate versions, the most recently played version drives resume. + resumeDataBatch = _userDataRepository.GetResumeUserDataBatch(accessibleItems, user); + } + + // Pre-compute collection folders once to avoid N+1 queries in CanDelete + List<Folder>? allCollectionFolders = null; + if (user is not null && options.ContainsField(ItemFields.CanDelete)) + { + allCollectionFolders = _libraryManager.GetUserRootFolder().Children.OfType<Folder>().ToList(); + } + + // Batch-fetch by-name item counts to avoid N+1 queries + Dictionary<Guid, ItemCounts>? itemCountsBatch = null; + if (options.ContainsField(ItemFields.ItemCounts)) + { + itemCountsBatch = GetItemCountsBatch(accessibleItems, user); + } + + // Batch-fetch child counts for all folders to avoid N+1 queries + Dictionary<Guid, int>? childCountBatch = null; + if (options.ContainsField(ItemFields.ChildCount)) + { + var folderIds = accessibleItems.OfType<Folder>().Select(f => f.Id).ToList(); + if (folderIds.Count > 0) + { + childCountBatch = _libraryManager.GetChildCountBatch(folderIds, user); + } + } + + // Batch-fetch played/total counts for all folders to avoid N+1 queries + Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null; + if (user is not null && options.EnableUserData) + { + var folderIds = accessibleItems.OfType<Folder>() + .Where(f => f.SupportsUserDataFromChildren && (f.SupportsPlayedStatus || options.ContainsField(ItemFields.RecursiveItemCount))) + .Select(f => f.Id).ToList(); + if (folderIds.Count > 0) + { + playedCountBatch = _libraryManager.GetPlayedAndTotalCountBatch(folderIds, user); + } + } + + // Batch-fetch MusicArtist lookups across all items to avoid N+1 queries. + IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null; + var artistNames = new HashSet<string>(StringComparer.Ordinal); + foreach (var item in accessibleItems) + { + if (item is IHasArtist hasArtist) + { + foreach (var name in hasArtist.Artists) + { + if (!string.IsNullOrWhiteSpace(name)) + { + artistNames.Add(name); + } + } + } + + if (item is IHasAlbumArtist hasAlbumArtist) + { + foreach (var name in hasAlbumArtist.AlbumArtists) + { + if (!string.IsNullOrWhiteSpace(name)) + { + artistNames.Add(name); + } + } + } + } + + if (artistNames.Count > 0) + { + artistsBatch = _libraryManager.GetArtists(artistNames.ToArray()); + } + + // Batch-fetch people across all items to avoid one GetPeople query per item. + IReadOnlyDictionary<Guid, IReadOnlyList<PersonInfo>>? peopleBatch = null; + if (options.ContainsField(ItemFields.People)) + { + var peopleItemIds = accessibleItems.Where(i => i.SupportsPeople).Select(i => i.Id).ToList(); + if (peopleItemIds.Count > 0) + { + peopleBatch = _libraryManager.GetPeopleByItems(peopleItemIds); + } + } + + // Batch-detect which videos own alternate versions to avoid the per-item alternate-version + // queries in MediaSourceCount. Videos absent from this set have a single media source. + IReadOnlySet<Guid>? alternateVersionItemIds = null; + if (options.ContainsField(ItemFields.MediaSourceCount)) + { + var versionItemIds = accessibleItems.OfType<Video>().Select(i => i.Id).ToList(); + if (versionItemIds.Count > 0) + { + alternateVersionItemIds = _libraryManager.GetItemIdsWithAlternateVersions(versionItemIds); + } + } + for (int index = 0; index < accessibleItems.Count; index++) { var item = accessibleItems[index]; - var dto = GetBaseItemDtoInternal(item, options, user, owner); + var dto = GetBaseItemDtoInternal( + item, + options, + user, + owner, + userDataBatch?.GetValueOrDefault(item.Id), + allCollectionFolders, + childCountBatch, + playedCountBatch, + artistsBatch, + resumeDataBatch?.GetValueOrDefault(item.Id), + peopleBatch, + alternateVersionItemIds); if (item is LiveTvChannel tvChannel) { @@ -176,7 +300,7 @@ namespace Emby.Server.Implementations.Dto if (options.ContainsField(ItemFields.ItemCounts)) { - SetItemByNameInfo(dto, user); + SetItemByNameInfo(dto, user, itemCountsBatch); } returnItems[index] = dto; @@ -197,7 +321,7 @@ namespace Emby.Server.Implementations.Dto public BaseItemDto GetBaseItemDto(BaseItem item, DtoOptions options, User? user = null, BaseItem? owner = null) { - var dto = GetBaseItemDtoInternal(item, options, user, owner); + var dto = GetBaseItemDtoInternal(item, options, user, owner, null); if (item is LiveTvChannel tvChannel) { LivetvManager.AddChannelInfo(new[] { (dto, tvChannel) }, options, user); @@ -215,7 +339,19 @@ namespace Emby.Server.Implementations.Dto return dto; } - private BaseItemDto GetBaseItemDtoInternal(BaseItem item, DtoOptions options, User? user = null, BaseItem? owner = null) + private BaseItemDto GetBaseItemDtoInternal( + BaseItem item, + DtoOptions options, + User? user = null, + BaseItem? owner = null, + UserItemData? userData = null, + List<Folder>? allCollectionFolders = null, + Dictionary<Guid, int>? childCountBatch = null, + Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null, + IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null, + VersionResumeData? resumeData = null, + IReadOnlyDictionary<Guid, IReadOnlyList<PersonInfo>>? peopleBatch = null, + IReadOnlySet<Guid>? alternateVersionItemIds = null) { var dto = new BaseItemDto { @@ -229,7 +365,15 @@ namespace Emby.Server.Implementations.Dto if (options.ContainsField(ItemFields.People)) { - AttachPeople(dto, item, user); + IReadOnlyList<PersonInfo>? prefetchedPeople = null; + if (peopleBatch is not null) + { + // The batch omits items with no people, so a miss means "no people", + // not "not fetched". Use an empty list to skip the per-item query. + prefetchedPeople = peopleBatch.GetValueOrDefault(item.Id) ?? []; + } + + AttachPeople(dto, item, user, prefetchedPeople); } if (options.ContainsField(ItemFields.PrimaryImageAspectRatio)) @@ -252,7 +396,15 @@ namespace Emby.Server.Implementations.Dto if (user is not null) { - AttachUserSpecificInfo(dto, item, user, options); + AttachUserSpecificInfo( + dto, + item, + user, + options, + userData, + childCountBatch, + playedCountBatch, + resumeData); } if (item is IHasMediaSources @@ -268,13 +420,15 @@ namespace Emby.Server.Implementations.Dto AttachStudios(dto, item); } - AttachBasicFields(dto, item, owner, options); + AttachBasicFields(dto, item, owner, options, artistsBatch, user, alternateVersionItemIds); if (options.ContainsField(ItemFields.CanDelete)) { dto.CanDelete = user is null ? item.CanDelete() - : item.CanDelete(user); + : allCollectionFolders is not null + ? item.CanDelete(user, allCollectionFolders) + : item.CanDelete(user); } if (options.ContainsField(ItemFields.CanDownload)) @@ -371,44 +525,36 @@ namespace Emby.Server.Implementations.Dto return dto; } - private void SetItemByNameInfo(BaseItemDto dto, User? user) + private Dictionary<Guid, ItemCounts> GetItemCountsBatch(IReadOnlyList<BaseItem> items, User? user) + { + var result = new Dictionary<Guid, ItemCounts>(); + + foreach (var group in items.GroupBy(item => item.GetBaseItemKind())) + { + if (!_relatedItemKinds.TryGetValue(group.Key, out var relatedItemKinds)) + { + continue; + } + + var ids = group.Select(item => item.Id).ToArray(); + foreach (var (id, counts) in _libraryManager.GetItemCountsForNameItems(group.Key, ids, relatedItemKinds, user)) + { + result[id] = counts; + } + } + + return result; + } + + private void SetItemByNameInfo(BaseItemDto dto, User? user, IReadOnlyDictionary<Guid, ItemCounts>? prefetchedCounts = null) { if (!_relatedItemKinds.TryGetValue(dto.Type, out var relatedItemKinds)) { return; } - var query = new InternalItemsQuery(user) - { - Recursive = true, - DtoOptions = new DtoOptions(false) { EnableImages = false }, - IncludeItemTypes = relatedItemKinds - }; - - switch (dto.Type) - { - case BaseItemKind.Genre: - case BaseItemKind.MusicGenre: - query.GenreIds = [dto.Id]; - break; - case BaseItemKind.MusicArtist: - query.ArtistIds = [dto.Id]; - break; - case BaseItemKind.Person: - query.PersonIds = [dto.Id]; - break; - case BaseItemKind.Studio: - query.StudioIds = [dto.Id]; - break; - case BaseItemKind.Year - when int.TryParse(dto.Name, NumberStyles.Integer, CultureInfo.InvariantCulture, out var year): - query.Years = [year]; - break; - default: - return; - } - - var counts = _libraryManager.GetItemCounts(query); + var counts = prefetchedCounts?.GetValueOrDefault(dto.Id) + ?? _libraryManager.GetItemCountsForNameItem(dto.Type, dto.Id, relatedItemKinds, user); dto.AlbumCount = counts.AlbumCount; dto.ArtistCount = counts.ArtistCount; @@ -458,7 +604,15 @@ namespace Emby.Server.Implementations.Dto /// <summary> /// Attaches the user specific info. /// </summary> - private void AttachUserSpecificInfo(BaseItemDto dto, BaseItem item, User user, DtoOptions options) + private void AttachUserSpecificInfo( + BaseItemDto dto, + BaseItem item, + User user, + DtoOptions options, + UserItemData? userData = null, + Dictionary<Guid, int>? childCountBatch = null, + Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null, + VersionResumeData? resumeData = null) { if (item.IsFolder) { @@ -466,7 +620,19 @@ namespace Emby.Server.Implementations.Dto if (options.EnableUserData) { - dto.UserData = _userDataRepository.GetUserDataDto(item, dto, user, options); + if (userData is not null) + { + // Use pre-fetched user data + dto.UserData = GetUserItemDataDto(userData, item.Id); + (int Played, int Total)? precomputed = playedCountBatch is not null + && playedCountBatch.TryGetValue(item.Id, out var counts) ? counts : null; + item.FillUserDataDtoValues(dto.UserData, userData, dto, user, options, precomputed); + } + else + { + // Fall back to individual fetch + dto.UserData = _userDataRepository.GetUserDataDto(item, dto, user, options); + } } if (!dto.ChildCount.HasValue && item.SourceType == SourceType.Library) @@ -474,7 +640,11 @@ namespace Emby.Server.Implementations.Dto // For these types we can try to optimize and assume these values will be equal if (item is MusicAlbum || item is Season || item is Playlist) { - dto.ChildCount = dto.RecursiveItemCount; + if (dto.RecursiveItemCount > 0) + { + dto.ChildCount = dto.RecursiveItemCount; + } + var folderChildCount = folder.LinkedChildren.Length; // The default is an empty array, so we can't reliably use the count when it's empty if (folderChildCount > 0) @@ -485,7 +655,7 @@ namespace Emby.Server.Implementations.Dto if (options.ContainsField(ItemFields.ChildCount)) { - dto.ChildCount ??= GetChildCount(folder, user); + dto.ChildCount ??= GetChildCount(folder, user, childCountBatch); } } @@ -503,7 +673,20 @@ namespace Emby.Server.Implementations.Dto { if (options.EnableUserData) { - dto.UserData = _userDataRepository.GetUserDataDto(item, user); + if (userData is not null) + { + // Use pre-fetched user data + dto.UserData = GetUserItemDataDto(userData, item.Id); + item.FillUserDataDtoValues(dto.UserData, userData, dto, user, options); + + // For items with alternate versions, the most recently played version drives resume. + resumeData?.ApplyTo(dto.UserData); + } + else + { + // Fall back to individual fetch + dto.UserData = _userDataRepository.GetUserDataDto(item, user); + } } } @@ -513,7 +696,25 @@ namespace Emby.Server.Implementations.Dto } } - private static int GetChildCount(Folder folder, User user) + private static UserItemDataDto GetUserItemDataDto(UserItemData data, Guid itemId) + { + ArgumentNullException.ThrowIfNull(data); + + return new UserItemDataDto + { + IsFavorite = data.IsFavorite, + Likes = data.Likes, + PlaybackPositionTicks = data.PlaybackPositionTicks, + PlayCount = data.PlayCount, + Rating = data.Rating, + Played = data.Played, + LastPlayedDate = data.LastPlayedDate, + ItemId = itemId, + Key = data.Key + }; + } + + private static int GetChildCount(Folder folder, User user, Dictionary<Guid, int>? childCountBatch) { // Right now this is too slow to calculate for top level folders on a per-user basis // Just return something so that apps that are expecting a value won't think the folders are empty @@ -522,6 +723,14 @@ namespace Emby.Server.Implementations.Dto return Random.Shared.Next(1, 10); } + // Use pre-fetched batch data if available + if (childCountBatch is not null && childCountBatch.TryGetValue(folder.Id, out var count)) + { + return count; + } + + // Only reached when no batch was computed: the batch holds an entry for every folder it + // was asked about, zero included. return folder.GetChildCount(user); } @@ -602,12 +811,18 @@ namespace Emby.Server.Implementations.Dto /// <param name="dto">The dto.</param> /// <param name="item">The item.</param> /// <param name="user">The requesting user.</param> - private void AttachPeople(BaseItemDto dto, BaseItem item, User? user = null) + /// <param name="prefetchedPeople">People fetched in batch by the caller; when null the people are queried per item.</param> + private void AttachPeople(BaseItemDto dto, BaseItem item, User? user = null, IReadOnlyList<PersonInfo>? prefetchedPeople = null) { + // When rendering a page of items the caller batch-fetches people for every item up + // front and passes them in, avoiding one GetPeople query per item. Fall back to the + // per-item query for the single item path where no batch is available. + var source = prefetchedPeople ?? _libraryManager.GetPeople(item); + // Ordering by person type to ensure actors and artists are at the front. // This is taking advantage of the fact that they both begin with A // This should be improved in the future - var people = _libraryManager.GetPeople(item).OrderBy(i => i.SortOrder ?? int.MaxValue) + var people = source.OrderBy(i => i.SortOrder ?? int.MaxValue) .ThenBy(i => { if (i.IsType(PersonKind.Actor)) @@ -815,7 +1030,10 @@ namespace Emby.Server.Implementations.Dto /// <param name="item">The item.</param> /// <param name="owner">The owner.</param> /// <param name="options">The options.</param> - private void AttachBasicFields(BaseItemDto dto, BaseItem item, BaseItem? owner, DtoOptions options) + /// <param name="artistsBatch">Optional pre-fetched artist lookup shared across a batch of items.</param> + /// <param name="user">The user, for per-user values such as the accessible media source count.</param> + /// <param name="alternateVersionItemIds">Optional pre-fetched set of item IDs that own alternate versions, shared across a batch of items.</param> + private void AttachBasicFields(BaseItemDto dto, BaseItem item, BaseItem? owner, DtoOptions options, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null, User? user = null, IReadOnlySet<Guid>? alternateVersionItemIds = null) { if (options.ContainsField(ItemFields.DateCreated)) { @@ -939,12 +1157,14 @@ namespace Emby.Server.Implementations.Dto dto.OriginalTitle = item.OriginalTitle; } + dto.OriginalLanguage = item.OriginalLanguage; + if (options.ContainsField(ItemFields.ParentId)) { dto.ParentId = item.DisplayParentId; } - AddInheritedImages(dto, item, options, owner); + AddInheritedImages(dto, item, options, owner, artistsBatch); if (options.ContainsField(ItemFields.Path)) { @@ -1019,6 +1239,15 @@ namespace Emby.Server.Implementations.Dto { dto.AlbumId = albumParent.Id; dto.AlbumPrimaryImageTag = GetTagAndFillBlurhash(dto, albumParent, ImageType.Primary); + if (albumParent.LUFS.HasValue) + { + // -18 LUFS reference, same as ReplayGain 2.0, compatible with ReplayGain 1.0 + dto.AlbumNormalizationGain = -18f - albumParent.LUFS; + } + else if (albumParent.NormalizationGain.HasValue) + { + dto.AlbumNormalizationGain = albumParent.NormalizationGain; + } } // if (options.ContainsField(ItemFields.MediaSourceCount)) @@ -1051,7 +1280,8 @@ namespace Emby.Server.Implementations.Dto // Include artists that are not in the database yet, e.g., just added via metadata editor // var foundArtists = artistItems.Items.Select(i => i.Item1.Name).ToList(); - var artistsLookup = _libraryManager.GetArtists([.. hasArtist.Artists.Where(e => !string.IsNullOrWhiteSpace(e))]); + var artistsLookup = artistsBatch + ?? _libraryManager.GetArtists([.. hasArtist.Artists.Where(e => !string.IsNullOrWhiteSpace(e))]); dto.ArtistItems = hasArtist.Artists .Where(name => !string.IsNullOrWhiteSpace(name)) @@ -1085,7 +1315,8 @@ namespace Emby.Server.Implementations.Dto // }) // .ToList(); - var albumArtistsLookup = _libraryManager.GetArtists([.. hasAlbumArtist.AlbumArtists.Where(e => !string.IsNullOrWhiteSpace(e))]); + var albumArtistsLookup = artistsBatch + ?? _libraryManager.GetArtists([.. hasAlbumArtist.AlbumArtists.Where(e => !string.IsNullOrWhiteSpace(e))]); dto.AlbumArtists = hasAlbumArtist.AlbumArtists .Where(name => !string.IsNullOrWhiteSpace(name)) @@ -1116,16 +1347,28 @@ namespace Emby.Server.Implementations.Dto if (options.ContainsField(ItemFields.MediaSourceCount)) { - var mediaSourceCount = video.MediaSourceCount; - if (mediaSourceCount != 1) - { - dto.MediaSourceCount = mediaSourceCount; - } - } + // A video with no primary version and no alternate versions always has a single + // media source. Only compute the count for videos that might have more: a primary + // version, or membership in the batch's set of items that own alternate versions. + // Without the batch we can't rule it out, so fall back to computing (the single-item + // path). Everything else is the common case and keeps the default count of one. + var mayHaveAlternateVersions = alternateVersionItemIds is null + || video.PrimaryVersionId.HasValue + || alternateVersionItemIds.Contains(video.Id); - if (options.ContainsField(ItemFields.Chapters)) - { - dto.Chapters = _chapterManager.GetChapters(item.Id).ToList(); + if (mayHaveAlternateVersions) + { + // Match the per-user filtering of the media sources: versions the user cannot + // access are not selectable, so they must not count towards the badge either. + var mediaSourceCount = user is null + || (!video.PrimaryVersionId.HasValue && video.LinkedAlternateVersions.Length == 0 && !video.HasLocalAlternateVersions) + ? video.MediaSourceCount + : video.GetAllVersions().Count(v => v.Id.Equals(video.Id) || v.IsVisibleStandalone(user)); + if (mediaSourceCount != 1) + { + dto.MediaSourceCount = mediaSourceCount; + } + } } if (options.ContainsField(ItemFields.Trickplay)) @@ -1141,6 +1384,11 @@ namespace Emby.Server.Implementations.Dto dto.ExtraType = video.ExtraType; } + if (options.ContainsField(ItemFields.Chapters)) + { + dto.Chapters = _chapterManager.GetChapters(item.Id).ToList(); + } + if (options.ContainsField(ItemFields.MediaStreams)) { // Add VideoInfo @@ -1225,6 +1473,25 @@ namespace Emby.Server.Implementations.Dto } } + if (options.GetImageLimit(ImageType.Primary) > 0) + { + var episodeSeason = episode.Season; + var seasonPrimaryTag = episodeSeason is not null + ? GetTagAndFillBlurhash(dto, episodeSeason, ImageType.Primary) + : null; + + if (seasonPrimaryTag is not null) + { + dto.ParentPrimaryImageItemId = episodeSeason!.Id; + dto.ParentPrimaryImageTag = seasonPrimaryTag; + } + else if (episodeSeries is not null && dto.SeriesPrimaryImageTag is not null) + { + dto.ParentPrimaryImageItemId = episodeSeries.Id; + dto.ParentPrimaryImageTag = dto.SeriesPrimaryImageTag; + } + } + if (options.ContainsField(ItemFields.SeriesStudio)) { episodeSeries ??= episode.Series; @@ -1340,11 +1607,11 @@ namespace Emby.Server.Implementations.Dto } } - private BaseItem? GetImageDisplayParent(BaseItem currentItem, BaseItem originalItem) + private BaseItem? GetImageDisplayParent(BaseItem currentItem, BaseItem originalItem, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch) { if (currentItem is MusicAlbum musicAlbum) { - var artist = musicAlbum.GetMusicArtist(new DtoOptions(false)); + var artist = GetBatchedAlbumArtist(musicAlbum, artistsBatch) ?? musicAlbum.GetMusicArtist(new DtoOptions(false)); if (artist is not null) { return artist; @@ -1361,8 +1628,36 @@ namespace Emby.Server.Implementations.Dto return parent; } - private void AddInheritedImages(BaseItemDto dto, BaseItem item, DtoOptions options, BaseItem? owner) + private static MusicArtist? GetBatchedAlbumArtist(MusicAlbum album, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch) { + if (artistsBatch is null) + { + return null; + } + + var name = album.AlbumArtists.Count > 0 ? album.AlbumArtists[0] : null; + return !string.IsNullOrEmpty(name) && artistsBatch.TryGetValue(name, out var artists) && artists.Length > 0 + ? artists[0] + : null; + } + + private void AddInheritedImages(BaseItemDto dto, BaseItem item, DtoOptions options, BaseItem? owner, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch) + { + if (item is UserView { ViewType: CollectionType.playlists } playlistsView + && options.GetImageLimit(ImageType.Primary) > 0 + && !playlistsView.DisplayParentId.IsEmpty()) + { + var displayParent = _libraryManager.GetItemById(playlistsView.DisplayParentId); + var displayParentPrimaryImage = displayParent?.GetImageInfo(ImageType.Primary, 0); + + if (displayParentPrimaryImage is not null) + { + dto.ImageTags?.Remove(ImageType.Primary); + dto.ParentPrimaryImageItemId = displayParent!.Id; + dto.ParentPrimaryImageTag = GetTagAndFillBlurhash(dto, displayParent, displayParentPrimaryImage); + } + } + if (!item.SupportsInheritedParentImages) { return; @@ -1391,7 +1686,7 @@ namespace Emby.Server.Implementations.Dto || (!(imageTags is not null && imageTags.ContainsKey(ImageType.Thumb)) && thumbLimit > 0) || parent is Series) { - parent ??= isFirst ? GetImageDisplayParent(item, item) ?? owner : parent; + parent ??= isFirst ? GetImageDisplayParent(item, item, artistsBatch) ?? owner : parent; if (parent is null) { break; @@ -1450,7 +1745,7 @@ namespace Emby.Server.Implementations.Dto break; } - parent = GetImageDisplayParent(parent, item); + parent = GetImageDisplayParent(parent, item, artistsBatch); } } diff --git a/Emby.Server.Implementations/Emby.Server.Implementations.csproj b/Emby.Server.Implementations/Emby.Server.Implementations.csproj index abdb0679b1..f0a5f1e2c3 100644 --- a/Emby.Server.Implementations/Emby.Server.Implementations.csproj +++ b/Emby.Server.Implementations/Emby.Server.Implementations.csproj @@ -27,7 +27,6 @@ <PackageReference Include="Microsoft.Data.Sqlite" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" /> <PackageReference Include="Microsoft.Extensions.Caching.Memory" /> - <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" /> <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" /> <PackageReference Include="prometheus-net.DotNetRuntime" /> @@ -39,7 +38,7 @@ </ItemGroup> <PropertyGroup> - <TargetFramework>net9.0</TargetFramework> + <TargetFramework>net10.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> </PropertyGroup> diff --git a/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs b/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs index 933cfc8cbe..02b104756e 100644 --- a/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs +++ b/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs @@ -27,6 +27,11 @@ namespace Emby.Server.Implementations.EntryPoints; /// </summary> public sealed class LibraryChangedNotifier : IHostedService, IDisposable { + // A batch holds a live reference to every item it names, so it has to stay small enough that a + // library scan - which changes items faster than any batch window closes - cannot grow it without + // bound. Reached only by a scan; interactive use closes a batch on the window long before this. + internal const int MaxBatchSize = 2000; + private readonly ILibraryManager _libraryManager; private readonly IServerConfigurationManager _configurationManager; private readonly IProviderManager _providerManager; @@ -35,11 +40,11 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable private readonly ILogger<LibraryChangedNotifier> _logger; private readonly Lock _libraryChangedSyncLock = new(); - private readonly List<Folder> _foldersAddedTo = new(); - private readonly List<Folder> _foldersRemovedFrom = new(); - private readonly List<BaseItem> _itemsAdded = new(); - private readonly List<BaseItem> _itemsRemoved = new(); - private readonly List<BaseItem> _itemsUpdated = new(); + private readonly Dictionary<Guid, Folder> _foldersAddedTo = []; + private readonly Dictionary<Guid, Folder> _foldersRemovedFrom = []; + private readonly Dictionary<Guid, BaseItem> _itemsAdded = []; + private readonly Dictionary<Guid, BaseItem> _itemsRemoved = []; + private readonly Dictionary<Guid, BaseItem> _itemsUpdated = []; private readonly ConcurrentDictionary<Guid, DateTime> _lastProgressMessageTimes = new(); private Timer? _libraryUpdateTimer; @@ -173,7 +178,7 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable private void OnLibraryItemRemoved(object? sender, ItemChangeEventArgs e) => OnLibraryChange(e.Item, e.Parent, _itemsRemoved, _foldersRemovedFrom); - private void OnLibraryChange(BaseItem item, BaseItem parent, List<BaseItem> itemsList, List<Folder>? foldersList) + private void OnLibraryChange(BaseItem item, BaseItem parent, Dictionary<Guid, BaseItem> itemsList, Dictionary<Guid, Folder>? foldersList) { if (!FilterItem(item)) { @@ -182,23 +187,28 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable lock (_libraryChangedSyncLock) { - var updateDuration = TimeSpan.FromSeconds(_configurationManager.Configuration.LibraryUpdateDuration); - + // The window runs from the first change of a batch and is never extended. Extending it on + // every change would keep a library scan's batch open for the whole scan, and the batch + // holds the items it names alive, so it would grow to the size of the library. if (_libraryUpdateTimer is null) { + var updateDuration = TimeSpan.FromSeconds(_configurationManager.Configuration.LibraryUpdateDuration); _libraryUpdateTimer = new Timer(LibraryUpdateTimerCallback, null, updateDuration, Timeout.InfiniteTimeSpan); } - else - { - _libraryUpdateTimer.Change(updateDuration, Timeout.InfiniteTimeSpan); - } if (foldersList is not null && parent is Folder folder) { - foldersList.Add(folder); + foldersList[folder.Id] = folder; } - itemsList.Add(item); + itemsList[item.Id] = item; + + // A window long enough to cover a burst still has to give way once the batch is large + // enough to be worth sending on its own. + if (_itemsAdded.Count + _itemsRemoved.Count + _itemsUpdated.Count >= MaxBatchSize) + { + _libraryUpdateTimer.Change(TimeSpan.Zero, Timeout.InfiniteTimeSpan); + } } } @@ -211,22 +221,16 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable List<BaseItem> itemsRemoved; lock (_libraryChangedSyncLock) { - // Remove dupes in case some were saved multiple times - foldersAddedTo = _foldersAddedTo - .DistinctBy(x => x.Id) - .ToList(); - - foldersRemovedFrom = _foldersRemovedFrom - .DistinctBy(x => x.Id) - .ToList(); + foldersAddedTo = _foldersAddedTo.Values.ToList(); + foldersRemovedFrom = _foldersRemovedFrom.Values.ToList(); itemsUpdated = _itemsUpdated - .Where(i => !_itemsAdded.Contains(i)) - .DistinctBy(x => x.Id) + .Where(e => !_itemsAdded.ContainsKey(e.Key)) + .Select(e => e.Value) .ToList(); - itemsAdded = _itemsAdded.ToList(); - itemsRemoved = _itemsRemoved.ToList(); + itemsAdded = _itemsAdded.Values.ToList(); + itemsRemoved = _itemsRemoved.Values.ToList(); if (_libraryUpdateTimer is not null) { @@ -241,6 +245,15 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable _foldersRemovedFrom.Clear(); } + if (itemsAdded.Count == 0 + && itemsUpdated.Count == 0 + && itemsRemoved.Count == 0 + && foldersAddedTo.Count == 0 + && foldersRemovedFrom.Count == 0) + { + return; + } + await SendChangeNotifications(itemsAdded, itemsUpdated, itemsRemoved, foldersAddedTo, foldersRemovedFrom, CancellationToken.None).ConfigureAwait(false); } diff --git a/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs b/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs index fc174b7c14..b182e5837b 100644 --- a/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs +++ b/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs @@ -18,15 +18,17 @@ namespace Emby.Server.Implementations.EntryPoints public sealed class UserDataChangeNotifier : IHostedService, IDisposable { private const int UpdateDuration = 500; + internal const int MaxBatchSize = 2000; private readonly ISessionManager _sessionManager; private readonly IUserDataManager _userDataManager; private readonly IUserManager _userManager; - private readonly Dictionary<Guid, List<BaseItem>> _changedItems = new(); + private readonly Dictionary<Guid, Dictionary<Guid, BaseItem>> _changedItems = []; private readonly Lock _syncLock = new(); private Timer? _updateTimer; + private int _changedItemCount; /// <summary> /// Initializes a new instance of the <see cref="UserDataChangeNotifier"/> class. @@ -69,50 +71,64 @@ namespace Emby.Server.Implementations.EntryPoints lock (_syncLock) { - if (_updateTimer is null) - { - _updateTimer = new Timer( - UpdateTimerCallback, - null, - UpdateDuration, - Timeout.Infinite); - } - else - { - _updateTimer.Change(UpdateDuration, Timeout.Infinite); - } + // The window runs from the first change of a batch and is never extended, so a stream + // of changes that never pauses - a library scan - still closes its batches instead of + // holding every item it touched alive until the stream stops. + _updateTimer ??= new Timer( + UpdateTimerCallback, + null, + UpdateDuration, + Timeout.Infinite); - if (!_changedItems.TryGetValue(e.UserId, out List<BaseItem>? keys)) + if (!_changedItems.TryGetValue(e.UserId, out Dictionary<Guid, BaseItem>? keys)) { - keys = new List<BaseItem>(); + keys = []; _changedItems[e.UserId] = keys; } - keys.Add(e.Item); - var baseItem = e.Item; // Go up one level for indicators if (baseItem is not null) { + Track(keys, baseItem); + var parent = baseItem.GetOwner() ?? baseItem.GetParent(); if (parent is not null) { - keys.Add(parent); + Track(keys, parent); } } + + // A window long enough to cover a burst still has to give way once the batch is + // large enough to be worth sending on its own. + if (_changedItemCount >= MaxBatchSize) + { + _updateTimer.Change(0, Timeout.Infinite); + } + } + } + + private void Track(Dictionary<Guid, BaseItem> keys, BaseItem item) + { + var before = keys.Count; + keys[item.Id] = item; + + if (keys.Count != before) + { + _changedItemCount++; } } private async void UpdateTimerCallback(object? state) { - List<KeyValuePair<Guid, List<BaseItem>>> changes; + List<KeyValuePair<Guid, Dictionary<Guid, BaseItem>>> changes; lock (_syncLock) { - // Remove dupes in case some were saved multiple times changes = _changedItems.ToList(); _changedItems.Clear(); + _changedItemCount = 0; if (_updateTimer is not null) { @@ -121,17 +137,22 @@ namespace Emby.Server.Implementations.EntryPoints } } + if (changes.Count == 0) + { + return; + } + foreach (var (userId, changedItems) in changes) { await _sessionManager.SendMessageToUserSessions( [userId], SessionMessageType.UserDataChanged, - () => GetUserDataChangeInfo(userId, changedItems), + () => GetUserDataChangeInfo(userId, changedItems.Values), default).ConfigureAwait(false); } } - private UserDataChangeInfo GetUserDataChangeInfo(Guid userId, List<BaseItem> changedItems) + private UserDataChangeInfo GetUserDataChangeInfo(Guid userId, IEnumerable<BaseItem> changedItems) { var user = _userManager.GetUserById(userId) ?? throw new ArgumentException("Invalid user ID", nameof(userId)); @@ -140,7 +161,6 @@ namespace Emby.Server.Implementations.EntryPoints { UserId = userId, UserDataList = changedItems - .DistinctBy(x => x.Id) .Select(i => { var dto = _userDataManager.GetUserDataDto(i, user); diff --git a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs index 373b0994a6..dc7f972c13 100644 --- a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs +++ b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs @@ -1,5 +1,6 @@ using System; using System.Buffers; +using System.Globalization; using System.IO.Pipelines; using System.Net; using System.Net.WebSockets; @@ -69,6 +70,11 @@ namespace Emby.Server.Implementations.HttpServer /// <inheritdoc /> public IPAddress? RemoteEndPoint { get; } + /// <summary> + /// Gets or initializes the UI culture captured from the upgrade request. + /// </summary> + public CultureInfo? RequestUICulture { get; init; } + /// <inheritdoc /> public Func<WebSocketMessageInfo, Task>? OnReceive { get; set; } @@ -81,6 +87,17 @@ namespace Emby.Server.Implementations.HttpServer /// <inheritdoc /> public WebSocketState State => _socket.State; + /// <inheritdoc /> + public void ApplyRequestCulture() + { + if (RequestUICulture is null) + { + return; + } + + CultureInfo.CurrentUICulture = RequestUICulture; + } + /// <inheritdoc /> public async Task SendAsync(OutboundWebSocketMessage message, CancellationToken cancellationToken) { @@ -110,8 +127,12 @@ namespace Emby.Server.Implementations.HttpServer { receiveResult = await _socket.ReceiveAsync(memory, cancellationToken).ConfigureAwait(false); } - catch (WebSocketException ex) + catch (Exception ex) when (ex is WebSocketException or ObjectDisposedException or OperationCanceledException) { + // ObjectDisposedException/OperationCanceledException: the socket was torn + // down underneath us (e.g. by the keep-alive watchdog after the connection + // was declared lost). Fall through so Closed is still raised and the + // session can release this connection. _logger.LogWarning("WS {IP} error receiving data: {Message}", RemoteEndPoint, ex.Message); break; } diff --git a/Emby.Server.Implementations/HttpServer/WebSocketManager.cs b/Emby.Server.Implementations/HttpServer/WebSocketManager.cs index cb5b3993b8..072034c4bf 100644 --- a/Emby.Server.Implementations/HttpServer/WebSocketManager.cs +++ b/Emby.Server.Implementations/HttpServer/WebSocketManager.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Net.WebSockets; using System.Threading.Tasks; @@ -47,14 +48,18 @@ namespace Emby.Server.Implementations.HttpServer _logger.LogInformation("WS {IP} request", context.Connection.RemoteIpAddress); WebSocket webSocket = await context.WebSockets.AcceptWebSocketAsync().ConfigureAwait(false); - var connection = new WebSocketConnection( _loggerFactory.CreateLogger<WebSocketConnection>(), webSocket, authorizationInfo, context.GetNormalizedRemoteIP()) { - OnReceive = ProcessWebSocketMessageReceived + RequestUICulture = CultureInfo.CurrentUICulture + }; + connection.OnReceive = result => + { + connection.ApplyRequestCulture(); + return ProcessWebSocketMessageReceived(result); }; await using (connection.ConfigureAwait(false)) { diff --git a/Emby.Server.Implementations/IO/LibraryMonitor.cs b/Emby.Server.Implementations/IO/LibraryMonitor.cs index 7cff2a25b6..0f92e2f03e 100644 --- a/Emby.Server.Implementations/IO/LibraryMonitor.cs +++ b/Emby.Server.Implementations/IO/LibraryMonitor.cs @@ -8,6 +8,7 @@ using Emby.Server.Implementations.Library; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; using MediaBrowser.Model.IO; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -21,6 +22,8 @@ namespace Emby.Server.Implementations.IO private readonly ILibraryManager _libraryManager; private readonly IServerConfigurationManager _configurationManager; private readonly IFileSystem _fileSystem; + private readonly IDirectoryService _directoryService; + private readonly DotIgnoreIgnoreRule _dotIgnoreIgnoreRule; /// <summary> /// The file system watchers. @@ -46,20 +49,27 @@ namespace Emby.Server.Implementations.IO /// <param name="libraryManager">The library manager.</param> /// <param name="configurationManager">The configuration manager.</param> /// <param name="fileSystem">The filesystem.</param> + /// <param name="directoryService">The directory service.</param> /// <param name="appLifetime">The <see cref="IHostApplicationLifetime"/>.</param> + /// <param name="dotIgnoreIgnoreRule">The .ignore rule handler.</param> public LibraryMonitor( ILogger<LibraryMonitor> logger, ILibraryManager libraryManager, IServerConfigurationManager configurationManager, IFileSystem fileSystem, - IHostApplicationLifetime appLifetime) + IDirectoryService directoryService, + IHostApplicationLifetime appLifetime, + DotIgnoreIgnoreRule dotIgnoreIgnoreRule) { _libraryManager = libraryManager; _logger = logger; _configurationManager = configurationManager; _fileSystem = fileSystem; + _directoryService = directoryService; + _dotIgnoreIgnoreRule = dotIgnoreIgnoreRule; appLifetime.ApplicationStarted.Register(Start); + appLifetime.ApplicationStopping.Register(Stop); } /// <inheritdoc /> @@ -353,11 +363,13 @@ namespace Emby.Server.Implementations.IO } var fileInfo = _fileSystem.GetFileSystemInfo(path); - if (DotIgnoreIgnoreRule.IsIgnored(fileInfo, null)) + if (_dotIgnoreIgnoreRule.ShouldIgnore(fileInfo, null)) { return; } + _directoryService.Invalidate(path); + // Ignore certain files, If the parent of an ignored path has a change event, ignore that too foreach (var i in _tempIgnoredPaths.Keys) { diff --git a/Emby.Server.Implementations/IO/ManagedFileSystem.cs b/Emby.Server.Implementations/IO/ManagedFileSystem.cs index 4d68cb4444..ede9b27592 100644 --- a/Emby.Server.Implementations/IO/ManagedFileSystem.cs +++ b/Emby.Server.Implementations/IO/ManagedFileSystem.cs @@ -586,6 +586,12 @@ namespace Emby.Server.Implementations.IO /// <inheritdoc /> public virtual IEnumerable<FileSystemMetadata> GetFiles(string path, string searchPattern, IReadOnlyList<string>? extensions, bool enableCaseSensitiveExtensions, bool recursive = false) { + if (!Directory.Exists(path)) + { + _logger.LogWarning("Directory does not exist: {Path}", path); + return []; + } + var enumerationOptions = GetEnumerationOptions(recursive); // On linux and macOS the search pattern is case-sensitive @@ -685,7 +691,7 @@ namespace Emby.Server.Implementations.IO } catch (Exception ex) when (ex is UnauthorizedAccessException or DirectoryNotFoundException or SecurityException) { - _logger.LogError(ex, "Failed to enumerate path {Path}", path); + _logger.LogWarning("Failed to enumerate path \"{Path}\": {Message}", path, ex.Message); return Enumerable.Empty<string>(); } } diff --git a/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs b/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs index a25373326f..7cae2a671b 100644 --- a/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs +++ b/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.IO; +using Jellyfin.Api.Extensions; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Enums; using MediaBrowser.Common.Configuration; @@ -14,7 +15,6 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; -using MediaBrowser.Model.Querying; namespace Emby.Server.Implementations.Images { @@ -28,51 +28,23 @@ namespace Emby.Server.Implementations.Images { var view = (CollectionFolder)item; var viewType = view.CollectionType; - - BaseItemKind[] includeItemTypes; - - switch (viewType) - { - case CollectionType.movies: - includeItemTypes = new[] { BaseItemKind.Movie }; - break; - case CollectionType.tvshows: - includeItemTypes = new[] { BaseItemKind.Series }; - break; - case CollectionType.music: - includeItemTypes = new[] { BaseItemKind.MusicAlbum }; - break; - case CollectionType.musicvideos: - includeItemTypes = new[] { BaseItemKind.MusicVideo }; - break; - case CollectionType.books: - includeItemTypes = new[] { BaseItemKind.Book, BaseItemKind.AudioBook }; - break; - case CollectionType.boxsets: - includeItemTypes = new[] { BaseItemKind.BoxSet }; - break; - case CollectionType.homevideos: - case CollectionType.photos: - includeItemTypes = new[] { BaseItemKind.Video, BaseItemKind.Photo }; - break; - default: - includeItemTypes = new[] { BaseItemKind.Video, BaseItemKind.Audio, BaseItemKind.Photo, BaseItemKind.Movie, BaseItemKind.Series }; - break; - } - + var includeItemTypes = DtoExtensions.GetBaseItemKindsForCollectionType(viewType); var recursive = viewType != CollectionType.playlists; + if (viewType == CollectionType.music) + { + // Music albums usually don't have dedicated backdrops, so use artist instead + includeItemTypes = [BaseItemKind.MusicArtist]; + } + return view.GetItemList(new InternalItemsQuery { CollapseBoxSetItems = false, Recursive = recursive, DtoOptions = new DtoOptions(false), - ImageTypes = new[] { ImageType.Primary }, + ImageTypes = [ImageType.Primary], Limit = 8, - OrderBy = new[] - { - (ItemSortBy.Random, SortOrder.Ascending) - }, + OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)], IncludeItemTypes = includeItemTypes }); } diff --git a/Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs b/Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs index ef5d24c70f..023c1e8915 100644 --- a/Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs +++ b/Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs @@ -1,6 +1,8 @@ using System; +using System.Collections.Generic; using System.IO; using System.Text.RegularExpressions; +using BitFaster.Caching.Lru; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Resolvers; @@ -15,22 +17,36 @@ public class DotIgnoreIgnoreRule : IResolverIgnoreRule { private static readonly bool IsWindows = OperatingSystem.IsWindows(); - private static FileInfo? FindIgnoreFile(DirectoryInfo directory) - { - for (var current = directory; current is not null; current = current.Parent) - { - var ignorePath = Path.Join(current.FullName, ".ignore"); - if (File.Exists(ignorePath)) - { - return new FileInfo(ignorePath); - } - } + private readonly FastConcurrentLru<string, IgnoreFileCacheEntry> _directoryCache; + private readonly FastConcurrentLru<string, ParsedIgnoreCacheEntry> _rulesCache; - return null; + /// <summary> + /// Initializes a new instance of the <see cref="DotIgnoreIgnoreRule"/> class. + /// </summary> + public DotIgnoreIgnoreRule() + { + var cacheSize = Math.Max(100, Environment.ProcessorCount * 100); + _directoryCache = new FastConcurrentLru<string, IgnoreFileCacheEntry>( + Environment.ProcessorCount, + cacheSize, + StringComparer.Ordinal); + _rulesCache = new FastConcurrentLru<string, ParsedIgnoreCacheEntry>( + Environment.ProcessorCount, + Math.Max(32, cacheSize / 4), + StringComparer.Ordinal); } /// <inheritdoc /> - public bool ShouldIgnore(FileSystemMetadata fileInfo, BaseItem? parent) => IsIgnored(fileInfo, parent); + public bool ShouldIgnore(FileSystemMetadata fileInfo, BaseItem? parent) => IsIgnoredInternal(fileInfo, parent); + + /// <summary> + /// Clears the directory lookup cache. The parsed rules cache is not cleared + /// as it validates file modification time on each access. + /// </summary> + public void ClearDirectoryCache() + { + _directoryCache.Clear(); + } /// <summary> /// Checks whether or not the file is ignored. @@ -38,40 +54,38 @@ public class DotIgnoreIgnoreRule : IResolverIgnoreRule /// <param name="fileInfo">The file information.</param> /// <param name="parent">The parent BaseItem.</param> /// <returns>True if the file should be ignored.</returns> - public static bool IsIgnored(FileSystemMetadata fileInfo, BaseItem? parent) + public bool IsIgnoredInternal(FileSystemMetadata fileInfo, BaseItem? parent) { var searchDirectory = fileInfo.IsDirectory - ? new DirectoryInfo(fileInfo.FullName) - : new DirectoryInfo(Path.GetDirectoryName(fileInfo.FullName) ?? string.Empty); + ? fileInfo.FullName + : Path.GetDirectoryName(fileInfo.FullName); - if (string.IsNullOrEmpty(searchDirectory.FullName)) + if (string.IsNullOrEmpty(searchDirectory)) { return false; } - var ignoreFile = FindIgnoreFile(searchDirectory); + var ignoreFile = FindIgnoreFileCached(searchDirectory); if (ignoreFile is null) { return false; } - // Fast path in case the ignore files isn't a symlink and is empty - if (ignoreFile.LinkTarget is null && ignoreFile.Length == 0) + var parsedEntry = GetParsedRules(ignoreFile); + if (parsedEntry is null) + { + // File was deleted after we cached the path - clear the directory cache entry and return false + _directoryCache.TryRemove(searchDirectory, out _); + return false; + } + + // Empty file means ignore everything + if (parsedEntry.IsEmpty) { - // Ignore directory if we just have the file return true; } - var content = GetFileContent(ignoreFile); - return string.IsNullOrWhiteSpace(content) - || CheckIgnoreRules(fileInfo.FullName, content, fileInfo.IsDirectory); - } - - private static bool CheckIgnoreRules(string path, string ignoreFileContent, bool isDirectory) - { - // If file has content, base ignoring off the content .gitignore-style rules - var rules = ignoreFileContent.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - return CheckIgnoreRules(path, rules, isDirectory); + return parsedEntry.Rules.IsIgnored(GetPathToCheck(fileInfo.FullName, fileInfo.IsDirectory)); } /// <summary> @@ -117,8 +131,8 @@ public class DotIgnoreIgnoreRule : IResolverIgnoreRule return true; } - // Mitigate the problem of the Ignore library not handling Windows paths correctly. - // See https://github.com/jellyfin/jellyfin/issues/15484 + // Mitigate the problem of the Ignore library not handling Windows paths correctly. + // See https://github.com/jellyfin/jellyfin/issues/15484 var pathToCheck = normalizePath ? path.NormalizePath('/') : path; // Add trailing slash for directories to match "folder/" @@ -130,11 +144,196 @@ public class DotIgnoreIgnoreRule : IResolverIgnoreRule return ignore.IsIgnored(pathToCheck); } - private static string GetFileContent(FileInfo ignoreFile) + private FileInfo? FindIgnoreFileCached(string directory) { - ignoreFile = FileSystemHelper.ResolveLinkTarget(ignoreFile, returnFinalTarget: true) ?? ignoreFile; - return ignoreFile.Exists - ? File.ReadAllText(ignoreFile.FullName) - : string.Empty; + // Check if we have a cached result for this directory + if (_directoryCache.TryGet(directory, out var cached)) + { + return cached.IgnoreFileDirectory is null + ? null + : new FileInfo(Path.Join(cached.IgnoreFileDirectory, ".ignore")); + } + + DirectoryInfo startDir; + try + { + startDir = new DirectoryInfo(directory); + } + catch (ArgumentException) + { + return null; + } + + // Walk up the directory tree to find .ignore file using DirectoryInfo.Parent + var checkedDirs = new List<string> { directory }; + + for (var current = startDir; current is not null; current = current.Parent) + { + var currentPath = current.FullName; + + // Check if this intermediate directory is cached + if (current != startDir && _directoryCache.TryGet(currentPath, out var parentCached)) + { + // Cache the result for all directories we checked + var entry = new IgnoreFileCacheEntry(parentCached.IgnoreFileDirectory); + foreach (var dir in checkedDirs) + { + _directoryCache.AddOrUpdate(dir, entry); + } + + return parentCached.IgnoreFileDirectory is null + ? null + : new FileInfo(Path.Join(parentCached.IgnoreFileDirectory, ".ignore")); + } + + var ignoreFile = new FileInfo(Path.Join(currentPath, ".ignore")); + if (ignoreFile.Exists) + { + // Cache for all directories we checked + var entry = new IgnoreFileCacheEntry(currentPath); + foreach (var dir in checkedDirs) + { + _directoryCache.AddOrUpdate(dir, entry); + } + + return ignoreFile; + } + + if (current != startDir) + { + checkedDirs.Add(currentPath); + } + } + + // No .ignore file found - cache null result for all directories + var nullEntry = new IgnoreFileCacheEntry((string?)null); + foreach (var dir in checkedDirs) + { + _directoryCache.AddOrUpdate(dir, nullEntry); + } + + return null; + } + + private ParsedIgnoreCacheEntry? GetParsedRules(FileInfo ignoreFile) + { + if (!ignoreFile.Exists) + { + _rulesCache.TryRemove(ignoreFile.FullName, out _); + return null; + } + + var lastModified = ignoreFile.LastWriteTimeUtc; + var fileLength = ignoreFile.Length; + var key = ignoreFile.FullName; + + // Check cache + if (_rulesCache.TryGet(key, out var cached)) + { + if (cached.FileLastModified == lastModified && cached.FileLength == fileLength) + { + return cached; + } + + // Stale - need to reparse + _rulesCache.TryRemove(key, out _); + } + + // Parse the file + var parsedEntry = ParseIgnoreFile(ignoreFile, lastModified, fileLength); + _rulesCache.AddOrUpdate(key, parsedEntry); + return parsedEntry; + } + + private static ParsedIgnoreCacheEntry ParseIgnoreFile(FileInfo ignoreFile, DateTime lastModified, long fileLength) + { + if (ignoreFile.LinkTarget is null && fileLength == 0) + { + return new ParsedIgnoreCacheEntry + { + Rules = new Ignore.Ignore(), + FileLastModified = lastModified, + FileLength = fileLength, + IsEmpty = true + }; + } + + // Resolve symlinks + var resolvedFile = FileSystemHelper.ResolveLinkTarget(ignoreFile, returnFinalTarget: true) ?? ignoreFile; + if (!resolvedFile.Exists) + { + return new ParsedIgnoreCacheEntry + { + Rules = new Ignore.Ignore(), + FileLastModified = lastModified, + FileLength = fileLength, + IsEmpty = true + }; + } + + var content = File.ReadAllText(resolvedFile.FullName); + if (string.IsNullOrWhiteSpace(content)) + { + return new ParsedIgnoreCacheEntry + { + Rules = new Ignore.Ignore(), + FileLastModified = lastModified, + FileLength = fileLength, + IsEmpty = true + }; + } + + var rules = content.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var ignore = new Ignore.Ignore(); + var validRulesAdded = 0; + + foreach (var rule in rules) + { + try + { + ignore.Add(rule); + validRulesAdded++; + } + catch (RegexParseException) + { + // Ignore invalid patterns + } + } + + // No valid rules means treat as empty (ignore all) + return new ParsedIgnoreCacheEntry + { + Rules = ignore, + FileLastModified = lastModified, + FileLength = fileLength, + IsEmpty = validRulesAdded == 0 + }; + } + + private static string GetPathToCheck(string path, bool isDirectory) + { + // Normalize Windows paths + var pathToCheck = IsWindows ? path.NormalizePath('/') : path; + + // Add trailing slash for directories to match "folder/" + if (isDirectory) + { + pathToCheck = string.Concat(pathToCheck.AsSpan().TrimEnd('/'), "/"); + } + + return pathToCheck; + } + + private readonly record struct IgnoreFileCacheEntry(string? IgnoreFileDirectory); + + private sealed class ParsedIgnoreCacheEntry + { + public required Ignore.Ignore Rules { get; init; } + + public required DateTime FileLastModified { get; init; } + + public required long FileLength { get; init; } + + public required bool IsEmpty { get; init; } } } diff --git a/Emby.Server.Implementations/Library/ExternalDataManager.cs b/Emby.Server.Implementations/Library/ExternalDataManager.cs index 4ad0f999bf..2c18e56df7 100644 --- a/Emby.Server.Implementations/Library/ExternalDataManager.cs +++ b/Emby.Server.Implementations/Library/ExternalDataManager.cs @@ -1,6 +1,5 @@ using System; using System.IO; -using System.Linq; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Chapters; @@ -52,26 +51,33 @@ public class ExternalDataManager : IExternalDataManager /// <inheritdoc/> public async Task DeleteExternalItemDataAsync(BaseItem item, CancellationToken cancellationToken) { - var validPaths = _pathManager.GetExtractedDataPaths(item).Where(Directory.Exists).ToList(); - var itemId = item.Id; - if (validPaths.Count > 0) - { - foreach (var path in validPaths) - { - try - { - Directory.Delete(path, true); - } - catch (Exception ex) - { - _logger.LogWarning("Unable to prune external item data at {Path}: {Exception}", path, ex); - } - } - } + DeleteExternalItemFiles(item); + var itemId = item.Id; await _keyframeManager.DeleteKeyframeDataAsync(itemId, cancellationToken).ConfigureAwait(false); await _mediaSegmentManager.DeleteSegmentsAsync(itemId, cancellationToken).ConfigureAwait(false); await _trickplayManager.DeleteTrickplayDataAsync(itemId, cancellationToken).ConfigureAwait(false); await _chapterManager.DeleteChapterDataAsync(itemId, cancellationToken).ConfigureAwait(false); } + + /// <inheritdoc/> + public void DeleteExternalItemFiles(BaseItem item) + { + foreach (var path in _pathManager.GetExtractedDataPaths(item)) + { + if (!Directory.Exists(path)) + { + continue; + } + + try + { + Directory.Delete(path, true); + } + catch (Exception ex) + { + _logger.LogWarning("Unable to prune external item data at {Path}: {Exception}", path, ex); + } + } + } } diff --git a/Emby.Server.Implementations/Library/IgnorePatterns.cs b/Emby.Server.Implementations/Library/IgnorePatterns.cs index 59ccb9e2c7..197ec42c50 100644 --- a/Emby.Server.Implementations/Library/IgnorePatterns.cs +++ b/Emby.Server.Implementations/Library/IgnorePatterns.cs @@ -31,6 +31,20 @@ namespace Emby.Server.Implementations.Library "**/*.sample.?????", "**/sample/*", + // Avoid adding Hungarian sample files + // https://github.com/jellyfin/jellyfin/issues/16237 + "**/minta.?", + "**/minta.??", + "**/minta.???", // Matches minta.mkv + "**/minta.????", // Matches minta.webm + "**/minta.?????", + "**/*.minta.?", + "**/*.minta.??", + "**/*.minta.???", + "**/*.minta.????", + "**/*.minta.?????", + "**/minta/*", + // Directories "**/metadata/**", "**/metadata", diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index aa5b37a94d..0044fcd4dc 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; @@ -13,8 +14,8 @@ using System.Threading.Tasks; using BitFaster.Caching.Lru; using Emby.Naming.Common; using Emby.Naming.TV; +using Emby.Naming.Video; using Emby.Server.Implementations.Library.Resolvers; -using Emby.Server.Implementations.Library.Validators; using Emby.Server.Implementations.Playlists; using Emby.Server.Implementations.ScheduledTasks.Tasks; using Emby.Server.Implementations.Sorting; @@ -30,21 +31,20 @@ using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Controller.MediaEncoding; -using MediaBrowser.Controller.MediaSegments; using MediaBrowser.Controller.Persistence; +using MediaBrowser.Controller.Playlists; using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Resolvers; using MediaBrowser.Controller.Sorting; -using MediaBrowser.Controller.Trickplay; using MediaBrowser.Model.Configuration; -using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Drawing; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Globalization; using MediaBrowser.Model.IO; using MediaBrowser.Model.Library; using MediaBrowser.Model.Querying; @@ -74,15 +74,23 @@ namespace Emby.Server.Implementations.Library private readonly Lazy<IProviderManager> _providerManagerFactory; private readonly Lazy<IUserViewManager> _userViewManagerFactory; private readonly IServerApplicationHost _appHost; - private readonly IMediaEncoder _mediaEncoder; private readonly IFileSystem _fileSystem; private readonly IItemRepository _itemRepository; + private readonly IItemPersistenceService _persistenceService; + private readonly INextUpService _nextUpService; + private readonly IItemCountService _countService; + private readonly ILinkedChildrenService _linkedChildrenService; private readonly IImageProcessor _imageProcessor; private readonly NamingOptions _namingOptions; private readonly IPeopleRepository _peopleRepository; private readonly ExtraResolver _extraResolver; private readonly IPathManager _pathManager; + private readonly ILocalizationManager _localization; + private readonly IDirectoryService _directoryService; private readonly FastConcurrentLru<Guid, BaseItem> _cache; + private readonly DotIgnoreIgnoreRule _dotIgnoreIgnoreRule; + private readonly IMediaStreamRepository _mediaStreamRepository; + private readonly Lazy<IExternalDataManager> _externalDataManagerFactory; /// <summary> /// The _root folder sync lock. @@ -113,13 +121,20 @@ namespace Emby.Server.Implementations.Library /// <param name="fileSystem">The file system.</param> /// <param name="providerManagerFactory">The provider manager.</param> /// <param name="userViewManagerFactory">The user view manager.</param> - /// <param name="mediaEncoder">The media encoder.</param> /// <param name="itemRepository">The item repository.</param> + /// <param name="persistenceService">The item persistence service.</param> + /// <param name="nextUpService">The next up service.</param> + /// <param name="countService">The item count service.</param> + /// <param name="linkedChildrenService">The linked children service.</param> /// <param name="imageProcessor">The image processor.</param> /// <param name="namingOptions">The naming options.</param> /// <param name="directoryService">The directory service.</param> /// <param name="peopleRepository">The people repository.</param> /// <param name="pathManager">The path manager.</param> + /// <param name="dotIgnoreIgnoreRule">The .ignore rule handler.</param> + /// <param name="localization">The localization manager.</param> + /// <param name="mediaStreamRepository">The media stream repository.</param> + /// <param name="externalDataManagerFactory">The external data manager (lazy, to break the DI cycle through ChapterManager).</param> public LibraryManager( IServerApplicationHost appHost, ILoggerFactory loggerFactory, @@ -131,13 +146,20 @@ namespace Emby.Server.Implementations.Library IFileSystem fileSystem, Lazy<IProviderManager> providerManagerFactory, Lazy<IUserViewManager> userViewManagerFactory, - IMediaEncoder mediaEncoder, IItemRepository itemRepository, + IItemPersistenceService persistenceService, + INextUpService nextUpService, + IItemCountService countService, + ILinkedChildrenService linkedChildrenService, IImageProcessor imageProcessor, NamingOptions namingOptions, IDirectoryService directoryService, IPeopleRepository peopleRepository, - IPathManager pathManager) + IPathManager pathManager, + DotIgnoreIgnoreRule dotIgnoreIgnoreRule, + ILocalizationManager localization, + IMediaStreamRepository mediaStreamRepository, + Lazy<IExternalDataManager> externalDataManagerFactory) { _appHost = appHost; _logger = loggerFactory.CreateLogger<LibraryManager>(); @@ -149,8 +171,11 @@ namespace Emby.Server.Implementations.Library _fileSystem = fileSystem; _providerManagerFactory = providerManagerFactory; _userViewManagerFactory = userViewManagerFactory; - _mediaEncoder = mediaEncoder; _itemRepository = itemRepository; + _persistenceService = persistenceService; + _nextUpService = nextUpService; + _countService = countService; + _linkedChildrenService = linkedChildrenService; _imageProcessor = imageProcessor; _cache = new FastConcurrentLru<Guid, BaseItem>(_configurationManager.Configuration.CacheSize); @@ -158,10 +183,16 @@ namespace Emby.Server.Implementations.Library _namingOptions = namingOptions; _peopleRepository = peopleRepository; _pathManager = pathManager; + _dotIgnoreIgnoreRule = dotIgnoreIgnoreRule; + _localization = localization; + _directoryService = directoryService; _extraResolver = new ExtraResolver(loggerFactory.CreateLogger<ExtraResolver>(), namingOptions, directoryService); _configurationManager.ConfigurationUpdated += ConfigurationUpdated; + _mediaStreamRepository = mediaStreamRepository; + _externalDataManagerFactory = externalDataManagerFactory; + RecordConfigurationValues(_configurationManager.Configuration); } @@ -327,9 +358,17 @@ namespace Emby.Server.Implementations.Library DeleteItem(item, options, parent, notifyParentItem); } - public void DeleteItemsUnsafeFast(IEnumerable<BaseItem> items) + public void DeleteItemsUnsafeFast(IReadOnlyCollection<BaseItem> items, bool deleteSourceFiles = false) { - var pathMaps = items.Select(e => (Item: e, InternalPath: GetInternalMetadataPaths(e), DeletePaths: e.GetDeletePaths())).ToArray(); + if (items.Count == 0) + { + return; + } + + var pathMaps = items.Select(e => + (Item: e, + InternalPath: GetInternalMetadataPaths(e), + DeletePaths: deleteSourceFiles ? e.GetDeletePaths() : [])).ToArray(); foreach (var (item, internalPaths, pathsToDelete) in pathMaps) { @@ -363,7 +402,20 @@ namespace Emby.Server.Implementations.Library } } - _itemRepository.DeleteItem([.. pathMaps.Select(f => f.Item.Id)]); + var externalDataManager = _externalDataManagerFactory.Value; + foreach (var (item, _, _) in pathMaps) + { + externalDataManager.DeleteExternalItemFiles(item); + } + + _persistenceService.DeleteItem([.. pathMaps.Select(f => f.Item.Id)]); + + // Evict the deleted items from the cache and announce each removal. + foreach (var (item, _, _) in pathMaps) + { + _cache.TryRemove(item.Id, out _); + ReportItemRemoved(item, item.GetOwner() ?? item.GetParent()); + } } public void DeleteItem(BaseItem item, DeleteOptions options, BaseItem parent, bool notifyParentItem) @@ -406,6 +458,99 @@ namespace Emby.Server.Implementations.Library item.Id); } + // If deleting a primary version video, clear PrimaryVersionId from alternate versions + // OwnerId check: items with OwnerId set are alternate versions or extras, not primaries + if (item is Video video && !video.PrimaryVersionId.HasValue && video.OwnerId.IsEmpty()) + { + var localAlternateIds = GetLocalAlternateVersionIds(video).ToHashSet(); + var allAlternateVersions = localAlternateIds + .Concat(GetLinkedAlternateVersions(video).Select(v => v.Id)) + .Distinct() + .Select(id => GetItemById(id)) + .OfType<Video>() + .ToList(); + + // Partition alternates by whether their files still exist on disk + var alternateVersions = new List<Video>(); + var missingAlternates = new List<Video>(); + foreach (var alt in allAlternateVersions) + { + if (!string.IsNullOrEmpty(alt.Path) && !_fileSystem.FileExists(alt.Path)) + { + missingAlternates.Add(alt); + } + else + { + alternateVersions.Add(alt); + } + } + + // Delete alternates whose files no longer exist to avoid ghost items. + // Clear PrimaryVersionId first so DeleteItem doesn't try to update the primary being deleted. + foreach (var missing in missingAlternates) + { + _logger.LogInformation( + "Deleting missing alternate version {Name} ({Path})", + missing.Name ?? "Unknown name", + missing.Path ?? string.Empty); + missing.SetPrimaryVersionId(null); + missing.OwnerId = Guid.Empty; + missing.LocalAlternateVersions = []; + missing.LinkedAlternateVersions = []; + DeleteItem(missing, new DeleteOptions { DeleteFileLocation = false }, false); + } + + if (alternateVersions.Count > 0) + { + _logger.LogInformation( + "Clearing PrimaryVersionId from {Count} alternate versions of {Name}", + alternateVersions.Count, + item.Name ?? "Unknown name"); + + // Promote the first alternate version to be the new primary + var newPrimary = alternateVersions[0]; + newPrimary.SetPrimaryVersionId(null); + newPrimary.OwnerId = Guid.Empty; + + // Transfer alternate version arrays from old primary to new primary + // so UpdateToRepositoryAsync creates correct LinkedChildren entries + newPrimary.LocalAlternateVersions = video.LocalAlternateVersions + .Where(p => !string.Equals(p, newPrimary.Path, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + newPrimary.LinkedAlternateVersions = video.LinkedAlternateVersions + .Where(lc => !lc.ItemId.HasValue || !lc.ItemId.Value.Equals(newPrimary.Id)) + .ToArray(); + + newPrimary.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).GetAwaiter().GetResult(); + + // Re-route playlist/collection references from deleted primary to new primary + RerouteLinkedChildReferencesAsync(video.Id, newPrimary.Id).GetAwaiter().GetResult(); + + // Update remaining alternates to point to new primary + foreach (var alternate in alternateVersions.Skip(1)) + { + alternate.SetPrimaryVersionId(newPrimary.Id); + // Only set OwnerId for local alternates; linked alternates are independent items + alternate.OwnerId = localAlternateIds.Contains(alternate.Id) ? newPrimary.Id : Guid.Empty; + alternate.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).GetAwaiter().GetResult(); + } + } + } + else if (item is Video alternateVideo && alternateVideo.PrimaryVersionId.HasValue) + { + // If deleting an alternate version, re-route references to its primary + RerouteLinkedChildReferencesAsync(alternateVideo.Id, alternateVideo.PrimaryVersionId.Value).GetAwaiter().GetResult(); + + // Remove deleted alternate from primary's LinkedAlternateVersions + if (GetItemById(alternateVideo.PrimaryVersionId.Value) is Video primaryVideo) + { + primaryVideo.LinkedAlternateVersions = primaryVideo.LinkedAlternateVersions + .Where(lc => !lc.ItemId.HasValue || !lc.ItemId.Value.Equals(alternateVideo.Id)) + .ToArray(); + primaryVideo.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).GetAwaiter().GetResult(); + } + } + var children = item.IsFolder ? ((Folder)item).GetRecursiveChildren(false) : []; @@ -450,7 +595,14 @@ namespace Emby.Server.Implementations.Library item.SetParent(null); - _itemRepository.DeleteItem([item.Id, .. children.Select(f => f.Id)]); + var externalDataManager = _externalDataManagerFactory.Value; + externalDataManager.DeleteExternalItemFiles(item); + foreach (var child in children) + { + externalDataManager.DeleteExternalItemFiles(child); + } + + _persistenceService.DeleteItem([item.Id, .. children.Select(f => f.Id)]); _cache.TryRemove(item.Id, out _); foreach (var child in children) { @@ -463,6 +615,12 @@ namespace Emby.Server.Implementations.Library folder.UserData = null; } + // Announce the descendants before the item itself. + foreach (var child in children) + { + ReportItemRemoved(child, item); + } + ReportItemRemoved(item, parent); } @@ -576,6 +734,9 @@ namespace Emby.Server.Implementations.Library // Trickplay list.Add(_pathManager.GetTrickplayDirectory(video)); + // Chapter Images + list.Add(_pathManager.GetChapterImageFolderPath(video)); + // Subtitles and attachments foreach (var mediaSource in item.GetMediaSources(false)) { @@ -657,8 +818,99 @@ namespace Emby.Server.Implementations.Library return key.GetMD5(); } - public BaseItem? ResolvePath(FileSystemMetadata fileInfo, Folder? parent = null, IDirectoryService? directoryService = null) - => ResolvePath(fileInfo, directoryService ?? new DirectoryService(_fileSystem), null, parent); + public BaseItem? ResolvePath( + FileSystemMetadata fileInfo, + Folder? parent = null, + IDirectoryService? directoryService = null, + CollectionType? collectionType = null) + => ResolvePath(fileInfo, directoryService ?? new DirectoryService(_fileSystem), null, parent, collectionType); + + private void SetAdditionalPartsFromStack(Video altVideo, string path) + { + if (altVideo.AdditionalParts is { Length: > 0 }) + { + return; + } + + var directory = Path.GetDirectoryName(path); + if (string.IsNullOrEmpty(directory)) + { + return; + } + + IEnumerable<FileSystemMetadata> siblings; + try + { + siblings = _fileSystem.GetFiles(directory); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to enumerate siblings to detect stack for {Path}", path); + return; + } + + var stacks = StackResolver.Resolve(siblings, _namingOptions); + foreach (var stack in stacks) + { + if (stack.Files.Count > 1 + && string.Equals(stack.Files[0], path, StringComparison.OrdinalIgnoreCase)) + { + altVideo.AdditionalParts = stack.Files.Skip(1).ToArray(); + return; + } + } + } + + /// <inheritdoc /> + public Video? ResolveAlternateVersion(string path, Type expectedVideoType, Folder? parent, CollectionType? collectionType) + { + // Clean up any existing item saved with wrong type (e.g. Video instead of Movie). + // This happens when items were previously resolved without proper type context + // in mixed-content libraries where collectionType is null. + var expectedId = GetNewItemId(path, expectedVideoType); + if (expectedVideoType != typeof(Video)) + { + var wrongTypeId = GetNewItemId(path, typeof(Video)); + if (!wrongTypeId.Equals(expectedId) && GetItemById(wrongTypeId) is Video wrongTypeItem) + { + _logger.LogInformation( + "Removing alternate version with wrong type {WrongType}, expected {ExpectedType}: {Path}", + wrongTypeItem.GetType().Name, + expectedVideoType.Name, + path); + DeleteItem(wrongTypeItem, new DeleteOptions { DeleteFileLocation = false }); + } + } + + var resolved = ResolvePath( + _fileSystem.GetFileSystemInfo(path), + parent, + collectionType: collectionType) as Video; + + if (resolved is null) + { + return null; + } + + // Ensure the alternate version has the same concrete type as the primary video. + // ResolvePath may return a generic Video for files in mixed-content libraries + // where collectionType is null, even though the primary is a Movie/Episode/etc. + if (resolved.GetType() != expectedVideoType) + { + if (Activator.CreateInstance(expectedVideoType) is Video correctVideo) + { + correctVideo.Path = resolved.Path; + correctVideo.Name = resolved.Name; + correctVideo.VideoType = resolved.VideoType; + correctVideo.ProductionYear = resolved.ProductionYear; + correctVideo.ExtraType = resolved.ExtraType; + resolved = correctVideo; + } + } + + resolved.Id = expectedId; + return resolved; + } private BaseItem? ResolvePath( FileSystemMetadata fileInfo, @@ -954,6 +1206,12 @@ namespace Emby.Server.Implementations.Library .FirstOrDefault(); } + /// <inheritdoc /> + public Guid GetPersonId(string name) + { + return GetItemByNameId<Person>(Person.GetPath(name)); + } + /// <inheritdoc /> public Person? GetPerson(string name) { @@ -967,6 +1225,33 @@ namespace Emby.Server.Implementations.Library return null; } + /// <inheritdoc /> + public Person GetOrCreatePerson(string name) + { + var existing = GetPerson(name); + if (existing is not null) + { + return existing; + } + + var path = Person.GetPath(name); + var info = Directory.CreateDirectory(path); + var item = new Person + { + Name = name, + Id = GetItemByNameId<Person>(path), + DateCreated = info.CreationTimeUtc, + DateModified = info.LastWriteTimeUtc, + Path = path + }; + + item.PresentationUniqueKey = item.CreatePresentationUniqueKey(); + + CreateItem(item, null); + + return item; + } + /// <summary> /// Gets the studio. /// </summary> @@ -1041,7 +1326,7 @@ namespace Emby.Server.Implementations.Library public IReadOnlyDictionary<string, MusicArtist[]> GetArtists(IReadOnlyList<string> names) { - return _itemRepository.FindArtists(names); + return _linkedChildrenService.FindArtists(names); } public MusicArtist GetArtist(string name, DtoOptions options) @@ -1099,15 +1384,6 @@ namespace Emby.Server.Implementations.Library return GetNewItemIdInternal(path, typeof(T), forceCaseInsensitiveId); } - /// <inheritdoc /> - public Task ValidatePeopleAsync(IProgress<double> progress, CancellationToken cancellationToken) - { - // Ensure the location is available. - Directory.CreateDirectory(_configurationManager.ApplicationPaths.PeoplePath); - - return new PeopleValidator(this, _logger, _fileSystem).ValidatePeople(cancellationToken, progress); - } - /// <summary> /// Reloads the root media folder. /// </summary> @@ -1131,6 +1407,7 @@ namespace Emby.Server.Implementations.Library public async Task ValidateMediaLibraryInternal(IProgress<double> progress, CancellationToken cancellationToken) { IsScanRunning = true; + ClearIgnoreRuleCache(); LibraryMonitor.Stop(); try @@ -1139,6 +1416,7 @@ namespace Emby.Server.Implementations.Library } finally { + ClearIgnoreRuleCache(); LibraryMonitor.Start(); IsScanRunning = false; } @@ -1146,6 +1424,7 @@ namespace Emby.Server.Implementations.Library public async Task ValidateTopLibraryFolders(CancellationToken cancellationToken, bool removeRoot = false) { + ClearIgnoreRuleCache(); RootFolder.Children = null; await RootFolder.RefreshMetadata(cancellationToken).ConfigureAwait(false); @@ -1186,8 +1465,16 @@ namespace Emby.Server.Implementations.Library if (toDelete.Count > 0) { - _itemRepository.DeleteItem(toDelete.ToArray()); + _persistenceService.DeleteItem(toDelete.ToArray()); } + + ClearIgnoreRuleCache(); + } + + /// <inheritdoc /> + public void ClearIgnoreRuleCache() + { + _dotIgnoreIgnoreRule.ClearDirectoryCache(); } private async Task PerformLibraryValidation(IProgress<double> progress, CancellationToken cancellationToken) @@ -1223,6 +1510,10 @@ namespace Emby.Server.Implementations.Library var numComplete = 0; var numTasks = tasks.Count; + _logger.LogInformation("Running {TaskCount} post-scan task(s)", numTasks); + + var phaseStart = Stopwatch.GetTimestamp(); + foreach (var task in tasks) { // Prevent access to modified closure @@ -1240,20 +1531,45 @@ namespace Emby.Server.Implementations.Library progress.Report(innerPercent); }); - _logger.LogDebug("Running post-scan task {0}", task.GetType().Name); + var taskName = task.GetType().Name; + var taskStart = Stopwatch.GetTimestamp(); + + _logger.LogInformation( + "Running post-scan task {TaskNumber}/{TaskCount}: {TaskName}", + currentNumComplete + 1, + numTasks, + taskName); try { await task.Run(innerProgress, cancellationToken).ConfigureAwait(false); + + var elapsed = Stopwatch.GetElapsedTime(taskStart); + _logger.LogInformation( + "Post-scan task {TaskName} completed after {Minutes} minute(s) and {Seconds} seconds", + taskName, + Math.Truncate(elapsed.TotalMinutes), + elapsed.Seconds); } catch (OperationCanceledException) { - _logger.LogInformation("Post-scan task cancelled: {0}", task.GetType().Name); + var elapsed = Stopwatch.GetElapsedTime(taskStart); + _logger.LogInformation( + "Post-scan task {TaskName} cancelled after {Minutes} minute(s) and {Seconds} seconds", + taskName, + Math.Truncate(elapsed.TotalMinutes), + elapsed.Seconds); throw; } catch (Exception ex) { - _logger.LogError(ex, "Error running post-scan task"); + var elapsed = Stopwatch.GetElapsedTime(taskStart); + _logger.LogError( + ex, + "Post-scan task {TaskName} failed after {Minutes} minute(s) and {Seconds} seconds", + taskName, + Math.Truncate(elapsed.TotalMinutes), + elapsed.Seconds); } numComplete++; @@ -1262,7 +1578,13 @@ namespace Emby.Server.Implementations.Library progress.Report(percent * 100); } - _itemRepository.UpdateInheritedValues(); + var phaseElapsed = Stopwatch.GetElapsedTime(phaseStart); + _logger.LogInformation( + "All post-scan tasks completed after {Minutes} minute(s) and {Seconds} seconds", + Math.Truncate(phaseElapsed.TotalMinutes), + phaseElapsed.Seconds); + + _persistenceService.UpdateInheritedValues(); progress.Report(100); } @@ -1421,14 +1743,7 @@ namespace Emby.Server.Implementations.Library AddUserToQuery(query, query.User, allowExternalContent); } - var itemList = _itemRepository.GetItemList(query); - var user = query.User; - if (user is not null) - { - return itemList.Where(i => i.IsVisible(user)).ToList(); - } - - return itemList; + return _itemRepository.GetItemList(query); } public IReadOnlyList<BaseItem> GetItemList(InternalItemsQuery query) @@ -1452,7 +1767,7 @@ namespace Emby.Server.Implementations.Library AddUserToQuery(query, query.User); } - return _itemRepository.GetCount(query); + return _countService.GetCount(query); } public ItemCounts GetItemCounts(InternalItemsQuery query) @@ -1471,7 +1786,42 @@ namespace Emby.Server.Implementations.Library AddUserToQuery(query, query.User); } - return _itemRepository.GetItemCounts(query); + return _countService.GetItemCounts(query); + } + + /// <inheritdoc/> + public ItemCounts GetItemCountsForNameItem(BaseItemKind kind, Guid id, BaseItemKind[] relatedItemKinds, User? user) + { + var query = new InternalItemsQuery(user); + if (user is not null) + { + AddUserToQuery(query, user); + } + + return _countService.GetItemCountsForNameItem(kind, id, relatedItemKinds, query); + } + + /// <inheritdoc/> + public Dictionary<Guid, ItemCounts> GetItemCountsForNameItems(BaseItemKind kind, IReadOnlyList<Guid> ids, BaseItemKind[] relatedItemKinds, User? user) + { + var query = new InternalItemsQuery(user); + if (user is not null) + { + AddUserToQuery(query, user); + } + + return _countService.GetItemCountsForNameItems(kind, ids, relatedItemKinds, query); + } + + public Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, User? user) + { + return _countService.GetChildCountBatch(parentIds, user); + } + + /// <inheritdoc/> + public Dictionary<Guid, (int Played, int Total)> GetPlayedAndTotalCountBatch(IReadOnlyList<Guid> folderIds, User user) + { + return _countService.GetPlayedAndTotalCountBatch(folderIds, user); } public IReadOnlyList<BaseItem> GetItemList(InternalItemsQuery query, List<BaseItem> parents) @@ -1516,7 +1866,17 @@ namespace Emby.Server.Implementations.Library } } - return _itemRepository.GetNextUpSeriesKeys(query, dateCutoff); + return _nextUpService.GetNextUpSeriesKeys(query, dateCutoff); + } + + /// <inheritdoc /> + public IReadOnlyDictionary<string, MediaBrowser.Controller.Persistence.NextUpEpisodeBatchResult> GetNextUpEpisodesBatch( + InternalItemsQuery query, + IReadOnlyList<string> seriesKeys, + bool includeSpecials, + bool includeWatchedForRewatching) + { + return _nextUpService.GetNextUpEpisodesBatch(query, seriesKeys, includeSpecials, includeWatchedForRewatching); } public QueryResult<BaseItem> QueryItems(InternalItemsQuery query) @@ -1622,14 +1982,14 @@ namespace Emby.Server.Implementations.Library } // Optimize by querying against top level views - query.TopParentIds = parents.SelectMany(i => GetTopParentIdsForQuery(i, query.User)).ToArray(); - query.AncestorIds = []; - - // Prevent searching in all libraries due to empty filter - if (query.TopParentIds.Length == 0) + var topParentIds = parents.SelectMany(i => GetTopParentIdsForQuery(i, query.User)).ToArray(); + if (topParentIds.Length == 0) { - query.TopParentIds = [Guid.NewGuid()]; + return; } + + query.TopParentIds = topParentIds; + query.AncestorIds = []; } public QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetAlbumArtists(InternalItemsQuery query) @@ -1675,38 +2035,63 @@ namespace Emby.Server.Implementations.Library if (parents.All(i => i is ICollectionFolder || i is UserView)) { // Optimize by querying against top level views - query.TopParentIds = parents.SelectMany(i => GetTopParentIdsForQuery(i, query.User)).ToArray(); + var topParentIds = parents.SelectMany(i => GetTopParentIdsForQuery(i, query.User)).ToArray(); - // Prevent searching in all libraries due to empty filter - if (query.TopParentIds.Length == 0) + if (topParentIds.Length > 0) { - query.TopParentIds = [Guid.NewGuid()]; + query.TopParentIds = topParentIds; } + else + { + SetAncestorIds(query, parents); + } + } + else if (parents.Count == 1 && parents.First() is Folder folder + && (folder is Playlist || folder is BoxSet) + && folder.LinkedChildren.Length > 0) + { + // Playlists and BoxSets store their contents in LinkedChildren and never + // populate AncestorIds for those items, so a recursive AncestorIds query + // would return zero rows. Filter by the descendant set instead, which follows + // the links and keeps descending, so a linked folder contributes what is below + // it as well - the episodes of a Series added to a collection, for example. + query.DescendantOfId = folder.Id; } else { - // We need to be able to query from any arbitrary ancestor up the tree - query.AncestorIds = parents.SelectMany(i => i.GetIdsForAncestorQuery()).ToArray(); - - // Prevent searching in all libraries due to empty filter - if (query.AncestorIds.Length == 0) - { - query.AncestorIds = [Guid.NewGuid()]; - } + SetAncestorIds(query, parents); } query.Parent = null; } + private static void SetAncestorIds(InternalItemsQuery query, IReadOnlyCollection<BaseItem> parents) + { + // We need to be able to query from any arbitrary ancestor up the tree + query.AncestorIds = parents.SelectMany(i => i.GetIdsForAncestorQuery()).ToArray(); + + // Prevent searching in all libraries due to empty filter + if (query.AncestorIds.Length == 0) + { + query.AncestorIds = [Guid.NewGuid()]; + } + } + private void AddUserToQuery(InternalItemsQuery query, User user, bool allowExternalContent = true) { + if (query.User is null) + { + query.SetUser(user); + } + if (query.AncestorIds.Length == 0 && query.ParentId.IsEmpty() && query.ChannelIds.Count == 0 && query.TopParentIds.Length == 0 && string.IsNullOrEmpty(query.AncestorWithPresentationUniqueKey) && string.IsNullOrEmpty(query.SeriesPresentationUniqueKey) && - query.ItemIds.Length == 0) + query.ItemIds.Length == 0 && + query.OwnerIds.Length == 0) { var userViews = UserViewManager.GetUserViews(new UserViewQuery { @@ -1725,6 +2110,15 @@ namespace Emby.Server.Implementations.Library } } + /// <inheritdoc/> + public void ConfigureUserAccess(InternalItemsQuery query, User user) + { + ArgumentNullException.ThrowIfNull(query); + ArgumentNullException.ThrowIfNull(user); + + AddUserToQuery(query, user); + } + private IEnumerable<Guid> GetTopParentIdsForQuery(BaseItem item, User? user) { if (item is UserView view) @@ -1889,6 +2283,50 @@ namespace Emby.Server.Implementations.Library return video; } + /// <inheritdoc /> + public IEnumerable<Guid> GetLocalAlternateVersionIds(Video video) + { + ArgumentNullException.ThrowIfNull(video); + + var linkedIds = _linkedChildrenService.GetLinkedChildrenIds(video.Id, (int)MediaBrowser.Controller.Entities.LinkedChildType.LocalAlternateVersion); + if (linkedIds.Count > 0) + { + return linkedIds; + } + + return []; + } + + /// <inheritdoc /> + public IEnumerable<Video> GetLinkedAlternateVersions(Video video) + { + ArgumentNullException.ThrowIfNull(video); + + var linkedIds = _linkedChildrenService.GetLinkedChildrenIds(video.Id, (int)MediaBrowser.Controller.Entities.LinkedChildType.LinkedAlternateVersion); + if (linkedIds.Count > 0) + { + return linkedIds + .Select(id => GetItemById(id)) + .Where(i => i is not null) + .OfType<Video>() + .OrderBy(i => i.SortName); + } + + return []; + } + + /// <inheritdoc /> + public IReadOnlySet<Guid> GetItemIdsWithAlternateVersions(IReadOnlyList<Guid> itemIds) + { + return _linkedChildrenService.GetItemIdsWithAlternateVersions(itemIds); + } + + /// <inheritdoc /> + public void UpsertLinkedChild(Guid parentId, Guid childId, MediaBrowser.Controller.Entities.LinkedChildType childType) + { + _linkedChildrenService.UpsertLinkedChild(parentId, childId, childType); + } + /// <inheritdoc /> public IEnumerable<BaseItem> Sort(IEnumerable<BaseItem> items, User? user, IEnumerable<ItemSortBy> sortBy, SortOrder sortOrder) { @@ -1969,9 +2407,13 @@ namespace Emby.Server.Implementations.Library { var comparer = Comparers.FirstOrDefault(c => name == c.Type); - // If it requires a user, create a new one, and assign the user if (comparer is IUserBaseItemComparer) { + if (user is null) + { + throw new ArgumentException($"Sort key '{name}' requires a user, but none was provided."); + } + var userComparer = (IUserBaseItemComparer)Activator.CreateInstance(comparer.GetType())!; // only null for Nullable<T> instances userComparer.User = user; @@ -1993,9 +2435,49 @@ namespace Emby.Server.Implementations.Library /// <inheritdoc /> public void CreateItems(IReadOnlyList<BaseItem> items, BaseItem? parent, CancellationToken cancellationToken) { - _itemRepository.SaveItems(items, cancellationToken); - + // Resolve and add any local alternate version items that don't exist yet + // This ensures they exist in the database when LinkedChildren are processed + var allItems = new List<BaseItem>(items); + var parentFolder = parent as Folder; + var parentCollectionType = parent is not null ? GetTopFolderContentType(parent) : null; foreach (var item in items) + { + if (item is Video video && video.LocalAlternateVersions.Length > 0) + { + var videoType = video.GetType(); + foreach (var path in video.LocalAlternateVersions) + { + if (string.IsNullOrEmpty(path)) + { + continue; + } + + // Use the primary video's type for ID calculation to ensure consistency + var altId = GetNewItemId(path, videoType); + if (GetItemById(altId) is null && !allItems.Any(i => i.Id.Equals(altId))) + { + // Alternate version doesn't exist, resolve and create it + // ensuring it has the same type as the primary video + var altVideo = ResolveAlternateVersion(path, videoType, parentFolder, parentCollectionType); + if (altVideo is not null) + { + altVideo.OwnerId = video.Id; + altVideo.SetPrimaryVersionId(video.Id); + altVideo.IsInMixedFolder = video.IsInMixedFolder; + // ResolveAlternateVersion only sees the alternate's primary file. + // If the alternate is itself a stack (e.g. 1080p part1 + part2), + // detect its parts from sibling files so its AdditionalParts persist. + SetAdditionalPartsFromStack(altVideo, path); + allItems.Add(altVideo); + } + } + } + } + } + + _persistenceService.SaveItems(allItems, cancellationToken); + + foreach (var item in allItems) { RegisterItem(item); } @@ -2065,8 +2547,14 @@ namespace Emby.Server.Implementations.Library var outdated = forceUpdate ? item.ImageInfos.Where(i => i.Path is not null).ToArray() : item.ImageInfos.Where(ImageNeedsRefresh).ToArray(); - // Skip image processing if current or live tv source - if (outdated.Length == 0 || item.SourceType != SourceType.Library) + + var parentItem = item.GetParent(); + var isLiveTvShow = item.SourceType != SourceType.Library && + parentItem is not null && + parentItem.SourceType != SourceType.Library; // not a channel + + // Skip image processing if current or live tv show + if (outdated.Length == 0 || isLiveTvShow) { RegisterItem(item); return; @@ -2099,9 +2587,15 @@ namespace Emby.Server.Implementations.Library } } - if (!File.Exists(image.Path)) + if (string.IsNullOrEmpty(image.Path) || !File.Exists(image.Path)) { - _logger.LogWarning("Image not found at {ImagePath}", image.Path); + _logger.LogWarning( + "{ImageType} image for {ItemName} ({ItemId}) not found at \"{ImagePath}\", source was {SourcePath}", + img.Type, + item.Name, + item.Id, + image.Path, + img.Path); continue; } @@ -2144,7 +2638,7 @@ namespace Emby.Server.Implementations.Library item.ValidateImages(); - _itemRepository.SaveImages(item); + await _persistenceService.SaveImagesAsync(item).ConfigureAwait(false); RegisterItem(item); } @@ -2161,7 +2655,57 @@ namespace Emby.Server.Implementations.Library item.DateLastSaved = DateTime.UtcNow; } - _itemRepository.SaveItems(items, cancellationToken); + ForgetDroppedLocalAlternateVersions(items); + + // Resolve and add any local alternate version items that don't exist yet + // This ensures they exist in the database when LinkedChildren are processed + var allItems = new List<BaseItem>(items); + var parentFolder = parent as Folder; + var parentCollectionType = GetTopFolderContentType(parent); + foreach (var item in items) + { + if (item is Video video && video.LocalAlternateVersions.Length > 0) + { + var videoType = video.GetType(); + foreach (var path in video.LocalAlternateVersions) + { + if (string.IsNullOrEmpty(path)) + { + continue; + } + + // Use the primary video's type for ID calculation to ensure consistency + var altId = GetNewItemId(path, videoType); + if (GetItemById(altId) is null && !allItems.Any(i => i.Id.Equals(altId))) + { + // Alternate version doesn't exist, resolve and create it + // ensuring it has the same type as the primary video + var altVideo = ResolveAlternateVersion(path, videoType, parentFolder, parentCollectionType); + if (altVideo is not null) + { + altVideo.OwnerId = video.Id; + altVideo.SetPrimaryVersionId(video.Id); + altVideo.IsInMixedFolder = video.IsInMixedFolder; + // ResolveAlternateVersion only sees the alternate's primary file. + // If the alternate is itself a stack (e.g. 1080p part1 + part2), + // detect its parts from sibling files so its AdditionalParts persist. + SetAdditionalPartsFromStack(altVideo, path); + allItems.Add(altVideo); + } + } + } + } + } + + _persistenceService.SaveItems(allItems, cancellationToken); + + foreach (var item in allItems) + { + if (!items.Contains(item)) + { + RegisterItem(item); + } + } if (parent is Folder folder) { @@ -2202,10 +2746,34 @@ namespace Emby.Server.Implementations.Library public Task UpdateItemAsync(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken) => UpdateItemsAsync([item], parent, updateReason, cancellationToken); + /// <summary> + /// Forgets the cached local alternate versions of the supplied items that they no longer list. + /// </summary> + /// <param name="items">The items about to be saved.</param> + private void ForgetDroppedLocalAlternateVersions(IReadOnlyList<BaseItem> items) + { + foreach (var video in items.OfType<Video>()) + { + var videoType = video.GetType(); + var keptIds = video.LocalAlternateVersions + .Where(path => !string.IsNullOrEmpty(path)) + .Select(path => GetNewItemId(path, videoType)) + .ToHashSet(); + + foreach (var versionId in GetLocalAlternateVersionIds(video)) + { + if (!keptIds.Contains(versionId)) + { + _cache.TryRemove(versionId, out _); + } + } + } + } + /// <inheritdoc /> public async Task ReattachUserDataAsync(BaseItem item, CancellationToken cancellationToken) { - await _itemRepository.ReattachUserDataAsync(item, cancellationToken).ConfigureAwait(false); + await _persistenceService.ReattachUserDataAsync(item, cancellationToken).ConfigureAwait(false); } public async Task RunMetadataSavers(BaseItem item, ItemUpdateType updateReason) @@ -2289,7 +2857,7 @@ namespace Emby.Server.Implementations.Library if (item is null) { - return new List<Folder>(); + return []; } return GetCollectionFoldersInternal(item, allUserRootChildren); @@ -2425,7 +2993,8 @@ namespace Emby.Server.Implementations.Library "views", _fileSystem.GetValidFilename(viewType.ToString())); - var id = GetNewItemId(path + "_namedview_" + name, typeof(UserView)); + // The display name is localized, so it must not take part in the id. + var id = GetNewItemId(path + "_namedview_" + viewType.ToString(), typeof(UserView)); var item = GetItemById(id) as UserView; @@ -2449,6 +3018,13 @@ namespace Emby.Server.Implementations.Library refresh = true; } + else if (!string.Equals(item.Name, name, StringComparison.Ordinal)) + { + item.Name = name; + item.ForcedSortName = sortName; + + refresh = true; + } if (refresh) { @@ -2469,7 +3045,9 @@ namespace Emby.Server.Implementations.Library var parentIdString = parentId.IsEmpty() ? null : parentId.ToString("N", CultureInfo.InvariantCulture); - var idValues = "38_namedview_" + name + user.Id.ToString("N", CultureInfo.InvariantCulture) + (parentIdString ?? string.Empty) + (viewType?.ToString() ?? string.Empty); + + // The name is either localized (grouped views) or the library folder's own name. + var idValues = "38_namedview_" + user.Id.ToString("N", CultureInfo.InvariantCulture) + (parentIdString ?? string.Empty) + (viewType?.ToString() ?? string.Empty); var id = GetNewItemId(idValues, typeof(UserView)); @@ -2499,6 +3077,11 @@ namespace Emby.Server.Implementations.Library isNew = true; } + else if (!string.Equals(item.Name, name, StringComparison.Ordinal)) + { + item.Name = name; + item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).GetAwaiter().GetResult(); + } var lastRefreshedUtc = item.DateLastRefreshed; var refresh = isNew || DateTime.UtcNow - lastRefreshedUtc >= _viewRefreshInterval; @@ -2600,7 +3183,7 @@ namespace Emby.Server.Implementations.Library var parentIdString = parentId.IsEmpty() ? null : parentId.ToString("N", CultureInfo.InvariantCulture); - var idValues = "37_namedview_" + name + (parentIdString ?? string.Empty) + (viewType?.ToString() ?? string.Empty); + var idValues = "37_namedview_" + (parentIdString ?? string.Empty) + (viewType?.ToString() ?? string.Empty); if (!string.IsNullOrEmpty(uniqueId)) { idValues += uniqueId; @@ -2634,9 +3217,10 @@ namespace Emby.Server.Implementations.Library isNew = true; } - if (viewType != item.ViewType) + if (viewType != item.ViewType || !string.Equals(item.Name, name, StringComparison.Ordinal)) { item.ViewType = viewType; + item.Name = name; item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).GetAwaiter().GetResult(); } @@ -2757,11 +3341,11 @@ namespace Emby.Server.Implementations.Library } } - if (!episode.ProductionYear.HasValue) + if (episode.ProductionYear is null) { episode.ProductionYear = episodeInfo.Year; - if (episode.ProductionYear.HasValue) + if (episode.ProductionYear is not null) { changed = true; } @@ -2833,13 +3417,16 @@ namespace Emby.Server.Implementations.Library public IEnumerable<BaseItem> FindExtras(BaseItem owner, IReadOnlyList<FileSystemMetadata> fileSystemChildren, IDirectoryService directoryService) { // Apply .ignore rules - var filtered = fileSystemChildren.Where(c => !DotIgnoreIgnoreRule.IsIgnored(c, owner)).ToList(); - var ownerVideoInfo = VideoResolver.Resolve(owner.Path, owner.IsFolder, _namingOptions, libraryRoot: owner.ContainingFolderPath); + var filtered = fileSystemChildren.Where(c => !_dotIgnoreIgnoreRule.ShouldIgnore(c, owner)).ToList(); + var isFolder = owner.IsFolder || (owner is Video video && (video.VideoType == VideoType.BluRay || video.VideoType == VideoType.Dvd)); + var ownerVideoInfo = VideoResolver.Resolve(owner.Path, isFolder, _namingOptions, libraryRoot: owner.ContainingFolderPath); if (ownerVideoInfo is null) { - yield break; + return []; } + var candidates = new List<ExtraCandidate>(); + var count = filtered.Count; for (var i = 0; i < count; i++) { @@ -2853,35 +3440,50 @@ namespace Emby.Server.Implementations.Library foreach (var file in filesInSubFolderList) { - if (!_extraResolver.TryGetExtraTypeForOwner(file.FullName, ownerVideoInfo, out var extraType)) + if (!_extraResolver.TryGetExtraTypeForOwner(file.FullName, ownerVideoInfo, out var extraType, out var extraRule)) { continue; } - var extra = GetExtra(file, extraType.Value, subFolderIsMixedFolder); - if (extra is not null) - { - yield return extra; - } + AddCandidate(file, extraType.Value, extraRule, subFolderIsMixedFolder); } } - else if (!current.IsDirectory && _extraResolver.TryGetExtraTypeForOwner(current.FullName, ownerVideoInfo, out var extraType)) + else if (!current.IsDirectory && _extraResolver.TryGetExtraTypeForOwner(current.FullName, ownerVideoInfo, out var extraType, out var extraRule)) { - var extra = GetExtra(current, extraType.Value, false); - if (extra is not null) - { - yield return extra; - } + AddCandidate(current, extraType.Value, extraRule, false); } } - BaseItem? GetExtra(FileSystemMetadata file, ExtraType extraType, bool isInMixedFolder) + var extras = new List<BaseItem>(); + var typeCounters = new Dictionary<ExtraType, int>(); + + // Order by path so that the numbering handed out below does not depend on the + // order the file system happened to list the folder in + foreach (var candidate in candidates.OrderBy(c => c.Extra.Path, StringComparer.Ordinal)) + { + var extra = PrepareExtra(candidate); + if (extra is not null) + { + extras.Add(extra); + } + } + + return extras; + + void AddCandidate(FileSystemMetadata file, ExtraType extraType, ExtraRule extraRule, bool isInMixedFolder) { var extra = ResolvePath(_fileSystem.GetFileInfo(file.FullName), directoryService, _extraResolver.GetResolversForExtraType(extraType)); - if (extra is not Video && extra is not Audio) + if (extra is Video or Audio) { - return null; + candidates.Add(new ExtraCandidate(extra, extraType, extraRule, isInMixedFolder)); } + } + + BaseItem? PrepareExtra(ExtraCandidate candidate) + { + var resolved = candidate.Extra; + var extra = resolved; + var name = GetExtraName(candidate, ownerVideoInfo, typeCounters); // Try to retrieve it from the db. If we don't find it, use the resolved version var itemById = GetItemById(extra.Id); @@ -2890,19 +3492,84 @@ namespace Emby.Server.Implementations.Library extra = itemById; } - // Only update extra type if it is more specific then the currently known extra type - if (extra.ExtraType is null or ExtraType.Unknown || extraType != ExtraType.Unknown) + // An extra is named after its file, so the file is the source of truth. Items created + // by older versions, or renamed by a metadata provider, are corrected here; + // RefreshExtras persists the change. + if (!string.IsNullOrEmpty(name) && extra.LockedFields?.Contains(MetadataField.Name) != true) { - extra.ExtraType = extraType; + extra.Name = name; } - extra.ParentId = Guid.Empty; - extra.OwnerId = owner.Id; - extra.IsInMixedFolder = isInMixedFolder; - return extra; + // Only update extra type if it is more specific then the currently known extra type + if (extra.ExtraType is null or ExtraType.Unknown || candidate.ExtraType != ExtraType.Unknown) + { + extra.ExtraType = candidate.ExtraType; + } + + // Only return items that are actual extras (have ExtraType set) + // Note: OwnerId and ParentId are set by RefreshExtras, not here, + // so that RefreshExtras can detect when they need updating and set ForceSave. + if (extra.ExtraType is not null) + { + extra.IsInMixedFolder = candidate.IsInMixedFolder; + return extra; + } + + return null; } } + /// <summary> + /// Gets the name to give an extra. + /// </summary> + /// <param name="candidate">The resolved extra.</param> + /// <param name="ownerVideoInfo">The naming info of the owner.</param> + /// <param name="typeCounters">Number of extras named after their type so far, per type.</param> + /// <returns>The name.</returns> + private string GetExtraName(ExtraCandidate candidate, VideoFileInfo ownerVideoInfo, Dictionary<ExtraType, int> typeCounters) + { + var isNamedAfterOwner = candidate.ExtraRule.RuleType switch + { + ExtraRuleType.Filename => true, + ExtraRuleType.Suffix => string.Equals(candidate.Extra.Name, ownerVideoInfo.Name, StringComparison.OrdinalIgnoreCase), + _ => false + }; + + if (!isNamedAfterOwner) + { + return candidate.Extra.Name; + } + + typeCounters.TryGetValue(candidate.ExtraType, out var seen); + typeCounters[candidate.ExtraType] = seen + 1; + + var typeName = _localization.GetServerLocalizedString(GetExtraTypeNameKey(candidate.ExtraType)); + + return seen == 0 + ? typeName + : string.Format( + CultureInfo.InvariantCulture, + _localization.GetServerLocalizedString("NameExtraNumbered"), + typeName, + seen + 1); + } + + private static string GetExtraTypeNameKey(ExtraType extraType) => extraType switch + { + ExtraType.Clip => "NameExtraClip", + ExtraType.Trailer => "NameExtraTrailer", + ExtraType.BehindTheScenes => "NameExtraBehindTheScenes", + ExtraType.DeletedScene => "NameExtraDeletedScene", + ExtraType.Interview => "NameExtraInterview", + ExtraType.Scene => "NameExtraScene", + ExtraType.Sample => "NameExtraSample", + ExtraType.ThemeSong => "NameExtraThemeSong", + ExtraType.ThemeVideo => "NameExtraThemeVideo", + ExtraType.Featurette => "NameExtraFeaturette", + ExtraType.Short => "NameExtraShort", + _ => "NameExtraUnknown" + }; + public string GetPathAfterNetworkSubstitution(string path, BaseItem? ownerItem) { foreach (var map in _configurationManager.Configuration.PathSubstitutions) @@ -2918,7 +3585,7 @@ namespace Emby.Server.Implementations.Library public IReadOnlyList<PersonInfo> GetPeople(InternalPeopleQuery query) { - return _peopleRepository.GetPeople(query); + return _peopleRepository.GetPeople(query).Items; } public IReadOnlyList<PersonInfo> GetPeople(BaseItem item) @@ -2939,24 +3606,33 @@ namespace Emby.Server.Implementations.Library return []; } - public IReadOnlyList<Person> GetPeopleItems(InternalPeopleQuery query) + public QueryResult<BaseItem> GetPeopleItems(InternalPeopleQuery query) { - return _peopleRepository.GetPeopleNames(query) - .Select(i => + var queryResult = _peopleRepository.GetPeople(query); + var baseItems = queryResult.Items.Select(i => + { + try + { + return GetPerson(i.Name); + } + catch (Exception ex) + { + _logger.LogError(ex, "error retrieving BaseItem for person: {0}", i.Name); + return null; + } + }) + .Where(i => i is not null) + .Where(i => query.User is null || i!.IsVisible(query.User)) + .OfType<BaseItem>() + .ToList() + .AsReadOnly(); + + return new QueryResult<BaseItem> { - try - { - return GetPerson(i); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting person"); - return null; - } - }) - .Where(i => i is not null) - .Where(i => query.User is null || i!.IsVisible(query.User)) - .ToList()!; // null values are filtered out + StartIndex = queryResult.StartIndex, + TotalRecordCount = queryResult.TotalRecordCount, + Items = baseItems, + }; } public IReadOnlyList<string> GetPeopleNames(InternalPeopleQuery query) @@ -2964,6 +3640,24 @@ namespace Emby.Server.Implementations.Library return _peopleRepository.GetPeopleNames(query); } + /// <inheritdoc/> + public int DeleteOrphanedCredits() + { + return _peopleRepository.DeleteOrphanedCredits(); + } + + /// <inheritdoc/> + public IReadOnlyDictionary<Guid, IReadOnlyList<string>> GetPeopleNamesByItems(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes) + { + return _peopleRepository.GetPeopleNamesByItems(itemIds, personTypes); + } + + /// <inheritdoc/> + public IReadOnlyDictionary<Guid, IReadOnlyList<PersonInfo>> GetPeopleByItems(IReadOnlyList<Guid> itemIds) + { + return _peopleRepository.GetPeopleByItems(itemIds); + } + public void UpdatePeople(BaseItem item, List<PersonInfo> people) { UpdatePeopleAsync(item, people, CancellationToken.None).GetAwaiter().GetResult(); @@ -2997,7 +3691,20 @@ namespace Emby.Server.Implementations.Library await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false); - return item.GetImageInfo(image.Type, imageIndex); + var localImage = item.GetImageInfo(image.Type, imageIndex); + if (localImage is null) + { + throw new InvalidOperationException(string.Format( + CultureInfo.InvariantCulture, + "Downloaded {0} image {1} from {2} is not attached to {3} ({4})", + image.Type, + imageIndex, + url, + item.Name, + item.Id)); + } + + return localImage; } catch (HttpRequestException ex) { @@ -3019,7 +3726,13 @@ namespace Emby.Server.Implementations.Library await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false); } - throw new InvalidOperationException("Unable to convert any images to local"); + throw new InvalidOperationException(string.Format( + CultureInfo.InvariantCulture, + "Unable to convert any {0} image url in \"{1}\" to a local file for {2} ({3})", + image.Type, + image.Path, + item.Name, + item.Id)); } public async Task AddVirtualFolder(string name, CollectionTypeOptions? collectionType, LibraryOptions options, bool refreshLibrary) @@ -3075,6 +3788,10 @@ namespace Emby.Server.Implementations.Library AddMediaPathInternal(name, path, false); } } + + // The libraries root was listed before this folder existed, so drop that listing: + // anything still reading it resolves the library set without the new folder. + _directoryService.Invalidate(virtualFolderPath); } finally { @@ -3101,27 +3818,14 @@ namespace Emby.Server.Implementations.Library var itemUpdateType = ItemUpdateType.MetadataDownload; var saveEntity = false; - var createEntity = false; var personEntity = GetPerson(person.Name); if (personEntity is null) { try { - var path = Person.GetPath(person.Name); - var info = Directory.CreateDirectory(path); - personEntity = new Person() - { - Name = person.Name, - Id = GetItemByNameId<Person>(path), - DateCreated = info.CreationTimeUtc, - DateModified = info.LastWriteTimeUtc, - Path = path - }; - - personEntity.PresentationUniqueKey = personEntity.CreatePresentationUniqueKey(); + personEntity = GetOrCreatePerson(person.Name); saveEntity = true; - createEntity = true; } catch (Exception ex) { @@ -3155,11 +3859,6 @@ namespace Emby.Server.Implementations.Library if (saveEntity) { - if (createEntity) - { - CreateItems([personEntity], null, CancellationToken.None); - } - await RunMetadataSavers(personEntity, itemUpdateType).ConfigureAwait(false); personEntity.DateLastSaved = DateTime.UtcNow; @@ -3199,7 +3898,9 @@ namespace Emby.Server.Implementations.Library } var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath; - var virtualFolderPath = Path.Combine(rootFolderPath, virtualFolderName); + var virtualFolderPath = FileSystemHelper.GetChildPath(rootFolderPath, virtualFolderName) + ?? throw new FileNotFoundException( + string.Format(CultureInfo.InvariantCulture, "The media collection {0} does not exist", virtualFolderName)); CreateShortcut(virtualFolderPath, pathInfo); @@ -3220,7 +3921,9 @@ namespace Emby.Server.Implementations.Library ArgumentNullException.ThrowIfNull(mediaPath); var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath; - var virtualFolderPath = Path.Combine(rootFolderPath, virtualFolderName); + var virtualFolderPath = FileSystemHelper.GetChildPath(rootFolderPath, virtualFolderName) + ?? throw new FileNotFoundException( + string.Format(CultureInfo.InvariantCulture, "The media collection {0} does not exist", virtualFolderName)); var libraryOptions = CollectionFolder.GetLibraryOptions(virtualFolderPath); @@ -3259,9 +3962,9 @@ namespace Emby.Server.Implementations.Library var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath; - var path = Path.Combine(rootFolderPath, name); + var path = FileSystemHelper.GetChildPath(rootFolderPath, name); - if (!Directory.Exists(path)) + if (path is null || !Directory.Exists(path)) { throw new FileNotFoundException("The media folder does not exist"); } @@ -3271,6 +3974,7 @@ namespace Emby.Server.Implementations.Library try { Directory.Delete(path, true); + _directoryService.Invalidate(path); } finally { @@ -3325,9 +4029,9 @@ namespace Emby.Server.Implementations.Library ArgumentException.ThrowIfNullOrEmpty(mediaPath); var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath; - var virtualFolderPath = Path.Combine(rootFolderPath, virtualFolderName); + var virtualFolderPath = FileSystemHelper.GetChildPath(rootFolderPath, virtualFolderName); - if (!Directory.Exists(virtualFolderPath)) + if (virtualFolderPath is null || !Directory.Exists(virtualFolderPath)) { throw new FileNotFoundException( string.Format(CultureInfo.InvariantCulture, "The media collection {0} does not exist", virtualFolderName)); @@ -3340,6 +4044,7 @@ namespace Emby.Server.Implementations.Library if (!string.IsNullOrEmpty(shortcut)) { _fileSystem.DeleteFile(shortcut); + _directoryService.Invalidate(shortcut); } var libraryOptions = CollectionFolder.GetLibraryOptions(virtualFolderPath); @@ -3383,7 +4088,63 @@ namespace Emby.Server.Implementations.Library } _fileSystem.CreateShortcut(lnk, _appHost.ReverseVirtualPath(path)); + _directoryService.Invalidate(lnk); RemoveContentTypeOverrides(path); } + + /// <inheritdoc /> + public async Task RerouteLinkedChildReferencesAsync(Guid fromChildId, Guid toChildId) + { + var affectedParentIds = _linkedChildrenService.RerouteLinkedChildren(fromChildId, toChildId); + + // Update in-memory LinkedChildren and re-save metadata (NFO) for affected parents + foreach (var parentId in affectedParentIds) + { + if (GetItemById(parentId) is Folder parent) + { + foreach (var lc in parent.LinkedChildren) + { + if (lc.ItemId.HasValue && lc.ItemId.Value.Equals(fromChildId)) + { + lc.ItemId = toChildId; + } + } + + await RunMetadataSavers(parent, ItemUpdateType.MetadataEdit).ConfigureAwait(false); + } + } + } + + /// <inheritdoc /> + public QueryFiltersLegacy GetQueryFiltersLegacy(InternalItemsQuery query) + { + if (query.User is not null) + { + AddUserToQuery(query, query.User); + } + + SetTopParentOrAncestorIds(query); + return _itemRepository.GetQueryFiltersLegacy(query); + } + + /// <inheritdoc /> + public IReadOnlyList<string> GetMediaStreamLanguages(MediaStreamType mediaStreamType) + { + return _mediaStreamRepository.GetMediaStreamLanguages(mediaStreamType); + } + + /// <inheritdoc /> + public IReadOnlyList<string> GetMediaStreamLanguages(MediaStreamType mediaStreamType, InternalItemsQuery query) + { + if (query.User is not null) + { + AddUserToQuery(query, query.User); + } + + SetTopParentOrAncestorIds(query); + return _itemRepository.GetMediaStreamLanguages(query, mediaStreamType); + } + + private sealed record ExtraCandidate(BaseItem Extra, ExtraType ExtraType, ExtraRule ExtraRule, bool IsInMixedFolder); } } diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs index c667fb0600..97e00177b6 100644 --- a/Emby.Server.Implementations/Library/MediaSourceManager.cs +++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs @@ -23,6 +23,8 @@ using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.MediaEncoding; @@ -126,6 +128,11 @@ namespace Emby.Server.Implementations.Library return true; } + if (stream.IsVobSubSubtitleStream) + { + return true; + } + return false; } @@ -170,6 +177,7 @@ namespace Emby.Server.Implementations.Library public async Task<IReadOnlyList<MediaSourceInfo>> GetPlaybackMediaSources(BaseItem item, User user, bool allowMediaProbe, bool enablePathSubstitution, CancellationToken cancellationToken) { var mediaSources = GetStaticMediaSources(item, enablePathSubstitution, user); + ResolveSymlinkPaths(mediaSources, enablePathSubstitution); // If file is strm or main media stream is missing, force a metadata refresh with remote probing if (allowMediaProbe && mediaSources[0].Type != MediaSourceType.Placeholder @@ -186,6 +194,7 @@ namespace Emby.Server.Implementations.Library cancellationToken).ConfigureAwait(false); mediaSources = GetStaticMediaSources(item, enablePathSubstitution, user); + ResolveSymlinkPaths(mediaSources, enablePathSubstitution); } var dynamicMediaSources = await GetDynamicMediaSources(item, cancellationToken).ConfigureAwait(false); @@ -220,7 +229,11 @@ namespace Emby.Server.Implementations.Library list.Add(source); } - return SortMediaSources(list).ToArray(); + var preferredId = mediaSources.Count > 0 && Guid.TryParse(mediaSources[0].Id, out var topSourceId) + ? topSourceId + : item.Id; + + return SortMediaSources(list, preferredId).ToArray(); } /// <inheritdoc />> @@ -318,6 +331,28 @@ namespace Emby.Server.Implementations.Library } } + /// <summary> + /// Resolves symlinked file paths on the supplied sources to the real on-disk target. + /// Skipped when <paramref name="enablePathSubstitution"/> is set because the path may + /// already have been rewritten to a UNC/URL meant for the client to consume directly. + /// </summary> + private static void ResolveSymlinkPaths(IReadOnlyList<MediaSourceInfo> sources, bool enablePathSubstitution) + { + if (enablePathSubstitution) + { + return; + } + + foreach (var source in sources) + { + if (source.Protocol == MediaProtocol.File + && FileSystemHelper.ResolveLinkTarget(source.Path, returnFinalTarget: true) is { Exists: true } target) + { + source.Path = target.FullName; + } + } + } + private static void SetKeyProperties(IMediaSourceProvider provider, MediaSourceInfo mediaSource) { var prefix = provider.GetType().FullName.GetMD5().ToString("N", CultureInfo.InvariantCulture) + LiveStreamIdDelimiter; @@ -355,6 +390,12 @@ namespace Emby.Server.Implementations.Library if (user is not null) { + sources = sources + .Where(source => !Guid.TryParse(source.Id, out var sourceId) + || sourceId.Equals(item.Id) + || _libraryManager.GetItemById<BaseItem>(sourceId, user) is not null) + .ToArray(); + foreach (var source in sources) { SetDefaultAudioAndSubtitleStreamIndices(item, source, user); @@ -369,6 +410,59 @@ namespace Emby.Server.Implementations.Library source.SupportsDirectStream = user.HasPermission(PermissionKind.EnablePlaybackRemuxing); } } + + sources = SetAlternateVersionResumeStates(item, sources, user); + } + + return sources; + } + + /// <summary> + /// When the queried item is a primary, moves the most recently played version to the front so + /// that resuming without an explicit source selection plays the version that was last watched. + /// A directly queried alternate version keeps its own source first. Per-user playback position + /// is not surfaced on the source itself; it is carried by each version's own UserData. + /// </summary> + /// <param name="item">The queried item.</param> + /// <param name="sources">The item's media sources.</param> + /// <param name="user">The user.</param> + /// <returns>The media sources, reordered when a version drives resume.</returns> + private IReadOnlyList<MediaSourceInfo> SetAlternateVersionResumeStates(BaseItem item, IReadOnlyList<MediaSourceInfo> sources, User user) + { + // For a video, multiple sources means alternate versions. + if (item is not Video video || sources.Count < 2) + { + return sources; + } + + var versions = video.GetAllVersions(); + if (versions.Count < 2) + { + return sources; + } + + var userDataByVersion = _userDataManager.GetUserDataBatch(versions, user); + var dataBySourceId = new Dictionary<string, UserItemData>(versions.Count, StringComparer.OrdinalIgnoreCase); + foreach (var version in versions) + { + if (userDataByVersion.TryGetValue(version.Id, out var data)) + { + dataBySourceId[version.Id.ToString("N", CultureInfo.InvariantCulture)] = data; + } + } + + // Reorder only for a resumable (in-progress) version; + // a completed version has no position to resume, so it must not be pulled to the front here. + var resumeSource = VersionPlaybackSelector.SelectMostRecentlyPlayed( + sources, + source => source.Id is not null ? dataBySourceId.GetValueOrDefault(source.Id) : null, + data => data.PlaybackPositionTicks > 0); + + if (resumeSource is not null && !video.PrimaryVersionId.HasValue && !ReferenceEquals(sources[0], resumeSource)) + { + var reordered = new List<MediaSourceInfo>(sources.Count) { resumeSource }; + reordered.AddRange(sources.Where(s => !ReferenceEquals(s, resumeSource))); + return reordered; } return sources; @@ -423,7 +517,7 @@ namespace Emby.Server.Implementations.Library MediaStreamSelector.SetSubtitleStreamScores(source.MediaStreams, preferredSubs, user.SubtitleMode, audioLanguage); } - private void SetDefaultAudioStreamIndex(MediaSourceInfo source, UserItemData userData, User user, bool allowRememberingSelection) + private void SetDefaultAudioStreamIndex(MediaSourceInfo source, UserItemData userData, User user, bool allowRememberingSelection, string originalLanguage) { if (userData is not null && userData.AudioStreamIndex.HasValue && user.RememberAudioSelections && allowRememberingSelection) { @@ -437,7 +531,38 @@ namespace Emby.Server.Implementations.Library } } - var preferredAudio = NormalizeLanguage(user.AudioLanguagePreference); + if (string.Equals(user.AudioLanguagePreference, "OriginalLanguage", StringComparison.OrdinalIgnoreCase)) + { + if (user.PlayDefaultAudioTrack) + { + source.DefaultAudioStreamIndex = MediaStreamSelector.GetDefaultAudioStreamIndex( + source.MediaStreams, + NormalizeLanguage(originalLanguage), + user.PlayDefaultAudioTrack); + return; + } + + var originalIndex = source.MediaStreams.FindIndex(i => i.Type == MediaStreamType.Audio && i.IsOriginal); + + if (!string.IsNullOrWhiteSpace(originalLanguage) && originalIndex != -1) + { + var mediaLanguageOriginal = source.MediaStreams[originalIndex].Language; + if (NormalizeLanguage(mediaLanguageOriginal).Contains(NormalizeLanguage(originalLanguage).FirstOrDefault())) + { + source.DefaultAudioStreamIndex = originalIndex; + return; + } + } + else if (originalIndex != -1) + { + source.DefaultAudioStreamIndex = originalIndex; + return; + } + } + + var preferredAudio = string.Equals(user.AudioLanguagePreference, "OriginalLanguage", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(originalLanguage) + ? NormalizeLanguage(originalLanguage) + : NormalizeLanguage(user.AudioLanguagePreference); source.DefaultAudioStreamIndex = MediaStreamSelector.GetDefaultAudioStreamIndex(source.MediaStreams, preferredAudio, user.PlayDefaultAudioTrack); if (user.PlayDefaultAudioTrack) @@ -462,7 +587,9 @@ namespace Emby.Server.Implementations.Library var allowRememberingSelection = item is null || item.EnableRememberingTrackSelections; - SetDefaultAudioStreamIndex(source, userData, user, allowRememberingSelection); + var originalLanguage = item?.GetInheritedOriginalLanguage(); + + SetDefaultAudioStreamIndex(source, userData, user, allowRememberingSelection, originalLanguage); SetDefaultSubtitleStreamIndex(source, userData, user, allowRememberingSelection); } else if (mediaType == MediaType.Audio) @@ -476,24 +603,32 @@ namespace Emby.Server.Implementations.Library } } - private static IEnumerable<MediaSourceInfo> SortMediaSources(IEnumerable<MediaSourceInfo> sources) + private static IEnumerable<MediaSourceInfo> SortMediaSources(IEnumerable<MediaSourceInfo> sources, Guid preferredItemId = default) { - return sources.OrderBy(i => - { - if (i.VideoType.HasValue && i.VideoType.Value == VideoType.VideoFile) + // The source belonging to the queried item sorts first so it stays the default that gets played. + var preferredId = preferredItemId.IsEmpty() + ? null + : preferredItemId.ToString("N", CultureInfo.InvariantCulture); + + return sources + .OrderByDescending(i => preferredId is not null && string.Equals(i.Id, preferredId, StringComparison.OrdinalIgnoreCase)) + .ThenBy(i => { - return 0; - } + if (i.VideoType.HasValue && i.VideoType.Value == VideoType.VideoFile) + { + return 0; + } - return 1; - }).ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0) - .ThenByDescending(i => - { - var stream = i.VideoStream; + return 1; + }) + .ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0) + .ThenByDescending(i => + { + var stream = i.VideoStream; - return stream?.Width ?? 0; - }) - .Where(i => i.Type != MediaSourceType.Placeholder); + return stream?.Width ?? 0; + }) + .Where(i => i.Type != MediaSourceType.Placeholder); } public async Task<Tuple<LiveStreamResponse, IDirectStreamProvider>> OpenLiveStreamInternal(LiveStreamRequest request, CancellationToken cancellationToken) diff --git a/Emby.Server.Implementations/Library/PathExtensions.cs b/Emby.Server.Implementations/Library/PathExtensions.cs index 21e7079d88..7d0f3900c5 100644 --- a/Emby.Server.Implementations/Library/PathExtensions.cs +++ b/Emby.Server.Implementations/Library/PathExtensions.cs @@ -29,28 +29,79 @@ namespace Emby.Server.Implementations.Library throw new ArgumentException("String can't be empty.", nameof(attribute)); } - var attributeIndex = str.IndexOf(attribute, StringComparison.OrdinalIgnoreCase); - - // Must be at least 3 characters after the attribute =, ], any character, - // then we offset it by 1, because we want the index and not length. - var maxIndex = str.Length - attribute.Length - 2; - while (attributeIndex > -1 && attributeIndex < maxIndex) + // Allow tmdb as an alias for tmdbid, tvdb for tvdbid, etc. + // The code below only supports aliases for attributes in the form of "<alias>id". + ReadOnlySpan<char> shortAttr = attribute switch { - var attributeEnd = attributeIndex + attribute.Length; - if (attributeIndex > 0 - && str[attributeIndex - 1] == '[' - && (str[attributeEnd] == '=' || str[attributeEnd] == '-')) + _ when attribute.Equals("tmdbid", StringComparison.OrdinalIgnoreCase) => "tmdb", + _ when attribute.Equals("tvdbid", StringComparison.OrdinalIgnoreCase) => "tvdb", + _ when attribute.Equals("imdbid", StringComparison.OrdinalIgnoreCase) => "imdb", + _ => ReadOnlySpan<char>.Empty + }; + + for (int strIndex = 0, attributeIndex = 0; attributeIndex > -1;) + { + // We may want to use imdbid pattern matching later, so we don't want to modify the original 'str'. + var subStr = str[strIndex..]; + int attributeEnd = 0; + + if (shortAttr.Length > 0) { - var closingIndex = str[attributeEnd..].IndexOf(']'); - // Must be at least 1 character before the closing bracket. - if (closingIndex > 1) - { - return str[(attributeEnd + 1)..(attributeEnd + closingIndex)].Trim().ToString(); - } + // If we are using an alias it should be shorter (and a prefix), so let's search for that. + attributeIndex = subStr.IndexOf(shortAttr, StringComparison.OrdinalIgnoreCase); + attributeEnd = attributeIndex + shortAttr.Length; + } + else + { + attributeIndex = subStr.IndexOf(attribute, StringComparison.OrdinalIgnoreCase); + attributeEnd = attributeIndex + attribute.Length; } - str = str[attributeEnd..]; - attributeIndex = str.IndexOf(attribute, StringComparison.OrdinalIgnoreCase); + // The next iteration should start at the end of the attribute we just found. + // If attributeIndex < 0, the loop will end and strIndex won't be used again. + strIndex += attributeEnd; + + if (attributeIndex > 0) + { + var attributeOpener = subStr[attributeIndex - 1]; + var attributeCloser = attributeOpener switch + { + '[' => ']', + '(' => ')', + '{' => '}', + _ => '\0' + }; + + if (attributeCloser != '\0') + { + if (shortAttr.Length > 0 + && attributeEnd + 1 < subStr.Length + && (subStr[attributeEnd] is 'i' or 'I') + && (subStr[attributeEnd + 1] is 'd' or 'D')) + { + // We were searching for a shortened attribute, but it's followed by "id" - let's skip it. + attributeEnd += 2; + } + + // attributeEnd points at '='. + // We need at least 1 more character and the closing bracket after that. + if (attributeEnd + 2 < subStr.Length && (subStr[attributeEnd] is '=' or '-')) + { + var closingIndex = subStr[attributeEnd..].IndexOf(attributeCloser); + + // Must be at least 1 character before the closing bracket. + if (closingIndex > 1) + { + var trimmed = subStr[(attributeEnd + 1)..(attributeEnd + closingIndex)].Trim(); + + if (trimmed.Length > 0) + { + return trimmed.ToString(); + } + } + } + } + } } // for imdbid we also accept pattern matching diff --git a/Emby.Server.Implementations/Library/PathManager.cs b/Emby.Server.Implementations/Library/PathManager.cs index a9b7a1274b..2a50fcc7fe 100644 --- a/Emby.Server.Implementations/Library/PathManager.cs +++ b/Emby.Server.Implementations/Library/PathManager.cs @@ -2,10 +2,12 @@ using System; using System.Collections.Generic; using System.Globalization; using System.IO; +using Jellyfin.Extensions; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.IO; +using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.Library; @@ -14,18 +16,22 @@ namespace Emby.Server.Implementations.Library; /// </summary> public class PathManager : IPathManager { + private readonly ILogger<PathManager> _logger; private readonly IServerConfigurationManager _config; private readonly IApplicationPaths _appPaths; /// <summary> /// Initializes a new instance of the <see cref="PathManager"/> class. /// </summary> + /// <param name="logger">The logger.</param> /// <param name="config">The server configuration manager.</param> /// <param name="appPaths">The application paths.</param> public PathManager( + ILogger<PathManager> logger, IServerConfigurationManager config, IApplicationPaths appPaths) { + _logger = logger; _config = config; _appPaths = appPaths; } @@ -35,31 +41,55 @@ public class PathManager : IPathManager private string AttachmentCachePath => Path.Combine(_appPaths.DataPath, "attachments"); /// <inheritdoc /> - public string GetAttachmentPath(string mediaSourceId, string fileName) + public string? GetAttachmentPath(string mediaSourceId, string fileName) { - return Path.Combine(GetAttachmentFolderPath(mediaSourceId), fileName); + var folder = GetAttachmentFolderPath(mediaSourceId); + if (folder is null) + { + return null; + } + + var safeName = PathHelper.GetSafeLeafFileName(fileName); + if (safeName is null) + { + _logger.LogWarning("Rejecting attachment filename '{FileName}' for MediaSource {MediaSourceId}: not a valid leaf name.", fileName, mediaSourceId); + return null; + } + + return Path.Combine(folder, safeName); } /// <inheritdoc /> - public string GetAttachmentFolderPath(string mediaSourceId) + public string? GetAttachmentFolderPath(string mediaSourceId) { - var id = Guid.Parse(mediaSourceId).ToString("D", CultureInfo.InvariantCulture).AsSpan(); + if (!Guid.TryParse(mediaSourceId, out var parsed)) + { + _logger.LogDebug("MediaSource Id '{MediaSourceId}' is not a GUID; no on-disk attachment folder.", mediaSourceId); + return null; + } + var id = parsed.ToString("D", CultureInfo.InvariantCulture).AsSpan(); return Path.Join(AttachmentCachePath, id[..2], id); } /// <inheritdoc /> - public string GetSubtitleFolderPath(string mediaSourceId) + public string? GetSubtitleFolderPath(string mediaSourceId) { - var id = Guid.Parse(mediaSourceId).ToString("D", CultureInfo.InvariantCulture).AsSpan(); + if (!Guid.TryParse(mediaSourceId, out var parsed)) + { + _logger.LogDebug("MediaSource Id '{MediaSourceId}' is not a GUID; no on-disk subtitle folder.", mediaSourceId); + return null; + } + var id = parsed.ToString("D", CultureInfo.InvariantCulture).AsSpan(); return Path.Join(SubtitleCachePath, id[..2], id); } /// <inheritdoc /> - public string GetSubtitlePath(string mediaSourceId, int streamIndex, string extension) + public string? GetSubtitlePath(string mediaSourceId, int streamIndex, string extension) { - return Path.Combine(GetSubtitleFolderPath(mediaSourceId), streamIndex.ToString(CultureInfo.InvariantCulture) + extension); + var folder = GetSubtitleFolderPath(mediaSourceId); + return folder is null ? null : Path.Combine(folder, streamIndex.ToString(CultureInfo.InvariantCulture) + extension); } /// <inheritdoc /> @@ -90,12 +120,27 @@ public class PathManager : IPathManager public IReadOnlyList<string> GetExtractedDataPaths(BaseItem item) { var mediaSourceId = item.Id.ToString("N", CultureInfo.InvariantCulture); - return [ - GetAttachmentFolderPath(mediaSourceId), - GetSubtitleFolderPath(mediaSourceId), - GetTrickplayDirectory(item, false), - GetTrickplayDirectory(item, true), - GetChapterImageFolderPath(item) - ]; + List<string> paths = []; + var attachmentFolder = GetAttachmentFolderPath(mediaSourceId); + if (attachmentFolder is not null) + { + paths.Add(attachmentFolder); + } + + var subtitleFolder = GetSubtitleFolderPath(mediaSourceId); + if (subtitleFolder is not null) + { + paths.Add(subtitleFolder); + } + + paths.Add(GetTrickplayDirectory(item, false)); + if (!string.IsNullOrEmpty(item.Path)) + { + paths.Add(GetTrickplayDirectory(item, true)); + } + + paths.Add(GetChapterImageFolderPath(item)); + + return paths; } } diff --git a/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs index 464a548ab9..7d51a0daa0 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs @@ -1,16 +1,14 @@ -#nullable disable - #pragma warning disable CS1591 using System; using System.IO; using System.Linq; +using Emby.Naming.Book; using Jellyfin.Data.Enums; using Jellyfin.Extensions; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Resolvers; -using MediaBrowser.Model.Entities; namespace Emby.Server.Implementations.Library.Resolvers.Books { @@ -18,7 +16,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Books { private readonly string[] _validExtensions = { ".azw", ".azw3", ".cb7", ".cbr", ".cbt", ".cbz", ".epub", ".mobi", ".pdf" }; - protected override Book Resolve(ItemResolveArgs args) + protected override Book? Resolve(ItemResolveArgs args) { var collectionType = args.GetCollectionType(); @@ -35,20 +33,26 @@ namespace Emby.Server.Implementations.Library.Resolvers.Books var extension = Path.GetExtension(args.Path.AsSpan()); - if (_validExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase)) + if (!_validExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase)) { - // It's a book - return new Book - { - Path = args.Path, - IsInMixedFolder = true - }; + return null; } - return null; + var result = BookFileNameParser.Parse(Path.GetFileNameWithoutExtension(args.Path)); + + return new Book + { + Path = args.Path, + Name = result.Name ?? string.Empty, + IndexNumber = result.Index, + ParentIndexNumber = result.ParentIndex, + ProductionYear = result.Year, + SeriesName = result.SeriesName ?? Path.GetFileName(Path.GetDirectoryName(args.Path)), + IsInMixedFolder = true, + }; } - private Book GetBook(ItemResolveArgs args) + private Book? GetBook(ItemResolveArgs args) { var bookFiles = args.FileSystemChildren.Where(f => { @@ -59,15 +63,23 @@ namespace Emby.Server.Implementations.Library.Resolvers.Books StringComparison.OrdinalIgnoreCase); }).ToList(); - // Don't return a Book if there is more (or less) than one document in the directory + // directory is only considered a book when it contains exactly one supported file + // other library structures with multiple books to a directory will get picked up as individual files if (bookFiles.Count != 1) { return null; } + var result = BookFileNameParser.Parse(Path.GetFileName(args.Path)); + return new Book { - Path = bookFiles[0].FullName + Path = bookFiles[0].FullName, + Name = result.Name ?? string.Empty, + IndexNumber = result.Index, + ParentIndexNumber = result.ParentIndex, + ProductionYear = result.Year, + SeriesName = result.SeriesName ?? string.Empty, }; } } diff --git a/Emby.Server.Implementations/Library/Resolvers/ExtraResolver.cs b/Emby.Server.Implementations/Library/Resolvers/ExtraResolver.cs index b9f9f29723..a0f75e4ddb 100644 --- a/Emby.Server.Implementations/Library/Resolvers/ExtraResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/ExtraResolver.cs @@ -32,8 +32,8 @@ namespace Emby.Server.Implementations.Library.Resolvers : base(logger, namingOptions, directoryService) { _namingOptions = namingOptions; - _trailerResolvers = new IItemResolver[] { new GenericVideoResolver<Trailer>(logger, namingOptions, directoryService) }; - _videoResolvers = new IItemResolver[] { this }; + _trailerResolvers = [new GenericVideoResolver<Trailer>(logger, namingOptions, directoryService, parseName: true)]; + _videoResolvers = [this]; } protected override Video Resolve(ItemResolveArgs args) @@ -54,12 +54,13 @@ namespace Emby.Server.Implementations.Library.Resolvers _ => _videoResolvers }; - public bool TryGetExtraTypeForOwner(string path, VideoFileInfo ownerVideoFileInfo, [NotNullWhen(true)] out ExtraType? extraType, string? libraryRoot = "") + public bool TryGetExtraTypeForOwner(string path, VideoFileInfo ownerVideoFileInfo, [NotNullWhen(true)] out ExtraType? extraType, [NotNullWhen(true)] out ExtraRule? extraRule, string? libraryRoot = "") { var extraResult = GetExtraInfo(path, _namingOptions, libraryRoot); - if (extraResult.ExtraType is null) + if (extraResult.ExtraType is null || extraResult.Rule is null) { extraType = null; + extraRule = null; return false; } @@ -88,6 +89,7 @@ namespace Emby.Server.Implementations.Library.Resolvers } extraType = extraResult.ExtraType; + extraRule = extraResult.Rule; return isValid; } diff --git a/Emby.Server.Implementations/Library/Resolvers/GenericVideoResolver.cs b/Emby.Server.Implementations/Library/Resolvers/GenericVideoResolver.cs index ba320266a4..b3bdea704a 100644 --- a/Emby.Server.Implementations/Library/Resolvers/GenericVideoResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/GenericVideoResolver.cs @@ -2,6 +2,7 @@ using Emby.Naming.Common; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using Microsoft.Extensions.Logging; @@ -14,15 +15,25 @@ namespace Emby.Server.Implementations.Library.Resolvers public class GenericVideoResolver<T> : BaseVideoResolver<T> where T : Video, new() { + private readonly bool _parseName; + /// <summary> /// Initializes a new instance of the <see cref="GenericVideoResolver{T}"/> class. /// </summary> /// <param name="logger">The logger.</param> /// <param name="namingOptions">The naming options.</param> /// <param name="directoryService">The directory service.</param> - public GenericVideoResolver(ILogger logger, NamingOptions namingOptions, IDirectoryService directoryService) + /// <param name="parseName">Whether to parse the file name for metadata such as the year.</param> + public GenericVideoResolver(ILogger logger, NamingOptions namingOptions, IDirectoryService directoryService, bool parseName = false) : base(logger, namingOptions, directoryService) { + _parseName = parseName; + } + + /// <inheritdoc /> + protected override T Resolve(ItemResolveArgs args) + { + return ResolveVideo<T>(args, _parseName); } } } diff --git a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs index 98e8f5350b..80375ae12d 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs @@ -28,15 +28,16 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies public partial class MovieResolver : BaseVideoResolver<Video>, IMultiItemResolver { private readonly IImageProcessor _imageProcessor; + private readonly VideoListResolver _videoListResolver; - private static readonly CollectionType[] _validCollectionTypes = new[] - { + private static readonly CollectionType[] _validCollectionTypes = + [ CollectionType.movies, CollectionType.homevideos, CollectionType.musicvideos, CollectionType.tvshows, CollectionType.photos - }; + ]; /// <summary> /// Initializes a new instance of the <see cref="MovieResolver"/> class. @@ -45,10 +46,12 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies /// <param name="logger">The logger.</param> /// <param name="namingOptions">The naming options.</param> /// <param name="directoryService">The directory service.</param> - public MovieResolver(IImageProcessor imageProcessor, ILogger<MovieResolver> logger, NamingOptions namingOptions, IDirectoryService directoryService) + /// <param name="videoListResolver">The video list resolver.</param> + public MovieResolver(IImageProcessor imageProcessor, ILogger<MovieResolver> logger, NamingOptions namingOptions, IDirectoryService directoryService, VideoListResolver videoListResolver) : base(logger, namingOptions, directoryService) { _imageProcessor = imageProcessor; + _videoListResolver = videoListResolver; } /// <summary> @@ -228,7 +231,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies if (collectionType == CollectionType.tvshows) { - return ResolveVideos<Episode>(parent, files, false, collectionType, true); + return ResolveVideos<Episode>(parent, files, true, collectionType, true); } return null; @@ -274,7 +277,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies .Where(f => f is not null) .ToList(); - var resolverResult = VideoListResolver.Resolve(videoInfos, NamingOptions, supportMultiEditions, parseName, parent.ContainingFolderPath); + var resolverResult = _videoListResolver.Resolve(videoInfos, supportMultiEditions, parseName, parent.ContainingFolderPath, collectionType); var result = new MultiItemResolverResult { @@ -302,7 +305,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies ProductionYear = video.Year, Name = parseName ? video.Name : firstVideo.Name, AdditionalParts = additionalParts, - LocalAlternateVersions = video.AlternateVersions.Select(i => i.Path).ToArray() + LocalAlternateVersions = video.AlternateVersions.Select(av => av.Files[0].Path).ToArray() }; SetVideoType(videoItem, firstVideo); @@ -331,9 +334,13 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies for (var j = 0; j < current.AlternateVersions.Count; j++) { - if (ContainsFile(current.AlternateVersions[j], file)) + var alternate = current.AlternateVersions[j]; + for (var k = 0; k < alternate.Files.Count; k++) { - return true; + if (ContainsFile(alternate.Files[k], file)) + { + return true; + } } } } @@ -369,15 +376,24 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies // We need to only look at the name of this actual item (not parents) var justName = item.IsInMixedFolder ? Path.GetFileName(item.Path.AsSpan()) : Path.GetFileName(item.ContainingFolderPath.AsSpan()); - var tmdbid = justName.GetAttributeValue("tmdbid"); + // The fallback filename is only used when the item isn't in a mixed folder + var fileName = item.IsInMixedFolder ? ReadOnlySpan<char>.Empty : Path.GetFileName(item.Path.AsSpan()); - // If not in a mixed folder and ID not found in folder path, check filename - if (string.IsNullOrEmpty(tmdbid) && !item.IsInMixedFolder) + item.TrySetProviderId(MetadataProvider.Tmdb, GetIdFromNameOrPath(justName, fileName, "tmdbid")); + item.TrySetProviderId(MetadataProvider.Tvdb, GetIdFromNameOrPath(justName, fileName, "tvdbid")); + + string GetIdFromNameOrPath(ReadOnlySpan<char> name, ReadOnlySpan<char> fallbackName, string attribute) { - tmdbid = Path.GetFileName(item.Path.AsSpan()).GetAttributeValue("tmdbid"); - } + var id = name.GetAttributeValue(attribute); - item.TrySetProviderId(MetadataProvider.Tmdb, tmdbid); + // If not in a mixed folder and ID not found in folder path, check filename + if (string.IsNullOrEmpty(id) && !item.IsInMixedFolder) + { + id = fallbackName.GetAttributeValue(attribute); + } + + return id; + } if (!string.IsNullOrEmpty(item.Path)) { diff --git a/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs b/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs index 14798dda65..d6513fc79c 100644 --- a/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs @@ -1,8 +1,10 @@ #nullable disable using System; +using System.Collections.Generic; using System.IO; using System.Linq; +using Emby.Server.Implementations.Playlists; using Jellyfin.Data.Enums; using Jellyfin.Extensions; using MediaBrowser.Controller.Library; @@ -45,8 +47,30 @@ namespace Emby.Server.Implementations.Library.Resolvers }; } + // Anything directly inside the internal playlists folder is a playlist, even when its + // playlist.xml is missing: failing to resolve here makes the library scan treat the + // playlist as deleted from disk and remove it, taking its items with it. + if (args.Parent is PlaylistsFolder) + { + return new Playlist + { + Path = args.Path, + Name = filename, + OpenAccess = true + }; + } + // It's a directory-based playlist if the directory contains a playlist file - var filePaths = Directory.EnumerateFiles(args.Path, "*", new EnumerationOptions { IgnoreInaccessible = true }); + IEnumerable<string> filePaths; + try + { + filePaths = Directory.EnumerateFiles(args.Path, "*", new EnumerationOptions { IgnoreInaccessible = true }); + } + catch (IOException) + { + return null; + } + if (filePaths.Any(f => f.EndsWith(PlaylistXmlSaver.DefaultPlaylistFilename, StringComparison.OrdinalIgnoreCase))) { return new Playlist diff --git a/Emby.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs index 5fd23c9f50..85bf20cc2a 100644 --- a/Emby.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs @@ -1,8 +1,10 @@ #nullable disable using System; +using System.IO; using System.Linq; using Emby.Naming.Common; +using Emby.Server.Implementations.Library; using Jellyfin.Data.Enums; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; @@ -81,10 +83,34 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV episode.ParentIndexNumber = 1; } + SetProviderIdFromPath(episode, args.Path); + return episode; } return null; } + + /// <summary> + /// Sets provider ids from the episode file name. + /// </summary> + /// <param name="item">The episode.</param> + /// <param name="path">The episode file path.</param> + private static void SetProviderIdFromPath(Episode item, string path) + { + var justName = Path.GetFileNameWithoutExtension(path.AsSpan()); + + var imdbId = justName.GetAttributeValue("imdbid"); + item.TrySetProviderId(MetadataProvider.Imdb, imdbId); + + var tvdbId = justName.GetAttributeValue("tvdbid"); + item.TrySetProviderId(MetadataProvider.Tvdb, tvdbId); + + var tvmazeId = justName.GetAttributeValue("tvmazeid"); + item.TrySetProviderId(MetadataProvider.TvMaze, tvmazeId); + + var tmdbId = justName.GetAttributeValue("tmdbid"); + item.TrySetProviderId(MetadataProvider.Tmdb, tmdbId); + } } } diff --git a/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs index 6cb63a28a2..a8bd832cc8 100644 --- a/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs @@ -1,10 +1,15 @@ #nullable disable +using System; using System.Globalization; +using System.IO; +using System.Linq; using Emby.Naming.Common; using Emby.Naming.TV; +using Emby.Server.Implementations.Library; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; using Microsoft.Extensions.Logging; @@ -77,6 +82,14 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV return null; } + + var hasAnyVideo = Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories) + .Any(file => _namingOptions.VideoFileExtensions.Contains(Path.GetExtension(file))); + + if (!hasAnyVideo) + { + return null; + } } if (season.IndexNumber.HasValue && string.IsNullOrEmpty(season.Name)) @@ -86,15 +99,47 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV args.LibraryOptions.SeasonZeroDisplayName : string.Format( CultureInfo.InvariantCulture, - _localization.GetLocalizedString("NameSeasonNumber"), + _localization.GetServerLocalizedString("NameSeasonNumber"), seasonNumber, args.LibraryOptions.PreferredMetadataLanguage); } + SetProviderIdFromPath(season, path); + return season; } return null; } + + /// <summary> + /// Sets provider ids from the season folder name. + /// </summary> + /// <param name="item">The season.</param> + /// <param name="path">The season folder path.</param> + private static void SetProviderIdFromPath(Season item, string path) + { + var justName = Path.GetFileName(path.AsSpan()); + + var tvdbId = justName.GetAttributeValue("tvdbid"); + item.TrySetProviderId(MetadataProvider.Tvdb, tvdbId); + + var tvmazeId = justName.GetAttributeValue("tvmazeid"); + item.TrySetProviderId(MetadataProvider.TvMaze, tvmazeId); + + var tmdbId = justName.GetAttributeValue("tmdbid"); + item.TrySetProviderId(MetadataProvider.Tmdb, tmdbId); + + // Anime databases model a single cour as its own entry, so a multi-season + // series maps to one of these ids per season rather than one per series. + var anidbId = justName.GetAttributeValue("anidbid"); + item.TrySetProviderId("AniDB", anidbId); + + var aniListId = justName.GetAttributeValue("anilistid"); + item.TrySetProviderId("AniList", aniListId); + + var aniSearchId = justName.GetAttributeValue("anisearchid"); + item.TrySetProviderId("AniSearch", aniSearchId); + } } } diff --git a/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs index c81a0adb89..8d40eab006 100644 --- a/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs @@ -31,7 +31,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV /// </summary> /// <param name="logger">The logger.</param> /// <param name="namingOptions">The naming options.</param> - public SeriesResolver(ILogger<SeriesResolver> logger, NamingOptions namingOptions) + public SeriesResolver(ILogger<SeriesResolver> logger, NamingOptions namingOptions) { _logger = logger; _namingOptions = namingOptions; @@ -57,6 +57,11 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV return null; } + if (args.Parent is not null && args.Parent.IsRoot) + { + return null; + } + var seriesInfo = Naming.TV.SeriesResolver.Resolve(_namingOptions, args.Path); var collectionType = args.GetCollectionType(); @@ -69,7 +74,8 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV return new Series { Path = args.Path, - Name = seriesInfo.Name + Name = seriesInfo.Name, + ProductionYear = seriesInfo.Year }; } } diff --git a/Emby.Server.Implementations/Library/Search/SearchManager.cs b/Emby.Server.Implementations/Library/Search/SearchManager.cs new file mode 100644 index 0000000000..306a8673d5 --- /dev/null +++ b/Emby.Server.Implementations/Library/Search/SearchManager.cs @@ -0,0 +1,453 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Extensions; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Model.Querying; +using MediaBrowser.Model.Search; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Emby.Server.Implementations.Library.Search; + +/// <summary> +/// Manages search providers and orchestrates search operations. +/// </summary> +public class SearchManager : ISearchManager +{ + private readonly ILibraryManager _libraryManager; + private readonly IUserManager _userManager; + private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; + private readonly IItemQueryHelpers _queryHelpers; + private readonly ILogger<SearchManager> _logger; + private IExternalSearchProvider[] _externalProviders = []; + private IInternalSearchProvider[] _internalProviders = []; + + /// <summary> + /// Initializes a new instance of the <see cref="SearchManager"/> class. + /// </summary> + /// <param name="libraryManager">The library manager.</param> + /// <param name="userManager">The user manager.</param> + /// <param name="dbProvider">The database context factory.</param> + /// <param name="queryHelpers">The shared item query helpers.</param> + /// <param name="logger">The logger.</param> + public SearchManager( + ILibraryManager libraryManager, + IUserManager userManager, + IDbContextFactory<JellyfinDbContext> dbProvider, + IItemQueryHelpers queryHelpers, + ILogger<SearchManager> logger) + { + _libraryManager = libraryManager; + _userManager = userManager; + _dbProvider = dbProvider; + _queryHelpers = queryHelpers; + _logger = logger; + } + + /// <inheritdoc/> + public void AddParts(IEnumerable<ISearchProvider> providers) + { + var allProviders = providers.OrderBy(p => p.Priority).ToArray(); + + _externalProviders = allProviders.OfType<IExternalSearchProvider>().ToArray(); + _internalProviders = allProviders.OfType<IInternalSearchProvider>().ToArray(); + + _logger.LogInformation( + "Registered {ExternalCount} external search providers: {ExternalProviders}. Fallback providers: {FallbackProviders}", + _externalProviders.Length, + string.Join(", ", _externalProviders.Select(p => $"{p.Name} (priority {p.Priority})")), + string.Join(", ", _internalProviders.Select(p => $"{p.Name} (priority {p.Priority})"))); + } + + /// <inheritdoc/> + public IReadOnlyList<ISearchProvider> GetProviders() + { + return [.. _externalProviders, .. _internalProviders]; + } + + /// <inheritdoc/> + public async Task<IReadOnlyList<SearchResult>> GetSearchResultsAsync( + SearchProviderQuery query, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + ArgumentException.ThrowIfNullOrWhiteSpace(query.SearchTerm); + + var searchTerm = query.SearchTerm.Trim().RemoveDiacritics(); + + var externalTask = CollectFromProvidersAsync(_externalProviders, query, searchTerm, cancellationToken); + var internalTask = _internalProviders.Length > 0 + ? CollectFromProvidersAsync(_internalProviders, query, searchTerm, cancellationToken) + : Task.FromResult<IReadOnlyList<SearchResult>>([]); + + await Task.WhenAll(externalTask, internalTask).ConfigureAwait(false); + + var externalResults = await externalTask.ConfigureAwait(false); + + // Internal providers apply user-access filtering inline in their queries. External + // providers don't know about user permissions, so they may return IDs from hidden + // libraries or items the user is otherwise blocked from. Filter them here to close + // that gap. The Items controller's second roundtrip via folder.GetItems applies most + // of these again, but it does not restrict by TopParentIds when ItemIds is set. + if (externalResults.Count > 0 && query.UserId.HasValue && !query.UserId.Value.IsEmpty()) + { + var user = _userManager.GetUserById(query.UserId.Value); + if (user is not null) + { + externalResults = await FilterByUserAccessAsync(externalResults, user, query, cancellationToken).ConfigureAwait(false); + } + } + + if (externalResults.Count > 0) + { + return externalResults; + } + + if (_internalProviders.Length > 0) + { + _logger.LogDebug("No results from external providers, using internal provider results"); + } + + return await internalTask.ConfigureAwait(false); + } + + private async Task<IReadOnlyList<SearchResult>> FilterByUserAccessAsync( + IReadOnlyList<SearchResult> candidates, + User user, + SearchProviderQuery query, + CancellationToken cancellationToken) + { + // SetUser populates parental rating + blocked/allowed tags, Build populates TopParentIds + // for the user's accessible libraries. The candidate ids are applied to the query below + // rather than to the filter because LibraryManager.AddUserToQuery skips TopParentIds when + // ItemIds is non-empty. + var accessFilter = SearchQueryAccessFilter.Build(user, query, _libraryManager); + + Guid[] candidateIds = [.. candidates.Select(c => c.ItemId)]; + + var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + var baseQuery = dbContext.BaseItems + .AsNoTracking() + .WhereOneOrMany(candidateIds, e => e.Id); + + baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, accessFilter); + + var allowedIds = await baseQuery + .Select(e => e.Id) + .ToHashSetAsync(cancellationToken) + .ConfigureAwait(false); + + if (allowedIds.Count == candidates.Count) + { + return candidates; + } + + var filtered = candidates.Where(c => allowedIds.Contains(c.ItemId)).ToList(); + if (filtered.Count < candidates.Count) + { + _logger.LogDebug( + "Dropped {Dropped} of {Total} search candidates due to user access filtering", + candidates.Count - filtered.Count, + candidates.Count); + } + + return filtered; + } + } + + /// <inheritdoc/> + public async Task<QueryResult<SearchHintInfo>> GetSearchHintsAsync(SearchQuery query, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + ArgumentException.ThrowIfNullOrWhiteSpace(query.SearchTerm); + + var providerQuery = BuildProviderQuery(query); + var candidates = await GetSearchResultsAsync(providerQuery, cancellationToken).ConfigureAwait(false); + if (candidates.Count == 0) + { + return new QueryResult<SearchHintInfo>(); + } + + var candidateScores = BuildScoreLookup(candidates); + var user = query.UserId.IsEmpty() ? null : _userManager.GetUserById(query.UserId); + + var excludeItemTypes = BuildExcludeItemTypes(query); + var includeItemTypes = BuildIncludeItemTypes(query); + + var internalQuery = new InternalItemsQuery(user) + { + ItemIds = candidateScores.Keys.ToArray(), + ExcludeItemTypes = excludeItemTypes.ToArray(), + IncludeItemTypes = includeItemTypes.Count > 0 ? includeItemTypes.ToArray() : [], + MediaTypes = query.MediaTypes.ToArray(), + IncludeItemsByName = !query.ParentId.HasValue, + ParentId = query.ParentId ?? Guid.Empty, + Recursive = true, + IsKids = query.IsKids, + IsMovie = query.IsMovie, + IsNews = query.IsNews, + IsSeries = query.IsSeries, + IsSports = query.IsSports, + DtoOptions = new DtoOptions + { + Fields = + [ + ItemFields.AirTime, + ItemFields.DateCreated, + ItemFields.ChannelInfo, + ItemFields.ParentId + ] + } + }; + + // MusicArtist items are "ItemsByName" entities - virtual items that aggregate content by artist name + // rather than being stored as regular library items. They require special handling: + // 1. Convert ParentId to AncestorIds (to filter by library folder) + // 2. Set IncludeItemsByName = true (to include these virtual items in results) + // 3. Clear IncludeItemTypes (GetAllArtists handles type filtering internally) + // 4. Use GetAllArtists() instead of GetItemList() to query the artist index + IReadOnlyList<BaseItem> items; + if (internalQuery.IncludeItemTypes.Length == 1 && internalQuery.IncludeItemTypes[0] == BaseItemKind.MusicArtist) + { + if (!internalQuery.ParentId.IsEmpty()) + { + internalQuery.AncestorIds = [internalQuery.ParentId]; + internalQuery.ParentId = Guid.Empty; + } + + internalQuery.IncludeItemsByName = true; + internalQuery.IncludeItemTypes = []; + items = _libraryManager.GetAllArtists(internalQuery).Items.Select(i => i.Item).ToList(); + } + else + { + items = _libraryManager.GetItemList(internalQuery); + } + + var orderedResults = items + .Select(item => new SearchHintInfo { Item = item }) + .OrderByDescending(hint => candidateScores.GetValueOrDefault(hint.Item.Id, 0f)) + .ToList(); + + var totalCount = orderedResults.Count; + + if (query.StartIndex.HasValue) + { + orderedResults = orderedResults.Skip(query.StartIndex.Value).ToList(); + } + + if (query.Limit.HasValue) + { + orderedResults = orderedResults.Take(query.Limit.Value).ToList(); + } + + return new QueryResult<SearchHintInfo>(query.StartIndex, totalCount, orderedResults); + } + + private async Task<IReadOnlyList<SearchResult>> CollectFromProvidersAsync( + IEnumerable<ISearchProvider> providers, + SearchProviderQuery providerQuery, + string searchTerm, + CancellationToken cancellationToken) + { + var requestedLimit = providerQuery.Limit ?? 100; + var applicable = providers.Where(p => p.CanSearch(providerQuery)).ToArray(); + if (applicable.Length == 0) + { + return []; + } + + var perProvider = await Task.WhenAll( + applicable.Select(p => CollectFromProviderAsync(p, providerQuery, searchTerm, requestedLimit, cancellationToken))) + .ConfigureAwait(false); + + var bestScores = new Dictionary<Guid, float>(); + foreach (var providerResults in perProvider) + { + foreach (var result in providerResults) + { + UpdateBestScore(bestScores, result); + } + } + + return bestScores + .Select(kvp => new SearchResult(kvp.Key, kvp.Value)) + .OrderByDescending(r => r.Score) + .Take(requestedLimit) + .ToList(); + } + + private async Task<IReadOnlyList<SearchResult>> CollectFromProviderAsync( + ISearchProvider provider, + SearchProviderQuery providerQuery, + string searchTerm, + int requestedLimit, + CancellationToken cancellationToken) + { + try + { + var results = provider is IExternalSearchProvider externalProvider + ? await CollectFromExternalProviderAsync(externalProvider, providerQuery, requestedLimit, cancellationToken).ConfigureAwait(false) + : await provider.SearchAsync(providerQuery, cancellationToken).ConfigureAwait(false); + + _logger.LogDebug( + "Provider {Provider} returned {Count} candidates for search term '{SearchTerm}'", + provider.Name, + results.Count, + searchTerm); + return results; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Search provider {Provider} failed for term '{SearchTerm}'", provider.Name, searchTerm); + return []; + } + } + + private static async Task<IReadOnlyList<SearchResult>> CollectFromExternalProviderAsync( + IExternalSearchProvider provider, + SearchProviderQuery providerQuery, + int requestedLimit, + CancellationToken cancellationToken) + { + var results = new List<SearchResult>(); + await foreach (var result in provider.SearchAsync(providerQuery, cancellationToken).ConfigureAwait(false)) + { + results.Add(result); + if (results.Count >= requestedLimit) + { + break; + } + } + + return results; + } + + private static void UpdateBestScore(Dictionary<Guid, float> bestScores, SearchResult result) + { + if (!bestScores.TryGetValue(result.ItemId, out var existingScore) || result.Score > existingScore) + { + bestScores[result.ItemId] = result.Score; + } + } + + private static Dictionary<Guid, float> BuildScoreLookup(IReadOnlyList<SearchResult> results) + { + var lookup = new Dictionary<Guid, float>(results.Count); + foreach (var result in results) + { + lookup[result.ItemId] = result.Score; + } + + return lookup; + } + + private static SearchProviderQuery BuildProviderQuery(SearchQuery query) + { + var excludeItemTypes = BuildExcludeItemTypes(query); + var includeItemTypes = BuildIncludeItemTypes(query); + + // Remove any excluded types from includes + if (includeItemTypes.Count > 0 && excludeItemTypes.Count > 0) + { + includeItemTypes.RemoveAll(excludeItemTypes.Contains); + } + + return new SearchProviderQuery + { + SearchTerm = query.SearchTerm, + UserId = query.UserId.IsEmpty() ? null : query.UserId, + IncludeItemTypes = includeItemTypes.ToArray(), + ExcludeItemTypes = excludeItemTypes.ToArray(), + MediaTypes = query.MediaTypes.ToArray(), + Limit = query.Limit, + ParentId = query.ParentId + }; + } + + private static List<BaseItemKind> BuildExcludeItemTypes(SearchQuery query) + { + var excludeItemTypes = query.ExcludeItemTypes.ToList(); + + excludeItemTypes.Add(BaseItemKind.Year); + excludeItemTypes.Add(BaseItemKind.Folder); + excludeItemTypes.Add(BaseItemKind.CollectionFolder); + + if (!query.IncludeGenres) + { + AddIfMissing(excludeItemTypes, BaseItemKind.Genre); + AddIfMissing(excludeItemTypes, BaseItemKind.MusicGenre); + } + + if (!query.IncludePeople) + { + AddIfMissing(excludeItemTypes, BaseItemKind.Person); + } + + if (!query.IncludeStudios) + { + AddIfMissing(excludeItemTypes, BaseItemKind.Studio); + } + + if (!query.IncludeArtists) + { + AddIfMissing(excludeItemTypes, BaseItemKind.MusicArtist); + } + + return excludeItemTypes; + } + + private static List<BaseItemKind> BuildIncludeItemTypes(SearchQuery query) + { + var includeItemTypes = query.IncludeItemTypes.ToList(); + if (query.IncludeMedia) + { + return includeItemTypes; + } + + if (query.IncludeGenres && IsEmptyOrContains(includeItemTypes, BaseItemKind.Genre)) + { + AddIfMissing(includeItemTypes, BaseItemKind.Genre); + AddIfMissing(includeItemTypes, BaseItemKind.MusicGenre); + } + + if (query.IncludePeople && IsEmptyOrContains(includeItemTypes, BaseItemKind.Person)) + { + AddIfMissing(includeItemTypes, BaseItemKind.Person); + } + + if (query.IncludeStudios && IsEmptyOrContains(includeItemTypes, BaseItemKind.Studio)) + { + AddIfMissing(includeItemTypes, BaseItemKind.Studio); + } + + if (query.IncludeArtists && IsEmptyOrContains(includeItemTypes, BaseItemKind.MusicArtist)) + { + AddIfMissing(includeItemTypes, BaseItemKind.MusicArtist); + } + + return includeItemTypes; + } + + private static bool IsEmptyOrContains(List<BaseItemKind> list, BaseItemKind value) + => list.Count == 0 || list.Contains(value); + + private static void AddIfMissing(List<BaseItemKind> list, BaseItemKind value) + { + if (!list.Contains(value)) + { + list.Add(value); + } + } +} diff --git a/Emby.Server.Implementations/Library/Search/SearchQueryAccessFilter.cs b/Emby.Server.Implementations/Library/Search/SearchQueryAccessFilter.cs new file mode 100644 index 0000000000..6e3f01de13 --- /dev/null +++ b/Emby.Server.Implementations/Library/Search/SearchQueryAccessFilter.cs @@ -0,0 +1,38 @@ +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Extensions; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; + +namespace Emby.Server.Implementations.Library.Search; + +/// <summary> +/// Builds the access filter that decides which items a search may return for a user. +/// </summary> +internal static class SearchQueryAccessFilter +{ + /// <summary> + /// Builds an access filter carrying the search's library access and type filters. + /// </summary> + /// <param name="user">The user the search runs for.</param> + /// <param name="query">The search query.</param> + /// <param name="libraryManager">The library manager.</param> + /// <returns>The access filter.</returns> + public static InternalItemsQuery Build(User user, SearchProviderQuery query, ILibraryManager libraryManager) + { + // The type filters have to travel with the access filter: a by-name item belongs to no + // library, so it carries no TopParentId to match, and the library filter only knows to + // exempt it when the query says those types are wanted. A search scoped to a parent gets + // no exemption because a by-name item has no parent to descend from either. + var accessFilter = new InternalItemsQuery(user) + { + IncludeItemTypes = query.IncludeItemTypes, + ExcludeItemTypes = query.ExcludeItemTypes, + IncludeItemsByName = !query.ParentId.HasValue || query.ParentId.Value.IsEmpty() + }; + + // ConfigureUserAccess populates TopParentIds for the libraries the user may open. + libraryManager.ConfigureUserAccess(accessFilter, user); + + return accessFilter; + } +} diff --git a/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs b/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs new file mode 100644 index 0000000000..c4d3b249d5 --- /dev/null +++ b/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs @@ -0,0 +1,230 @@ +#pragma warning disable RS0030 // Do not use banned APIs +#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Extensions; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Model.Configuration; +using Microsoft.EntityFrameworkCore; + +namespace Emby.Server.Implementations.Library.Search; + +/// <summary> +/// Built-in SQL-based search provider that queries the library database directly. +/// </summary> +public class SqlSearchProvider : IInternalSearchProvider +{ + private const int DefaultSearchLimit = 100; + private const float ExactMatchScore = 100f; + private const float PrefixMatchScore = 80f; + private const float WordPrefixMatchScore = 75f; + private const float ContainsMatchScore = 50f; + + private static readonly Guid _placeholderId = Guid.Parse("00000000-0000-0000-0000-000000000001"); + + private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; + private readonly IItemTypeLookup _itemTypeLookup; + private readonly ILibraryManager _libraryManager; + private readonly IUserManager _userManager; + private readonly IItemQueryHelpers _queryHelpers; + + /// <summary> + /// Initializes a new instance of the <see cref="SqlSearchProvider"/> class. + /// </summary> + /// <param name="dbProvider">The database context factory.</param> + /// <param name="itemTypeLookup">The item type lookup.</param> + /// <param name="libraryManager">The library manager.</param> + /// <param name="userManager">The user manager.</param> + /// <param name="queryHelpers">The shared item query helpers.</param> + public SqlSearchProvider( + IDbContextFactory<JellyfinDbContext> dbProvider, + IItemTypeLookup itemTypeLookup, + ILibraryManager libraryManager, + IUserManager userManager, + IItemQueryHelpers queryHelpers) + { + _dbProvider = dbProvider; + _itemTypeLookup = itemTypeLookup; + _libraryManager = libraryManager; + _userManager = userManager; + _queryHelpers = queryHelpers; + } + + /// <inheritdoc/> + public string Name => "Database"; + + /// <inheritdoc/> + public MetadataPluginType Type => MetadataPluginType.SearchProvider; + + /// <inheritdoc/> + public int Priority => 100; // Low priority - runs as fallback + + /// <inheritdoc/> + public bool CanSearch(SearchProviderQuery query) + { + // SQL search can always handle any query + return true; + } + + /// <inheritdoc/> + public async Task<IReadOnlyList<SearchResult>> SearchAsync(SearchProviderQuery query, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(query); + ArgumentException.ThrowIfNullOrWhiteSpace(query.SearchTerm); + + var rawSearchTerm = query.SearchTerm.Trim().RemoveDiacritics(); + if (string.IsNullOrEmpty(rawSearchTerm)) + { + return []; + } + + var cleanSearchTerm = rawSearchTerm.GetCleanValue(); + if (string.IsNullOrEmpty(cleanSearchTerm)) + { + return []; + } + + var cleanPrefix = cleanSearchTerm + " "; + // OriginalTitle is stored mixed-case and isn't pre-normalized like CleanName, + // so match it via a case-insensitive LIKE rather than a per-row case conversion + // that may not translate to SQL on every provider. + var likeOriginal = $"%{rawSearchTerm}%"; + var limit = query.Limit ?? DefaultSearchLimit; + + var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + // Lightweight projection: select only what's needed to score and identify items. + var dbQuery = dbContext.BaseItems + .AsNoTracking() + .Where(e => e.Id != _placeholderId) + .Where(e => !e.IsVirtualItem) + .Where(e => e.CleanName!.Contains(cleanSearchTerm) + || (e.OriginalTitle != null && EF.Functions.Like(e.OriginalTitle, likeOriginal))); + + dbQuery = ApplyTypeFilter(dbQuery, query.IncludeItemTypes, query.ExcludeItemTypes); + dbQuery = ApplyMediaTypeFilter(dbQuery, query.MediaTypes); + dbQuery = ApplyParentFilter(dbQuery, query.ParentId); + dbQuery = ApplyUserAccessFilter(dbContext, dbQuery, query); + + // Compute the score in SQL: the ternary translates to a CASE WHEN. CleanName is + // the pre-normalized (lowercase, diacritic-stripped) form, so we score against it + // directly without any per-row case conversion. Items that match only via + // OriginalTitle fall through to the Contains tier. + // Tie-break by Id for deterministic ordering so the explicit OrderBy + Take + // satisfies EF Core's row-limiting-with-OrderBy requirement. + var scored = dbQuery.Select(e => new + { + e.Id, + Score = + (e.CleanName == cleanSearchTerm) ? ExactMatchScore + : e.CleanName!.StartsWith(cleanSearchTerm) ? PrefixMatchScore + : e.CleanName!.Contains(cleanPrefix) ? WordPrefixMatchScore + : ContainsMatchScore + }); + + return await scored + .OrderByDescending(x => x.Score) + .ThenBy(x => x.Id) + .Take(limit) + .Select(x => new SearchResult(x.Id, x.Score)) + .ToArrayAsync(cancellationToken) + .ConfigureAwait(false); + } + } + + private IQueryable<BaseItemEntity> ApplyTypeFilter( + IQueryable<BaseItemEntity> query, + BaseItemKind[] includeItemTypes, + BaseItemKind[] excludeItemTypes) + { + if (includeItemTypes.Length > 0) + { + var includeTypeNames = MapKindsToTypeNames(includeItemTypes); + if (includeTypeNames.Count > 0) + { + query = query.Where(e => includeTypeNames.Contains(e.Type)); + } + } + else if (excludeItemTypes.Length > 0) + { + var excludeTypeNames = MapKindsToTypeNames(excludeItemTypes); + if (excludeTypeNames.Count > 0) + { + query = query.Where(e => !excludeTypeNames.Contains(e.Type)); + } + } + + return query; + } + + private static IQueryable<BaseItemEntity> ApplyMediaTypeFilter( + IQueryable<BaseItemEntity> query, + MediaType[] mediaTypes) + { + if (mediaTypes.Length == 0) + { + return query; + } + + var mediaTypeNames = mediaTypes.Select(m => m.ToString()).ToArray(); + return query.Where(e => e.MediaType != null && mediaTypeNames.Contains(e.MediaType)); + } + + private static IQueryable<BaseItemEntity> ApplyParentFilter( + IQueryable<BaseItemEntity> query, + Guid? parentId) + { + if (!parentId.HasValue || parentId.Value.IsEmpty()) + { + return query; + } + + var pid = parentId.Value; + return query.Where(e => e.ParentId == pid || e.Parents!.Any(p => p.ParentItemId == pid)); + } + + private IQueryable<BaseItemEntity> ApplyUserAccessFilter( + JellyfinDbContext dbContext, + IQueryable<BaseItemEntity> query, + SearchProviderQuery searchQuery) + { + var userId = searchQuery.UserId; + if (!userId.HasValue || userId.Value.IsEmpty()) + { + return query; + } + + var user = _userManager.GetUserById(userId.Value); + if (user is null) + { + return query; + } + + var accessFilter = SearchQueryAccessFilter.Build(user, searchQuery, _libraryManager); + return _queryHelpers.ApplyAccessFiltering(dbContext, query, accessFilter); + } + + private List<string> MapKindsToTypeNames(BaseItemKind[] kinds) + { + var list = new List<string>(kinds.Length); + foreach (var kind in kinds) + { + if (_itemTypeLookup.BaseItemKindNames.TryGetValue(kind, out var name) && name is not null) + { + list.Add(name); + } + } + + return list; + } +} diff --git a/Emby.Server.Implementations/Library/SearchEngine.cs b/Emby.Server.Implementations/Library/SearchEngine.cs deleted file mode 100644 index 9d81b835ce..0000000000 --- a/Emby.Server.Implementations/Library/SearchEngine.cs +++ /dev/null @@ -1,200 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Linq; -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; -using MediaBrowser.Controller.Library; -using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Search; - -namespace Emby.Server.Implementations.Library -{ - public class SearchEngine : ISearchEngine - { - private readonly ILibraryManager _libraryManager; - private readonly IUserManager _userManager; - - public SearchEngine(ILibraryManager libraryManager, IUserManager userManager) - { - _libraryManager = libraryManager; - _userManager = userManager; - } - - public QueryResult<SearchHintInfo> GetSearchHints(SearchQuery query) - { - User? user = null; - if (!query.UserId.IsEmpty()) - { - user = _userManager.GetUserById(query.UserId); - } - - var results = GetSearchHints(query, user); - var totalRecordCount = results.Count; - - if (query.StartIndex.HasValue) - { - results = results.GetRange(query.StartIndex.Value, totalRecordCount - query.StartIndex.Value); - } - - if (query.Limit.HasValue) - { - results = results.GetRange(0, Math.Min(query.Limit.Value, results.Count)); - } - - return new QueryResult<SearchHintInfo>( - query.StartIndex, - totalRecordCount, - results); - } - - private static void AddIfMissing(List<BaseItemKind> list, BaseItemKind value) - { - if (!list.Contains(value)) - { - list.Add(value); - } - } - - /// <summary> - /// Gets the search hints. - /// </summary> - /// <param name="query">The query.</param> - /// <param name="user">The user.</param> - /// <returns>IEnumerable{SearchHintResult}.</returns> - /// <exception cref="ArgumentException"><c>query.SearchTerm</c> is <c>null</c> or empty.</exception> - private List<SearchHintInfo> GetSearchHints(SearchQuery query, User? user) - { - var searchTerm = query.SearchTerm; - - ArgumentException.ThrowIfNullOrEmpty(searchTerm); - - searchTerm = searchTerm.Trim().RemoveDiacritics(); - - var excludeItemTypes = query.ExcludeItemTypes.ToList(); - var includeItemTypes = query.IncludeItemTypes.ToList(); - - excludeItemTypes.Add(BaseItemKind.Year); - excludeItemTypes.Add(BaseItemKind.Folder); - - if (query.IncludeGenres && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Genre))) - { - if (!query.IncludeMedia) - { - AddIfMissing(includeItemTypes, BaseItemKind.Genre); - AddIfMissing(includeItemTypes, BaseItemKind.MusicGenre); - } - } - else - { - AddIfMissing(excludeItemTypes, BaseItemKind.Genre); - AddIfMissing(excludeItemTypes, BaseItemKind.MusicGenre); - } - - if (query.IncludePeople && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Person))) - { - if (!query.IncludeMedia) - { - AddIfMissing(includeItemTypes, BaseItemKind.Person); - } - } - else - { - AddIfMissing(excludeItemTypes, BaseItemKind.Person); - } - - if (query.IncludeStudios && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Studio))) - { - if (!query.IncludeMedia) - { - AddIfMissing(includeItemTypes, BaseItemKind.Studio); - } - } - else - { - AddIfMissing(excludeItemTypes, BaseItemKind.Studio); - } - - if (query.IncludeArtists && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.MusicArtist))) - { - if (!query.IncludeMedia) - { - AddIfMissing(includeItemTypes, BaseItemKind.MusicArtist); - } - } - else - { - AddIfMissing(excludeItemTypes, BaseItemKind.MusicArtist); - } - - AddIfMissing(excludeItemTypes, BaseItemKind.CollectionFolder); - AddIfMissing(excludeItemTypes, BaseItemKind.Folder); - var mediaTypes = query.MediaTypes.ToList(); - - if (includeItemTypes.Count > 0) - { - excludeItemTypes.Clear(); - mediaTypes.Clear(); - } - - var searchQuery = new InternalItemsQuery(user) - { - SearchTerm = searchTerm, - ExcludeItemTypes = excludeItemTypes.ToArray(), - IncludeItemTypes = includeItemTypes.ToArray(), - Limit = query.Limit, - IncludeItemsByName = !query.ParentId.HasValue, - ParentId = query.ParentId ?? Guid.Empty, - OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) }, - Recursive = true, - - IsKids = query.IsKids, - IsMovie = query.IsMovie, - IsNews = query.IsNews, - IsSeries = query.IsSeries, - IsSports = query.IsSports, - MediaTypes = mediaTypes.ToArray(), - - DtoOptions = new DtoOptions - { - Fields = new ItemFields[] - { - ItemFields.AirTime, - ItemFields.DateCreated, - ItemFields.ChannelInfo, - ItemFields.ParentId - } - } - }; - - IReadOnlyList<BaseItem> mediaItems; - - if (searchQuery.IncludeItemTypes.Length == 1 && searchQuery.IncludeItemTypes[0] == BaseItemKind.MusicArtist) - { - if (!searchQuery.ParentId.IsEmpty()) - { - searchQuery.AncestorIds = [searchQuery.ParentId]; - searchQuery.ParentId = Guid.Empty; - } - - searchQuery.IncludeItemsByName = true; - searchQuery.IncludeItemTypes = Array.Empty<BaseItemKind>(); - mediaItems = _libraryManager.GetAllArtists(searchQuery).Items.Select(i => i.Item).ToList(); - } - else - { - mediaItems = _libraryManager.GetItemList(searchQuery); - } - - return mediaItems.Select(i => new SearchHintInfo - { - Item = i - }).ToList(); - } - } -} diff --git a/Emby.Server.Implementations/Library/SimilarItems/AudioSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/AudioSimilarItemsProvider.cs new file mode 100644 index 0000000000..1cc670b8ee --- /dev/null +++ b/Emby.Server.Implementations/Library/SimilarItems/AudioSimilarItemsProvider.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Configuration; + +namespace Emby.Server.Implementations.Library.SimilarItems; + +/// <summary> +/// Provides similar items for audio tracks. +/// </summary> +public class AudioSimilarItemsProvider : ILocalSimilarItemsProvider<Audio> +{ + private readonly ILibraryManager _libraryManager; + + /// <summary> + /// Initializes a new instance of the <see cref="AudioSimilarItemsProvider"/> class. + /// </summary> + /// <param name="libraryManager">The library manager.</param> + public AudioSimilarItemsProvider(ILibraryManager libraryManager) + { + _libraryManager = libraryManager; + } + + /// <inheritdoc/> + public string Name => "Local Genre/Tag"; + + /// <inheritdoc/> + public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider; + + /// <inheritdoc/> + public Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(Audio item, SimilarItemsQuery query, CancellationToken cancellationToken) + { + var internalQuery = new InternalItemsQuery(query.User) + { + Genres = item.Genres, + Tags = item.Tags, + Limit = query.Limit, + DtoOptions = query.DtoOptions ?? new DtoOptions(), + ExcludeItemIds = [.. query.ExcludeItemIds], + ExcludeArtistIds = [.. query.ExcludeArtistIds], + IncludeItemTypes = [BaseItemKind.Audio], + EnableGroupByMetadataKey = false, + EnableTotalRecordCount = true, + OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)] + }; + + return Task.FromResult(_libraryManager.GetItemList(internalQuery)); + } +} diff --git a/Emby.Server.Implementations/Library/SimilarItems/LiveTvProgramSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/LiveTvProgramSimilarItemsProvider.cs new file mode 100644 index 0000000000..7665ee2f79 --- /dev/null +++ b/Emby.Server.Implementations/Library/SimilarItems/LiveTvProgramSimilarItemsProvider.cs @@ -0,0 +1,94 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Configuration; + +namespace Emby.Server.Implementations.Library.SimilarItems; + +/// <summary> +/// Provides similar items for Live TV programs. +/// </summary> +public class LiveTvProgramSimilarItemsProvider : ILocalSimilarItemsProvider<LiveTvProgram> +{ + private readonly ILibraryManager _libraryManager; + private readonly IServerConfigurationManager _serverConfigurationManager; + + /// <summary> + /// Initializes a new instance of the <see cref="LiveTvProgramSimilarItemsProvider"/> class. + /// </summary> + /// <param name="libraryManager">The library manager.</param> + /// <param name="serverConfigurationManager">The server configuration manager.</param> + public LiveTvProgramSimilarItemsProvider( + ILibraryManager libraryManager, + IServerConfigurationManager serverConfigurationManager) + { + _libraryManager = libraryManager; + _serverConfigurationManager = serverConfigurationManager; + } + + /// <inheritdoc/> + public string Name => "Local Genre/Tag"; + + /// <inheritdoc/> + public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider; + + /// <inheritdoc/> + public Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(LiveTvProgram item, SimilarItemsQuery query, CancellationToken cancellationToken) + { + BaseItemKind[] includeItemTypes; + bool enableGroupByMetadataKey; + bool enableTotalRecordCount; + + if (item.IsMovie) + { + // Movie-like program + var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie }; + + if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) + { + itemTypes.Add(BaseItemKind.Trailer); + itemTypes.Add(BaseItemKind.LiveTvProgram); + } + + includeItemTypes = [.. itemTypes]; + enableGroupByMetadataKey = true; + enableTotalRecordCount = false; + } + else if (item.IsSeries) + { + // Series-like program + includeItemTypes = [BaseItemKind.Series]; + enableGroupByMetadataKey = false; + enableTotalRecordCount = true; + } + else + { + // Default - match same type + includeItemTypes = [item.GetBaseItemKind()]; + enableGroupByMetadataKey = false; + enableTotalRecordCount = true; + } + + var internalQuery = new InternalItemsQuery(query.User) + { + Genres = item.Genres, + Tags = item.Tags, + Limit = query.Limit, + DtoOptions = query.DtoOptions ?? new DtoOptions(), + ExcludeItemIds = [.. query.ExcludeItemIds], + IncludeItemTypes = includeItemTypes, + EnableGroupByMetadataKey = enableGroupByMetadataKey, + EnableTotalRecordCount = enableTotalRecordCount, + OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)] + }; + + return Task.FromResult(_libraryManager.GetItemList(internalQuery)); + } +} diff --git a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs new file mode 100644 index 0000000000..b1547e72fe --- /dev/null +++ b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs @@ -0,0 +1,343 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Extensions; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Model.Configuration; +using Microsoft.EntityFrameworkCore; +using BaseItemDto = MediaBrowser.Controller.Entities.BaseItem; + +namespace Emby.Server.Implementations.Library.SimilarItems; + +/// <summary> +/// Provides similar items for movies and trailers using weighted scoring. +/// </summary> +public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie>, ILocalSimilarItemsProvider<Trailer>, IBatchLocalSimilarItemsProvider +{ + private const int GenreWeight = 10; + private const int TagWeight = 5; + private const int StudioWeight = 5; + private const int DirectorWeight = 50; + private const int ActorWeight = 15; + + // Caps the batch fan-out so downstream IN-list sizes (per-source scores, accessible-id + // load, navigation includes) stay bounded regardless of caller input. + private const int MaxBatchSourceItems = 64; + + private static readonly (ItemValueType Type, int Weight)[] _itemValueDimensions = + [ + (ItemValueType.Genre, GenreWeight), + (ItemValueType.Tags, TagWeight), + (ItemValueType.Studios, StudioWeight) + ]; + + private static readonly Dictionary<string, int> _personTypeWeights = new(StringComparer.Ordinal) + { + [nameof(PersonKind.Director)] = DirectorWeight, + [nameof(PersonKind.Actor)] = ActorWeight, + [nameof(PersonKind.GuestStar)] = ActorWeight, + }; + + private static readonly string[] _scoredPersonTypes = [.. _personTypeWeights.Keys]; + + private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; + private readonly IItemQueryHelpers _queryHelpers; + private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly ILibraryManager _libraryManager; + + /// <summary> + /// Initializes a new instance of the <see cref="MovieSimilarItemsProvider"/> class. + /// </summary> + /// <param name="dbProvider">The database context factory.</param> + /// <param name="queryHelpers">The shared query helpers.</param> + /// <param name="serverConfigurationManager">The server configuration manager.</param> + /// <param name="libraryManager">The library manager.</param> + public MovieSimilarItemsProvider( + IDbContextFactory<JellyfinDbContext> dbProvider, + IItemQueryHelpers queryHelpers, + IServerConfigurationManager serverConfigurationManager, + ILibraryManager libraryManager) + { + _dbProvider = dbProvider; + _queryHelpers = queryHelpers; + _serverConfigurationManager = serverConfigurationManager; + _libraryManager = libraryManager; + } + + /// <inheritdoc/> + public string Name => "Local Genre/Tag"; + + /// <inheritdoc/> + public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider; + + /// <inheritdoc/> + public async Task<IReadOnlyList<BaseItemDto>> GetSimilarItemsAsync(Movie item, SimilarItemsQuery query, CancellationToken cancellationToken) + { + var results = await GetBatchSimilarItemsAsync([item], query, cancellationToken).ConfigureAwait(false); + return results.TryGetValue(item.Id, out var items) ? items : []; + } + + /// <inheritdoc/> + public async Task<IReadOnlyList<BaseItemDto>> GetSimilarItemsAsync(Trailer item, SimilarItemsQuery query, CancellationToken cancellationToken) + { + var results = await GetBatchSimilarItemsAsync([item], query, cancellationToken).ConfigureAwait(false); + return results.TryGetValue(item.Id, out var items) ? items : []; + } + + bool ILocalSimilarItemsProvider.Supports(Type itemType) + => typeof(Movie).IsAssignableFrom(itemType) || typeof(Trailer).IsAssignableFrom(itemType); + + Task<IReadOnlyList<BaseItem>> ILocalSimilarItemsProvider.GetSimilarItemsAsync(BaseItem item, SimilarItemsQuery query, CancellationToken cancellationToken) + => item switch + { + Movie movie => GetSimilarItemsAsync(movie, query, cancellationToken), + Trailer trailer => GetSimilarItemsAsync(trailer, query, cancellationToken), + _ => throw new ArgumentException($"Unsupported item type {item.GetType()}", nameof(item)) + }; + + /// <inheritdoc/> + public async Task<Dictionary<Guid, IReadOnlyList<BaseItemDto>>> GetBatchSimilarItemsAsync( + IReadOnlyList<BaseItemDto> sourceItems, + SimilarItemsQuery query, + CancellationToken cancellationToken) + { + var includeItemTypes = new List<BaseItemKind> { BaseItemKind.Movie }; + if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) + { + includeItemTypes.Add(BaseItemKind.Trailer); + includeItemTypes.Add(BaseItemKind.LiveTvProgram); + } + + var limit = query.Limit ?? 50; + var dtoOptions = query.DtoOptions ?? new DtoOptions(); + + if (sourceItems.Count > MaxBatchSourceItems) + { + sourceItems = sourceItems.Take(MaxBatchSourceItems).ToList(); + } + + var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (context.ConfigureAwait(false)) + { + // Phase 1: Score all candidates per source item + var sourceIds = sourceItems.Select(i => i.Id).ToList(); + var perSourceScores = await ComputeBatchScoresAsync(sourceIds, context, cancellationToken).ConfigureAwait(false); + + var allCandidateIds = new HashSet<Guid>(); + foreach (var (_, scores) in perSourceScores) + { + allCandidateIds.UnionWith( + scores.OrderByDescending(kvp => kvp.Value) + .Take(limit * 3) + .Select(kvp => kvp.Key)); + } + + var result = new Dictionary<Guid, IReadOnlyList<BaseItemDto>>(); + if (allCandidateIds.Count == 0) + { + return result; + } + + // Phase 2: One access filter for all candidates + var filter = new InternalItemsQuery(query.User) + { + IncludeItemTypes = [.. includeItemTypes], + ExcludeItemIds = [.. query.ExcludeItemIds], + DtoOptions = dtoOptions, + EnableGroupByMetadataKey = true, + EnableTotalRecordCount = false, + IsMovie = true, + IsPlayed = false + }; + + if (query.User is not null) + { + _libraryManager.ConfigureUserAccess(filter, query.User); + } + + _queryHelpers.PrepareFilterQuery(filter); + var baseQuery = _queryHelpers.PrepareItemQuery(context, filter); + baseQuery = _queryHelpers.TranslateQuery(baseQuery, context, filter); + + var allCandidateIdsList = allCandidateIds.ToList(); + var accessibleItems = await baseQuery + .WhereOneOrMany(allCandidateIdsList, e => e.Id) + .Select(e => new { e.Id, e.PresentationUniqueKey }) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + // Phase 3: Pick top IDs per source, dedup by PresentationUniqueKey + var allOrderedIds = new HashSet<Guid>(); + var perSourceOrderedIds = new Dictionary<Guid, List<Guid>>(); + + foreach (var item in sourceItems) + { + if (!perSourceScores.TryGetValue(item.Id, out var scores)) + { + continue; + } + + var orderedIds = accessibleItems + .Where(x => scores.ContainsKey(x.Id)) + .OrderByDescending(x => scores.GetValueOrDefault(x.Id)) + .DistinctBy(x => x.PresentationUniqueKey) + .Take(limit) + .Select(x => x.Id) + .ToList(); + + if (orderedIds.Count > 0) + { + perSourceOrderedIds[item.Id] = orderedIds; + allOrderedIds.UnionWith(orderedIds); + } + } + + if (allOrderedIds.Count == 0) + { + return result; + } + + // Phase 4: One entity load for all results + var allOrderedIdsList = allOrderedIds.ToList(); + var entities = await _queryHelpers.ApplyNavigations( + context.BaseItems.AsNoTracking().WhereOneOrMany(allOrderedIdsList, e => e.Id), + filter) + .AsSplitQuery() + .ToListAsync(cancellationToken).ConfigureAwait(false); + + var entitiesById = entities + .Select(e => _queryHelpers.DeserializeBaseItem(e, filter.SkipDeserialization)) + .Where(dto => dto is not null) + .ToDictionary(i => i!.Id); + + // Phase 5: Split by source, preserving score order + foreach (var (sourceId, orderedIds) in perSourceOrderedIds) + { + var items = orderedIds + .Where(entitiesById.ContainsKey) + .Select(id => entitiesById[id]!) + .ToList(); + + if (items.Count > 0) + { + result[sourceId] = items; + } + } + + return result; + } + } + + private static async Task<Dictionary<Guid, Dictionary<Guid, int>>> ComputeBatchScoresAsync(List<Guid> sourceIds, JellyfinDbContext context, CancellationToken cancellationToken) + { + var result = new Dictionary<Guid, Dictionary<Guid, int>>(); + foreach (var id in sourceIds) + { + result[id] = []; + } + + foreach (var (valueType, weight) in _itemValueDimensions) + { + var sourceRows = await context.ItemValuesMap.AsNoTracking() + .Where(m => sourceIds.Contains(m.ItemId) && m.ItemValue.Type == valueType) + .Select(m => new { m.ItemId, Key = m.ItemValue.CleanValue }) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + var sourceMap = sourceRows.GroupBy(r => r.ItemId).ToDictionary(g => g.Key, g => g.Select(x => x.Key).ToHashSet()); + var allKeys = sourceMap.Values.SelectMany(v => v).Distinct().ToList(); + if (allKeys.Count == 0) + { + continue; + } + + var candidateRows = await context.ItemValuesMap.AsNoTracking() + .Where(m => !m.Item.PrimaryVersionId.HasValue && m.ItemValue.Type == valueType && allKeys.Contains(m.ItemValue.CleanValue)) + .Select(m => new { m.ItemId, Key = m.ItemValue.CleanValue }) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + var keyToCandidates = candidateRows.GroupBy(r => r.Key).ToDictionary(g => g.Key, g => g.Select(x => x.ItemId).ToList()); + ApplyDimensionScores(sourceIds, sourceMap, keyToCandidates, weight, result); + } + + var personSourceRows = await context.PeopleBaseItemMap.AsNoTracking() + .Where(m => sourceIds.Contains(m.ItemId) && _scoredPersonTypes.Contains(m.People.PersonType)) + .Select(m => new { m.ItemId, m.PeopleId, m.People.PersonType }) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + if (personSourceRows.Count > 0) + { + var personCandidateRows = await context.PeopleBaseItemMap.AsNoTracking() + .Where(m => !m.Item.PrimaryVersionId.HasValue) + .Where(m => context.PeopleBaseItemMap + .Where(s => sourceIds.Contains(s.ItemId) && _scoredPersonTypes.Contains(s.People.PersonType)) + .Select(s => s.PeopleId) + .Contains(m.PeopleId)) + .Select(m => new { m.ItemId, m.PeopleId }) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + var personToCandidates = personCandidateRows + .GroupBy(r => r.PeopleId) + .ToDictionary(g => g.Key, g => g.Select(x => x.ItemId).ToList()); + + foreach (var weightGroup in personSourceRows.GroupBy(r => _personTypeWeights[r.PersonType!])) + { + var sourceMap = weightGroup + .GroupBy(r => r.ItemId) + .ToDictionary(g => g.Key, g => g.Select(x => x.PeopleId).ToHashSet()); + ApplyDimensionScores(sourceIds, sourceMap, personToCandidates, weightGroup.Key, result); + } + } + + foreach (var sourceId in sourceIds) + { + var scoreMap = result[sourceId]; + scoreMap.Remove(sourceId); + if (scoreMap.Count == 0) + { + result.Remove(sourceId); + } + } + + return result; + } + + private static void ApplyDimensionScores<TKey>( + List<Guid> sourceIds, + Dictionary<Guid, HashSet<TKey>> sourceMap, + Dictionary<TKey, List<Guid>> keyToCandidates, + int weight, + Dictionary<Guid, Dictionary<Guid, int>> result) + where TKey : notnull + { + foreach (var sourceId in sourceIds) + { + if (!sourceMap.TryGetValue(sourceId, out var sourceKeys)) + { + continue; + } + + var scoreMap = result[sourceId]; + foreach (var key in sourceKeys) + { + if (!keyToCandidates.TryGetValue(key, out var candidates)) + { + continue; + } + + foreach (var candidateId in candidates) + { + scoreMap[candidateId] = scoreMap.GetValueOrDefault(candidateId) + weight; + } + } + } + } +} diff --git a/Emby.Server.Implementations/Library/SimilarItems/MusicAlbumSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/MusicAlbumSimilarItemsProvider.cs new file mode 100644 index 0000000000..c13045deda --- /dev/null +++ b/Emby.Server.Implementations/Library/SimilarItems/MusicAlbumSimilarItemsProvider.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Configuration; + +namespace Emby.Server.Implementations.Library.SimilarItems; + +/// <summary> +/// Provides similar items for music albums. +/// </summary> +public class MusicAlbumSimilarItemsProvider : ILocalSimilarItemsProvider<MusicAlbum> +{ + private readonly ILibraryManager _libraryManager; + + /// <summary> + /// Initializes a new instance of the <see cref="MusicAlbumSimilarItemsProvider"/> class. + /// </summary> + /// <param name="libraryManager">The library manager.</param> + public MusicAlbumSimilarItemsProvider(ILibraryManager libraryManager) + { + _libraryManager = libraryManager; + } + + /// <inheritdoc/> + public string Name => "Local Genre/Tag"; + + /// <inheritdoc/> + public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider; + + /// <inheritdoc/> + public Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(MusicAlbum item, SimilarItemsQuery query, CancellationToken cancellationToken) + { + var internalQuery = new InternalItemsQuery(query.User) + { + Genres = item.Genres, + Tags = item.Tags, + Limit = query.Limit, + DtoOptions = query.DtoOptions ?? new DtoOptions(), + ExcludeItemIds = [.. query.ExcludeItemIds], + ExcludeArtistIds = [.. query.ExcludeArtistIds], + IncludeItemTypes = [BaseItemKind.MusicAlbum], + EnableGroupByMetadataKey = false, + EnableTotalRecordCount = true, + OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)] + }; + + return Task.FromResult(_libraryManager.GetItemList(internalQuery)); + } +} diff --git a/Emby.Server.Implementations/Library/SimilarItems/MusicArtistSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/MusicArtistSimilarItemsProvider.cs new file mode 100644 index 0000000000..3331419442 --- /dev/null +++ b/Emby.Server.Implementations/Library/SimilarItems/MusicArtistSimilarItemsProvider.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Configuration; + +namespace Emby.Server.Implementations.Library.SimilarItems; + +/// <summary> +/// Provides similar items for music artists. +/// </summary> +public class MusicArtistSimilarItemsProvider : ILocalSimilarItemsProvider<MusicArtist> +{ + private readonly ILibraryManager _libraryManager; + + /// <summary> + /// Initializes a new instance of the <see cref="MusicArtistSimilarItemsProvider"/> class. + /// </summary> + /// <param name="libraryManager">The library manager.</param> + public MusicArtistSimilarItemsProvider(ILibraryManager libraryManager) + { + _libraryManager = libraryManager; + } + + /// <inheritdoc/> + public string Name => "Local Genre/Tag"; + + /// <inheritdoc/> + public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider; + + /// <inheritdoc/> + public Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(MusicArtist item, SimilarItemsQuery query, CancellationToken cancellationToken) + { + var internalQuery = new InternalItemsQuery(query.User) + { + Genres = item.Genres, + Tags = item.Tags, + Limit = query.Limit, + DtoOptions = query.DtoOptions ?? new DtoOptions(), + ExcludeItemIds = [.. query.ExcludeItemIds], + ExcludeArtistIds = [.. query.ExcludeArtistIds], + IncludeItemTypes = [BaseItemKind.MusicArtist], + EnableGroupByMetadataKey = false, + EnableTotalRecordCount = true, + OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)] + }; + + return Task.FromResult(_libraryManager.GetItemList(internalQuery)); + } +} diff --git a/Emby.Server.Implementations/Library/SimilarItems/SeriesSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/SeriesSimilarItemsProvider.cs new file mode 100644 index 0000000000..0366fb752e --- /dev/null +++ b/Emby.Server.Implementations/Library/SimilarItems/SeriesSimilarItemsProvider.cs @@ -0,0 +1,54 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Configuration; + +namespace Emby.Server.Implementations.Library.SimilarItems; + +/// <summary> +/// Provides similar items for TV series. +/// </summary> +public class SeriesSimilarItemsProvider : ILocalSimilarItemsProvider<Series> +{ + private readonly ILibraryManager _libraryManager; + + /// <summary> + /// Initializes a new instance of the <see cref="SeriesSimilarItemsProvider"/> class. + /// </summary> + /// <param name="libraryManager">The library manager.</param> + public SeriesSimilarItemsProvider(ILibraryManager libraryManager) + { + _libraryManager = libraryManager; + } + + /// <inheritdoc/> + public string Name => "Local Genre/Tag"; + + /// <inheritdoc/> + public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider; + + /// <inheritdoc/> + public Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(Series item, SimilarItemsQuery query, CancellationToken cancellationToken) + { + var internalQuery = new InternalItemsQuery(query.User) + { + Genres = item.Genres, + Tags = item.Tags, + Limit = query.Limit, + DtoOptions = query.DtoOptions ?? new DtoOptions(), + ExcludeItemIds = [.. query.ExcludeItemIds], + IncludeItemTypes = [BaseItemKind.Series], + EnableGroupByMetadataKey = false, + EnableTotalRecordCount = true, + OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)] + }; + + return Task.FromResult(_libraryManager.GetItemList(internalQuery)); + } +} diff --git a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsAccessFilter.cs b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsAccessFilter.cs new file mode 100644 index 0000000000..75aea0eab6 --- /dev/null +++ b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsAccessFilter.cs @@ -0,0 +1,42 @@ +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; + +namespace Emby.Server.Implementations.Library.SimilarItems; + +/// <summary> +/// Builds the access filter that decides which items a similar-items lookup may return for a user. +/// </summary> +internal static class SimilarItemsAccessFilter +{ + private static readonly BaseItemKind[] _itemByNameKinds = + [ + BaseItemKind.Person, + BaseItemKind.Genre, + BaseItemKind.MusicGenre, + BaseItemKind.MusicArtist, + BaseItemKind.Studio + ]; + + /// <summary> + /// Builds an access filter carrying the user's library access and parental restrictions. + /// </summary> + /// <param name="user">The user the lookup runs for.</param> + /// <param name="libraryManager">The library manager.</param> + /// <returns>The access filter.</returns> + public static InternalItemsQuery Build(User user, ILibraryManager libraryManager) + { + // IncludeItemTypes is read only for the by-name exemption here; the caller applies this + // filter through ApplyAccessFiltering, which does not translate it into a type restriction. + var accessFilter = new InternalItemsQuery(user) + { + IncludeItemTypes = _itemByNameKinds + }; + + // ConfigureUserAccess populates TopParentIds for the libraries the user may open. + libraryManager.ConfigureUserAccess(accessFilter, user); + + return accessFilter; + } +} diff --git a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs new file mode 100644 index 0000000000..fd5f292ebe --- /dev/null +++ b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs @@ -0,0 +1,724 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +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 MediaBrowser.Common.Extensions; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Querying; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Emby.Server.Implementations.Library.SimilarItems; + +/// <summary> +/// Manages similar items providers and orchestrates similar items operations. +/// </summary> +public class SimilarItemsManager : ISimilarItemsManager +{ + private readonly ILogger<SimilarItemsManager> _logger; + private readonly IServerApplicationPaths _appPaths; + private readonly ILibraryManager _libraryManager; + private readonly IFileSystem _fileSystem; + private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; + private readonly IItemQueryHelpers _queryHelpers; + private ISimilarItemsProvider[] _similarItemsProviders = []; + + /// <summary> + /// Initializes a new instance of the <see cref="SimilarItemsManager"/> class. + /// </summary> + /// <param name="logger">The logger.</param> + /// <param name="appPaths">The server application paths.</param> + /// <param name="libraryManager">The library manager.</param> + /// <param name="fileSystem">The file system.</param> + /// <param name="serverConfigurationManager">The server configuration manager.</param> + /// <param name="dbProvider">The database context factory.</param> + /// <param name="queryHelpers">The shared item query helpers.</param> + public SimilarItemsManager( + ILogger<SimilarItemsManager> logger, + IServerApplicationPaths appPaths, + ILibraryManager libraryManager, + IFileSystem fileSystem, + IServerConfigurationManager serverConfigurationManager, + IDbContextFactory<JellyfinDbContext> dbProvider, + IItemQueryHelpers queryHelpers) + { + _logger = logger; + _appPaths = appPaths; + _libraryManager = libraryManager; + _fileSystem = fileSystem; + _serverConfigurationManager = serverConfigurationManager; + _dbProvider = dbProvider; + _queryHelpers = queryHelpers; + } + + /// <inheritdoc/> + public void AddParts(IEnumerable<ISimilarItemsProvider> providers) + { + _similarItemsProviders = providers.ToArray(); + } + + /// <inheritdoc/> + public IReadOnlyList<ISimilarItemsProvider> GetSimilarItemsProviders<T>() + where T : BaseItem + { + var itemType = typeof(T); + return _similarItemsProviders + .Where(p => (p is ILocalSimilarItemsProvider local && local.Supports(itemType)) + || (p is IRemoteSimilarItemsProvider remote && remote.Supports(itemType))) + .ToList(); + } + + /// <inheritdoc/> + public async Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync( + BaseItem item, + IReadOnlyList<Guid> excludeArtistIds, + User? user, + DtoOptions dtoOptions, + int? limit, + LibraryOptions? libraryOptions, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(item); + ArgumentNullException.ThrowIfNull(excludeArtistIds); + + var itemType = item.GetType(); + var requestedLimit = limit ?? 50; + var itemKind = item.GetBaseItemKind(); + + // Ensure ProviderIds is included in DtoOptions for matching remote provider responses + if (!dtoOptions.Fields.Contains(ItemFields.ProviderIds)) + { + dtoOptions.Fields = dtoOptions.Fields.Concat([ItemFields.ProviderIds]).ToArray(); + } + + // Local providers are always enabled. Remote providers must be explicitly enabled. + var localProviders = _similarItemsProviders + .OfType<ILocalSimilarItemsProvider>() + .Where(p => p.Supports(itemType)) + .ToList(); + var remoteProviders = _similarItemsProviders + .OfType<IRemoteSimilarItemsProvider>() + .Where(p => p.Supports(itemType)); + var matchingProviders = new List<ISimilarItemsProvider>(localProviders); + + var typeOptions = libraryOptions?.GetTypeOptions(itemType.Name); + if (typeOptions?.SimilarItemProviders?.Length > 0) + { + matchingProviders.AddRange(remoteProviders + .Where(p => typeOptions.SimilarItemProviders.Contains(p.Name, StringComparer.OrdinalIgnoreCase))); + } + + var orderConfig = typeOptions?.SimilarItemProviderOrder is { Length: > 0 } order + ? order + : typeOptions?.SimilarItemProviders; + var orderedProviders = matchingProviders + .OrderBy(p => GetConfiguredSimilarProviderOrder(orderConfig, p.Name)) + .ToList(); + + var allResults = new List<(BaseItem Item, float Score)>(); + var excludeIds = new HashSet<Guid> { item.Id }; + var excludeKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { item.GetPresentationUniqueKey() }; + foreach (var (providerOrder, provider) in orderedProviders.Index()) + { + if (allResults.Count >= requestedLimit || cancellationToken.IsCancellationRequested) + { + break; + } + + try + { + if (provider is ILocalSimilarItemsProvider localProvider) + { + var query = new SimilarItemsQuery + { + User = user, + Limit = requestedLimit - allResults.Count, + DtoOptions = dtoOptions, + ExcludeItemIds = [.. excludeIds], + ExcludeArtistIds = excludeArtistIds + }; + + var items = await localProvider.GetSimilarItemsAsync(item, query, cancellationToken).ConfigureAwait(false); + + foreach (var (position, resultItem) in items.Index()) + { + var isNewId = excludeIds.Add(resultItem.Id); + var isNewKey = excludeKeys.Add(resultItem.GetPresentationUniqueKey()); + if (isNewId && isNewKey) + { + var score = CalculateScore(null, providerOrder, position); + allResults.Add((resultItem, score)); + } + } + } + else if (provider is IRemoteSimilarItemsProvider remoteProvider) + { + var cachePath = GetSimilarItemsCachePath(provider.Name, itemType.Name, item.Id); + + var cachedReferences = await TryReadSimilarItemsCacheAsync(cachePath, cancellationToken).ConfigureAwait(false); + if (cachedReferences is not null) + { + var resolvedItems = ResolveRemoteReferences(cachedReferences, providerOrder, user, dtoOptions, itemKind, excludeIds, excludeKeys); + allResults.AddRange(resolvedItems); + continue; + } + + var query = new SimilarItemsQuery + { + User = user, + Limit = requestedLimit - allResults.Count, + DtoOptions = dtoOptions, + ExcludeItemIds = [.. excludeIds], + ExcludeArtistIds = excludeArtistIds + }; + + // Collect references in batches and resolve against local library. + // Stop fetching once we have enough resolved local items. + const int BatchSize = 20; + const int MaxRemoteReferenceFetchLimit = 500; + var remaining = requestedLimit - allResults.Count; + var collectedReferences = new List<SimilarItemReference>(); + var pendingBatch = new List<SimilarItemReference>(); + + await foreach (var reference in remoteProvider.GetSimilarItemsAsync(item, query, cancellationToken).ConfigureAwait(false)) + { + collectedReferences.Add(reference); + pendingBatch.Add(reference); + + if (pendingBatch.Count >= BatchSize) + { + var resolvedItems = ResolveRemoteReferences(pendingBatch, providerOrder, user, dtoOptions, itemKind, excludeIds, excludeKeys); + allResults.AddRange(resolvedItems); + remaining -= resolvedItems.Count; + pendingBatch.Clear(); + + if (remaining <= 0 || collectedReferences.Count >= MaxRemoteReferenceFetchLimit) + { + break; + } + } + } + + // Resolve any remaining references in the last partial batch + if (pendingBatch.Count > 0) + { + var resolvedItems = ResolveRemoteReferences(pendingBatch, providerOrder, user, dtoOptions, itemKind, excludeIds, excludeKeys); + allResults.AddRange(resolvedItems); + } + + if (collectedReferences.Count > 0 && provider.CacheDuration is not null) + { + await SaveSimilarItemsCacheAsync(cachePath, collectedReferences, provider.CacheDuration.Value, cancellationToken).ConfigureAwait(false); + } + } + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Similar items provider {ProviderName} failed for item {ItemId}", provider.Name, item.Id); + } + } + + var ordered = allResults + .OrderByDescending(x => x.Score) + .Select(x => x.Item) + .Take(requestedLimit) + .ToList(); + + return await FilterByLibraryAccessAsync(ordered, user, cancellationToken).ConfigureAwait(false); + } + + private async Task<IReadOnlyList<BaseItem>> FilterByLibraryAccessAsync( + IReadOnlyList<BaseItem> candidates, + User? user, + CancellationToken cancellationToken) + { + if (candidates.Count == 0 || user is null) + { + return candidates; + } + + var accessFilter = SimilarItemsAccessFilter.Build(user, _libraryManager); + + // No accessible libraries means nothing to compare against, and an empty TopParentIds set + // would disable the filter rather than reject everything. + if (accessFilter.TopParentIds.Length == 0) + { + return candidates; + } + + Guid[] candidateIds = [.. candidates.Select(c => c.Id)]; + + var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + var baseQuery = dbContext.BaseItems + .AsNoTracking() + .WhereOneOrMany(candidateIds, e => e.Id); + + baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, accessFilter); + + var allowedCount = await baseQuery.CountAsync(cancellationToken).ConfigureAwait(false); + if (allowedCount == candidates.Count) + { + return candidates; + } + + var allowedIds = await baseQuery + .Select(e => e.Id) + .ToHashSetAsync(cancellationToken) + .ConfigureAwait(false); + + var filtered = candidates.Where(c => allowedIds.Contains(c.Id)).ToList(); + _logger.LogDebug( + "Dropped {Dropped} of {Total} similar-item candidates due to user access filtering", + candidates.Count - filtered.Count, + candidates.Count); + + return filtered; + } + } + + /// <inheritdoc/> + public async Task<IReadOnlyList<SimilarItemsRecommendation>> GetMovieRecommendationsAsync( + User? user, + Guid parentId, + int categoryLimit, + int itemLimit, + DtoOptions dtoOptions, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(dtoOptions); + + var recentlyPlayedMovies = _libraryManager.GetItemList(new InternalItemsQuery(user) + { + IncludeItemTypes = [BaseItemKind.Movie], + OrderBy = [(ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.Random, SortOrder.Descending)], + Limit = 7, + ParentId = parentId, + Recursive = true, + IsPlayed = true, + EnableGroupByMetadataKey = true, + DtoOptions = dtoOptions + }); + + var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie }; + if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) + { + itemTypes.Add(BaseItemKind.Trailer); + itemTypes.Add(BaseItemKind.LiveTvProgram); + } + + var likedMovies = _libraryManager.GetItemList(new InternalItemsQuery(user) + { + IncludeItemTypes = itemTypes.ToArray(), + IsMovie = true, + OrderBy = [(ItemSortBy.Random, SortOrder.Descending)], + Limit = 10, + IsFavoriteOrLiked = true, + ExcludeItemIds = recentlyPlayedMovies.Select(i => i.Id).ToArray(), + EnableGroupByMetadataKey = true, + ParentId = parentId, + Recursive = true, + DtoOptions = dtoOptions + }); + + var mostRecentMovies = recentlyPlayedMovies.Take(Math.Min(recentlyPlayedMovies.Count, 6)).ToList(); + var recentDirectors = GetPeopleNames(mostRecentMovies, [PersonType.Director]); + var recentActors = GetPeopleNames(mostRecentMovies, [PersonType.Actor, PersonType.GuestStar]); + + // Cap baseline items to categoryLimit - the round-robin can't use more categories than that. + var recentlyPlayedBaseline = recentlyPlayedMovies.Count > categoryLimit + ? recentlyPlayedMovies.Take(categoryLimit).ToList() + : recentlyPlayedMovies; + var likedBaseline = likedMovies.Count > categoryLimit + ? likedMovies.Take(categoryLimit).ToList() + : likedMovies; + + var batchQuery = new SimilarItemsQuery + { + User = user, + Limit = itemLimit, + DtoOptions = dtoOptions + }; + + var similarToRecentlyPlayed = await GetSimilarItemsRecommendationsAsync( + recentlyPlayedBaseline, + RecommendationType.SimilarToRecentlyPlayed, + batchQuery, + cancellationToken).ConfigureAwait(false); + + var similarToLiked = await GetSimilarItemsRecommendationsAsync( + likedBaseline, + RecommendationType.SimilarToLikedItem, + batchQuery, + cancellationToken).ConfigureAwait(false); + + var hasDirectorFromRecentlyPlayed = GetPersonRecommendations(user, recentDirectors, itemLimit, dtoOptions, RecommendationType.HasDirectorFromRecentlyPlayed, itemTypes); + var hasActorFromRecentlyPlayed = GetPersonRecommendations(user, recentActors, itemLimit, dtoOptions, RecommendationType.HasActorFromRecentlyPlayed, itemTypes); + + // Use a single enumerator per list, listed twice so MoveNext advances it + // twice per round-robin pass (giving these categories double weight). + // IMPORTANT: Declare as IEnumerator<T> to box the List<T>.Enumerator struct once; + // using var would box separately per list insertion, creating independent copies. + IEnumerator<SimilarItemsRecommendation> similarToRecentlyPlayedEnum = similarToRecentlyPlayed.GetEnumerator(); + IEnumerator<SimilarItemsRecommendation> similarToLikedEnum = similarToLiked.GetEnumerator(); + + var categoryTypes = new List<IEnumerator<SimilarItemsRecommendation>> + { + similarToRecentlyPlayedEnum, + similarToRecentlyPlayedEnum, + similarToLikedEnum, + similarToLikedEnum, + hasDirectorFromRecentlyPlayed.GetEnumerator(), + hasActorFromRecentlyPlayed.GetEnumerator() + }; + + var categories = new List<SimilarItemsRecommendation>(); + while (categories.Count < categoryLimit) + { + var allEmpty = true; + foreach (var category in categoryTypes) + { + if (category.MoveNext()) + { + categories.Add(category.Current); + allEmpty = false; + + if (categories.Count >= categoryLimit) + { + break; + } + } + } + + if (allEmpty) + { + break; + } + } + + return [.. categories.OrderBy(i => i.RecommendationType)]; + } + + private async Task<IReadOnlyList<SimilarItemsRecommendation>> GetSimilarItemsRecommendationsAsync( + IReadOnlyList<BaseItem> baselineItems, + RecommendationType recommendationType, + SimilarItemsQuery query, + CancellationToken cancellationToken) + { + var batchProvider = _similarItemsProviders + .OfType<IBatchLocalSimilarItemsProvider>() + .FirstOrDefault(); + + if (batchProvider is null || baselineItems.Count == 0) + { + return []; + } + + var batchResults = await batchProvider.GetBatchSimilarItemsAsync(baselineItems, query, cancellationToken).ConfigureAwait(false); + + // Filter once across every category rather than per baseline, so a batch provider costs one + // access query no matter how many categories it produced. + var allItems = batchResults.Values.SelectMany(items => items).DistinctBy(item => item.Id).ToList(); + var allowed = await FilterByLibraryAccessAsync(allItems, query.User, cancellationToken).ConfigureAwait(false); + + HashSet<Guid>? allowedIds = allowed.Count == allItems.Count + ? null + : [.. allowed.Select(item => item.Id)]; + + var recommendations = new List<SimilarItemsRecommendation>(baselineItems.Count); + foreach (var baseline in baselineItems) + { + if (!batchResults.TryGetValue(baseline.Id, out var similar) || similar.Count == 0) + { + continue; + } + + if (allowedIds is not null) + { + similar = similar.Where(item => allowedIds.Contains(item.Id)).ToList(); + if (similar.Count == 0) + { + continue; + } + } + + recommendations.Add(new SimilarItemsRecommendation + { + BaselineItemName = baseline.Name, + CategoryId = baseline.Id, + RecommendationType = recommendationType, + Items = similar + }); + } + + return recommendations; + } + + private IEnumerable<SimilarItemsRecommendation> GetPersonRecommendations( + User? user, + IReadOnlyList<string> names, + int itemLimit, + DtoOptions dtoOptions, + RecommendationType type, + IReadOnlyList<BaseItemKind> itemTypes) + { + var personTypes = type == RecommendationType.HasDirectorFromRecentlyPlayed + ? [PersonType.Director] + : Array.Empty<string>(); + + foreach (var name in names) + { + var items = _libraryManager.GetItemList(new InternalItemsQuery(user) + { + Person = name, + Limit = itemLimit + 2, + PersonTypes = personTypes, + IncludeItemTypes = itemTypes.ToArray(), + IsMovie = true, + IsPlayed = false, + EnableGroupByMetadataKey = true, + DtoOptions = dtoOptions + }) + .DistinctBy(i => i.GetProviderId(MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)) + .Take(itemLimit) + .ToList(); + + if (items.Count > 0) + { + yield return new SimilarItemsRecommendation + { + BaselineItemName = name, + CategoryId = name.GetMD5(), + RecommendationType = type, + Items = items + }; + } + } + } + + private IReadOnlyList<string> GetPeopleNames(IReadOnlyList<BaseItem> items, IReadOnlyList<string> personTypes) + { + var itemIds = items.Select(i => i.Id).ToArray(); + return _libraryManager.GetPeopleNamesByItems(itemIds, personTypes) + .Values + .SelectMany(names => names) + .Distinct() + .ToArray(); + } + + private List<(BaseItem Item, float Score)> ResolveRemoteReferences( + IReadOnlyList<SimilarItemReference> references, + int providerOrder, + User? user, + DtoOptions dtoOptions, + BaseItemKind itemKind, + HashSet<Guid> excludeIds, + HashSet<string> excludeKeys) + { + if (references.Count == 0) + { + return []; + } + + var resolvedByKey = new Dictionary<string, (BaseItem Item, float Score)>(StringComparer.OrdinalIgnoreCase); + var providerLookup = new Dictionary<(string ProviderName, string ProviderId), (float? Score, int Position)>(StringTupleComparer.Instance); + + foreach (var (position, match) in references.Index()) + { + var lookupKey = (match.ProviderName, match.ProviderId); + if (!providerLookup.TryGetValue(lookupKey, out var existing)) + { + providerLookup[lookupKey] = (match.Score, position); + } + else if (match.Score > existing.Score || (match.Score == existing.Score && position < existing.Position)) + { + providerLookup[lookupKey] = (match.Score, position); + } + } + + var allProviderIds = providerLookup + .GroupBy(kvp => kvp.Key.ProviderName) + .ToDictionary(g => g.Key, g => g.Select(x => x.Key.ProviderId).ToArray()); + + var query = new InternalItemsQuery(user) + { + HasAnyProviderIds = allProviderIds, + IncludeItemTypes = [itemKind], + DtoOptions = dtoOptions + }; + + var items = _libraryManager.GetItemList(query); + + foreach (var item in items) + { + if (excludeIds.Contains(item.Id)) + { + continue; + } + + var presentationKey = item.GetPresentationUniqueKey(); + if (excludeKeys.Contains(presentationKey)) + { + continue; + } + + foreach (var providerName in allProviderIds.Keys) + { + if (item.TryGetProviderId(providerName, out var itemProviderId) && providerLookup.TryGetValue((providerName, itemProviderId), out var matchInfo)) + { + var score = CalculateScore(matchInfo.Score, providerOrder, matchInfo.Position); + if (!resolvedByKey.TryGetValue(presentationKey, out var existing) || existing.Score < score) + { + resolvedByKey[presentationKey] = (item, score); + } + + break; + } + } + } + + foreach (var (key, entry) in resolvedByKey) + { + excludeIds.Add(entry.Item.Id); + excludeKeys.Add(key); + } + + return [.. resolvedByKey.Values]; + } + + private static float CalculateScore(float? matchScore, int providerOrder, int position) + { + // Use provider-supplied score if available, otherwise derive from position + var baseScore = matchScore ?? (1.0f - (position * 0.02f)); + + // Apply small boost based on provider order (higher priority providers get small bonus) + var priorityBoost = Math.Max(0, 10 - providerOrder) * 0.005f; + + return Math.Clamp(baseScore + priorityBoost, 0f, 1f); + } + + private static int GetConfiguredSimilarProviderOrder(string[]? orderConfig, string providerName) + { + if (orderConfig is null || orderConfig.Length == 0) + { + return int.MaxValue; + } + + var index = Array.FindIndex(orderConfig, name => string.Equals(name, providerName, StringComparison.OrdinalIgnoreCase)); + return index >= 0 ? index : int.MaxValue; + } + + private string GetSimilarItemsCachePath(string providerName, string baseItemType, Guid itemId) + { + var dataPath = Path.Combine( + _appPaths.CachePath, + $"{providerName.ToLowerInvariant()}-similar-{baseItemType.ToLowerInvariant()}"); + return Path.Combine(dataPath, $"{itemId.ToString("N", CultureInfo.InvariantCulture)}.json"); + } + + private async Task<List<SimilarItemReference>?> TryReadSimilarItemsCacheAsync(string cachePath, CancellationToken cancellationToken) + { + var fileInfo = _fileSystem.GetFileSystemInfo(cachePath); + if (!fileInfo.Exists || fileInfo.Length == 0) + { + return null; + } + + try + { + var stream = File.OpenRead(cachePath); + await using (stream.ConfigureAwait(false)) + { + var cache = await JsonSerializer.DeserializeAsync<SimilarItemsCache>(stream, JsonDefaults.Options, cancellationToken).ConfigureAwait(false); + if (cache?.References is not null && DateTime.UtcNow < cache.ExpiresAt) + { + return cache.References; + } + } + } + catch (IOException ex) + { + _logger.LogWarning(ex, "Failed to read similar items cache from {CachePath}", cachePath); + } + catch (JsonException ex) + { + _logger.LogWarning(ex, "Failed to parse similar items cache from {CachePath}", cachePath); + } + + return null; + } + + private async Task SaveSimilarItemsCacheAsync(string cachePath, List<SimilarItemReference> references, TimeSpan cacheDuration, CancellationToken cancellationToken) + { + try + { + var directory = Path.GetDirectoryName(cachePath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + var cache = new SimilarItemsCache + { + References = references, + ExpiresAt = DateTime.UtcNow.Add(cacheDuration) + }; + + var stream = File.Create(cachePath); + await using (stream.ConfigureAwait(false)) + { + await JsonSerializer.SerializeAsync(stream, cache, JsonDefaults.Options, cancellationToken).ConfigureAwait(false); + } + } + catch (IOException ex) + { + _logger.LogWarning(ex, "Failed to save similar items cache to {CachePath}", cachePath); + } + } + + private sealed class SimilarItemsCache + { + public List<SimilarItemReference>? References { get; set; } + + public DateTime ExpiresAt { get; set; } + } + + private sealed class StringTupleComparer : IEqualityComparer<(string Key, string Value)> + { + public static readonly StringTupleComparer Instance = new(); + + public bool Equals((string Key, string Value) x, (string Key, string Value) y) + => string.Equals(x.Key, y.Key, StringComparison.OrdinalIgnoreCase) && + string.Equals(x.Value, y.Value, StringComparison.OrdinalIgnoreCase); + + public int GetHashCode((string Key, string Value) obj) + => HashCode.Combine( + StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Key), + StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Value)); + } +} diff --git a/Emby.Server.Implementations/Library/SplashscreenPostScanTask.cs b/Emby.Server.Implementations/Library/SplashscreenPostScanTask.cs index 71ce3b6012..7c605036cf 100644 --- a/Emby.Server.Implementations/Library/SplashscreenPostScanTask.cs +++ b/Emby.Server.Implementations/Library/SplashscreenPostScanTask.cs @@ -80,7 +80,7 @@ public class SplashscreenPostScanTask : ILibraryPostScanTask ImageTypes = [imageType], Limit = 30, // TODO max parental rating configurable - MaxParentalRating = new(10, null), + MaxParentalRating = new(13, null), OrderBy = [ (ItemSortBy.Random, SortOrder.Ascending) diff --git a/Emby.Server.Implementations/Library/UserDataManager.cs b/Emby.Server.Implementations/Library/UserDataManager.cs index 72c8d7a9d2..0680046c11 100644 --- a/Emby.Server.Implementations/Library/UserDataManager.cs +++ b/Emby.Server.Implementations/Library/UserDataManager.cs @@ -177,53 +177,167 @@ namespace Emby.Server.Implementations.Library }; } - private UserItemData? GetUserData(User user, Guid itemId, List<string> keys) + /// <inheritdoc /> + public Dictionary<Guid, UserItemData> GetUserDataBatch(IReadOnlyList<BaseItem> items, User user) { - var cacheKey = GetCacheKey(user.InternalId, itemId); + var result = new Dictionary<Guid, UserItemData>(items.Count); + var itemsNeedingQuery = new List<(BaseItem Item, List<string> Keys)>(); - if (_cache.TryGet(cacheKey, out var data)) + foreach (var item in items) { - return data; - } - - data = GetUserDataInternal(user.Id, itemId, keys); - - if (data is null) - { - return new UserItemData() + var cacheKey = GetCacheKey(user.InternalId, item.Id); + if (_cache.TryGet(cacheKey, out var cachedData)) { - Key = keys[0], - }; + result[item.Id] = cachedData; + } + else + { + var userDataRow = ResolveUserDataRow(item, item.UserData?.Where(e => e.UserId.Equals(user.Id))); + var userData = userDataRow is not null ? Map(userDataRow) : null; + if (userData is not null) + { + result[item.Id] = userData; + _cache.AddOrUpdate(cacheKey, userData); + } + else + { + var keys = item.GetUserDataKeys(); + itemsNeedingQuery.Add((item, keys)); + } + } } - return _cache.GetOrAdd(cacheKey, _ => data); - } - - private UserItemData? GetUserDataInternal(Guid userId, Guid itemId, List<string> keys) - { - if (keys.Count == 0) + if (itemsNeedingQuery.Count == 0) { - return null; + return result; } + // Build a single query for all missing items. Fetch rows by item alone so rows kept + // under keys from older metadata resolve the same way as the in-memory path. + var allItemIds = itemsNeedingQuery.Select(x => x.Item.Id).ToList(); using var context = _repository.CreateDbContext(); - var userData = context.UserData.AsNoTracking().Where(e => e.ItemId == itemId && keys.Contains(e.CustomDataKey) && e.UserId.Equals(userId)).ToArray(); + var userDataArray = context.UserData + .AsNoTracking() + .Where(e => e.UserId.Equals(user.Id)) + .WhereOneOrMany(allItemIds, e => e.ItemId) + .ToArray(); - if (userData.Length > 0) + var userDataByItem = userDataArray.GroupBy(e => e.ItemId).ToDictionary(g => g.Key, g => g.ToArray()); + foreach (var (item, keys) in itemsNeedingQuery) { - var directDataReference = userData.FirstOrDefault(e => e.CustomDataKey == itemId.ToString("N")); - if (directDataReference is not null) + UserItemData userData; + if (userDataByItem.TryGetValue(item.Id, out var itemUserData) && itemUserData.Length > 0) { - return Map(directDataReference); + userData = Map(ResolveUserDataRow(item, itemUserData)!); + } + else + { + userData = new UserItemData { Key = keys.Count > 0 ? keys[0] : string.Empty }; } - return Map(userData.First()); + result[item.Id] = userData; + var cacheKey = GetCacheKey(user.InternalId, item.Id); + _cache.AddOrUpdate(cacheKey, userData); } - return new UserItemData + return result; + } + + /// <inheritdoc /> + public VersionResumeData? GetResumeUserData(User user, BaseItem item) + { + return GetResumeUserDataBatch([item], user).GetValueOrDefault(item.Id); + } + + /// <inheritdoc /> + public IReadOnlyDictionary<Guid, VersionResumeData> GetResumeUserDataBatch(IReadOnlyList<BaseItem> items, User user) + { + ArgumentNullException.ThrowIfNull(user); + + var result = new Dictionary<Guid, VersionResumeData>(); + + // Candidate primaries: a directly queried version (PrimaryVersionId set) keeps its own data. + // Linked alternates are already known in memory; only the local-alternate existence check + // would otherwise hit the database (one query per item via Video.HasLocalAlternateVersions), + // so collect those ids and resolve them all in a single query below. + List<Video>? candidates = null; + List<Guid>? localProbeIds = null; + foreach (var item in items) { - Key = keys.Last()! - }; + if (item is not Video video || video.PrimaryVersionId.HasValue) + { + continue; + } + + (candidates ??= []).Add(video); + + if (video.LinkedAlternateVersions.Length == 0) + { + (localProbeIds ??= []).Add(video.Id); + } + } + + if (candidates is null) + { + return result; + } + + HashSet<Guid>? withLocalAlternates = null; + if (localProbeIds is not null) + { + using var dbContext = _repository.CreateDbContext(); + withLocalAlternates = dbContext.LinkedChildren + .Where(lc => lc.ChildType == Jellyfin.Database.Implementations.Entities.LinkedChildType.LocalAlternateVersion) + .WhereOneOrMany(localProbeIds, lc => lc.ParentId) + .Select(lc => lc.ParentId) + .Distinct() + .ToHashSet(); + } + + List<(Guid PrimaryId, IReadOnlyList<Video> Versions)>? versionGroups = null; + List<BaseItem>? allVersions = null; + + foreach (var video in candidates) + { + // Only items that actually have alternate versions aggregate over them. + if (video.LinkedAlternateVersions.Length == 0 + && (withLocalAlternates is null || !withLocalAlternates.Contains(video.Id))) + { + continue; + } + + var versions = video.GetAllVersions(); + if (versions.Count < 2) + { + continue; + } + + (versionGroups ??= []).Add((video.Id, versions)); + (allVersions ??= []).AddRange(versions); + } + + if (versionGroups is null) + { + return result; + } + + var userDataByVersion = GetUserDataBatch(allVersions!.DistinctBy(i => i.Id).ToList(), user); + + foreach (var (primaryId, versions) in versionGroups) + { + // Consider both in-progress and completed versions so a finished alternate still marks the primary as played. + var resumeVersion = VersionPlaybackSelector.SelectMostRecentlyPlayed( + versions, + version => userDataByVersion.GetValueOrDefault(version.Id), + data => data.PlaybackPositionTicks > 0 || data.Played); + + if (resumeVersion is not null) + { + result[primaryId] = new VersionResumeData(resumeVersion.Id, userDataByVersion[resumeVersion.Id]); + } + } + + return result; } /// <summary> @@ -238,12 +352,41 @@ namespace Emby.Server.Implementations.Library /// <inheritdoc /> public UserItemData? GetUserData(User user, BaseItem item) { - return item.UserData?.Where(e => e.UserId.Equals(user.Id)).Select(Map).FirstOrDefault() ?? new UserItemData() + ArgumentNullException.ThrowIfNull(user); + var row = ResolveUserDataRow(item, item.UserData?.Where(e => e.UserId.Equals(user.Id))); + return row is not null ? Map(row) : new UserItemData() { Key = item.GetUserDataKeys()[0], }; } + /// <summary> + /// Picks the row matching the item's current user data keys, in key order, so rows left behind + /// under keys from older metadata don't take priority over the rows the write path updates. + /// </summary> + /// <param name="item">The item whose keys to match.</param> + /// <param name="rows">The candidate user data rows for a single user.</param> + /// <returns>The best matching row, or <c>null</c> when there are none.</returns> + private static UserData? ResolveUserDataRow(BaseItem item, IEnumerable<UserData>? rows) + { + var candidates = rows?.ToList(); + if (candidates is null || candidates.Count == 0) + { + return null; + } + + foreach (var key in item.GetUserDataKeys()) + { + var match = candidates.Find(e => string.Equals(e.CustomDataKey, key, StringComparison.Ordinal)); + if (match is not null) + { + return match; + } + } + + return candidates[0]; + } + /// <inheritdoc /> public UserItemDataDto? GetUserDataDto(BaseItem item, User user) => GetUserDataDto(item, null, user, new DtoOptions()); @@ -260,6 +403,10 @@ namespace Emby.Server.Implementations.Library var dto = GetUserItemDataDto(userData, item.Id); item.FillUserDataDtoValues(dto, userData, itemDto, user, options); + + // For an item with alternate versions, surface the most recently played version's resume point. + GetResumeUserData(user, item)?.ApplyTo(dto); + return dto; } @@ -364,5 +511,41 @@ namespace Emby.Server.Implementations.Library return playedToCompletion; } + + /// <inheritdoc /> + public void ResetPlaybackStreamSelections(User user, BaseItem item) + { + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(item); + + using var dbContext = _repository.CreateDbContext(); + var rows = dbContext.UserData + .Where(e => e.ItemId == item.Id && e.UserId == user.Id + && (e.AudioStreamIndex != null || e.SubtitleStreamIndex != null)) + .ToList(); + + if (rows.Count == 0) + { + return; + } + + foreach (var row in rows) + { + row.AudioStreamIndex = null; + row.SubtitleStreamIndex = null; + } + + dbContext.SaveChanges(); + + var cacheKey = GetCacheKey(user.InternalId, item.Id); + if (_cache.TryGet(cacheKey, out var cached)) + { + cached.AudioStreamIndex = null; + cached.SubtitleStreamIndex = null; + _cache.AddOrUpdate(cacheKey, cached); + } + + item.UserData = dbContext.UserData.Where(e => e.ItemId == item.Id).AsNoTracking().ToArray(); + } } } diff --git a/Emby.Server.Implementations/Library/UserViewManager.cs b/Emby.Server.Implementations/Library/UserViewManager.cs index 6fb53ff15d..47b3891901 100644 --- a/Emby.Server.Implementations/Library/UserViewManager.cs +++ b/Emby.Server.Implementations/Library/UserViewManager.cs @@ -59,8 +59,8 @@ namespace Emby.Server.Implementations.Library var collectionFolder = folder as ICollectionFolder; var folderViewType = collectionFolder?.CollectionType; - // Playlist library requires special handling because the folder only references user playlists - if (folderViewType == CollectionType.playlists) + // Playlist and BoxSet libraries require special handling because the folder only references linked items + if (folderViewType == CollectionType.playlists || folderViewType == CollectionType.boxsets) { var items = folder.GetItemList(new InternalItemsQuery(user) { @@ -112,7 +112,7 @@ namespace Emby.Server.Implementations.Library if (_config.Configuration.EnableFolderView) { - var name = _localizationManager.GetLocalizedString("Folders"); + var name = _localizationManager.GetServerLocalizedString("Folders"); list.Add(_libraryManager.GetNamedView(name, CollectionType.folders, string.Empty)); } @@ -138,7 +138,7 @@ namespace Emby.Server.Implementations.Library list = list.Where(i => !user.GetPreferenceValues<Guid>(PreferenceKind.MyMediaExcludes).Contains(i.Id)).ToList(); } - var sorted = _libraryManager.Sort(list, user, new[] { ItemSortBy.SortName }, SortOrder.Ascending).ToList(); + var sorted = _libraryManager.Sort(list, user, [ItemSortBy.SortName], SortOrder.Ascending).ToList(); var orders = user.GetPreferenceValues<Guid>(PreferenceKind.OrderedViews); return list @@ -168,7 +168,7 @@ namespace Emby.Server.Implementations.Library public UserView GetUserSubView(Guid parentId, CollectionType? type, string localizationKey, string sortName) { - var name = _localizationManager.GetLocalizedString(localizationKey); + var name = _localizationManager.GetServerLocalizedString(localizationKey); return GetUserSubViewWithName(name, parentId, type, sortName); } @@ -191,7 +191,7 @@ namespace Emby.Server.Implementations.Library return GetUserView((Folder)parents[0], viewType, string.Empty); } - var name = _localizationManager.GetLocalizedString(localizationKey); + var name = _localizationManager.GetServerLocalizedString(localizationKey); return _libraryManager.GetNamedView(user, name, viewType, sortName); } @@ -205,7 +205,7 @@ namespace Emby.Server.Implementations.Library var libraryItems = GetItemsForLatestItems(request.User, request, options); var list = new List<Tuple<BaseItem, List<BaseItem>>>(); - + var containerIndexMap = new Dictionary<Guid, int>(); foreach (var item in libraryItems) { // Only grab the index container for media @@ -213,20 +213,16 @@ namespace Emby.Server.Implementations.Library if (container is null) { - list.Add(new Tuple<BaseItem, List<BaseItem>>(null, new List<BaseItem> { item })); + list.Add(new Tuple<BaseItem, List<BaseItem>>(null!, new List<BaseItem> { item })); + } + else if (containerIndexMap.TryGetValue(container.Id, out var existingIndex)) + { + list[existingIndex].Item2.Add(item); } else { - var current = list.FirstOrDefault(i => i.Item1 is not null && i.Item1.Id.Equals(container.Id)); - - if (current is not null) - { - current.Item2.Add(item); - } - else - { - list.Add(new Tuple<BaseItem, List<BaseItem>>(container, new List<BaseItem> { item })); - } + containerIndexMap[container.Id] = list.Count; + list.Add(new Tuple<BaseItem, List<BaseItem>>(container, new List<BaseItem> { item })); } if (list.Count >= request.Limit) @@ -255,7 +251,7 @@ namespace Emby.Server.Implementations.Library return _channelManager.GetLatestChannelItemsInternal( new InternalItemsQuery(user) { - ChannelIds = new[] { parentId }, + ChannelIds = [parentId], IsPlayed = request.IsPlayed, StartIndex = request.StartIndex, Limit = request.Limit, @@ -301,11 +297,11 @@ namespace Emby.Server.Implementations.Library { if (hasCollectionType.All(i => i.CollectionType == CollectionType.movies)) { - includeItemTypes = new[] { BaseItemKind.Movie }; + includeItemTypes = [BaseItemKind.Movie]; } else if (hasCollectionType.All(i => i.CollectionType == CollectionType.tvshows)) { - includeItemTypes = new[] { BaseItemKind.Episode }; + includeItemTypes = [BaseItemKind.Episode]; } } } @@ -344,29 +340,29 @@ namespace Emby.Server.Implementations.Library } var excludeItemTypes = includeItemTypes.Length == 0 && mediaTypes.Length == 0 - ? new[] - { + ? + [ BaseItemKind.Person, BaseItemKind.Studio, BaseItemKind.Year, BaseItemKind.MusicGenre, BaseItemKind.Genre - } + ] : Array.Empty<BaseItemKind>(); var query = new InternalItemsQuery(user) { IncludeItemTypes = includeItemTypes, - OrderBy = new[] - { + OrderBy = + [ (ItemSortBy.DateCreated, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Descending), (ItemSortBy.ProductionYear, SortOrder.Descending) - }, + ], IsFolder = includeItemTypes.Length == 0 ? false : null, ExcludeItemTypes = excludeItemTypes, IsVirtualItem = false, - Limit = limit * 5, + Limit = limit * 2, IsPlayed = isPlayed, DtoOptions = options, MediaTypes = mediaTypes @@ -394,6 +390,18 @@ namespace Emby.Server.Implementations.Library query.Limit = limit; return _libraryManager.GetLatestItemList(query, parents, CollectionType.music); } + + if (collectionType == CollectionType.movies) + { + query.Limit = limit; + return _libraryManager.GetLatestItemList(query, parents, CollectionType.movies); + } + + if (collectionType is null) + { + query.Limit = limit; + return _libraryManager.GetLatestItemList(query, parents, CollectionType.unknown); + } } return _libraryManager.GetItemList(query, parents); diff --git a/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs b/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs index 7cc851b73b..690466be70 100644 --- a/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs +++ b/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Threading; @@ -50,25 +51,55 @@ public class ArtistsValidator public async Task Run(IProgress<double> progress, CancellationToken cancellationToken) { var names = _itemRepo.GetAllArtistNames(); + var existingArtistIds = _libraryManager.GetItemIds(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.MusicArtist] + }).ToHashSet(); + + var existingArtists = _libraryManager.GetArtists(names); var numComplete = 0; var count = names.Count; + var refreshed = 0; + + var liveIds = new HashSet<Guid>(); + var unresolved = 0; foreach (var name in names) { try { - var item = _libraryManager.GetArtist(name); + MusicArtist? item = null; + if (existingArtists.TryGetValue(name, out var artists) && artists.Length > 0) + { + item = artists.OrderBy(i => i.IsAccessedByName ? 1 : 0).First(); + } - await item.RefreshMetadata(cancellationToken).ConfigureAwait(false); + // Fall back to GetArtist if not found (creates new item if needed) + item ??= _libraryManager.GetArtist(name); + + // A name with no item is nothing to refresh, and nothing to keep alive either. + if (item is not null) + { + liveIds.Add(item.Id); + + var isNew = !existingArtistIds.Contains(item.Id); + var neverRefreshed = item.DateLastRefreshed == default; + + if (isNew || neverRefreshed) + { + await item.RefreshMetadata(cancellationToken).ConfigureAwait(false); + refreshed++; + } + } } catch (OperationCanceledException) { - // Don't clutter the log throw; } catch (Exception ex) { + unresolved++; _logger.LogError(ex, "Error refreshing {ArtistName}", name); } @@ -80,31 +111,37 @@ public class ArtistsValidator progress.Report(percent); } + _logger.LogInformation("Refreshed metadata for {RefreshedCount} new artists out of {TotalCount} total", refreshed, count); + + // Every name that threw is a name whose artist is missing from the live set, and deleting against + // a live set with holes in it deletes artists the library still refers to. Leave the sweep to a + // run that got a clean read of them. + if (unresolved > 0) + { + _logger.LogWarning( + "Not removing dead artists: {Count} of {TotalCount} names could not be resolved this run", + unresolved, + count); + + progress.Report(100); + return; + } + var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery { IncludeItemTypes = [BaseItemKind.MusicArtist], - IsDeadArtist = true, IsLocked = false - }).Cast<MusicArtist>().ToList(); + }).OfType<MusicArtist>() + .Where(item => item.IsAccessedByName && !liveIds.Contains(item.Id)) + .ToList(); foreach (var item in deadEntities) { - if (!item.IsAccessedByName) - { - continue; - } - _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); } + _libraryManager.DeleteItemsUnsafeFast(deadEntities, deleteSourceFiles: true); + progress.Report(100); } } diff --git a/Emby.Server.Implementations/Library/Validators/CollectionPostScanTask.cs b/Emby.Server.Implementations/Library/Validators/CollectionPostScanTask.cs index e62c638ed6..e3ef75b9ee 100644 --- a/Emby.Server.Implementations/Library/Validators/CollectionPostScanTask.cs +++ b/Emby.Server.Implementations/Library/Validators/CollectionPostScanTask.cs @@ -74,7 +74,7 @@ public class CollectionPostScanTask : ILibraryPostScanTask foreach (var m in movies) { - if (m is Movie movie && !string.IsNullOrEmpty(movie.CollectionName)) + if (m is Movie movie && !string.IsNullOrEmpty(movie.CollectionName) && !movie.PrimaryVersionId.HasValue) { if (collectionNameMoviesMap.TryGetValue(movie.CollectionName, out var movieList)) { diff --git a/Emby.Server.Implementations/Library/Validators/CollectionPosterVerifyPostScanTask.cs b/Emby.Server.Implementations/Library/Validators/CollectionPosterVerifyPostScanTask.cs new file mode 100644 index 0000000000..2cfa446862 --- /dev/null +++ b/Emby.Server.Implementations/Library/Validators/CollectionPosterVerifyPostScanTask.cs @@ -0,0 +1,64 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Entities; +using Microsoft.Extensions.Logging; + +namespace Emby.Server.Implementations.Library.Validators; + +/// <summary> +/// Ensures top-level library folders have a primary poster after scans. +/// Poster extraction is attempted before library scanning. When a library is +/// empty at that point, no poster can be extracted. This post-scan task reruns +/// metadata extraction for top-level folders that are still missing images. +/// </summary> +public class CollectionPosterVerifyPostScanTask : ILibraryPostScanTask +{ + private readonly ILibraryManager _libraryManager; + private readonly ILogger<CollectionPosterVerifyPostScanTask> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="CollectionPosterVerifyPostScanTask" /> class. + /// </summary> + /// <param name="libraryManager">The library manager.</param> + /// <param name="logger">The logger.</param> + public CollectionPosterVerifyPostScanTask( + ILibraryManager libraryManager, + ILogger<CollectionPosterVerifyPostScanTask> logger) + { + _libraryManager = libraryManager; + _logger = logger; + } + + /// <summary> + /// Runs the specified progress. + /// </summary> + /// <param name="progress">The progress.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + public async Task Run(IProgress<double> progress, CancellationToken cancellationToken) + { + var libraries = _libraryManager.GetUserRootFolder().Children.OfType<CollectionFolder>().ToList(); + var totalLibraries = libraries.Count; + var processedLibraries = 0; + + foreach (var library in libraries) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!library.HasImage(ImageType.Primary)) + { + _logger.LogDebug("Library {LibraryName} is missing a primary image. Refreshing metadata.", library.Name); + await library.RefreshMetadata(cancellationToken).ConfigureAwait(false); + } + + processedLibraries++; + progress.Report((double)processedLibraries / totalLibraries * 100); + } + + progress.Report(100); + } +} diff --git a/Emby.Server.Implementations/Library/Validators/GenresValidator.cs b/Emby.Server.Implementations/Library/Validators/GenresValidator.cs index fbfc9f7d54..fc5a2fa0c5 100644 --- a/Emby.Server.Implementations/Library/Validators/GenresValidator.cs +++ b/Emby.Server.Implementations/Library/Validators/GenresValidator.cs @@ -1,5 +1,6 @@ using System; using System.Globalization; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; @@ -48,17 +49,40 @@ public class GenresValidator public async Task Run(IProgress<double> progress, CancellationToken cancellationToken) { var names = _itemRepo.GetGenreNames(); + var existingGenreIds = _libraryManager.GetItemIds(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.Genre] + }).ToHashSet(); + + var existingGenres = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.Genre] + }).Cast<Genre>() + .GroupBy(g => g.Name, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); var numComplete = 0; var count = names.Count; + var refreshed = 0; foreach (var name in names) { try { - var item = _libraryManager.GetGenre(name); + Genre? item = null; + if (existingGenres.TryGetValue(name, out var existingGenre)) + { + item = existingGenre; + } - await item.RefreshMetadata(cancellationToken).ConfigureAwait(false); + // Fall back to GetGenre if not found (creates new item if needed) + item ??= _libraryManager.GetGenre(name); + + if (!existingGenreIds.Contains(item.Id)) + { + await item.RefreshMetadata(cancellationToken).ConfigureAwait(false); + refreshed++; + } } catch (OperationCanceledException) { @@ -78,6 +102,8 @@ public class GenresValidator progress.Report(percent); } + _logger.LogInformation("Refreshed metadata for {RefreshedCount} new genres out of {TotalCount} total", refreshed, count); + var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery { IncludeItemTypes = [BaseItemKind.Genre, BaseItemKind.MusicGenre], @@ -88,16 +114,10 @@ public class GenresValidator 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); } + _libraryManager.DeleteItemsUnsafeFast(deadEntities, deleteSourceFiles: true); + progress.Report(100); } } diff --git a/Emby.Server.Implementations/Library/Validators/MusicGenresValidator.cs b/Emby.Server.Implementations/Library/Validators/MusicGenresValidator.cs index 6203bce2bc..4365707529 100644 --- a/Emby.Server.Implementations/Library/Validators/MusicGenresValidator.cs +++ b/Emby.Server.Implementations/Library/Validators/MusicGenresValidator.cs @@ -1,6 +1,9 @@ using System; +using System.Linq; 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; @@ -45,17 +48,25 @@ public class MusicGenresValidator public async Task Run(IProgress<double> progress, CancellationToken cancellationToken) { var names = _itemRepo.GetMusicGenreNames(); + var existingMusicGenreIds = _libraryManager.GetItemIds(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.MusicGenre] + }).ToHashSet(); var numComplete = 0; var count = names.Count; + var refreshed = 0; foreach (var name in names) { try { var item = _libraryManager.GetMusicGenre(name); - - await item.RefreshMetadata(cancellationToken).ConfigureAwait(false); + if (!existingMusicGenreIds.Contains(item.Id)) + { + await item.RefreshMetadata(cancellationToken).ConfigureAwait(false); + refreshed++; + } } catch (OperationCanceledException) { @@ -75,6 +86,8 @@ public class MusicGenresValidator progress.Report(percent); } + _logger.LogInformation("Refreshed metadata for {RefreshedCount} new music genres out of {TotalCount} total", refreshed, count); + progress.Report(100); } } diff --git a/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs b/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs index f9a6f0d19e..7d53f40ce7 100644 --- a/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs +++ b/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs @@ -1,12 +1,12 @@ using System; +using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.IO; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.Library.Validators; @@ -17,104 +17,143 @@ namespace Emby.Server.Implementations.Library.Validators; public class PeopleValidator { /// <summary> - /// The _library manager. + /// The library manager. /// </summary> private readonly ILibraryManager _libraryManager; /// <summary> - /// The _logger. + /// The logger. /// </summary> - private readonly ILogger _logger; - - private readonly IFileSystem _fileSystem; + private readonly ILogger<PeopleValidator> _logger; /// <summary> /// Initializes a new instance of the <see cref="PeopleValidator" /> class. /// </summary> /// <param name="libraryManager">The library manager.</param> /// <param name="logger">The logger.</param> - /// <param name="fileSystem">The file system.</param> - public PeopleValidator(ILibraryManager libraryManager, ILogger logger, IFileSystem fileSystem) + public PeopleValidator(ILibraryManager libraryManager, ILogger<PeopleValidator> logger) { _libraryManager = libraryManager; _logger = logger; - _fileSystem = fileSystem; } /// <summary> /// Validates the people. /// </summary> - /// <param name="cancellationToken">The cancellation token.</param> /// <param name="progress">The progress.</param> + /// <param name="cancellationToken">The cancellation token.</param> /// <returns>Task.</returns> - public async Task ValidatePeople(CancellationToken cancellationToken, IProgress<double> progress) + public async Task Run(IProgress<double> progress, CancellationToken cancellationToken) { - var people = _libraryManager.GetPeopleNames(new InternalPeopleQuery()); + // Before the refresh below walks them: a credit no item maps to any more stands for nothing, + // and while it is there the person it names cannot reach the dead-person sweep either. + var numOrphaned = _libraryManager.DeleteOrphanedCredits(); + if (numOrphaned > 0) + { + _logger.LogInformation("Deleted {Amount} credits no item maps to", numOrphaned); + } + + var names = _libraryManager.GetPeopleNames(new InternalPeopleQuery()); + var existingPersonIds = _libraryManager.GetItemIds(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.Person] + }).ToHashSet(); + + var (newNames, deadIds) = PartitionCreditsByPersonId(names, _libraryManager.GetPersonId, existingPersonIds); var numComplete = 0; + var count = names.Count; + var refreshed = 0; - var numPeople = people.Count; - - IProgress<double> subProgress = new Progress<double>((val) => progress.Report(val / 2)); - - _logger.LogDebug("Will refresh {Amount} people", numPeople); - - foreach (var person in people) + foreach (var name in names) { cancellationToken.ThrowIfCancellationRequested(); try { - var item = _libraryManager.GetPerson(person); - if (item is null) + var item = _libraryManager.GetOrCreatePerson(name); + var isNew = !existingPersonIds.Contains(item.Id); + var neverRefreshed = item.DateLastRefreshed == default; + + if (isNew || neverRefreshed) { - _logger.LogWarning("Failed to get person: {Name}", person); - continue; + await item.RefreshMetadata(cancellationToken).ConfigureAwait(false); + refreshed++; } - - var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem)) - { - ImageRefreshMode = MetadataRefreshMode.ValidationOnly, - MetadataRefreshMode = MetadataRefreshMode.ValidationOnly - }; - - await item.RefreshMetadata(options, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { + // Don't clutter the log throw; } catch (Exception ex) { - _logger.LogError(ex, "Error validating IBN entry {Person}", person); + _logger.LogError(ex, "Error refreshing {PersonName}", name); } - // Update progress numComplete++; double percent = numComplete; - percent /= numPeople; + percent /= count; + percent *= 100; - subProgress.Report(100 * percent); + progress.Report(percent); } - var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery - { - IncludeItemTypes = [BaseItemKind.Person], - IsDeadPerson = true, - IsLocked = false - }); + _logger.LogInformation( + "Refreshed metadata for {RefreshedCount} people out of {TotalCount} total, {NewCount} of which had no item yet", + refreshed, + count, + newNames.Count); - subProgress = new Progress<double>((val) => progress.Report((val / 2) + 50)); + // A person somebody locked is theirs, not ours, however little the library still credits them. + var deadEntities = deadIds + .Select(_libraryManager.GetItemById) + .OfType<Person>() + .Where(item => !item.IsLocked) + .ToList(); - var i = 0; - foreach (var item in deadEntities.Chunk(500)) + foreach (var item in deadEntities) { - _libraryManager.DeleteItemsUnsafeFast(item); - subProgress.Report(100f / deadEntities.Count * (i++ * 100)); + _logger.LogInformation("Deleting dead {ItemType} {ItemId} {ItemName}", item.GetType().Name, item.Id.ToString("N", CultureInfo.InvariantCulture), item.Name); } + _libraryManager.DeleteItemsUnsafeFast(deadEntities, deleteSourceFiles: true); + progress.Report(100); + } - _logger.LogInformation("People validation complete"); + /// <summary> + /// Splits the person items into the ones a credit still calls for and the ones nothing does. + /// </summary> + /// <param name="creditNames">Every name credited on an item, from the people table.</param> + /// <param name="getPersonId">Maps a credit name to the id its person item has.</param> + /// <param name="existingPersonIds">The ids of the person items that exist.</param> + /// <returns>The credits needing an item, and the ids of the items nothing credits.</returns> + internal static (List<string> NewNames, List<Guid> DeadIds) PartitionCreditsByPersonId( + IReadOnlyList<string> creditNames, + Func<string, Guid> getPersonId, + IReadOnlySet<Guid> existingPersonIds) + { + ArgumentNullException.ThrowIfNull(creditNames); + ArgumentNullException.ThrowIfNull(getPersonId); + ArgumentNullException.ThrowIfNull(existingPersonIds); + + var newNames = new List<string>(); + var liveIds = new HashSet<Guid>(); + + foreach (var name in creditNames) + { + var personId = getPersonId(name); + + // Distinct credit names can normalize onto one id; only the first of them needs an item. + if (liveIds.Add(personId) && !existingPersonIds.Contains(personId)) + { + newNames.Add(name); + } + } + + var deadIds = existingPersonIds.Where(id => !liveIds.Contains(id)).ToList(); + + return (newNames, deadIds); } } diff --git a/Emby.Server.Implementations/Library/Validators/StudiosValidator.cs b/Emby.Server.Implementations/Library/Validators/StudiosValidator.cs index 5b87e4d9d0..88f86ae6ca 100644 --- a/Emby.Server.Implementations/Library/Validators/StudiosValidator.cs +++ b/Emby.Server.Implementations/Library/Validators/StudiosValidator.cs @@ -1,5 +1,6 @@ using System; using System.Globalization; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; @@ -49,17 +50,40 @@ public class StudiosValidator public async Task Run(IProgress<double> progress, CancellationToken cancellationToken) { var names = _itemRepo.GetStudioNames(); + var existingStudioIds = _libraryManager.GetItemIds(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.Studio] + }).ToHashSet(); + + var existingStudios = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.Studio] + }).Cast<Studio>() + .GroupBy(s => s.Name, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); var numComplete = 0; var count = names.Count; + var refreshed = 0; foreach (var name in names) { try { - var item = _libraryManager.GetStudio(name); + Studio? item = null; + if (existingStudios.TryGetValue(name, out var existingStudio)) + { + item = existingStudio; + } - await item.RefreshMetadata(cancellationToken).ConfigureAwait(false); + // Fall back to GetStudio if not found (creates new item if needed) + item ??= _libraryManager.GetStudio(name); + + if (!existingStudioIds.Contains(item.Id)) + { + await item.RefreshMetadata(cancellationToken).ConfigureAwait(false); + refreshed++; + } } catch (OperationCanceledException) { @@ -79,6 +103,8 @@ public class StudiosValidator progress.Report(percent); } + _logger.LogInformation("Refreshed metadata for {RefreshedCount} new studios out of {TotalCount} total", refreshed, count); + var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery { IncludeItemTypes = [BaseItemKind.Studio], @@ -89,16 +115,10 @@ public class StudiosValidator 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); } + _libraryManager.DeleteItemsUnsafeFast(deadEntities, deleteSourceFiles: true); + progress.Report(100); } } diff --git a/Emby.Server.Implementations/Localization/Core/ab.json b/Emby.Server.Implementations/Localization/Core/ab.json index bc6062f429..d67f2d67e9 100644 --- a/Emby.Server.Implementations/Localization/Core/ab.json +++ b/Emby.Server.Implementations/Localization/Core/ab.json @@ -1,3 +1,3 @@ { - "Albums": "аальбомқәа" + "AppDeviceValues": "Апп: {0}, Априбор: {1}" } diff --git a/Emby.Server.Implementations/Localization/Core/af.json b/Emby.Server.Implementations/Localization/Core/af.json index 1dce589234..308341ad49 100644 --- a/Emby.Server.Implementations/Localization/Core/af.json +++ b/Emby.Server.Implementations/Localization/Core/af.json @@ -1,11 +1,8 @@ { "Artists": "Kunstenare", - "Channels": "Kanale", "Folders": "Lêergidse", "Favorites": "Gunstelinge", "HeaderFavoriteShows": "Gunsteling Vertonings", - "ValueSpecialEpisodeName": "Spesiale - {0}", - "HeaderAlbumArtists": "Album kunstenaars", "Books": "Boeke", "HeaderNextUp": "Volgende", "Movies": "Flieks", @@ -13,24 +10,13 @@ "HeaderContinueWatching": "Hou aan kyk", "HeaderFavoriteEpisodes": "Gunsteling Episodes", "Photos": "Foto's", - "Playlists": "Snitlyste", - "HeaderFavoriteArtists": "Gunsteling Kunstenaars", - "HeaderFavoriteAlbums": "Gunsteling Albums", - "Sync": "Sinkroniseer", - "HeaderFavoriteSongs": "Gunsteling Liedjies", - "Songs": "Liedjies", - "DeviceOnlineWithName": "{0} is aanlyn", - "DeviceOfflineWithName": "{0} is ontkoppel", "Collections": "Versamelings", "Inherit": "Ontvang", "HeaderLiveTV": "Lewendige TV", - "Application": "Program", "AppDeviceValues": "App: {0}, Toestel: {1}", "VersionNumber": "Weergawe {0}", - "ValueHasBeenAddedToLibrary": "{0} is by jou media biblioteek bygevoeg", "UserStoppedPlayingItemWithValues": "{0} het klaar {1} op {2} gespeel", "UserStartedPlayingItemWithValues": "{0} is besig om {1} op {2} te speel", - "UserPolicyUpdatedWithName": "Gebruiker beleid is verander vir {0}", "UserPasswordChangedWithName": "Gebruiker {0} se wagwoord is verander", "UserOnlineFromDevice": "{0} is aanlyn van {1}", "UserOfflineFromDevice": "{0} is ontkoppel van {1}", @@ -38,19 +24,13 @@ "UserDownloadingItemWithValues": "{0} is besig om {1} af te laai", "UserDeletedWithName": "Gebruiker {0} is verwyder", "UserCreatedWithName": "Gebruiker {0} is geskep", - "User": "Gebruiker", "TvShows": "TV Programme", - "System": "Stelsel", "SubtitleDownloadFailureFromForItem": "Ondertitels het misluk om af te laai van {0} vir {1}", "StartupEmbyServerIsLoading": "Jellyfin Bediener is besig om te laai. Probeer weer in 'n kort tyd.", - "ServerNameNeedsToBeRestarted": "{0} moet herbegin word", - "ScheduledTaskStartedWithName": "{0} het begin", "ScheduledTaskFailedWithName": "{0} het misluk", - "ProviderValue": "Voorsiener: {0}", "PluginUpdatedWithName": "{0} was opgedateer", "PluginUninstalledWithName": "{0} was verwyder", "PluginInstalledWithName": "{0} is geïnstalleer", - "Plugin": "Inprop module", "NotificationOptionVideoPlaybackStopped": "Video terugspeel het gestop", "NotificationOptionVideoPlayback": "Video terugspeel het begin", "NotificationOptionUserLockedOut": "Gebruiker uitgeslyt", @@ -74,23 +54,14 @@ "MusicVideos": "Musiek Videos", "Music": "Musiek", "MixedContent": "Gemengde inhoud", - "MessageServerConfigurationUpdated": "Bediener konfigurasie is opgedateer", - "MessageNamedServerConfigurationUpdatedWithValue": "Bediener konfigurasie seksie {0} is opgedateer", - "MessageApplicationUpdatedTo": "Jellyfin Bediener is opgedateer na {0}", - "MessageApplicationUpdated": "Jellyfin Bediener is opgedateer", "Latest": "Nuutste", "LabelRunningTimeValue": "Werktyd: {0}", "LabelIpAddressValue": "IP adres: {0}", - "ItemRemovedWithName": "{0} is uit versameling verwyder", - "ItemAddedWithName": "{0} is by die versameling gevoeg", "HomeVideos": "Tuis Videos", - "HeaderRecordingGroups": "Groep Opnames", "Genres": "Genres", "FailedLoginAttemptWithUserName": "Mislukte aanmeldpoging van {0}", "ChapterNameValue": "Hoofstuk {0}", - "CameraImageUploadedFrom": "'n Nuwe kamera foto is opgelaai vanaf {0}", "AuthenticationSucceededWithUserName": "{0} suksesvol geverifieer", - "Albums": "Albums", "TasksChannelsCategory": "Internet kanale", "TasksApplicationCategory": "aansoek", "TasksLibraryCategory": "biblioteek", @@ -128,12 +99,12 @@ "TaskRefreshTrickplayImagesDescription": "Skep fopspeel voorskou vir videos in aangeskakelde media versameling.", "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.", "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." + "TaskMoveTrickplayImagesDescription": "Skuif ontstaande trickplay lêers volgens die biblioteekinstellings.", + "CleanupUserDataTask": "Gebruikers data skoon maak taak", + "CleanupUserDataTaskDescription": "Maak alle gebruikers data (kykstatus, gunstelingstatus, ens.) skoon van media wat nie meer vir ten minste 90 dae teenwoordig is nie." } diff --git a/Emby.Server.Implementations/Localization/Core/ar.json b/Emby.Server.Implementations/Localization/Core/ar.json index a92148cafe..2dc4f5652b 100644 --- a/Emby.Server.Implementations/Localization/Core/ar.json +++ b/Emby.Server.Implementations/Localization/Core/ar.json @@ -1,142 +1,125 @@ { - "Albums": "ألبومات", - "AppDeviceValues": "تطبيق: {0}, جهاز: {1}", - "Application": "تطبيق", + "AppDeviceValues": "التطبيق: {0}، الجهاز: {1}", "Artists": "الفنانون", - "AuthenticationSucceededWithUserName": "نجحت عملية التوثيق بـ {0}", + "AuthenticationSucceededWithUserName": "تمت مصادقة {0} بنجاح", "Books": "الكتب", - "CameraImageUploadedFrom": "رُفعت صورة الكاميرا الجديدة من {0}", - "Channels": "القنوات", "ChapterNameValue": "الفصل {0}", "Collections": "المجموعات", - "DeviceOfflineWithName": "قُطِع الاتصال ب{0}", - "DeviceOnlineWithName": "{0} متصل", - "FailedLoginAttemptWithUserName": "محاولة تسجيل الدخول فاشلة من {0}", + "FailedLoginAttemptWithUserName": "محاولة تسجيل دخول فاشلة من {0}", "Favorites": "المفضلة", "Folders": "المجلدات", - "Genres": "التصنيفات", - "HeaderAlbumArtists": "فناني الألبوم", - "HeaderContinueWatching": "إستئناف المشاهدة", - "HeaderFavoriteAlbums": "الألبومات المفضلة", - "HeaderFavoriteArtists": "الفنانون المفضلون", + "Genres": "الأنواع", + "HeaderContinueWatching": "متابعة المشاهدة", "HeaderFavoriteEpisodes": "الحلقات المفضلة", "HeaderFavoriteShows": "المسلسلات المفضلة", - "HeaderFavoriteSongs": "الأغاني المفضلة", - "HeaderLiveTV": "التلفاز المباشر", + "HeaderLiveTV": "البث التلفزيوني المباشر", "HeaderNextUp": "التالي", - "HeaderRecordingGroups": "مجموعات التسجيل", - "HomeVideos": "الفيديوهات الشخصية", - "Inherit": "توريث", - "ItemAddedWithName": "أُضيف {0} للمكتبة", - "ItemRemovedWithName": "أُزيل {0} من المكتبة", - "LabelIpAddressValue": "عنوان الآي بي: {0}", + "HomeVideos": "فيديوهات منزلية", + "Inherit": "وراثة", + "LabelIpAddressValue": "عنوان IP: {0}", "LabelRunningTimeValue": "مدة التشغيل: {0}", "Latest": "الأحدث", - "MessageApplicationUpdated": "حُدث خادم Jellyfin", - "MessageApplicationUpdatedTo": "حُدث خادم Jellyfin إلى {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "حُدثت إعدادات الخادم في قسم {0}", - "MessageServerConfigurationUpdated": "حُدثت إعدادات الخادم", "MixedContent": "محتوى مختلط", "Movies": "الأفلام", "Music": "الموسيقى", "MusicVideos": "الفيديوهات الموسيقية", "NameInstallFailed": "فشل تثبيت {0}", "NameSeasonNumber": "الموسم {0}", - "NameSeasonUnknown": "الموسم غير معروف", - "NewVersionIsAvailable": "نسخة جديدة من خادم Jellyfin متوفرة للتحميل.", - "NotificationOptionApplicationUpdateAvailable": "يوجد تحديث للتطبيق", - "NotificationOptionApplicationUpdateInstalled": "نُصب تحديث التطبيق", - "NotificationOptionAudioPlayback": "بدأ تشغيل المقطع الصوتي", - "NotificationOptionAudioPlaybackStopped": "أُوقف تشغيل المقطع الصوتي", - "NotificationOptionCameraImageUploaded": "رُفعت صورة الكاميرا", - "NotificationOptionInstallationFailed": "فشل في التثبيت", - "NotificationOptionNewLibraryContent": "أُضيف محتوى جديدا", - "NotificationOptionPluginError": "فشل في الملحق", - "NotificationOptionPluginInstalled": "ثُبتت الملحق", + "NameSeasonUnknown": "موسم غير معروف", + "NewVersionIsAvailable": "يتوفر إصدار جديد من خادم Jellyfin للتنزيل.", + "NotificationOptionApplicationUpdateAvailable": "تحديث التطبيق متاح", + "NotificationOptionApplicationUpdateInstalled": "تم تثبيت تحديث التطبيق", + "NotificationOptionAudioPlayback": "بدأ تشغيل الصوت", + "NotificationOptionAudioPlaybackStopped": "توقف تشغيل الصوت", + "NotificationOptionCameraImageUploaded": "تم رفع صورة كاميرا", + "NotificationOptionInstallationFailed": "فشل التثبيت", + "NotificationOptionNewLibraryContent": "تمت إضافة محتوى جديد", + "NotificationOptionPluginError": "خطأ في الملحق", + "NotificationOptionPluginInstalled": "تم تثبيت الملحق", "NotificationOptionPluginUninstalled": "تمت إزالة الملحق", - "NotificationOptionPluginUpdateInstalled": "تم تثبيت تحديثات الملحق", - "NotificationOptionServerRestartRequired": "يجب إعادة تشغيل الخادم", - "NotificationOptionTaskFailed": "فشل في المهمة المجدولة", - "NotificationOptionUserLockedOut": "تم إقفال حساب المستخدم", + "NotificationOptionPluginUpdateInstalled": "تم تحديث الملحق", + "NotificationOptionServerRestartRequired": "مطلوب إعادة تشغيل الخادم", + "NotificationOptionTaskFailed": "فشل المهمة المجدولة", + "NotificationOptionUserLockedOut": "تم قفل حساب المستخدم", "NotificationOptionVideoPlayback": "بدأ تشغيل الفيديو", - "NotificationOptionVideoPlaybackStopped": "تم إيقاف تشغيل الفيديو", + "NotificationOptionVideoPlaybackStopped": "توقف تشغيل الفيديو", "Photos": "الصور", - "Playlists": "قوائم التشغيل", - "Plugin": "الملحق", "PluginInstalledWithName": "تم تثبيت {0}", "PluginUninstalledWithName": "تمت إزالة {0}", "PluginUpdatedWithName": "تم تحديث {0}", - "ProviderValue": "المزود: {0}", - "ScheduledTaskFailedWithName": "فشلت العملية {0}", - "ScheduledTaskStartedWithName": "تم بدء العملية {0}", - "ServerNameNeedsToBeRestarted": "يحتاج {0} لإعادة التشغيل", - "Shows": "العروض", - "Songs": "الأغاني", - "StartupEmbyServerIsLoading": "يتم تحميل خادم Jellyfin . الرجاء المحاولة بعد قليل.", - "SubtitleDownloadFailureForItem": "عملية إنزال الترجمة فشلت لـ{0}", - "SubtitleDownloadFailureFromForItem": "فشل تحميل الترجمات من {0} ل {1}", - "Sync": "مزامنة", - "System": "النظام", + "ScheduledTaskFailedWithName": "فشلت {0}", + "Shows": "المسلسلات", + "StartupEmbyServerIsLoading": "يتم الآن تحميل خادم Jellyfin. يرجى المحاولة مرة أخرى بعد قليل.", + "SubtitleDownloadFailureFromForItem": "فشل تنزيل الترجمات من {0} لـ {1}", "TvShows": "البرامج التلفزيونية", - "User": "المستخدم", "UserCreatedWithName": "تم إنشاء المستخدم {0}", "UserDeletedWithName": "تم حذف المستخدم {0}", - "UserDownloadingItemWithValues": "يقوم {0} بتنزيل {1}", - "UserLockedOutWithName": "تم منع المستخدم {0} من الدخول", - "UserOfflineFromDevice": "تم قطع اتصال {0} من {1}", - "UserOnlineFromDevice": "{0} متصل عبر {1}", - "UserPasswordChangedWithName": "تم تغيير كلمة السر للمستخدم {0}", - "UserPolicyUpdatedWithName": "تم تحديث سياسة المستخدم {0}", - "UserStartedPlayingItemWithValues": "قام {0} ببدء تشغيل {1} على {2}", - "UserStoppedPlayingItemWithValues": "قام {0} بإيقاف تشغيل {1} على {2}", - "ValueHasBeenAddedToLibrary": "تمت اضافت {0} إلى مكتبة الوسائط", - "ValueSpecialEpisodeName": "حلقة خاصه - {0}", + "UserDownloadingItemWithValues": "{0} يقوم بتنزيل {1}", + "UserLockedOutWithName": "تم قفل حساب المستخدم {0}", + "UserOfflineFromDevice": "انقطع اتصال {0} من {1}", + "UserOnlineFromDevice": "{0} متصل من {1}", + "UserPasswordChangedWithName": "تم تغيير كلمة المرور للمستخدم {0}", + "UserStartedPlayingItemWithValues": "{0} يقوم بتشغيل {1} على {2}", + "UserStoppedPlayingItemWithValues": "أنهى {0} تشغيل {1} على {2}", "VersionNumber": "الإصدار {0}", - "TaskCleanCacheDescription": "يحذف الملفات المؤقتة التي لم يعد النظام بحاجة إليها.", - "TaskCleanCache": "حذف الملفات المؤقتة", + "TaskCleanCacheDescription": "يحذف ملفات ذاكرة التخزين المؤقت التي لم يعد النظام بحاجة إليها.", + "TaskCleanCache": "تنظيف مجلد ذاكرة التخزين المؤقت", "TasksChannelsCategory": "قنوات الإنترنت", - "TasksLibraryCategory": "مكتبة", - "TasksMaintenanceCategory": "صيانة", - "TaskRefreshLibraryDescription": "يفحص مكتبة الوسائط الخاصة بك باحثا عن ملفات جديدة، ومن ثم يُحدث البيانات الوصفية.", - "TaskRefreshLibrary": "افحص مكتبة الوسائط", - "TaskRefreshChapterImagesDescription": "يُنشئ صور مصغرة لمقاطع الفيديو التي تحتوي على فصول.", - "TaskRefreshChapterImages": "استخراج صور الفصل", - "TasksApplicationCategory": "تطبيق", - "TaskDownloadMissingSubtitlesDescription": "يبحث في الإنترنت على الترجمات الناقصة استنادا على البيانات الوصفية.", - "TaskDownloadMissingSubtitles": "تحميل الترجمات الناقصة", - "TaskRefreshChannelsDescription": "يحدث معلومات قنوات الإنترنت.", - "TaskRefreshChannels": "إعادة تحديث القنوات", - "TaskCleanTranscodeDescription": "يحذف ملفات الترميز الأقدم من يوم واحد.", - "TaskCleanTranscode": "حذف ما بمجلد الترميز", - "TaskUpdatePluginsDescription": "تحميل وتثبيت الإضافات التي تم تفعيل التحديث التلقائي لها.", - "TaskUpdatePlugins": "تحديث الإضافات", - "TaskRefreshPeopleDescription": "يقوم بتحديث البيانات الوصفية للممثلين والمخرجين في مكتبة الوسائط الخاصة بك.", - "TaskRefreshPeople": "إعادة تحميل الأشخاص", - "TaskCleanLogsDescription": "يحذف السجلات الأقدم من {0} يوم.", - "TaskCleanLogs": "حذف مسار السجل", - "TaskCleanActivityLogDescription": "يحذف سجل الأنشطة الأقدم من الوقت الذي تم تحديده.", - "TaskCleanActivityLog": "حذف سجل الأنشطة", - "Default": "افتراضي", - "Undefined": "غير معرف", - "Forced": "ملحقة", - "TaskOptimizeDatabaseDescription": "يضغط قاعدة البيانات ويقتطع المساحة الحرة. تشغيل هذه المهمة بعد فحص المكتبة أو إجراء تغييرات أخرى تتضمن تعديلات في قاعدة البيانات قد تؤدي إلى تحسين الأداء.", + "TasksLibraryCategory": "المكتبة", + "TasksMaintenanceCategory": "الصيانة", + "TaskRefreshLibraryDescription": "يفحص مكتبة المحتوى الخاصة بك بحثاً عن ملفات جديدة ويحدّث البيانات الوصفية.", + "TaskRefreshLibrary": "فحص مكتبة المحتوى", + "TaskRefreshChapterImagesDescription": "ينشئ صوراً مصغرة للفيديوهات التي تحتوي على فصول.", + "TaskRefreshChapterImages": "استخراج صور الفصول", + "TasksApplicationCategory": "التطبيق", + "TaskDownloadMissingSubtitlesDescription": "يبحث في الإنترنت عن الترجمات المفقودة بناءً على إعدادات البيانات الوصفية.", + "TaskDownloadMissingSubtitles": "تنزيل الترجمات المفقودة", + "TaskRefreshChannelsDescription": "يحدّث معلومات قنوات الإنترنت.", + "TaskRefreshChannels": "تحديث القنوات", + "TaskCleanTranscodeDescription": "يحذف ملفات تحويل الترميز التي مر عليها أكثر من يوم واحد.", + "TaskCleanTranscode": "تنظيف مجلد تحويل الترميز", + "TaskUpdatePluginsDescription": "ينزّل ويثبّت التحديثات للملحقات المهيأة للتحديث التلقائي.", + "TaskUpdatePlugins": "تحديث الملحقات", + "TaskRefreshPeopleDescription": "يحدّث البيانات الوصفية للممثلين والمخرجين في مكتبة المحتوى الخاصة بك.", + "TaskRefreshPeople": "تحديث الأشخاص", + "TaskCleanLogsDescription": "يحذف ملفات السجل التي يزيد عمرها عن {0} أيام.", + "TaskCleanLogs": "تنظيف مجلد السجلات", + "TaskCleanActivityLogDescription": "يحذف إدخالات سجل النشاط الأقدم من العمر المحدد.", + "TaskCleanActivityLog": "تنظيف سجل النشاط", + "Default": "الافتراضي", + "Undefined": "غير محدد", + "Forced": "إجباري", + "TaskOptimizeDatabaseDescription": "يضغط قاعدة البيانات ويقلل المساحة الحرة. قد يؤدي تشغيل هذه المهمة بعد فحص المكتبة أو إجراء تغييرات أخرى تتضمن تعديلات على قاعدة البيانات إلى تحسين الأداء.", "TaskOptimizeDatabase": "تحسين قاعدة البيانات", - "TaskKeyframeExtractorDescription": "يستخرج الإطارات الرئيسية من ملفات الفيديو لكي ينشئ قوائم تشغيل بث HTTP المباشر. قد تستمر هذه العملية لوقت طويل.", - "TaskKeyframeExtractor": "مستخرج الإطار الرئيسي", + "TaskKeyframeExtractorDescription": "يستخرج الإطارات الرئيسية من ملفات الفيديو لإنشاء قوائم تشغيل HLS أكثر دقة. قد يستغرق تشغيل هذه المهمة وقتاً طويلاً.", + "TaskKeyframeExtractor": "مستخرج الإطارات الرئيسية", "External": "خارجي", - "HearingImpaired": "ضعاف السمع", - "TaskRefreshTrickplayImages": "توليد صور المعاينة السريعة", - "TaskRefreshTrickplayImagesDescription": "يُولّد معاينات تنقل سريع لمقاطع الفيديو ضمن المكتبات المفعّلة.", - "TaskCleanCollectionsAndPlaylists": "حذف المجموعات وقوائم التشغيل", - "TaskCleanCollectionsAndPlaylistsDescription": "حذف عناصر من المجموعات وقوائم التشغيل التي لم تعد موجودة.", - "TaskAudioNormalization": "تسوية الصوت", - "TaskAudioNormalizationDescription": "مسح الملفات لتطبيع بيانات الصوت.", - "TaskDownloadMissingLyrics": "تنزيل عبارات القصيدة", - "TaskDownloadMissingLyricsDescription": "كلمات", - "TaskExtractMediaSegments": "فحص مقاطع الوسائط", - "TaskExtractMediaSegmentsDescription": "يستخرج مقاطع وسائط من إضافات MediaSegment المُفعّلة.", - "TaskMoveTrickplayImages": "تغيير مكان صور المعاينة السريعة", - "TaskMoveTrickplayImagesDescription": "تُنقل ملفات التشغيل السريع الحالية بناءً على إعدادات المكتبة.", + "HearingImpaired": "لضعاف السمع", + "TaskRefreshTrickplayImages": "إنشاء صور معاينات التنقل (Trickplay)", + "TaskRefreshTrickplayImagesDescription": "ينشئ صور معاينات التنقل السريع للفيديوهات في المكتبات المفعّلة.", + "TaskAudioNormalization": "تطبيع الصوت", + "TaskAudioNormalizationDescription": "يفحص الملفات لجمع بيانات تطبيع الصوت.", + "TaskDownloadMissingLyrics": "تنزيل الكلمات المفقودة", + "TaskDownloadMissingLyricsDescription": "تحميل كلمات الأغاني", + "TaskExtractMediaSegments": "فحص مقاطع المحتوى", + "TaskExtractMediaSegmentsDescription": "يستخرج أو يحصل على مقاطع المحتوى من الملحقات المفعّلة لمقاطع المحتوى (MediaSegment).", + "TaskMoveTrickplayImages": "نقل موقع صور معاينات التنقل", + "TaskMoveTrickplayImagesDescription": "ينقل ملفات معاينات التنقل الحالية وفقاً لإعدادات المكتبة.", "CleanupUserDataTask": "مهمة تنظيف بيانات المستخدم", - "CleanupUserDataTaskDescription": "مسح جميع بيانات المستخدم (حالة المشاهدة، والحالة المفضلة وما إلى ذلك) من الوسائط التي لم تعد موجودة لمدة 90 يومًا على الأقل." + "CleanupUserDataTaskDescription": "ينظف جميع بيانات المستخدم (مثل حالة المشاهدة وحالة المفضلة وغيرها) للمحتوى الذي لم يعد موجوداً لمدة 90 يوماً على الأقل.", + "Original": "فريد", + "LyricDownloadFailureFromForItem": "فشل تحميل الكلمات من {0} إلى {1}", + "NameExtraBehindTheScenes": "خلف المشاهد", + "NameExtraClip": "مقطع", + "NameExtraDeletedScene": "المشهد المحذوف", + "NameExtraInterview": "مقابلة", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "عيّنة", + "NameExtraScene": "مشهد", + "NameExtraShort": "قصير", + "NameExtraThemeSong": "الاغنية السمة", + "NameExtraThemeVideo": "الفيديو السمة", + "NameExtraFeaturette": "فيلم قصير إضافي", + "NameExtraTrailer": "إعلان ترويجي", + "NameExtraUnknown": "إضافي" } diff --git a/Emby.Server.Implementations/Localization/Core/enm.json b/Emby.Server.Implementations/Localization/Core/ar_SA.json similarity index 100% rename from Emby.Server.Implementations/Localization/Core/enm.json rename to Emby.Server.Implementations/Localization/Core/ar_SA.json diff --git a/Emby.Server.Implementations/Localization/Core/as.json b/Emby.Server.Implementations/Localization/Core/as.json index 7c7dd26e92..bc0c2c5ff5 100644 --- a/Emby.Server.Implementations/Localization/Core/as.json +++ b/Emby.Server.Implementations/Localization/Core/as.json @@ -1,18 +1,13 @@ { - "Albums": "এলবাম", - "Application": "আবেদন", "AppDeviceValues": "এপ্‌: {0}, ডিভাইচ: {1}", "Artists": "শিল্পী", - "Channels": "চেনেলস", "Default": "ডিফল্ট", "AuthenticationSucceededWithUserName": "{0} সফলভাবে প্রমাণিত", "Books": "পুস্তক", "Movies": "চলচ্চিত্ৰ", - "CameraImageUploadedFrom": "একটি নতুন ক্যামেরা চিত্র আপলোড করা হয়েছে {0}", "Collections": "সংগ্রহ", "HeaderFavoriteShows": "প্রিয় শোসমূহ", "Latest": "শেহতীয়া", - "MessageApplicationUpdated": "জেলিফিন চাইভাৰ আপডেট কৰা হৈছে", "MixedContent": "মিশ্ৰিত সমগ্ৰতা", "NewVersionIsAvailable": "ডাউনলোড কৰিবলৈ জেলিফিন চাইভাৰৰ এটা নতুন সংস্কৰণ উপলব্ধ আছে.", "NotificationOptionCameraImageUploaded": "কেমেৰাৰ চিত্ৰ আপল'ড কৰা হ'ল", @@ -21,20 +16,14 @@ "Folders": "ফোল্ডাৰ", "Forced": "বলপূর্বক", "Genres": "শ্রেণী", - "HeaderAlbumArtists": "অ্যালবাম শিল্পী", "HeaderContinueWatching": "দেখা চালিয়ে যান", "FailedLoginAttemptWithUserName": "লগইন ব্যর্থ চেষ্টা কৰা হৈছে থেকে {0}", - "HeaderFavoriteAlbums": "প্রিয় অ্যালবামসমূহ", - "HeaderFavoriteArtists": "প্রিয় শিল্পীসমূহ", "HeaderFavoriteEpisodes": "প্রিয় পর্বসমূহ", - "HeaderFavoriteSongs": "প্ৰিয় গীত", "HeaderLiveTV": "প্ৰতিবেদন টিভি", "HeaderNextUp": "পৰৱৰ্তী অংশ", - "HeaderRecordingGroups": "অলংকৰণ গোষ্ঠীসমূহ", "HearingImpaired": "শ্ৰবণ অক্ষম", "HomeVideos": "ঘৰৰ ভিডিঅ'সমূহ", "Inherit": "উত্তপ্ত কৰা", - "MessageServerConfigurationUpdated": "চাইভাৰ কনফিগাৰেশ্যন আপডেট কৰা হৈছে", "NotificationOptionApplicationUpdateAvailable": "অ্যাপ্লিকেশ্যন আপডেট উপলব্ধ", "NotificationOptionApplicationUpdateInstalled": "অ্যাপ্লিকেশ্যন আপডেট ইনষ্টল কৰা হ'ল", "NotificationOptionAudioPlayback": "অডিঅ' প্লেবেক আৰম্ভ হ'ল", diff --git a/Emby.Server.Implementations/Localization/Core/az.json b/Emby.Server.Implementations/Localization/Core/az.json new file mode 100644 index 0000000000..6ab18c8534 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/az.json @@ -0,0 +1,19 @@ +{ + "Books": "Kitablar", + "HomeVideos": "Ev Videoları", + "Latest": "Ən son", + "MixedContent": "Qarışıq məzmun", + "Movies": "Filmlər", + "Music": "Musiqi", + "MusicVideos": "Musiqi Videoları", + "NameSeasonUnknown": "Mövsüm Naməlum", + "NewVersionIsAvailable": "Jellyfin Serverin yeni versiyası yükləmək üçün əlçatandır.", + "NotificationOptionApplicationUpdateAvailable": "Tətbiq yeniləməsi mövcuddur", + "NotificationOptionApplicationUpdateInstalled": "Tətbiq yeniləməsi quraşdırılıb", + "NotificationOptionAudioPlayback": "Audio oxutma başladı", + "NotificationOptionAudioPlaybackStopped": "Audio oxutma dayandırıldı", + "NotificationOptionCameraImageUploaded": "Kamera şəkli yükləndi", + "NotificationOptionInstallationFailed": "Quraşdırma uğursuzluğu", + "NotificationOptionNewLibraryContent": "Yeni məzmun əlavə edildi", + "NotificationOptionPluginError": "Plugin uğursuzluğu" +} diff --git a/Emby.Server.Implementations/Localization/Core/be.json b/Emby.Server.Implementations/Localization/Core/be.json index 29847048cb..c5b1213096 100644 --- a/Emby.Server.Implementations/Localization/Core/be.json +++ b/Emby.Server.Implementations/Localization/Core/be.json @@ -1,36 +1,24 @@ { - "Sync": "Сінхранізаваць", - "Playlists": "Плэй-лісты", "Latest": "Апошняе", "LabelIpAddressValue": "IP-адрас: {0}", - "ItemAddedWithName": "{0} даданы ў бібліятэку", - "MessageApplicationUpdated": "Сервер Jellyfin абноўлены", "NotificationOptionApplicationUpdateInstalled": "Абнаўленне праграмы ўсталявана", "PluginInstalledWithName": "{0} быў усталяваны", "UserCreatedWithName": "Карыстальнік {0} быў створаны", - "Albums": "Альбомы", - "Application": "Праграма", "AuthenticationSucceededWithUserName": "{0} паспяхова аўтарызаваны", - "Channels": "Каналы", "ChapterNameValue": "Раздзел {0}", "Collections": "Калекцыі", - "Default": "Па змаўчанні", + "Default": "Прадвызначана", "FailedLoginAttemptWithUserName": "Няўдалая спроба ўваходу з {0}", - "Folders": "Тэчкі", + "Folders": "Папкі", "Favorites": "Абранае", "External": "Знешні", "Genres": "Жанры", "HeaderContinueWatching": "Працягнуць прагляд", - "HeaderFavoriteAlbums": "Абраныя альбомы", "HeaderFavoriteEpisodes": "Абраныя серыі", "HeaderFavoriteShows": "Абраныя шоу", - "HeaderFavoriteSongs": "Абраныя песні", "HeaderLiveTV": "Прамы эфір", - "HeaderAlbumArtists": "Выканаўцы альбома", "LabelRunningTimeValue": "Працягласць: {0}", "HomeVideos": "Хатнія відэа", - "ItemRemovedWithName": "{0} выдалены з бібліятэкі", - "MessageApplicationUpdatedTo": "Сервер Jellyfin абноўлены да версіі {0}", "Movies": "Фільмы", "Music": "Музыка", "MusicVideos": "Музычныя кліпы", @@ -41,48 +29,35 @@ "NotificationOptionPluginUpdateInstalled": "Абнаўленне плагіна ўсталявана", "NotificationOptionServerRestartRequired": "Патрабуецца перазапуск сервера", "Photos": "Фотаздымкі", - "Plugin": "Плагін", "PluginUninstalledWithName": "{0} быў выдалены", "PluginUpdatedWithName": "{0} быў абноўлены", - "ProviderValue": "Пастаўшчык: {0}", - "Songs": "Песні", - "System": "Сістэма", - "User": "Карыстальнік", "UserDeletedWithName": "Карыстальнік {0} быў выдалены", "UserDownloadingItemWithValues": "{0} спампоўваецца {1}", - "TaskOptimizeDatabase": "Аптымізаваць базу дадзеных", + "TaskOptimizeDatabase": "Аптымізацыя базы даных", "Artists": "Выканаўцы", "UserOfflineFromDevice": "{0} адлучыўся ад {1}", - "UserPolicyUpdatedWithName": "Палітыка карыстальніка абноўлена для {0}", "TaskCleanActivityLogDescription": "Выдаляе запісы старэйшыя за зададзены ўзрост ў журнале актыўнасці.", "TaskRefreshChapterImagesDescription": "Стварае мініяцюры для відэа, якія маюць раздзелы.", "TaskCleanLogsDescription": "Выдаляе файлы журналу, якім больш за {0} дзён.", "TaskUpdatePluginsDescription": "Спампоўвае і ўсталёўвае абнаўленні для плагінаў, якія сканфігураваныя на аўтаматычнае абнаўленне.", "TaskRefreshChannelsDescription": "Абнаўляе інфармацыю аб інтэрнэт-канале.", - "TaskDownloadMissingSubtitlesDescription": "Шукае ў інтэрнэце адсутныя субцітры на аснове канфігурацыі метададзеных.", - "TaskOptimizeDatabaseDescription": "Ушчыльняе базу дадзеных і скарачае вольную прастору. Выкананне гэтай задачы пасля сканавання бібліятэкі або ўнясення іншых зменаў, якія прадугледжваюць мадыфікацыю базы дадзеных, можа палепшыць выдайнасць.", + "TaskDownloadMissingSubtitlesDescription": "Шукае ў інтэрнэце адсутныя субцітры на аснове канфігурацыі метаданых.", + "TaskOptimizeDatabaseDescription": "Сціскае базу даных і вызваляе вольную прастору. Выкананне гэтай задачы пасля сканіравання бібліятэкі або іншых змяненняў, якія мадыфікуюць базу даных, можа палепшыць прадукцыйнасць.", "TaskKeyframeExtractor": "Экстрактар ключавых кадраў", "TasksApplicationCategory": "Праграма", "AppDeviceValues": "Праграма: {0}, Прылада: {1}", "Books": "Кнігі", - "CameraImageUploadedFrom": "Новая выява камеры была загружана з {0}", - "DeviceOfflineWithName": "{0} адлучыўся", - "DeviceOnlineWithName": "{0} падлучаны", "Forced": "Прымусова", - "HeaderRecordingGroups": "Групы запісаў", "HeaderNextUp": "Наступнае", - "HeaderFavoriteArtists": "Абраныя выканаўцы", "HearingImpaired": "Са слабым слыхам", "Inherit": "Атрымаць у спадчыну", - "MessageNamedServerConfigurationUpdatedWithValue": "Канфігурацыя сервера (секцыя {0}) абноўлена", - "MessageServerConfigurationUpdated": "Канфігурацыя сервера абноўлена", "MixedContent": "Змешаны змест", "NameSeasonUnknown": "Невядомы сезон", "NotificationOptionInstallationFailed": "Збой усталёўкі", "NewVersionIsAvailable": "Новая версія сервера Jellyfin даступная для cпампоўкі.", "NotificationOptionCameraImageUploaded": "Выява камеры запампавана", - "NotificationOptionAudioPlaybackStopped": "Прайграванне аўдыё спынена", - "NotificationOptionAudioPlayback": "Прайграванне аўдыё пачалося", + "NotificationOptionAudioPlaybackStopped": "Прайграванне аўдыя спынена", + "NotificationOptionAudioPlayback": "Прайграванне аўдыя пачалося", "NotificationOptionNewLibraryContent": "Дададзены новы кантэнт", "NotificationOptionPluginError": "Збой плагіна", "NotificationOptionPluginUninstalled": "Плагін выдалены", @@ -91,11 +66,9 @@ "NotificationOptionVideoPlayback": "Пачалося прайграванне відэа", "NotificationOptionVideoPlaybackStopped": "Прайграванне відэа спынена", "ScheduledTaskFailedWithName": "{0} не атрымалася", - "ScheduledTaskStartedWithName": "{0} пачалося", - "ServerNameNeedsToBeRestarted": "{0} патрабуе перазапуску", "Shows": "Шоу", "StartupEmbyServerIsLoading": "Jellyfin Server загружаецца. Калі ласка, паўтарыце спробу крыху пазней.", - "SubtitleDownloadFailureFromForItem": "Не атрымалася спампаваць субтытры з {0} для {1}", + "SubtitleDownloadFailureFromForItem": "Субцітры для {1} не ўдалося спампаваць з {0}", "TvShows": "Тэлепраграма", "Undefined": "Нявызначана", "UserLockedOutWithName": "Карыстальнік {0} быў заблакіраваны", @@ -103,8 +76,6 @@ "UserPasswordChangedWithName": "Пароль быў зменены для карыстальніка {0}", "UserStartedPlayingItemWithValues": "{0} прайграваецца {1} на {2}", "UserStoppedPlayingItemWithValues": "{0} скончыў прайграванне {1} на {2}", - "ValueHasBeenAddedToLibrary": "{0} быў дададзены ў вашу медыятэку", - "ValueSpecialEpisodeName": "Спецэпізод - {0}", "VersionNumber": "Версія {0}", "TasksMaintenanceCategory": "Абслугоўванне", "TasksLibraryCategory": "Бібліятэка", @@ -114,7 +85,7 @@ "TaskCleanCacheDescription": "Выдаляе файлы кэша, якія больш не патрэбныя сістэме.", "TaskRefreshChapterImages": "Вынуць выявы раздзелаў", "TaskRefreshLibrary": "Сканаваць бібліятэку", - "TaskRefreshLibraryDescription": "Скануе вашу медыятэку на наяўнасць новых файлаў і абнаўляе метададзеныя.", + "TaskRefreshLibraryDescription": "Сканіруе вашу медыятэку на наяўнасць новых файлаў і абнаўляе метаданыя.", "TaskCleanLogs": "Ачысціць журнал", "TaskRefreshPeople": "Абнавіць выканаўцаў", "TaskRefreshPeopleDescription": "Абнаўленне метаданых для акцёраў і рэжысёраў у вашай медыятэцы.", @@ -123,11 +94,9 @@ "TaskCleanTranscodeDescription": "Выдаляе перакадзіраваныя файлы, старэйшыя за адзін дзень.", "TaskRefreshChannels": "Абнавіць каналы", "TaskDownloadMissingSubtitles": "Спампаваць адсутныя субцітры", - "TaskKeyframeExtractorDescription": "Выдае ключавыя кадры з відэафайлаў для стварэння больш дакладных плэй-лістоў HLS. Гэта задача можа працягнуцца шмат часу.", + "TaskKeyframeExtractorDescription": "Выдае ключавыя кадры з відэафайлаў для стварэння больш дакладных плэй-лістоў HLS. Гэта задача можа выконвацца доўга.", "TaskRefreshTrickplayImages": "Стварыць выявы Trickplay", "TaskRefreshTrickplayImagesDescription": "Стварае перадпрагляды відэаролікаў для Trickplay у падключаных бібліятэках.", - "TaskCleanCollectionsAndPlaylists": "Ачысціце калекцыі і плэй-лісты", - "TaskCleanCollectionsAndPlaylistsDescription": "Выдаляе элементы з калекцый і плэй-лістоў, якія больш не існуюць.", "TaskAudioNormalizationDescription": "Скануе файлы на прадмет нармалізацыі гуку.", "TaskAudioNormalization": "Нармалізацыя гуку", "TaskExtractMediaSegmentsDescription": "Выдае або атрымлівае медыясегменты з убудоў з падтрымкай MediaSegment.", @@ -136,6 +105,18 @@ "TaskDownloadMissingLyricsDescription": "Спампоўвае тэксты для песняў", "TaskExtractMediaSegments": "Сканіраванне медыя-сегмента", "TaskMoveTrickplayImages": "Перанесці месцазнаходжанне выявы Trickplay", - "CleanupUserDataTask": "Задача па ачыстцы дадзеных карыстальніка", - "CleanupUserDataTaskDescription": "Ачысьціць усе дадзеныя карыстальніка (стан прагляду, абранае і г.д.) для медыяфайлаў, што адсутнічаюць больш за 90 дзён." + "CleanupUserDataTask": "Задача па ачыстцы даных карыстальніка", + "CleanupUserDataTaskDescription": "Ачышчае ўсе даныя карыстальніка (стан прагляду, абранае і г.д.) для медыяфайлаў, што адсутнічаюць больш за 90 дзён.", + "LyricDownloadFailureFromForItem": "Не ўдалося загрузіць тэкст песні з {0} для {1}", + "NameExtraDeletedScene": "Выдаленая сцэна", + "NameExtraInterview": "Інтэрв'ю", + "NameExtraNumbered": "{0} {1}", + "NameExtraScene": "Сцэна", + "NameExtraTrailer": "Трэйлер", + "NameExtraBehindTheScenes": "За кулісамі", + "NameExtraClip": "Кліп", + "NameExtraFeaturette": "Кароткаметражка", + "NameExtraSample": "Прыклад", + "NameExtraShort": "Кароткаметражка", + "NameExtraThemeSong": "Тэматычная песня" } diff --git a/Emby.Server.Implementations/Localization/Core/bg-BG.json b/Emby.Server.Implementations/Localization/Core/bg-BG.json index fd3666ef1c..3d49675c63 100644 --- a/Emby.Server.Implementations/Localization/Core/bg-BG.json +++ b/Emby.Server.Implementations/Localization/Core/bg-BG.json @@ -1,41 +1,24 @@ { - "Albums": "Албуми", "AppDeviceValues": "Програма: {0}, Устройство: {1}", - "Application": "Програма", "Artists": "Артисти", "AuthenticationSucceededWithUserName": "{0} се удостовери успешно", "Books": "Книги", - "CameraImageUploadedFrom": "Нова снимка от камера беше качена от {0}", - "Channels": "Канали", "ChapterNameValue": "Глава {0}", "Collections": "Колекции", - "DeviceOfflineWithName": "{0} се разкачи", - "DeviceOnlineWithName": "{0} е свързан", "FailedLoginAttemptWithUserName": "Неуспешен опит за влизане от {0}", "Favorites": "Любими", "Folders": "Папки", "Genres": "Жанрове", - "HeaderAlbumArtists": "Изпълнители на албуми", "HeaderContinueWatching": "Продължаване на гледането", - "HeaderFavoriteAlbums": "Любими албуми", - "HeaderFavoriteArtists": "Любими изпълнители", "HeaderFavoriteEpisodes": "Любими епизоди", "HeaderFavoriteShows": "Любими сериали", - "HeaderFavoriteSongs": "Любими песни", "HeaderLiveTV": "Телевизия на живо", "HeaderNextUp": "Следва", - "HeaderRecordingGroups": "Запис групи", "HomeVideos": "Домашни Клипове", "Inherit": "Наследяване", - "ItemAddedWithName": "{0} е добавено към библиотеката", - "ItemRemovedWithName": "{0} е премахнато от библиотеката", "LabelIpAddressValue": "IP адрес: {0}", "LabelRunningTimeValue": "Продължителност: {0}", "Latest": "Последни", - "MessageApplicationUpdated": "Сървърът беше обновен", - "MessageApplicationUpdatedTo": "Сървърът беше обновен до {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Секцията {0} от сървърната конфигурация беше актуализирана", - "MessageServerConfigurationUpdated": "Конфигурацията на сървъра беше актуализирана", "MixedContent": "Смесено съдържание", "Movies": "Филми", "Music": "Музика", @@ -61,24 +44,14 @@ "NotificationOptionVideoPlayback": "Възпроизвеждането на видео започна", "NotificationOptionVideoPlaybackStopped": "Възпроизвеждането на видео е спряно", "Photos": "Снимки", - "Playlists": "Списъци", - "Plugin": "Добавка", "PluginInstalledWithName": "{0} е инсталиранa", "PluginUninstalledWithName": "{0} е деинсталиранa", "PluginUpdatedWithName": "{0} е обновенa", - "ProviderValue": "Доставчик: {0}", "ScheduledTaskFailedWithName": "{0} се провали", - "ScheduledTaskStartedWithName": "{0} започна", - "ServerNameNeedsToBeRestarted": "{0} трябва да се рестартира", "Shows": "Сериали", - "Songs": "Песни", "StartupEmbyServerIsLoading": "Сървърът зарежда. Моля, опитайте отново след малко.", - "SubtitleDownloadFailureForItem": "Неуспешно изтегляне на субтитри за {0}", "SubtitleDownloadFailureFromForItem": "Субтитрите за {1} от {0} не можаха да бъдат изтеглени", - "Sync": "Синхронизиране", - "System": "Система", "TvShows": "Телевизионни сериали", - "User": "Потребител", "UserCreatedWithName": "Потребителят {0} е създаден", "UserDeletedWithName": "Потребителят {0} е изтрит", "UserDownloadingItemWithValues": "{0} изтегля {1}", @@ -86,11 +59,8 @@ "UserOfflineFromDevice": "{0} се разкачи от {1}", "UserOnlineFromDevice": "{0} е на линия от {1}", "UserPasswordChangedWithName": "Паролата на потребителя {0} е променена", - "UserPolicyUpdatedWithName": "Потребителската политика за {0} се актуализира", "UserStartedPlayingItemWithValues": "{0} пусна {1}", "UserStoppedPlayingItemWithValues": "{0} спря {1}", - "ValueHasBeenAddedToLibrary": "{0} беше добавен във Вашата библиотека", - "ValueSpecialEpisodeName": "Специални - {0}", "VersionNumber": "Версия {0}", "TaskDownloadMissingSubtitlesDescription": "Търси Интернет за липсващи субтитри, на база конфигурацията за мета-данни.", "TaskDownloadMissingSubtitles": "Изтегляне на липсващи субтитри", @@ -129,8 +99,6 @@ "TaskRefreshTrickplayImagesDescription": "Създава прегледи на Trickplay за видеа в активирани библиотеки.", "TaskDownloadMissingLyrics": "Свали липсващи текстове", "TaskDownloadMissingLyricsDescription": "Свали текстове за песни", - "TaskCleanCollectionsAndPlaylists": "Изчисти колекциите и плейлистите", - "TaskCleanCollectionsAndPlaylistsDescription": "Премахни несъществуващи файлове в колекциите и плейлистите.", "TaskAudioNormalization": "Нормализиране на звука", "TaskAudioNormalizationDescription": "Сканирай файловете за нормализация на звука.", "TaskExtractMediaSegmentsDescription": "Изважда медиини сегменти от MediaSegment плъгини.", @@ -138,5 +106,20 @@ "TaskMoveTrickplayImagesDescription": "Премества съществуващите trickplay изображения спрямо настройките на библиотеката.", "TaskExtractMediaSegments": "Сканиране за сегменти", "CleanupUserDataTask": "Задача за почистване на потребителски данни", - "CleanupUserDataTaskDescription": "Почиства всички потребителски данни (статус на гледане, любими и т.н.) от медия, която вече не е налична от поне 90 дни." + "CleanupUserDataTaskDescription": "Почиства всички потребителски данни (статус на гледане, любими и т.н.) от медия, която вече не е налична от поне 90 дни.", + "LyricDownloadFailureFromForItem": "Текстът на песента не успя да се изтегли от {0} за {1}", + "NameExtraBehindTheScenes": "Зад кулисите", + "NameExtraScene": "Сцена", + "NameExtraShort": "Откъс", + "NameExtraThemeVideo": "Тематично видео", + "NameExtraTrailer": "Трейлър", + "NameExtraUnknown": "Екстра", + "NameExtraClip": "Клип", + "NameExtraDeletedScene": "Изтрита Сцена", + "NameExtraFeaturette": "Кратък филм", + "NameExtraInterview": "Интервю", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Пример", + "NameExtraThemeSong": "Тема-песен", + "Original": "Оригинал" } diff --git a/Emby.Server.Implementations/Localization/Core/bn.json b/Emby.Server.Implementations/Localization/Core/bn.json index 876773778b..eb7fdf2b68 100644 --- a/Emby.Server.Implementations/Localization/Core/bn.json +++ b/Emby.Server.Implementations/Localization/Core/bn.json @@ -1,31 +1,19 @@ { - "DeviceOnlineWithName": "{0}-এর সাথে সংযুক্ত হয়েছে", - "DeviceOfflineWithName": "{0}-এর সাথে সংযোগ বিচ্ছিন্ন হয়েছে", "Collections": "সংগ্রহশালা", "ChapterNameValue": "অধ্যায় {0}", - "Channels": "চ্যানেলসমূহ", - "CameraImageUploadedFrom": "{0} থেকে একটি নতুন ক্যামেরার চিত্র আপলোড করা হয়েছে", "Books": "পুস্তকসমূহ", "AuthenticationSucceededWithUserName": "{0} সফলভাবে অথেন্টিকেট করেছেন", "Artists": "শিল্পীগণ", - "Application": "অ্যাপ্লিকেশন", - "Albums": "অ্যালবামসমূহ", "HeaderFavoriteEpisodes": "প্রিয় পর্বগুলো", - "HeaderFavoriteArtists": "প্রিয় শিল্পীরা", - "HeaderFavoriteAlbums": "প্রিয় এলবামগুলো", "HeaderContinueWatching": "দেখতে থাকুন", - "HeaderAlbumArtists": "অ্যালবাম শিল্পীবৃন্দ", "Genres": "ধরণ", "Folders": "ফোল্ডারসমূহ", "Favorites": "পছন্দসমূহ", "FailedLoginAttemptWithUserName": "{0} লগিন করতে ব্যর্থ হয়েছে", "AppDeviceValues": "অ্যাপ: {0}, ডিভাইস: {1}", "VersionNumber": "সংস্করণ {0}", - "ValueSpecialEpisodeName": "বিশেষ পর্ব - {0}", - "ValueHasBeenAddedToLibrary": "আপনার লাইব্রেরিতে {0} যোগ করা হয়েছে", "UserStoppedPlayingItemWithValues": "{2}তে {1} প্লে শেষ করেছেন {0}", "UserStartedPlayingItemWithValues": "{2}তে {1} প্লে করেছেন {0}", - "UserPolicyUpdatedWithName": "{0} এর জন্য ব্যবহার নীতি আপডেট করা হয়েছে", "UserPasswordChangedWithName": "ব্যবহারকারী {0} এর পাসওয়ার্ড পরিবর্তিত হয়েছে", "UserOnlineFromDevice": "{0}, {1} থেকে অনলাইন আছে", "UserOfflineFromDevice": "{0} {1} থেকে বিচ্ছিন্ন হয়ে গেছে", @@ -33,23 +21,14 @@ "UserDownloadingItemWithValues": "{0}, {1} ডাউনলোড করছে", "UserDeletedWithName": "ব্যবহারকারী {0}কে বাদ দেয়া হয়েছে", "UserCreatedWithName": "ব্যবহারকারী {0} সৃষ্টি করা হয়েছে", - "User": "ব্যবহারকারী", "TvShows": "টিভি শোগুলো", - "System": "সিস্টেম", - "Sync": "সমন্বয় করুন", "SubtitleDownloadFailureFromForItem": "{0} থেকে {1} এর জন্য সাবটাইটেল ডাউনলোড ব্যর্থ হয়েছে", "StartupEmbyServerIsLoading": "জেলিফিন সার্ভার লোড হচ্ছে। দয়া করে একটু পরে আবার চেষ্টা করুন।", - "Songs": "সঙ্গীত সমূহ", "Shows": "শো সমূহ", - "ServerNameNeedsToBeRestarted": "{0} রিস্টার্ট করা প্রয়োজন", - "ScheduledTaskStartedWithName": "{0} শুরু হয়েছে", "ScheduledTaskFailedWithName": "{0} ব্যর্থ", - "ProviderValue": "প্রদানকারী: {0}", "PluginUpdatedWithName": "{0} আপডেট করা হয়েছে", "PluginUninstalledWithName": "{0} আনইন্সটল হয়েছে", "PluginInstalledWithName": "{0} ইন্সটল হয়েছে", - "Plugin": "প্লাগিন", - "Playlists": "প্লে লিস্ট সমূহ", "Photos": "ছবিসমূহ", "NotificationOptionVideoPlaybackStopped": "ভিডিও প্লেব্যাক বন্ধ হয়েছে", "NotificationOptionVideoPlayback": "ভিডিও প্লেব্যাক শুরু হয়েছে", @@ -75,21 +54,13 @@ "Music": "গান", "Movies": "চলচ্চিত্রসমূহ", "MixedContent": "মিশ্র কন্টেন্ট", - "MessageServerConfigurationUpdated": "সার্ভারের কনফিগারেশন আপডেট করা হয়েছে", - "HeaderRecordingGroups": "রেকর্ডিং গ্রুপগুলো", - "MessageNamedServerConfigurationUpdatedWithValue": "সার্ভার কনফিগারেশন সেকশন {0} আপডেট করা হয়েছে", - "MessageApplicationUpdatedTo": "জেলিফিন সার্ভার {0} তে আপডেট করা হয়েছে", - "MessageApplicationUpdated": "জেলিফিন সার্ভার আপডেট করা হয়েছে", "Latest": "সর্বশেষ", "LabelRunningTimeValue": "চলার সময়: {0}", "LabelIpAddressValue": "আইপি এড্রেস: {0}", - "ItemRemovedWithName": "{0} লাইব্রেরি থেকে বাদ দেয়া হয়েছে", - "ItemAddedWithName": "{0} লাইব্রেরিতে যোগ করা হয়েছে", "Inherit": "উত্তরাধিকারসূত্র থেকে গ্রহণ করুন", "HomeVideos": "হোম ভিডিও", "HeaderNextUp": "এরপরে আসছে", "HeaderLiveTV": "লাইভ টিভি", - "HeaderFavoriteSongs": "প্রিয় গানগুলো", "HeaderFavoriteShows": "প্রিয় শোগুলো", "TasksLibraryCategory": "লাইব্রেরি", "TasksMaintenanceCategory": "রক্ষণাবেক্ষণ", @@ -127,8 +98,6 @@ "TaskRefreshTrickplayImages": "ট্রিকপ্লে ইমেজ তৈরি", "TaskRefreshTrickplayImagesDescription": "সক্ষম লাইব্রেরিতে ভিডিওর জন্য ট্রিকপ্লে প্রিভিউ তৈরি করে।", "TaskDownloadMissingLyricsDescription": "গানের জন্য লিরিকস ডাউনলোড করুন", - "TaskCleanCollectionsAndPlaylists": "কালেকশন এবং প্লেলিস্ট পরিষ্কার করুন", - "TaskCleanCollectionsAndPlaylistsDescription": "কালেকশন এবং প্লেলিস্ট থেকে আইটেমগুলি সরিয়ে দেয় যা আর বিদ্যমান নেই।", "TaskExtractMediaSegments": "মিডিয়া সেগমেন্ট স্ক্যান", "TaskExtractMediaSegmentsDescription": "মিডিয়া সেগমেন্ট সক্ষম প্লাগইনগুলি থেকে মিডিয়া সেগমেন্ট বের করে বা অর্জন করে।", "TaskDownloadMissingLyrics": "অনুপস্থিত গান ডাউনলোড করুন", diff --git a/Emby.Server.Implementations/Localization/Core/br.json b/Emby.Server.Implementations/Localization/Core/br.json new file mode 100644 index 0000000000..cedc87e5a6 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/br.json @@ -0,0 +1,63 @@ +{ + "Artists": "Arzourien", + "AuthenticationSucceededWithUserName": "{0} kennasket gant berzh", + "Books": "Levrioù", + "ChapterNameValue": "Pennad {0}", + "Collections": "Dastumadegoù", + "Default": "Dre ziouer", + "External": "Diavaez", + "FailedLoginAttemptWithUserName": "Kennaskañ c'hwitet gant {0}", + "Favorites": "Sinedoù", + "Folders": "Teuliadoù", + "Forced": "Rediet", + "Genres": "Doareoù", + "HeaderContinueWatching": "Kenderc'hel da sellet", + "HeaderFavoriteEpisodes": "Rannoù Karetañ", + "HeaderFavoriteShows": "Heuliadennoù Karetañ", + "HeaderLiveTV": "TV war-eeun", + "HeaderNextUp": "Da c'houde", + "HearingImpaired": "Tud fall o C'hleved", + "HomeVideos": "Videoioù Personel", + "Inherit": "Hêrezhiñ", + "LabelIpAddressValue": "Chomlec'h IP : {0}", + "LabelRunningTimeValue": "Padelezh : {0}", + "Latest": "Diwezhañ", + "AppDeviceValues": "Arload : {0}, Trobarzhell : {1}", + "LyricDownloadFailureFromForItem": "C'hwitet eo pellgargañ ar c'homzoù eus {0} evit {1}", + "MixedContent": "Danvez mesket", + "Movies": "Filmoù", + "Music": "Sonerezh", + "MusicVideos": "Videoioù Sonerezh", + "NameInstallFailed": "{0} c'hwitet war ar staliadur", + "NameSeasonNumber": "Koulzad {0}", + "NameSeasonUnknown": "Koulzad Dianav", + "NewVersionIsAvailable": "Ur stumm Servijer Jellyfin nevez a c'haller pellgargañ.", + "NotificationOptionApplicationUpdateAvailable": "Hizivadur an arload zo da gaout", + "NotificationOptionApplicationUpdateInstalled": "Hizivadur an arload staliet", + "NotificationOptionAudioPlayback": "Lenn aodio lañset", + "NotificationOptionAudioPlaybackStopped": "Lenn aodio ehanet", + "Original": "Orin", + "Photos": "Fotoioù", + "Shows": "Heuliadennoù", + "Undefined": "Dianav", + "TasksMaintenanceCategory": "Trezalc’h", + "TasksLibraryCategory": "Levraoueg", + "TasksApplicationCategory": "Arload", + "NotificationOptionInstallationFailed": "C'hwitet war staliañ", + "NotificationOptionPluginError": "Fazi Askouezh", + "NotificationOptionPluginInstalled": "Askouezh staliet", + "NotificationOptionPluginUninstalled": "Askouezh distaliet", + "ScheduledTaskFailedWithName": "c'hwitadenn war {0}", + "TvShows": "Heuliadennoù TV", + "VersionNumber": "Stumm {0}", + "TasksChannelsCategory": "Chadennoù enlinenn", + "TaskAudioNormalization": "Normalizadur an aodio", + "TaskRefreshPeople": "Freskaat ar gomedianed", + "TaskUpdatePlugins": "Hizivaat an askouezhioù", + "TaskRefreshChannels": "Freskaat ar chadennoù", + "TaskOptimizeDatabase": "Gwellekaat an diaz roadennoù", + "TaskKeyframeExtractor": "Eztenner skeudennoù-alc'hwez", + "NotificationOptionCameraImageUploaded": "Karget eo skeudenn ar benveg", + "NotificationOptionNewLibraryContent": "Danvez nevez ouzhpennet", + "NotificationOptionPluginUpdateInstalled": "Staliet eo hizivadur an askouezh" +} diff --git a/Emby.Server.Implementations/Localization/Core/bs.json b/Emby.Server.Implementations/Localization/Core/bs.json new file mode 100644 index 0000000000..5686807d9a --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/bs.json @@ -0,0 +1,122 @@ +{ + "Artists": "Umjetnici", + "Books": "Knjige", + "Collections": "Zbirke", + "Default": "Zadano", + "Favorites": "Omiljeni", + "Folders": "Mape", + "Genres": "Žanrovi", + "HeaderContinueWatching": "Nastavi gledati", + "Movies": "Filmovi", + "MusicVideos": "Muzički spotovi", + "Photos": "Slike", + "Shows": "Pokazuje", + "AppDeviceValues": "Aplikacija: {0}, Uređaj: {1}", + "AuthenticationSucceededWithUserName": "{0} uspješno autentificirano", + "ChapterNameValue": "Poglavlje {0}", + "External": "Vanjsko", + "FailedLoginAttemptWithUserName": "Neuspjeli pokušaj prijave sa {0}", + "Forced": "Prisilno", + "HeaderFavoriteEpisodes": "Omiljene epizode", + "HeaderFavoriteShows": "Omiljene emisije", + "HeaderLiveTV": "TV uživo", + "HeaderNextUp": "Slijedi", + "HearingImpaired": "Oštećen sluh", + "HomeVideos": "Kućni videozapisi", + "Inherit": "Nasljedi", + "LabelIpAddressValue": "IP adresa: {0}", + "LabelRunningTimeValue": "Trajanje: {0}", + "Latest": "Posljednje dodano", + "MixedContent": "Miješani sadržaj", + "Music": "Muzika", + "NameInstallFailed": "{0} instalacija je propala", + "NameSeasonNumber": "Sezona {0}", + "NameSeasonUnknown": "Sezona nepoznata", + "NewVersionIsAvailable": "Dostupna je nova verzija Jellyfin Servera za preuzimanje.", + "NotificationOptionApplicationUpdateAvailable": "Dostupno ažuriranje aplikacije", + "NotificationOptionApplicationUpdateInstalled": "Ažuriranje aplikacije instalirano", + "NotificationOptionAudioPlayback": "Pokrenuto je reproduciranje zvuka", + "NotificationOptionAudioPlaybackStopped": "Zaustavljeno je reproduciranje zvuka", + "NotificationOptionCameraImageUploaded": "Učitana slika s kamere", + "NotificationOptionInstallationFailed": "Neuspjeh instalacije", + "NotificationOptionNewLibraryContent": "Dodan novi sadržaj", + "NotificationOptionPluginError": "Neuspjeh dodatka", + "NotificationOptionPluginInstalled": "Dodatak je instaliran", + "NotificationOptionPluginUninstalled": "Dodatak je deinstaliran", + "NotificationOptionPluginUpdateInstalled": "Ažuriranje dodatka je instalirano", + "NotificationOptionServerRestartRequired": "Potreban je ponovni pokret servera", + "NotificationOptionTaskFailed": "Neuspjeh zakazane zadatke", + "NotificationOptionUserLockedOut": "Korisnik je zaključan", + "NotificationOptionVideoPlayback": "Pokrenuto je reproduciranje videa", + "NotificationOptionVideoPlaybackStopped": "Reprodukcija videa je zaustavljena", + "PluginInstalledWithName": "{0} je instaliran", + "PluginUninstalledWithName": "{0} je deinstaliran", + "PluginUpdatedWithName": "{0} je ažurirano", + "ScheduledTaskFailedWithName": "{0} nije uspjelo", + "StartupEmbyServerIsLoading": "Jellyfin Server se učitava. Molimo pokušajte ponovo za kratko vrijeme.", + "SubtitleDownloadFailureFromForItem": "Podtitlovi nisu uspjeli preuzeti sa {0} za {1}", + "TvShows": "TV serije", + "Undefined": "Nedefinirano", + "UserCreatedWithName": "Korisnik {0} je kreiran", + "UserDeletedWithName": "Korisnik {0} je izbrisan", + "UserDownloadingItemWithValues": "{0} preuzima {1}", + "UserLockedOutWithName": "Korisnik {0} je zaključan", + "UserOfflineFromDevice": "{0} se odspojio od {1}", + "UserOnlineFromDevice": "{0} je online od {1}", + "UserPasswordChangedWithName": "Lozinka je promijenjena za korisnika {0}", + "UserStartedPlayingItemWithValues": "{0} igra protiv {1} na {2}", + "UserStoppedPlayingItemWithValues": "{0} je završio igru protiv {1} na {2}", + "VersionNumber": "Verzija {0}", + "TasksMaintenanceCategory": "Održavanje", + "TasksLibraryCategory": "Biblioteka", + "TasksApplicationCategory": "Prijava", + "TasksChannelsCategory": "Internetski kanali", + "TaskCleanActivityLog": "Očisti dnevnik aktivnosti", + "TaskCleanActivityLogDescription": "Brisanje unosa u dnevnik aktivnosti starijih od konfigurisane starosti.", + "TaskCleanCache": "Očistite direktorij keša", + "TaskCleanCacheDescription": "Brisanje keš datoteka koje sistemu više nisu potrebne.", + "TaskRefreshChapterImages": "Izvadi slike iz poglavlja", + "TaskRefreshChapterImagesDescription": "Stvara minijature za videozapise koji imaju poglavlja.", + "TaskAudioNormalization": "Normalizacija zvuka", + "TaskAudioNormalizationDescription": "Skeneriše datoteke radi podataka za normalizaciju zvuka.", + "TaskRefreshLibrary": "Skenerisati medijsku biblioteku", + "TaskRefreshLibraryDescription": "Skenerira vašu medijsku biblioteku na nove datoteke i osvježava metapodatke.", + "TaskCleanLogs": "Očisti direktorij dnevnika", + "TaskCleanLogsDescription": "Brisanje dnevničkih datoteka starijih od {0} dana.", + "TaskRefreshPeople": "Osvježite ljude", + "TaskRefreshPeopleDescription": "Ažurira metapodatke za glumce i režisere u vašoj medijskoj biblioteci.", + "TaskRefreshTrickplayImages": "Generirajte Trickplay slike", + "TaskRefreshTrickplayImagesDescription": "Stvara pregled trik-igara za videozapise u omogućenim bibliotekama.", + "TaskUpdatePlugins": "Ažuriraj dodatke", + "TaskUpdatePluginsDescription": "Preuzima i instalira ažuriranja dodataka koji su konfigurisani da se automatski ažuriraju.", + "TaskCleanTranscode": "Očisti Transcode direktorij", + "TaskCleanTranscodeDescription": "Brisanje transkodiranih datoteka starijih od jednog dana.", + "TaskRefreshChannels": "Osvježi kanale", + "TaskRefreshChannelsDescription": "Osvježava informacije o internetskom kanalu.", + "TaskDownloadMissingLyrics": "Preuzmi nedostajuće tekstove", + "TaskDownloadMissingLyricsDescription": "Preuzmi tekstove pjesama", + "TaskDownloadMissingSubtitles": "Preuzmite nedostajuće titlove", + "TaskDownloadMissingSubtitlesDescription": "Pretražuje internet u potrazi za nedostajućim titlovima na osnovu konfiguracije metapodataka.", + "TaskOptimizeDatabase": "Optimizirajte bazu podataka", + "TaskOptimizeDatabaseDescription": "Komprimira bazu podataka i čisti slobodan prostor. Pokretanje ovog zadatka nakon skeniranja biblioteke ili izvođenja drugih promjena koje podrazumijevaju izmjene baze podataka može poboljšati performanse.", + "TaskKeyframeExtractor": "Izvađač ključnih sličica", + "TaskKeyframeExtractorDescription": "Izvlači ključne okvire iz video datoteka kako bi kreirao preciznije HLS playliste. Ovaj zadatak može trajati dugo.", + "TaskExtractMediaSegments": "Analiza medijskog segmenta", + "TaskExtractMediaSegmentsDescription": "Izvlači ili dobija medijske segmente iz dodataka koji podržavaju MediaSegment.", + "TaskMoveTrickplayImages": "Migracija lokacije slike Trickplay", + "TaskMoveTrickplayImagesDescription": "Premješta postojeće datoteke trik-igara prema postavkama biblioteke.", + "CleanupUserDataTask": "Zadatak čišćenja korisničkih podataka", + "CleanupUserDataTaskDescription": "Čisti sve korisničke podatke (stanje praćenja, status omiljenog itd.) sa medija koji više nije prisutan najmanje 90 dana.", + "NameExtraBehindTheScenes": "Iza kulisa", + "NameExtraClip": "Isječak", + "NameExtraDeletedScene": "Izbrišana scena", + "NameExtraFeaturette": "Kratki prilog", + "NameExtraInterview": "Intervju", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Uzorak", + "NameExtraScene": "Scena", + "NameExtraShort": "Kratko", + "NameExtraThemeSong": "Tema", + "NameExtraThemeVideo": "Tematski video", + "NameExtraTrailer": "Najava" +} diff --git a/Emby.Server.Implementations/Localization/Core/ca.json b/Emby.Server.Implementations/Localization/Core/ca.json index 596df63482..12076d6c15 100644 --- a/Emby.Server.Implementations/Localization/Core/ca.json +++ b/Emby.Server.Implementations/Localization/Core/ca.json @@ -1,41 +1,24 @@ { - "Albums": "Àlbums", "AppDeviceValues": "Aplicació: {0}, Dispositiu: {1}", - "Application": "Aplicació", "Artists": "Artistes", "AuthenticationSucceededWithUserName": "{0} s'ha autenticat correctament", "Books": "Llibres", - "CameraImageUploadedFrom": "S'ha pujat una nova imatge de càmera des de {0}", - "Channels": "Canals", "ChapterNameValue": "Capítol {0}", "Collections": "Col·leccions", - "DeviceOfflineWithName": "{0} s'ha desconnectat", - "DeviceOnlineWithName": "{0} està connectat", "FailedLoginAttemptWithUserName": "Intent de connexió fallit des de {0}", "Favorites": "Preferits", "Folders": "Directoris", "Genres": "Gèneres", - "HeaderAlbumArtists": "Artistes de l'àlbum", "HeaderContinueWatching": "Continueu mirant", - "HeaderFavoriteAlbums": "Àlbums preferits", - "HeaderFavoriteArtists": "Artistes preferits", "HeaderFavoriteEpisodes": "Episodis preferits", "HeaderFavoriteShows": "Sèries preferides", - "HeaderFavoriteSongs": "Cançons preferides", "HeaderLiveTV": "TV en directe", "HeaderNextUp": "A continuació", - "HeaderRecordingGroups": "Grups musicals", "HomeVideos": "Vídeos domèstics", "Inherit": "Heretat", - "ItemAddedWithName": "{0} s'ha afegit a la mediateca", - "ItemRemovedWithName": "{0} s'ha eliminat de la mediateca", "LabelIpAddressValue": "Adreça IP: {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}", - "MessageNamedServerConfigurationUpdatedWithValue": "La secció {0} de la configuració del servidor ha estat actualitzada", - "MessageServerConfigurationUpdated": "S'ha actualitzat la configuració del servidor", "MixedContent": "Contingut barrejat", "Movies": "Pel·lícules", "Music": "Música", @@ -61,24 +44,14 @@ "NotificationOptionVideoPlayback": "Reproducció de vídeo iniciada", "NotificationOptionVideoPlaybackStopped": "Reproducció de vídeo aturada", "Photos": "Fotos", - "Playlists": "Llistes de reproducció", - "Plugin": "Complement", - "PluginInstalledWithName": "{0} ha estat instal·lat", - "PluginUninstalledWithName": "S'ha instal·lat {0}", + "PluginInstalledWithName": "S'ha instal·lat {0}", + "PluginUninstalledWithName": "S'ha desinstal·lat {0}", "PluginUpdatedWithName": "S'ha actualitzat {0}", - "ProviderValue": "Proveïdor: {0}", "ScheduledTaskFailedWithName": "{0} ha fallat", - "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 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": "Sincronitza", - "System": "Sistema", "TvShows": "Sèries de TV", - "User": "Usuari", "UserCreatedWithName": "S'ha creat l'usuari {0}", "UserDeletedWithName": "S'ha eliminat l'usuari {0}", "UserDownloadingItemWithValues": "{0} està descarregant {1}", @@ -86,11 +59,8 @@ "UserOfflineFromDevice": "{0} s'ha desconnectat de {1}", "UserOnlineFromDevice": "{0} està connectat des de {1}", "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} a {2}", "UserStoppedPlayingItemWithValues": "{0} ha parat de reproduir {1} a {2}", - "ValueHasBeenAddedToLibrary": "S'ha afegit {0} a la mediateca", - "ValueSpecialEpisodeName": "Especial - {0}", "VersionNumber": "Versió {0}", "TaskDownloadMissingSubtitlesDescription": "Cerca a internet els subtítols que faltin a partir de la configuració de metadades.", "TaskDownloadMissingSubtitles": "Descàrrega dels subtítols que faltin", @@ -105,7 +75,7 @@ "TaskCleanLogsDescription": "Esborra els registres que tinguin més de {0} dies.", "TaskCleanLogs": "Neteja dels registres", "TaskRefreshLibraryDescription": "Escaneja les mediateques, a la cerca de fitxers nous i refresca les metadades.", - "TaskRefreshLibrary": "Escaneig de les mediateques", + "TaskRefreshLibrary": "Escaneja la mediateca", "TaskRefreshChapterImagesDescription": "Creació de les miniatures dels vídeos que tinguin capítols.", "TaskRefreshChapterImages": "Extracció de les imatges dels capítols", "TaskCleanCacheDescription": "Eliminació de la memòria cau no necessària per al servidor.", @@ -127,8 +97,6 @@ "HearingImpaired": "Discapacitat auditiva", "TaskRefreshTrickplayImages": "Generació d'imatges de previsualització", "TaskRefreshTrickplayImagesDescription": "Creació d'imatges de previsualització per a vídeos en les mediateques habilitades.", - "TaskCleanCollectionsAndPlaylistsDescription": "Esborra elements de col·leccions i llistes de reproducció que ja no existeixen.", - "TaskCleanCollectionsAndPlaylists": "Neteja de les col·leccions i llistes de reproducció", "TaskAudioNormalization": "Estabilització de l'àudio", "TaskAudioNormalizationDescription": "Escaneja els fitxer per a obtenir dades de normalització de l'àudio.", "TaskDownloadMissingLyricsDescription": "Descàrrega de les lletres de les cançons", @@ -138,5 +106,20 @@ "TaskMoveTrickplayImages": "Migració de la ubicació de la imatge de previsualització", "TaskMoveTrickplayImagesDescription": "Mou els fitxers existents d'imatges de previsualització segons la configuració de la mediateca.", "CleanupUserDataTaskDescription": "Neteja totes les dades d'usuari (estat de la visualització, estat dels preferits, etc.) del contingut multimèdia que no ha estat present durant almenys 90 dies.", - "CleanupUserDataTask": "Tasca de neteja de dades d'usuari" + "CleanupUserDataTask": "Tasca de neteja de dades d'usuari", + "Original": "Original", + "LyricDownloadFailureFromForItem": "No s'han pogut descarregar les lletres des de {0} per a {1}", + "NameExtraBehindTheScenes": "Rere les càmeres", + "NameExtraClip": "Tall", + "NameExtraDeletedScene": "Escena eliminada", + "NameExtraFeaturette": "Migmetratge", + "NameExtraInterview": "Entrevista", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Mostra", + "NameExtraScene": "Escena", + "NameExtraShort": "Curt", + "NameExtraThemeSong": "Tema musical", + "NameExtraThemeVideo": "Vídeo temàtic", + "NameExtraTrailer": "Tràiler", + "NameExtraUnknown": "Extra" } diff --git a/Emby.Server.Implementations/Localization/Core/chr.json b/Emby.Server.Implementations/Localization/Core/chr.json index 85d1f4c881..7e039bafc2 100644 --- a/Emby.Server.Implementations/Localization/Core/chr.json +++ b/Emby.Server.Implementations/Localization/Core/chr.json @@ -1,44 +1,30 @@ { "ChapterNameValue": "Didanedi {0}", - "HeaderAlbumArtists": "Didanidanolisgisgi", - "HeaderFavoriteAlbums": "Dvganidi didanidisgisgi", "HeaderLiveTV": "Anigadi didanidisgosgi", - "HeaderRecordingGroups": "Didanisquodiisgisgi", "HomeVideos": "Diganadi dinagadisgisgi", "Inherit": "Anigwe", - "MessageApplicationUpdatedTo": "Tsenigwidinonvhi Jellyfin Server tsadanidigwe anigadi {0}", "MixedContent": "Ganinidi dininoladisgisgi", "Movies": "Anidvnisgisgi", "MusicVideos": "Danodisgisgi didanidisgosgi", "NotificationOptionAudioPlayback": "Didanidigwe diganuyisgisgi anigadi", "NotificationOptionInstallationFailed": "Diudvdi anadvnatisgisgi", "NotificationOptionPluginUninstalled": "Ditsigvhnidv anawvdisgisgi", - "Albums": "Anigawidaniyv", - "Application": "Didanvyi", "Artists": "Dinidaniyi", "AuthenticationSucceededWithUserName": "{0} Sesoquonisdi nagadani", "Books": "Didanedi", - "CameraImageUploadedFrom": "Anigawidaniyv nasgi didagwalanvyi {0}", - "Channels": "Diganadasgi", "Collections": "Diganadisgi", "Default": "Dinadi", - "DeviceOfflineWithName": "{0} Aniyvolehvi nasgi", "External": "Amohdi", "Favorites": "Nvdayelvdisgi", "Folders": "Didanididisgi", "Forced": "Ganedi", "Genres": "Diganadisgi", "HeaderContinueWatching": "Uwoditsu asdanidisgisgi", - "HeaderFavoriteArtists": "Dvganidi dinidanolisgisgi", "HeaderFavoriteEpisodes": "Dvganidi didanidilisgadisgisgi", "HeaderFavoriteShows": "Dvganidi didanididanolisgisgi)", - "HeaderFavoriteSongs": "Dvganidi danodisgisgi", "HeaderNextUp": "Anidvli uwodoli", "HearingImpaired": "Anitsunidi talunidisgisgi", - "ItemAddedWithName": "{0} Dinigwe anididanidisgi", "Latest": "Uwodoli", - "MessageApplicationUpdated": "Tsenigwidinonvhi Jellyfin Server tsadanidigwe", - "MessageServerConfigurationUpdated": "Sedanidvdi anigadi diganidinonvhi", "Music": "Danodisgisgi", "NameSeasonUnknown": "Tsunita anidvdisgi", "NewVersionIsAvailable": "Danodigwe anigadi Jellyfin Server tsadanidigwe adisdi uwodvdi diganidinonvhi.", diff --git a/Emby.Server.Implementations/Localization/Core/cs.json b/Emby.Server.Implementations/Localization/Core/cs.json index e14edcffa2..033002d2b2 100644 --- a/Emby.Server.Implementations/Localization/Core/cs.json +++ b/Emby.Server.Implementations/Localization/Core/cs.json @@ -1,41 +1,24 @@ { - "Albums": "Alba", "AppDeviceValues": "Aplikace: {0}, Zařízení: {1}", - "Application": "Aplikace", "Artists": "Umělci", "AuthenticationSucceededWithUserName": "{0} úspěšně ověřen", "Books": "Knihy", - "CameraImageUploadedFrom": "Z {0} byla nahrána nová fotografie z fotoaparátu", - "Channels": "Kanály", "ChapterNameValue": "Kapitola {0}", "Collections": "Kolekce", - "DeviceOfflineWithName": "{0} se odpojil", - "DeviceOnlineWithName": "{0} je připojen", "FailedLoginAttemptWithUserName": "Neúspěšný pokus o přihlášení z {0}", "Favorites": "Oblíbené", "Folders": "Složky", "Genres": "Žánry", - "HeaderAlbumArtists": "Umělci alba", "HeaderContinueWatching": "Pokračovat ve sledování", - "HeaderFavoriteAlbums": "Oblíbená alba", - "HeaderFavoriteArtists": "Oblíbení interpreti", "HeaderFavoriteEpisodes": "Oblíbené epizody", "HeaderFavoriteShows": "Oblíbené seriály", - "HeaderFavoriteSongs": "Oblíbená hudba", "HeaderLiveTV": "TV vysílání", "HeaderNextUp": "Další díly", - "HeaderRecordingGroups": "Skupiny nahrávek", "HomeVideos": "Domácí videa", "Inherit": "Zdědit", - "ItemAddedWithName": "{0} byl přidán do knihovny", - "ItemRemovedWithName": "{0} byl odstraněn z knihovny", "LabelIpAddressValue": "IP adresa: {0}", "LabelRunningTimeValue": "Délka média: {0}", "Latest": "Nejnovější", - "MessageApplicationUpdated": "Jellyfin Server byl aktualizován", - "MessageApplicationUpdatedTo": "Jellyfin server byl aktualizován na verzi {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Konfigurace sekce {0} na serveru byla aktualizována", - "MessageServerConfigurationUpdated": "Konfigurace serveru aktualizována", "MixedContent": "Smíšený obsah", "Movies": "Filmy", "Music": "Hudba", @@ -61,24 +44,14 @@ "NotificationOptionVideoPlayback": "Přehrávání videa zahájeno", "NotificationOptionVideoPlaybackStopped": "Přehrávání videa ukončeno", "Photos": "Fotky", - "Playlists": "Seznamy skladeb", - "Plugin": "Zásuvný modul", "PluginInstalledWithName": "{0} byl nainstalován", "PluginUninstalledWithName": "{0} byl odinstalován", "PluginUpdatedWithName": "{0} byl aktualizován", - "ProviderValue": "Poskytl: {0}", "ScheduledTaskFailedWithName": "{0} selhalo", - "ScheduledTaskStartedWithName": "{0} zahájeno", - "ServerNameNeedsToBeRestarted": "{0} vyžaduje restart", "Shows": "Seriály", - "Songs": "Skladby", "StartupEmbyServerIsLoading": "Jellyfin Server je spouštěn. Zkuste to prosím v brzké době znovu.", - "SubtitleDownloadFailureForItem": "Stahování titulků selhalo pro {0}", "SubtitleDownloadFailureFromForItem": "Stažení titulků pro {1} z {0} selhalo", - "Sync": "Synchronizace", - "System": "Systém", "TvShows": "Seriály", - "User": "Uživatel", "UserCreatedWithName": "Uživatel {0} byl vytvořen", "UserDeletedWithName": "Uživatel {0} byl smazán", "UserDownloadingItemWithValues": "{0} stahuje {1}", @@ -86,11 +59,8 @@ "UserOfflineFromDevice": "{0} se odpojil ze zařízení {1}", "UserOnlineFromDevice": "{0} se připojil ze zařízení {1}", "UserPasswordChangedWithName": "Provedena změna hesla pro uživatele {0}", - "UserPolicyUpdatedWithName": "Zásady uživatele pro {0} byly aktualizovány", "UserStartedPlayingItemWithValues": "{0} spustil přehrávání {1}", "UserStoppedPlayingItemWithValues": "{0} zastavil přehrávání {1}", - "ValueHasBeenAddedToLibrary": "{0} byl přidán do vaší knihovny médií", - "ValueSpecialEpisodeName": "Speciál - {0}", "VersionNumber": "Verze {0}", "TaskDownloadMissingSubtitlesDescription": "Vyhledá na internetu chybějící titulky na základě nastavení metadat.", "TaskDownloadMissingSubtitles": "Stáhnout chybějící titulky", @@ -127,8 +97,6 @@ "HearingImpaired": "Sluchově postižení", "TaskRefreshTrickplayImages": "Generovat obrázky pro Trickplay", "TaskRefreshTrickplayImagesDescription": "Obrázky Trickplay se používají k zobrazení náhledů u videí v knihovnách, kde je to povoleno.", - "TaskCleanCollectionsAndPlaylists": "Pročistit kolekce a seznamy přehrávání", - "TaskCleanCollectionsAndPlaylistsDescription": "Odstraní neexistující položky z kolekcí a seznamů přehrávání.", "TaskAudioNormalization": "Normalizace zvuku", "TaskAudioNormalizationDescription": "Skenovat soubory za účelem normalizace zvuku.", "TaskDownloadMissingLyrics": "Stáhnout chybějící texty k písni", @@ -138,5 +106,20 @@ "TaskMoveTrickplayImages": "Přesunout úložiště obrázků Trickplay", "TaskMoveTrickplayImagesDescription": "Přesune existující soubory Trickplay podle nastavení knihovny.", "CleanupUserDataTaskDescription": "Odstraní všechna uživatelská data (stav zhlédnutí, oblíbené atd.) z médií, které již neexistují více než 90 dní.", - "CleanupUserDataTask": "Pročistit uživatelská data" + "CleanupUserDataTask": "Pročistit uživatelská data", + "Original": "Originál", + "LyricDownloadFailureFromForItem": "Nepodařilo se stáhnout texty pro {1} ze služby {0}", + "NameExtraBehindTheScenes": "Zákulisí", + "NameExtraClip": "Klip", + "NameExtraDeletedScene": "Vymazaná scéna", + "NameExtraFeaturette": "Featurette", + "NameExtraInterview": "Rozhovor", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Ukázka", + "NameExtraScene": "Scéna", + "NameExtraShort": "Krátké", + "NameExtraThemeSong": "Úvodní píseň", + "NameExtraThemeVideo": "Úvodní video", + "NameExtraTrailer": "Upoutávka", + "NameExtraUnknown": "Extra" } diff --git a/Emby.Server.Implementations/Localization/Core/cy.json b/Emby.Server.Implementations/Localization/Core/cy.json index 794a8e4ce4..e4d8ee7731 100644 --- a/Emby.Server.Implementations/Localization/Core/cy.json +++ b/Emby.Server.Implementations/Localization/Core/cy.json @@ -1,16 +1,11 @@ { - "DeviceOnlineWithName": "Mae {0} wedi'i gysylltu", - "DeviceOfflineWithName": "Mae {0} wedi datgysylltu", "Default": "Diofyn", "Collections": "Casgliadau", "ChapterNameValue": "Pennod {0}", - "Channels": "Sianeli", - "CameraImageUploadedFrom": "Mae delwedd camera newydd wedi'i lanlwytho o {0}", "Books": "Llyfrau", "AuthenticationSucceededWithUserName": "{0} wedi’i ddilysu’n llwyddiannus", - "Artists": "Artistiaid", + "Artists": "Crewyr", "AppDeviceValues": "Ap: {0}, Dyfais: {1}", - "Albums": "Albwmau", "Genres": "Genres", "Folders": "Ffolderi", "Favorites": "Ffefrynnau", @@ -20,9 +15,7 @@ "TaskRefreshPeople": "Adnewyddu Pobl", "TasksChannelsCategory": "Sianeli Internet", "VersionNumber": "Fersiwn {0}", - "ScheduledTaskStartedWithName": "{0} wedi dechrau", "ScheduledTaskFailedWithName": "{0} wedi methu", - "ProviderValue": "Darparwr: {0}", "NotificationOptionInstallationFailed": "Fethu Gosod", "NameSeasonUnknown": "Tymor Anhysbys", "NameSeasonNumber": "Tymor {0}", @@ -30,31 +23,20 @@ "MixedContent": "Cynnwys amrywiol", "HomeVideos": "Genres", "HeaderNextUp": "Nesaf i Fyny", - "HeaderFavoriteArtists": "Ffefryn Artistiaid", - "HeaderFavoriteAlbums": "Ffefryn Albwmau", "HeaderContinueWatching": "Parhewch i Wylio", "TasksApplicationCategory": "Rhaglen", "TasksLibraryCategory": "Llyfrgell", "TasksMaintenanceCategory": "Cynnal a Chadw", - "System": "System", - "Plugin": "Ategyn", "Music": "Cerddoriaeth", "Latest": "Diweddaraf", "Inherit": "Etifeddu", "Forced": "Orfodi", - "Application": "Rhaglen", - "HeaderAlbumArtists": "Artistiaid albwm", - "Sync": "Cysoni", - "Songs": "Caneuon", "Shows": "Rhaglenni", - "Playlists": "Rhestri Chwarae", "Photos": "Lluniau", - "ValueSpecialEpisodeName": "Arbennig - {0}", "Movies": "Ffilmiau", "Undefined": "Heb ddiffiniad", "TvShows": "Rhaglenni teledu", "HeaderLiveTV": "Teledu Byw", - "User": "Defnyddiwr", "TaskCleanLogsDescription": "Dileu ffeiliau log sy'n fwy na {0} diwrnod oed.", "TaskCleanLogs": "Glanhau ffolder log", "TaskRefreshLibraryDescription": "Sganio'ch llyfrgell gyfryngau am ffeiliau newydd ac yn adnewyddu metaddata.", @@ -65,13 +47,9 @@ "NotificationOptionPluginError": "Methodd ategyn", "NotificationOptionAudioPlaybackStopped": "Stopiwyd chwarae sain", "NotificationOptionAudioPlayback": "Dechreuwyd chwarae sain", - "MessageServerConfigurationUpdated": "Mae gosodiadau gweinydd wedi'i ddiweddaru", - "MessageNamedServerConfigurationUpdatedWithValue": "Mae adran gosodiadau gweinydd {0} wedi'i diweddaru", - "FailedLoginAttemptWithUserName": "Cais mewngofnodi wedi methu gan {0}", - "ValueHasBeenAddedToLibrary": "{0} wedi'i hychwanegu at eich llyfrgell gyfryngau", + "FailedLoginAttemptWithUserName": "Cais mewngofnodi wedi methu o {0}", "UserStoppedPlayingItemWithValues": "{0} wedi gorffen chwarae {1} ar {2}", "UserStartedPlayingItemWithValues": "{0} yn chwarae {1} ar {2}", - "UserPolicyUpdatedWithName": "Polisi defnyddiwr wedi'i newid ar gyfer {0}", "UserPasswordChangedWithName": "Cyfrinair wedi'i newid ar gyfer defnyddiwr {0}", "UserOnlineFromDevice": "Mae {0} ar-lein o {1}", "UserOfflineFromDevice": "Mae {0} wedi datgysylltu o {1}", @@ -80,7 +58,6 @@ "UserDeletedWithName": "Defnyddiwr {0} wedi'i ddileu", "UserCreatedWithName": "Defnyddiwr {0} wedi'i greu", "StartupEmbyServerIsLoading": "Gweinydd Jellyfin yn llwytho. Triwch eto mewn ychydig.", - "ServerNameNeedsToBeRestarted": "Mae angen ailddechrau {0}", "PluginUpdatedWithName": "{0} wedi'i ddiweddaru", "PluginUninstalledWithName": "{0} wedi'i ddadosod", "PluginInstalledWithName": "{0} wedi'i osod", @@ -98,13 +75,7 @@ "NotificationOptionApplicationUpdateAvailable": "Diweddariad ap ar gael", "NewVersionIsAvailable": "Mae fersiwn diweddarach o'r gweinydd Jellyfin ar gael.", "NameInstallFailed": "Gosodiad {0} wedi methu", - "MessageApplicationUpdatedTo": "Gweinydd Jellyfin wedi'i ddiweddaru i {0}", - "MessageApplicationUpdated": "Gweinydd Jellyfin wedi'i ddiweddaru", "LabelIpAddressValue": "Cyfeiriad IP: {0}", - "ItemRemovedWithName": "{0} wedi'i dynnu o'r llyfrgell", - "ItemAddedWithName": "{0} wedi'i adio i'r llyfrgell", - "HeaderRecordingGroups": "Grwpiau Recordio", - "HeaderFavoriteSongs": "Ffefryn Ganeuon", "HeaderFavoriteShows": "Ffefryn Shoeau", "HeaderFavoriteEpisodes": "Ffefryn Rhaglenni", "TaskDownloadMissingSubtitlesDescription": "Chwilio'r rhyngrwyd am is-deitlau coll yn seiliedig ar gosodiadau metaddata.", @@ -123,5 +94,12 @@ "TaskRefreshChapterImages": "Echdynnu Lluniau Pennod", "TaskCleanCacheDescription": "Dileu ffeiliau cache nad oes eu hangen ar y system mwyach.", "TaskCleanCache": "Gwaghau Ffolder Cache", - "HearingImpaired": "Nam ar y clyw" + "HearingImpaired": "Nam ar y clyw", + "TaskAudioNormalization": "Gwastatau Sain", + "TaskAudioNormalizationDescription": "Yn sganio ffeiliau am ddata gwastatau sain.", + "TaskRefreshTrickplayImages": "Creuwch lluniau Trickplay", + "TaskRefreshTrickplayImagesDescription": "Creu rhagolygon Trickplay ar gyfer fideos mewn llyfrgelloedd gweithredol.", + "TaskDownloadMissingLyrics": "Lawrlwytho geiriau coll", + "TaskDownloadMissingLyricsDescription": "Lawrlwytho geiriau caneuon", + "TaskExtractMediaSegments": "Sganio Darnau Cyfryngau" } diff --git a/Emby.Server.Implementations/Localization/Core/da.json b/Emby.Server.Implementations/Localization/Core/da.json index bbee38ba5f..6e5532ead9 100644 --- a/Emby.Server.Implementations/Localization/Core/da.json +++ b/Emby.Server.Implementations/Localization/Core/da.json @@ -1,41 +1,24 @@ { - "Albums": "Albummer", "AppDeviceValues": "App: {0}, Enhed: {1}", - "Application": "Applikation", "Artists": "Kunstnere", "AuthenticationSucceededWithUserName": "{0} er logget ind", "Books": "Bøger", - "CameraImageUploadedFrom": "Et nyt kamerabillede er blevet uploadet fra {0}", - "Channels": "Kanaler", "ChapterNameValue": "Kapitel {0}", "Collections": "Samlinger", - "DeviceOfflineWithName": "{0} har afbrudt forbindelsen", - "DeviceOnlineWithName": "{0} er forbundet", "FailedLoginAttemptWithUserName": "Mislykket loginforsøg fra {0}", "Favorites": "Favoritter", "Folders": "Mapper", "Genres": "Genrer", - "HeaderAlbumArtists": "Albumkunstnere", - "HeaderContinueWatching": "Fortsæt afspilning", - "HeaderFavoriteAlbums": "Favoritalbum", - "HeaderFavoriteArtists": "Favoritkunstnere", + "HeaderContinueWatching": "Fortsæt med at se", "HeaderFavoriteEpisodes": "Yndlingsafsnit", "HeaderFavoriteShows": "Yndlingsserier", - "HeaderFavoriteSongs": "Yndlingssange", "HeaderLiveTV": "Live-TV", "HeaderNextUp": "Næste", - "HeaderRecordingGroups": "Optagelsesgrupper", "HomeVideos": "Hjemmevideoer", "Inherit": "Nedarv", - "ItemAddedWithName": "{0} blev tilføjet til biblioteket", - "ItemRemovedWithName": "{0} blev fjernet fra biblioteket", "LabelIpAddressValue": "IP-adresse: {0}", "LabelRunningTimeValue": "Spilletid: {0}", "Latest": "Seneste", - "MessageApplicationUpdated": "Jellyfin Server er blevet opdateret", - "MessageApplicationUpdatedTo": "Jellyfin Server er blevet opdateret til {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Serverkonfiguration sektion {0} er blevet opdateret", - "MessageServerConfigurationUpdated": "Serverkonfigurationen er blevet opdateret", "MixedContent": "Blandet indhold", "Movies": "Film", "Music": "Musik", @@ -61,24 +44,14 @@ "NotificationOptionVideoPlayback": "Videoafspilning påbegyndt", "NotificationOptionVideoPlaybackStopped": "Videoafspilning blev stoppet", "Photos": "Fotos", - "Playlists": "Afspilningslister", - "Plugin": "Plugin", "PluginInstalledWithName": "{0} blev installeret", "PluginUninstalledWithName": "{0} blev afinstalleret", "PluginUpdatedWithName": "{0} blev opdateret", - "ProviderValue": "Udbyder: {0}", "ScheduledTaskFailedWithName": "{0} mislykkedes", - "ScheduledTaskStartedWithName": "{0} påbegyndte", - "ServerNameNeedsToBeRestarted": "{0} skal genstartes", "Shows": "Serier", - "Songs": "Sange", "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", - "System": "System", - "TvShows": "TV-serier", - "User": "Bruger", + "TvShows": "Serier", "UserCreatedWithName": "Bruger {0} er blevet oprettet", "UserDeletedWithName": "Brugeren {0} er nu slettet", "UserDownloadingItemWithValues": "{0} henter {1}", @@ -86,16 +59,13 @@ "UserOfflineFromDevice": "{0} har afbrudt fra {1}", "UserOnlineFromDevice": "{0} er online fra {1}", "UserPasswordChangedWithName": "Adgangskode er ændret for brugeren {0}", - "UserPolicyUpdatedWithName": "Brugerpolitikken er blevet opdateret for {0}", "UserStartedPlayingItemWithValues": "{0} afspiller {1} på {2}", "UserStoppedPlayingItemWithValues": "{0} har afsluttet afspilning af {1} på {2}", - "ValueHasBeenAddedToLibrary": "{0} er blevet tilføjet til dit mediebibliotek", - "ValueSpecialEpisodeName": "Special - {0}", "VersionNumber": "Version {0}", "TaskDownloadMissingSubtitlesDescription": "Søger på internettet efter manglende undertekster baseret på metadata-konfigurationen.", "TaskDownloadMissingSubtitles": "Hent manglende undertekster", "TaskUpdatePluginsDescription": "Henter og installerer opdateringer for plugins, som er konfigurerede til at blive opdateret automatisk.", - "TaskUpdatePlugins": "Opdater plugins", + "TaskUpdatePlugins": "Opdatér plugins", "TaskCleanLogsDescription": "Sletter log-filer som er mere end {0} dage gamle.", "TaskCleanLogs": "Ryd log-mappe", "TaskRefreshLibraryDescription": "Scanner dit mediebibliotek for nye filer og opdateret metadata.", @@ -109,10 +79,10 @@ "TaskRefreshChapterImages": "Udtræk kapitelbilleder", "TaskRefreshChapterImagesDescription": "Laver miniaturebilleder for videoer, der har kapitler.", "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", + "TaskRefreshChannels": "Opdatér kanaler", + "TaskCleanTranscodeDescription": "Fjerner transkodningsfiler, som er mere end 1 dag gamle.", + "TaskCleanTranscode": "Tøm transkodningsmappen", + "TaskRefreshPeople": "Opdatér personer", "TaskRefreshPeopleDescription": "Opdaterer metadata for skuespillere og instruktører i dit mediebibliotek.", "TaskCleanActivityLogDescription": "Sletter linjer i aktivitetsloggen ældre end den konfigurerede alder.", "TaskCleanActivityLog": "Ryd aktivitetslog", @@ -120,23 +90,36 @@ "Forced": "Tvunget", "Default": "Standard", "TaskOptimizeDatabaseDescription": "Komprimerer databasen for at frigøre plads. Denne handling køres efter at have scannet mediebiblioteket, eller efter at have lavet ændringer til databasen.", - "TaskOptimizeDatabase": "Optimer database", + "TaskOptimizeDatabase": "Optimér database", "TaskKeyframeExtractorDescription": "Udtrækker rammer fra videofiler for at lave mere præcise HLS-playlister. Denne opgave kan tage lang tid.", "TaskKeyframeExtractor": "Udtræk nøglerammer", "External": "Ekstern", "HearingImpaired": "Hørehæmmet", "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 lydnormalisering.", "TaskAudioNormalization": "Lydnormalisering", - "TaskDownloadMissingLyricsDescription": "Søger på internettet efter manglende sangtekster baseret på metadata-konfigurationen", + "TaskDownloadMissingLyricsDescription": "Download sangtekster", "TaskDownloadMissingLyrics": "Hent manglende sangtekster", "TaskExtractMediaSegments": "Scan for mediesegmenter", "TaskMoveTrickplayImages": "Migrer billedelokationer for trickplay-billeder", "TaskMoveTrickplayImagesDescription": "Flyt eksisterende trickplay-billeder jævnfør biblioteksindstillinger.", "TaskExtractMediaSegmentsDescription": "Udtrækker eller henter mediesegmenter fra plugins som understøtter MediaSegment.", "CleanupUserDataTask": "Brugerdata oprydningsopgave", - "CleanupUserDataTaskDescription": "Rydder alle brugerdata (eks. visning- og favoritstatus) fra medier, der har været utilgængelige i mindst 90 dage." + "CleanupUserDataTaskDescription": "Rydder alle brugerdata (eks. visning- og favoritstatus) fra medier, der har været utilgængelige i mindst 90 dage.", + "LyricDownloadFailureFromForItem": "Sangtekster kunne ikke downloades fra {0} til {1}", + "Original": "Original", + "NameExtraBehindTheScenes": "Bag scenerne", + "NameExtraClip": "Klip", + "NameExtraDeletedScene": "Slettet scene", + "NameExtraFeaturette": "Featurette", + "NameExtraInterview": "Interview", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Smagsprøve", + "NameExtraScene": "Scene", + "NameExtraShort": "Kort", + "NameExtraThemeSong": "Temasang", + "NameExtraThemeVideo": "Temavideo", + "NameExtraTrailer": "Trailer", + "NameExtraUnknown": "Ekstra" } diff --git a/Emby.Server.Implementations/Localization/Core/de.json b/Emby.Server.Implementations/Localization/Core/de.json index e60d03e468..ce5a4c0a6c 100644 --- a/Emby.Server.Implementations/Localization/Core/de.json +++ b/Emby.Server.Implementations/Localization/Core/de.json @@ -1,41 +1,24 @@ { - "Albums": "Alben", "AppDeviceValues": "App: {0}, Gerät: {1}", - "Application": "Anwendung", - "Artists": "Interpreten", + "Artists": "Künstler", "AuthenticationSucceededWithUserName": "{0} erfolgreich authentifiziert", "Books": "Bücher", - "CameraImageUploadedFrom": "Ein neues Kamerabild wurde von {0} hochgeladen", - "Channels": "Kanäle", "ChapterNameValue": "Kapitel {0}", "Collections": "Sammlungen", - "DeviceOfflineWithName": "{0} hat die Verbindung getrennt", - "DeviceOnlineWithName": "{0} ist verbunden", - "FailedLoginAttemptWithUserName": "Fehlgeschlagener Anmeldeversuch von {0}", + "FailedLoginAttemptWithUserName": "Anmeldung von {0} fehlgeschlagen", "Favorites": "Favoriten", "Folders": "Verzeichnisse", "Genres": "Genres", - "HeaderAlbumArtists": "Album-Interpreten", "HeaderContinueWatching": "Weiterschauen", - "HeaderFavoriteAlbums": "Lieblingsalben", - "HeaderFavoriteArtists": "Lieblingsinterpreten", - "HeaderFavoriteEpisodes": "Lieblingsepisoden", + "HeaderFavoriteEpisodes": "Lieblingsfolgen", "HeaderFavoriteShows": "Lieblingsserien", - "HeaderFavoriteSongs": "Lieblingslieder", "HeaderLiveTV": "Live TV", "HeaderNextUp": "Als Nächstes", - "HeaderRecordingGroups": "Aufnahme-Gruppen", "HomeVideos": "Heimvideos", "Inherit": "Vererben", - "ItemAddedWithName": "{0} wurde der Bibliothek hinzugefügt", - "ItemRemovedWithName": "{0} wurde aus der Bibliothek entfernt", "LabelIpAddressValue": "IP-Adresse: {0}", "LabelRunningTimeValue": "Laufzeit: {0}", "Latest": "Neueste", - "MessageApplicationUpdated": "Jellyfin-Server wurde aktualisiert", - "MessageApplicationUpdatedTo": "Jellyfin-Server wurde auf Version {0} aktualisiert", - "MessageNamedServerConfigurationUpdatedWithValue": "Der Server-Einstellungsbereich {0} wurde aktualisiert", - "MessageServerConfigurationUpdated": "Servereinstellungen wurden aktualisiert", "MixedContent": "Gemischte Inhalte", "Movies": "Filme", "Music": "Musik", @@ -46,7 +29,7 @@ "NewVersionIsAvailable": "Eine neue Jellyfin-Serverversion steht zum Download bereit.", "NotificationOptionApplicationUpdateAvailable": "Anwendungsaktualisierung verfügbar", "NotificationOptionApplicationUpdateInstalled": "Anwendungsaktualisierung installiert", - "NotificationOptionAudioPlayback": "Audiowiedergabe gestartet", + "NotificationOptionAudioPlayback": "Audio wird abgespielt", "NotificationOptionAudioPlaybackStopped": "Audiowiedergabe gestoppt", "NotificationOptionCameraImageUploaded": "Foto hochgeladen", "NotificationOptionInstallationFailed": "Installation fehlgeschlagen", @@ -57,65 +40,52 @@ "NotificationOptionPluginUpdateInstalled": "Pluginaktualisierung installiert", "NotificationOptionServerRestartRequired": "Serverneustart notwendig", "NotificationOptionTaskFailed": "Geplante Aufgabe fehlgeschlagen", - "NotificationOptionUserLockedOut": "Benutzer ausgeschlossen", - "NotificationOptionVideoPlayback": "Videowiedergabe gestartet", + "NotificationOptionUserLockedOut": "Benutzer gesperrt", + "NotificationOptionVideoPlayback": "Video wird abgespielt", "NotificationOptionVideoPlaybackStopped": "Videowiedergabe gestoppt", "Photos": "Fotos", - "Playlists": "Wiedergabelisten", - "Plugin": "Plugin", "PluginInstalledWithName": "{0} wurde installiert", "PluginUninstalledWithName": "{0} wurde deinstalliert", "PluginUpdatedWithName": "{0} wurde aktualisiert", - "ProviderValue": "Anbieter: {0}", "ScheduledTaskFailedWithName": "{0} ist fehlgeschlagen", - "ScheduledTaskStartedWithName": "{0} wurde gestartet", - "ServerNameNeedsToBeRestarted": "{0} muss neu gestartet werden", "Shows": "Serien", - "Songs": "Lieder", "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": "Serien", - "User": "Benutzer", "UserCreatedWithName": "Benutzer {0} wurde erstellt", "UserDeletedWithName": "Benutzer {0} wurde gelöscht", "UserDownloadingItemWithValues": "{0} lädt {1} herunter", - "UserLockedOutWithName": "Benutzer {0} wurde ausgeschlossen", + "UserLockedOutWithName": "Benutzer {0} wurde gesperrt", "UserOfflineFromDevice": "{0} wurde getrennt von {1}", "UserOnlineFromDevice": "{0} ist online von {1}", "UserPasswordChangedWithName": "Das Passwort für Benutzer {0} wurde geändert", - "UserPolicyUpdatedWithName": "Benutzerrichtlinie von {0} wurde aktualisiert", "UserStartedPlayingItemWithValues": "{0} hat die Wiedergabe von {1} auf {2} gestartet", "UserStoppedPlayingItemWithValues": "{0} hat die Wiedergabe von {1} auf {2} beendet", - "ValueHasBeenAddedToLibrary": "{0} wurde deiner Bibliothek hinzugefügt", - "ValueSpecialEpisodeName": "Extra – {0}", "VersionNumber": "Version {0}", "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", + "TaskCleanTranscode": "Transkodierungsverzeichnis leeren", "TaskUpdatePluginsDescription": "Lädt Updates für Plugins herunter, welche für automatische Updates konfiguriert sind und installiert diese.", "TaskUpdatePlugins": "Plugins aktualisieren", "TaskRefreshPeopleDescription": "Aktualisiert Metadaten für Schauspieler und Regisseure in deinen Bibliotheken.", "TaskRefreshPeople": "Personen aktualisieren", "TaskCleanLogsDescription": "Lösche Log-Dateien, die älter als {0} Tage sind.", - "TaskCleanLogs": "Log-Verzeichnis aufräumen", - "TaskRefreshLibraryDescription": "Durchsucht alle Bibliotheken nach neu hinzugefügten Dateien und aktualisiert Metadaten.", + "TaskCleanLogs": "Protokollverzeichnis leeren", + "TaskRefreshLibraryDescription": "Durchsucht deine Medienbibliothek nach neuen Dateien und aktualisiert Metadaten.", "TaskRefreshLibrary": "Medien-Bibliothek scannen", - "TaskRefreshChapterImagesDescription": "Erstellt Vorschaubilder für Videos, die Kapitel besitzen.", - "TaskRefreshChapterImages": "Kapitel-Bilder extrahieren", - "TaskCleanCacheDescription": "Löscht vom System nicht mehr benötigte Zwischenspeicherdateien.", - "TaskCleanCache": "Zwischenspeicher-Verzeichnis aufräumen", + "TaskRefreshChapterImagesDescription": "Erstellt Vorschaubilder für Videokapitel.", + "TaskRefreshChapterImages": "Kapitelvorschauen erstellen", + "TaskCleanCacheDescription": "Löscht Cache-Dateien, die vom System nicht mehr benötigt werden.", + "TaskCleanCache": "Cache-Verzeichnis leeren", "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ätsprotokolle aufräumen", + "TaskCleanActivityLog": "Aktivitätsverlauf bereinigen", "Undefined": "Undefiniert", "Forced": "Erzwungen", "Default": "Standard", @@ -127,8 +97,6 @@ "HearingImpaired": "Hörgeschädigt", "TaskRefreshTrickplayImages": "Trickplay-Bilder generieren", "TaskRefreshTrickplayImagesDescription": "Erstellt ein Trickplay-Vorschauen für Videos in aktivierten Bibliotheken.", - "TaskCleanCollectionsAndPlaylists": "Sammlungen und Playlisten aufräumen", - "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", @@ -138,5 +106,20 @@ "TaskMoveTrickplayImages": "Verzeichnis für Trickplay-Bilder migrieren", "TaskMoveTrickplayImagesDescription": "Trickplay-Bilder werden entsprechend der Bibliothekseinstellungen verschoben.", "CleanupUserDataTask": "Aufgabe zur Bereinigung von Benutzerdaten", - "CleanupUserDataTaskDescription": "Löscht alle Benutzerdaten (Abspielstatus, Favoritenstatus, usw.) von Medien, die seit mindestens 90 Tagen nicht mehr vorhanden sind." + "CleanupUserDataTaskDescription": "Löscht alle Benutzerdaten (Abspielstatus, Favoritenstatus, usw.) von Medien, die seit mindestens 90 Tagen nicht mehr vorhanden sind.", + "Original": "Original", + "LyricDownloadFailureFromForItem": "Fehler beim Download der Songtexte von {0} für {1}", + "NameExtraBehindTheScenes": "Behind The Scenes", + "NameExtraDeletedScene": "Entfernte Szene", + "NameExtraInterview": "Interview", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Ausschnitt", + "NameExtraScene": "Szene", + "NameExtraShort": "Kurzfilm", + "NameExtraThemeSong": "Titellied", + "NameExtraThemeVideo": "Titelvideo", + "NameExtraTrailer": "Trailer", + "NameExtraUnknown": "Extra", + "NameExtraClip": "Clip", + "NameExtraFeaturette": "Hinter den Kulissen" } diff --git a/Emby.Server.Implementations/Localization/Core/el.json b/Emby.Server.Implementations/Localization/Core/el.json index 2ba2085da2..ee03c471ea 100644 --- a/Emby.Server.Implementations/Localization/Core/el.json +++ b/Emby.Server.Implementations/Localization/Core/el.json @@ -1,41 +1,24 @@ { - "Albums": "Άλμπουμ", "AppDeviceValues": "Εφαρμογή: {0}, Συσκευή: {1}", - "Application": "Εφαρμογή", "Artists": "Καλλιτέχνες", "AuthenticationSucceededWithUserName": "Ο χρήστης {0} επαληθεύτηκε επιτυχώς", "Books": "Βιβλία", - "CameraImageUploadedFrom": "Μια νέα φωτογραφία φορτώθηκε από {0}", - "Channels": "Κανάλια", "ChapterNameValue": "Κεφάλαιο {0}", "Collections": "Συλλογές", - "DeviceOfflineWithName": "Ο/Η {0} αποσυνδέθηκε", - "DeviceOnlineWithName": "Ο/Η {0} συνδέθηκε", "FailedLoginAttemptWithUserName": "Αποτυχία προσπάθειας σύνδεσης από {0}", "Favorites": "Αγαπημένα", "Folders": "Φάκελοι", "Genres": "Είδη", - "HeaderAlbumArtists": "Καλλιτέχνες άλμπουμ", "HeaderContinueWatching": "Συνεχίστε την παρακολούθηση", - "HeaderFavoriteAlbums": "Αγαπημένα Άλμπουμ", - "HeaderFavoriteArtists": "Αγαπημένοι Καλλιτέχνες", "HeaderFavoriteEpisodes": "Αγαπημένα Επεισόδια", "HeaderFavoriteShows": "Αγαπημένες Σειρές", - "HeaderFavoriteSongs": "Αγαπημένα Τραγούδια", "HeaderLiveTV": "Ζωντανή Τηλεόραση", "HeaderNextUp": "Επόμενο", - "HeaderRecordingGroups": "Ομάδες Ηχογράφησης", "HomeVideos": "Προσωπικά Βίντεο", "Inherit": "Κληρονόμηση", - "ItemAddedWithName": "Το {0} προστέθηκε στη βιβλιοθήκη", - "ItemRemovedWithName": "Το {0} διαγράφτηκε από τη βιβλιοθήκη", "LabelIpAddressValue": "Διεύθυνση IP: {0}", "LabelRunningTimeValue": "Διάρκεια: {0}", "Latest": "Πρόσφατα", - "MessageApplicationUpdated": "Ο διακομιστής Jellyfin έχει ενημερωθεί", - "MessageApplicationUpdatedTo": "Ο διακομιστής Jellyfin αναβαθμίστηκε στην έκδοση {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Η ενότητα {0} ρύθμισης παραμέτρων του διακομιστή έχει ενημερωθεί", - "MessageServerConfigurationUpdated": "Η ρύθμιση παραμέτρων του διακομιστή έχει ενημερωθεί", "MixedContent": "Ανάμεικτο Περιεχόμενο", "Movies": "Ταινίες", "Music": "Μουσική", @@ -61,24 +44,14 @@ "NotificationOptionVideoPlayback": "Η αναπαραγωγή βίντεο ξεκίνησε", "NotificationOptionVideoPlaybackStopped": "Η αναπαραγωγή βίντεο σταμάτησε", "Photos": "Φωτογραφίες", - "Playlists": "Λίστες αναπαραγωγής", - "Plugin": "Πρόσθετο", "PluginInstalledWithName": "Το {0} εγκαταστάθηκε", "PluginUninstalledWithName": "Το {0} έχει απεγκατασταθεί", "PluginUpdatedWithName": "Το {0} ενημερώθηκε", - "ProviderValue": "Πάροχος: {0}", "ScheduledTaskFailedWithName": "{0} αποτυχία", - "ScheduledTaskStartedWithName": "{0} ξεκίνησε", - "ServerNameNeedsToBeRestarted": "{0} χρειάζεται επανεκκίνηση", "Shows": "Σειρές", - "Songs": "Τραγούδια", "StartupEmbyServerIsLoading": "Ο διακομιστής Jellyfin φορτώνει. Περιμένετε λίγο και δοκιμάστε ξανά.", - "SubtitleDownloadFailureForItem": "Οι υπότιτλοι απέτυχαν να κατέβουν για {0}", - "SubtitleDownloadFailureFromForItem": "Αποτυχίες μεταφόρτωσης υποτίτλων από {0} για {1}", - "Sync": "Συγχρονισμός", - "System": "Σύστημα", + "SubtitleDownloadFailureFromForItem": "Αποτυχία λήψης υποτίτλων από {0} για {1}", "TvShows": "Τηλεοπτικές Σειρές", - "User": "Χρήστης", "UserCreatedWithName": "Ο χρήστης {0} δημιουργήθηκε", "UserDeletedWithName": "Ο χρήστης {0} έχει διαγραφεί", "UserDownloadingItemWithValues": "{0} κατεβάζει {1}", @@ -86,11 +59,8 @@ "UserOfflineFromDevice": "{0} αποσυνδέθηκε από {1}", "UserOnlineFromDevice": "{0} είναι online απο {1}", "UserPasswordChangedWithName": "Ο κωδικός του χρήστη {0} έχει αλλάξει", - "UserPolicyUpdatedWithName": "Η πολιτική χρήστη έχει ενημερωθεί για {0}", "UserStartedPlayingItemWithValues": "{0} παίζει {1} σε {2}", "UserStoppedPlayingItemWithValues": "{0} τελείωσε να παίζει {1} σε {2}", - "ValueHasBeenAddedToLibrary": "{0} προστέθηκαν στη βιβλιοθήκη πολυμέσων σας", - "ValueSpecialEpisodeName": "Σπέσιαλ - {0}", "VersionNumber": "Έκδοση {0}", "TaskRefreshPeople": "Ανανέωση Ατόμων", "TaskCleanLogsDescription": "Διαγράφει αρχεία καταγραφής που είναι πάνω από {0} ημέρες.", @@ -129,8 +99,6 @@ "TaskRefreshTrickplayImagesDescription": "Δημιουργεί προεπισκοπήσεις trickplay για βίντεο σε ενεργοποιημένες βιβλιοθήκες.", "TaskAudioNormalization": "Ομοιομορφία ήχου", "TaskAudioNormalizationDescription": "Ανίχνευση αρχείων για δεδομένα ομοιομορφίας ήχου.", - "TaskCleanCollectionsAndPlaylists": "Καθαρισμός συλλογών και λιστών αναπαραγωγής", - "TaskCleanCollectionsAndPlaylistsDescription": "Αφαιρούνται στοιχεία από τις συλλογές και τις λίστες αναπαραγωγής που δεν υπάρχουν πλέον.", "TaskMoveTrickplayImages": "Αλλαγή τοποθεσίας εικόνων Trickplay", "TaskDownloadMissingLyrics": "Λήψη στίχων που λείπουν", "TaskMoveTrickplayImagesDescription": "Μετακινεί τα υπάρχοντα αρχεία trickplay σύμφωνα με τις ρυθμίσεις της βιβλιοθήκης.", @@ -138,5 +106,20 @@ "TaskExtractMediaSegments": "Σάρωση τμημάτων πολυμέσων", "TaskExtractMediaSegmentsDescription": "Εξάγει ή βρίσκει τμήματα πολυμέσων από επεκτάσεις που χρησιμοποιούν το MediaSegment.", "CleanupUserDataTaskDescription": "Καθαρίζει όλα τα δεδομένα χρήστη (κατάσταση παρακολούθησης, κατάσταση αγαπημένων κ.λπ.) από πολυμέσα που δεν υπάρχουν πλέον για τουλάχιστον 90 ημέρες.", - "CleanupUserDataTask": "Εργασία εκκαθάρισης δεδομένων χρήστη" + "CleanupUserDataTask": "Εργασία εκκαθάρισης δεδομένων χρήστη", + "LyricDownloadFailureFromForItem": "Αποτυχία λήψης στίχων από {0} για {1}", + "Original": "Πρωτότυπο", + "NameExtraBehindTheScenes": "Πίσω από τις Σκηνές", + "NameExtraDeletedScene": "Διεγραμμένη Σκηνή", + "NameExtraFeaturette": "Πρόσθετα βίντεο", + "NameExtraInterview": "Συνέντευξη", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Δείγμα", + "NameExtraScene": "Σκηνή", + "NameExtraShort": "Βίντεο μικρού μήκους", + "NameExtraThemeSong": "Θεματικό Τραγούδι", + "NameExtraThemeVideo": "Θεματικό Βίντεο", + "NameExtraTrailer": "τρέιλερ ταινίας", + "NameExtraUnknown": "Πρόσθετα", + "NameExtraClip": "Απόσπασμα" } diff --git a/Emby.Server.Implementations/Localization/Core/en-GB.json b/Emby.Server.Implementations/Localization/Core/en-GB.json index 720f550b33..a053fc2da9 100644 --- a/Emby.Server.Implementations/Localization/Core/en-GB.json +++ b/Emby.Server.Implementations/Localization/Core/en-GB.json @@ -1,48 +1,31 @@ { - "Albums": "Albums", "AppDeviceValues": "App: {0}, Device: {1}", - "Application": "Application", "Artists": "Artists", "AuthenticationSucceededWithUserName": "{0} successfully authenticated", "Books": "Books", - "CameraImageUploadedFrom": "A new camera image has been uploaded from {0}", - "Channels": "Channels", "ChapterNameValue": "Chapter {0}", "Collections": "Collections", - "DeviceOfflineWithName": "{0} has disconnected", - "DeviceOnlineWithName": "{0} is connected", "FailedLoginAttemptWithUserName": "Failed login attempt from {0}", "Favorites": "Favourites", "Folders": "Folders", "Genres": "Genres", - "HeaderAlbumArtists": "Album artists", "HeaderContinueWatching": "Continue Watching", - "HeaderFavoriteAlbums": "Favourite Albums", - "HeaderFavoriteArtists": "Favourite Artists", "HeaderFavoriteEpisodes": "Favourite Episodes", "HeaderFavoriteShows": "Favourite Shows", - "HeaderFavoriteSongs": "Favourite Songs", "HeaderLiveTV": "Live TV", "HeaderNextUp": "Next Up", - "HeaderRecordingGroups": "Recording Groups", "HomeVideos": "Home Videos", "Inherit": "Inherit", - "ItemAddedWithName": "{0} was added to the library", - "ItemRemovedWithName": "{0} was removed from the library", "LabelIpAddressValue": "IP address: {0}", "LabelRunningTimeValue": "Running time: {0}", "Latest": "Latest", - "MessageApplicationUpdated": "Jellyfin Server has been updated", - "MessageApplicationUpdatedTo": "Jellyfin Server has been updated to {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Server configuration section {0} has been updated", - "MessageServerConfigurationUpdated": "Server configuration has been updated", "MixedContent": "Mixed content", "Movies": "Movies", "Music": "Music", "MusicVideos": "Music Videos", "NameInstallFailed": "{0} installation failed", - "NameSeasonNumber": "Season {0}", - "NameSeasonUnknown": "Season Unknown", + "NameSeasonNumber": "Series {0}", + "NameSeasonUnknown": "Series Unknown", "NewVersionIsAvailable": "A new version of Jellyfin Server is available for download.", "NotificationOptionApplicationUpdateAvailable": "Application update available", "NotificationOptionApplicationUpdateInstalled": "Application update installed", @@ -61,24 +44,14 @@ "NotificationOptionVideoPlayback": "Video playback started", "NotificationOptionVideoPlaybackStopped": "Video playback stopped", "Photos": "Photos", - "Playlists": "Playlists", - "Plugin": "Plugin", "PluginInstalledWithName": "{0} was installed", "PluginUninstalledWithName": "{0} was uninstalled", "PluginUpdatedWithName": "{0} was updated", - "ProviderValue": "Provider: {0}", "ScheduledTaskFailedWithName": "{0} failed", - "ScheduledTaskStartedWithName": "{0} started", - "ServerNameNeedsToBeRestarted": "{0} needs to be restarted", "Shows": "Shows", - "Songs": "Songs", "StartupEmbyServerIsLoading": "Jellyfin Server is loading. Please try again shortly.", - "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", "SubtitleDownloadFailureFromForItem": "Subtitles failed to download from {0} for {1}", - "Sync": "Sync", - "System": "System", "TvShows": "TV Shows", - "User": "User", "UserCreatedWithName": "User {0} has been created", "UserDeletedWithName": "User {0} has been deleted", "UserDownloadingItemWithValues": "{0} is downloading {1}", @@ -86,11 +59,8 @@ "UserOfflineFromDevice": "{0} has disconnected from {1}", "UserOnlineFromDevice": "{0} is online from {1}", "UserPasswordChangedWithName": "Password has been changed for user {0}", - "UserPolicyUpdatedWithName": "User policy has been updated for {0}", "UserStartedPlayingItemWithValues": "{0} has started playing {1}", "UserStoppedPlayingItemWithValues": "{0} has finished playing {1} on {2}", - "ValueHasBeenAddedToLibrary": "{0} has been added to your media library", - "ValueSpecialEpisodeName": "Special - {0}", "VersionNumber": "Version {0}", "TaskDownloadMissingSubtitlesDescription": "Searches the internet for missing subtitles based on metadata configuration.", "TaskDownloadMissingSubtitles": "Download missing subtitles", @@ -127,8 +97,6 @@ "HearingImpaired": "Hearing Impaired", "TaskRefreshTrickplayImages": "Generate Trickplay Images", "TaskRefreshTrickplayImagesDescription": "Creates trickplay previews for videos in enabled libraries.", - "TaskCleanCollectionsAndPlaylists": "Clean up collections and playlists", - "TaskCleanCollectionsAndPlaylistsDescription": "Removes items from collections and playlists that no longer exist.", "TaskAudioNormalization": "Audio Normalisation", "TaskAudioNormalizationDescription": "Scans files for audio normalisation data.", "TaskDownloadMissingLyrics": "Download missing lyrics", @@ -138,5 +106,20 @@ "TaskMoveTrickplayImages": "Migrate Trickplay Image Location", "TaskMoveTrickplayImagesDescription": "Moves existing trickplay files according to the library settings.", "CleanupUserDataTask": "User data cleanup task", - "CleanupUserDataTaskDescription": "Cleans all user data (Watch state, favourite status etc) from media that is no longer present for at least 90 days." + "CleanupUserDataTaskDescription": "Cleans all user data (Watch state, favourite status etc) from media that is no longer present for at least 90 days.", + "LyricDownloadFailureFromForItem": "Lyrics failed to download from {0} for {1}", + "Original": "Original", + "NameExtraBehindTheScenes": "Behind The Scenes", + "NameExtraClip": "Clip", + "NameExtraDeletedScene": "Deleted Scene", + "NameExtraFeaturette": "Featurette", + "NameExtraInterview": "Interview", + "NameExtraSample": "Sample", + "NameExtraScene": "Scene", + "NameExtraShort": "Short", + "NameExtraThemeSong": "Theme Song", + "NameExtraThemeVideo": "Theme Video", + "NameExtraTrailer": "Trailer", + "NameExtraUnknown": "Extra", + "NameExtraNumbered": "{0} {1}" } diff --git a/Emby.Server.Implementations/Localization/Core/en-US.json b/Emby.Server.Implementations/Localization/Core/en-US.json index c09d5af96c..578c85da9d 100644 --- a/Emby.Server.Implementations/Localization/Core/en-US.json +++ b/Emby.Server.Implementations/Localization/Core/en-US.json @@ -1,49 +1,46 @@ { - "Albums": "Albums", "AppDeviceValues": "App: {0}, Device: {1}", - "Application": "Application", "Artists": "Artists", "AuthenticationSucceededWithUserName": "{0} successfully authenticated", "Books": "Books", - "CameraImageUploadedFrom": "A new camera image has been uploaded from {0}", - "Channels": "Channels", "ChapterNameValue": "Chapter {0}", "Collections": "Collections", "Default": "Default", - "DeviceOfflineWithName": "{0} has disconnected", - "DeviceOnlineWithName": "{0} is connected", "External": "External", "FailedLoginAttemptWithUserName": "Failed login attempt from {0}", "Favorites": "Favorites", "Folders": "Folders", "Forced": "Forced", "Genres": "Genres", - "HeaderAlbumArtists": "Album artists", "HeaderContinueWatching": "Continue Watching", - "HeaderFavoriteAlbums": "Favorite Albums", - "HeaderFavoriteArtists": "Favorite Artists", "HeaderFavoriteEpisodes": "Favorite Episodes", "HeaderFavoriteShows": "Favorite Shows", - "HeaderFavoriteSongs": "Favorite Songs", "HeaderLiveTV": "Live TV", "HeaderNextUp": "Next Up", - "HeaderRecordingGroups": "Recording Groups", "HearingImpaired": "Hearing Impaired", "HomeVideos": "Home Videos", "Inherit": "Inherit", - "ItemAddedWithName": "{0} was added to the library", - "ItemRemovedWithName": "{0} was removed from the library", "LabelIpAddressValue": "IP address: {0}", "LabelRunningTimeValue": "Running time: {0}", "Latest": "Latest", - "MessageApplicationUpdated": "Jellyfin Server has been updated", - "MessageApplicationUpdatedTo": "Jellyfin Server has been updated to {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Server configuration section {0} has been updated", - "MessageServerConfigurationUpdated": "Server configuration has been updated", + "LyricDownloadFailureFromForItem": "Lyrics failed to download from {0} for {1}", "MixedContent": "Mixed content", "Movies": "Movies", "Music": "Music", "MusicVideos": "Music Videos", + "NameExtraBehindTheScenes": "Behind The Scenes", + "NameExtraClip": "Clip", + "NameExtraDeletedScene": "Deleted Scene", + "NameExtraFeaturette": "Featurette", + "NameExtraInterview": "Interview", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Sample", + "NameExtraScene": "Scene", + "NameExtraShort": "Short", + "NameExtraThemeSong": "Theme Song", + "NameExtraThemeVideo": "Theme Video", + "NameExtraTrailer": "Trailer", + "NameExtraUnknown": "Extra", "NameInstallFailed": "{0} installation failed", "NameSeasonNumber": "Season {0}", "NameSeasonUnknown": "Season Unknown", @@ -64,25 +61,17 @@ "NotificationOptionUserLockedOut": "User locked out", "NotificationOptionVideoPlayback": "Video playback started", "NotificationOptionVideoPlaybackStopped": "Video playback stopped", + "Original": "Original", "Photos": "Photos", - "Playlists": "Playlists", - "Plugin": "Plugin", "PluginInstalledWithName": "{0} was installed", "PluginUninstalledWithName": "{0} was uninstalled", "PluginUpdatedWithName": "{0} was updated", - "ProviderValue": "Provider: {0}", "ScheduledTaskFailedWithName": "{0} failed", - "ScheduledTaskStartedWithName": "{0} started", - "ServerNameNeedsToBeRestarted": "{0} needs to be restarted", "Shows": "Shows", - "Songs": "Songs", "StartupEmbyServerIsLoading": "Jellyfin Server is loading. Please try again shortly.", "SubtitleDownloadFailureFromForItem": "Subtitles failed to download from {0} for {1}", - "Sync": "Sync", - "System": "System", "TvShows": "TV Shows", "Undefined": "Undefined", - "User": "User", "UserCreatedWithName": "User {0} has been created", "UserDeletedWithName": "User {0} has been deleted", "UserDownloadingItemWithValues": "{0} is downloading {1}", @@ -90,11 +79,8 @@ "UserOfflineFromDevice": "{0} has disconnected from {1}", "UserOnlineFromDevice": "{0} is online from {1}", "UserPasswordChangedWithName": "Password has been changed for user {0}", - "UserPolicyUpdatedWithName": "User policy has been updated for {0}", "UserStartedPlayingItemWithValues": "{0} is playing {1} on {2}", "UserStoppedPlayingItemWithValues": "{0} has finished playing {1} on {2}", - "ValueHasBeenAddedToLibrary": "{0} has been added to your media library", - "ValueSpecialEpisodeName": "Special - {0}", "VersionNumber": "Version {0}", "TasksMaintenanceCategory": "Maintenance", "TasksLibraryCategory": "Library", @@ -130,8 +116,6 @@ "TaskOptimizeDatabaseDescription": "Compacts database and truncates free space. Running this task after scanning the library or doing other changes that imply database modifications might improve performance.", "TaskKeyframeExtractor": "Keyframe Extractor", "TaskKeyframeExtractorDescription": "Extracts keyframes from video files to create more precise HLS playlists. This task may run for a long time.", - "TaskCleanCollectionsAndPlaylists": "Clean up collections and playlists", - "TaskCleanCollectionsAndPlaylistsDescription": "Removes items from collections and playlists that no longer exist.", "TaskExtractMediaSegments": "Media Segment Scan", "TaskExtractMediaSegmentsDescription": "Extracts or obtains media segments from MediaSegment enabled plugins.", "TaskMoveTrickplayImages": "Migrate Trickplay Image Location", diff --git a/Emby.Server.Implementations/Localization/Core/eo.json b/Emby.Server.Implementations/Localization/Core/eo.json index 42cce1096f..e59f089459 100644 --- a/Emby.Server.Implementations/Localization/Core/eo.json +++ b/Emby.Server.Implementations/Localization/Core/eo.json @@ -7,35 +7,22 @@ "NameInstallFailed": "{0} instalado fiaskis", "Music": "Muziko", "Movies": "Filmoj", - "ItemRemovedWithName": "{0} forigis el la plurmediteko", - "ItemAddedWithName": "{0} aldonis al la plurmediteko", "HeaderLiveTV": "TV-etero", "HeaderContinueWatching": "Daŭrigi Spektadon", - "HeaderAlbumArtists": "Artistoj de albumo", "Folders": "Dosierujoj", - "DeviceOnlineWithName": "{0} estas konektita", "Default": "Defaŭlte", "Collections": "Kolektoj", "ChapterNameValue": "Ĉapitro {0}", - "Channels": "Kanaloj", "Books": "Libroj", "Artists": "Artistoj", - "Application": "Aplikaĵo", "AppDeviceValues": "Aplikaĵo: {0}, Aparato: {1}", - "Albums": "Albumoj", "TasksLibraryCategory": "Plurmediteko", "VersionNumber": "Versio {0}", "UserDownloadingItemWithValues": "{0} elŝutas {1}", "UserCreatedWithName": "Uzanto {0} kreiĝis", - "User": "Uzanto", - "System": "Sistemo", - "Songs": "Kantoj", - "ScheduledTaskStartedWithName": "{0} lanĉis", "ScheduledTaskFailedWithName": "{0} malsukcesis", "PluginUninstalledWithName": "{0} malinstaliĝis", "PluginInstalledWithName": "{0} instaliĝis", - "Plugin": "Kromprogramo", - "Playlists": "Ludlistoj", "Photos": "Fotoj", "NotificationOptionPluginUninstalled": "Kromprogramo malinstaliĝis", "NotificationOptionNewLibraryContent": "Nova enhavo aldoniĝis", @@ -43,36 +30,28 @@ "MusicVideos": "Muzikvideoj", "LabelIpAddressValue": "IP-adreso: {0}", "Genres": "Ĝenroj", - "DeviceOfflineWithName": "{0} malkonektis", - "HeaderFavoriteArtists": "Favorataj Artistoj", "Shows": "Serioj", "HeaderFavoriteShows": "Favorataj Serioj", "TvShows": "TV-serioj", "Favorites": "Favorataj", "TaskCleanLogs": "Purigi Ĵurnalan Katalogon", "TaskRefreshLibrary": "Skani Plurmeditekon", - "ValueSpecialEpisodeName": "Speciala - {0}", "TaskOptimizeDatabase": "Optimumigi datenbazon", "TaskRefreshChannels": "Refreŝigi Kanalojn", "TaskUpdatePlugins": "Ĝisdatigi Kromprogramojn", "TaskRefreshPeople": "Refreŝigi Homojn", "TasksChannelsCategory": "Interretaj Kanaloj", - "ProviderValue": "Provizanto: {0}", "NotificationOptionPluginError": "Kromprogramo malsukcesis", "MixedContent": "Miksita enhavo", "TasksApplicationCategory": "Aplikaĵo", "TasksMaintenanceCategory": "Prizorgado", "Undefined": "Nedifinita", - "Sync": "Sinkronigo", "Latest": "Plej novaj", "Inherit": "Hereda", "HomeVideos": "Hejmaj Videoj", "HeaderNextUp": "Sekva Plue", - "HeaderFavoriteSongs": "Favorataj Kantoj", "HeaderFavoriteEpisodes": "Favorataj Epizodoj", - "HeaderFavoriteAlbums": "Favorataj Albumoj", "Forced": "Forcita", - "ServerNameNeedsToBeRestarted": "{0} devas esti relanĉita", "NotificationOptionVideoPlayback": "La videoludado lanĉis", "NotificationOptionServerRestartRequired": "Servila relanĉigo bezonata", "TaskOptimizeDatabaseDescription": "Kompaktigas datenbazon kaj trunkas liberan lokon. Lanĉi ĉi tiun taskon post la plurmediteka skanado aŭ fari aliajn ŝanĝojn, kiuj implicas datenbazajn modifojn, povus plibonigi rendimenton.", @@ -85,22 +64,16 @@ "TaskCleanCacheDescription": "Forigas stapla dosierojn ne plu necesajn de la sistemo.", "TaskCleanActivityLogDescription": "Forigas aktivecan ĵurnalaĵojn pli malnovajn ol la agordita aĝo.", "TaskCleanTranscodeDescription": "Forigas transkodajn dosierojn aĝajn pli ol unu tagon.", - "ValueHasBeenAddedToLibrary": "{0} estis aldonita al via plurmediteko", "SubtitleDownloadFailureFromForItem": "Subtekstoj malsukcesis elŝuti de {0} por {1}", "StartupEmbyServerIsLoading": "Jellyfin Server ŝarĝas. Provi denove baldaŭ.", "TaskRefreshChapterImagesDescription": "Kreas bildetojn por videoj kiuj havas ĉapitrojn.", "UserStoppedPlayingItemWithValues": "{0} finis ludi {1} ĉe {2}", - "UserPolicyUpdatedWithName": "Uzanta politiko estis ĝisdatigita por {0}", "UserPasswordChangedWithName": "Pasvorto estis ŝanĝita por uzanto {0}", "UserStartedPlayingItemWithValues": "{0} ludas {1} ĉe {2}", "UserLockedOutWithName": "Uzanto {0} estas elŝlosita", "UserOnlineFromDevice": "{0} estas enreta de {1}", "UserOfflineFromDevice": "{0} malkonektis de {1}", "UserDeletedWithName": "Uzanto {0} estis forigita", - "MessageServerConfigurationUpdated": "Servila agordaro estis ĝisdatigita", - "MessageNamedServerConfigurationUpdatedWithValue": "Servila agorda sekcio {0} estis ĝisdatigita", - "MessageApplicationUpdatedTo": "Jellyfin Server estis ĝisdatigita al {0}", - "MessageApplicationUpdated": "Jellyfin Server estis ĝisdatigita", "TaskRefreshChannelsDescription": "Refreŝigas informon pri interretaj kanaloj.", "TaskDownloadMissingSubtitles": "Elŝuti mankantajn subtekstojn", "TaskCleanTranscode": "Malplenigi Transkodadan Katalogon", @@ -116,9 +89,7 @@ "NotificationOptionApplicationUpdateInstalled": "Aplikaĵa ĝisdatigo instalita", "NotificationOptionApplicationUpdateAvailable": "Ĝisdatigo de aplikaĵo havebla", "LabelRunningTimeValue": "Ludada tempo: {0}", - "HeaderRecordingGroups": "Rikordadaj Grupoj", "FailedLoginAttemptWithUserName": "Malsukcesa ensaluta provo de {0}", - "CameraImageUploadedFrom": "Nova kamera bildo estis alŝutita de {0}", "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", @@ -126,5 +97,9 @@ "TaskAudioNormalizationDescription": "Skanas dosierojn por sonnivelaj normaligaj datumoj.", "TaskRefreshTrickplayImages": "Generi la bildojn por TrickPlay (Antaŭrigardo rapida antaŭen)", "TaskAudioNormalization": "Normaligo Sonnivela", - "HearingImpaired": "Surda" + "HearingImpaired": "Surda", + "NameExtraDeletedScene": "Forigita sceno", + "NameExtraScene": "Sceno", + "NameExtraThemeSong": "Tema Kanto", + "Original": "Originala" } diff --git a/Emby.Server.Implementations/Localization/Core/es-AR.json b/Emby.Server.Implementations/Localization/Core/es-AR.json index 012f793a6c..a30abb9d4e 100644 --- a/Emby.Server.Implementations/Localization/Core/es-AR.json +++ b/Emby.Server.Implementations/Localization/Core/es-AR.json @@ -1,41 +1,24 @@ { - "Albums": "Álbumes", "AppDeviceValues": "Aplicación: {0}, Dispositivo: {1}", - "Application": "Aplicación", "Artists": "Artistas", "AuthenticationSucceededWithUserName": "{0} autenticado correctamente", "Books": "Libros", - "CameraImageUploadedFrom": "Se ha subido una nueva imagen de cámara desde {0}", - "Channels": "Canales", "ChapterNameValue": "Capítulo {0}", "Collections": "Colecciones", - "DeviceOfflineWithName": "{0} se ha desconectado", - "DeviceOnlineWithName": "{0} está conectado", "FailedLoginAttemptWithUserName": "Error al intentar iniciar sesión de {0}", "Favorites": "Favoritos", "Folders": "Carpetas", "Genres": "Géneros", - "HeaderAlbumArtists": "Artistas del álbum", "HeaderContinueWatching": "Seguir viendo", - "HeaderFavoriteAlbums": "Álbumes favoritos", - "HeaderFavoriteArtists": "Artistas favoritos", "HeaderFavoriteEpisodes": "Capítulos favoritos", - "HeaderFavoriteShows": "Programas favoritos", - "HeaderFavoriteSongs": "Canciones favoritas", + "HeaderFavoriteShows": "Series favoritas", "HeaderLiveTV": "TV en vivo", "HeaderNextUp": "Siguiente", - "HeaderRecordingGroups": "Grupos de grabación", "HomeVideos": "Videos caseros", "Inherit": "Heredar", - "ItemAddedWithName": "{0} se ha añadido a la biblioteca", - "ItemRemovedWithName": "{0} ha sido eliminado de la biblioteca", "LabelIpAddressValue": "Dirección IP: {0}", "LabelRunningTimeValue": "Tiempo de funcionamiento: {0}", "Latest": "Últimos", - "MessageApplicationUpdated": "El servidor Jellyfin fue actualizado", - "MessageApplicationUpdatedTo": "Se ha actualizado el servidor Jellyfin a la versión {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Se ha actualizado la sección {0} de la configuración del servidor", - "MessageServerConfigurationUpdated": "Se ha actualizado la configuración del servidor", "MixedContent": "Contenido mezclado", "Movies": "Películas", "Music": "Música", @@ -61,24 +44,14 @@ "NotificationOptionVideoPlayback": "Se inició la reproducción de video", "NotificationOptionVideoPlaybackStopped": "Reproducción de video detenida", "Photos": "Fotos", - "Playlists": "Listas de reproducción", - "Plugin": "Complemento", "PluginInstalledWithName": "{0} fue instalado", "PluginUninstalledWithName": "{0} fue desinstalado", "PluginUpdatedWithName": "{0} fue actualizado", - "ProviderValue": "Proveedor: {0}", "ScheduledTaskFailedWithName": "{0} falló", - "ScheduledTaskStartedWithName": "{0} iniciado", - "ServerNameNeedsToBeRestarted": "{0} necesita ser reiniciado", - "Shows": "Programas", - "Songs": "Canciones", + "Shows": "Series", "StartupEmbyServerIsLoading": "El servidor Jellyfin se está cargando. Vuelve a intentarlo en breve.", - "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", "SubtitleDownloadFailureFromForItem": "Falló la descarga de subtitulos desde {0} para {1}", - "Sync": "Sincronizar", - "System": "Sistema", "TvShows": "Series de TV", - "User": "Usuario", "UserCreatedWithName": "El usuario {0} ha sido creado", "UserDeletedWithName": "El usuario {0} ha sido borrado", "UserDownloadingItemWithValues": "{0} está descargando {1}", @@ -86,11 +59,8 @@ "UserOfflineFromDevice": "{0} se ha desconectado de {1}", "UserOnlineFromDevice": "{0} está en línea desde {1}", "UserPasswordChangedWithName": "Se ha cambiado la contraseña para el usuario {0}", - "UserPolicyUpdatedWithName": "Las política de usuario ha sido actualizada para {0}", "UserStartedPlayingItemWithValues": "{0} está reproduciendo {1} en {2}", "UserStoppedPlayingItemWithValues": "{0} ha terminado de reproducir {1} en {2}", - "ValueHasBeenAddedToLibrary": "{0} ha sido añadido a tu biblioteca multimedia", - "ValueSpecialEpisodeName": "Especial - {0}", "VersionNumber": "Versión {0}", "TaskDownloadMissingSubtitlesDescription": "Busca en internet los subtítulos que falten basándose en la configuración de los metadatos.", "TaskDownloadMissingSubtitles": "Descargar subtítulos faltantes", @@ -129,8 +99,6 @@ "TaskRefreshTrickplayImagesDescription": "Crea vistas previas de reproducción engañosa para videos en bibliotecas habilitadas.", "TaskAudioNormalization": "Normalización de audio", "TaskAudioNormalizationDescription": "Escanea archivos en busca de datos de normalización de audio.", - "TaskCleanCollectionsAndPlaylists": "Limpiar colecciones y listas de reproducción", - "TaskCleanCollectionsAndPlaylistsDescription": "Elimina elementos de colecciones y listas de reproducción que ya no existen.", "TaskDownloadMissingLyrics": "Descargar letra faltante", "TaskDownloadMissingLyricsDescription": "Descarga letras de canciones", "TaskExtractMediaSegments": "Escanear Segmentos de Media", @@ -138,5 +106,20 @@ "TaskMoveTrickplayImagesDescription": "Mueve archivos existentes de trickplay de acuerdo a la configuración de la biblioteca.", "TaskMoveTrickplayImages": "Migrar Ubicación de Imagen de Trickplay", "CleanupUserDataTaskDescription": "Limpia todos los datos del usuario (estado de visualización, estado de los favoritos, etc.) que no están presentes en la biblioteca por al menos 90 días.", - "CleanupUserDataTask": "Tarea de limpieza de datos de usuarios" + "CleanupUserDataTask": "Tarea de limpieza de datos de usuarios", + "LyricDownloadFailureFromForItem": "No se pudo descargar la letra desde {0} para {1}", + "Original": "Original", + "NameExtraBehindTheScenes": "Detrás de cámaras", + "NameExtraClip": "Clip", + "NameExtraDeletedScene": "Escena eliminada", + "NameExtraFeaturette": "Minidocumental", + "NameExtraInterview": "Entrevista", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Muestra", + "NameExtraScene": "Escena", + "NameExtraShort": "Cortometraje", + "NameExtraThemeSong": "Música de presentación", + "NameExtraThemeVideo": "Video de presentación", + "NameExtraTrailer": "Tráiler", + "NameExtraUnknown": "Extra" } diff --git a/Emby.Server.Implementations/Localization/Core/es-MX.json b/Emby.Server.Implementations/Localization/Core/es-MX.json index 52a26c1af2..f4cf45cb12 100644 --- a/Emby.Server.Implementations/Localization/Core/es-MX.json +++ b/Emby.Server.Implementations/Localization/Core/es-MX.json @@ -1,41 +1,24 @@ { - "Albums": "Álbumes", "AppDeviceValues": "App: {0}, Dispositivo: {1}", - "Application": "Aplicación", "Artists": "Artistas", "AuthenticationSucceededWithUserName": "{0} autenticado con éxito", "Books": "Libros", - "CameraImageUploadedFrom": "Una nueva imagen de cámara ha sido subida desde {0}", - "Channels": "Canales", "ChapterNameValue": "Capítulo {0}", "Collections": "Colecciones", - "DeviceOfflineWithName": "{0} se ha desconectado", - "DeviceOnlineWithName": "{0} está conectado", "FailedLoginAttemptWithUserName": "Intento fallido de inicio de sesión de {0}", "Favorites": "Favoritos", "Folders": "Carpetas", "Genres": "Géneros", - "HeaderAlbumArtists": "Artistas del Álbum", "HeaderContinueWatching": "Continuar viendo", - "HeaderFavoriteAlbums": "Álbumes favoritos", - "HeaderFavoriteArtists": "Artistas favoritos", "HeaderFavoriteEpisodes": "Episodios favoritos", "HeaderFavoriteShows": "Programas favoritos", - "HeaderFavoriteSongs": "Canciones favoritas", "HeaderLiveTV": "TV en vivo", "HeaderNextUp": "A continuación", - "HeaderRecordingGroups": "Grupos de grabación", "HomeVideos": "Videos Caseros", "Inherit": "Heredar", - "ItemAddedWithName": "{0} fue agregado a la biblioteca", - "ItemRemovedWithName": "{0} fue removido de la biblioteca", "LabelIpAddressValue": "Dirección IP: {0}", - "LabelRunningTimeValue": "Tiempo de reproducción: {0}", + "LabelRunningTimeValue": "Tiempo corriendo: {0}", "Latest": "Recientes", - "MessageApplicationUpdated": "El servidor Jellyfin ha sido actualizado", - "MessageApplicationUpdatedTo": "El servidor Jellyfin ha sido actualizado a {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Se ha actualizado la sección {0} de la configuración del servidor", - "MessageServerConfigurationUpdated": "Se ha actualizado la configuración del servidor", "MixedContent": "Contenido mezclado", "Movies": "Películas", "Music": "Música", @@ -61,24 +44,14 @@ "NotificationOptionVideoPlayback": "Reproducción de video iniciada", "NotificationOptionVideoPlaybackStopped": "Reproducción de video detenida", "Photos": "Fotos", - "Playlists": "Listas de reproducción", - "Plugin": "Complemento", "PluginInstalledWithName": "{0} fue instalado", "PluginUninstalledWithName": "{0} fue desinstalado", "PluginUpdatedWithName": "{0} fue actualizado", - "ProviderValue": "Proveedor: {0}", "ScheduledTaskFailedWithName": "{0} falló", - "ScheduledTaskStartedWithName": "{0} iniciado", - "ServerNameNeedsToBeRestarted": "{0} necesita ser reiniciado", "Shows": "Programas", - "Songs": "Canciones", "StartupEmbyServerIsLoading": "El servidor Jellyfin está cargando. Por favor, intente de nuevo pronto.", - "SubtitleDownloadFailureForItem": "Falló la descarga de subtítulos para {0}", "SubtitleDownloadFailureFromForItem": "Falló la descarga de subtítulos desde {0} para {1}", - "Sync": "Sincronizar", - "System": "Sistema", "TvShows": "Programas de TV", - "User": "Usuario", "UserCreatedWithName": "El usuario {0} ha sido creado", "UserDeletedWithName": "El usuario {0} ha sido eliminado", "UserDownloadingItemWithValues": "{0} está descargando {1}", @@ -86,11 +59,8 @@ "UserOfflineFromDevice": "{0} se ha desconectado desde {1}", "UserOnlineFromDevice": "{0} está en línea desde {1}", "UserPasswordChangedWithName": "Se ha cambiado la contraseña para el usuario {0}", - "UserPolicyUpdatedWithName": "La política de usuario ha sido actualizada para {0}", "UserStartedPlayingItemWithValues": "{0} está reproduciendo {1} en {2}", "UserStoppedPlayingItemWithValues": "{0} ha terminado de reproducir {1} en {2}", - "ValueHasBeenAddedToLibrary": "{0} se ha añadido a tu biblioteca de medios", - "ValueSpecialEpisodeName": "Especial - {0}", "VersionNumber": "Versión {0}", "TaskDownloadMissingSubtitlesDescription": "Busca subtítulos faltantes en Internet basándose en la configuración de metadatos.", "TaskDownloadMissingSubtitles": "Descargar subtítulos faltantes", @@ -129,8 +99,6 @@ "TaskRefreshTrickplayImages": "Generar imágenes de la barra de reproducción", "TaskAudioNormalization": "Normalización de audio", "TaskAudioNormalizationDescription": "Analiza los archivos para normalizar el audio.", - "TaskCleanCollectionsAndPlaylists": "Limpieza de colecciones y listas de reproducción", - "TaskCleanCollectionsAndPlaylistsDescription": "Quita elementos que ya no existen de colecciones y listas de reproducción.", "TaskDownloadMissingLyrics": "descargar letras que faltan", "TaskDownloadMissingLyricsDescription": "Descargar letras de canciones", "TaskExtractMediaSegments": "Escaneo de segmentos de medios", @@ -138,5 +106,20 @@ "TaskMoveTrickplayImages": "Migrar la ubicación de la imagen de Trickplay", "TaskMoveTrickplayImagesDescription": "Mueve archivos de trickplay existentes según la configuración de la biblioteca.", "CleanupUserDataTask": "Tarea de limpieza de los datos del usuario", - "CleanupUserDataTaskDescription": "Limpia toda la información de usuario (Estado de última vez visto, favoritos, etc) del archivo media que no está presente por los últimos 90 días." + "CleanupUserDataTaskDescription": "Limpia toda la información de usuario (Estado de última vez visto, favoritos, etc) del archivo media que no está presente por los últimos 90 días.", + "LyricDownloadFailureFromForItem": "No se pudo descargar la letra desde {0} para {1}", + "NameExtraBehindTheScenes": "Detrás de cámaras", + "NameExtraClip": "Clip", + "NameExtraDeletedScene": "Escena eliminada", + "NameExtraFeaturette": "Minidocumental", + "NameExtraInterview": "Entrevista", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Muestra", + "NameExtraScene": "Escena", + "NameExtraShort": "Cortometraje", + "NameExtraThemeSong": "Música de presentación", + "NameExtraThemeVideo": "Video de presentación", + "NameExtraTrailer": "Tráiler", + "NameExtraUnknown": "Extra", + "Original": "Original" } diff --git a/Emby.Server.Implementations/Localization/Core/es.json b/Emby.Server.Implementations/Localization/Core/es.json index 1ec5eaa2a8..9e82e0601b 100644 --- a/Emby.Server.Implementations/Localization/Core/es.json +++ b/Emby.Server.Implementations/Localization/Core/es.json @@ -1,41 +1,24 @@ { - "Albums": "Álbumes", "AppDeviceValues": "Aplicación: {0}, Dispositivo: {1}", - "Application": "Aplicación", "Artists": "Artistas", "AuthenticationSucceededWithUserName": "{0} autenticado correctamente", "Books": "Libros", - "CameraImageUploadedFrom": "Se ha subido una nueva imagen por cámara desde {0}", - "Channels": "Canales", "ChapterNameValue": "Capítulo {0}", "Collections": "Colecciones", - "DeviceOfflineWithName": "{0} se ha desconectado", - "DeviceOnlineWithName": "{0} está conectado", "FailedLoginAttemptWithUserName": "Intento fallido de inicio de sesión de {0}", "Favorites": "Favoritos", "Folders": "Carpetas", "Genres": "Géneros", - "HeaderAlbumArtists": "Artistas del álbum", "HeaderContinueWatching": "Seguir viendo", - "HeaderFavoriteAlbums": "Álbumes favoritos", - "HeaderFavoriteArtists": "Artistas favoritos", "HeaderFavoriteEpisodes": "Episodios favoritos", "HeaderFavoriteShows": "Series favoritas", - "HeaderFavoriteSongs": "Canciones favoritas", "HeaderLiveTV": "Televisión en directo", "HeaderNextUp": "Siguiente", - "HeaderRecordingGroups": "Grupos de grabación", "HomeVideos": "Vídeos caseros", "Inherit": "Heredar", - "ItemAddedWithName": "{0} se ha añadido a la biblioteca", - "ItemRemovedWithName": "{0} ha sido eliminado de la biblioteca", "LabelIpAddressValue": "Dirección IP: {0}", "LabelRunningTimeValue": "Duración: {0}", "Latest": "Últimas", - "MessageApplicationUpdated": "Se ha actualizado el servidor Jellyfin", - "MessageApplicationUpdatedTo": "Se ha actualizado el servidor Jellyfin a la versión {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "La sección {0} de configuración del servidor ha sido actualizada", - "MessageServerConfigurationUpdated": "Se ha actualizado la configuración del servidor", "MixedContent": "Contenido mixto", "Movies": "Películas", "Music": "Música", @@ -61,24 +44,14 @@ "NotificationOptionVideoPlayback": "Se inició la reproducción de vídeo", "NotificationOptionVideoPlaybackStopped": "Reproducción de vídeo detenida", "Photos": "Fotos", - "Playlists": "Listas de reproducción", - "Plugin": "Plugin", "PluginInstalledWithName": "{0} se ha instalado", "PluginUninstalledWithName": "{0} se ha desinstalado", "PluginUpdatedWithName": "{0} se actualizó", - "ProviderValue": "Proveedor: {0}", "ScheduledTaskFailedWithName": "{0} falló", - "ScheduledTaskStartedWithName": "{0} iniciada", - "ServerNameNeedsToBeRestarted": "{0} necesita ser reiniciado", "Shows": "Series", - "Songs": "Canciones", "StartupEmbyServerIsLoading": "Jellyfin Server se está cargando. Vuelve a intentarlo en breve.", - "SubtitleDownloadFailureForItem": "Error al descargar subtítulos para {0}", "SubtitleDownloadFailureFromForItem": "Fallo en la descarga de subtítulos desde {0} para {1}", - "Sync": "Sincronizar", - "System": "Sistema", "TvShows": "Series", - "User": "Usuario", "UserCreatedWithName": "El usuario {0} ha sido creado", "UserDeletedWithName": "El usuario {0} ha sido borrado", "UserDownloadingItemWithValues": "{0} está descargando {1}", @@ -86,11 +59,8 @@ "UserOfflineFromDevice": "{0} se ha desconectado desde {1}", "UserOnlineFromDevice": "{0} está en línea desde {1}", "UserPasswordChangedWithName": "Se ha cambiado la contraseña para el usuario {0}", - "UserPolicyUpdatedWithName": "Actualizada política de usuario para {0}", "UserStartedPlayingItemWithValues": "{0} está reproduciendo {1} en {2}", "UserStoppedPlayingItemWithValues": "{0} ha terminado de reproducir {1} en {2}", - "ValueHasBeenAddedToLibrary": "{0} ha sido añadido a tu biblioteca multimedia", - "ValueSpecialEpisodeName": "Especial - {0}", "VersionNumber": "Versión {0}", "TasksMaintenanceCategory": "Mantenimiento", "TasksLibraryCategory": "Biblioteca", @@ -127,8 +97,6 @@ "HearingImpaired": "Discapacidad Auditiva", "TaskRefreshTrickplayImages": "Generar miniaturas de línea de tiempo", "TaskRefreshTrickplayImagesDescription": "Crear miniaturas de tiempo para videos en las librerías habilitadas.", - "TaskCleanCollectionsAndPlaylists": "Limpiar colecciones y listas de reproducción", - "TaskCleanCollectionsAndPlaylistsDescription": "Elimina elementos de colecciones y listas de reproducción que ya no existen.", "TaskAudioNormalization": "Normalización de audio", "TaskAudioNormalizationDescription": "Escanear archivos para obtener datos de normalización.", "TaskDownloadMissingLyricsDescription": "Descargar letras para las canciones", @@ -138,5 +106,20 @@ "TaskExtractMediaSegmentsDescription": "Extrae u obtiene segmentos de medios de plugins habilitados para MediaSegment.", "TaskMoveTrickplayImages": "Migrar la ubicación de la imagen de Trickplay", "CleanupUserDataTask": "Tarea de limpieza de datos del usuario", - "CleanupUserDataTaskDescription": "Limpia todos los datos del usuario (estado de visualización, favoritos, etc.) de los medios que ya no están disponibles desde hace al menos 90 días." + "CleanupUserDataTaskDescription": "Limpia todos los datos del usuario (estado de visualización, favoritos, etc.) de los medios que ya no están disponibles desde hace al menos 90 días.", + "Original": "Original", + "LyricDownloadFailureFromForItem": "No se pudieron descargar las letras desde {0} para {1}", + "NameExtraBehindTheScenes": "Detrás de Cámaras", + "NameExtraClip": "Clip", + "NameExtraDeletedScene": "Escena eliminada", + "NameExtraFeaturette": "Reportaje especial", + "NameExtraInterview": "Entrevista", + "NameExtraSample": "Muestra", + "NameExtraScene": "Escena", + "NameExtraShort": "Cortometraje", + "NameExtraThemeSong": "Tema principal", + "NameExtraThemeVideo": "Vídeo del tema principal", + "NameExtraTrailer": "Tráiler", + "NameExtraUnknown": "Extra", + "NameExtraNumbered": "{0} {1}" } diff --git a/Emby.Server.Implementations/Localization/Core/es_419.json b/Emby.Server.Implementations/Localization/Core/es_419.json index 34c68c33bb..274c60c7bc 100644 --- a/Emby.Server.Implementations/Localization/Core/es_419.json +++ b/Emby.Server.Implementations/Localization/Core/es_419.json @@ -1,29 +1,19 @@ { "LabelRunningTimeValue": "Tiempo en ejecución: {0}", - "ValueSpecialEpisodeName": "Especial - {0}", - "Sync": "Sincronizar", - "Songs": "Canciones", "Shows": "Programas", - "Playlists": "Listas de reproducción", "Photos": "Fotos", "Movies": "Películas", "HeaderNextUp": "A continuación", "HeaderLiveTV": "TV en vivo", - "HeaderFavoriteSongs": "Canciones favoritas", - "HeaderFavoriteArtists": "Artistas favoritos", - "HeaderFavoriteAlbums": "Álbumes favoritos", "HeaderFavoriteEpisodes": "Episodios favoritos", "HeaderFavoriteShows": "Programas favoritos", "HeaderContinueWatching": "Continuar viendo", - "HeaderAlbumArtists": "Artistas de álbum", "Genres": "Géneros", "Folders": "Carpetas", "Favorites": "Favoritos", "Collections": "Colecciones", - "Channels": "Canales", "Books": "Libros", "Artists": "Artistas", - "Albums": "Álbumes", "TaskDownloadMissingSubtitlesDescription": "Busca subtítulos faltantes en Internet basándose en la configuración de metadatos.", "TaskDownloadMissingSubtitles": "Descargar subtítulos faltantes", "TaskRefreshChannelsDescription": "Actualiza la información de canales de Internet.", @@ -47,10 +37,8 @@ "TasksLibraryCategory": "Biblioteca", "TasksMaintenanceCategory": "Mantenimiento", "VersionNumber": "Versión {0}", - "ValueHasBeenAddedToLibrary": "{0} se ha añadido a tu biblioteca de medios", "UserStoppedPlayingItemWithValues": "{0} ha terminado de reproducir {1} en {2}", "UserStartedPlayingItemWithValues": "{0} está reproduciendo {1} en {2}", - "UserPolicyUpdatedWithName": "La política de usuario ha sido actualizada para {0}", "UserPasswordChangedWithName": "Se ha cambiado la contraseña para el usuario {0}", "UserOnlineFromDevice": "{0} está en línea desde {1}", "UserOfflineFromDevice": "{0} se ha desconectado desde {1}", @@ -58,19 +46,13 @@ "UserDownloadingItemWithValues": "{0} está descargando {1}", "UserDeletedWithName": "El usuario {0} ha sido eliminado", "UserCreatedWithName": "El usuario {0} ha sido creado", - "User": "Usuario", "TvShows": "Programas de TV", - "System": "Sistema", "SubtitleDownloadFailureFromForItem": "Falló la descarga de subtítulos desde {0} para {1}", "StartupEmbyServerIsLoading": "El servidor Jellyfin está cargando. Por favor, intente de nuevo pronto.", - "ServerNameNeedsToBeRestarted": "{0} debe ser reiniciado", - "ScheduledTaskStartedWithName": "{0} iniciado", "ScheduledTaskFailedWithName": "{0} falló", - "ProviderValue": "Proveedor: {0}", "PluginUpdatedWithName": "{0} fue actualizado", "PluginUninstalledWithName": "{0} fue desinstalado", "PluginInstalledWithName": "{0} fue instalado", - "Plugin": "Complemento", "NotificationOptionVideoPlaybackStopped": "Reproducción de video detenida", "NotificationOptionVideoPlayback": "Reproducción de video iniciada", "NotificationOptionUserLockedOut": "Usuario bloqueado", @@ -94,24 +76,13 @@ "MusicVideos": "Videos musicales", "Music": "Música", "MixedContent": "Contenido mezclado", - "MessageServerConfigurationUpdated": "Se ha actualizado la configuración del servidor", - "MessageNamedServerConfigurationUpdatedWithValue": "Se ha actualizado la sección {0} de la configuración del servidor", - "MessageApplicationUpdatedTo": "El servidor Jellyfin ha sido actualizado a {0}", - "MessageApplicationUpdated": "El servidor Jellyfin ha sido actualizado", "Latest": "Recientes", "LabelIpAddressValue": "Dirección IP: {0}", - "ItemRemovedWithName": "{0} fue removido de la biblioteca", - "ItemAddedWithName": "{0} fue agregado a la biblioteca", "Inherit": "Heredar", "HomeVideos": "Videos caseros", - "HeaderRecordingGroups": "Grupos de grabación", "FailedLoginAttemptWithUserName": "Intento de inicio de sesión fallido desde {0}", - "DeviceOnlineWithName": "{0} está conectado", - "DeviceOfflineWithName": "{0} se ha desconectado", "ChapterNameValue": "Capítulo {0}", - "CameraImageUploadedFrom": "Una nueva imagen de cámara ha sido subida desde {0}", "AuthenticationSucceededWithUserName": "{0} autenticado con éxito", - "Application": "Aplicación", "AppDeviceValues": "Aplicación: {0}, Dispositivo: {1}", "TaskCleanActivityLogDescription": "Elimina las entradas del registro de actividad anteriores al periodo configurado.", "TaskCleanActivityLog": "Limpiar registro de actividades", @@ -127,9 +98,7 @@ "TaskRefreshTrickplayImagesDescription": "Crea previsualizaciones para la barra de reproducción en las bibliotecas habilitadas.", "TaskRefreshTrickplayImages": "Generar imágenes de la barra de reproducción", "TaskAudioNormalization": "Normalización de audio", - "TaskCleanCollectionsAndPlaylistsDescription": "Quita elementos que ya no existen de colecciones y listas de reproducción.", "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", "TaskExtractMediaSegmentsDescription": "Extrae u obtiene segmentos de medios de complementos habilitados para MediaSegment.", @@ -137,5 +106,20 @@ "TaskExtractMediaSegments": "Escaneo de segmentos de medios", "TaskMoveTrickplayImages": "Migrar la ubicación de la imagen de Trickplay", "CleanupUserDataTask": "Tarea de limpieza de datos de usuario", - "CleanupUserDataTaskDescription": "Limpia todos los datos de usuario (estado de visualización, favoritos, etc.) que no están presentes en la biblioteca por al menos 90 días." + "CleanupUserDataTaskDescription": "Limpia todos los datos de usuario (estado de visualización, favoritos, etc.) que no están presentes en la biblioteca por al menos 90 días.", + "LyricDownloadFailureFromForItem": "No se pudo descargar las letras de {0} para {1}", + "Original": "Original", + "NameExtraUnknown": "Extra", + "NameExtraBehindTheScenes": "Detrás de cámaras", + "NameExtraClip": "Clip", + "NameExtraDeletedScene": "Escena eliminada", + "NameExtraFeaturette": "Minidocumental", + "NameExtraInterview": "Entrevista", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Muestra", + "NameExtraScene": "Escena", + "NameExtraShort": "Cortometraje", + "NameExtraThemeSong": "Música de presentación", + "NameExtraThemeVideo": "Video de presentación", + "NameExtraTrailer": "Tráiler" } diff --git a/Emby.Server.Implementations/Localization/Core/es_DO.json b/Emby.Server.Implementations/Localization/Core/es_DO.json index f98a5e5b2c..a1b9944125 100644 --- a/Emby.Server.Implementations/Localization/Core/es_DO.json +++ b/Emby.Server.Implementations/Localization/Core/es_DO.json @@ -1,35 +1,24 @@ { - "Channels": "Canales", "Books": "Libros", - "Albums": "Álbumes", "Collections": "Colecciones", "Artists": "Artistas", - "DeviceOnlineWithName": "{0} está conectado", - "DeviceOfflineWithName": "{0} se ha desconectado", "ChapterNameValue": "Capítulo {0}", - "CameraImageUploadedFrom": "Se ha subido una nueva imagen de cámara desde {0}", "AuthenticationSucceededWithUserName": "{0} autenticado con éxito", - "Application": "Aplicación", "AppDeviceValues": "App: {0}, Dispositivo: {1}", "HeaderContinueWatching": "Continuar Viendo", - "HeaderAlbumArtists": "Artistas del álbum", "Genres": "Géneros", "Folders": "Carpetas", "Favorites": "Favoritos", "FailedLoginAttemptWithUserName": "Intento de inicio de sesión fallido desde {0}", - "HeaderFavoriteSongs": "Canciones Favoritas", "HeaderFavoriteEpisodes": "Episodios Favoritos", - "HeaderFavoriteArtists": "Artistas Favoritos", "External": "Externo", "Default": "Predeterminado", "Movies": "Películas", - "MessageNamedServerConfigurationUpdatedWithValue": "La sección {0} de la configuración ha sido actualizada", "MixedContent": "Contenido mixto", "Music": "Música", "NotificationOptionCameraImageUploaded": "Imagen de la cámara subida", "NotificationOptionServerRestartRequired": "Se necesita reiniciar el servidor", "NotificationOptionVideoPlayback": "Reproducción de video iniciada", - "Sync": "Sincronizar", "Shows": "Series", "UserDownloadingItemWithValues": "{0} está descargando {1}", "UserOfflineFromDevice": "{0} se ha desconectado desde {1}", @@ -41,8 +30,6 @@ "TaskKeyframeExtractorDescription": "Extrae Fotogramas Clave de los archivos de vídeo para crear Listas de Reproducción HLS más precisas. Esta tarea puede durar mucho tiempo.", "TaskAudioNormalization": "Normalización de audio", "TaskAudioNormalizationDescription": "Escanear archivos para la normalización de data.", - "TaskCleanCollectionsAndPlaylists": "Limpiar colecciones y listas de reproducción", - "TaskCleanCollectionsAndPlaylistsDescription": "Remover elementos de colecciones y listas de reproducción que no existen.", "TvShows": "Series de TV", "UserStartedPlayingItemWithValues": "{0} está reproduciendo {1} en {2}", "TaskRefreshChannels": "Actualizar canales", @@ -50,17 +37,12 @@ "HeaderFavoriteShows": "Programas favoritos", "TaskCleanActivityLog": "Limpiar registro de actividades", "UserPasswordChangedWithName": "Se ha cambiado la contraseña para el usuario {0}", - "System": "Sistema", - "User": "Usuario", "Forced": "Forzado", "PluginInstalledWithName": "{0} ha sido instalado", - "HeaderFavoriteAlbums": "Álbumes favoritos", "TaskUpdatePlugins": "Actualizar Plugins", "Latest": "Recientes", "UserStoppedPlayingItemWithValues": "{0} ha terminado de reproducir {1} en {2}", - "Songs": "Canciones", "NotificationOptionPluginError": "Falla de plugin", - "ScheduledTaskStartedWithName": "{0} iniciado", "TasksApplicationCategory": "Aplicación", "UserDeletedWithName": "El usuario {0} ha sido eliminado", "TaskRefreshChapterImages": "Extraer imágenes de los capítulos", @@ -73,34 +55,26 @@ "NotificationOptionAudioPlaybackStopped": "Reproducción de audio detenida", "TasksLibraryCategory": "Biblioteca", "NotificationOptionPluginInstalled": "Plugin instalado", - "UserPolicyUpdatedWithName": "La política de usuario ha sido actualizada para {0}", "VersionNumber": "Versión {0}", "HeaderNextUp": "A continuación", - "ValueHasBeenAddedToLibrary": "{0} se ha añadido a tu biblioteca", "LabelIpAddressValue": "Dirección IP: {0}", "NameSeasonNumber": "Temporada {0}", "NotificationOptionNewLibraryContent": "Nuevo contenido agregado", - "Plugin": "Plugin", "NotificationOptionAudioPlayback": "Reproducción de audio iniciada", "NotificationOptionTaskFailed": "Falló la tarea programada", "LabelRunningTimeValue": "Tiempo en ejecución: {0}", "SubtitleDownloadFailureFromForItem": "Falló la descarga de subtítulos desde {0} para {1}", "TaskRefreshLibrary": "Escanear biblioteca de medios", - "ServerNameNeedsToBeRestarted": "{0} debe ser reiniciado", "TasksMaintenanceCategory": "Mantenimiento", - "ProviderValue": "Proveedor: {0}", "UserCreatedWithName": "El usuario {0} ha sido creado", "PluginUninstalledWithName": "{0} ha sido desinstalado", - "ValueSpecialEpisodeName": "Especial - {0}", "ScheduledTaskFailedWithName": "{0} falló", "TaskCleanLogs": "Limpiar directorio de registros", "NameInstallFailed": "Falló la instalación de {0}", "UserLockedOutWithName": "El usuario {0} ha sido bloqueado", "TaskRefreshLibraryDescription": "Escanea tu biblioteca de medios para encontrar archivos nuevos y actualizar los metadatos.", "StartupEmbyServerIsLoading": "El servidor Jellyfin está cargando. Por favor, intente de nuevo en un momento.", - "Playlists": "Listas de reproducción", "TaskDownloadMissingSubtitlesDescription": "Busca subtítulos faltantes en Internet basándose en la configuración de metadatos.", - "MessageServerConfigurationUpdated": "Se ha actualizado la configuración del servidor", "TaskRefreshPeople": "Actualizar personas", "NotificationOptionVideoPlaybackStopped": "Reproducción de video detenida", "HeaderLiveTV": "TV en vivo", @@ -110,15 +84,10 @@ "TaskCleanCache": "Limpiar directorio caché", "TaskRefreshChapterImagesDescription": "Crea miniaturas para videos que tienen capítulos.", "Inherit": "Heredar", - "HeaderRecordingGroups": "Grupos de grabación", - "ItemAddedWithName": "{0} fue agregado a la biblioteca", "TaskOptimizeDatabase": "Optimizar base de datos", "TaskKeyframeExtractor": "Extractor de Fotogramas Clave", "HearingImpaired": "Discapacidad auditiva", "HomeVideos": "Videos caseros", - "ItemRemovedWithName": "{0} fue removido de la biblioteca", - "MessageApplicationUpdated": "El servidor Jellyfin ha sido actualizado", - "MessageApplicationUpdatedTo": "El servidor Jellyfin ha sido actualizado a {0}", "MusicVideos": "Videos musicales", "NewVersionIsAvailable": "Una nueva versión de Jellyfin está disponible para descargar.", "PluginUpdatedWithName": "{0} ha sido actualizado", diff --git a/Emby.Server.Implementations/Localization/Core/et.json b/Emby.Server.Implementations/Localization/Core/et.json index a3f9dc2f8f..a7afcf5b77 100644 --- a/Emby.Server.Implementations/Localization/Core/et.json +++ b/Emby.Server.Implementations/Localization/Core/et.json @@ -1,7 +1,6 @@ { "TaskCleanActivityLogDescription": "Kustutab määratud ajast vanemad tegevuslogi kirjed.", "UserDownloadingItemWithValues": "{0} laadib alla {1}", - "HeaderRecordingGroups": "Salvestusrühmad", "TaskOptimizeDatabaseDescription": "Tihendab ja puhastab andmebaasi. Selle toimingu tegemine pärast meediakogu andmebaasiga seotud muudatuste skannimist võib jõudlust parandada.", "TaskOptimizeDatabase": "Optimeeri andmebaasi", "TaskDownloadMissingSubtitlesDescription": "Otsib veebist puuduvaid subtiitreid vastavalt määratud metaandmete seadetele.", @@ -29,30 +28,19 @@ "TasksLibraryCategory": "Meediakogu", "TasksMaintenanceCategory": "Hooldus", "VersionNumber": "Versioon {0}", - "ValueSpecialEpisodeName": "Eriepisood - {0}", - "ValueHasBeenAddedToLibrary": "{0} lisati meediakogusse", "UserStartedPlayingItemWithValues": "{0} taasesitab {1} seadmes {2}", "UserPasswordChangedWithName": "Kasutaja {0} parool muudeti", "UserLockedOutWithName": "Kasutaja {0} lukustati", "UserDeletedWithName": "Kasutaja {0} kustutati", "UserCreatedWithName": "Kasutaja {0} on loodud", - "ScheduledTaskStartedWithName": "{0} käivitati", - "ProviderValue": "Allikas: {0}", "StartupEmbyServerIsLoading": "Jellyfin server laadib. Proovi varsti uuesti.", - "User": "Kasutaja", "Undefined": "Määratlemata", "TvShows": "Sarjad", - "System": "Süsteem", - "Sync": "Sünkrooni", - "Songs": "Lood", "Shows": "Sarjad", - "ServerNameNeedsToBeRestarted": "{0} tuleb taaskäivitada", "ScheduledTaskFailedWithName": "{0} nurjus", "PluginUpdatedWithName": "{0} uuendati", "PluginUninstalledWithName": "{0} eemaldati", "PluginInstalledWithName": "{0} paigaldati", - "Plugin": "Plugin", - "Playlists": "Esitusloendid", "Photos": "Fotod", "NotificationOptionVideoPlaybackStopped": "Video taasesitus lõppes", "NotificationOptionVideoPlayback": "Video taasesitus algas", @@ -72,52 +60,35 @@ "NotificationOptionApplicationUpdateAvailable": "Rakenduse uuendus on saadaval", "NewVersionIsAvailable": "Jellyfin serveri uus versioon on allalaadimiseks saadaval.", "NameSeasonUnknown": "Tundmatu hooaeg", - "NameSeasonNumber": "Hooaeg {0}", + "NameSeasonNumber": "{0}. hooaeg", "NameInstallFailed": "{0} paigaldamine nurjus", "MusicVideos": "Muusikavideod", "Music": "Muusika", "Movies": "Filmid", "MixedContent": "Segatud sisu", - "MessageServerConfigurationUpdated": "Serveri seadistust uuendati", - "MessageNamedServerConfigurationUpdatedWithValue": "Serveri seadistusosa {0} uuendati", - "MessageApplicationUpdatedTo": "Jellyfin server uuendati versioonile {0}", - "MessageApplicationUpdated": "Jellyfin server uuendati", "Latest": "Uusimad", "LabelRunningTimeValue": "Kestus: {0}", "LabelIpAddressValue": "IP aadress: {0}", - "ItemRemovedWithName": "{0} eemaldati meediakogust", - "ItemAddedWithName": "{0} lisati meediakogusse", "Inherit": "Päri", "HomeVideos": "Koduvideod", "HeaderNextUp": "Järgmisena", "HeaderLiveTV": "Otse TV", - "HeaderFavoriteSongs": "Lemmiklood", "HeaderFavoriteShows": "Lemmiksarjad", "HeaderFavoriteEpisodes": "Lemmikepisoodid", - "HeaderFavoriteArtists": "Lemmikesitajad", - "HeaderFavoriteAlbums": "Lemmikalbumid", "HeaderContinueWatching": "Jätka vaatamist", - "HeaderAlbumArtists": "Albumi esitajad", "Genres": "Žanrid", "Forced": "Sunnitud", "Folders": "Kaustad", "Favorites": "Lemmikud", "FailedLoginAttemptWithUserName": "Sisselogimine nurjus aadressilt {0}", - "DeviceOnlineWithName": "{0} on ühendatud", - "DeviceOfflineWithName": "{0} katkestas ühenduse", "Default": "Vaikimisi", "ChapterNameValue": "Peatükk {0}", - "Channels": "Kanalid", - "CameraImageUploadedFrom": "Uus kaamera pilt laaditi üles allikalt {0}", "Books": "Raamatud", "AuthenticationSucceededWithUserName": "{0} autentimine õnnestus", "Artists": "Esitajad", - "Application": "Rakendus", "AppDeviceValues": "Rakendus: {0}, seade: {1}", - "Albums": "Albumid", "UserOfflineFromDevice": "{0} katkestas ühenduse seadmega {1}", "SubtitleDownloadFailureFromForItem": "Subtiitrite allalaadimine {0} > {1} nurjus", - "UserPolicyUpdatedWithName": "Kasutaja {0} õigusi värskendati", "UserStoppedPlayingItemWithValues": "{0} lõpetas {1} taasesituse seadmes {2}", "UserOnlineFromDevice": "{0} on ühendatud seadmest {1}", "External": "Väline", @@ -128,14 +99,27 @@ "TaskRefreshTrickplayImagesDescription": "Loob trickplay eelvaated videotele lubatud meediakogudes.", "TaskAudioNormalization": "Normaliseeri helitugevus", "TaskAudioNormalizationDescription": "Otsib failidest helitugevuse normaliseerimise teavet.", - "TaskCleanCollectionsAndPlaylistsDescription": "Eemaldab kogumikest ja esitusloenditest üksused, mida enam ei eksisteeri.", - "TaskCleanCollectionsAndPlaylists": "Puhasta kogumikud ja esitusloendid", "TaskDownloadMissingLyrics": "Hangi puuduvad laulusõnad", "TaskDownloadMissingLyricsDescription": "Laulusõnade allalaadimine", "TaskMoveTrickplayImagesDescription": "Liigutab trickplay pildid meediakogu sätete kohaselt.", - "TaskExtractMediaSegments": "Skaneeri meediasegmente", - "TaskExtractMediaSegmentsDescription": "Eraldab või võtab meediasegmendid MediaSegment'i lubavatest pluginatest.", + "TaskExtractMediaSegments": "Skaneeri meedialõike", + "TaskExtractMediaSegmentsDescription": "Eraldab või võtab meedialõigud MediaSegment'i toega pluginatest.", "TaskMoveTrickplayImages": "Muuda trickplay piltide asukoht", "CleanupUserDataTask": "Puhasta kasutajaandmed", - "CleanupUserDataTaskDescription": "Puhastab kõik kasutajaandmed (vaatamise olek, lemmikute olek jne) meediast, mis pole enam vähemalt 90 päeva saadaval olnud." + "CleanupUserDataTaskDescription": "Puhastab kõik kasutajaandmed (vaatamise olek, lemmikute olek jne) meediast, mida pole enam vähemalt 90 päeva saadaval olnud.", + "LyricDownloadFailureFromForItem": "Laulusõnade hankimine teenusest {0} loole {1} nurjus", + "Original": "Algne", + "NameExtraBehindTheScenes": "Kulisside taga", + "NameExtraDeletedScene": "Väljajäetud stseen", + "NameExtraFeaturette": "Lisalõik", + "NameExtraInterview": "Intervjuu", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Näidis", + "NameExtraScene": "Stseen", + "NameExtraShort": "Lühifilm", + "NameExtraThemeSong": "Tunnusmeloodia", + "NameExtraThemeVideo": "Tunnusvideo", + "NameExtraTrailer": "Treiler", + "NameExtraUnknown": "Lisamaterjal", + "NameExtraClip": "Videoklipp" } diff --git a/Emby.Server.Implementations/Localization/Core/eu.json b/Emby.Server.Implementations/Localization/Core/eu.json index c9a798cacd..df6855d6e3 100644 --- a/Emby.Server.Implementations/Localization/Core/eu.json +++ b/Emby.Server.Implementations/Localization/Core/eu.json @@ -1,23 +1,16 @@ { - "ValueSpecialEpisodeName": "Berezia - {0}", - "Sync": "Sinkronizatu", - "Songs": "Abestiak", "Shows": "Serieak", - "Playlists": "Erreprodukzio-zerrendak", "Photos": "Argazkiak", "MusicVideos": "Bideo musikalak", "Movies": "Filmak", "HeaderContinueWatching": "Ikusten jarraitu", - "HeaderAlbumArtists": "Albumeko artistak", "Genres": "Generoak", "Folders": "Karpetak", "Favorites": "Gogokoak", "Default": "Lehenetsia", "Collections": "Bildumak", - "Channels": "Kanalak", "Books": "Liburuak", "Artists": "Artistak", - "Albums": "Albumak", "TaskOptimizeDatabase": "Datu basea optimizatu", "TaskDownloadMissingSubtitlesDescription": "Falta diren azpitituluak bilatzen ditu interneten metadatuen konfigurazioaren arabera.", "TaskDownloadMissingSubtitles": "Falta diren azpitituluak deskargatu", @@ -44,10 +37,8 @@ "TasksLibraryCategory": "Liburutegia", "TasksMaintenanceCategory": "Mantenua", "VersionNumber": "Bertsioa {0}", - "ValueHasBeenAddedToLibrary": "{0} zure multimedia liburutegian gehitu da", "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", @@ -55,19 +46,14 @@ "UserDownloadingItemWithValues": "{0} {1} deskargatzen ari da", "UserDeletedWithName": "{0} Erabiltzailea ezabatu da", "UserCreatedWithName": "{0} Erabiltzailea sortu da", - "User": "Erabiltzailea", "Undefined": "Ezezaguna", "TvShows": "TB serieak", - "System": "Sistema", "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", "PluginUpdatedWithName": "{0} eguneratu da", "PluginUninstalledWithName": "{0} desinstalatu da", "PluginInstalledWithName": "{0} instalatu da", - "Plugin": "Plugin", "NotificationOptionVideoPlaybackStopped": "Bideoa geldituta", "NotificationOptionVideoPlayback": "Bideoa martxan", "NotificationOptionUserLockedOut": "Erabiltzailea blokeatua", @@ -90,37 +76,22 @@ "NameInstallFailed": "{0} instalazioak huts egin du", "Music": "Musika", "MixedContent": "Eduki mistoa", - "MessageServerConfigurationUpdated": "Zerbitzariaren konfigurazioa eguneratu da", - "MessageNamedServerConfigurationUpdatedWithValue": "Zerbitzariaren {0} konfigurazio atala eguneratu da", - "MessageApplicationUpdatedTo": "Jellyfin zerbitzaria {0}-ra eguneratu da", - "MessageApplicationUpdated": "Jellyfin zerbitzaria eguneratu da", "Latest": "Azkena", "LabelRunningTimeValue": "Iraupena: {0}", "LabelIpAddressValue": "IP helbidea: {0}", - "ItemRemovedWithName": "{0} liburutegitik kendu da", - "ItemAddedWithName": "{0} liburutegira gehitu da", "HomeVideos": "Etxeko bideoak", "HeaderNextUp": "Hurrengoa", "HeaderLiveTV": "Zuzeneko TB", - "HeaderFavoriteSongs": "Gogoko abestiak", "HeaderFavoriteShows": "Gogoko serieak", "HeaderFavoriteEpisodes": "Gogoko atalak", - "HeaderFavoriteArtists": "Gogoko artistak", - "HeaderFavoriteAlbums": "Gogoko albumak", "Forced": "Behartuta", "FailedLoginAttemptWithUserName": "{0}-tik saioa hasteak huts egin du", "External": "Kanpokoa", - "DeviceOnlineWithName": "{0} konektatu da", - "DeviceOfflineWithName": "{0} deskonektatu da", "ChapterNameValue": "{0} Kapitulua", - "CameraImageUploadedFrom": "{0}-tik kamera irudi berri bat igo da", "AuthenticationSucceededWithUserName": "{0} ongi autentifikatu da", - "Application": "Aplikazioa", "AppDeviceValues": "App: {0}, Gailua: {1}", "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", @@ -130,12 +101,25 @@ "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.", "CleanupUserDataTaskDescription": "Gutxienez 90 egunez dagoeneko existitzen ez den multimediatik erabiltzaile-datu guztiak (ikusteko egoera, gogokoen egoera, etab.) garbitzen ditu.", - "CleanupUserDataTask": "Erabiltzaileen datuak garbitzeko zeregina" + "CleanupUserDataTask": "Erabiltzaileen datuak garbitzeko zeregina", + "LyricDownloadFailureFromForItem": "Ezin izan dira {1}-ren letrak deskargatu {0}-tik", + "Original": "Jatorrizkoa", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Lagina", + "NameExtraScene": "Eszena", + "NameExtraShort": "Laburra", + "NameExtraThemeSong": "Gai-abestia", + "NameExtraThemeVideo": "Gai-bideoa", + "NameExtraBehindTheScenes": "Eszenen atzean", + "NameExtraClip": "Klipa", + "NameExtraDeletedScene": "Ezabatutako eszena", + "NameExtraFeaturette": "Erreportajea", + "NameExtraInterview": "Elkarrizketa", + "NameExtraTrailer": "Trailerra", + "NameExtraUnknown": "Extra" } diff --git a/Emby.Server.Implementations/Localization/Core/fa.json b/Emby.Server.Implementations/Localization/Core/fa.json index ff14c13678..17ed54b117 100644 --- a/Emby.Server.Implementations/Localization/Core/fa.json +++ b/Emby.Server.Implementations/Localization/Core/fa.json @@ -1,41 +1,24 @@ { - "Albums": "آلبوم‌ها", "AppDeviceValues": "برنامه: {0} ، دستگاه: {1}", - "Application": "برنامه", "Artists": "هنرمندان", "AuthenticationSucceededWithUserName": "{0} با موفقیت تایید اعتبار شد", "Books": "کتاب‌ها", - "CameraImageUploadedFrom": "یک عکس جدید از دوربین ارسال شده است {0}", - "Channels": "کانالها", "ChapterNameValue": "قسمت {0}", "Collections": "مجموعه‌ها", - "DeviceOfflineWithName": "ارتباط {0} قطع شد", - "DeviceOnlineWithName": "{0} متصل شد", "FailedLoginAttemptWithUserName": "تلاش برای ورود از {0} ناموفق بود", "Favorites": "مورد علاقه‌ها", "Folders": "پوشه‌ها", "Genres": "ژانرها", - "HeaderAlbumArtists": "هنرمندان آلبوم", "HeaderContinueWatching": "ادامه تماشا", - "HeaderFavoriteAlbums": "آلبوم‌های مورد علاقه", - "HeaderFavoriteArtists": "هنرمندان مورد علاقه", "HeaderFavoriteEpisodes": "قسمت‌های مورد علاقه", "HeaderFavoriteShows": "سریال‌های مورد علاقه", - "HeaderFavoriteSongs": "آهنگ‌های مورد علاقه", "HeaderLiveTV": "پخش زنده", "HeaderNextUp": "قسمت بعدی", - "HeaderRecordingGroups": "گروه‌های ضبط", "HomeVideos": "ویدیوهای خانگی", "Inherit": "به ارث برده", - "ItemAddedWithName": "{0} به کتابخانه افزوده شد", - "ItemRemovedWithName": "{0} از کتابخانه حذف شد", "LabelIpAddressValue": "آدرس آی پی: {0}", "LabelRunningTimeValue": "زمان اجرا: {0}", "Latest": "جدیدترین‌ها", - "MessageApplicationUpdated": "سرور Jellyfin بروزرسانی شد", - "MessageApplicationUpdatedTo": "سرور Jellyfin به نسخه {0} بروزرسانی شد", - "MessageNamedServerConfigurationUpdatedWithValue": "پکربندی بخش {0} سرور بروزرسانی شد", - "MessageServerConfigurationUpdated": "پیکربندی سرور بروزرسانی شد", "MixedContent": "محتوای مخلوط", "Movies": "فیلم ها", "Music": "موسیقی", @@ -61,24 +44,14 @@ "NotificationOptionVideoPlayback": "پخش ویدیو آغاز شد", "NotificationOptionVideoPlaybackStopped": "پخش ویدیو متوقف شد", "Photos": "عکس‌ها", - "Playlists": "لیست‌های پخش", - "Plugin": "افزونه", "PluginInstalledWithName": "{0} نصب شد", "PluginUninstalledWithName": "{0} حذف شد", "PluginUpdatedWithName": "{0} آپدیت شد", - "ProviderValue": "ارائه دهنده: {0}", "ScheduledTaskFailedWithName": "{0} شکست خورد", - "ScheduledTaskStartedWithName": "{0} شروع شد", - "ServerNameNeedsToBeRestarted": "{0} نیاز به راه اندازی مجدد دارد", "Shows": "سریال‌ها", - "Songs": "موسیقی‌ها", "StartupEmbyServerIsLoading": "سرور Jellyfin در حال بارگیری است. لطفا کمی بعد دوباره تلاش کنید.", - "SubtitleDownloadFailureForItem": "دانلود زیرنویس برای {0} ناموفق بود", "SubtitleDownloadFailureFromForItem": "بارگیری زیرنویس برای {1} از {0} شکست خورد", - "Sync": "همگام‌سازی", - "System": "سیستم", "TvShows": "سریال‌های تلویزیونی", - "User": "کاربر", "UserCreatedWithName": "کاربر {0} ایجاد شد", "UserDeletedWithName": "کاربر {0} حذف شد", "UserDownloadingItemWithValues": "{0} در حال بارگیری {1} می‌باشد", @@ -86,11 +59,8 @@ "UserOfflineFromDevice": "ارتباط {0} از {1} قطع شد", "UserOnlineFromDevice": "{0} از {1} آنلاین می‌باشد", "UserPasswordChangedWithName": "گذرواژه برای کاربر {0} تغییر کرد", - "UserPolicyUpdatedWithName": "سیاست کاربری برای {0} بروزرسانی شد", "UserStartedPlayingItemWithValues": "{0} در حال پخش {1} بر روی {2} است", "UserStoppedPlayingItemWithValues": "{0} پخش {1} را بر روی {2} به پایان رساند", - "ValueHasBeenAddedToLibrary": "{0} به کتابخانه‌ی رسانه‌ی شما افزوده شد", - "ValueSpecialEpisodeName": "ویژه - {0}", "VersionNumber": "نسخه {0}", "TaskCleanTranscodeDescription": "فایل‌های کدگذاری که قدیمی‌تر از یک روز هستند را حذف می‌کند.", "TaskCleanTranscode": "پاکسازی مسیر کد گذاری", @@ -127,8 +97,6 @@ "HearingImpaired": "مشکل شنوایی", "TaskRefreshTrickplayImages": "تولید تصاویر Trickplay", "TaskRefreshTrickplayImagesDescription": "تولید پیش‌نمایش های trickplay برای ویدیو های فعال شده در کتابخانه.", - "TaskCleanCollectionsAndPlaylists": "پاکسازی مجموعه ها و لیست پخش", - "TaskCleanCollectionsAndPlaylistsDescription": "موارد را از مجموعه ها و لیست پخش هایی که دیگر وجود ندارند حذف میکند.", "TaskAudioNormalizationDescription": "بررسی فایل برای داده‌های نرمال کردن صدا.", "TaskDownloadMissingLyrics": "دانلود متن‌های ناموجود", "TaskDownloadMissingLyricsDescription": "دانلود متن شعر‌ها", diff --git a/Emby.Server.Implementations/Localization/Core/fi.json b/Emby.Server.Implementations/Localization/Core/fi.json index 15a04d22cd..9a453120dd 100644 --- a/Emby.Server.Implementations/Localization/Core/fi.json +++ b/Emby.Server.Implementations/Localization/Core/fi.json @@ -8,57 +8,33 @@ "Music": "Musiikki", "Movies": "Elokuvat", "MixedContent": "Sekalainen sisältö", - "MessageServerConfigurationUpdated": "Palvelimen asetukset on päivitetty", - "MessageNamedServerConfigurationUpdatedWithValue": "Palvelimen asetusten osio {0} on päivitetty", - "MessageApplicationUpdatedTo": "Jellyfin-palvelin on päivitetty versioon {0}", - "MessageApplicationUpdated": "Jellyfin-palvelin on päivitetty", "Latest": "Viimeisimmät", "LabelRunningTimeValue": "Kesto: {0}", "LabelIpAddressValue": "IP-osoite: {0}", - "ItemRemovedWithName": "{0} poistettiin kirjastosta", - "ItemAddedWithName": "{0} lisättiin kirjastoon", "Inherit": "Peri", "HomeVideos": "Kotivideot", - "HeaderRecordingGroups": "Tallennusryhmät", "HeaderNextUp": "Seuraavaksi", - "HeaderFavoriteSongs": "Suosikkikappaleet", "HeaderFavoriteShows": "Suosikkisarjat", "HeaderFavoriteEpisodes": "Suosikkijaksot", - "HeaderFavoriteArtists": "Suosikkiesittäjät", - "HeaderFavoriteAlbums": "Suosikkialbumit", "HeaderContinueWatching": "Jatka katselua", - "HeaderAlbumArtists": "Albumin esittäjät", "Genres": "Tyylilajit", "Folders": "Kansiot", "Favorites": "Suosikit", "FailedLoginAttemptWithUserName": "Epäonnistunut kirjautumisyritys lähteestä \"{0}\"", - "DeviceOnlineWithName": "{0} on yhdistetty", - "DeviceOfflineWithName": "{0} on katkaissut yhteyden", "Collections": "Kokoelmat", "ChapterNameValue": "Kappale {0}", - "Channels": "Kanavat", - "CameraImageUploadedFrom": "Uusi kameran kuva on sirretty lähteestä {0}", "Books": "Kirjat", - "AuthenticationSucceededWithUserName": "{0} on todennettu", - "Artists": "Esittäjät", - "Application": "Sovellus", + "AuthenticationSucceededWithUserName": "{0} todennus onnistunut", + "Artists": "Artistit", "AppDeviceValues": "Sovellus: {0}, Laite: {1}", - "Albums": "Albumit", - "User": "Käyttäjä", - "System": "Järjestelmä", "ScheduledTaskFailedWithName": "{0} epäonnistui", "PluginUpdatedWithName": "{0} päivitettiin", "PluginInstalledWithName": "{0} asennettiin", "Photos": "Valokuvat", - "ScheduledTaskStartedWithName": "\"{0}\" käynnistetty", "PluginUninstalledWithName": "{0} poistettiin", - "Playlists": "Soittolistat", "VersionNumber": "Versio {0}", - "ValueSpecialEpisodeName": "Erikoisjakso - {0}", - "ValueHasBeenAddedToLibrary": "\"{0}\" on lisätty mediakirjastoon", "UserStoppedPlayingItemWithValues": "{0} lopetti kohteen \"{1}\" toiston sijainnissa \"{2}\"", "UserStartedPlayingItemWithValues": "{0} toistaa kohdetta \"{1}\" sijainnissa \"{2}\"", - "UserPolicyUpdatedWithName": "Käyttäjän {0} käyttöoikeudet on päivitetty", "UserPasswordChangedWithName": "Käyttäjän {0} salasana on vaihdettu", "UserOnlineFromDevice": "{0} on yhdistänyt sijainnista \"{1}\"", "UserOfflineFromDevice": "{0} on katkaissut yhteyden sijainnista \"{1}\"", @@ -67,14 +43,9 @@ "UserDeletedWithName": "Käyttäjä {0} on poistettu", "UserCreatedWithName": "Käyttäjä {0} on luotu", "TvShows": "Sarjat", - "Sync": "Synkronointi", "SubtitleDownloadFailureFromForItem": "Tekstityksen lataus lähteestä \"{0}\" kohteelle \"{1}\" epäonnistui", "StartupEmbyServerIsLoading": "Jellyfin-palvelin on latautumassa. Yritä hetken kuluttua uudelleen.", - "Songs": "Kappaleet", "Shows": "Sarjat", - "ServerNameNeedsToBeRestarted": "\"{0}\" on käynnistettävä uudelleen", - "ProviderValue": "Lähde: {0}", - "Plugin": "Lisäosa", "NotificationOptionVideoPlaybackStopped": "Videon toisto lopetettu", "NotificationOptionVideoPlayback": "Videon toisto aloitettu", "NotificationOptionUserLockedOut": "Käyttäjä on lukittu", @@ -126,8 +97,6 @@ "HearingImpaired": "Kuulorajoitteinen", "TaskRefreshTrickplayImages": "Luo Trickplay-kuvat", "TaskRefreshTrickplayImagesDescription": "Luo Trickplay-esikatselut käytössä olevien kirjastojen videoista.", - "TaskCleanCollectionsAndPlaylistsDescription": "Poistaa kohteet kokoelmista ja soittolistoista joita ei ole enää olemassa.", - "TaskCleanCollectionsAndPlaylists": "Puhdista kokoelmat ja soittolistat", "TaskAudioNormalization": "Äänenvoimakkuuden normalisointi", "TaskAudioNormalizationDescription": "Etsii tiedostoista äänenvoimakkuuden normalisointitietoja.", "TaskDownloadMissingLyrics": "Lataa puuttuva lyriikka", @@ -137,5 +106,19 @@ "TaskMoveTrickplayImages": "Siirrä Trickplay-kuvien sijainti", "TaskMoveTrickplayImagesDescription": "Siirtää olemassa olevia trickplay-tiedostoja kirjaston asetusten mukaan.", "CleanupUserDataTask": "Käyttäjätietojen puhdistustehtävä", - "CleanupUserDataTaskDescription": "Puhdistaa kaikki käyttäjätiedot (katselutila, suosikit ym.) medioista, joita ei ole ollut saatavilla yli 90 päivään." + "CleanupUserDataTaskDescription": "Puhdistaa kaikki käyttäjätiedot (katselutila, suosikit ym.) medioista, joita ei ole ollut saatavilla yli 90 päivään.", + "LyricDownloadFailureFromForItem": "Sanoitusten lataus kohteesta {0} kappaleelle {1} epäonnistui", + "Original": "Alkuperäinen", + "NameExtraBehindTheScenes": "Kulissien Takana", + "NameExtraClip": "Klippi", + "NameExtraDeletedScene": "Poistettu Kohtaus", + "NameExtraFeaturette": "Lyhytelokuva", + "NameExtraInterview": "Haastattelu", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Näyte", + "NameExtraScene": "Kohtaus", + "NameExtraShort": "Lyhytfilmi", + "NameExtraThemeSong": "Tunnusmusiikki", + "NameExtraThemeVideo": "Tunnusvideo", + "NameExtraTrailer": "Traileri" } diff --git a/Emby.Server.Implementations/Localization/Core/fil.json b/Emby.Server.Implementations/Localization/Core/fil.json index 28c1d2be5e..30d90e9f98 100644 --- a/Emby.Server.Implementations/Localization/Core/fil.json +++ b/Emby.Server.Implementations/Localization/Core/fil.json @@ -1,10 +1,7 @@ { "VersionNumber": "Bersyon {0}", - "ValueSpecialEpisodeName": "Espesyal - {0}", - "ValueHasBeenAddedToLibrary": "Naidagdag na ang {0} sa iyong librerya ng medya", "UserStoppedPlayingItemWithValues": "Natapos ni {0} ang {1} sa {2}", "UserStartedPlayingItemWithValues": "Si {0} ay nagpla-play ng {1} sa {2}", - "UserPolicyUpdatedWithName": "Ang user policy ay nai-update para kay {0}", "UserPasswordChangedWithName": "Napalitan na ang password ni {0}", "UserOnlineFromDevice": "Si {0} ay naka-konekta galing sa {1}", "UserOfflineFromDevice": "Si {0} ay na-diskonekta galing sa {1}", @@ -12,23 +9,14 @@ "UserDownloadingItemWithValues": "Nagdadownload si {0} ng {1}", "UserDeletedWithName": "Natanggal na is user {0}", "UserCreatedWithName": "Nagawa na si user {0}", - "User": "User", "TvShows": "Mga Palabas sa Telebisyon", - "System": "Sistema", - "Sync": "Pag-sync", "SubtitleDownloadFailureFromForItem": "Hindi nai-download ang subtitles {0} para sa {1}", "StartupEmbyServerIsLoading": "Naglo-load ang Jellyfin Server. Mangyaring subukan ulit sandali.", - "Songs": "Mga Kanta", "Shows": "Mga Pelikula", - "ServerNameNeedsToBeRestarted": "Kailangan irestart ang {0}", - "ScheduledTaskStartedWithName": "Nagsimula na ang {0}", "ScheduledTaskFailedWithName": "Hindi gumana ang {0}", - "ProviderValue": "Tagapagtustos: {0}", "PluginUpdatedWithName": "Naiupdate na ang {0}", "PluginUninstalledWithName": "Naiuninstall na ang {0}", "PluginInstalledWithName": "Nainstall na ang {0}", - "Plugin": "Plugin", - "Playlists": "Mga Playlist", "Photos": "Mga Larawan", "NotificationOptionVideoPlaybackStopped": "Huminto na ang pelikula", "NotificationOptionVideoPlayback": "Nagsimula na ang pelikula", @@ -54,42 +42,25 @@ "Music": "Mga Kanta", "Movies": "Mga Pelikula", "MixedContent": "Halo-halong content", - "MessageServerConfigurationUpdated": "Naiupdate na ang server configuration", - "MessageNamedServerConfigurationUpdatedWithValue": "Naiupdate na ang server configuration section {0}", - "MessageApplicationUpdatedTo": "Ang bersyon ng Jellyfin Server ay naiupdate sa {0}", - "MessageApplicationUpdated": "Naiupdate na ang Jellyfin Server", "Latest": "Pinakabago", "LabelRunningTimeValue": "Oras: {0}", "LabelIpAddressValue": "IP address: {0}", - "ItemRemovedWithName": "Naitanggal ang {0} sa librerya", - "ItemAddedWithName": "Naidagdag ang {0} sa librerya", "Inherit": "Manahin", - "HeaderRecordingGroups": "Pagtatalang Grupo", "HeaderNextUp": "Susunod", "HeaderLiveTV": "Live TV", - "HeaderFavoriteSongs": "Mga Paboritong Kanta", "HeaderFavoriteShows": "Mga Paboritong Pelikula", "HeaderFavoriteEpisodes": "Mga Paboritong Yugto", - "HeaderFavoriteArtists": "Mga Paboritong Artista", - "HeaderFavoriteAlbums": "Mga Paboritong Album", "HeaderContinueWatching": "Magpatuloy sa Panonood", - "HeaderAlbumArtists": "Mga Artista ng Album", "Genres": "Mga Kategorya", "Folders": "Mga Folder", "Favorites": "Mga Paborito", "FailedLoginAttemptWithUserName": "Maling login galing kay/sa {0}", - "DeviceOnlineWithName": "Nakakonekta si/ang {0}", - "DeviceOfflineWithName": "Nadiskonekta si/ang {0}", "Collections": "Mga Koleksyon", "ChapterNameValue": "Kabanata {0}", - "Channels": "Mga Channel", - "CameraImageUploadedFrom": "May bagong larawan na naupload galing sa/kay {0}", "Books": "Mga Libro", "AuthenticationSucceededWithUserName": "Napatunayan si/ang {0}", "Artists": "Mga Artista", - "Application": "Aplikasyon", "AppDeviceValues": "Aplikasyon: {0}, Aparato: {1}", - "Albums": "Mga Album", "TaskRefreshLibrary": "Suriin and Librerya ng Medya", "TaskRefreshChapterImagesDescription": "Gumawa ng larawan para sa mga pelikula na may kabanata.", "TaskRefreshChapterImages": "Kunin ang mga larawan ng kabanata", diff --git a/Emby.Server.Implementations/Localization/Core/fo.json b/Emby.Server.Implementations/Localization/Core/fo.json index 40aa5f71a4..1723120c16 100644 --- a/Emby.Server.Implementations/Localization/Core/fo.json +++ b/Emby.Server.Implementations/Localization/Core/fo.json @@ -1,18 +1,125 @@ { - "Artists": "Listafólk", - "Collections": "Søvn", - "Default": "Sjálvgildi", - "DeviceOfflineWithName": "{0} hevur slitið sambandið", + "Artists": "Tónlistafólk", + "Collections": "Samlingar", + "Default": "Forsett", "External": "Ytri", "Genres": "Greinar", - "Albums": "Album", "AppDeviceValues": "App: {0}, Eind: {1}", - "Application": "Nýtsluskipan", "Books": "Bøkur", - "Channels": "Rásir", "ChapterNameValue": "Kapittul {0}", - "DeviceOnlineWithName": "{0} er sambundið", - "Favorites": "Yndis", - "Folders": "Mappur", - "Forced": "Kravt" + "Favorites": "Yndislista", + "Folders": "Skjáttur", + "Forced": "Kravt", + "FailedLoginAttemptWithUserName": "Miseydnað innritanarroynd frá {0}", + "HeaderFavoriteEpisodes": "Yndispartar", + "LabelIpAddressValue": "IP-atsetur: {0}", + "AuthenticationSucceededWithUserName": "{0} var samgildur", + "HeaderFavoriteShows": "Yndisrøðir", + "HeaderLiveTV": "Beinleiðis sjónvarp", + "HearingImpaired": "Hoyrnarveik", + "Inherit": "Arvar", + "LabelRunningTimeValue": "Spælitíð: {0}", + "Latest": "Seinastu", + "LyricDownloadFailureFromForItem": "Miseydnaðist at niðurtakað sangtekst fyri {1} frá {0}", + "NameInstallFailed": "{0} innlegging miseydnaðist", + "NewVersionIsAvailable": "Ein nýggj útgáva av Jellyfin ambætaranum er tøk.", + "NotificationOptionNewLibraryContent": "Nýtt tilfar innlagt", + "NotificationOptionPluginInstalled": "Ískoytisforrit innlagt", + "NotificationOptionPluginUninstalled": "Ískoytisforrit strikað", + "NotificationOptionPluginUpdateInstalled": "Ískoytisforrit dagført", + "NotificationOptionUserLockedOut": "Brúkari útihýstur", + "Photos": "Ljósmyndir", + "PluginInstalledWithName": "{0} innlagt", + "PluginUninstalledWithName": "{0} strikað", + "PluginUpdatedWithName": "{0} dagført", + "Shows": "Røðir", + "SubtitleDownloadFailureFromForItem": "Miseydnaðist at niðurtakað undirtekstir til {1} frá {0}", + "TvShows": "Sjónvarpsrøðir", + "UserCreatedWithName": "Brúkari {0} er stovnaður", + "UserDeletedWithName": "Brúkari {0} er strikaður", + "UserDownloadingItemWithValues": "{0} niðurtekur {1}", + "UserLockedOutWithName": "Brúkari {0} er útihýstur", + "VersionNumber": "Útgáva {0}", + "TasksLibraryCategory": "Savn", + "TaskRefreshLibrary": "Skanna miðlasavn", + "TaskCleanLogsDescription": "Strikar gerðalistafílur eldri enn {0} dagar.", + "TaskUpdatePlugins": "Dagfør ískoytisforrit", + "TaskRefreshChannels": "Endurinnles rásir", + "TaskDownloadMissingLyricsDescription": "Niðurtekur sangtekstir", + "Movies": "Filmar", + "MixedContent": "Blandað innihald", + "Music": "Tónleikur", + "UserStartedPlayingItemWithValues": "{0} spælur {1} á {2}", + "HeaderContinueWatching": "Hald áfram at hyggja", + "MusicVideos": "Sjónbandaløg", + "TaskUpdatePluginsDescription": "Niðurtekur og innleggur dagføringar til ískoytisforrit ið eru stillaði til at dagførast sjálvvirkandi.", + "TaskCleanTranscodeDescription": "Strikar umkotaðar fílar ið eru eldri enn 1 dag.", + "TaskOptimizeDatabase": "Albøt dátugrunn", + "NameSeasonNumber": "Umfar {0}", + "NameSeasonUnknown": "Ókent umfar", + "ScheduledTaskFailedWithName": "{0} miseydnaðist", + "Undefined": "Óskilmarkað", + "TasksMaintenanceCategory": "Viðlíkahald", + "TaskCleanLogs": "Reinsa gerðalistaskjáttu", + "UserOnlineFromDevice": "{0} er íbundin frá {1}", + "HeaderNextUp": "Næst á skránni", + "NotificationOptionPluginError": "Brek í ískoytisforriti", + "NotificationOptionInstallationFailed": "Innleggingarbrek", + "NotificationOptionServerRestartRequired": "Tørvur er á ambætaraendurbyrjan", + "TasksApplicationCategory": "Nýtsluskipan", + "NotificationOptionApplicationUpdateAvailable": "Skipanardagføring er tøk", + "NotificationOptionApplicationUpdateInstalled": "Skipanardagføring var innløgd", + "UserStoppedPlayingItemWithValues": "{0} er liðugur at spæla {1} á {2}", + "HomeVideos": "Heimaupptøkur", + "StartupEmbyServerIsLoading": "Jellyfin-ambætarin er undir byrjanarinnlesing. Vinaliga royn aftur um eitt bil.", + "UserOfflineFromDevice": "{0} breyt av á {1}", + "UserPasswordChangedWithName": "Loyniorðið hjá brúkaranum {0} er broytt", + "TasksChannelsCategory": "Alnetsrásir", + "TaskCleanActivityLog": "Reinsa virksemisskrá", + "TaskCleanActivityLogDescription": "Strikar skrásetingar eldri enn ásetta aldur.", + "TaskCleanCache": "Reinsa kovaskjáttu", + "TaskCleanCacheDescription": "Strikar kovafílar ið kervið ikki hevur tørv á longur.", + "TaskCleanTranscode": "Reinsa umkotuskjáttu", + "TaskDownloadMissingLyrics": "Niðurtak vantandi sangtekstir", + "TaskDownloadMissingSubtitles": "Niðurtak vantandi undirtekstir", + "CleanupUserDataTaskDescription": "Strikar allar brúkaradátur, so sum spælistøðu, yndislistastøðu o.s.fr., fyri miðlar ið ikki hava verið tøkir í í minsta lagi 90 dagar.", + "CleanupUserDataTask": "Koyrsla ið reinsar brúkaradátur", + "TaskRefreshPeople": "Dagfør persónsupplýsingar", + "TaskRefreshPeopleDescription": "Dagførur metadátur um leikarar og leikstjórar í tínum margmiðlasavni.", + "TaskRefreshChannelsDescription": "Dagførur upplýsingar um alnetsrásir.", + "TaskDownloadMissingSubtitlesDescription": "Leitar á alnótini eftir vantandi undirtekstum grundað á metadátauppsetan.", + "NotificationOptionTaskFailed": "Brek undir fyriskipaðari koyrslu", + "TaskRefreshLibraryDescription": "Skannar títt miðlasavn fyri nýggjum fílum og dagførur metadátur.", + "TaskKeyframeExtractor": "Lyklamyndaúttøka", + "TaskKeyframeExtractorDescription": "Úttekur lyklamyndir frá kykmynda-fílum til tess at byggja nágreiniligari HLS-spælilistar. Koyrslan kann taka langa tíð.", + "TaskOptimizeDatabaseDescription": "Trýstur dátugrunninin saman og loysur tóma goymslu. Koyrslan kann bøta um avrikið, eftir skanning ella aðrar broytingar í savninum ið elva til dátugrunnsbroytingar.", + "TaskRefreshChapterImagesDescription": "Ger smámyndir fyri kykmyndir ið hava kapitlar.", + "TaskRefreshChapterImages": "Kapitlamyndaúttøkur", + "NotificationOptionVideoPlayback": "Kykmyndaspæl byrjað", + "NotificationOptionVideoPlaybackStopped": "Kykmyndaspæl steðgað", + "NotificationOptionAudioPlayback": "Ljóðspæl byrjað", + "NotificationOptionAudioPlaybackStopped": "Ljóðspæl steðgað", + "TaskExtractMediaSegments": "Leita eftir margmiðlabrotum", + "TaskExtractMediaSegmentsDescription": "Framleiður upplýsingar um brot í margmiðlum, við hjálp frá MediaSegment-virktum ískoytisforritum.", + "NotificationOptionCameraImageUploaded": "Ljósmynd uppsend", + "NameExtraShort": "Stuttfilmur", + "NameExtraThemeSong": "Eyðkennislag", + "NameExtraTrailer": "Brellbiti", + "NameExtraInterview": "Samrøða", + "NameExtraBehindTheScenes": "Aftanfyri leiktjøldini", + "NameExtraClip": "Klipp", + "NameExtraNumbered": "{0} {1}", + "NameExtraFeaturette": "Stuttur heimildarfilmur", + "TaskAudioNormalization": "Ljóðjavnan", + "TaskAudioNormalizationDescription": "Kannar fílur fyri dátum til ljóðjavnan.", + "NameExtraSample": "Sýnislutur", + "TaskRefreshTrickplayImages": "Framleið Trickplay-myndir", + "TaskRefreshTrickplayImagesDescription": "Framleiðir trickplay-myndir fyri kykmyndir í søvnunm har tað er virkt.", + "TaskMoveTrickplayImages": "Flyt Trickplay-myndagoymslustað", + "TaskMoveTrickplayImagesDescription": "Flytur verandi trickplay-fílur sambært savnsstillingunum.", + "NameExtraThemeVideo": "Eyðkenniskykmynd", + "NameExtraDeletedScene": "Úrtikin mynd", + "NameExtraScene": "Mynd", + "NameExtraUnknown": "Eykatilfar", + "Original": "Upprunalig(t/ur)" } diff --git a/Emby.Server.Implementations/Localization/Core/fr-CA.json b/Emby.Server.Implementations/Localization/Core/fr-CA.json index 6d079d2f57..393af79ab8 100644 --- a/Emby.Server.Implementations/Localization/Core/fr-CA.json +++ b/Emby.Server.Implementations/Localization/Core/fr-CA.json @@ -1,41 +1,24 @@ { - "Albums": "Albums", "AppDeviceValues": "App : {0}, Appareil : {1}", - "Application": "Application", "Artists": "Artistes", "AuthenticationSucceededWithUserName": "{0} authentifié avec succès", "Books": "Livres", - "CameraImageUploadedFrom": "Une nouvelle photo a été téléversée depuis {0}", - "Channels": "Chaînes", "ChapterNameValue": "Chapitre {0}", "Collections": "Collections", - "DeviceOfflineWithName": "{0} s'est déconnecté", - "DeviceOnlineWithName": "{0} est connecté", "FailedLoginAttemptWithUserName": "Tentative de connexion échouée par {0}", "Favorites": "Favoris", "Folders": "Dossiers", "Genres": "Genres", - "HeaderAlbumArtists": "Artistes de l'album", "HeaderContinueWatching": "Reprendre le visionnement", - "HeaderFavoriteAlbums": "Albums favoris", - "HeaderFavoriteArtists": "Artistes favoris", "HeaderFavoriteEpisodes": "Épisodes favoris", "HeaderFavoriteShows": "Séries favorites", - "HeaderFavoriteSongs": "Chansons favorites", "HeaderLiveTV": "TV en direct", "HeaderNextUp": "À Suivre", - "HeaderRecordingGroups": "Groupes d'enregistrements", "HomeVideos": "Vidéos personnelles", "Inherit": "Hérite", - "ItemAddedWithName": "{0} a été ajouté à la médiathèque", - "ItemRemovedWithName": "{0} a été supprimé de la médiathèque", "LabelIpAddressValue": "Adresse IP : {0}", "LabelRunningTimeValue": "Durée : {0}", "Latest": "Plus récent", - "MessageApplicationUpdated": "Le serveur Jellyfin a été mis à jour", - "MessageApplicationUpdatedTo": "Le serveur Jellyfin a été mis à jour vers la version {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "La configuration de la section {0} du serveur a été mise à jour", - "MessageServerConfigurationUpdated": "La configuration du serveur a été mise à jour", "MixedContent": "Contenu mixte", "Movies": "Films", "Music": "Musique", @@ -61,24 +44,14 @@ "NotificationOptionVideoPlayback": "Lecture vidéo démarrée", "NotificationOptionVideoPlaybackStopped": "Lecture vidéo arrêtée", "Photos": "Photos", - "Playlists": "Listes de lecture", - "Plugin": "Extension", "PluginInstalledWithName": "{0} a été installé", "PluginUninstalledWithName": "{0} a été désinstallé", "PluginUpdatedWithName": "{0} a été mis à jour", - "ProviderValue": "Fournisseur : {0}", "ScheduledTaskFailedWithName": "{0} a échoué", - "ScheduledTaskStartedWithName": "{0} a commencé", - "ServerNameNeedsToBeRestarted": "{0} doit être redémarré", "Shows": "Séries", - "Songs": "Chansons", "StartupEmbyServerIsLoading": "Serveur Jellyfin en cours de chargement. Réessayez dans quelques instants.", - "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", "SubtitleDownloadFailureFromForItem": "Échec du téléchargement des sous-titres depuis {0} pour {1}", - "Sync": "Synchroniser", - "System": "Système", "TvShows": "Séries Télé", - "User": "Utilisateur", "UserCreatedWithName": "L'utilisateur {0} a été créé", "UserDeletedWithName": "L'utilisateur {0} supprimé", "UserDownloadingItemWithValues": "{0} télécharge {1}", @@ -86,11 +59,8 @@ "UserOfflineFromDevice": "{0} s'est déconnecté de {1}", "UserOnlineFromDevice": "{0} s'est connecté de {1}", "UserPasswordChangedWithName": "Le mot de passe de utilisateur {0} a été modifié", - "UserPolicyUpdatedWithName": "La politique de l'utilisateur a été mise à jour pour {0}", "UserStartedPlayingItemWithValues": "{0} joue {1} sur {2}", "UserStoppedPlayingItemWithValues": "{0} a terminé la lecture de {1} sur {2}", - "ValueHasBeenAddedToLibrary": "{0} a été ajouté à votre médiathèque", - "ValueSpecialEpisodeName": "Spécial - {0}", "VersionNumber": "Version {0}", "TasksLibraryCategory": "Médiathèque", "TasksMaintenanceCategory": "Entretien", @@ -127,8 +97,6 @@ "HearingImpaired": "Malentendants", "TaskRefreshTrickplayImages": "Générer des images Trickplay", "TaskRefreshTrickplayImagesDescription": "Crée des aperçus Trickplay pour les vidéos dans les médiathèques activées.", - "TaskCleanCollectionsAndPlaylists": "Nettoyer les collections et les listes de lecture", - "TaskCleanCollectionsAndPlaylistsDescription": "Supprime les éléments des collections et des listes de lecture qui n'existent plus.", "TaskAudioNormalization": "Normalisation audio", "TaskAudioNormalizationDescription": "Analyse les fichiers à la recherche de données de normalisation audio.", "TaskExtractMediaSegments": "Analyse des segments de média", @@ -138,5 +106,20 @@ "TaskMoveTrickplayImages": "Changer l'emplacement des images Trickplay", "TaskExtractMediaSegmentsDescription": "Extrait ou obtient des segments de média à partir des plugins compatibles avec MediaSegment.", "CleanupUserDataTaskDescription": "Nettoie toutes les données utilisateur (état de la montre, statut favori, etc.) des supports qui ne sont plus présents depuis au moins 90 jours.", - "CleanupUserDataTask": "Tâche de nettoyage des données utilisateur" + "CleanupUserDataTask": "Tâche de nettoyage des données utilisateur", + "LyricDownloadFailureFromForItem": "Le téléchargement des paroles a échoué de {0} pour {1}", + "Original": "Original", + "NameExtraBehindTheScenes": "Dans Les Coulisses", + "NameExtraClip": "Clip", + "NameExtraDeletedScene": "Scène supprimée", + "NameExtraFeaturette": "Court-métrage", + "NameExtraInterview": "Entrevue", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Échantillon", + "NameExtraScene": "Scène", + "NameExtraShort": "Court-métrage", + "NameExtraThemeSong": "Chanson thème", + "NameExtraThemeVideo": "Générique", + "NameExtraTrailer": "Bande-annonce", + "NameExtraUnknown": "Extra" } diff --git a/Emby.Server.Implementations/Localization/Core/fr.json b/Emby.Server.Implementations/Localization/Core/fr.json index 8bf41c02a7..858fd9eff6 100644 --- a/Emby.Server.Implementations/Localization/Core/fr.json +++ b/Emby.Server.Implementations/Localization/Core/fr.json @@ -1,41 +1,24 @@ { - "Albums": "Albums", "AppDeviceValues": "Application : {0}, Appareil : {1}", - "Application": "Application", "Artists": "Artistes", "AuthenticationSucceededWithUserName": "{0} authentifié avec succès", "Books": "Livres", - "CameraImageUploadedFrom": "Une photo a été téléchargée depuis {0}", - "Channels": "Chaînes", "ChapterNameValue": "Chapitre {0}", "Collections": "Collections", - "DeviceOfflineWithName": "{0} s'est déconnecté", - "DeviceOnlineWithName": "{0} est connecté", "FailedLoginAttemptWithUserName": "Échec de connexion depuis {0}", "Favorites": "Favoris", "Folders": "Dossiers", "Genres": "Genres", - "HeaderAlbumArtists": "Artistes d'albums", "HeaderContinueWatching": "Continuer de regarder", - "HeaderFavoriteAlbums": "Albums favoris", - "HeaderFavoriteArtists": "Artistes préférés", "HeaderFavoriteEpisodes": "Épisodes favoris", "HeaderFavoriteShows": "Séries favorites", - "HeaderFavoriteSongs": "Chansons préférées", "HeaderLiveTV": "TV en direct", "HeaderNextUp": "Prochain à venir", - "HeaderRecordingGroups": "Groupes d'enregistrements", "HomeVideos": "Vidéos personnelles", "Inherit": "Hériter", - "ItemAddedWithName": "{0} a été ajouté à la médiathèque", - "ItemRemovedWithName": "{0} a été supprimé de la médiathèque", "LabelIpAddressValue": "Adresse IP : {0}", "LabelRunningTimeValue": "Durée : {0}", "Latest": "Derniers", - "MessageApplicationUpdated": "Le serveur Jellyfin a été mis à jour", - "MessageApplicationUpdatedTo": "Le serveur Jellyfin a été mis à jour en version {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "La configuration de la section {0} du serveur a été mise à jour", - "MessageServerConfigurationUpdated": "La configuration du serveur a été mise à jour", "MixedContent": "Contenu mixte", "Movies": "Films", "Music": "Musique", @@ -61,24 +44,14 @@ "NotificationOptionVideoPlayback": "Lecture vidéo démarrée", "NotificationOptionVideoPlaybackStopped": "Lecture vidéo arrêtée", "Photos": "Photos", - "Playlists": "Listes de lecture", - "Plugin": "Extension", "PluginInstalledWithName": "{0} a été installé", "PluginUninstalledWithName": "{0} a été désinstallé", "PluginUpdatedWithName": "{0} a été mis à jour", - "ProviderValue": "Fournisseur : {0}", "ScheduledTaskFailedWithName": "{0} a échoué", - "ScheduledTaskStartedWithName": "{0} a démarré", - "ServerNameNeedsToBeRestarted": "{0} doit être redémarré", "Shows": "Séries", - "Songs": "Chansons", "StartupEmbyServerIsLoading": "Le serveur Jellyfin est en cours de chargement. Veuillez réessayer dans quelques instants.", - "SubtitleDownloadFailureForItem": "Le téléchargement des sous-titres pour {0} a échoué.", "SubtitleDownloadFailureFromForItem": "Échec du téléchargement des sous-titres depuis {0} pour {1}", - "Sync": "Synchroniser", - "System": "Système", "TvShows": "Séries TV", - "User": "Utilisateur", "UserCreatedWithName": "L'utilisateur {0} a été créé", "UserDeletedWithName": "L'utilisateur {0} a été supprimé", "UserDownloadingItemWithValues": "{0} est en train de télécharger {1}", @@ -86,11 +59,8 @@ "UserOfflineFromDevice": "{0} s'est déconnecté depuis {1}", "UserOnlineFromDevice": "{0} s'est connecté depuis {1}", "UserPasswordChangedWithName": "Le mot de passe pour l'utilisateur {0} a été modifié", - "UserPolicyUpdatedWithName": "La politique de l'utilisateur a été mise à jour pour {0}", "UserStartedPlayingItemWithValues": "{0} est en train de lire {1} sur {2}", "UserStoppedPlayingItemWithValues": "{0} vient d'arrêter la lecture de {1} sur {2}", - "ValueHasBeenAddedToLibrary": "{0} a été ajouté à votre médiathèque", - "ValueSpecialEpisodeName": "Spécial - {0}", "VersionNumber": "Version {0}", "TasksChannelsCategory": "Chaînes en ligne", "TaskDownloadMissingSubtitlesDescription": "Recherche les sous-titres manquants sur Internet en se basant sur la configuration des métadonnées.", @@ -127,8 +97,6 @@ "HearingImpaired": "Malentendants", "TaskRefreshTrickplayImages": "Générer des images Trickplay", "TaskRefreshTrickplayImagesDescription": "Crée des aperçus Trickplay pour les vidéos dans les médiathèques activées.", - "TaskCleanCollectionsAndPlaylists": "Nettoyer les collections et les listes de lecture", - "TaskCleanCollectionsAndPlaylistsDescription": "Supprime les éléments des collections et des listes de lecture qui n'existent plus.", "TaskAudioNormalization": "Normalisation audio", "TaskAudioNormalizationDescription": "Analyse les fichiers à la recherche de données de normalisation audio.", "TaskDownloadMissingLyricsDescription": "Téléchargement des paroles des chansons", @@ -138,5 +106,20 @@ "TaskExtractMediaSegmentsDescription": "Extrait ou obtient des segments de média à partir des plugins compatibles avec MediaSegment.", "TaskMoveTrickplayImagesDescription": "Déplace les fichiers trickplay existants en fonction des paramètres de la bibliothèque.", "CleanupUserDataTaskDescription": "Nettoie toutes les données utilisateur (état de la montre, statut favori, etc.) des supports qui ne sont plus présents depuis au moins 90 jours.", - "CleanupUserDataTask": "Tâche de nettoyage des données utilisateur" + "CleanupUserDataTask": "Tâche de nettoyage des données utilisateur", + "LyricDownloadFailureFromForItem": "Le téléchargement des paroles à échoué de {0} pour {1}", + "Original": "Original", + "NameExtraBehindTheScenes": "Dans Les Coulisses", + "NameExtraClip": "Clip", + "NameExtraDeletedScene": "Scène supprimée", + "NameExtraFeaturette": "Court-métrage", + "NameExtraInterview": "Entrevue", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Échantillon", + "NameExtraScene": "Scène", + "NameExtraShort": "Court-métrage", + "NameExtraThemeSong": "Thème musical", + "NameExtraThemeVideo": "Générique", + "NameExtraTrailer": "Bande-annonce", + "NameExtraUnknown": "Extra" } diff --git a/Emby.Server.Implementations/Localization/Core/ga.json b/Emby.Server.Implementations/Localization/Core/ga.json index 8c0ae8922a..30e11d15f0 100644 --- a/Emby.Server.Implementations/Localization/Core/ga.json +++ b/Emby.Server.Implementations/Localization/Core/ga.json @@ -1,15 +1,10 @@ { - "Albums": "Albaim", "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": "Réamhshocrú", - "DeviceOfflineWithName": "Tá {0} dícheangailte", - "DeviceOnlineWithName": "Tá {0} nasctha", "External": "Seachtrach", "FailedLoginAttemptWithUserName": "Theip ar iarracht logáil isteach ó {0}", "Favorites": "Ceanáin", @@ -29,41 +24,27 @@ "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", @@ -89,24 +70,15 @@ "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}", @@ -114,11 +86,8 @@ "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", @@ -137,5 +106,19 @@ "TaskCleanTranscode": "Eolaire Transcode Glan", "TaskDownloadMissingSubtitles": "Íosluchtaigh fotheidil ar iarraidh", "CleanupUserDataTask": "Tasc glantacháin sonraí úsáideora", - "CleanupUserDataTaskDescription": "Glanann sé gach sonraí úsáideora (stádas faire, stádas is fearr leat srl.) ó mheáin nach bhfuil i láthair a thuilleadh ar feadh 90 lá ar a laghad." + "CleanupUserDataTaskDescription": "Glanann sé gach sonraí úsáideora (stádas faire, stádas is fearr leat srl.) ó mheáin nach bhfuil i láthair a thuilleadh ar feadh 90 lá ar a laghad.", + "Original": "Bunaidh", + "LyricDownloadFailureFromForItem": "Theip ar liricí a íoslódáil ó {0} do {1}", + "NameExtraBehindTheScenes": "Taobh thiar de na Radhairc", + "NameExtraClip": "Gearrthóg", + "NameExtraDeletedScene": "Radharc Scriosta", + "NameExtraFeaturette": "Mionghné", + "NameExtraInterview": "Agallamh", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Sampla", + "NameExtraScene": "Radharc", + "NameExtraShort": "Gearr", + "NameExtraThemeSong": "Amhrán Téama", + "NameExtraThemeVideo": "Físeán Téama", + "NameExtraTrailer": "Leantóir" } diff --git a/Emby.Server.Implementations/Localization/Core/gl.json b/Emby.Server.Implementations/Localization/Core/gl.json index b3f137febd..9eb9786b8a 100644 --- a/Emby.Server.Implementations/Localization/Core/gl.json +++ b/Emby.Server.Implementations/Localization/Core/gl.json @@ -1,13 +1,9 @@ { - "Albums": "Álbums", "Collections": "Coleccións", "ChapterNameValue": "Capítulo {0}", - "Channels": "Canles", - "CameraImageUploadedFrom": "Cargouse unha nova imaxe de cámara dende {0}", "Books": "Libros", "AuthenticationSucceededWithUserName": "{0} autenticouse correctamente", "Artists": "Artistas", - "Application": "Aplicación", "NotificationOptionServerRestartRequired": "Necesario o reinicio do servidor", "NotificationOptionPluginUpdateInstalled": "Actualización do plugin instalada", "NotificationOptionPluginUninstalled": "Plugin desinstalado", @@ -28,63 +24,41 @@ "Music": "Música", "Movies": "Películas", "MixedContent": "Contido mixto", - "MessageServerConfigurationUpdated": "Actualizouse a configuración do servidor", - "MessageNamedServerConfigurationUpdatedWithValue": "Actualizouse a sección de configuración {0} do servidor", - "MessageApplicationUpdatedTo": "O servidor Jellyfin actualizouse a {0}", - "MessageApplicationUpdated": "O servidor Jellyfin actualizouse", "Latest": "Último", "LabelRunningTimeValue": "Tempo en execución: {0}", "LabelIpAddressValue": "Enderezo IP: {0}", - "ItemRemovedWithName": "{0} eliminouse da biblioteca", - "ItemAddedWithName": "{0} engadiuse á biblioteca", "Inherit": "Herdar", "HomeVideos": "Videos caseiros", - "HeaderRecordingGroups": "Grupos de grabación", "HeaderNextUp": "De seguido", "HeaderLiveTV": "TV en directo", - "HeaderFavoriteSongs": "Cancións favoritas", "HeaderFavoriteShows": "Series de TV favoritas", "HeaderFavoriteEpisodes": "Episodios favoritos", - "HeaderFavoriteArtists": "Artistas favoritos", - "HeaderFavoriteAlbums": "Álbums favoritos", "HeaderContinueWatching": "Seguir vendo", - "HeaderAlbumArtists": "Artistas do álbum", "Genres": "Xéneros", "Forced": "Forzado", "Folders": "Cartafoles", "Favorites": "Favoritos", "FailedLoginAttemptWithUserName": "Fallo de intento de inicio de sesión dende {0}", - "DeviceOnlineWithName": "{0} conectouse", - "DeviceOfflineWithName": "{0} desconectouse", "Default": "Por defecto", "AppDeviceValues": "Aplicación: {0}, Dispositivo: {1}", "TaskCleanLogs": "Limpar directorio de rexistros", "TaskCleanActivityLog": "Limpar rexistro de actividade", "TasksChannelsCategory": "Canles da Internet", "TaskUpdatePlugins": "Actualizar plugins", - "User": "Usuario", "Undefined": "Sen definir", "TvShows": "Programas de TV", - "System": "Sistema", - "Sync": "Sincronizar", "SubtitleDownloadFailureFromForItem": "Fallou a descarga de subtítulos para {1} dende {0}", "StartupEmbyServerIsLoading": "O servidor Jellyfin está cargando. Por favor, ténteo axiña outra vez.", - "Songs": "Cancións", "Shows": "Programas", - "ServerNameNeedsToBeRestarted": "{0} precisa ser reiniciado", - "ScheduledTaskStartedWithName": "{0} comezou", "ScheduledTaskFailedWithName": "{0} fallou", - "ProviderValue": "Provedor: {0}", "PluginUpdatedWithName": "{0} foi actualizado", "PluginUninstalledWithName": "{0} foi desinstalado", "PluginInstalledWithName": "{0} foi instalado", - "Playlists": "Listas de reproducción", "Photos": "Fotos", "UserLockedOutWithName": "O usuario {0} foi bloqueado", "UserDownloadingItemWithValues": "{0} está a ser transferido {1}", "UserDeletedWithName": "O usuario {0} foi borrado", "UserCreatedWithName": "O usuario {0} foi creado", - "Plugin": "Plugin", "NotificationOptionVideoPlaybackStopped": "Reproducción de vídeo detida", "NotificationOptionVideoPlayback": "Reproducción de vídeo iniciada", "NotificationOptionUserLockedOut": "Usuario bloqueado", @@ -109,12 +83,9 @@ "TaskCleanCache": "Limpar directorio de caché", "TaskCleanActivityLogDescription": "Borra do rexistro de actividade as entradas anteriores á data configurada.", "TasksApplicationCategory": "Aplicación", - "ValueSpecialEpisodeName": "Especial - {0}", - "ValueHasBeenAddedToLibrary": "{0} engadiuse á túa biblioteca de medios", "TasksLibraryCategory": "Biblioteca", "TasksMaintenanceCategory": "Mantemento", "VersionNumber": "Versión {0}", - "UserPolicyUpdatedWithName": "A política de usuario foi actualizada para {0}", "UserPasswordChangedWithName": "Cambiouse o contrasinal para o usuario {0}", "UserOnlineFromDevice": "{0} está en liña desde {1}", "UserOfflineFromDevice": "{0} desconectouse dende {1}", @@ -128,8 +99,6 @@ "TaskRefreshTrickplayImagesDescription": "Crea miniaturas de previsualización para os vídeos nas bibliotecas habilitadas.", "TaskDownloadMissingLyrics": "Descargar letras que faltan", "TaskDownloadMissingLyricsDescription": "Descarga as letras das cancións", - "TaskCleanCollectionsAndPlaylists": "Limpar coleccións e listas de reprodución", - "TaskCleanCollectionsAndPlaylistsDescription": "Quita ítems que xa non existen das coleccións e listas de reprodución.", "TaskExtractMediaSegmentsDescription": "Procura segmentos de medios cos plugins habilitados.", "TaskExtractMediaSegments": "Escaneo de segmentos de medios", "TaskMoveTrickplayImages": "Migrar as miniaturas de previsualización a outra ubicación", @@ -137,5 +106,20 @@ "TaskRefreshTrickplayImages": "Xerar miniaturas de previsualización", "TaskAudioNormalizationDescription": "Escanea ficheiros á procura de datos de normalización de volume.", "CleanupUserDataTask": "Tarefa de limpeza de datos dos usuarios", - "CleanupUserDataTaskDescription": "Limpa todos os datos do usuario (estado de visualización, de favorito etc.) dos medios ausentes polo menos 90 días." + "CleanupUserDataTaskDescription": "Limpa todos os datos do usuario (estado de visualización, de favorito etc.) dos medios ausentes polo menos 90 días.", + "Original": "Orixinal", + "LyricDownloadFailureFromForItem": "Non se puideron descargar as letras desde {0} para {1}", + "NameExtraBehindTheScenes": "Detrás das Cámaras", + "NameExtraClip": "Clip", + "NameExtraDeletedScene": "Escena Eliminada", + "NameExtraFeaturette": "Reportaxe especial", + "NameExtraInterview": "Entrevista", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Mostra", + "NameExtraScene": "Escena", + "NameExtraShort": "Curtametraxe", + "NameExtraThemeSong": "Canción principal", + "NameExtraThemeVideo": "Vídeo da canción principal", + "NameExtraTrailer": "Tráiler", + "NameExtraUnknown": "Extra" } diff --git a/Emby.Server.Implementations/Localization/Core/gsw.json b/Emby.Server.Implementations/Localization/Core/gsw.json index f847d83d14..b95e5edecc 100644 --- a/Emby.Server.Implementations/Localization/Core/gsw.json +++ b/Emby.Server.Implementations/Localization/Core/gsw.json @@ -1,41 +1,24 @@ { - "Albums": "Alben", "AppDeviceValues": "App: {0}, Gerät: {1}", - "Application": "Applikation", "Artists": "Künstler", "AuthenticationSucceededWithUserName": "{0} hat sich angemeldet", "Books": "Bücher", - "CameraImageUploadedFrom": "Ein neues Foto wurde von {0} hochgeladen", - "Channels": "Kanäle", "ChapterNameValue": "Kapitel {0}", "Collections": "Sammlungen", - "DeviceOfflineWithName": "{0} wurde getrennt", - "DeviceOnlineWithName": "{0} ist verbunden", - "FailedLoginAttemptWithUserName": "Fehlgeschlagener Anmeldeversuch von {0}", + "FailedLoginAttemptWithUserName": "Fählgschlagene Ameldeversuech vo {0}", "Favorites": "Favorite", "Folders": "Ordner", "Genres": "Genre", - "HeaderAlbumArtists": "Album-Künschtler", "HeaderContinueWatching": "weiter schauen", - "HeaderFavoriteAlbums": "Lieblingsalben", - "HeaderFavoriteArtists": "Lieblings-Künstler", "HeaderFavoriteEpisodes": "Lieblingsepisoden", "HeaderFavoriteShows": "Lieblingsserien", - "HeaderFavoriteSongs": "Lieblingslieder", "HeaderLiveTV": "Live-Fernseh", "HeaderNextUp": "Als Nächstes", - "HeaderRecordingGroups": "Aufnahme-Gruppen", "HomeVideos": "Heimvideos", "Inherit": "Vererben", - "ItemAddedWithName": "{0} wurde der Bibliothek hinzugefügt", - "ItemRemovedWithName": "{0} wurde aus der Bibliothek entfernt", "LabelIpAddressValue": "IP-Adresse: {0}", "LabelRunningTimeValue": "Laufzeit: {0}", "Latest": "Neueste", - "MessageApplicationUpdated": "Jellyfin-Server wurde aktualisiert", - "MessageApplicationUpdatedTo": "Jellyfin-Server wurde auf Version {0} aktualisiert", - "MessageNamedServerConfigurationUpdatedWithValue": "Der Server-Einstellungsbereich {0} wurde aktualisiert", - "MessageServerConfigurationUpdated": "Serveriistöuige send aktualisiert worde", "MixedContent": "Gmeschti Inhäut", "Movies": "Film", "Music": "Musig", @@ -61,24 +44,14 @@ "NotificationOptionVideoPlayback": "Videowedergab gstartet", "NotificationOptionVideoPlaybackStopped": "Videowedergab gstoppt", "Photos": "Fotis", - "Playlists": "Wedergabeliste", - "Plugin": "Plugin", "PluginInstalledWithName": "{0} esch installiert worde", "PluginUninstalledWithName": "{0} esch deinstalliert worde", "PluginUpdatedWithName": "{0} esch updated worde", - "ProviderValue": "Aabieter: {0}", "ScheduledTaskFailedWithName": "{0} esch fäugschlage", - "ScheduledTaskStartedWithName": "{0} het gstartet", - "ServerNameNeedsToBeRestarted": "{0} mues nöi gstartet wärde", "Shows": "Serie", - "Songs": "Lieder", "StartupEmbyServerIsLoading": "Jellyfin Server ladt. Bitte grad noeinisch probiere.", - "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", "SubtitleDownloadFailureFromForItem": "Ondertetle vo {0} för {1} hend ned chönne abeglade wärde", - "Sync": "Synchronisation", - "System": "System", "TvShows": "Färnsehserie", - "User": "Benotzer", "UserCreatedWithName": "Benotzer {0} esch erstöut worde", "UserDeletedWithName": "Benotzer {0} esch glösche worde", "UserDownloadingItemWithValues": "{0} ladt {1} abe", @@ -86,11 +59,8 @@ "UserOfflineFromDevice": "{0} esch vo {1} trennt worde", "UserOnlineFromDevice": "{0} esch online vo {1}", "UserPasswordChangedWithName": "S'Passwort för Benotzer {0} esch gänderet worde", - "UserPolicyUpdatedWithName": "Benotzerrechtlinie för {0} esch aktualisiert worde", "UserStartedPlayingItemWithValues": "{0} hed d'Wedergab vo {1} of {2} gstartet", "UserStoppedPlayingItemWithValues": "{0} het d'Wedergab vo {1} of {2} gstoppt", - "ValueHasBeenAddedToLibrary": "{0} esch dinnere Biblithek hinzuegfüegt worde", - "ValueSpecialEpisodeName": "Extra - {0}", "VersionNumber": "Version {0}", "TaskCleanLogs": "Lösche Log Pfad", "TaskRefreshLibraryDescription": "Scanne alle Bibliotheken für hinzugefügte Datein und erneuere Metadaten.", diff --git a/Emby.Server.Implementations/Localization/Core/he.json b/Emby.Server.Implementations/Localization/Core/he.json index 90c921898f..0ca7c6aa08 100644 --- a/Emby.Server.Implementations/Localization/Core/he.json +++ b/Emby.Server.Implementations/Localization/Core/he.json @@ -1,41 +1,24 @@ { - "Albums": "אלבומים", "AppDeviceValues": "יישום: {0}, מכשיר: {1}", - "Application": "יישום", "Artists": "אומנים", "AuthenticationSucceededWithUserName": "{0} אומת בהצלחה", "Books": "ספרים", - "CameraImageUploadedFrom": "תמונת מצלמה חדשה הועלתה מתוך {0}", - "Channels": "ערוצים", "ChapterNameValue": "פרק {0}", "Collections": "אוספים", - "DeviceOfflineWithName": "{0} התנתק", - "DeviceOnlineWithName": "{0} מחובר", "FailedLoginAttemptWithUserName": "ניסיון כניסה שגוי דרך {0}", "Favorites": "מועדפים", "Folders": "תיקיות", "Genres": "ז׳אנרים", - "HeaderAlbumArtists": "אמני האלבום", "HeaderContinueWatching": "המשך צפייה", - "HeaderFavoriteAlbums": "אלבומים מועדפים", - "HeaderFavoriteArtists": "אמנים מועדפים", "HeaderFavoriteEpisodes": "פרקים מועדפים", "HeaderFavoriteShows": "תוכניות מועדפות", - "HeaderFavoriteSongs": "שירים מועדפים", "HeaderLiveTV": "שידורים חיים", "HeaderNextUp": "הבא בתור", - "HeaderRecordingGroups": "קבוצות הקלטה", "HomeVideos": "סרטונים בייתים", "Inherit": "הורש", - "ItemAddedWithName": "{0} נוסף לספרייה", - "ItemRemovedWithName": "{0} נמחק מהספרייה", "LabelIpAddressValue": "Ip כתובת: {0}", "LabelRunningTimeValue": "משך צפייה: {0}", "Latest": "אחרון", - "MessageApplicationUpdated": "שרת Jellyfin עודכן", - "MessageApplicationUpdatedTo": "שרת Jellyfin עודכן לגרסה {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "סעיף הגדרת השרת {0} עודכן", - "MessageServerConfigurationUpdated": "תצורת השרת עודכנה", "MixedContent": "תוכן מעורב", "Movies": "סרטים", "Music": "מוזיקה", @@ -61,24 +44,14 @@ "NotificationOptionVideoPlayback": "ניגון וידאו החל", "NotificationOptionVideoPlaybackStopped": "ניגון וידאו הופסק", "Photos": "צילומים", - "Playlists": "רשימות נגינה", - "Plugin": "תוסף", "PluginInstalledWithName": "{0} הותקן", "PluginUninstalledWithName": "{0} הוסר", "PluginUpdatedWithName": "{0} עודכן", - "ProviderValue": "ספק: {0}", "ScheduledTaskFailedWithName": "{0} נכשל", - "ScheduledTaskStartedWithName": "{0} החל", - "ServerNameNeedsToBeRestarted": "{0} דורש הפעלה מחדש", "Shows": "סדרות", - "Songs": "שירים", "StartupEmbyServerIsLoading": "שרת Jellyfin בתהליך טעינה. נא לנסות שוב בקרוב.", - "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", "SubtitleDownloadFailureFromForItem": "הורדת כתוביות מ־{0} עבור {1} נכשלה", - "Sync": "סנכרון", - "System": "מערכת", "TvShows": "סדרות טלוויזיה", - "User": "משתמש", "UserCreatedWithName": "המשתמש {0} נוצר", "UserDeletedWithName": "המשתמש {0} הוסר", "UserDownloadingItemWithValues": "{0} מוריד את {1}", @@ -86,11 +59,8 @@ "UserOfflineFromDevice": "{0} התנתק מ־{1}", "UserOnlineFromDevice": "{0} מחובר מ־{1}", "UserPasswordChangedWithName": "הסיסמה שונתה עבור המשתמש {0}", - "UserPolicyUpdatedWithName": "מדיניות המשתמש {0} עודכנה", "UserStartedPlayingItemWithValues": "{0} מנגן את {1} על {2}", "UserStoppedPlayingItemWithValues": "{0} סיים לנגן את {1} על {2}", - "ValueHasBeenAddedToLibrary": "{0} התווסף לספריית המדיה שלך", - "ValueSpecialEpisodeName": "מיוחד- {0}", "VersionNumber": "גרסה {0}", "TaskRefreshLibrary": "סרוק ספריית מדיה", "TaskRefreshChapterImages": "חלץ תמונות פרקים", @@ -128,9 +98,7 @@ "TaskRefreshTrickplayImages": "יצירת תמונות Trickplay", "TaskRefreshTrickplayImagesDescription": "יוצר תמונות Trickplay לסרטונים בספריות הפעילות.", "TaskAudioNormalization": "נרמול שמע", - "TaskCleanCollectionsAndPlaylistsDescription": "מנקה פריטים לא קיימים מאוספים ורשימות השמעה.", "TaskAudioNormalizationDescription": "מחפש קבצי נורמליזציה של שמע.", - "TaskCleanCollectionsAndPlaylists": "מנקה אוספים ורשימות השמעה", "TaskDownloadMissingLyrics": "הורדת מילים חסרות", "TaskDownloadMissingLyricsDescription": "הורדת מילים לשירים", "TaskMoveTrickplayImages": "העברת מיקום של תמונות Trickplay", @@ -138,5 +106,14 @@ "TaskExtractMediaSegmentsDescription": "מחלץ חלקי מדיה מתוספים המאפשרים זאת.", "TaskMoveTrickplayImagesDescription": "הזזת קבצי Trickplay קיימים בהתאם להגדרות הספרייה.", "CleanupUserDataTaskDescription": "ניקוי כל המידע של המשתמש (מצב צפייה, מועדפים וכו) ממדיה שאינה קיימת מעל 90 יום.", - "CleanupUserDataTask": "משימת ניקוי מידע משתמש" + "CleanupUserDataTask": "משימת ניקוי מידע משתמש", + "LyricDownloadFailureFromForItem": "הורדת המילים מ-{0} עבור {1} נכשלה", + "Original": "מקור", + "NameExtraBehindTheScenes": "מאחורי הקלעים", + "NameExtraClip": "קליפ", + "NameExtraDeletedScene": "סצנה שנמחקה", + "NameExtraFeaturette": "סרט קצר", + "NameExtraInterview": "ריאיון", + "NameExtraSample": "דגימה", + "NameExtraScene": "סצנה" } diff --git a/Emby.Server.Implementations/Localization/Core/he_IL.json b/Emby.Server.Implementations/Localization/Core/he_IL.json index 0967ef424b..b551608fd0 100644 --- a/Emby.Server.Implementations/Localization/Core/he_IL.json +++ b/Emby.Server.Implementations/Localization/Core/he_IL.json @@ -1 +1,112 @@ -{} +{ + "Books": "ספרים", + "NameSeasonNumber": "עונה {0}", + "Movies": "סרטים", + "Music": "מוזיקה", + "Collections": "אוספים", + "Artists": "אמנים", + "ChapterNameValue": "פרק {0}", + "External": "חיצונית", + "Favorites": "מועדפים", + "Folders": "תיקיות", + "Genres": "ז'אנרים", + "HeaderContinueWatching": "להמשיך לצפות", + "HeaderFavoriteEpisodes": "פרקים אהובים", + "HeaderFavoriteShows": "תוכניות אהובות", + "HeaderLiveTV": "טלוויזיה בשידור חי", + "HeaderNextUp": "הבא", + "HearingImpaired": "ללקויי שמיעה", + "HomeVideos": "סרטונים ביתיים", + "AppDeviceValues": "אפליקציה: {0}, מכשיר: {1}", + "AuthenticationSucceededWithUserName": "{0} אומת בהצלחה", + "Default": "בררת מחדל", + "FailedLoginAttemptWithUserName": "התחברות נכשלה מ {0}", + "Forced": "בכוח", + "Inherit": "ירש", + "LabelIpAddressValue": "כתובת IP: {0}", + "LabelRunningTimeValue": "זמן ריצה: {0}", + "Latest": "הכי חדש", + "LyricDownloadFailureFromForItem": "מילות שיר נכשלו לרדת מ{0} בשביל {1}", + "MixedContent": "תוכן מעורב", + "MusicVideos": "סרטוני מוזיקה", + "NameInstallFailed": "{0} התכנות כושלות", + "NameSeasonUnknown": "עונה לא ידוע", + "NewVersionIsAvailable": "גרסה חדשה של שרת Jellyfin זמינה להורדה.", + "NotificationOptionApplicationUpdateAvailable": "גרסת אפליקציה חדשה זמינה להורדה", + "NotificationOptionApplicationUpdateInstalled": "עדכון אפליקציה הותקן", + "NotificationOptionAudioPlayback": "החלה השמעת אודיו", + "NotificationOptionAudioPlaybackStopped": "ניגון השמע הופסק", + "NotificationOptionCameraImageUploaded": "תמונת מצלמה עודכן", + "NotificationOptionInstallationFailed": "התקנה נכשלה", + "NotificationOptionNewLibraryContent": "תוכן חדש נוסף", + "NotificationOptionPluginError": "תוסף נכשל", + "NotificationOptionPluginInstalled": "תוסף הותקן", + "NotificationOptionPluginUninstalled": "תוסף נמחק", + "NotificationOptionPluginUpdateInstalled": "עידכון לתוסף הותקן", + "NotificationOptionServerRestartRequired": "נדרש התחול מחדש לשרת", + "NotificationOptionTaskFailed": "כשל במשימה מתוכננת", + "NotificationOptionUserLockedOut": "המשתמש ננעל", + "NotificationOptionVideoPlayback": "החלה הפעלת וידאו", + "NotificationOptionVideoPlaybackStopped": "הפעלת הסרטון הופסקה", + "Original": "מקורי", + "Photos": "תמונות", + "PluginInstalledWithName": "{0} הותקן", + "PluginUninstalledWithName": "{0} נמחק", + "PluginUpdatedWithName": "{0} עודכן", + "ScheduledTaskFailedWithName": "{0} נכשל", + "Shows": "סדרות", + "StartupEmbyServerIsLoading": "שרת Jellyfin נטען. אנא נסה שוב בקרוב.", + "SubtitleDownloadFailureFromForItem": "הורדת הכתוביות מ-{0} עבור {1} נכשלה", + "TvShows": "תוכניות טלויזיה", + "Undefined": "לא מוגדר", + "UserCreatedWithName": "המשתמש {0} נוצר", + "UserDeletedWithName": "המשתמש {0} נמחק", + "UserDownloadingItemWithValues": "{0} מוריד את {1}", + "UserLockedOutWithName": "המשתמש {0} ננעל בחוץ", + "UserOfflineFromDevice": "{0} התנתק מ-{1}", + "UserOnlineFromDevice": "{0} מחובר מ-{1}", + "UserPasswordChangedWithName": "הסיסמה שונתה עבור המשתמש {0}", + "UserStartedPlayingItemWithValues": "{0} מנגן ב-{1} ב-{2}", + "UserStoppedPlayingItemWithValues": "{0} סיים לנגן את {1} ב-{2}", + "VersionNumber": "גרסה {0}", + "TasksMaintenanceCategory": "תחזוקה", + "TasksLibraryCategory": "ספריה", + "TasksApplicationCategory": "אפליקציה", + "TasksChannelsCategory": "ערוצי אינטרנט", + "TaskCleanActivityLog": "נקה יומן פעילות", + "TaskCleanActivityLogDescription": "מוחק רשומות יומן פעילות ישנות יותר מהגיל שהוגדר.", + "TaskCleanCache": "נקה ספריית מטמון", + "TaskCleanCacheDescription": "מוחק קבצי מטמון שאינם נחוצים עוד על ידי המערכת.", + "TaskRefreshChapterImages": "חלץ תמונות פרק", + "TaskRefreshChapterImagesDescription": "יוצר תמונות ממוזערות עבור סרטונים שיש להם פרקים.", + "TaskAudioNormalization": "נורמליזציה של שמע", + "TaskAudioNormalizationDescription": "סורק קבצים לאיתור נתוני נרמול שמע.", + "TaskRefreshLibrary": "סרוק ספריית מדיה", + "TaskRefreshLibraryDescription": "סורק את ספריית המדיה שלך לאיתור קבצים חדשים ומרענן מטא-דאטה.", + "TaskCleanLogs": "נקה ספריית יומן", + "TaskCleanLogsDescription": "מוחק קבצי יומן שגילם עולה על {0} ימים.", + "TaskRefreshPeople": "רענן אנשים", + "TaskRefreshPeopleDescription": "מעדכן מטא-דאטה עבור שחקנים ובמאים בספריית המדיה שלך.", + "TaskRefreshTrickplayImages": "צור תמונות Trickplay", + "TaskRefreshTrickplayImagesDescription": "יוצר תצוגות מקדימות של trickplay עבור סרטונים בספריות מופעלות.", + "TaskUpdatePlugins": "עדכן פלאגינים", + "TaskUpdatePluginsDescription": "מוריד ומתקין עדכונים עבור תוספים שתצורתם נקבעה לעדכון אוטומטי.", + "TaskCleanTranscode": "נקה ספריית קידוד", + "TaskCleanTranscodeDescription": "תמחוק את קבצי הקידוד בני יותר מיום.", + "TaskRefreshChannels": "רענן ערוצים", + "TaskRefreshChannelsDescription": "מרענן את פרטי ערוץ האינטרנט.", + "TaskDownloadMissingLyrics": "הורד מילות שיר חסרות", + "TaskDownloadMissingLyricsDescription": "הורדות מילים לשירים", + "TaskDownloadMissingSubtitles": "הורד כתוביות חסרות", + "TaskDownloadMissingSubtitlesDescription": "מחפש באינטרנט אחר כתוביות חסרות בהתבסס על תצורת מטא-דאטה.", + "TaskOptimizeDatabase": "בצע אופטימיזציה של מסד הנתונים", + "TaskOptimizeDatabaseDescription": "דוחס את מסד הנתונים וקיצוץ שטח פנוי. הפעלת משימה זו לאחר סריקת הספרייה או ביצוע שינויים אחרים שמשמעותם שינויים בבסיס הנתונים עשויה לשפר את הביצועים.", + "TaskKeyframeExtractor": "מחלץ פריים מרכזי", + "TaskKeyframeExtractorDescription": "מחלץ פריימים מרכזיים מקבצי וידאו כדי ליצור רשימות השמעה HLS מדויקות יותר. משימה זו עשויה להימשך זמן רב.", + "TaskExtractMediaSegments": "סריקת מקטעי מדיה", + "TaskExtractMediaSegmentsDescription": "מחלץ או משיג קטעי מדיה מתוספים התומכים ב-MediaSegment.", + "TaskMoveTrickplayImages": "העברת מיקום תמונת Trickplay", + "TaskMoveTrickplayImagesDescription": "מעביר קבצי trickplay קיימים בהתאם להגדרות הספרייה.", + "CleanupUserDataTask": "משימת ניקוי נתוני משתמש", + "CleanupUserDataTaskDescription": "מנקה את כל נתוני המשתמש (מצב צפייה, סטטוס מועדף וכו') ממדיה שכבר לא הייתה קיימת במשך 90 יום לפחות." +} diff --git a/Emby.Server.Implementations/Localization/Core/hi.json b/Emby.Server.Implementations/Localization/Core/hi.json index 813b18ad4b..f4b1f86d1d 100644 --- a/Emby.Server.Implementations/Localization/Core/hi.json +++ b/Emby.Server.Implementations/Localization/Core/hi.json @@ -1,31 +1,20 @@ { - "Albums": "एल्बम", - "HeaderRecordingGroups": "रिकॉर्डिंग समूह", "HeaderNextUp": "इसके बाद", "HeaderLiveTV": "लाइव टीवी", - "HeaderFavoriteSongs": "पसंदीदा गीत", "HeaderFavoriteShows": "पसंदीदा शो", "HeaderFavoriteEpisodes": "पसंदीदा प्रकरण", - "HeaderFavoriteArtists": "पसंदीदा कलाकार", - "HeaderFavoriteAlbums": "पसंदीदा एलबम्स", "HeaderContinueWatching": "देखना जारी रखें", - "HeaderAlbumArtists": "एल्बम कलाकार", "Genres": "शैलियां", "Forced": "बलपूर्वक", "Folders": "फ़ोल्डर", "Favorites": "पसंदीदा", "FailedLoginAttemptWithUserName": "{0} से संप्रवेश असफल हुआ", - "DeviceOnlineWithName": "{0} कनेक्ट हो गया है", - "DeviceOfflineWithName": "{0} डिस्कनेक्ट हो गया है", "Default": "प्राथमिक", "Collections": "संग्रह", "ChapterNameValue": "अध्याय {0}", - "Channels": "चैनल", - "CameraImageUploadedFrom": "{0} से एक नया कैमरा छवि अपलोड की गई है", "Books": "पुस्तकें", "AuthenticationSucceededWithUserName": "{0} सफलतापूर्वक प्रमाणित किया गया", "Artists": "कलाकार", - "Application": "एप्लिकेशन", "AppDeviceValues": "एप: {0}, उपकरण: {1}", "NotificationOptionPluginUninstalled": "प्लगइन अनइंस्टाल हो गया", "NotificationOptionPluginInstalled": "प्लगइन इनस्टॉल हो गया", @@ -44,13 +33,8 @@ "Music": "संगीत", "Movies": "फ़िल्म", "MixedContent": "मिला-जुला कंटेंट", - "MessageServerConfigurationUpdated": "सर्वर कॉन्फ़िगरेशन अपडेट हो गया है", - "MessageNamedServerConfigurationUpdatedWithValue": "सर्वर कॉन्फ़िगरेशन भाग {0} अपडेट हो गया है", - "MessageApplicationUpdatedTo": "जैलीफिन सर्वर {0} में अपडेट हो गया है", - "MessageApplicationUpdated": "जैलीफिन सर्वर अपडेट हो गया है", "Latest": "सबसे नया", "LabelIpAddressValue": "आई पी एड्रेस: {0}", - "ItemRemovedWithName": "{0} लाइब्रेरी में से निकाल दिया है", "HomeVideos": "होम चलचित्र", "NotificationOptionVideoPlayback": "वीडियो प्लेबैक शुरू हुआ", "NotificationOptionUserLockedOut": "उपयोगकर्ता लॉक हो गया", @@ -59,22 +43,16 @@ "NotificationOptionPluginUpdateInstalled": "प्लगइन अद्यतन स्थापित", "NotificationOptionNewLibraryContent": "नई सामग्री जोड़ी गई", "LabelRunningTimeValue": "चलने का समय: {0}", - "ItemAddedWithName": "{0} को लाइब्रेरी में जोड़ा गया", "Inherit": "इनहेरिट", "NotificationOptionVideoPlaybackStopped": "चलचित्र रुका हुआ", "PluginUninstalledWithName": "{0} अनइंस्टॉल हुए", "PluginInstalledWithName": "{0} इंस्टॉल हुए", - "Plugin": "प्लग-इन", - "Playlists": "प्लेलिस्ट", "Photos": "तस्वीरें", "External": "बाहरी", "PluginUpdatedWithName": "{0} अपडेट हुए", - "ScheduledTaskStartedWithName": "{0} शुरू हुए", - "Songs": "गाने", "UserStartedPlayingItemWithValues": "{0} {2} पर {1} खेल रहे हैं", "UserStoppedPlayingItemWithValues": "{0} ने {2} पर {1} खेलना खत्म किया", "StartupEmbyServerIsLoading": "जेलीफ़िन सर्वर लोड हो रहा है। कृपया शीघ्र ही पुन: प्रयास करें।", - "ServerNameNeedsToBeRestarted": "{0} रीस्टार्ट करने की आवश्यकता है", "UserCreatedWithName": "उपयोगकर्ता {0} बनाया गया", "UserDownloadingItemWithValues": "{0} डाउनलोड हो रहा है", "UserOfflineFromDevice": "{0} {1} से डिस्कनेक्ट हो गया है", @@ -83,20 +61,13 @@ "Shows": "शो", "UserPasswordChangedWithName": "उपयोगकर्ता {0} के लिए पासवर्ड बदल दिया गया है", "UserDeletedWithName": "उपयोगकर्ता {0} हटा दिया गया", - "UserPolicyUpdatedWithName": "{0} के लिए उपयोगकर्ता नीति अपडेट कर दी गई है", - "User": "उपयोगकर्ता", "SubtitleDownloadFailureFromForItem": "{1} के लिए {0} से उपशीर्षक डाउनलोड करने में विफल", - "ProviderValue": "प्रदाता: {0}", "ScheduledTaskFailedWithName": "{0}असफल", "UserLockedOutWithName": "उपयोगकर्ता {0} को लॉक आउट कर दिया गया है", - "System": "प्रणाली", "TvShows": "टीवी शो", "HearingImpaired": "मूक बधिर", - "ValueSpecialEpisodeName": "विशेष - {0}", "TasksMaintenanceCategory": "रखरखाव", - "Sync": "समाकलयति", "VersionNumber": "{0} पाठान्तर", - "ValueHasBeenAddedToLibrary": "{0} आपके माध्यम ग्रन्थालय में उपजात हो गया हैं", "TasksLibraryCategory": "संग्रहालय", "TaskOptimizeDatabase": "जानकारी प्रवृद्धि", "TaskDownloadMissingSubtitles": "लापता अनुलेख डाउनलोड करें", @@ -127,7 +98,28 @@ "TaskRefreshTrickplayImages": "ट्रिकप्लै चित्रों को सृजन करे", "TaskRefreshTrickplayImagesDescription": "नियत संग्रहों में चलचित्रों का ट्रीकप्लै दर्शनों को सृजन करे.", "TaskAudioNormalization": "श्रव्य सामान्यीकरण", - "TaskAudioNormalizationDescription": "श्रव्य सामान्यीकरण के लिए फाइलें अन्वेषण करें", + "TaskAudioNormalizationDescription": "श्रव्य सामान्यीकरण के लिए फाइलें अन्वेषण करें।", "TaskDownloadMissingLyrics": "लापता गानों के बोल डाउनलोड करेँ", - "TaskDownloadMissingLyricsDescription": "गानों के बोल डाउनलोड करता है" + "TaskDownloadMissingLyricsDescription": "गानों के बोल डाउनलोड करता है", + "TaskExtractMediaSegments": "मीडिया सेगमेंट स्कैन", + "TaskExtractMediaSegmentsDescription": "मीडियासेगमेंट सक्षम प्लगइन्स से मीडिया सेगमेंट निकालता है या प्राप्त करता है।", + "TaskMoveTrickplayImages": "ट्रिकप्ले छवि स्थान माइग्रेट करें", + "TaskMoveTrickplayImagesDescription": "लाइब्रेरी सेटिंग्स के अनुसार मौजूदा ट्रिकप्ले फ़ाइलों को स्थानांतरित करता है।", + "CleanupUserDataTask": "यूज़र डेटा सफाई कार्य", + "Original": "असली", + "LyricDownloadFailureFromForItem": "{0} के लिए {1} से बोल (Lyrics) डाउनलोड करने में विफल रहा", + "NameExtraBehindTheScenes": "परदे के पीछे", + "NameExtraClip": "क्लिप", + "NameExtraDeletedScene": "हटाया गया दृश्य", + "NameExtraFeaturette": "फीचरेट", + "NameExtraInterview": "साक्षात्कार", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "नमूना", + "NameExtraScene": "दृश्य", + "NameExtraShort": "शॉर्ट", + "NameExtraThemeSong": "थीम सॉन्ग", + "NameExtraThemeVideo": "थीम वीडियो", + "NameExtraTrailer": "ट्रेलर", + "NameExtraUnknown": "अतिरिक्त", + "CleanupUserDataTaskDescription": "कम से कम 90 दिनों से अनुपस्थित मीडिया से सभी उपयोगकर्ता डेटा (देखने की स्थिति, पसंदीदा स्थिति आदि) को साफ़ करता है।" } diff --git a/Emby.Server.Implementations/Localization/Core/hr.json b/Emby.Server.Implementations/Localization/Core/hr.json index 67263d3b22..2d38c173f0 100644 --- a/Emby.Server.Implementations/Localization/Core/hr.json +++ b/Emby.Server.Implementations/Localization/Core/hr.json @@ -1,41 +1,24 @@ { - "Albums": "Albumi", "AppDeviceValues": "Aplikacija: {0}, Uređaj: {1}", - "Application": "Aplikacija", "Artists": "Izvođači", "AuthenticationSucceededWithUserName": "{0} uspješno ovjerena", "Books": "Knjige", - "CameraImageUploadedFrom": "Nova fotografija sa kamere je učitana iz {0}", - "Channels": "Kanali", "ChapterNameValue": "Poglavlje {0}", - "Collections": "Kolekcije", - "DeviceOfflineWithName": "{0} je prekinuo vezu", - "DeviceOnlineWithName": "{0} je povezan", + "Collections": "Zbirke", "FailedLoginAttemptWithUserName": "Neuspješan pokušaj prijave od {0}", "Favorites": "Favoriti", "Folders": "Mape", "Genres": "Žanrovi", - "HeaderAlbumArtists": "Izvođači albuma", "HeaderContinueWatching": "Nastavi gledati", - "HeaderFavoriteAlbums": "Omiljeni albumi", - "HeaderFavoriteArtists": "Omiljeni izvođači", "HeaderFavoriteEpisodes": "Omiljene epizode", "HeaderFavoriteShows": "Omiljene serije", - "HeaderFavoriteSongs": "Omiljene pjesme", "HeaderLiveTV": "TV uživo", - "HeaderNextUp": "Slijedi", - "HeaderRecordingGroups": "Grupa snimka", + "HeaderNextUp": "Sljedeće na redu", "HomeVideos": "Kućni video", "Inherit": "Naslijedi", - "ItemAddedWithName": "{0} je dodano u biblioteku", - "ItemRemovedWithName": "{0} je uklonjeno iz biblioteke", "LabelIpAddressValue": "IP adresa: {0}", "LabelRunningTimeValue": "Vrijeme rada: {0}", "Latest": "Najnovije", - "MessageApplicationUpdated": "Jellyfin server je ažuriran", - "MessageApplicationUpdatedTo": "Jellyfin server je ažuriran na {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Dio konfiguracije servera {0} je ažuriran", - "MessageServerConfigurationUpdated": "Konfiguracija servera je ažurirana", "MixedContent": "Miješani sadržaj", "Movies": "Filmovi", "Music": "Glazba", @@ -61,24 +44,14 @@ "NotificationOptionVideoPlayback": "Reprodukcija videa započela", "NotificationOptionVideoPlaybackStopped": "Reprodukcija videa zaustavljena", "Photos": "Fotografije", - "Playlists": "Popisi za reprodukciju", - "Plugin": "Dodatak", "PluginInstalledWithName": "{0} je instalirano", "PluginUninstalledWithName": "{0} je deinstalirano", "PluginUpdatedWithName": "{0} je ažurirano", - "ProviderValue": "Pružatelj: {0}", "ScheduledTaskFailedWithName": "{0} neuspjelo", - "ScheduledTaskStartedWithName": "{0} pokrenuto", - "ServerNameNeedsToBeRestarted": "{0} treba ponovno pokrenuti", - "Shows": "Serije", - "Songs": "Pjesme", + "Shows": "Emisije", "StartupEmbyServerIsLoading": "Jellyfin server se učitava. Pokušajte ponovo uskoro.", - "SubtitleDownloadFailureForItem": "Titlovi prijevoda nisu preuzeti za {0}", - "SubtitleDownloadFailureFromForItem": "Prijevod nije uspješno preuzet od {0} za {1}", - "Sync": "Sinkronizacija", - "System": "Sustav", - "TvShows": "Serije", - "User": "Korisnik", + "SubtitleDownloadFailureFromForItem": "Titlovi nisu uspješno preuzeti od {0} za {1}", + "TvShows": "TV emisije", "UserCreatedWithName": "Korisnik {0} je kreiran", "UserDeletedWithName": "Korisnik {0} je obrisan", "UserDownloadingItemWithValues": "{0} preuzima {1}", @@ -86,29 +59,26 @@ "UserOfflineFromDevice": "{0} prekinuo vezu od {1}", "UserOnlineFromDevice": "{0} povezan od {1}", "UserPasswordChangedWithName": "Lozinka je promijenjena za korisnika {0}", - "UserPolicyUpdatedWithName": "Pravila za korisnika ažurirana su za {0}", "UserStartedPlayingItemWithValues": "{0} je pokrenuo reprodukciju {1} na {2}", "UserStoppedPlayingItemWithValues": "{0} je završio reprodukciju {1} na {2}", - "ValueHasBeenAddedToLibrary": "{0} je dodano u medijsku biblioteku", - "ValueSpecialEpisodeName": "Posebno - {0}", "VersionNumber": "Verzija {0}", - "TaskRefreshLibraryDescription": "Skenira medijsku biblioteku radi novih datoteka i osvježava metapodatke.", - "TaskRefreshLibrary": "Skeniraj medijsku biblioteku", + "TaskRefreshLibraryDescription": "Skenira biblioteku medija radi novih datoteka i osvježava metapodatke.", + "TaskRefreshLibrary": "Skeniraj biblioteku medija", "TaskRefreshChapterImagesDescription": "Kreira sličice za videozapise koji imaju poglavlja.", "TaskRefreshChapterImages": "Izdvoji slike poglavlja", "TaskCleanCacheDescription": "Briše nepotrebne datoteke iz predmemorije.", "TaskCleanCache": "Očisti mapu predmemorije", "TasksApplicationCategory": "Aplikacija", "TasksMaintenanceCategory": "Održavanje", - "TaskDownloadMissingSubtitlesDescription": "Pretraži Internet za prijevodima koji nedostaju prema konfiguraciji metapodataka.", - "TaskDownloadMissingSubtitles": "Preuzmi prijevod koji nedostaje", + "TaskDownloadMissingSubtitlesDescription": "Pretraži internet za nedsotajućim titlovima ne osnovi konfiguracije metapodataka.", + "TaskDownloadMissingSubtitles": "Preuzmi nedostajuće titlove", "TaskRefreshChannelsDescription": "Osvježava informacije Internet kanala.", "TaskRefreshChannels": "Osvježi kanale", "TaskCleanTranscodeDescription": "Briše transkodirane datoteke starije od jednog dana.", "TaskCleanTranscode": "Očisti mapu transkodiranja", "TaskUpdatePluginsDescription": "Preuzima i instalira ažuriranja za dodatke koji su konfigurirani da se ažuriraju automatski.", "TaskUpdatePlugins": "Ažuriraj dodatke", - "TaskRefreshPeopleDescription": "Ažurira metapodatke za glumce i redatelje u medijskoj biblioteci.", + "TaskRefreshPeopleDescription": "Ažurira metapodatke za glumce i redatelje u biblioteci medija.", "TaskRefreshPeople": "Osvježi osobe", "TaskCleanLogsDescription": "Briše zapise dnevnika koji su stariji od {0} dana.", "TaskCleanLogs": "Očisti mapu dnevnika zapisa", @@ -120,7 +90,7 @@ "Forced": "Forsirani", "Default": "Zadano", "TaskOptimizeDatabase": "Optimiziraj bazu podataka", - "External": "Vanjski", + "External": "Eksterni", "TaskKeyframeExtractorDescription": "Izvlačenje ključnih okvira iz videozapisa za stvaranje objektivnije HLS liste za reprodukciju. Pokretanje ovog zadatka može potrajati.", "TaskKeyframeExtractor": "Izvoditelj ključnog okvira", "TaskOptimizeDatabaseDescription": "Sažima bazu podataka i uklanja prazan prostor. Pokretanje ovog zadatka, može poboljšati performanse nakon provođenja indeksiranja biblioteke ili provođenja drugih promjena koje utječu na bazu podataka.", @@ -129,14 +99,26 @@ "TaskRefreshTrickplayImagesDescription": "Stvara preglede brzog pregledavanja za videa u aktiviranim bibliotekama.", "TaskAudioNormalization": "Normalizacija zvuka", "TaskAudioNormalizationDescription": "Skenira datoteke u potrazi za podacima o normalizaciji zvuka.", - "TaskCleanCollectionsAndPlaylistsDescription": "Uklanja stavke iz zbirki i popisa za reprodukciju koje više ne postoje.", - "TaskCleanCollectionsAndPlaylists": "Očisti zbirke i popise za reprodukciju", "TaskExtractMediaSegments": "Skeniranje dijelova medija", "TaskDownloadMissingLyrics": "Preuzmi tekstove koji nedostaju", "TaskDownloadMissingLyricsDescription": "Preuzmi tekstove pjesama", "TaskExtractMediaSegmentsDescription": "Izvlači ili pribavlja dijelove medija iz omogućenih media pluginova.", "TaskMoveTrickplayImages": "Premjesti mjesto slika brzog pregledavanja", - "TaskMoveTrickplayImagesDescription": "Premješta postojeće datoteke brzog pregledavanja prema postavkama biblioteke.", + "TaskMoveTrickplayImagesDescription": "Premješta postojeće datoteke brzog pregledavanja u postavke biblioteke.", "CleanupUserDataTask": "Zadatak čišćenja korisničkih podataka", - "CleanupUserDataTaskDescription": "Briše sve korisničke podatke (stanje gledanja, status favorita itd.) s medija koji više nisu prisutni najmanje 90 dana." + "CleanupUserDataTaskDescription": "Briše sve korisničke podatke (stanje gledanja, status favorita itd.) s medija koji više nisu prisutni najmanje 90 dana.", + "Original": "Original", + "LyricDownloadFailureFromForItem": "Preuzimanje tekstova pjesmi od {0} za {1} nije uspjelo", + "NameExtraBehindTheScenes": "Iza kulisa", + "NameExtraClip": "Klip", + "NameExtraDeletedScene": "Obrisana Scena", + "NameExtraFeaturette": "Promotivni video", + "NameExtraInterview": "Intervju", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Uzorak", + "NameExtraScene": "Scena", + "NameExtraShort": "Kratki film", + "NameExtraThemeSong": "Glavna Pjesma", + "NameExtraThemeVideo": "Tema videa", + "NameExtraTrailer": "Trailer" } diff --git a/Emby.Server.Implementations/Localization/Core/ht.json b/Emby.Server.Implementations/Localization/Core/ht.json index f927d3173a..da90a92339 100644 --- a/Emby.Server.Implementations/Localization/Core/ht.json +++ b/Emby.Server.Implementations/Localization/Core/ht.json @@ -1,33 +1,22 @@ { "Books": "Liv", "TasksLibraryCategory": "Libreri", - "Albums": "Albòm yo", "Artists": "Atis yo", - "Application": "Aplikasyon", - "Channels": "Kanal yo", "ChapterNameValue": "Chapit {0}", "Default": "Defo", - "DeviceOnlineWithName": "{0} konekte", - "DeviceOfflineWithName": "{0} dekonekte", "External": "Extèn", "Collections": "Koleksyon yo", "Favorites": "Pi Renmen", "Folders": "Dosye", "Genres": "Jan yo", "Forced": "Fòse", - "HeaderAlbumArtists": "Albòm Atis", "HeaderContinueWatching": "Kontinye Kade", - "HeaderFavoriteAlbums": "Albòm Pi Renmen", - "HeaderFavoriteArtists": "Atis Pi Renmen", "HeaderFavoriteEpisodes": "Epizòd Pi Renmen", "HeaderFavoriteShows": "Emisyon Pi Renmen", - "HeaderFavoriteSongs": "Mizik Pi Renmen", "HeaderLiveTV": "Televizyon an Direk", "HeaderNextUp": "Pwochen an", "HomeVideos": "Videyo Lakay", "Latest": "Pi Resan", - "MessageApplicationUpdated": "Sèvè Jellyfin met a jou", - "MessageApplicationUpdatedTo": "Sèvè Jellyfin met a jou sou {0}", "Movies": "Fim", "MixedContent": "Kontni Melanje", "Music": "Mizik", @@ -42,12 +31,8 @@ "PluginUninstalledWithName": "{0} te dezenstale", "PluginUpdatedWithName": "{0} te mi a jou", "ScheduledTaskFailedWithName": "{0} echwe", - "ScheduledTaskStartedWithName": "{0} komanse", - "Songs": "Mizik yo", "Shows": "Emisyon yo", - "System": "Sistèm", "TvShows": "Emisyon Tele", - "User": "Itilizatè", "UserCreatedWithName": "Itilizatè {0} kreye", "UserDeletedWithName": "Itilizatè {0} a efase", "UserDownloadingItemWithValues": "{0} ap telechaje {1}", @@ -55,8 +40,10 @@ "UserStartedPlayingItemWithValues": "{0} ap jwe {1} sou {2}", "UserStoppedPlayingItemWithValues": "{0} fin jwe {1} sou {2}", "UserPasswordChangedWithName": "Modpas la chanje pou Itilizatè {0}", - "ValueSpecialEpisodeName": "Spesyal - {0}", "VersionNumber": "Vesyon {0}", "TasksApplicationCategory": "Aplikasyon", - "TasksMaintenanceCategory": "Antretyen" + "TasksMaintenanceCategory": "Antretyen", + "AppDeviceValues": "Aplikasyon: {0}, Aparèy: {1}", + "AuthenticationSucceededWithUserName": "{0} otantifye avèk siksè", + "Original": "Original" } diff --git a/Emby.Server.Implementations/Localization/Core/hu.json b/Emby.Server.Implementations/Localization/Core/hu.json index 81a996330b..4a327e7b02 100644 --- a/Emby.Server.Implementations/Localization/Core/hu.json +++ b/Emby.Server.Implementations/Localization/Core/hu.json @@ -1,45 +1,28 @@ { - "Albums": "Albumok", - "AppDeviceValues": "Program: {0}, Eszköz: {1}", - "Application": "Alkalmazás", + "AppDeviceValues": "alkalmazás: {0}, eszköz: {1}", "Artists": "Előadók", "AuthenticationSucceededWithUserName": "{0} sikeresen hitelesítve", "Books": "Könyvek", - "CameraImageUploadedFrom": "Új kamerakép lett feltöltve innen: {0}", - "Channels": "Csatornák", "ChapterNameValue": "{0}. jelenet", "Collections": "Gyűjtemények", - "DeviceOfflineWithName": "{0} kijelentkezett", - "DeviceOnlineWithName": "{0} belépett", "FailedLoginAttemptWithUserName": "Sikertelen bejelentkezési kísérlet innen: {0}", "Favorites": "Kedvencek", "Folders": "Mappák", "Genres": "Műfajok", - "HeaderAlbumArtists": "Albumelőadók", "HeaderContinueWatching": "Megtekintés folytatása", - "HeaderFavoriteAlbums": "Kedvenc albumok", - "HeaderFavoriteArtists": "Kedvenc előadók", "HeaderFavoriteEpisodes": "Kedvenc epizódok", "HeaderFavoriteShows": "Kedvenc sorozatok", - "HeaderFavoriteSongs": "Kedvenc számok", "HeaderLiveTV": "Élő TV", "HeaderNextUp": "Következik", - "HeaderRecordingGroups": "Felvételi csoportok", "HomeVideos": "Otthoni videók", "Inherit": "Öröklés", - "ItemAddedWithName": "{0} hozzáadva a médiatárhoz", - "ItemRemovedWithName": "{0} eltávolítva a médiatárból", "LabelIpAddressValue": "IP-cím: {0}", "LabelRunningTimeValue": "Lejátszási idő: {0}", "Latest": "Legújabb", - "MessageApplicationUpdated": "A Jellyfin kiszolgáló frissítve lett", - "MessageApplicationUpdatedTo": "A Jellyfin kiszolgáló frissítve lett a következőre: {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "A kiszolgálókonfigurációs rész frissítve lett: {0}", - "MessageServerConfigurationUpdated": "A kiszolgálókonfiguráció frissítve lett", "MixedContent": "Vegyes tartalom", "Movies": "Filmek", "Music": "Zenék", - "MusicVideos": "Zenei videóklipek", + "MusicVideos": "Zenei videók", "NameInstallFailed": "{0} sikertelen telepítés", "NameSeasonNumber": "{0}. évad", "NameSeasonUnknown": "Ismeretlen évad", @@ -55,30 +38,20 @@ "NotificationOptionPluginInstalled": "Bővítmény telepítve", "NotificationOptionPluginUninstalled": "Bővítmény eltávolítva", "NotificationOptionPluginUpdateInstalled": "Bővítményfrissítés telepítve", - "NotificationOptionServerRestartRequired": "A kiszolgáló újraindítása szükséges", + "NotificationOptionServerRestartRequired": "A szerver újraindítása szükséges", "NotificationOptionTaskFailed": "Hiba az ütemezett feladatban", "NotificationOptionUserLockedOut": "Felhasználó tiltva", "NotificationOptionVideoPlayback": "Videólejátszás elkezdve", "NotificationOptionVideoPlaybackStopped": "Videólejátszás leállítva", "Photos": "Fényképek", - "Playlists": "Lejátszási listák", - "Plugin": "Bővítmény", "PluginInstalledWithName": "{0} telepítve", "PluginUninstalledWithName": "{0} eltávolítva", "PluginUpdatedWithName": "{0} frissítve", - "ProviderValue": "Szolgáltató: {0}", "ScheduledTaskFailedWithName": "{0} sikertelen", - "ScheduledTaskStartedWithName": "{0} elkezdve", - "ServerNameNeedsToBeRestarted": "A(z) {0} újraindítása szükséges", "Shows": "Sorozatok", - "Songs": "Számok", "StartupEmbyServerIsLoading": "A Jellyfin kiszolgáló betöltődik. Próbálja újra hamarosan.", - "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", "SubtitleDownloadFailureFromForItem": "Nem sikerült a felirat letöltése innen: {0}, ehhez: {1}", - "Sync": "Szinkronizálás", - "System": "Rendszer", "TvShows": "TV műsorok", - "User": "Felhasználó", "UserCreatedWithName": "{0} felhasználó létrehozva", "UserDeletedWithName": "{0} felhasználó törölve", "UserDownloadingItemWithValues": "{0} letölti: {1}", @@ -86,11 +59,8 @@ "UserOfflineFromDevice": "{0} kijelentkezett innen: {1}", "UserOnlineFromDevice": "{0} online innen: {1}", "UserPasswordChangedWithName": "{0} jelszava megváltozott", - "UserPolicyUpdatedWithName": "{0} felhasználói házirendje frissült", "UserStartedPlayingItemWithValues": "{0} elkezdte lejátszani a következőt: {1}, itt: {2}", "UserStoppedPlayingItemWithValues": "{0} befejezte a következő lejátszását: {1}, itt: {2}", - "ValueHasBeenAddedToLibrary": "{0} hozzáadva a médiatárhoz", - "ValueSpecialEpisodeName": "Különkiadás – {0}", "VersionNumber": "Verzió: {0}", "TaskCleanTranscode": "Átkódolási könyvtár ürítése", "TaskUpdatePluginsDescription": "Letölti és telepíti a frissítéseket azokhoz a bővítményekhez, amelyeknél az automatikus frissítés engedélyezve van.", @@ -128,9 +98,7 @@ "TaskRefreshTrickplayImages": "Trickplay képek előállítása", "TaskRefreshTrickplayImagesDescription": "Trickplay előnézetet készít az engedélyezett könyvtárakban lévő videókhoz.", "TaskAudioNormalization": "Hangerő-normalizálás", - "TaskCleanCollectionsAndPlaylistsDescription": "Nem létező elemek törlése a gyűjteményekből és lejátszási listákról.", "TaskAudioNormalizationDescription": "Hangerő-normalizálási adatok keresése.", - "TaskCleanCollectionsAndPlaylists": "Gyűjtemények és lejátszási listák optimalizálása", "TaskExtractMediaSegments": "Médiaszegmens felismerése", "TaskDownloadMissingLyrics": "Hiányzó szöveg letöltése", "TaskDownloadMissingLyricsDescription": "Zenék szövegének letöltése", @@ -138,5 +106,20 @@ "TaskMoveTrickplayImagesDescription": "A médiatár-beállításoknak megfelelően áthelyezi a meglévő trickplay fájlokat.", "TaskExtractMediaSegmentsDescription": "Kinyeri vagy megszerzi a médiaszegmenseket a MediaSegment támogatással rendelkező bővítményekből.", "CleanupUserDataTaskDescription": "Legalább 90 napja nem elérhető médiákhoz kapcsolódó összes felhasználói adat (pl. megtekintési állapot, kedvencek) törlése.", - "CleanupUserDataTask": "Felhasználói adatok tisztítása feladat" + "CleanupUserDataTask": "Felhasználói adatok tisztítása feladat", + "Original": "Eredeti", + "LyricDownloadFailureFromForItem": "Dalszöveg letöltése {0}-tól {1}-hez sikertelen", + "NameExtraBehindTheScenes": "Színfalak mögött", + "NameExtraClip": "Klip", + "NameExtraDeletedScene": "Törölt jelenet", + "NameExtraFeaturette": "Kísérő film", + "NameExtraInterview": "Interjú", + "NameExtraSample": "Minta", + "NameExtraScene": "Jelenet", + "NameExtraShort": "Rövidfilm", + "NameExtraThemeSong": "Főcímdal", + "NameExtraThemeVideo": "Főcímvideó", + "NameExtraTrailer": "Előzetes", + "NameExtraUnknown": "Extra", + "NameExtraNumbered": "{0} {1}" } diff --git a/Emby.Server.Implementations/Localization/Core/hy.json b/Emby.Server.Implementations/Localization/Core/hy.json index 563f842923..80be71ed70 100644 --- a/Emby.Server.Implementations/Localization/Core/hy.json +++ b/Emby.Server.Implementations/Localization/Core/hy.json @@ -2,38 +2,32 @@ "TasksLibraryCategory": "Գրադարան", "TasksApplicationCategory": "Հավելված", "TaskCleanActivityLog": "Մաքրել ակտիվության մատյանը", - "Application": "Հավելված", "AuthenticationSucceededWithUserName": "{0} հաջողությամբ վավերականացվել են", "Books": "Գրքեր", - "CameraImageUploadedFrom": "Նոր լուսանկար է վերբեռնվել {0}-ի կողմից", - "Channels": "Ալիքներ", - "DeviceOfflineWithName": "{0}ը անջատվեց", "External": "Արտաքին", "FailedLoginAttemptWithUserName": "Ձախողված մուտքի փործ {0}-ի կողմից", "Folders": "Պանակներ", "HeaderContinueWatching": "Շարունակել դիտումը", "Inherit": "Ժառանգել", - "ItemAddedWithName": "{0}ը ավացված է գրադարանի մեջ", - "ItemRemovedWithName": "{0}ը հեռացված է գրադարանից", "LabelIpAddressValue": "IP հասցե` {0}", "Movies": "Ֆիլմեր", "Music": "Երաժշտություն", "NameSeasonNumber": "Սեզոն {0}", "Photos": "Լուսանկարներ", "PluginInstalledWithName": "{0}ն տեղադրված է", - "Songs": "Երգեր", - "System": "Համակարգ", "TvShows": "Հեռուստասերիալներ", - "User": "Օգտատեր", "VersionNumber": "Տարբերակ {0}", "TasksMaintenanceCategory": "Սպասարկում", "TasksChannelsCategory": "Ինտերնետային ալիքներ", "TaskRefreshPeople": "Թարմացնել մարդկանց", "TaskRefreshChannels": "Թարմացնել ալիքները", "TaskDownloadMissingSubtitles": "Ներբեռնել պակասող ենթագրերը", - "Albums": "Ալբոմներ", "AppDeviceValues": "Հավելված` {0}, Սարք `{1}", "ChapterNameValue": "Գլուխ {0}", "Collections": "Հավաքածուներ", - "DeviceOnlineWithName": "{0}-ն միացված է" + "Artists": "Երաժիշտներ", + "Default": "Լռելյայն", + "Favorites": "Ընտրյալ", + "Forced": "Ստիպուած", + "Genres": "Ոճ" } diff --git a/Emby.Server.Implementations/Localization/Core/id.json b/Emby.Server.Implementations/Localization/Core/id.json index 2a42816852..3502ec39ad 100644 --- a/Emby.Server.Implementations/Localization/Core/id.json +++ b/Emby.Server.Implementations/Localization/Core/id.json @@ -1,48 +1,31 @@ { - "Albums": "Album", "AuthenticationSucceededWithUserName": "{0} berhasil diautentikasi", "AppDeviceValues": "Aplikasi : {0}, Perangkat : {1}", "LabelRunningTimeValue": "Waktu berjalan: {0}", - "MessageApplicationUpdatedTo": "Jellyfin Server sudah diperbarui ke {0}", - "MessageApplicationUpdated": "Jellyfin Server sudah diperbarui", "Latest": "Terbaru", "LabelIpAddressValue": "Alamat IP: {0}", - "ItemRemovedWithName": "{0} sudah dihapus dari pustaka", - "ItemAddedWithName": "{0} telah dimasukkan ke dalam pustaka", "Inherit": "Warisi", "HomeVideos": "Video Rumahan", - "HeaderRecordingGroups": "Grup Rekaman", "HeaderNextUp": "Selanjutnya", "HeaderLiveTV": "Siaran langsung", - "HeaderFavoriteSongs": "Lagu Favorit", "HeaderFavoriteShows": "Tayangan Favorit", "HeaderFavoriteEpisodes": "Episode Favorit", - "HeaderFavoriteArtists": "Artis Favorit", - "HeaderFavoriteAlbums": "Album Favorit", "HeaderContinueWatching": "Lanjut Menonton", - "HeaderAlbumArtists": "Album Artis", "Genres": "Aliran", "Folders": "Folder", "Favorites": "Favorit", "Collections": "Koleksi", "Books": "Buku", "Artists": "Artis", - "Application": "Aplikasi", "ChapterNameValue": "Bagian {0}", - "Channels": "Saluran", "TvShows": "Seri TV", "SubtitleDownloadFailureFromForItem": "Subtitel gagal diunduh dari {0} untuk {1}", "StartupEmbyServerIsLoading": "Server Jellyfin sedang dimuat. Silakan coba lagi nanti.", - "Songs": "Lagu", - "Playlists": "Daftar putar", "NotificationOptionPluginUninstalled": "Plugin dihapus", "MusicVideos": "Video Musik", "VersionNumber": "Versi {0}", - "ValueSpecialEpisodeName": "Spesial - {0}", - "ValueHasBeenAddedToLibrary": "{0} telah ditambahkan ke pustaka media Anda", "UserStoppedPlayingItemWithValues": "{0} telah selesai memutar {1} pada {2}", "UserStartedPlayingItemWithValues": "{0} sedang memutar {1} pada {2}", - "UserPolicyUpdatedWithName": "Kebijakan pengguna telah diperbarui untuk {0}", "UserPasswordChangedWithName": "Kata sandi telah diubah untuk pengguna {0}", "UserOnlineFromDevice": "{0} sedang daring dari {1}", "UserOfflineFromDevice": "{0} telah terputus dari {1}", @@ -50,17 +33,10 @@ "UserDownloadingItemWithValues": "{0} sedang mengunduh {1}", "UserDeletedWithName": "Pengguna {0} telah dihapus", "UserCreatedWithName": "Pengguna {0} telah dibuat", - "User": "Pengguna", - "System": "Sistem", - "Sync": "Sinkron", "Shows": "Tayangan", - "ServerNameNeedsToBeRestarted": "{0} perlu dimuat ulang", - "ScheduledTaskStartedWithName": "{0} dimulai", "ScheduledTaskFailedWithName": "{0} gagal", - "ProviderValue": "Penyedia: {0}", "PluginUpdatedWithName": "{0} telah diperbarui", "PluginInstalledWithName": "{0} telah dipasang", - "Plugin": "Plugin", "Photos": "Foto", "NotificationOptionUserLockedOut": "Pengguna terkunci", "NotificationOptionTaskFailed": "Kegagalan tugas terjadwal", @@ -79,12 +55,7 @@ "NameInstallFailed": "{0} penginstalan gagal", "Music": "Musik", "Movies": "Film", - "MessageServerConfigurationUpdated": "Konfigurasi server telah diperbarui", - "MessageNamedServerConfigurationUpdatedWithValue": "Bagian konfigurasi server {0} telah diperbarui", "FailedLoginAttemptWithUserName": "Gagal upaya login dari {0}", - "CameraImageUploadedFrom": "Sebuah gambar kamera baru telah diunggah dari {0}", - "DeviceOfflineWithName": "{0} telah terputus", - "DeviceOnlineWithName": "{0} telah terhubung", "NotificationOptionVideoPlaybackStopped": "Pemutaran video berhenti", "NotificationOptionVideoPlayback": "Pemutaran video dimulai", "NotificationOptionAudioPlaybackStopped": "Pemutaran audio berhenti", @@ -128,8 +99,6 @@ "TaskRefreshTrickplayImagesDescription": "Buat pratinjau trickplay untuk video di perpustakaan yang diaktifkan.", "TaskAudioNormalizationDescription": "Pindai file untuk data normalisasi audio.", "TaskAudioNormalization": "Normalisasi Audio", - "TaskCleanCollectionsAndPlaylists": "Bersihkan koleksi dan daftar putar", - "TaskCleanCollectionsAndPlaylistsDescription": "Menghapus item dari koleksi dan daftar putar yang sudah tidak ada.", "TaskDownloadMissingLyricsDescription": "Unduh lirik untuk lagu", "TaskExtractMediaSegmentsDescription": "Mengekstrak atau memperoleh segmen media dari plugin yang mendukung MediaSegment.", "TaskMoveTrickplayImagesDescription": "Memindahkan file trickplay yang sudah ada sesuai dengan pengaturan pustaka.", @@ -137,5 +106,7 @@ "TaskExtractMediaSegments": "Scan Segmen media", "TaskMoveTrickplayImages": "Migrasikan Lokasi Gambar Trickplay", "TaskDownloadMissingLyrics": "Unduh Lirik yang Hilang", - "CleanupUserDataTask": "Tugas Pembersihan Data Pengguna" + "CleanupUserDataTask": "Tugas Pembersihan Data Pengguna", + "LyricDownloadFailureFromForItem": "Lirik gagal di download dari {0} untuk {1}", + "Original": "Asli" } diff --git a/Emby.Server.Implementations/Localization/Core/is.json b/Emby.Server.Implementations/Localization/Core/is.json index 6f94df9d79..a25857e510 100644 --- a/Emby.Server.Implementations/Localization/Core/is.json +++ b/Emby.Server.Implementations/Localization/Core/is.json @@ -1,36 +1,22 @@ { "LabelIpAddressValue": "IP tala: {0}", - "ItemRemovedWithName": "{0} var fjarlægt úr safninu", - "ItemAddedWithName": "{0} var bætt í safnið", "Inherit": "Erfa", "HomeVideos": "Heimamyndbönd", - "HeaderRecordingGroups": "Upptökuhópar", "HeaderNextUp": "Næst á dagskrá", "HeaderLiveTV": "Sjónvarp í beinni útsendingu", - "HeaderFavoriteSongs": "Uppáhalds Lög", "HeaderFavoriteShows": "Uppáhalds Sjónvarpsþættir", "HeaderFavoriteEpisodes": "Uppáhalds Þættir", - "HeaderFavoriteArtists": "Uppáhalds Listamenn", - "HeaderFavoriteAlbums": "Uppáhalds Plötur", "HeaderContinueWatching": "Halda áfram að horfa", - "HeaderAlbumArtists": "Listamaður á umslagi", "Genres": "Stefnur", "Folders": "Möppur", "Favorites": "Uppáhalds", "FailedLoginAttemptWithUserName": "{0} mistókst að auðkenna sig", - "DeviceOnlineWithName": "{0} hefur tengst", - "DeviceOfflineWithName": "{0} hefur aftengst", "Collections": "Söfn", "ChapterNameValue": "Kafli {0}", - "Channels": "Rásir", - "CameraImageUploadedFrom": "{0} hefur hlaðið upp nýrri ljósmynd úr myndavél sinni", "Books": "Bækur", "AuthenticationSucceededWithUserName": "Auðkenning fyrir {0} tókst", "Artists": "Listamenn", - "Application": "Forrit", "AppDeviceValues": "Snjallforrit: {0}, Tæki: {1}", - "Albums": "Plötur", - "Plugin": "Viðbótarvirkni", "Photos": "Ljósmyndir", "NotificationOptionVideoPlaybackStopped": "Myndbandsafspilun stöðvuð", "NotificationOptionVideoPlayback": "Myndbandsafspilun hafin", @@ -49,13 +35,8 @@ "NameSeasonUnknown": "Þáttaröð óþekkt", "NameSeasonNumber": "Þáttaröð {0}", "MixedContent": "Blandað efni", - "MessageServerConfigurationUpdated": "Stillingar þjóns hafa verið uppfærðar", - "MessageApplicationUpdatedTo": "Jellyfin þjónn hefur verið uppfærður í {0}", - "MessageApplicationUpdated": "Jellyfin þjónn hefur verið uppfærður", "Latest": "Nýjasta", "LabelRunningTimeValue": "spilunartími: {0}", - "User": "Notandi", - "System": "Kerfi", "NotificationOptionNewLibraryContent": "Nýju efni bætt við", "NewVersionIsAvailable": "Ný útgáfa af Jellyfin þjón er tilbúin til niðurhals.", "NameInstallFailed": "{0} uppsetning mistókst", @@ -65,10 +46,6 @@ "UserDeletedWithName": "Notanda {0} hefur verið eytt", "UserCreatedWithName": "Notandi {0} hefur verið stofnaður", "TvShows": "Sjónvarpsþættir", - "Sync": "Samstilla", - "Songs": "Lög", - "ServerNameNeedsToBeRestarted": "{0} þarf að vera endurræstur", - "ScheduledTaskStartedWithName": "{0} hafin", "ScheduledTaskFailedWithName": "{0} mistókst", "PluginUpdatedWithName": "{0} var uppfært", "PluginUninstalledWithName": "{0} var fjarlægt", @@ -76,22 +53,16 @@ "NotificationOptionTaskFailed": "Tímasett verkefni mistókst", "StartupEmbyServerIsLoading": "Jellyfin netþjónnin er að ræsa sig upp. Vinsamlegast reyndu aftur fljótlega.", "VersionNumber": "Útgáfa {0}", - "ValueHasBeenAddedToLibrary": "{0} hefur verið bætt við í gagnasafnið þitt", "UserStoppedPlayingItemWithValues": "{0} hefur lokið spilunar af {1} á {2}", "UserStartedPlayingItemWithValues": "{0} er að spila {1} á {2}", - "UserPolicyUpdatedWithName": "Notandaregla hefur verið uppfærð fyrir {0}", "UserPasswordChangedWithName": "Lykilorði fyrir notandann {0} hefur verið breytt", "UserOnlineFromDevice": "{0} hefur verið virkur síðan {1}", "UserOfflineFromDevice": "{0} hefur aftengst frá {1}", "UserLockedOutWithName": "Notandi {0} hefur verið læstur úti", "UserDownloadingItemWithValues": "{0} hleður niður {1}", "SubtitleDownloadFailureFromForItem": "Tókst ekki að hala niður skjátextum frá {0} til {1}", - "ProviderValue": "Efnisveita: {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Stilling {0} hefur verið uppfærð á netþjón", - "ValueSpecialEpisodeName": "Sérstaktur - {0}", "Shows": "Þættir", - "Playlists": "Efnisskrár", - "TaskRefreshChannelsDescription": "Endurhlaða upplýsingum netrása.", + "TaskRefreshChannelsDescription": "Endurhleður upplýsingum netrása.", "TaskRefreshChannels": "Endurhlaða Rásir", "TaskCleanTranscodeDescription": "Eyða umkóðuðum skrám sem eru meira en einum degi eldri.", "TaskCleanTranscode": "Hreinsa Umkóðunarmöppu", @@ -109,7 +80,7 @@ "TasksMaintenanceCategory": "Viðhald", "Default": "Sjálfgefið", "TaskCleanActivityLog": "Hreinsa athafnaskrá", - "TaskRefreshPeople": "Endurnýja fólk", + "TaskRefreshPeople": "Endurnýja upplýsingar um fólk", "TaskDownloadMissingSubtitles": "Sækja texta sem vantar", "TaskOptimizeDatabase": "Fínstilla gagnagrunn", "Undefined": "Óskilgreint", @@ -124,15 +95,31 @@ "TaskCleanActivityLogDescription": "Eyðir virkniskráningarfærslum sem hafa náð settum hámarksaldri.", "Forced": "Þvingað", "External": "Útvær", - "TaskRefreshTrickplayImagesDescription": "Býr til hraðspilunarmyndir fyrir myndbönd í virkum söfnum.", - "TaskRefreshTrickplayImages": "Búa til hraðspilunarmyndir", + "TaskRefreshTrickplayImagesDescription": "Býr til hraðspilunarmyndir (Trickplay) fyrir myndbönd í virkum söfnum.", + "TaskRefreshTrickplayImages": "Búa til hraðspilunarmyndir (Trickplay)", "TaskAudioNormalization": "Hljóðstöðlun", "TaskAudioNormalizationDescription": "Leitar að hljóðstöðlunargögnum í skrám.", - "TaskCleanCollectionsAndPlaylists": "Hreinsa söfn og spilunarlista", - "TaskCleanCollectionsAndPlaylistsDescription": "Fjarlægir hluti úr söfnum og spilalistum sem eru ekki lengur til.", "TaskDownloadMissingLyricsDescription": "Sækja söngtexta fyrir lög", "TaskDownloadMissingLyrics": "Sækja söngtexta sem vantar", "TaskExtractMediaSegments": "Skönnun efnishluta", "CleanupUserDataTask": "Hreinsun notendagagna", - "CleanupUserDataTaskDescription": "Hreinsar öll notendagögn (spilunarstöðu, uppáhöld o.s.frv.) um gögn sem hafa ekki verið til staðar í að lámarki 90 daga." + "CleanupUserDataTaskDescription": "Hreinsar öll notendagögn (spilunarstöðu, uppáhöld o.s.frv.) um gögn sem hafa ekki verið til staðar í að lámarki 90 daga.", + "LyricDownloadFailureFromForItem": "Ekki tókst að niðurhala texta frá {0} fyrir {1}", + "Original": "Upprunaleg", + "TaskExtractMediaSegmentsDescription": "Sækir myndbúta úr viðbótum þar sem MediaSegment er virkt.", + "TaskMoveTrickplayImages": "Flytja geymslustað fyrir Trickplay-myndir", + "TaskMoveTrickplayImagesDescription": "Flytur fyrirliggjandi Trickplay-skrár í samræmi við stillingar safnsins.", + "NameExtraBehindTheScenes": "Bak við tjöldin", + "NameExtraClip": "Brot", + "NameExtraDeletedScene": "Eydd atriði", + "NameExtraFeaturette": "Stutt heimildarmynd", + "NameExtraInterview": "Viðtal", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Sýnishorn", + "NameExtraScene": "Sena", + "NameExtraShort": "Stuttmynd", + "NameExtraThemeSong": "Þema lag", + "NameExtraThemeVideo": "Þema myndband", + "NameExtraTrailer": "Stikla", + "NameExtraUnknown": "Aukaefni" } diff --git a/Emby.Server.Implementations/Localization/Core/it.json b/Emby.Server.Implementations/Localization/Core/it.json index 421c4ee306..41097ed92e 100644 --- a/Emby.Server.Implementations/Localization/Core/it.json +++ b/Emby.Server.Implementations/Localization/Core/it.json @@ -1,46 +1,29 @@ { - "Albums": "Album", "AppDeviceValues": "App: {0}, Dispositivo: {1}", - "Application": "Applicazione", "Artists": "Artisti", - "AuthenticationSucceededWithUserName": "{0} autenticato con successo", + "AuthenticationSucceededWithUserName": "{0} autenticato correttamente", "Books": "Libri", - "CameraImageUploadedFrom": "È stata caricata una nuova fotografia da {0}", - "Channels": "Canali", "ChapterNameValue": "Capitolo {0}", "Collections": "Collezioni", - "DeviceOfflineWithName": "{0} si è disconnesso", - "DeviceOnlineWithName": "{0} è connesso", - "FailedLoginAttemptWithUserName": "Tentativo di accesso fallito da {0}", + "FailedLoginAttemptWithUserName": "Tentativo di accesso non riuscito da {0}", "Favorites": "Preferiti", "Folders": "Cartelle", "Genres": "Generi", - "HeaderAlbumArtists": "Artisti dell'album", "HeaderContinueWatching": "Continua a guardare", - "HeaderFavoriteAlbums": "Album Preferiti", - "HeaderFavoriteArtists": "Artisti Preferiti", - "HeaderFavoriteEpisodes": "Episodi Preferiti", - "HeaderFavoriteShows": "Serie TV Preferite", - "HeaderFavoriteSongs": "Brani Preferiti", + "HeaderFavoriteEpisodes": "Episodi preferiti", + "HeaderFavoriteShows": "Serie TV preferite", "HeaderLiveTV": "Diretta TV", "HeaderNextUp": "Prossimo", - "HeaderRecordingGroups": "Gruppi di Registrazione", - "HomeVideos": "Video Personali", + "HomeVideos": "Video personali", "Inherit": "Eredita", - "ItemAddedWithName": "{0} è stato aggiunto alla libreria", - "ItemRemovedWithName": "{0} è stato rimosso dalla libreria", "LabelIpAddressValue": "Indirizzo IP: {0}", "LabelRunningTimeValue": "Durata: {0}", "Latest": "Novità", - "MessageApplicationUpdated": "Il Server Jellyfin è stato aggiornato", - "MessageApplicationUpdatedTo": "Jellyfin Server è stato aggiornato a {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "La sezione {0} della configurazione server è stata aggiornata", - "MessageServerConfigurationUpdated": "La configurazione del server è stata aggiornata", "MixedContent": "Contenuto misto", "Movies": "Film", "Music": "Musica", - "MusicVideos": "Video Musicali", - "NameInstallFailed": "{0} installazione fallita", + "MusicVideos": "Video musicali", + "NameInstallFailed": "{0} installazione non riuscita", "NameSeasonNumber": "Stagione {0}", "NameSeasonUnknown": "Stagione sconosciuta", "NewVersionIsAvailable": "Una nuova versione di Jellyfin Server è disponibile per il download.", @@ -49,48 +32,35 @@ "NotificationOptionAudioPlayback": "La riproduzione audio è iniziata", "NotificationOptionAudioPlaybackStopped": "La riproduzione audio è stata interrotta", "NotificationOptionCameraImageUploaded": "Immagine fotocamera caricata", - "NotificationOptionInstallationFailed": "Installazione fallita", + "NotificationOptionInstallationFailed": "Installazione non riuscita", "NotificationOptionNewLibraryContent": "Nuovo contenuto aggiunto", "NotificationOptionPluginError": "Errore del plugin", "NotificationOptionPluginInstalled": "Plugin installato", "NotificationOptionPluginUninstalled": "Plugin disinstallato", "NotificationOptionPluginUpdateInstalled": "Aggiornamento plugin installato", "NotificationOptionServerRestartRequired": "Riavvio del server necessario", - "NotificationOptionTaskFailed": "Operazione pianificata fallita", + "NotificationOptionTaskFailed": "Operazione pianificata non riuscita", "NotificationOptionUserLockedOut": "Utente bloccato", "NotificationOptionVideoPlayback": "Riproduzione video iniziata", "NotificationOptionVideoPlaybackStopped": "Riproduzione video interrotta", "Photos": "Foto", - "Playlists": "Playlist", - "Plugin": "Plugin", - "PluginInstalledWithName": "{0} è stato Installato", + "PluginInstalledWithName": "{0} è stato installato", "PluginUninstalledWithName": "{0} è stato disinstallato", "PluginUpdatedWithName": "{0} è stato aggiornato", - "ProviderValue": "Provider: {0}", - "ScheduledTaskFailedWithName": "{0} fallito", - "ScheduledTaskStartedWithName": "{0} avviato", - "ServerNameNeedsToBeRestarted": "{0} deve essere riavviato", + "ScheduledTaskFailedWithName": "{0} non riuscito", "Shows": "Serie TV", - "Songs": "Brani", - "StartupEmbyServerIsLoading": "Jellyfin server si sta avviando. Per favore riprova più tardi.", - "SubtitleDownloadFailureForItem": "Impossibile scaricare i sottotitoli per {0}", + "StartupEmbyServerIsLoading": "Jellyfin Server si sta avviando. Riprova più tardi.", "SubtitleDownloadFailureFromForItem": "Impossibile scaricare i sottotitoli da {0} per {1}", - "Sync": "Sincronizza", - "System": "Sistema", "TvShows": "Serie TV", - "User": "Utente", "UserCreatedWithName": "L'utente {0} è stato creato", - "UserDeletedWithName": "L'utente {0} è stato rimosso", + "UserDeletedWithName": "L'utente {0} è stato eliminato", "UserDownloadingItemWithValues": "{0} sta scaricando {1}", "UserLockedOutWithName": "L'utente {0} è stato bloccato", "UserOfflineFromDevice": "{0} si è disconnesso da {1}", "UserOnlineFromDevice": "{0} è online su {1}", "UserPasswordChangedWithName": "La password è stata cambiata per l'utente {0}", - "UserPolicyUpdatedWithName": "La policy dell'utente è stata aggiornata per {0}", "UserStartedPlayingItemWithValues": "{0} ha avviato la riproduzione di {1} su {2}", "UserStoppedPlayingItemWithValues": "{0} ha interrotto la riproduzione di {1} su {2}", - "ValueHasBeenAddedToLibrary": "{0} è stato aggiunto alla tua libreria multimediale", - "ValueSpecialEpisodeName": "Speciale - {0}", "VersionNumber": "Versione {0}", "TaskRefreshChannelsDescription": "Aggiorna le informazioni dei canali internet.", "TaskDownloadMissingSubtitlesDescription": "Cerca su internet i sottotitoli mancanti basandosi sulle configurazioni dei metadati.", @@ -115,20 +85,18 @@ "TasksLibraryCategory": "Libreria", "TasksMaintenanceCategory": "Manutenzione", "TaskCleanActivityLog": "Attività di Registro Completate", - "TaskCleanActivityLogDescription": "Elimina le voci del registro delle attività più vecchie dell’età configurata.", - "Undefined": "Non Definito", + "TaskCleanActivityLogDescription": "Elimina le voci del registro delle attività più vecchie dell'età configurata.", + "Undefined": "Non specificato", "Forced": "Forzato", "Default": "Predefinito", "TaskOptimizeDatabaseDescription": "Compatta database e tronca spazi liberi. Eseguire questa azione dopo la scansione o dopo aver fatto altre modifiche inerenti il database potrebbe aumentarne le prestazioni.", "TaskOptimizeDatabase": "Ottimizza database", "TaskKeyframeExtractor": "Estrattore di Keyframe", - "TaskKeyframeExtractorDescription": "Estrae i keyframe dai video per creare migliori playlist HLS. Questa procedura potrebbe richiedere molto tempo.", + "TaskKeyframeExtractorDescription": "Estrae i keyframe dai video per creare migliori scalette HLS. Questa procedura potrebbe richiedere molto tempo.", "External": "Esterno", - "HearingImpaired": "Non Udenti", + "HearingImpaired": "Non udenti", "TaskRefreshTrickplayImages": "Genera immagini Trickplay", "TaskRefreshTrickplayImagesDescription": "Crea anteprime trickplay per i video nelle librerie abilitate.", - "TaskCleanCollectionsAndPlaylists": "Ripulire le collezioni e le playlist", - "TaskCleanCollectionsAndPlaylistsDescription": "Rimuove gli elementi dalle collezioni e dalle playlist che non esistono più.", "TaskAudioNormalization": "Normalizzazione dell'audio", "TaskAudioNormalizationDescription": "Scansiona i file alla ricerca dei dati per la normalizzazione dell'audio.", "TaskDownloadMissingLyricsDescription": "Scarica testi per le canzoni", @@ -138,5 +106,20 @@ "TaskExtractMediaSegmentsDescription": "Estrae o ottiene segmenti multimediali dai plugin abilitati MediaSegment.", "TaskExtractMediaSegments": "Scansiona Segmento Media", "CleanupUserDataTask": "Task di pulizia dei dati utente", - "CleanupUserDataTaskDescription": "Pulisce tutti i dati utente (stato di visione, status preferiti, ecc.) dai contenuti non più presenti da almeno 90 giorni." + "CleanupUserDataTaskDescription": "Pulisce tutti i dati utente (stato di visione, status preferiti, ecc.) dai contenuti non più presenti da almeno 90 giorni.", + "Original": "Originale", + "LyricDownloadFailureFromForItem": "Scaricamento dei testi non riuscito da {0} per {1}", + "NameExtraBehindTheScenes": "Dietro le scene", + "NameExtraClip": "Filmato", + "NameExtraDeletedScene": "Scena eliminata", + "NameExtraInterview": "Intervista", + "NameExtraNumbered": "{0} {1}", + "NameExtraScene": "Scena", + "NameExtraSample": "Campione", + "NameExtraShort": "Corto", + "NameExtraThemeSong": "Sigla musicale", + "NameExtraTrailer": "Trailer", + "NameExtraFeaturette": "Caratteristica", + "NameExtraThemeVideo": "Video tematico", + "NameExtraUnknown": "Extra" } diff --git a/Emby.Server.Implementations/Localization/Core/ja.json b/Emby.Server.Implementations/Localization/Core/ja.json index d564d54cef..78b7ec744b 100644 --- a/Emby.Server.Implementations/Localization/Core/ja.json +++ b/Emby.Server.Implementations/Localization/Core/ja.json @@ -1,41 +1,24 @@ { - "Albums": "アルバム", "AppDeviceValues": "アプリ: {0}, デバイス: {1}", - "Application": "アプリケーション", "Artists": "アーティスト", "AuthenticationSucceededWithUserName": "{0} 認証に成功しました", "Books": "ブック", - "CameraImageUploadedFrom": "新しいカメライメージが {0}からアップロードされました", - "Channels": "チャンネル", "ChapterNameValue": "チャプター {0}", "Collections": "コレクション", - "DeviceOfflineWithName": "{0} が切断しました", - "DeviceOnlineWithName": "{0} が接続しました", "FailedLoginAttemptWithUserName": "{0} からのログインに失敗しました", "Favorites": "お気に入り", "Folders": "フォルダー", "Genres": "ジャンル", - "HeaderAlbumArtists": "アルバムアーティスト", "HeaderContinueWatching": "再生を続ける", - "HeaderFavoriteAlbums": "お気に入りのアルバム", - "HeaderFavoriteArtists": "お気に入りのアーティスト", "HeaderFavoriteEpisodes": "お気に入りのエピソード", "HeaderFavoriteShows": "お気に入りの番組", - "HeaderFavoriteSongs": "お気に入りの曲", "HeaderLiveTV": "ライブTV", "HeaderNextUp": "次", - "HeaderRecordingGroups": "レコーディンググループ", "HomeVideos": "ホームビデオ", "Inherit": "継承", - "ItemAddedWithName": "{0} をライブラリーに追加しました", - "ItemRemovedWithName": "{0} をライブラリーから削除しました", "LabelIpAddressValue": "IPアドレス: {0}", "LabelRunningTimeValue": "時間: {0}", "Latest": "最新", - "MessageApplicationUpdated": "Jellyfin Server を更新しました", - "MessageApplicationUpdatedTo": "Jellyfin Server を {0}に更新しました", - "MessageNamedServerConfigurationUpdatedWithValue": "サーバー設定項目の {0} を更新しました", - "MessageServerConfigurationUpdated": "サーバー設定を更新しました", "MixedContent": "ミックスコンテンツ", "Movies": "映画", "Music": "音楽", @@ -43,41 +26,32 @@ "NameInstallFailed": "{0}のインストールに失敗しました", "NameSeasonNumber": "シーズン {0}", "NameSeasonUnknown": "シーズン不明", - "NewVersionIsAvailable": "新しいバージョンの Jellyfin Server がダウンロード可能です。", + "NewVersionIsAvailable": "新しいバージョンの Jellyfin Server がダウンロードできます。", "NotificationOptionApplicationUpdateAvailable": "アプリケーションの更新があります", "NotificationOptionApplicationUpdateInstalled": "アプリケーションは最新です", "NotificationOptionAudioPlayback": "オーディオの再生を開始", - "NotificationOptionAudioPlaybackStopped": "オーディオの再生をストップしました", + "NotificationOptionAudioPlaybackStopped": "オーディオの再生を停止", "NotificationOptionCameraImageUploaded": "カメライメージがアップロードされました", "NotificationOptionInstallationFailed": "インストール失敗", "NotificationOptionNewLibraryContent": "新しいコンテンツを追加しました", "NotificationOptionPluginError": "プラグインに障害が発生しました", - "NotificationOptionPluginInstalled": "プラグインがインストールされました", - "NotificationOptionPluginUninstalled": "プラグインがアンインストールされました", + "NotificationOptionPluginInstalled": "プラグインをインストールしました", + "NotificationOptionPluginUninstalled": "プラグインをアンインストールしました", "NotificationOptionPluginUpdateInstalled": "プラグインのアップデートをインストールしました", "NotificationOptionServerRestartRequired": "サーバーを再起動してください", "NotificationOptionTaskFailed": "スケジュールされていたタスクの失敗", "NotificationOptionUserLockedOut": "ユーザーはロックされています", - "NotificationOptionVideoPlayback": "ビデオの再生を開始しました", - "NotificationOptionVideoPlaybackStopped": "ビデオを停止しました", + "NotificationOptionVideoPlayback": "ビデオの再生を開始", + "NotificationOptionVideoPlaybackStopped": "ビデオの再生を停止", "Photos": "フォト", - "Playlists": "プレイリスト", - "Plugin": "プラグイン", - "PluginInstalledWithName": "{0} がインストールされました", - "PluginUninstalledWithName": "{0} がアンインストールされました", - "PluginUpdatedWithName": "{0} が更新されました", - "ProviderValue": "プロバイダ: {0}", + "PluginInstalledWithName": "{0} をインストールしました", + "PluginUninstalledWithName": "{0} をアンインストールしました", + "PluginUpdatedWithName": "{0} を更新しました", "ScheduledTaskFailedWithName": "{0} が失敗しました", - "ScheduledTaskStartedWithName": "{0} が開始されました", - "ServerNameNeedsToBeRestarted": "{0} を再起動してください", "Shows": "番組", - "Songs": "曲", "StartupEmbyServerIsLoading": "Jellyfin Server は現在読み込み中です。しばらくしてからもう一度お試しください。", "SubtitleDownloadFailureFromForItem": "{0} から {1}の字幕のダウンロードに失敗しました", - "Sync": "同期", - "System": "システム", "TvShows": "テレビ番組", - "User": "ユーザー", "UserCreatedWithName": "ユーザー {0} が作成されました", "UserDeletedWithName": "User {0} を削除しました", "UserDownloadingItemWithValues": "{0} が {1} をダウンロードしています", @@ -85,11 +59,8 @@ "UserOfflineFromDevice": "{0} は {1} から切断しました", "UserOnlineFromDevice": "{0} は {1} からオンラインになりました", "UserPasswordChangedWithName": "ユーザー {0} のパスワードは変更されました", - "UserPolicyUpdatedWithName": "ユーザーポリシーが{0}に更新されました", "UserStartedPlayingItemWithValues": "{0} は {2}で{1} を再生しています", "UserStoppedPlayingItemWithValues": "{0} は{2}で{1} の再生が終わりました", - "ValueHasBeenAddedToLibrary": "{0} をメディアライブラリーに追加しました", - "ValueSpecialEpisodeName": "スペシャル - {0}", "VersionNumber": "バージョン {0}", "TaskCleanLogsDescription": "{0} 日以上前のログを消去します。", "TaskCleanLogs": "ログの掃除", @@ -126,10 +97,8 @@ "HearingImpaired": "聴覚障害の方", "TaskRefreshTrickplayImages": "トリックプレー画像を生成", "TaskRefreshTrickplayImagesDescription": "有効なライブラリ内のビデオをもとにトリックプレーのプレビューを生成します。", - "TaskCleanCollectionsAndPlaylists": "コレクションとプレイリストをクリーンアップ", "TaskAudioNormalization": "音声の正規化", "TaskAudioNormalizationDescription": "音声の正規化データのためにファイルをスキャンします。", - "TaskCleanCollectionsAndPlaylistsDescription": "在しなくなったコレクションやプレイリストからアイテムを削除します。", "TaskDownloadMissingLyricsDescription": "歌詞をダウンロード", "TaskExtractMediaSegments": "メディアセグメントを読み取る", "TaskMoveTrickplayImages": "Trickplayの画像を移動", @@ -137,5 +106,7 @@ "TaskDownloadMissingLyrics": "失われた歌詞をダウンロード", "TaskExtractMediaSegmentsDescription": "MediaSegment 対応プラグインからメディア セグメントを抽出または取得します。", "CleanupUserDataTask": "ユーザーデータのクリーンアップタスク", - "CleanupUserDataTaskDescription": "90日以上存在しないメディアに対して、視聴状態やお気に入り状態などのユーザーデータをすべて削除します。" + "CleanupUserDataTaskDescription": "90日以上存在しないメディアに対して、視聴状態やお気に入り状態などのユーザーデータをすべて削除します。", + "LyricDownloadFailureFromForItem": "歌詞", + "Original": "オリジナル" } diff --git a/Emby.Server.Implementations/Localization/Core/jbo.json b/Emby.Server.Implementations/Localization/Core/jbo.json index 1b47bb2f23..50d6d49601 100644 --- a/Emby.Server.Implementations/Localization/Core/jbo.json +++ b/Emby.Server.Implementations/Localization/Core/jbo.json @@ -1,7 +1,4 @@ { - "Albums": "lo albuma", "Artists": "lo larpra", - "Books": "lo cukta", - "HeaderAlbumArtists": "lo albuma larpra", - "Playlists": "lo zgipor" + "Books": "lo cukta" } diff --git a/Emby.Server.Implementations/Localization/Core/ka.json b/Emby.Server.Implementations/Localization/Core/ka.json index 2d02522fea..f7ca19d7f0 100644 --- a/Emby.Server.Implementations/Localization/Core/ka.json +++ b/Emby.Server.Implementations/Localization/Core/ka.json @@ -1,42 +1,26 @@ { "Genres": "ჟანრები", - "HeaderAlbumArtists": "ალბომის შემსრულებლები", - "HeaderFavoriteAlbums": "რჩეული ალბომები", "TasksApplicationCategory": "აპლიკაცია", - "Albums": "ალბომები", "AppDeviceValues": "აპლიკაცია: {0}, მოწყობილობა: {1}", - "Application": "აპლიკაცია", - "Artists": "არტისტი", + "Artists": "შემსრულებლები", "AuthenticationSucceededWithUserName": "{0} -ის ავთენტიკაცია წარმატებულია", "Books": "წიგნები", - "Forced": "ძალით", + "Forced": "იძულებითი", "Inherit": "მემკვიდრეობით", "Latest": "უახლესი", "Movies": "ფილმები", "Music": "მუსიკა", "Photos": "ფოტოები", - "Playlists": "დასაკრავი სიები", - "Plugin": "დამატება", "Shows": "სერიალები", - "Songs": "სიმღერები", - "Sync": "სინქრონიზაცია", - "System": "სისტემა", - "Undefined": "აღუწერელი", - "User": "მომხმარებელი", + "Undefined": "განუსაზღვრელი", "TasksMaintenanceCategory": "რემონტი", "TasksLibraryCategory": "ბიბლიოთეკა", "ChapterNameValue": "თავი {0}", "HeaderContinueWatching": "ყურების გაგრძელება", - "HeaderFavoriteArtists": "რჩეული შემსრულებლები", - "DeviceOfflineWithName": "{0} გაითიშა", "External": "გარე", "HeaderFavoriteEpisodes": "რჩეული ეპიზოდები", - "HeaderFavoriteSongs": "რჩეული სიმღერები", - "HeaderRecordingGroups": "ჩამწერი ჯგუფები", "HearingImpaired": "სმენადაქვეითებული", - "LabelRunningTimeValue": "გაშვებულობის დრო: {0}", - "MessageApplicationUpdatedTo": "Jellyfin-ის სერვერი განახლდა {0}-ზე", - "MessageNamedServerConfigurationUpdatedWithValue": "სერვერის კონფიგურაციის სექცია {0} განახლდა", + "LabelRunningTimeValue": "გაშვების დრო: {0}", "MixedContent": "შერეული შემცველობა", "MusicVideos": "მუსიკის ვიდეოები", "NotificationOptionInstallationFailed": "დაყენების შეცდომა", @@ -45,85 +29,84 @@ "NotificationOptionCameraImageUploaded": "კამერის გამოსახულება ატვირთულია", "NotificationOptionVideoPlaybackStopped": "ვიდეოს დაკვრა გაჩერებულია", "PluginUninstalledWithName": "{0} წაიშალა", - "ScheduledTaskStartedWithName": "{0} გაეშვა", "VersionNumber": "ვერსია {0}", "TasksChannelsCategory": "ინტერნეტ-არხები", - "ValueSpecialEpisodeName": "სპეციალური - {0}", - "TaskRefreshChannelsDescription": "ინტერნეტ-არხის ინფორმაციის განახლება.", - "Channels": "არხები", + "TaskRefreshChannelsDescription": "განაახლებს ინტერნეტ-არხის ინფორმაციას.", "Collections": "კოლექციები", - "Default": "ნაგულისხმები", + "Default": "ნაგულისხმევი", "Favorites": "რჩეულები", "Folders": "საქაღალდეები", "HeaderFavoriteShows": "რჩეული სერიალები", - "HeaderLiveTV": "ცოცხალი TV", - "HeaderNextUp": "შემდეგი ზემოთ", + "HeaderLiveTV": "ცოცხალი ტელევიზია", + "HeaderNextUp": "შემდეგი", "HomeVideos": "სახლის ვიდეოები", "NameSeasonNumber": "სეზონი {0}", "NameSeasonUnknown": "სეზონი უცნობია", "NotificationOptionPluginError": "დამატების შეცდომა", "NotificationOptionPluginInstalled": "დამატება დაყენებულია", - "NotificationOptionPluginUninstalled": "დამატება წაიშალა", - "ProviderValue": "მომწოდებელი: {0}", - "ScheduledTaskFailedWithName": "{0} ავარიულია", - "TvShows": "TV სერიალები", + "NotificationOptionPluginUninstalled": "მოდული წაიშალა", + "ScheduledTaskFailedWithName": "{0} ჩავარდა", + "TvShows": "სატელევიზიო სერიალები", "TaskRefreshPeople": "ხალხის განახლება", "TaskUpdatePlugins": "დამატებების განახლება", "TaskRefreshChannels": "არხების განახლება", - "TaskOptimizeDatabase": "ბაზების ოპტიმიზაცია", + "TaskOptimizeDatabase": "მონაცემთა ბაზის ოპტიმიზაცია", "TaskKeyframeExtractor": "საკვანძო კადრის გამომღები", - "DeviceOnlineWithName": "{0} შეერთებულია", "LabelIpAddressValue": "IP მისამართი: {0}", - "NameInstallFailed": "{0}-ის დაყენების შეცდომა", + "NameInstallFailed": "{0}-ის დაყენების ჩავარდა", "NotificationOptionApplicationUpdateAvailable": "ხელმისაწვდომია აპლიკაციის განახლება", "NotificationOptionAudioPlaybackStopped": "აუდიოს დაკვრა გაჩერებულია", "NotificationOptionNewLibraryContent": "ახალი შემცველობა დამატებულია", "NotificationOptionPluginUpdateInstalled": "დამატების განახლება დაყენებულია", - "NotificationOptionServerRestartRequired": "სერვერის გადატვირთვა აუცილებელია", - "NotificationOptionTaskFailed": "დაგეგმილი ამოცანის შეცდომა", + "NotificationOptionServerRestartRequired": "საჭიროა სერვერის გადატვირთვა", + "NotificationOptionTaskFailed": "დაგეგმილი ამოცანა ჩავარდა", "NotificationOptionUserLockedOut": "მომხმარებელი დაიბლოკა", "NotificationOptionVideoPlayback": "ვიდეოს დაკვრა დაწყებულია", "PluginInstalledWithName": "{0} დაყენებულია", "PluginUpdatedWithName": "{0} განახლდა", "TaskCleanActivityLog": "აქტივობების ჟურნალის გასუფთავება", - "TaskCleanCache": "ქეშის საქაღალდის გასუფთავება", - "TaskRefreshChapterImages": "თავის სურათების გაშლა", + "TaskCleanCache": "კეშის საქაღალდის გასუფთავება", + "TaskRefreshChapterImages": "თავის სურათების ამოღება", "TaskRefreshLibrary": "მედიის ბიბლიოთეკის სკანირება", "TaskCleanLogs": "ჟურნალის საქაღალდის გასუფთავება", "TaskCleanTranscode": "ტრანსკოდირების საქაღალდის გასუფთავება", "TaskDownloadMissingSubtitles": "ნაკლული სუბტიტრების გადმოწერა", - "UserDownloadingItemWithValues": "{0} -ი {0}-ს იწერს", - "FailedLoginAttemptWithUserName": "{0}-დან შემოსვლის შეცდომა", - "MessageApplicationUpdated": "Jellyfin-ის სერვერი განახლდა", - "MessageServerConfigurationUpdated": "სერვერის კონფიგურაცია განახლდა", - "ServerNameNeedsToBeRestarted": "საჭიროა {0}-ის გადატვირთვა", + "UserDownloadingItemWithValues": "{0} იწერს {1}-ს", + "FailedLoginAttemptWithUserName": "შესვლის წარუმატებელი მცდელობა {0}-დან", "UserCreatedWithName": "მომხმარებელი {0} შეიქმნა", - "UserDeletedWithName": "მომხმარებელი {0} წაშლილია", - "UserOnlineFromDevice": "{0}-ი ხაზზეა {1}-დან", - "UserOfflineFromDevice": "{0}-ი {1}-დან გაითიშა", - "ItemAddedWithName": "{0} ჩამატებულია ბიბლიოთეკაში", - "ItemRemovedWithName": "{0} წაშლილია ბიბლიოთეკიდან", + "UserDeletedWithName": "მომხმარებელი {0} წაიშალა", + "UserOnlineFromDevice": "{0} ხაზზეა {1}-დან", + "UserOfflineFromDevice": "{0} გაითიშა {1}-დან", "UserLockedOutWithName": "მომხმარებელი {0} დაბლოკილია", - "UserStartedPlayingItemWithValues": "{0} თამაშობს {1}-ს {2}-ზე", - "UserPasswordChangedWithName": "მომხმარებლისთვის {0} პაროლი შეცვლილია", - "UserPolicyUpdatedWithName": "{0}-ის მომხმარებლის პოლიტიკა განახლდა", - "UserStoppedPlayingItemWithValues": "{0}-მა დაამთავრა {1}-ის დაკვრა {2}-ზე", + "UserStartedPlayingItemWithValues": "{0} უკრავს {1}-ს {2}-ზე", + "UserPasswordChangedWithName": "მომხმარებელი {0}-სთვის პაროლი შეიცვალა", + "UserStoppedPlayingItemWithValues": "{0}-მა დაასრულა {1}-ის ყურება {2}-ზე", "TaskRefreshChapterImagesDescription": "თავების მქონე ვიდეოებისთვის მინიატურების შექმნა.", "TaskKeyframeExtractorDescription": "უფრო ზუსტი HLS დასაკრავი სიებისითვის ვიდეოდან საკვანძო გადრების ამოღება. შეიძლება საკმაო დრო დასჭირდეს.", "NewVersionIsAvailable": "გადმოსაწერად ხელმისაწვდომია Jellyfin -ის ახალი ვერსია.", - "CameraImageUploadedFrom": "ახალი კამერის გამოსახულება ატვირთულია {0}-დან", "StartupEmbyServerIsLoading": "Jellyfin სერვერი იტვირთება. მოგვიანებით სცადეთ.", - "SubtitleDownloadFailureFromForItem": "{0}-დან {1}-სთვის სუბტიტრების გადმოწერის შეცდომა", - "ValueHasBeenAddedToLibrary": "{0} დაემატა თქვენს მედიის ბიბლიოთეკას", - "TaskCleanActivityLogDescription": "მითითებულ ასაკზე ძველი ჟურნალის ჩანაწერების წაშლა.", - "TaskCleanCacheDescription": "სისტემისთვის არასაჭირო ქეშის ფაილების წაშლა.", - "TaskRefreshLibraryDescription": "თქვენი მედია ბიბლიოთეკაში ახალი ფაილების ძებნა და მეტამონაცემების განახლება.", + "SubtitleDownloadFailureFromForItem": "{0}-დან {1}-სთვის სუბტიტრების გადმოწერა ვერ შესრულდა", + "TaskCleanActivityLogDescription": "შლის მითითებულ ასაკზე ძველ ჟურნალის ჩანაწერებს.", + "TaskCleanCacheDescription": "შლის სისტემისთვის არასაჭირო ქეშის ფაილებს.", + "TaskRefreshLibraryDescription": "ეძებს ახალ ფაილებს თქვენს მედიის ბიბლიოთეკაში და ანახლებს მეტამონაცემებს.", "TaskCleanLogsDescription": "{0} დღეზე ძველი ჟურნალის ფაილების წაშლა.", "TaskRefreshPeopleDescription": "თქვენს მედიის ბიბლიოთეკაში მსახიობების და რეჟისორების მეტამონაცემების განახლება.", - "TaskUpdatePluginsDescription": "ავტომატურად განახლებადად მონიშნული დამატებების განახლებების გადმოწერა და დაყენება.", + "TaskUpdatePluginsDescription": "ავტომატურად განახლებადად მონიშნული მოდულების განახლებების გადმოწერა და დაყენება.", "TaskCleanTranscodeDescription": "ერთ დღეზე უფრო ძველი ტრანსკოდირების ფაილების წაშლა.", - "TaskDownloadMissingSubtitlesDescription": "მეტამონაცემებზე დაყრდნობით ინტერნეტში ნაკლული სუბტიტრების ძებნა.", - "TaskOptimizeDatabaseDescription": "ბაზს შეკუშვა და ადგილის გათავისუფლება. ამ ამოცანის ბიბლიოთეკის სკანირების ან ნებისმიერი ცვლილების, რომელიც ბაზაში რამეს აკეთებს, გაშვებას შეუძლია ბაზის წარმადობა გაზარდოს.", - "TaskRefreshTrickplayImagesDescription": "ქმნის trickplay წინასწარ ხედებს ვიდეოებისთვის ჩართულ ბიბლიოთეკებში.", - "TaskRefreshTrickplayImages": "Trickplay სურათების გენერირება" + "TaskDownloadMissingSubtitlesDescription": "ეძებს ბიბლიოთეკაში მიუწვდომელ სუბტიტრებს ინტერნეტში მეტამონაცემებზე დაყრდნობით.", + "TaskOptimizeDatabaseDescription": "კუმშავს მონაცემთა ბაზას ადგილის გათავისუფლებლად. ამ ამოცანის ბიბლიოთეკის სკანირების ან ნებისმიერი ცვლილების, რომელიც ბაზაში რამეს აკეთებს, გაშვებას შეუძლია ბაზის წარმადობა გაზარდოს.", + "TaskRefreshTrickplayImagesDescription": "ქმნის trickplay წინასწარ ხედებს ვიდეოებისთვის დაშვებულ ბიბლიოთეკებში.", + "TaskRefreshTrickplayImages": "Trickplay სურათების გენერაცია", + "TaskAudioNormalization": "აუდიოს ნორმალიზაცია", + "TaskAudioNormalizationDescription": "აანალიზებს ფაილებს აუდიოს ნორმალიზაციისთვის.", + "TaskDownloadMissingLyrics": "მიუწვდომელი ლირიკების ჩამოტვირთვა", + "TaskDownloadMissingLyricsDescription": "გადმოწერს ლირიკას სიმღერებისთვის", + "TaskExtractMediaSegments": "მედიის სეგმენტების სკანირება", + "TaskExtractMediaSegmentsDescription": "მედია სეგმენტების სკანირება მხარდაჭერილი მოდულებისთვის.", + "TaskMoveTrickplayImages": "Trickplay-ის გამოსახულებების მდებარეობის მიგრაცია", + "TaskMoveTrickplayImagesDescription": "გადააქვს trickplay ფაილები ბიბლიოთეკის პარამეტრებზე დაყრდნობით.", + "CleanupUserDataTask": "მომხმარებლების მონაცემების გასუფთავების ამოცანა", + "CleanupUserDataTaskDescription": "ასუფთავებს მომხმარებლების მონაცემებს (ყურების სტატუსი, ფავორიტები ანდ ა.შ) მედია ელემენტებისთვის რომლების 90 დღეზე მეტია აღარ არსებობენ.", + "LyricDownloadFailureFromForItem": "{1}-ისთვის {0}-დან ლირიკის გადმოწერა ჩავარდა", + "Original": "ორიგინალი" } diff --git a/Emby.Server.Implementations/Localization/Core/kab.json b/Emby.Server.Implementations/Localization/Core/kab.json index 9551f0e5c1..0d0932b585 100644 --- a/Emby.Server.Implementations/Localization/Core/kab.json +++ b/Emby.Server.Implementations/Localization/Core/kab.json @@ -1,14 +1,10 @@ { "Music": "Aẓawan", - "Sync": "Amtawi", "Photos": "Tiwlafin", "Movies": "Isura", "External": "Azɣaray", - "User": "Aseqdac", "Folders": "Ikaramen", "Favorites": "Ismenyifen", "Default": "Lexṣas", - "Collections": "Tigrummiwin", - "Channels": "Ibuda", - "Albums": "Iseɣraz" + "Collections": "Tigrummiwin" } diff --git a/Emby.Server.Implementations/Localization/Core/kk.json b/Emby.Server.Implementations/Localization/Core/kk.json index e050196bc9..ddcf60944d 100644 --- a/Emby.Server.Implementations/Localization/Core/kk.json +++ b/Emby.Server.Implementations/Localization/Core/kk.json @@ -1,41 +1,24 @@ { - "Albums": "Älbomdar", "AppDeviceValues": "Qoldanba: {0}, Qūrylğy: {1}", - "Application": "Qoldanba", "Artists": "Oryndauşylar", "AuthenticationSucceededWithUserName": "{0} tüpnūsqalyq rastaluy sättı aiaqtaldy", "Books": "Kıtaptar", - "CameraImageUploadedFrom": "{0} kamerasynan jaña suret jüktep salyndy", - "Channels": "Arnalar", "ChapterNameValue": "{0}-sahna", "Collections": "Jiyntyqtar", - "DeviceOfflineWithName": "{0} ajyratylğan", - "DeviceOnlineWithName": "{0} qosylğan", "FailedLoginAttemptWithUserName": "{0} tarapynan kıru äreketı sätsız aiaqtaldy", "Favorites": "Tañdaulylar", "Folders": "Qaltalar", "Genres": "Janrlar", - "HeaderAlbumArtists": "Älbom oryndauşylary", "HeaderContinueWatching": "Qaraudy jalğastyru", - "HeaderFavoriteAlbums": "Tañdauly älbomdar", - "HeaderFavoriteArtists": "Tañdauly oryndauşylar", "HeaderFavoriteEpisodes": "Tañdauly telebölımder", "HeaderFavoriteShows": "Tañdauly körsetımder", - "HeaderFavoriteSongs": "Tañdauly äuender", "HeaderLiveTV": "Efir", "HeaderNextUp": "Kezektı", - "HeaderRecordingGroups": "Jazba toptary", "HomeVideos": "Üilık beineler", "Inherit": "İelenu", - "ItemAddedWithName": "{0} tasyğyşhanağa üstelindı", - "ItemRemovedWithName": "{0} tasyğyşhanadan alastaldy", "LabelIpAddressValue": "IP-mekenjaiy: {0}", "LabelRunningTimeValue": "Oinatu uaqyty: {0}", "Latest": "Eñ keiıngı", - "MessageApplicationUpdated": "Jellyfin Serverı jañartyldy", - "MessageApplicationUpdatedTo": "Jellyfin Serverı {0} nūsqasyna jañartyldy", - "MessageNamedServerConfigurationUpdatedWithValue": "Server teñşelımderınıñ {0} bölımı jañartyldy", - "MessageServerConfigurationUpdated": "Server teñşelımderı jañartyldy", "MixedContent": "Aralas mazmūn", "Movies": "Filmder", "Music": "Muzyka", @@ -61,24 +44,14 @@ "NotificationOptionVideoPlayback": "Beine oinatuy bastaldy", "NotificationOptionVideoPlaybackStopped": "Beine oinatuy toqtatyldy", "Photos": "Fotosuretter", - "Playlists": "Oinatu tızımderı", - "Plugin": "Plagin", "PluginInstalledWithName": "{0} ornatyldy", "PluginUninstalledWithName": "{0} joiyldy", "PluginUpdatedWithName": "{0} jañartyldy", - "ProviderValue": "Jetkızuşı: {0}", "ScheduledTaskFailedWithName": "{0} sätsız", - "ScheduledTaskStartedWithName": "{0} ıske qosyldy", - "ServerNameNeedsToBeRestarted": "{0} qaita ıske qosu qajet", "Shows": "Körsetımder", - "Songs": "Äuender", "StartupEmbyServerIsLoading": "Jellyfin Server jüktelude. Ärekettı köp ūzamai qaitalañyz.", - "SubtitleDownloadFailureForItem": "Субтитрлер {0} үшін жүктеліп алынуы сәтсіз", "SubtitleDownloadFailureFromForItem": "{1} üşın subtitrlerdı {0} közınen jüktep alu sätsız", - "Sync": "Ündestıru", - "System": "Jüie", "TvShows": "TD-körsetımder", - "User": "Paidalanuşy", "UserCreatedWithName": "Paidalanuşy {0} jasalğan", "UserDeletedWithName": "Paidalanuşy {0} joiylğan", "UserDownloadingItemWithValues": "{0} — {1} jüktep aluda", @@ -86,11 +59,8 @@ "UserOfflineFromDevice": "{0} — {1} tarapynan ajyratyldy", "UserOnlineFromDevice": "{0} — {1} tarapynan qosyldy", "UserPasswordChangedWithName": "Paidalanuşy {0} üşın paröl özgertıldı", - "UserPolicyUpdatedWithName": "Paidalanuşy {0} üşın saiasattary jañartyldy", "UserStartedPlayingItemWithValues": "{0} — {2} tarapynan {1} oinatuda", "UserStoppedPlayingItemWithValues": "{0} — {2} tarapynan {1} oinatuyn toqtatty", - "ValueHasBeenAddedToLibrary": "{0} tasyğyşhanağa üstelındı", - "ValueSpecialEpisodeName": "Arnaiy - {0}", "VersionNumber": "Nūsqasy {0}", "Default": "Ädepkı", "TaskDownloadMissingSubtitles": "Joq subtitrlerdı jüktep alu", diff --git a/Emby.Server.Implementations/Localization/Core/km.json b/Emby.Server.Implementations/Localization/Core/km.json index 5d10975f32..b4057eb8eb 100644 --- a/Emby.Server.Implementations/Localization/Core/km.json +++ b/Emby.Server.Implementations/Localization/Core/km.json @@ -1,88 +1,62 @@ { - "Albums": "អាលប៊ុម", - "MessageApplicationUpdatedTo": "ម៉ាស៊ីនមេនៃJellyfinត្រូវបានអាប់ដេតទៅកាន់ {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "ការកំណត់ម៉ាស៊ីនមេ ផ្នែក {0} ត្រូវបានអាប់ដេត", - "MessageServerConfigurationUpdated": "ការកំណត់ម៉ាស៊ីនមេត្រូវបានអាប់ដេត", "AppDeviceValues": "កម្មវិធី: {0}, ឧបករណ៍: {1}", "MixedContent": "មាតិកាចម្រុះ", "UserLockedOutWithName": "អ្នកប្រើប្រាស់ {0} ត្រូវ​បាន​ផ្អាក", - "Application": "កម្មវិធី", "Artists": "សិល្បករ", "AuthenticationSucceededWithUserName": "{0} បានផ្ទៀងផ្ទាត់ដោយជោគជ័យ", "Books": "សៀវភៅ", "NameSeasonNumber": "រដូវកាលទី {0}", "NotificationOptionPluginInstalled": "Plugin បានដំឡើងរួច", - "CameraImageUploadedFrom": "រូបភាពកាមេរ៉ាថ្មីត្រូវបានបង្ហោះពី {0}", - "Channels": "ប៉ុស្ត៍", "ChapterNameValue": "ជំពូក {0}", "Collections": "បណ្តុំ", "External": "ខាងក្រៅ", "Default": "លំនាំដើម", "NotificationOptionInstallationFailed": "ការដំឡើងមិនបានសម្រេច", - "DeviceOfflineWithName": "{0} បានផ្តាច់", "Folders": "ថតឯកសារ", - "DeviceOnlineWithName": "{0} បានភ្ចាប់", "HearingImpaired": "ខ្សោយការស្តាប់", "HomeVideos": "វីឌីអូថតខ្លួនឯង", "Favorites": "ចំណូលចិត្ត", "HeaderFavoriteEpisodes": "ភាគដែលចូលចិត្ត", "Forced": "បង្ខំ", "Genres": "ប្រភេទ", - "HeaderFavoriteArtists": "សិល្បករដែលចូលចិត្ត", "NotificationOptionApplicationUpdateAvailable": "កម្មវិធី យើងអាចអាប់ដេតបាន", "NotificationOptionApplicationUpdateInstalled": "កម្មវិធី ដែលបានដំឡើងរួច", "NotificationOptionAudioPlaybackStopped": "ការ​ចាក់សម្លេងបានផ្អាក", "HeaderContinueWatching": "បន្តមើល", - "HeaderFavoriteAlbums": "អាល់ប៊ុមដែលចូលចិត្ត", "HeaderFavoriteShows": "រឿងភាគដែលចូលចិត្ត", "NewVersionIsAvailable": "មានជំនាន់ថ្មី ម៉ាស៊ីនមេJellyfin អាចទាញយកបាន.", - "HeaderAlbumArtists": "សិល្បករអាល់ប៊ុម", "NotificationOptionCameraImageUploaded": "រូបភាពពីកាំមេរ៉ាបានអាប់ឡូតរួច", - "HeaderFavoriteSongs": "ចម្រៀងដែលចូលចិត្ត", "HeaderNextUp": "បន្ទាប់", "HeaderLiveTV": "ទូរទស្សន៍ផ្សាយផ្ទាល់", "Movies": "រឿង", - "HeaderRecordingGroups": "ក្រុមនៃការថត", "Music": "តន្ត្រី", "Inherit": "មរតក", "MusicVideos": "វីដេអូតន្ត្រី", "NameInstallFailed": "{0} ការដំឡើងបានបរាជ័យ", "NotificationOptionNewLibraryContent": "មាតិកាថ្មីៗត្រូវបានបន្ថែម", - "ItemAddedWithName": "{0} ត្រូវបានបន្ថែមទៅបណ្ណាល័យ", "NameSeasonUnknown": "រដូវកាលមិនច្បាស់លាស់", - "ItemRemovedWithName": "{0} ត្រូវបានដកចេញពីបណ្ណាល័យ", "LabelIpAddressValue": "លេខ IP: {0}", "LabelRunningTimeValue": "ពេលវេលាកំពុងដំណើរការ: {0}", "Latest": "ចុងក្រោយ", "NotificationOptionAudioPlayback": "ការ​ចាក់​សំឡេង​បាន​ចាប់ផ្ដើម", "NotificationOptionPluginError": "Plugin មិនដំណើរការ", "NotificationOptionPluginUninstalled": "Plugin បានលុបចេញរួច", - "MessageApplicationUpdated": "ម៉ាស៊ីនមេនៃJellyfinត្រូវបានអាប់ដេត", "NotificationOptionPluginUpdateInstalled": "Plugin អាប់ដេតបានដំឡើងរួច", "NotificationOptionUserLockedOut": "អ្នកប្រើប្រាស់ត្រូវបានជាប់គាំង", "NotificationOptionServerRestartRequired": "តម្រូវឱ្យចាប់ផ្ដើមម៉ាស៊ីនមេឡើងវិញ", "Photos": "រូបថត", - "Playlists": "បញ្ជីចាក់", - "Plugin": "Plugin", "PluginInstalledWithName": "{0} ត្រូវបានដំឡើង", "NotificationOptionTaskFailed": "កិច្ចការដែលបានគ្រោងទុកបានបរាជ័យ", "PluginUpdatedWithName": "{0} ត្រូវបានអាប់ដេត", "NotificationOptionVideoPlayback": "ការចាក់វីដេអូបានចាប់ផ្តើម", - "Songs": "ចម្រៀង", - "ScheduledTaskStartedWithName": "{0} បានចាប់ផ្តើម", "NotificationOptionVideoPlaybackStopped": "ការ​ចាក់​វីដេអូ​បាន​បញ្ឈប់", "PluginUninstalledWithName": "{0} ត្រូវបានលុបចេញ", "Shows": "រឿងភាគ", - "ProviderValue": "អ្នកផ្តល់សេវា: {0}", "SubtitleDownloadFailureFromForItem": "សាប់ថាយថលបានបរាជ័យក្នុងការទាញយកពី {0} នៃ {1}", - "Sync": "ធ្វើអោយដំណាលគ្នា", - "System": "ប្រព័ន្ធ", "TvShows": "កម្មវិធីទូរទស្សន៍", "ScheduledTaskFailedWithName": "{0} បានបរាជ័យ", "Undefined": "មិនបានកំណត់", - "User": "អ្នកប្រើប្រាស់", "UserCreatedWithName": "អ្នកប្រើប្រាស់ {0} ត្រូវបានបង្កើតឡើង", - "ServerNameNeedsToBeRestarted": "{0} ចាំបាច់ត្រូវចាប់ផ្តើមឡើងវិញ", "StartupEmbyServerIsLoading": "ម៉ាស៊ីនមេJellyfin កំពុងដំណើរការ. សូមព្យាយាមម្តងទៀតក្នុងពេលឆាប់ៗនេះ.", "UserDeletedWithName": "អ្នកប្រើប្រាស់ {0} ត្រូវបានលុបចេញ", "UserOnlineFromDevice": "{0} បានឃើញអនឡានពី {1}", @@ -98,10 +72,7 @@ "UserPasswordChangedWithName": "ពាក្យសម្ងាត់ត្រូវបានផ្លាស់ប្តូរសម្រាប់អ្នកប្រើប្រាស់ {0}", "TaskCleanCache": "សម្អាតបញ្ជីឃ្លាំងសម្ងាត់", "TaskRefreshChapterImages": "ដកស្រង់រូបភាពតាមជំពូក", - "UserPolicyUpdatedWithName": "គោលការណ៍អ្នកប្រើប្រាស់ត្រូវបានធ្វើបច្ចុប្បន្នភាពសម្រាប់ {0}", "UserStoppedPlayingItemWithValues": "{0} បានបញ្ចប់ការចាក់ {1} នៅលើ {2}", - "ValueHasBeenAddedToLibrary": "{0} ត្រូវបានបញ្ចូលទៅក្នុងបណ្ណាល័យរឿងរបស់អ្នក", - "ValueSpecialEpisodeName": "ពិសេស - {0}", "TasksChannelsCategory": "ប៉ុស្តតាមអ៊ីនធឺណិត", "TaskAudioNormalization": "ធ្វើឱ្យមានតន្ត្រីមានសម្លេងស្មើគ្នា", "TaskCleanActivityLogDescription": "លុបកំណត់ហេតុសកម្មភាពចាស់ជាងអាយុដែលបានកំណត់រចនាសម្ព័ន្ធ.", @@ -127,7 +98,5 @@ "TaskUpdatePluginsDescription": "ទាញយក និងដំឡើងបច្ចុប្បន្នភាពសម្រាប់Plugins ដែលត្រូវបាន Config ដើម្បីធ្វើបច្ចុប្បន្នភាពដោយស្វ័យប្រវត្តិ.", "TaskCleanTranscodeDescription": "លុបឯកសារ Transcode ដែលលើសពីមួយថ្ងៃ.", "TaskDownloadMissingSubtitlesDescription": "ស្វែងរកតាមអ៊ីនធឺណិត សម្រាប់សាប់ថាយថល ដែលបាត់ដោយផ្អែកលើ metadata.", - "TaskOptimizeDatabase": "ធ្វើឱ្យ Database ប្រសើរឡើង", - "TaskCleanCollectionsAndPlaylistsDescription": "លុបរបស់របរចេញពីបណ្តុំ និងបញ្ជីចាក់ដែលលែងមាន.", - "TaskCleanCollectionsAndPlaylists": "សម្អាតបណ្តុំ និងបញ្ជីចាក់" + "TaskOptimizeDatabase": "ធ្វើឱ្យ Database ប្រសើរឡើង" } diff --git a/Emby.Server.Implementations/Localization/Core/kn.json b/Emby.Server.Implementations/Localization/Core/kn.json index 9f49be53b4..6009b50fe0 100644 --- a/Emby.Server.Implementations/Localization/Core/kn.json +++ b/Emby.Server.Implementations/Localization/Core/kn.json @@ -4,8 +4,6 @@ "TaskOptimizeDatabaseDescription": "ಡೇಟಾಬೇಸ್ ಅನ್ನು ಕಾಂಪ್ಯಾಕ್ಟ್ ಮಾಡುತ್ತದೆ ಮತ್ತು ಮುಕ್ತ ಜಾಗವನ್ನು ಮೊಟಕುಗೊಳಿಸುತ್ತದೆ. ಲೈಬ್ರರಿಯನ್ನು ಸ್ಕ್ಯಾನ್ ಮಾಡಿದ ನಂತರ ಈ ಕಾರ್ಯವನ್ನು ನಡೆಸುವುದು ಅಥವಾ ಡೇಟಾಬೇಸ್ ಮಾರ್ಪಾಡುಗಳನ್ನು ಸೂಚಿಸುವ ಇತರ ಬದಲಾವಣೆಗಳನ್ನು ಮಾಡುವುದರಿಂದ ಕಾರ್ಯಕ್ಷಮತೆಯನ್ನು ಸುಧಾರಿಸಬಹುದು.", "TaskKeyframeExtractor": "ಕೀಫ್ರೇಮ್ ಎಕ್ಸ್‌ಟ್ರಾಕ್ಟರ್", "TaskKeyframeExtractorDescription": "ಹೆಚ್ಚು ನಿಖರವಾದ HLS ಪ್ಲೇಪಟ್ಟಿಗಳನ್ನು ರಚಿಸಲು ವೀಡಿಯೊ ಫೈಲ್‌ಗಳಿಂದ ಕೀಫ್ರೇಮ್‌ಗಳನ್ನು ಹೊರತೆಗೆಯುತ್ತದೆ. ಈ ಕಾರ್ಯವು ದೀರ್ಘಕಾಲದವರೆಗೆ ನಡೆಯಬಹುದು.", - "ValueHasBeenAddedToLibrary": "{0} ಅನ್ನು ನಿಮ್ಮ ಮಾಧ್ಯಮ ಲೈಬ್ರರಿಗೆ ಸೇರಿಸಲಾಗಿದೆ", - "ValueSpecialEpisodeName": "ವಿಶೇಷ - {0}", "TasksLibraryCategory": "ಸಮೊಹ", "TasksApplicationCategory": "ಅಪ್ಲಿಕೇಶನ್", "TasksChannelsCategory": "ಇಂಟರ್ನೆಟ್ ಚಾನೆಲ್ಗಳು", @@ -13,8 +11,6 @@ "TaskCleanCacheDescription": "ಸಿಸ್ಟಮ್‌ಗೆ ಇನ್ನು ಮುಂದೆ ಅಗತ್ಯವಿಲ್ಲದ ಸಂಗ್ರಹ ಫೈಲ್‌ಗಳನ್ನು ಅಳಿಸುತ್ತದೆ.", "TaskRefreshLibrary": "ಸ್ಕ್ಯಾನ್ ಮೀಡಿಯಾ ಲೈಬ್ರರಿ", "UserOfflineFromDevice": "{1} ನಿಂದ {0} ಸಂಪರ್ಕ ಕಡಿತಗೊಂಡಿದೆ", - "Albums": "ಸಂಪುಟ", - "Application": "ಅಪ್ಲಿಕೇಶನ್", "AppDeviceValues": "ಅಪ್ಲಿಕೇಶನ್: {0}, ಸಾಧನ: {1}", "Artists": "ಕಲಾವಿದರು", "AuthenticationSucceededWithUserName": "{0} ಯಶಸ್ವಿಯಾಗಿ ದೃಢೀಕರಿಸಲಾಗಿದೆ", @@ -22,8 +18,6 @@ "ChapterNameValue": "ಅಧ್ಯಾಯ {0}", "Collections": "ಸಂಗ್ರಹಣೆಗಳು", "Default": "ಪೂರ್ವನಿಯೋಜಿತ", - "DeviceOfflineWithName": "{0} ಸಂಪರ್ಕ ಕಡಿತಗೊಂಡಿದೆ", - "DeviceOnlineWithName": "{0} ಸಂಪರ್ಕಗೊಂಡಿದೆ", "External": "ಹೊರಗಿನ", "FailedLoginAttemptWithUserName": "ವಿಫಲ ಲಾಗಿನ್ ಪ್ರಯತ್ನ ಸಂಖ್ಯೆ {0}", "Favorites": "ಮೆಚ್ಚಿನವುಗಳು", @@ -31,22 +25,11 @@ "Forced": "ಬಲವಂತವಾಗಿ", "Genres": "ಪ್ರಕಾರಗಳು", "HeaderContinueWatching": "ನೋಡುವುದನ್ನು ಮುಂದುವರಿಸಿ", - "HeaderFavoriteAlbums": "ಮೆಚ್ಚಿನ ಸಂಪುಟಗಳು", - "HeaderFavoriteArtists": "ಮೆಚ್ಚಿನ ಕಲಾವಿದರು", "HeaderFavoriteShows": "ಮೆಚ್ಚಿನ ಪ್ರದರ್ಶನಗಳು", - "HeaderFavoriteSongs": "ಮೆಚ್ಚಿನ ಹಾಡುಗಳು", "HeaderLiveTV": "ನೇರ ದೂರದರ್ಶನ", "HeaderNextUp": "ಮುಂದೆ", - "HeaderRecordingGroups": "ರೆಕಾರ್ಡಿಂಗ್ ಗುಂಪುಗಳು", - "MessageApplicationUpdated": "ಜೆಲ್ಲಿಫಿನ್ ಸರ್ವರ್ ಅನ್ನು ನವೀಕರಿಸಲಾಗಿದೆ", - "CameraImageUploadedFrom": "ಹೊಸ ಕ್ಯಾಮರಾ ಚಿತ್ರವನ್ನು {0} ನಿಂದ ಅಪ್‌ಲೋಡ್ ಮಾಡಲಾಗಿದೆ", - "Channels": "ಮೂಲಗಳು", - "HeaderAlbumArtists": "ಸಂಪುಟ ಕಲಾವಿದರು", "HeaderFavoriteEpisodes": "ಮೆಚ್ಚಿನ ಸಂಚಿಕೆಗಳು", "HearingImpaired": "ಮೂಗ", - "ItemAddedWithName": "{0} ಅನ್ನು ಸಂಕಲನಕ್ಕೆ ಸೇರಿಸಲಾಗಿದೆ", - "MessageApplicationUpdatedTo": "ಜೆಲ್ಲಿಫಿನ್ ಸರ್ವರ್ ಅನ್ನು {0} ಗೆ ನವೀಕರಿಸಲಾಗಿದೆ", - "MessageNamedServerConfigurationUpdatedWithValue": "ಸರ್ವರ್ ಕಾನ್ಫಿಗರೇಶನ್ ವಿಭಾಗ {0} ಅನ್ನು ನವೀಕರಿಸಲಾಗಿದೆ", "NewVersionIsAvailable": "ಜೆಲ್ಲಿಫಿನ್ ಸರ್ವರ್‌ನ ಹೊಸ ಆವೃತ್ತಿಯು ಡೌನ್‌ಲೋಡ್‌ಗೆ ಲಭ್ಯವಿದೆ.", "NotificationOptionAudioPlayback": "ಆಡಿಯೋ ಪ್ಲೇಬ್ಯಾಕ್ ಪ್ರಾರಂಭವಾಗಿದೆ", "NotificationOptionCameraImageUploaded": "ಕ್ಯಾಮರಾ ಚಿತ್ರವನ್ನು ಅಪ್ಲೋಡ್ ಮಾಡಲಾಗಿದೆ", @@ -55,13 +38,10 @@ "NotificationOptionVideoPlaybackStopped": "ವೀಡಿಯೊ ಪ್ಲೇಬ್ಯಾಕ್ ನಿಲ್ಲಿಸಲಾಗಿದೆ", "PluginUninstalledWithName": "{0} ಅನ್ನು ಅನ್‌ಇನ್‌ಸ್ಟಾಲ್ ಮಾಡಲಾಗಿದೆ", "ScheduledTaskFailedWithName": "{0} ವಿಫಲವಾಗಿದೆ", - "ScheduledTaskStartedWithName": "{0} ಪ್ರಾರಂಭವಾಯಿತು", - "ServerNameNeedsToBeRestarted": "{0} ಅನ್ನು ಮರುಪ್ರಾರಂಭಿಸಬೇಕಾಗಿದೆ", "UserCreatedWithName": "ಬಳಕೆದಾರ {0} ಅನ್ನು ರಚಿಸಲಾಗಿದೆ", "UserLockedOutWithName": "ಬಳಕೆದಾರ {0} ಅನ್ನು ಲಾಕ್ ಮಾಡಲಾಗಿದೆ", "UserOnlineFromDevice": "{1} ನಿಂದ {0} ಆನ್‌ಲೈನ್‌ನಲ್ಲಿದೆ", "UserPasswordChangedWithName": "{0} ಬಳಕೆದಾರರಿಗಾಗಿ ಪಾಸ್‌ವರ್ಡ್ ಅನ್ನು ಬದಲಾಯಿಸಲಾಗಿದೆ", - "UserPolicyUpdatedWithName": "ಬಳಕೆದಾರರ ನೀತಿಯನ್ನು {0} ಗೆ ನವೀಕರಿಸಲಾಗಿದೆ", "UserStartedPlayingItemWithValues": "{2} ರಂದು {0} ಆಡುತ್ತಿದೆ {1}", "UserStoppedPlayingItemWithValues": "{0} ಅವರು {1} ಅನ್ನು {2} ನಲ್ಲಿ ಆಡುವುದನ್ನು ಮುಗಿಸಿದ್ದಾರೆ", "VersionNumber": "ಆವೃತ್ತಿ {0}", @@ -76,23 +56,17 @@ "TaskCleanTranscodeDescription": "ಒಂದು ದಿನಕ್ಕಿಂತ ಹಳೆಯದಾದ ಟ್ರಾನ್ಸ್‌ಕೋಡ್ ಫೈಲ್‌ಗಳನ್ನು ಅಳಿಸುತ್ತದೆ.", "TaskDownloadMissingSubtitles": "ಕಾಣೆಯಾದ ಉಪಶೀರ್ಷಿಕೆಗಳನ್ನು ಡೌನ್‌ಲೋಡ್ ಮಾಡಿ", "Shows": "ಧಾರವಾಹಿಗಳು", - "Songs": "ಹಾಡುಗಳು", "StartupEmbyServerIsLoading": "ಜೆಲ್ಲಿಫಿನ್ ಸರ್ವರ್ ಲೋಡ್ ಆಗುತ್ತಿದೆ. ದಯವಿಟ್ಟು ಸ್ವಲ್ಪ ಸಮಯದ ನಂತರ ಮತ್ತೆ ಪ್ರಯತ್ನಿಸಿ.", "UserDeletedWithName": "ಬಳಕೆದಾರ {0} ಅನ್ನು ಅಳಿಸಲಾಗಿದೆ", "UserDownloadingItemWithValues": "{0} ಡೌನ್‌ಲೋಡ್ ಆಗುತ್ತಿದೆ {1}", "SubtitleDownloadFailureFromForItem": "ಉಪಶೀರ್ಷಿಕೆಗಳು {0} ನಿಂದ {1} ಗಾಗಿ ಡೌನ್‌ಲೋಡ್ ಮಾಡಲು ವಿಫಲವಾಗಿವೆ", - "Sync": "ಹೊಂದಿಕೆ", - "System": "ವ್ಯವಸ್ಥೆ", "TvShows": "ದೂರದರ್ಶನ ಕಾರ್ಯಕ್ರಮಗಳು", "Undefined": "ವ್ಯಾಖ್ಯಾನಿಸಲಾಗಿಲ್ಲ", - "User": "ಬಳಕೆದಾರ", "HomeVideos": "ಮುಖಪುಟ ವೀಡಿಯೊಗಳು", "Inherit": "ಪಾರಂಪರ್ಯವಾಗಿ", - "ItemRemovedWithName": "{0} ಅನ್ನು ಸಂಕಲನದಿಂದ ತೆಗೆದುಹಾಕಲಾಗಿದೆ", "LabelIpAddressValue": "IP ವಿಳಾಸ: {0}", "LabelRunningTimeValue": "ಅವಧಿ: {0}", "Latest": "ಹೊಸದಾದ", - "MessageServerConfigurationUpdated": "ಸರ್ವರ್ ಕಾನ್ಫಿಗರೇಶನ್ ಅನ್ನು ನವೀಕರಿಸಲಾಗಿದೆ", "MixedContent": "ಮಿಶ್ರ ವಿಷಯ", "Movies": "ಚಲನಚಿತ್ರಗಳು", "Music": "ಸಂಗೀತ", @@ -106,17 +80,14 @@ "NotificationOptionInstallationFailed": "ಸ್ಥಾಪನ ವೈಫಲ್ಯ", "NotificationOptionNewLibraryContent": "ಹೊಸ ವಿಷಯವನ್ನು ಒಳಗೊಂಡಿದೆ", "NotificationOptionPluginError": "ಪ್ಲಗಿನ್ ವೈಫಲ್ಯ", - "NotificationOptionPluginInstalled": "ಪ್ಲಗಿನ್ ವೈಫಲ್ಯ", + "NotificationOptionPluginInstalled": "ಪ್ಲಗಿನ್ ಸ್ಥಾಪಿಸಲಾಗಿದೆ", "NotificationOptionPluginUpdateInstalled": "ಪ್ಲಗಿನ್ ನವೀಕರಣವನ್ನು ಸ್ಥಾಪಿಸಲಾಗಿದೆ", "NotificationOptionServerRestartRequired": "ಸರ್ವರ್ ಮರುಪ್ರಾರಂಭದ ಅಗತ್ಯವಿದೆ", "NotificationOptionTaskFailed": "ನಿಗದಿತ ಕಾರ್ಯ ವೈಫಲ್ಯ", "NotificationOptionVideoPlayback": "ವೀಡಿಯೊ ಪ್ಲೇಬ್ಯಾಕ್ ಪ್ರಾರಂಭವಾಗಿದೆ", "Photos": "ಚಿತ್ರಗಳು", - "Playlists": "ಪ್ಲೇಪಟ್ಟಿಗಳು", - "Plugin": "ಪ್ಲಗಿನ್", "PluginInstalledWithName": "{0} ಅನ್ನು ಸ್ಥಾಪಿಸಲಾಗಿದೆ", "PluginUpdatedWithName": "{0} ಅನ್ನು ನವೀಕರಿಸಲಾಗಿದೆ", - "ProviderValue": "ಒದಗಿಸುವವರು: {0}", "TaskCleanLogs": "ಕ್ಲೀನ್ ಲಾಗ್ ಡೈರೆಕ್ಟರಿ", "TaskRefreshPeople": "ಜನರನ್ನು ರಿಫ್ರೆಶ್ ಮಾಡಿ", "TaskRefreshPeopleDescription": "ನಿಮ್ಮ ಮಾಧ್ಯಮ ಲೈಬ್ರರಿಯಲ್ಲಿ ನಟರು ಮತ್ತು ನಿರ್ದೇಶಕರಿಗಾಗಿ ಮೆಟಾಡೇಟಾವನ್ನು ನವೀಕರಿಸಿ.", @@ -129,7 +100,5 @@ "TaskExtractMediaSegments": "ಮಾಧ್ಯಮ ವಿಭಾಗದ ಹುಡುಕು", "TaskDownloadMissingLyrics": "ಇಲ್ಲದ ಸಾಹಿತ್ಯವನ್ನು ಪಡೆಯಿರಿ", "TaskAudioNormalization": "ಧ್ವನಿ ಸಾಮಾನ್ಯೀಕರಣ", - "TaskRefreshTrickplayImages": "ಟ್ರಿಕ್‌ಪ್ಲೇ ಚಿತ್ರಗಳನ್ನು ರಚಿಸಿ", - "TaskCleanCollectionsAndPlaylists": "ಸಂಗ್ರಹಗಳು ಮತ್ತು ಪ್ಲೇಪಟ್ಟಿಗಳನ್ನು ಸ್ವಚ್ಛಗೊಳಿಸಿ", - "TaskCleanCollectionsAndPlaylistsDescription": "ಇಲ್ಲದ ಸಂಗ್ರಹಗಳು ಮತ್ತು ಪ್ಲೇಪಟ್ಟಿಗಳಿಂದ ವಸ್ತುಗಳನ್ನು ತೆಗೆದುಹಾಕುತ್ತದೆ." + "TaskRefreshTrickplayImages": "ಟ್ರಿಕ್‌ಪ್ಲೇ ಚಿತ್ರಗಳನ್ನು ರಚಿಸಿ" } diff --git a/Emby.Server.Implementations/Localization/Core/ko.json b/Emby.Server.Implementations/Localization/Core/ko.json index efc9f61ddf..1f16a90842 100644 --- a/Emby.Server.Implementations/Localization/Core/ko.json +++ b/Emby.Server.Implementations/Localization/Core/ko.json @@ -1,41 +1,24 @@ { - "Albums": "앨범", "AppDeviceValues": "앱: {0}, 장치: {1}", - "Application": "애플리케이션", "Artists": "아티스트", "AuthenticationSucceededWithUserName": "{0} 사용자가 성공적으로 인증됨", "Books": "도서", - "CameraImageUploadedFrom": "{0}에서 새로운 카메라 이미지가 업로드됨", - "Channels": "채널", "ChapterNameValue": "챕터 {0}", "Collections": "컬렉션", - "DeviceOfflineWithName": "{0}의 연결 끊김", - "DeviceOnlineWithName": "{0}이(가) 연결됨", "FailedLoginAttemptWithUserName": "{0}에서 로그인 실패", "Favorites": "즐겨찾기", "Folders": "폴더", "Genres": "장르", - "HeaderAlbumArtists": "앨범 음악가", "HeaderContinueWatching": "계속 시청하기", - "HeaderFavoriteAlbums": "즐겨찾는 앨범", - "HeaderFavoriteArtists": "즐겨찾는 아티스트", "HeaderFavoriteEpisodes": "즐겨찾는 에피소드", "HeaderFavoriteShows": "즐겨찾는 쇼", - "HeaderFavoriteSongs": "즐겨찾는 노래", "HeaderLiveTV": "실시간 TV", "HeaderNextUp": "다음으로", - "HeaderRecordingGroups": "녹화 그룹", "HomeVideos": "홈 비디오", "Inherit": "상속", - "ItemAddedWithName": "{0}가 라이브러리에 추가되었습니다", - "ItemRemovedWithName": "{0}가 라이브러리에서 제거됨", "LabelIpAddressValue": "IP 주소: {0}", "LabelRunningTimeValue": "상영 시간: {0}", "Latest": "최근", - "MessageApplicationUpdated": "Jellyfin 서버가 업데이트되었습니다", - "MessageApplicationUpdatedTo": "Jellyfin 서버가 {0}로 업데이트되었습니다", - "MessageNamedServerConfigurationUpdatedWithValue": "서버 환경 설정 {0} 섹션이 업데이트되었습니다", - "MessageServerConfigurationUpdated": "서버 환경 설정이 업데이트되었습니다", "MixedContent": "혼합 콘텐츠", "Movies": "영화", "Music": "음악", @@ -61,24 +44,14 @@ "NotificationOptionVideoPlayback": "비디오 재생 시작됨", "NotificationOptionVideoPlaybackStopped": "비디오 재생 중지됨", "Photos": "사진", - "Playlists": "재생목록", - "Plugin": "플러그인", "PluginInstalledWithName": "{0} 설치됨", "PluginUninstalledWithName": "{0} 제거됨", "PluginUpdatedWithName": "{0} 업데이트됨", - "ProviderValue": "제공자: {0}", "ScheduledTaskFailedWithName": "{0} 실패", - "ScheduledTaskStartedWithName": "{0} 시작", - "ServerNameNeedsToBeRestarted": "{0}를 재시작해야합니다", "Shows": "시리즈", - "Songs": "노래", "StartupEmbyServerIsLoading": "Jellyfin 서버를 불러오고 있습니다. 잠시 후에 다시 시도하십시오.", - "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", "SubtitleDownloadFailureFromForItem": "{0}에서 {1} 자막 다운로드에 실패했습니다", - "Sync": "동기화", - "System": "시스템", "TvShows": "TV 쇼", - "User": "사용자", "UserCreatedWithName": "사용자 {0} 생성됨", "UserDeletedWithName": "사용자 {0} 삭제됨", "UserDownloadingItemWithValues": "{0} 사용자가 {1} 다운로드 중", @@ -86,11 +59,8 @@ "UserOfflineFromDevice": "{0} 사용자의 {1}에서 연결이 끊김", "UserOnlineFromDevice": "{0} 사용자가 {1}에서 접속함", "UserPasswordChangedWithName": "{0} 사용자 비밀번호 변경됨", - "UserPolicyUpdatedWithName": "{0} 사용자 정책 업데이트됨", "UserStartedPlayingItemWithValues": "{0} 사용자의 {2}에서 {1} 재생 중", "UserStoppedPlayingItemWithValues": "{0} 사용자의 {2}에서 {1} 재생을 마침", - "ValueHasBeenAddedToLibrary": "{0}가 미디어 라이브러리에 추가되었습니다", - "ValueSpecialEpisodeName": "스페셜 - {0}", "VersionNumber": "버전 {0}", "TasksApplicationCategory": "어플리케이션", "TasksMaintenanceCategory": "유지 보수", @@ -125,16 +95,21 @@ "TaskKeyframeExtractor": "키프레임 추출", "External": "외부", "HearingImpaired": "청각 장애", - "TaskCleanCollectionsAndPlaylists": "컬렉션과 재생목록 정리", "TaskAudioNormalization": "오디오의 볼륨 수준을 일정하게 조정", "TaskAudioNormalizationDescription": "오디오의 볼륨 수준을 일정하게 조정하기 위해 파일을 스캔합니다.", "TaskRefreshTrickplayImages": "비디오 탐색용 미리보기 썸네일 생성", "TaskRefreshTrickplayImagesDescription": "활성화된 라이브러리에서 비디오의 트릭플레이 미리보기를 생성합니다.", - "TaskCleanCollectionsAndPlaylistsDescription": "더 이상 존재하지 않는 컬렉션 및 재생 목록에서 항목을 제거합니다.", "TaskExtractMediaSegments": "미디어 세그먼트 스캔", "TaskExtractMediaSegmentsDescription": "MediaSegment를 지원하는 플러그인에서 미디어 세그먼트를 추출하거나 가져옵니다.", "TaskMoveTrickplayImages": "트릭플레이 이미지 위치 마이그레이션", "TaskMoveTrickplayImagesDescription": "추출된 트릭플레이 이미지를 라이브러리 설정에 따라 이동합니다.", "TaskDownloadMissingLyrics": "누락된 가사 다운로드", - "TaskDownloadMissingLyricsDescription": "가사 다운로드" + "TaskDownloadMissingLyricsDescription": "가사 다운로드", + "CleanupUserDataTask": "사용자 데이터 정리 작업", + "CleanupUserDataTaskDescription": "최소 90일 이상 존재하지 않는 미디어에 대한 사용자 데이터(시청 상태, 즐겨찾기 등)를 정리합니다.", + "LyricDownloadFailureFromForItem": "{1}에 대한 가사를 {0}에서 다운로드하지 못했습니다", + "Original": "원본", + "NameExtraClip": "클립", + "NameExtraDeletedScene": "삭제된 장면", + "NameExtraInterview": "인터뷰" } diff --git a/Emby.Server.Implementations/Localization/Core/kw.json b/Emby.Server.Implementations/Localization/Core/kw.json index 336d286fc4..fc2e189e7f 100644 --- a/Emby.Server.Implementations/Localization/Core/kw.json +++ b/Emby.Server.Implementations/Localization/Core/kw.json @@ -1,21 +1,13 @@ { "Collections": "Kuntellow", - "DeviceOfflineWithName": "{0} re anjunyas", "External": "A-ves", "Folders": "Plegellow", - "HeaderFavoriteAlbums": "Albomow Drudh", - "HeaderFavoriteArtists": "Artydhyon Drudh", "HeaderFavoriteEpisodes": "Towlennow Drudh", - "HeaderFavoriteSongs": "Kanow Drudh", - "HeaderRecordingGroups": "Bagasow Rekordya", "HearingImpaired": "Klewans Aperys", "HomeVideos": "Gwydhyow Tre", "Inherit": "Herya", "LabelRunningTimeValue": "Prys ow ponya: {0}", "Latest": "Diwettha", - "MessageApplicationUpdated": "Servell Jellyfin re beu nowedhys", - "MessageApplicationUpdatedTo": "Servell Jellyfin re beu nowedhys dhe {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Rann dewisyans servell {0} re beu nowedhys", "MixedContent": "Dalgh kemmyskys", "Movies": "Fylmow", "MusicVideos": "Gwydhyow Ilow", @@ -25,23 +17,16 @@ "NotificationOptionPluginError": "Defowt ystynnans", "NotificationOptionPluginUninstalled": "Ystynnans anynstallys", "NotificationOptionPluginUpdateInstalled": "Nowedheans ystynnans ynstallys", - "Application": "Gweythres", "Favorites": "Moyha Kerys", "Forced": "Konstrynys", - "Albums": "Albomow", "Books": "Lyvrow", - "Channels": "Kanolyow", "AppDeviceValues": "App: {0}, Devis: {1}", "Artists": "Artyhdyon", - "HeaderAlbumArtists": "Albom artydhyon", "HeaderNextUp": "Nessa", - "CameraImageUploadedFrom": "Skeusen kamera nowydh re beu ughkargys a-dhyworth {0}", "ChapterNameValue": "Chaptra {0}", "FailedLoginAttemptWithUserName": "Assay omgelm fyllys a-dhyworth {0}", "AuthenticationSucceededWithUserName": "{0} omgelmys yn sewen", "Default": "Defowt", - "DeviceOnlineWithName": "{0} yw junys", - "ItemRemovedWithName": "{0} a veu dileys a-dhyworth an lyverva", "LabelIpAddressValue": "Trigva PK: {)}", "Music": "Ilow", "HeaderContinueWatching": "Pesya Ow Kweles", @@ -50,8 +35,6 @@ "NotificationOptionCameraImageUploaded": "Skeusen kamera ughkargys", "HeaderFavoriteShows": "Diskwedhyansow Drudh", "HeaderLiveTV": "PW Yn Fyw", - "MessageServerConfigurationUpdated": "Dewisyans servell re beu nowedhys", - "ItemAddedWithName": "{0} a veu keworrys dhe'n lyverva", "NameInstallFailed": "{0} ynstallyans fyllys", "NotificationOptionNewLibraryContent": "Dalgh nowydh keworrys", "NewVersionIsAvailable": "Yma versyon nowydh a Servell Jellyfin neb yw kavadow rag iskarga.", @@ -62,8 +45,6 @@ "NotificationOptionServerRestartRequired": "Dastalleth servell yw res", "StartupEmbyServerIsLoading": "Yma Servell Jellyfin ow kargya. Assay arta yn berr mar pleg.", "SubtitleDownloadFailureFromForItem": "Istitlow a fyllis iskarga a-dhyworth {0] rag {1}", - "System": "Kevreyth", - "User": "Devnydhyer", "UserDeletedWithName": "Devnydhyer {0} re beu dileys", "UserLockedOutWithName": "Devnydhyer {0} re beu alhwedhys yn-mes", "UserStoppedPlayingItemWithValues": "{0} re worfennas gwari {1} war {2}", @@ -71,21 +52,15 @@ "UserOnlineFromDevice": "{0} yw warlinen a-dhyworth {1}", "NotificationOptionUserLockedOut": "Devnydhyer yw alhwedhys yn-mes", "Photos": "Skeusennow", - "Playlists": "Rolyow-gwari", - "Plugin": "Ystynnans", "PluginInstalledWithName": "{0} a veu ynstallys", - "UserPolicyUpdatedWithName": "Polici devnydhyer re beu nowedhys rag {0}", "PluginUpdatedWithName": "{0} a veu nowedhys", "ScheduledTaskFailedWithName": "{0} a fyllis", - "Songs": "Kanow", - "Sync": "Kesseni", "TvShows": "Towlennow PW", "Undefined": "Anstyrys", "UserCreatedWithName": "Devnydhyer {0} re beu gwruthys", "UserDownloadingItemWithValues": "Yma {0} owth iskarga {1}", "UserPasswordChangedWithName": "Ger-tremena re beu chanjys rag devnydhyer {0}", "UserStartedPlayingItemWithValues": "Yma {0} ow kwari {1} war {2}", - "ValueHasBeenAddedToLibrary": "{0} re beu keworrys dhe'th lyverva media", "VersionNumber": "Versyon {0}", "TasksLibraryCategory": "Lyverva", "TaskCleanActivityLog": "Glanhe Kovlyver Gwrians", @@ -96,10 +71,6 @@ "NotificationOptionVideoPlayback": "Gwareans gwydhyow yw dallethys", "PluginUninstalledWithName": "{0} a veu anynstallys", "NotificationOptionTaskFailed": "Defowt oberen towlennys", - "ProviderValue": "Provier: {0}", - "ScheduledTaskStartedWithName": "{0} a dhallathas", - "ServerNameNeedsToBeRestarted": "Yma edhom dhe {0} a vos dastallathys", - "ValueSpecialEpisodeName": "Arbennik - {0}", "TasksMaintenanceCategory": "Mentons", "TasksApplicationCategory": "Gweythres", "TasksChannelsCategory": "Kanolyow Kesrosweyth", @@ -128,9 +99,7 @@ "TaskOptimizeDatabaseDescription": "Y hwra kesstrotha ha berrhe efander rydh. Martesen y hwra gwellhe gwryth mar kwre'ta an oberen ma wosa ty dhe arhwilas an lyverva, po neb chanj aral neb a brof chanjyansow selvanylyon.", "TaskAudioNormalizationDescription": "Y hwra arhwilas restrennow rag manylyon normalheans klewans.", "TaskRefreshLibraryDescription": "Y hwra arhwilas dha lyverva media rag restrennow nowydh ha disegha metamanylyon.", - "TaskCleanCollectionsAndPlaylists": "Glanhe kuntellow ha rolyow-gwari", "TaskKeyframeExtractor": "Estennell Framalhwedh", - "TaskCleanCollectionsAndPlaylistsDescription": "Y hwra dilea taklow a-dhyworth kuntellow ha rolyow-gwari na vos na moy.", "TaskKeyframeExtractorDescription": "Y hwra kuntel framyowalhwedh a-dhyworth restrennow gwydhyowyow rag gul rolyow-gwari HLS moy poran. Martesen y hwra an oberen ma ow ponya rag termyn hir.", "TaskExtractMediaSegments": "Arhwilas Rann Media", "TaskExtractMediaSegmentsDescription": "Kavos rannow media a-dhyworth ystynansow gallosegys MediaSegment.", diff --git a/Emby.Server.Implementations/Localization/Core/lb.json b/Emby.Server.Implementations/Localization/Core/lb.json index 176f2ba2b7..21d31c5fbf 100644 --- a/Emby.Server.Implementations/Localization/Core/lb.json +++ b/Emby.Server.Implementations/Localization/Core/lb.json @@ -1,38 +1,24 @@ { - "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", @@ -55,19 +41,11 @@ "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", @@ -76,13 +54,10 @@ "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.", @@ -104,22 +79,16 @@ "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", @@ -135,5 +104,21 @@ "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." + "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.", + "LyricDownloadFailureFromForItem": "Feeler beim Download vun de Songtexter vun {0} fir {1}", + "Original": "Original", + "CleanupUserDataTask": "Aufgab fir Berengege vu Benotzerdaten", + "CleanupUserDataTaskDescription": "Läscht all Benotzerdaten (Ofspillstatus, Favoritestatus, asw.) vu Medien, déi zënter mindestens 90 Deeg net méi besteeënd sinn.", + "NameExtraBehindTheScenes": "Hannert de Kulissen", + "NameExtraClip": "Clip", + "NameExtraDeletedScene": "Geläschte Scène", + "NameExtraFeaturette": "Featurette", + "NameExtraInterview": "Interview", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Beispill", + "NameExtraScene": "Scène", + "NameExtraShort": "Kuerzfilm", + "NameExtraThemeSong": "Theme-Lidd", + "NameExtraThemeVideo": "Theme-Video", + "NameExtraTrailer": "Bande-Annonce" } diff --git a/Emby.Server.Implementations/Localization/Core/lt-LT.json b/Emby.Server.Implementations/Localization/Core/lt-LT.json index 3918ab81c6..c41cedf98a 100644 --- a/Emby.Server.Implementations/Localization/Core/lt-LT.json +++ b/Emby.Server.Implementations/Localization/Core/lt-LT.json @@ -1,41 +1,24 @@ { - "Albums": "Albumai", "AppDeviceValues": "Programa: {0}, Įrenginys: {1}", - "Application": "Programėlė", "Artists": "Atlikėjai", "AuthenticationSucceededWithUserName": "{0} sėkmingai autentifikuota", "Books": "Knygos", - "CameraImageUploadedFrom": "Nauja nuotrauka įkelta iš kameros {0}", - "Channels": "Kanalai", - "ChapterNameValue": "Scena{0}", - "Collections": "Rinkiniai", - "DeviceOfflineWithName": "{0} buvo atjungtas", - "DeviceOnlineWithName": "{0} prisijungęs", + "ChapterNameValue": "Skyrius{0}", + "Collections": "Kolekcijos", "FailedLoginAttemptWithUserName": "Nesėkmingas {0} bandymas prisijungti", "Favorites": "Mėgstami", - "Folders": "Katalogai", + "Folders": "Aplankai", "Genres": "Žanrai", - "HeaderAlbumArtists": "Albumo atlikėjai", "HeaderContinueWatching": "Žiūrėti toliau", - "HeaderFavoriteAlbums": "Mėgstami albumai", - "HeaderFavoriteArtists": "Mėgstami atlikėjai", - "HeaderFavoriteEpisodes": "Mėgstamiausios serijos", - "HeaderFavoriteShows": "Mėgstamiausios TV Laidos", - "HeaderFavoriteSongs": "Mėgstamos Dainos", + "HeaderFavoriteEpisodes": "Mėgstami Epizodai", + "HeaderFavoriteShows": "Mėgstamos TV Laidos", "HeaderLiveTV": "Tiesioginė TV", "HeaderNextUp": "Toliau", - "HeaderRecordingGroups": "Įrašų grupės", "HomeVideos": "Namų vaizdo įrašai", "Inherit": "Paveldėti", - "ItemAddedWithName": "{0} - buvo įkeltas į biblioteką", - "ItemRemovedWithName": "{0} - buvo pašalinta iš bibliotekos", "LabelIpAddressValue": "IP adresas: {0}", "LabelRunningTimeValue": "Trukmė: {0}", "Latest": "Naujausi", - "MessageApplicationUpdated": "\"Jellyfin Server\" atnaujintas", - "MessageApplicationUpdatedTo": "\"Jellyfin Server\" buvo atnaujinta iki {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Serverio nustatymai (skyrius {0}) buvo atnaujinti", - "MessageServerConfigurationUpdated": "Serverio nustatymai buvo atnaujinti", "MixedContent": "Mišrus turinys", "Movies": "Filmai", "Music": "Muzika", @@ -43,14 +26,14 @@ "NameInstallFailed": "{0} diegimo klaida", "NameSeasonNumber": "Sezonas {0}", "NameSeasonUnknown": "Sezonas neatpažintas", - "NewVersionIsAvailable": "Nauja \"Jellyfin Server\" versija yra prieinama atsisiuntimui.", + "NewVersionIsAvailable": "Nauja Jellyfin Server versija yra prieinama atsisiuntimui.", "NotificationOptionApplicationUpdateAvailable": "Galimi programos atnaujinimai", "NotificationOptionApplicationUpdateInstalled": "Programos atnaujinimai įdiegti", "NotificationOptionAudioPlayback": "Garso atkūrimas pradėtas", "NotificationOptionAudioPlaybackStopped": "Garso atkūrimas sustabdytas", - "NotificationOptionCameraImageUploaded": "Kameros vaizdai įkelti", + "NotificationOptionCameraImageUploaded": "Kameros atvaizdai įkelti", "NotificationOptionInstallationFailed": "Diegimo klaida", - "NotificationOptionNewLibraryContent": "Naujas turinys įkeltas", + "NotificationOptionNewLibraryContent": "Pridėtas naujas turinys", "NotificationOptionPluginError": "Įskiepio klaida", "NotificationOptionPluginInstalled": "Įskiepis įdiegtas", "NotificationOptionPluginUninstalled": "Įskiepis išdiegtas", @@ -61,24 +44,14 @@ "NotificationOptionVideoPlayback": "Vaizdo įrašo atkūrimas pradėtas", "NotificationOptionVideoPlaybackStopped": "Vaizdo įrašo atkūrimas sustabdytas", "Photos": "Nuotraukos", - "Playlists": "Grojaraščiai", - "Plugin": "Įskiepis", "PluginInstalledWithName": "{0} buvo įdiegtas", "PluginUninstalledWithName": "{0} buvo pašalintas", "PluginUpdatedWithName": "{0} buvo atnaujintas", - "ProviderValue": "Paslaugos tiekėjas: {0}", "ScheduledTaskFailedWithName": "{0} nepavyko", - "ScheduledTaskStartedWithName": "{0} paleista", - "ServerNameNeedsToBeRestarted": "{0} reikia iš naujo paleisti", "Shows": "Laidos", - "Songs": "Kūriniai", "StartupEmbyServerIsLoading": "Jellyfin Server kraunasi. Netrukus pabandykite dar kartą.", - "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", "SubtitleDownloadFailureFromForItem": "{1} subtitrai buvo nesėkmingai parsiųsti iš {0}", - "Sync": "Sinchronizuoti", - "System": "Sistema", - "TvShows": "TV laidos", - "User": "Naudotojas", + "TvShows": "TV Laidos", "UserCreatedWithName": "Buvo sukurtas {0} naudotojas", "UserDeletedWithName": "Naudotojas {0} ištrintas", "UserDownloadingItemWithValues": "{0} siunčiasi {1}", @@ -86,17 +59,14 @@ "UserOfflineFromDevice": "{0} buvo atjungtas nuo {1}", "UserOnlineFromDevice": "{0} prisijungęs iš {1}", "UserPasswordChangedWithName": "Slaptažodis pakeistas naudotojui {0}", - "UserPolicyUpdatedWithName": "Naudotojo {0} teisės buvo pakeistos", - "UserStartedPlayingItemWithValues": "{0} leidžia {1} į {2}", - "UserStoppedPlayingItemWithValues": "{0} baigė leisti {1} į {2}", - "ValueHasBeenAddedToLibrary": "{0} pridėtas į mediateką", - "ValueSpecialEpisodeName": "Ypatingų - {0}", + "UserStartedPlayingItemWithValues": "{0} atkuriama {1} į {2}", + "UserStoppedPlayingItemWithValues": "{0} baigė atkūrimą {1} į {2}", "VersionNumber": "Versija {0}", "TaskUpdatePluginsDescription": "Atsisiunčia ir įdiegia įskiepių, kurie sukonfigūruoti atnaujinti automatiškai, naujinius.", "TaskUpdatePlugins": "Atnaujinti įskieius", "TaskDownloadMissingSubtitlesDescription": "Ieško trūkstamų subtitrų internete remiantis metaduomenų konfigūracija.", - "TaskCleanTranscodeDescription": "Ištrina dienos senumo perkodavimo failus.", - "TaskCleanTranscode": "Išvalyti perkodavimo katalogą", + "TaskCleanTranscodeDescription": "Ištrina dienos senumo transkodavimo failus.", + "TaskCleanTranscode": "Išvalyti transkodavimo katalogą", "TaskRefreshLibraryDescription": "Skenuoja medijos biblioteką, ieškodamas naujų failų, ir atnaujina metaduomenis.", "TaskRefreshLibrary": "Skenuoti medijos biblioteką", "TaskDownloadMissingSubtitles": "Atsisiųsti trūkstamus subtitrus", @@ -106,8 +76,8 @@ "TaskRefreshPeople": "Atnaujinti žmones", "TaskCleanLogsDescription": "Ištrina žurnalo failus kurie yra senesni nei {0} dienos.", "TaskCleanLogs": "Išvalyti žurnalą", - "TaskRefreshChapterImagesDescription": "Sukuria vaizdo įrašų, kuriuose yra skyrių, miniatiūras.", - "TaskRefreshChapterImages": "Ištraukti skyrių vaizdus", + "TaskRefreshChapterImagesDescription": "Sukuria miniatiūras vaizdo įrašams, kuriuose yra skyriai.", + "TaskRefreshChapterImages": "Ištraukti skyrių atvaizdus", "TaskCleanCache": "Išvalyti talpyklą", "TaskCleanCacheDescription": "Ištrina talpyklos failus, kurių daugiau nereikia sistemai.", "TasksChannelsCategory": "Internetiniai kanalai", @@ -117,7 +87,7 @@ "TaskCleanActivityLog": "Išvalyti veiklos žurnalą", "Undefined": "Neapibrėžtas", "Forced": "Priverstinis", - "Default": "Numatytas", + "Default": "Numatytasis", "TaskCleanActivityLogDescription": "Ištrina senesnius nei nustatytas amžius veiklos žurnalo įrašus.", "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.", @@ -126,17 +96,30 @@ "External": "Išorinis", "HearingImpaired": "Su klausos sutrikimais", "TaskRefreshTrickplayImages": "Generuoti Trickplay atvaizdus", - "TaskRefreshTrickplayImagesDescription": "Sukuria trickplay peržiūras vaizdo įrašams įgalintose bibliotekose.", - "TaskCleanCollectionsAndPlaylists": "Išvalo duomenis rinkiniuose ir grojaraščiuose", - "TaskCleanCollectionsAndPlaylistsDescription": "Pašalina neegzistuojančius elementus iš rinkinių ir grojaraščių.", + "TaskRefreshTrickplayImagesDescription": "Sukuria vaizdo įrašų, esančių įgalintose bibliotekose, Trickplay peržiūras.", "TaskAudioNormalization": "Garso normalizavimas", "TaskAudioNormalizationDescription": "Skenuoja failus, ieškant garso normalizavimo duomenų.", "TaskExtractMediaSegments": "Medijos segmentų nuskaitymas", - "TaskDownloadMissingLyrics": "Parsisiųsti trūkstamus dainų tekstus", + "TaskDownloadMissingLyrics": "Atsisiųsti trūkstamus dainų tekstus", "TaskExtractMediaSegmentsDescription": "Ištraukia arba gauna medijos segmentus iš MediaSegment ijungtų įskiepių.", - "TaskMoveTrickplayImages": "Pakeisti Trickplay vaizdų vietą", - "TaskMoveTrickplayImagesDescription": "Perkelia egzistuojančius trickplay failus pagal bibliotekos nustatymus.", - "TaskDownloadMissingLyricsDescription": "Parsisiųsti dainų žodžius", + "TaskMoveTrickplayImages": "Pakeisti Trickplay atvaizdų vietą", + "TaskMoveTrickplayImagesDescription": "Perkelia egzistuojančius Trickplay failus pagal bibliotekos nustatymus.", + "TaskDownloadMissingLyricsDescription": "Atsisiųsti dainų tekstus", "CleanupUserDataTask": "Naudotojo duomenų valymo užduotis", - "CleanupUserDataTaskDescription": "Iš medijos, kurios nebėra bent 90 dienų, išvalo visus naudotojo duomenis (žiūrėjimo būseną, mėgstamiausią būseną ir t. t.)." + "CleanupUserDataTaskDescription": "Iš medijos, kurios nebėra bent 90 dienų, išvalo visus naudotojo duomenis (žiūrėjimo būseną, mėgstamą būseną ir t. t.).", + "LyricDownloadFailureFromForItem": "Nepavyko atsisiųsti dainos teksto iš {0}, skirto {1}", + "NameExtraBehindTheScenes": "Užkulisiuose", + "NameExtraClip": "Klipas", + "NameExtraDeletedScene": "Ištrinta scena", + "NameExtraInterview": "Interviu", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Pavyzdys", + "NameExtraScene": "Scena", + "NameExtraThemeSong": "Teminė daina", + "NameExtraThemeVideo": "Teminis vaizdo įrašas", + "NameExtraTrailer": "Anonsas", + "NameExtraUnknown": "Papildomas", + "Original": "Originalus", + "NameExtraFeaturette": "Trumpametražis filmas", + "NameExtraShort": "Trumpas filmukas" } diff --git a/Emby.Server.Implementations/Localization/Core/lv.json b/Emby.Server.Implementations/Localization/Core/lv.json index 55549c66d0..52f1eecbe4 100644 --- a/Emby.Server.Implementations/Localization/Core/lv.json +++ b/Emby.Server.Implementations/Localization/Core/lv.json @@ -1,15 +1,10 @@ { - "ServerNameNeedsToBeRestarted": "{0} ir vajadzīgs restarts", "NotificationOptionTaskFailed": "Plānota uzdevuma kļūme", - "HeaderRecordingGroups": "Ierakstu grupas", - "UserPolicyUpdatedWithName": "Lietotāju politika atjaunota priekš {0}", "SubtitleDownloadFailureFromForItem": "Subtitru lejupielāde no {0} priekš {1} neizdevās", "NotificationOptionVideoPlaybackStopped": "Video atskaņošana apturēta", "NotificationOptionVideoPlayback": "Video atskaņošana sākta", "NotificationOptionInstallationFailed": "Instalācija neizdevās", "AuthenticationSucceededWithUserName": "{0} veiksmīgi autentificējies", - "ValueSpecialEpisodeName": "Speciālais - {0}", - "ScheduledTaskStartedWithName": "{0} iesākts", "ScheduledTaskFailedWithName": "{0} neizdevās", "Photos": "Attēli", "NotificationOptionUserLockedOut": "Lietotājs bloķēts", @@ -17,7 +12,6 @@ "Inherit": "Pārmantot", "AppDeviceValues": "Lietotne: {0}, Ierīce: {1}", "VersionNumber": "Versija {0}", - "ValueHasBeenAddedToLibrary": "{0} tika pievienots jūsu multvides bibliotēkai", "UserStoppedPlayingItemWithValues": "{0} ir beidzis atskaņot {1} uz {2}", "UserStartedPlayingItemWithValues": "{0} atskaņo {1} uz {2}", "UserPasswordChangedWithName": "Lietotāja {0} parole tika nomainīta", @@ -27,23 +21,16 @@ "UserDownloadingItemWithValues": "{0} lejupielādē {1}", "UserDeletedWithName": "Lietotājs {0} ir izdzēsts", "UserCreatedWithName": "Lietotājs {0} ir ticis izveidots", - "User": "Lietotājs", "TvShows": "TV raidījumi", - "Sync": "Sinhronizācija", - "System": "Sistēma", "StartupEmbyServerIsLoading": "Jellyfin Serveris lādējas. Lūdzu mēģiniet vēlreiz pēc brīža.", - "Songs": "Dziesmas", "Shows": "Šovi", "PluginUpdatedWithName": "{0} tika atjaunots", "PluginUninstalledWithName": "{0} tika noņemts", "PluginInstalledWithName": "{0} tika uzstādīts", - "Plugin": "Paplašinājums", - "Playlists": "Atskaņošanas saraksti", "MixedContent": "Jaukts saturs", "HomeVideos": "Mājas video", "HeaderNextUp": "Nākamais", "ChapterNameValue": "{0}. nodaļa", - "Application": "Lietotne", "NotificationOptionServerRestartRequired": "Nepieciešams servera restarts", "NotificationOptionPluginUpdateInstalled": "Paplašinājuma atjauninājums uzstādīts", "NotificationOptionPluginUninstalled": "Paplašinājums noņemts", @@ -62,35 +49,19 @@ "MusicVideos": "Mūzikas video", "Music": "Mūzika", "Movies": "Filmas", - "MessageServerConfigurationUpdated": "Servera konfigurācija ir tikusi atjaunota", - "MessageNamedServerConfigurationUpdatedWithValue": "Servera konfigurācijas sadaļa {0} tika atjaunota", - "MessageApplicationUpdatedTo": "Jellyfin Server ir ticis atjaunots uz {0}", - "MessageApplicationUpdated": "Jellyfin Server ir ticis atjaunots", "Latest": "Jaunākais", "LabelIpAddressValue": "IP adrese: {0}", - "ItemRemovedWithName": "{0} tika noņemts no bibliotēkas", - "ItemAddedWithName": "{0} tika pievienots bibliotēkai", "HeaderLiveTV": "Tiešraides TV", "HeaderContinueWatching": "Turpini skatīties", - "HeaderAlbumArtists": "Albumu izpildītāji", "Genres": "Žanri", "Folders": "Mapes", "Favorites": "Izlase", "FailedLoginAttemptWithUserName": "Neizdevies ieiešanas mēģinājums no {0}", - "DeviceOnlineWithName": "Savienojums ar {0} ir izveidots", - "DeviceOfflineWithName": "Savienojums ar {0} ir pārtraukts", "Collections": "Kolekcijas", - "Channels": "Kanāli", - "CameraImageUploadedFrom": "Jauns kameras attēls tika augšupielādēts no {0}", "Books": "Grāmatas", "Artists": "Izpildītāji", - "Albums": "Albumi", - "ProviderValue": "Provider: {0}", - "HeaderFavoriteSongs": "Dziesmu izlase", "HeaderFavoriteShows": "Raidījumu izlase", "HeaderFavoriteEpisodes": "Sēriju izlase", - "HeaderFavoriteArtists": "Izpildītāju izlase", - "HeaderFavoriteAlbums": "Albumu izlase", "TaskCleanCacheDescription": "Nodzēš kešatmiņas datnes, kas vairs nav sistēmai vajadzīgas.", "TaskRefreshChapterImages": "Izvilkt nodaļu attēlus", "TasksApplicationCategory": "Lietotne", @@ -127,9 +98,7 @@ "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", "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", @@ -137,5 +106,19 @@ "TaskDownloadMissingLyrics": "Lejupielādēt trūkstošos vārdus", "TaskDownloadMissingLyricsDescription": "Lejupielādēt vārdus dziesmām", "CleanupUserDataTask": "Lietotāju datu tīrīšanas uzdevums", - "CleanupUserDataTaskDescription": "Notīra visus lietotāja datus (skatīšanās stāvokļus, favorītu statusi utt.) no medijiem, kas vairs nav pieejami vismaz 90 dienas." + "CleanupUserDataTaskDescription": "Notīra visus lietotāja datus (skatīšanās stāvokļus, favorītu statusi utt.) no medijiem, kas vairs nav pieejami vismaz 90 dienas.", + "Original": "Oriģināls", + "LyricDownloadFailureFromForItem": "Dziesmu vārdi nevarēja tikt lejupielādēti no {0} priekš {1}", + "NameExtraBehindTheScenes": "Aiz kadra", + "NameExtraClip": "Klips", + "NameExtraDeletedScene": "Izdzēsta aina", + "NameExtraFeaturette": "Īsfilma", + "NameExtraInterview": "Intervija", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Paraugs", + "NameExtraScene": "Aina", + "NameExtraShort": "Īsfilma", + "NameExtraThemeSong": "Motīvu dziesma", + "NameExtraThemeVideo": "Tēmas video", + "NameExtraTrailer": "Treileris" } diff --git a/Emby.Server.Implementations/Localization/Core/lzh.json b/Emby.Server.Implementations/Localization/Core/lzh.json index 9fb53e41d5..6b61755d12 100644 --- a/Emby.Server.Implementations/Localization/Core/lzh.json +++ b/Emby.Server.Implementations/Localization/Core/lzh.json @@ -1,10 +1,8 @@ { - "Albums": "辑册", "Artists": "艺人", "AuthenticationSucceededWithUserName": "{0} 授之权矣", "Books": "册", "Genres": "类", - "HeaderAlbumArtists": "辑者", "Favorites": "至爱", "Folders": "箧", "HeaderContinueWatching": "接目未竟" diff --git a/Emby.Server.Implementations/Localization/Core/mi.json b/Emby.Server.Implementations/Localization/Core/mi.json new file mode 100644 index 0000000000..74fae52dca --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/mi.json @@ -0,0 +1,15 @@ +{ + "AppDeviceValues": "Taupānga: {0}, Pūrere: {1}", + "Artists": "Kaiwaiata", + "AuthenticationSucceededWithUserName": "{0} has been successfully authenticated", + "Books": "Ngā pukapuka", + "Default": "Taunoa", + "Collections": "Kohinga", + "External": "Waho", + "Folders": "Kōpaki", + "Forced": "Kaha", + "Music": "Waiata", + "Movies": "Kiriata", + "Latest": "Hou", + "Inherit": "Riro" +} diff --git a/Emby.Server.Implementations/Localization/Core/mk.json b/Emby.Server.Implementations/Localization/Core/mk.json index 6da31227d7..daf8112eef 100644 --- a/Emby.Server.Implementations/Localization/Core/mk.json +++ b/Emby.Server.Implementations/Localization/Core/mk.json @@ -1,11 +1,8 @@ { "ScheduledTaskFailedWithName": "{0} неуспешно", - "ProviderValue": "Провајдер: {0}", "PluginUpdatedWithName": "{0} беше надоградено", "PluginUninstalledWithName": "{0} беше успешно деинсталирано", "PluginInstalledWithName": "{0} беше успешно инсталирано", - "Plugin": "Додатоци", - "Playlists": "Плејлисти", "Photos": "Слики", "NotificationOptionVideoPlaybackStopped": "Видео стопирано", "NotificationOptionVideoPlayback": "Видео пуштено", @@ -31,49 +28,29 @@ "Music": "Музика", "Movies": "Филмови", "MixedContent": "Мешана содржина", - "MessageServerConfigurationUpdated": "Серверската конфигурација беше надградена", - "MessageNamedServerConfigurationUpdatedWithValue": "Секцијата на конфигурација на сервер {0} беше надоградена", - "MessageApplicationUpdatedTo": "Jellyfin беше надограден до {0}", - "MessageApplicationUpdated": "Jellyfin Серверот беше надограден", "Latest": "Последно", "LabelRunningTimeValue": "Време на работа: {0}", "LabelIpAddressValue": "ИП Адреса: {0}", - "ItemRemovedWithName": "{0} е избришано до Библиотеката", - "ItemAddedWithName": "{0} беше додадено во Библиотеката", "Inherit": "Следно", "HomeVideos": "Домашни Видеа", - "HeaderRecordingGroups": "Групи на снимање", "HeaderNextUp": "Следно", "HeaderLiveTV": "ТВ", - "HeaderFavoriteSongs": "Омилени Песни", "HeaderFavoriteShows": "Омилени Серии", "HeaderFavoriteEpisodes": "Омилени Епизоди", - "HeaderFavoriteArtists": "Омилени Изведувачи", - "HeaderFavoriteAlbums": "Омилени Албуми", "HeaderContinueWatching": "Продолжи со Гледање", - "HeaderAlbumArtists": "Изведувачи од Албуми", "Genres": "Жанрови", "Folders": "Папки", "Favorites": "Омилени", "FailedLoginAttemptWithUserName": "Неуспешен обид за најавување од {0}", - "DeviceOnlineWithName": "{0} е приклучен", - "DeviceOfflineWithName": "{0} се исклучи", "Collections": "Колекции", "ChapterNameValue": "Дел {0}", - "Channels": "Канали", - "CameraImageUploadedFrom": "Нова слика од камера беше поставена од {0}", "Books": "Книги", "AuthenticationSucceededWithUserName": "{0} успешно поврзан", "Artists": "Изведувачи", - "Application": "Апликација", "AppDeviceValues": "Апликација: {0}, Уред: {1}", - "Albums": "Албуми", "VersionNumber": "Верзија {0}", - "ValueSpecialEpisodeName": "Специјално - {0}", - "ValueHasBeenAddedToLibrary": "{0} е додадено во твојата библиотека", "UserStoppedPlayingItemWithValues": "{0} заврши со репродукција {1} во {2}", "UserStartedPlayingItemWithValues": "{0} пушти {1} на {2}", - "UserPolicyUpdatedWithName": "Полисата на користење беше надоградена за {0}", "UserPasswordChangedWithName": "Лозинката е сменета за корисникот {0}", "UserOnlineFromDevice": "{0} е приклучен од {1}", "UserOfflineFromDevice": "{0} е дисконектиран од {1}", @@ -81,16 +58,10 @@ "UserDownloadingItemWithValues": "{0} се спушта {1}", "UserDeletedWithName": "Корисникот {0} е избришан", "UserCreatedWithName": "Корисникот {0} е креиран", - "User": "Корисник", "TvShows": "ТВ Серии", - "System": "Систем", - "Sync": "Синхронизација", "SubtitleDownloadFailureFromForItem": "Преводот неуспешно се спушти од {0} за {1}", "StartupEmbyServerIsLoading": "Jellyfin Server се пушта. Ве молиме причекајте.", - "Songs": "Песни", "Shows": "Серии", - "ServerNameNeedsToBeRestarted": "{0} треба да се рестартира", - "ScheduledTaskStartedWithName": "{0} започна", "TaskRefreshChapterImages": "Извези Слики од Поглавје", "TaskCleanCacheDescription": "Ги брише кешираните фајлови што не се повеќе потребни од системот.", "TaskCleanCache": "Исчисти Го Кешот", @@ -124,13 +95,15 @@ "TaskCleanActivityLog": "Избриши Лог на Активности", "External": "Надворешен", "HearingImpaired": "Оштетен слух", - "TaskCleanCollectionsAndPlaylists": "Исчисти ги колекциите и плејлистите", "TaskAudioNormalizationDescription": "Скенирање датотеки за податоци за нормализација на звукот.", "TaskDownloadMissingLyrics": "Преземи стихови кои недостасуваат", "TaskDownloadMissingLyricsDescription": "Преземи стихови/текстови за песни", "TaskRefreshTrickplayImages": "Генерирај слики за прегледување (Trickplay)", "TaskAudioNormalization": "Нормализација на звукот", "TaskRefreshTrickplayImagesDescription": "Креира трикплеј прегледи за видеа во овозможените библиотеки.", - "TaskCleanCollectionsAndPlaylistsDescription": "Отстранува ставки од колекциите и плејлистите што веќе не постојат.", - "TaskExtractMediaSegments": "Скенирање на сегменти на содржина" + "TaskExtractMediaSegments": "Скенирање на сегменти на содржина", + "TaskMoveTrickplayImages": "Мигрирај ја локацијата на сликата од Trickplay", + "TaskMoveTrickplayImagesDescription": "Ги преместува постоечките датотеки за трикплеј според поставките на библиотеката.", + "CleanupUserDataTask": "Задача за чистење на кориснички податоци", + "CleanupUserDataTaskDescription": "Ги чисти сите кориснички податоци (состојба на гледање, статус на омилени итн.) од медиуми што повеќе не се присутни најмалку 90 дена." } diff --git a/Emby.Server.Implementations/Localization/Core/ml.json b/Emby.Server.Implementations/Localization/Core/ml.json index 5c34493816..dbf2ed4648 100644 --- a/Emby.Server.Implementations/Localization/Core/ml.json +++ b/Emby.Server.Implementations/Localization/Core/ml.json @@ -1,32 +1,18 @@ { "AppDeviceValues": "അപ്ലിക്കേഷൻ: {0}, ഉപകരണം: {1}", - "Application": "അപ്ലിക്കേഷൻ", "AuthenticationSucceededWithUserName": "{0} വിജയകരമായി പ്രാമാണീകരിച്ചു", - "CameraImageUploadedFrom": "Camera 0 from എന്നതിൽ നിന്ന് ഒരു പുതിയ ക്യാമറ ചിത്രം അപ്‌ലോഡുചെയ്‌തു", "ChapterNameValue": "അധ്യായം {0}", - "DeviceOfflineWithName": "{0} വിച്ഛേദിച്ചു", - "DeviceOnlineWithName": "{0} ബന്ധിപ്പിച്ചു", "FailedLoginAttemptWithUserName": "{0}ൽ നിന്നുള്ള പ്രവേശന ശ്രമം പരാജയപ്പെട്ടു", - "Forced": "നിർബന്ധിച്ചു", - "HeaderFavoriteAlbums": "പ്രിയപ്പെട്ട ആൽബങ്ങൾ", - "HeaderFavoriteArtists": "പ്രിയപ്പെട്ട കലാകാരന്മാർ", + "Forced": "നിർബന്ധിതമായി", "HeaderFavoriteEpisodes": "പ്രിയപ്പെട്ട എപ്പിസോഡുകൾ", "HeaderFavoriteShows": "പ്രിയപ്പെട്ട ഷോകൾ", - "HeaderFavoriteSongs": "പ്രിയപ്പെട്ട ഗാനങ്ങൾ", "HeaderLiveTV": "തത്സമയ ടിവി", "HeaderNextUp": "അടുത്തത്", - "HeaderRecordingGroups": "ഗ്രൂപ്പുകൾ റെക്കോർഡുചെയ്യുന്നു", "HomeVideos": "ഹോം വീഡിയോകൾ", "Inherit": "അനന്തരാവകാശം", - "ItemAddedWithName": "{0} ലൈബ്രറിയിൽ ചേർത്തു", - "ItemRemovedWithName": "{0} ലൈബ്രറിയിൽ നിന്ന് നീക്കംചെയ്തു", "LabelIpAddressValue": "IP വിലാസം: {0}", "LabelRunningTimeValue": "പ്രവർത്തന സമയം: {0}", "Latest": "ഏറ്റവും പുതിയ", - "MessageApplicationUpdated": "ജെല്ലിഫിൻ സെർവർ അപ്‌ഡേറ്റുചെയ്‌തു", - "MessageApplicationUpdatedTo": "ജെല്ലിഫിൻ സെർവർ {0 to ലേക്ക് അപ്‌ഡേറ്റുചെയ്‌തു", - "MessageNamedServerConfigurationUpdatedWithValue": "സെർവർ കോൺഫിഗറേഷൻ വിഭാഗം {0 അപ്‌ഡേറ്റുചെയ്‌തു", - "MessageServerConfigurationUpdated": "സെർവർ കോൺഫിഗറേഷൻ അപ്‌ഡേറ്റുചെയ്‌തു", "MixedContent": "മിശ്രിത ഉള്ളടക്കം", "Music": "സംഗീതം", "MusicVideos": "സംഗീത വീഡിയോകൾ", @@ -50,20 +36,14 @@ "NotificationOptionUserLockedOut": "ഉപയോക്താവ് ലോക്ക് out ട്ട് ചെയ്‌തു", "NotificationOptionVideoPlayback": "വീഡിയോ പ്ലേബാക്ക് ആരംഭിച്ചു", "NotificationOptionVideoPlaybackStopped": "വീഡിയോ പ്ലേബാക്ക് നിർത്തി", - "Plugin": "പ്ലഗിൻ", "PluginInstalledWithName": "{0} ഇൻസ്റ്റാളുചെയ്‌തു", "PluginUninstalledWithName": "{0 un അൺഇൻസ്റ്റാൾ ചെയ്തു", "PluginUpdatedWithName": "{0} അപ്‌ഡേറ്റുചെയ്‌തു", - "ProviderValue": "ദാതാവ്: {0}", "ScheduledTaskFailedWithName": "{0} പരാജയപ്പെട്ടു", - "ScheduledTaskStartedWithName": "{0} ആരംഭിച്ചു", - "ServerNameNeedsToBeRestarted": "{0} പുനരാരംഭിക്കേണ്ടതുണ്ട്", "StartupEmbyServerIsLoading": "ജെല്ലിഫിൻ സെർവർ ലോഡുചെയ്യുന്നു. ഉടൻ തന്നെ വീണ്ടും ശ്രമിക്കുക.", "SubtitleDownloadFailureFromForItem": "സബ്ടൈറ്റിലുകൾ {1} ന് {0 from ൽ നിന്ന് ഡ download ൺലോഡ് ചെയ്യുന്നതിൽ പരാജയപ്പെട്ടു", - "System": "സിസ്റ്റം", "TvShows": "ടിവി ഷോകൾ", "Undefined": "നിർവചിച്ചിട്ടില്ല", - "User": "ഉപയോക്താവ്", "UserCreatedWithName": "ഉപയോക്താവ് {0 created സൃഷ്ടിച്ചു", "UserDeletedWithName": "ഉപയോക്താവ് {0 deleted ഇല്ലാതാക്കി", "UserDownloadingItemWithValues": "{0} ഡൗൺലോഡുചെയ്യുന്നു {1}", @@ -71,10 +51,8 @@ "UserOfflineFromDevice": "{0} {1} ൽ നിന്ന് വിച്ഛേദിച്ചു", "UserOnlineFromDevice": "{0} {1} മുതൽ ഓൺ‌ലൈനിലാണ്", "UserPasswordChangedWithName": "{0} ഉപയോക്താവിനായി പാസ്‌വേഡ് മാറ്റി", - "UserPolicyUpdatedWithName": "{0} എന്നതിനായി ഉപയോക്തൃ നയം അപ്‌ഡേറ്റുചെയ്‌തു", "UserStartedPlayingItemWithValues": "{0} {2} ൽ {1} പ്ലേ ചെയ്യുന്നു", "UserStoppedPlayingItemWithValues": "{0} {2} ൽ {1 play കളിക്കുന്നത് പൂർത്തിയാക്കി", - "ValueHasBeenAddedToLibrary": "Media 0 your നിങ്ങളുടെ മീഡിയ ലൈബ്രറിയിലേക്ക് ചേർത്തു", "VersionNumber": "പതിപ്പ് {0}", "TasksMaintenanceCategory": "പരിപാലനം", "TasksLibraryCategory": "പുസ്തകശാല", @@ -100,34 +78,30 @@ "TaskRefreshChannelsDescription": "ഇന്റർനെറ്റ് ചാനൽ വിവരങ്ങൾ പുതുക്കുന്നു.", "TaskDownloadMissingSubtitles": "നഷ്‌ടമായ സബ്‌ടൈറ്റിലുകൾ ഡൗൺലോഡുചെയ്യുക", "TaskDownloadMissingSubtitlesDescription": "മെറ്റാഡാറ്റ കോൺഫിഗറേഷനെ അടിസ്ഥാനമാക്കി നഷ്‌ടമായ സബ്‌ടൈറ്റിലുകൾക്കായി ഇന്റർനെറ്റ് തിരയുന്നു.", - "ValueSpecialEpisodeName": "പ്രത്യേക - {0}", "Collections": "ശേഖരങ്ങൾ", "Folders": "ഫോൾഡറുകൾ", - "HeaderAlbumArtists": "കലാകാരന്റെ ആൽബം", - "Sync": "സമന്വയിപ്പിക്കുക", "Movies": "സിനിമകൾ", "Photos": "ഫോട്ടോകൾ", - "Albums": "ആൽബങ്ങൾ", - "Playlists": "പ്ലേലിസ്റ്റുകൾ", - "Songs": "ഗാനങ്ങൾ", "HeaderContinueWatching": "കാണുന്നത് തുടരുക", "Artists": "കലാകാരന്മാർ", "Shows": "ഷോകൾ", "Default": "സ്ഥിരസ്ഥിതി", - "Favorites": "പ്രിയങ്കരങ്ങൾ", + "Favorites": "പ്രിയപ്പെട്ടവ", "Books": "പുസ്തകങ്ങൾ", "Genres": "വിഭാഗങ്ങൾ", - "Channels": "ചാനലുകൾ", "TaskOptimizeDatabaseDescription": "ഡാറ്റാബേസ് ചുരുക്കുകയും സ്വതന്ത്ര ഇടം വെട്ടിച്ചുരുക്കുകയും ചെയ്യുന്നു. ലൈബ്രറി സ്‌കാൻ ചെയ്‌തതിനുശേഷം അല്ലെങ്കിൽ ഡാറ്റാബേസ് പരിഷ്‌ക്കരണങ്ങളെ സൂചിപ്പിക്കുന്ന മറ്റ് മാറ്റങ്ങൾ ചെയ്‌തതിന് ശേഷം ഈ ടാസ്‌ക് പ്രവർത്തിപ്പിക്കുന്നത് പ്രകടനം മെച്ചപ്പെടുത്തും.", "TaskOptimizeDatabase": "ഡാറ്റാബേസ് ഒപ്റ്റിമൈസ് ചെയ്യുക", "HearingImpaired": "കേൾവി തകരാറുകൾ", "External": "പുറമേയുള്ള", "TaskKeyframeExtractorDescription": "കൂടുതൽ കൃത്യമായ HLS പ്ലേലിസ്റ്റുകൾ സൃഷ്‌ടിക്കുന്നതിന് വീഡിയോ ഫയലുകളിൽ നിന്ന് കീഫ്രെയിമുകൾ എക്‌സ്‌ട്രാക്‌റ്റ് ചെയ്യുന്നു. ഈ പ്രവർത്തനം പൂർത്തിയാവാൻ കുറച്ചധികം സമയം എടുത്തേക്കാം.", "TaskKeyframeExtractor": "കീഫ്രെയിം എക്സ്ട്രാക്റ്റർ", - "TaskCleanCollectionsAndPlaylistsDescription": "നിലവിലില്ലാത്ത ശേഖരങ്ങളിൽ നിന്നും പ്ലേലിസ്റ്റുകളിൽ നിന്നും ഇനങ്ങൾ നീക്കംചെയ്യുന്നു.", - "TaskCleanCollectionsAndPlaylists": "ശേഖരങ്ങളും പ്ലേലിസ്റ്റുകളും വൃത്തിയാക്കുക", "TaskAudioNormalization": "സാധാരണ ശബ്ദ നിലയിലെത്തിലെത്തിക്കുക", "TaskAudioNormalizationDescription": "സാധാരണ ശബ്ദ നിലയിലെത്തിലെത്തിക്കുന്ന ഡാറ്റയ്ക്കായി ഫയലുകൾ സ്കാൻ ചെയ്യുക.", "TaskRefreshTrickplayImages": "ട്രിക്ക് പ്ലേ ചിത്രങ്ങൾ സൃഷ്ടിക്കുക", - "TaskRefreshTrickplayImagesDescription": "പ്രവർത്തനക്ഷമമാക്കിയ ലൈബ്രറികളിൽ വീഡിയോകൾക്കായി ട്രിക്ക്പ്ലേ പ്രിവ്യൂകൾ സൃഷ്ടിക്കുന്നു." + "TaskRefreshTrickplayImagesDescription": "പ്രവർത്തനക്ഷമമാക്കിയ ലൈബ്രറികളിൽ വീഡിയോകൾക്കായി ട്രിക്ക്പ്ലേ പ്രിവ്യൂകൾ സൃഷ്ടിക്കുന്നു.", + "Original": "ഓറിജിനൽ", + "TaskDownloadMissingLyrics": "ഇല്ലാത്ത വരികൾ ഡൗൺലോഡ് ചെയ്യുക", + "TaskDownloadMissingLyricsDescription": "പാട്ടുകളുടെ വരികൾ ഡൗൺലോഡ് ചെയ്യുന്നു", + "TaskExtractMediaSegments": "മീഡിയ സെഗ്‌മെന്റ് സ്‌കാൻ", + "TaskExtractMediaSegmentsDescription": "മീഡിയസെഗ്മെന്റ് പ്രാപ്തമാക്കിയ പ്ലഗിനുകളിൽ നിന്ന് മീഡിയ സെഗ്‌മെന്റുകൾ എക്‌സ്‌ട്രാക്റ്റുചെയ്യുന്നു അല്ലെങ്കിൽ നേടുന്നു." } diff --git a/Emby.Server.Implementations/Localization/Core/mn.json b/Emby.Server.Implementations/Localization/Core/mn.json index 240059a3bf..83caaf346f 100644 --- a/Emby.Server.Implementations/Localization/Core/mn.json +++ b/Emby.Server.Implementations/Localization/Core/mn.json @@ -2,15 +2,12 @@ "Books": "Номнууд", "HeaderNextUp": "Дараа нь", "HeaderContinueWatching": "Үргэлжлүүлэн үзэх", - "Songs": "Дуунууд", - "Playlists": "Playlist-ууд", "Movies": "Кинонууд", "Latest": "Сүүлийн үеийн", "Genres": "Төрлүүд", "Favorites": "Дуртай", "Collections": "Цуглуулгууд", "Artists": "Уран бүтээлчид", - "Albums": "Дуут цомгууд", "TaskExtractMediaSegments": "Медиа сегмент шалга", "TaskExtractMediaSegmentsDescription": "MediaSegment идэвхжүүлсэн залгаасуудаас медиа сегментүүдийг задлах эсвэл олж авах.", "TaskMoveTrickplayImages": "Трикплэй зургуудын байршлыг шилжүүлэх", @@ -21,20 +18,17 @@ "TaskKeyframeExtractor": "Түлхүүр кадр гаргагч", "TaskCleanCache": "Кэш санг цэвэрлэх", "NewVersionIsAvailable": "Jellyfin Server-н шинэ хувилбар татаж авахад нээлттэй боллоо.", - "MessageNamedServerConfigurationUpdatedWithValue": "Server-н {0}-р хэсгийн тохиргоо шинэчлэгдлээ", "NotificationOptionAudioPlaybackStopped": "Дууг зогсоов", "NotificationOptionNewLibraryContent": "Шинэ агуулга орлоо", "NotificationOptionServerRestartRequired": "Server-г дахин асаана уу", "NotificationOptionVideoPlaybackStopped": "Бичлэгийг зогсоов", "UserPasswordChangedWithName": "Хэрэглэгч {0}-н нууц үгийг өөрчиллөө", - "TaskCleanCollectionsAndPlaylists": "Цуглуулга ба тоглуулах жагсаалтыг цэвэрлэх", "ScheduledTaskFailedWithName": "{0} амжилтгүй", "StartupEmbyServerIsLoading": "Jellyfin Server ачааллаж байна. Хэсэг хугацааны дараа дахин оролдоно уу.", "TaskCleanActivityLog": "Үйл ажиллагааны бүртгэлийг цэвэрлэх", "SubtitleDownloadFailureFromForItem": "{0}-г {1}-д зориулсан хадмал орчуулгыг татаж авч чадсангүй", "TaskRefreshLibraryDescription": "Таны медиа санг шинэ файлуудын хувьд шалгаж, мета мэдээллийг шинэчилнэ.", "UserOfflineFromDevice": "{0}-г {1}-с салгалаа", - "ValueHasBeenAddedToLibrary": "{0}-г медиа сан руу нэмэгдлээ", "TaskRefreshPeopleDescription": "Таны медиа санд байгаа жүжигчид болон найруулагчдын мета мэдээллийг шинэчилнэ.", "TaskCleanTranscodeDescription": "Нэг өдрөөс илүү настай транскодлох файлуудыг устгана.", "TaskRefreshChannelsDescription": "Интернет сувгуудын мэдээллийг шинэчлэх.", @@ -44,7 +38,6 @@ "NotificationOptionAudioPlayback": "Дууг тоглууллаа", "TaskRefreshTrickplayImages": "Трикплэй зургуудыг үүсгэх", "TaskUpdatePlugins": "Plugin-уудыг шинэчлэх", - "TaskCleanCollectionsAndPlaylistsDescription": "Одоо байхгүй болсон зүйлсийг цуглуулга ба тоглуулах жагсаалтаас устгана.", "TaskAudioNormalization": "Аудиог хэвшүүлэх", "TaskAudioNormalizationDescription": "Файлуудаас дууны хэвийн хэмжээсийн мэдээллийг шалгана.", "TaskRefreshTrickplayImagesDescription": "Идэвхжсэн сангуудад байгаа видеонуудын трикплэй урьдчилсан харагдацыг үүсгэнэ.", @@ -53,36 +46,21 @@ "TaskRefreshChannels": "Сувгуудыг шинэчлэх", "TaskDownloadMissingSubtitles": "Алга болсон хадмал орчуулгыг татах", "External": "Гадны", - "HeaderFavoriteArtists": "Дуртай уран бүтээлчид", "HeaderFavoriteEpisodes": "Дуртай ангиуд", "HeaderFavoriteShows": "Дуртай нэвтрүүлэг", - "HeaderFavoriteSongs": "Дуртай дуу", "AppDeviceValues": "Aпп: {0}, Төхөөрөмж: {1}", - "Application": "Aпп", "AuthenticationSucceededWithUserName": "{0} амжилттай нэвтэрлээ", - "CameraImageUploadedFrom": "{0}-с шинэ зураг байршуулагдлаа", - "Channels": "Сувгууд", "ChapterNameValue": "{0}-р бүлэг", "Default": "Анхдагч", - "DeviceOfflineWithName": "{0}-н холболт саллаа", - "DeviceOnlineWithName": "{0} холбогдлоо", "FailedLoginAttemptWithUserName": "{0}-н нэвтрэх оролдлого амжилтгүй", "Folders": "Хавтасууд", "Forced": "Хүчээр", - "HeaderAlbumArtists": "Цомгийн уран бүтээлчид", - "HeaderFavoriteAlbums": "Дуртай цомгууд", - "HeaderLiveTV": "Шууд", - "HeaderRecordingGroups": "Бичлэгийн бүлгүүд", + "HeaderLiveTV": "Шууд ТВ", "HearingImpaired": "Сонсголын бэрхшээлтэй", "HomeVideos": "Үндсэн дүрсүүд", "Inherit": "Уламжлах", - "ItemAddedWithName": "{0}-г санд нэмлээ", - "ItemRemovedWithName": "{0}-с сангаас хаслаа", "LabelIpAddressValue": "IP хаяг: {0}", "LabelRunningTimeValue": "Үргэлжлэх хугацаа: {0}", - "MessageApplicationUpdated": "Jellyfin Server шинэчлэгдлээ", - "MessageApplicationUpdatedTo": "Jellyfin Server {0} болж шинэчлэгдлээ", - "MessageServerConfigurationUpdated": "Server-н тохиргоо шинэчлэгдлээ", "MixedContent": "Холимог агуулга", "Music": "Хөгжим", "MusicVideos": "Дууны клипүүд", @@ -101,28 +79,19 @@ "NotificationOptionUserLockedOut": "Хэрэглэгчийг түгжив", "NotificationOptionVideoPlayback": "Бичлэгийг тоглуулж эхлэв", "Photos": "Зургууд", - "Plugin": "Плагин", "PluginInstalledWithName": "{0}-г суулгалаа", "PluginUninstalledWithName": "{0}-г устгалаа", "PluginUpdatedWithName": "{0}-г шинэчиллээ", - "ProviderValue": "Нийлүүлэгч: {0}", - "ScheduledTaskStartedWithName": "{0}-г эхлүүлэв", - "ServerNameNeedsToBeRestarted": "{0}-г дахин асаана уу", "Shows": "Шоу", - "Sync": "Дахин", - "System": "Систем", "TvShows": "ТВ нэвтрүүлгүүд", "Undefined": "Танисангүй", - "User": "Хэрэглэгч", "UserCreatedWithName": "Хэрэглэгч {0}-г үүсгэлээ", "UserDeletedWithName": "Хэрэглэгч {0}-г устгалаа", "UserDownloadingItemWithValues": "{0} нь {1}-г татаж байна", "UserLockedOutWithName": "Хэрэглэгч {0}-г түгжлээ", "UserOnlineFromDevice": "{0} нь {1}-тэй холбоотой байна", - "UserPolicyUpdatedWithName": "Хэрэглэгчийн журмыг {0}-д зориулан шинэчиллээ", "UserStartedPlayingItemWithValues": "{0}-г {2} дээр {1}-г тоглуулж байна", "UserStoppedPlayingItemWithValues": "{0}-г {2} дээр {1}-г тоглуулж дуусгалаа", - "ValueSpecialEpisodeName": "Онцгой - {0}", "VersionNumber": "Хувилбар {0}", "TasksMaintenanceCategory": "Засвар", "TasksLibraryCategory": "Сан", diff --git a/Emby.Server.Implementations/Localization/Core/mr.json b/Emby.Server.Implementations/Localization/Core/mr.json index 9cfeb407b6..f169d085d5 100644 --- a/Emby.Server.Implementations/Localization/Core/mr.json +++ b/Emby.Server.Implementations/Localization/Core/mr.json @@ -1,22 +1,13 @@ { "Books": "पुस्तकं", "Artists": "संगीतकार", - "Albums": "अल्बम", - "Playlists": "प्लेलिस्ट", - "HeaderAlbumArtists": "अल्बम संगीतकार", "Folders": "फोल्डर", "HeaderFavoriteEpisodes": "आवडते भाग", - "HeaderFavoriteSongs": "आवडती गाणी", "Movies": "चित्रपट", - "HeaderFavoriteArtists": "आवडते संगीतकार", "Shows": "कार्यक्रम", - "HeaderFavoriteAlbums": "आवडते अल्बम", - "Channels": "वाहिन्या", - "ValueSpecialEpisodeName": "विशेष - {0}", "HeaderFavoriteShows": "आवडते कार्यक्रम", "Favorites": "आवडीचे", "HeaderNextUp": "यानंतर", - "Songs": "गाणी", "HeaderLiveTV": "लाइव्ह टीव्ही", "Genres": "जाँनरे", "Photos": "चित्र", @@ -34,10 +25,8 @@ "UserOnlineFromDevice": "{0} हे {1} येथून ऑनलाइन आहेत", "UserDeletedWithName": "प्रयोक्ता {0} काढून टाकण्यात आले आहे", "UserCreatedWithName": "प्रयोक्ता {0} बनवण्यात आले आहे", - "User": "प्रयोक्ता", "TvShows": "टीव्ही कार्यक्रम", "StartupEmbyServerIsLoading": "जेलिफिन सर्व्हर लोड होत आहे. कृपया थोड्या वेळात पुन्हा प्रयत्न करा.", - "Plugin": "प्लगइन", "NotificationOptionCameraImageUploaded": "कॅमेरा चित्र अपलोड केले आहे", "NotificationOptionApplicationUpdateInstalled": "अ‍ॅप्लिकेशन अपडेट इन्स्टॉल केले आहे", "NotificationOptionApplicationUpdateAvailable": "अ‍ॅप्लिकेशन अपडेट उपलब्ध आहे", @@ -46,16 +35,9 @@ "NameSeasonNumber": "सीझन {0}", "MusicVideos": "संगीत व्हिडीयो", "Music": "संगीत", - "MessageApplicationUpdatedTo": "जेलिफिन सर्व्हर अपडेट होऊन {0} आवृत्तीवर पोहोचला आहे", - "MessageApplicationUpdated": "जेलिफिन सर्व्हर अपडेट केला गेला आहे", "Latest": "नवीनतम", "LabelIpAddressValue": "आयपी पत्ता: {0}", - "ItemRemovedWithName": "{0} हे संग्रहालयातून काढून टाकण्यात आले", - "ItemAddedWithName": "{0} हे संग्रहालयात जोडले गेले", "HomeVideos": "घरचे व्हिडीयो", - "HeaderRecordingGroups": "रेकॉर्डिंग गट", - "CameraImageUploadedFrom": "एक नवीन कॅमेरा चित्र {0} येथून अपलोड केले आहे", - "Application": "अ‍ॅप्लिकेशन", "AppDeviceValues": "अ‍ॅप: {0}, यंत्र: {1}", "Collections": "संग्रह", "ChapterNameValue": "धडा {0}", @@ -70,18 +52,12 @@ "TaskRefreshChapterImagesDescription": "अध्याय असलेल्या व्हिडियोंसाठी थंबनेल चित्र बनवतो.", "TaskRefreshChapterImages": "अध्याय चित्र काढून घ्या", "TasksMaintenanceCategory": "देखरेख", - "ValueHasBeenAddedToLibrary": "{0} हे तुमच्या माध्यम संग्रहात जोडण्यात आले आहे", "UserStoppedPlayingItemWithValues": "{0} यांचं {2} वर {1} पूर्णपणे प्ले करून झालं आहे", "UserStartedPlayingItemWithValues": "{0} हे {2} वर {1} प्ले करत आहे", "UserDownloadingItemWithValues": "{0} हे {1} डाउनलोड करत आहे", - "System": "प्रणाली", "Undefined": "अव्याख्यात", - "Sync": "सिंक", - "ServerNameNeedsToBeRestarted": "{0} याला बंद करून पुन्हा सुरू करायची गरज आहे", "SubtitleDownloadFailureFromForItem": "{0} येथून {1} यासाठी उपशिर्षक डाउनलोड करण्यात अपयश", - "ScheduledTaskStartedWithName": "{0} सुरू झाले", "ScheduledTaskFailedWithName": "{0} अपयशी झाले", - "ProviderValue": "पुरवणारा: {0}", "PluginUpdatedWithName": "{0} अपडेट केले", "PluginUninstalledWithName": "{0} अनिन्स्टॉल केले", "PluginInstalledWithName": "{0} इन्स्टॉल केले", @@ -109,28 +85,26 @@ "TaskCleanCacheDescription": "सिस्टमला यापुढे आवश्यक नसलेल्या कॅशे फाइल्स हटवा.", "TaskCleanActivityLogDescription": "कॉन्फिगर केलेल्या वयापेक्षा जुन्या क्रियाकलाप लॉग एंट्री हटवा.", "TaskCleanActivityLog": "क्रियाकलाप लॉग साफ करा", - "UserPolicyUpdatedWithName": "{0} साठी वापरकर्ता धोरण अपडेट केले गेले आहे", "UserOfflineFromDevice": "{0} {1} वरून डिस्कनेक्ट झाला आहे", "UserLockedOutWithName": "वापरकर्ता {0} लॉक केले गेले आहे", "NotificationOptionUserLockedOut": "वापरकर्ता लॉक आउट", "NameInstallFailed": "{0} स्थापना अयशस्वी", - "MessageServerConfigurationUpdated": "सर्व्हर कॉन्फिगरेशन अद्यतनित केले आहे", - "MessageNamedServerConfigurationUpdatedWithValue": "सर्व्हर कॉन्फिगरेशन विभाग {0} अद्यतनित केला गेला आहे", "Inherit": "वारसा", "Forced": "सक्ती केली आहे", "FailedLoginAttemptWithUserName": "{0} कडून लॉगिन करण्याचा प्रयत्न अयशस्वी झाला", "External": "बाहेरचा", - "DeviceOnlineWithName": "{0} कनेक्ट झाले", - "DeviceOfflineWithName": "{0} डिस्कनेक्ट झाला आहे", "AuthenticationSucceededWithUserName": "{0} यशस्वीरित्या प्रमाणीकृत", "HearingImpaired": "कर्णबधीर", "TaskRefreshTrickplayImages": "ट्रिकप्ले प्रतिमा तयार करा", "TaskRefreshTrickplayImagesDescription": "सक्षम लायब्ररीमधील व्हिडिओंसाठी ट्रिकप्ले पूर्वावलोकन तयार करते.", - "TaskCleanCollectionsAndPlaylists": "संग्रह आणि प्लेलिस्ट व्यवस्थित करा", "TaskExtractMediaSegments": "मिडिया विभाग तपासणी", "TaskMoveTrickplayImages": "ट्रिकप्ले प्रतिमेचे स्थान स्थलांतर करा", "TaskDownloadMissingLyrics": "उपलब्ध नसलेली गीतपट्टी (Lyrics) डाउनलोड करा", "TaskAudioNormalization": "ऑडिओ सामान्यीकरण", "TaskAudioNormalizationDescription": "ऑडिओ सामान्यीकरणाचा डाटा स्कॅन करतो.", - "TaskDownloadMissingLyricsDescription": "गाण्यांची गीतपट्टी (Lyrics) डाउनलोड करतो" + "TaskDownloadMissingLyricsDescription": "गाण्यांची गीतपट्टी (Lyrics) डाउनलोड करतो", + "TaskExtractMediaSegmentsDescription": "सक्रिय असलेल्या प्लगिनमधून मीडिया विभाग प्राप्त करते.", + "TaskMoveTrickplayImagesDescription": "लायब्ररीच्या सेटिंग्जप्रमाणे आधीपासून अस्तित्वात असलेल्या ट्रिकप्ले फाइल्सचे स्थान बदलते.", + "CleanupUserDataTask": "वापरकर्ता डेटाची स्वच्छता प्रक्रिया", + "CleanupUserDataTaskDescription": "९० दिवसांहून अधिक काळ अनुपस्थित असलेल्या माध्यमांवरील सर्व वापरकर्ता माहिती (जसे पाहण्याची स्थिती, आवडी इ.) हटवते." } diff --git a/Emby.Server.Implementations/Localization/Core/ms.json b/Emby.Server.Implementations/Localization/Core/ms.json index 971f79c2c8..bb4469ad9c 100644 --- a/Emby.Server.Implementations/Localization/Core/ms.json +++ b/Emby.Server.Implementations/Localization/Core/ms.json @@ -1,41 +1,24 @@ { - "Albums": "Album", "AppDeviceValues": "Aplikasi: {0}, Peranti: {1}", - "Application": "Aplikasi", "Artists": "Artis", "AuthenticationSucceededWithUserName": "{0} berjaya disahkan", "Books": "Buku", - "CameraImageUploadedFrom": "Gambar baharu telah dimuat naik melalui {0}", - "Channels": "Saluran", "ChapterNameValue": "Bab {0}", "Collections": "Koleksi", - "DeviceOfflineWithName": "{0} telah dinyahsambung", - "DeviceOnlineWithName": "{0} telah disambung", "FailedLoginAttemptWithUserName": "Percubaan gagal log masuk daripada {0}", "Favorites": "Kegemaran", "Folders": "Folder-folder", "Genres": "Genre-genre", - "HeaderAlbumArtists": "Album artis-artis", "HeaderContinueWatching": "Teruskan Menonton", - "HeaderFavoriteAlbums": "Album-album Kegemaran", - "HeaderFavoriteArtists": "Artis-artis Kegemaran", "HeaderFavoriteEpisodes": "Episod-episod Kegemaran", "HeaderFavoriteShows": "Rancangan-rancangan Kegemaran", - "HeaderFavoriteSongs": "Lagu-lagu Kegemaran", "HeaderLiveTV": "TV Siaran Langsung", "HeaderNextUp": "Seterusnya", - "HeaderRecordingGroups": "Kumpulan-kumpulan Rakaman", "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": "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", @@ -61,24 +44,14 @@ "NotificationOptionVideoPlayback": "Ulangmain video bermula", "NotificationOptionVideoPlaybackStopped": "Ulangmain video dihentikan", "Photos": "Gambar-gambar", - "Playlists": "Senarai ulangmain", - "Plugin": "Plugin", "PluginInstalledWithName": "{0} telah dipasang", "PluginUninstalledWithName": "{0} telah dinyahpasang", "PluginUpdatedWithName": "{0} telah dikemaskini", - "ProviderValue": "Pembekal: {0}", "ScheduledTaskFailedWithName": "{0} gagal", - "ScheduledTaskStartedWithName": "{0} bermula", - "ServerNameNeedsToBeRestarted": "{0} perlu di ulangmula", "Shows": "Tayangan", - "Songs": "Lagu-lagu", "StartupEmbyServerIsLoading": "Pelayan Jellyfin sedang dimuatkan. Sila cuba sebentar lagi.", - "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", "SubtitleDownloadFailureFromForItem": "Muat turun sarikata gagal dari {0} untuk {1}", - "Sync": "Segerak", - "System": "Sistem", "TvShows": "Tayangan TV", - "User": "Pengguna", "UserCreatedWithName": "Pengguna {0} telah diwujudkan", "UserDeletedWithName": "Pengguna {0} telah dipadamkan", "UserDownloadingItemWithValues": "{0} sedang memuat turun {1}", @@ -86,11 +59,8 @@ "UserOfflineFromDevice": "{0} telah terputus dari {1}", "UserOnlineFromDevice": "{0} berada dalam talian dari {1}", "UserPasswordChangedWithName": "Kata laluan telah ditukar bagi pengguna {0}", - "UserPolicyUpdatedWithName": "Dasar pengguna telah dikemas kini untuk {0}", "UserStartedPlayingItemWithValues": "{0} sedang dimainkan {1} pada {2}", "UserStoppedPlayingItemWithValues": "{0} telah tamat dimainkan {1} pada {2}", - "ValueHasBeenAddedToLibrary": "{0} telah ditambah ke media library anda", - "ValueSpecialEpisodeName": "Khas - {0}", "VersionNumber": "Versi {0}", "TaskCleanActivityLog": "Log Aktiviti Bersih", "TasksChannelsCategory": "Saluran Internet", @@ -133,10 +103,14 @@ "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.", "CleanupUserDataTaskDescription": "Membersihkan semua data pengguna (keadaan tontonan, status kegemaran, dan sebagainya) daripada media yang tidak lagi wujud sekurang-kurangnya selama 90 hari.", - "CleanupUserDataTask": "Tugas pembersihan data pengguna" + "CleanupUserDataTask": "Tugas pembersihan data pengguna", + "LyricDownloadFailureFromForItem": "Lirik gagal dimuat turun dari {0} untuk {1}", + "NameExtraBehindTheScenes": "Di Sebalik Takbir", + "NameExtraClip": "Klip", + "NameExtraInterview": "Temu bual", + "NameExtraThemeSong": "Lagu Tema", + "NameExtraThemeVideo": "Video Tema" } diff --git a/Emby.Server.Implementations/Localization/Core/mt.json b/Emby.Server.Implementations/Localization/Core/mt.json index f7501ab404..e237a80b70 100644 --- a/Emby.Server.Implementations/Localization/Core/mt.json +++ b/Emby.Server.Implementations/Localization/Core/mt.json @@ -1,28 +1,18 @@ { - "Albums": "Albums", "AppDeviceValues": "Applikazzjoni: {0}, Device: {1}", - "Application": "Applikazzjoni", "Artists": "Artisti", "AuthenticationSucceededWithUserName": "{1} awtentikat b'suċċess", "Books": "Kotba", - "CameraImageUploadedFrom": "Ttella' ritratt ġdid tal-kamera minn {1}", - "Channels": "Stazzjonijiet", "ChapterNameValue": "Kapitlu {0}", "Collections": "Kollezzjonijiet", - "DeviceOfflineWithName": "{0} tneħħa", - "DeviceOnlineWithName": "{0} tqabbad", "External": "Estern", "FailedLoginAttemptWithUserName": "Attentat fallut ta' login minn {0}", "Favorites": "Favoriti", "Forced": "Sfurzat", "Genres": "Ġeneri", - "HeaderAlbumArtists": "Artisti tal-album", "HeaderContinueWatching": "Kompli Ara", - "HeaderFavoriteAlbums": "Albums Favoriti", - "HeaderFavoriteArtists": "Artisti Favoriti", "HeaderFavoriteEpisodes": "Episodji Favoriti", "HeaderFavoriteShows": "Programmi Favoriti", - "HeaderFavoriteSongs": "Kanzunetti Favoriti", "HeaderNextUp": "Li Jmiss", "SubtitleDownloadFailureFromForItem": "Is-sottotitli ma setgħux jitniżżlu minn {0} għal {1}", "UserPasswordChangedWithName": "Il-password għall-utent {0} inbidlet", @@ -32,18 +22,11 @@ "Default": "Standard", "Folders": "Folders", "HeaderLiveTV": "TV Dirett", - "HeaderRecordingGroups": "Gruppi ta' Rikordjar", "HearingImpaired": "Nuqqas ta' Smigħ", "HomeVideos": "Filmati Personali", "Inherit": "Jiret", - "ItemAddedWithName": "{0} żdied fil-librerija", - "ItemRemovedWithName": "{0} tneħħa mil-librerija", "LabelIpAddressValue": "Indirizz tal-IP: {0}", "Latest": "Tal-Aħħar", - "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", @@ -67,21 +50,12 @@ "NotificationOptionTaskFailed": "Falliment tat-task skedat", "NotificationOptionUserLockedOut": "Utent imsakkar", "Photos": "Ritratti", - "Playlists": "Playlists", - "Plugin": "Plugin", "PluginInstalledWithName": "{0} ġie installat", "PluginUninstalledWithName": "{0} tneħħa", "PluginUpdatedWithName": "{0} ġie aġġornat", - "ProviderValue": "Fornitur: {0}", "ScheduledTaskFailedWithName": "{0} falla", - "ScheduledTaskStartedWithName": "{0} beda", - "ServerNameNeedsToBeRestarted": "{0} jeħtieġ restart", - "Songs": "Kanzunetti", "StartupEmbyServerIsLoading": "Jellyfin Server qed jillowdja. Jekk jogħġbok erġa' pprova ftit tal-ħin oħra.", - "Sync": "Sinkronizza", - "System": "Sistema", "Undefined": "Bla Definizzjoni", - "User": "Utent", "UserCreatedWithName": "L-utent {0} inħoloq", "UserDeletedWithName": "L-utent {0} tħassar", "UserDownloadingItemWithValues": "{0} qed iniżżel {1}", @@ -93,11 +67,8 @@ "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", @@ -128,8 +99,6 @@ "TaskOptimizeDatabase": "Ottimiżża d-database", "TaskKeyframeExtractor": "Estrattur ta' Keyframes", "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.", "TaskDownloadMissingLyrics": "Niżżel il-lirika nieqsa", "TaskDownloadMissingLyricsDescription": "Iniżżel il-lirika għal-kanzunetti", "TaskExtractMediaSegments": "Scan tas-Sezzjoni tal-Midja", diff --git a/Emby.Server.Implementations/Localization/Core/my.json b/Emby.Server.Implementations/Localization/Core/my.json index 4cb4cdc757..47728adfc5 100644 --- a/Emby.Server.Implementations/Localization/Core/my.json +++ b/Emby.Server.Implementations/Localization/Core/my.json @@ -1,10 +1,8 @@ { "Default": "ပုံသေ", "Collections": "စုစည်းမှုများ", - "Channels": "တီဗွီလိုင်းများ", "Books": "စာအုပ်များ", "Artists": "အနုပညာရှင်များ", - "Albums": "သီချင်းအခွေများ", "TaskOptimizeDatabaseDescription": "ဒေတာဘေ့စ်ကို ကျစ်လစ်စေပြီး နေရာလွတ်များကို ဖြတ်တောက်ပေးသည်။ စာကြည့်တိုက်ကို စကင်န်ဖတ်ပြီးနောက် ဤလုပ်ငန်းကို လုပ်ဆောင်ခြင်း သို့မဟုတ် ဒေတာဘေ့စ်မွမ်းမံမှုများ စွမ်းဆောင်ရည်ကို မြှင့်တင်ပေးနိုင်သည်ဟု ရည်ညွှန်းသော အခြားပြောင်းလဲမှုများကို လုပ်ဆောင်ခြင်း။.", "TaskOptimizeDatabase": "ဒေတာဘေ့စ်ကို အကောင်းဆုံးဖြစ်အောင်လုပ်ပါ", "TaskDownloadMissingSubtitlesDescription": "မက်တာဒေတာ ဖွဲ့စည်းမှုပုံစံအပေါ် အခြေခံ၍ ပျောက်ဆုံးနေသော စာတန်းထိုးများအတွက် အင်တာနက်ကို ရှာဖွေသည်။", @@ -32,11 +30,8 @@ "TasksLibraryCategory": "မီဒီယာတိုက်", "TasksMaintenanceCategory": "ပြုပြင် ထိန်းသိမ်းခြင်း", "VersionNumber": "ဗားရှင်း {0}", - "ValueSpecialEpisodeName": "အထူး- {0}", - "ValueHasBeenAddedToLibrary": "{0} ကို သင့်မီဒီယာဒစ်ဂျစ်တိုက်သို့ ပေါင်းထည့်လိုက်ပါပြီ", "UserStoppedPlayingItemWithValues": "{0} သည် {1} ကို {2} တွင် ဖွင့်ပြီးပါပြီ", "UserStartedPlayingItemWithValues": "{0} သည် {1} ကို {2} တွင် ပြသနေသည်", - "UserPolicyUpdatedWithName": "{0} အတွက် အသုံးပြုသူမူဝါဒကို အပ်ဒိတ်လုပ်ပြီးပါပြီ", "UserPasswordChangedWithName": "အသုံးပြုသူ {0} အတွက် စကားဝှက်ကို ပြောင်းထားသည်", "UserOnlineFromDevice": "{0} သည် {1} မှ အွန်လိုင်းဖြစ်သည်", "UserOfflineFromDevice": "{0} သည် {1} မှ ချိတ်ဆက်မှုပြတ်တောက်သွားသည်", @@ -44,24 +39,15 @@ "UserDownloadingItemWithValues": "{0} သည် {1} ကို ဒေါင်းလုဒ်လုပ်နေသည်", "UserDeletedWithName": "အသုံးပြုသူ {0} ကို ဖျက်လိုက်ပါပြီ", "UserCreatedWithName": "အသုံးပြုသူ {0} ကို ဖန်တီးပြီးပါပြီ", - "User": "အသုံးပြုသူ", "Undefined": "သတ်မှတ်မထားသော", "TvShows": "တီဗီ ဇာတ်လမ်းတွဲများ", - "System": "စနစ်", - "Sync": "ချိန်ကိုက်မည်", "SubtitleDownloadFailureFromForItem": "{1} အတွက် {0} မှ စာတန်းထိုးများ ဒေါင်းလုဒ်လုပ်ခြင်း မအောင်မြင်ပါ", "StartupEmbyServerIsLoading": "Jellyfin ဆာဗာကို အသင့်ပြင်နေပါသည်။ ခဏနေ ထပ်စမ်းကြည့်ပါ။", - "Songs": "သီချင်းများ", "Shows": "ဇာတ်လမ်းတွဲများ", - "ServerNameNeedsToBeRestarted": "{0} ကို ပြန်လည်စတင်ရန် လိုအပ်သည်", - "ScheduledTaskStartedWithName": "{0} စတင်ခဲ့သည်", "ScheduledTaskFailedWithName": "{0} မအောင်မြင်ပါ", - "ProviderValue": "ဝန်ဆောင်မှုပေးသူ- {0}", "PluginUpdatedWithName": "ပလပ်ခ်အင် {0} ကို အပ်ဒိတ်လုပ်ထားသည်", "PluginUninstalledWithName": "ပလပ်ခ်အင် {0} ကို ဖြုတ်လိုက်ပါပြီ", "PluginInstalledWithName": "ပလပ်ခ်အင် {0} ကို ထည့်သွင်းခဲ့သည်", - "Plugin": "ပလပ်အင်", - "Playlists": "အစီအစဉ်များ", "Photos": "ဓာတ်ပုံများ", "NotificationOptionVideoPlaybackStopped": "ဗီဒီယိုဖွင့်ခြင်း ရပ်သွားသည်", "NotificationOptionVideoPlayback": "ဗီဒီယိုဖွင့်ခြင်း စတင်ပါပြီ", @@ -87,44 +73,29 @@ "Music": "တေးဂီတ", "Movies": "ရုပ်ရှင်များ", "MixedContent": "ရောနှောပါဝင်မှု", - "MessageServerConfigurationUpdated": "ဆာဗာဖွဲ့စည်းပုံကို အပ်ဒိတ်လုပ်ပြီးပါပြီ", - "MessageNamedServerConfigurationUpdatedWithValue": "ဆာဗာဖွဲ့စည်းပုံကဏ္ဍ {0} ကို အပ်ဒိတ်လုပ်ပြီးပါပြီ", - "MessageApplicationUpdatedTo": "Jellyfin ဆာဗာကို {0} သို့ အပ်ဒိတ်လုပ်ထားသည်", - "MessageApplicationUpdated": "Jellyfin ဆာဗာကို အပ်ဒိတ်လုပ်ပြီးပါပြီ", "Latest": "နောက်ဆုံး", "LabelRunningTimeValue": "ကြာချိန် - {0}", "LabelIpAddressValue": "IP လိပ်စာ- {0}", - "ItemRemovedWithName": "{0} ကို ဒစ်ဂျစ်တိုက်မှ ဖယ်ရှားခဲ့သည်", - "ItemAddedWithName": "{0} ကို စာကြည့်တိုက်သို့ ထည့်ထားသည်", "Inherit": "ဆက်ခံ၍ လုပ်ဆောင်သည်", "HomeVideos": "ကိုယ်တိုင်ရိုက် ဗီဒီယိုများ", - "HeaderRecordingGroups": "အသံဖမ်းအဖွဲ့များ", "HeaderNextUp": "နောက်ထပ်", "HeaderLiveTV": "တီဗွီတိုက်ရိုက်", - "HeaderFavoriteSongs": "အကြိုက်ဆုံးသီချင်းများ", "HeaderFavoriteShows": "အကြိုက်ဆုံး ဇာတ်လမ်းတွဲများ", "HeaderFavoriteEpisodes": "အကြိုက်ဆုံး ဇာတ်လမ်းအပိုင်းများ", - "HeaderFavoriteArtists": "အကြိုက်ဆုံး အနုပညာရှင်များ", - "HeaderFavoriteAlbums": "အကြိုက်ဆုံး အယ်လ်ဘမ်များ", "HeaderContinueWatching": "ဆက်လက်ကြည့်ရှုပါ", - "HeaderAlbumArtists": "အယ်လ်ဘမ်အနုပညာရှင်များ", "Genres": "အမျိုးအစားများ", "Forced": "အတင်းအကြပ်", "Folders": "ဖိုလ်ဒါများ", "Favorites": "အကြိုက်ဆုံးများ", "FailedLoginAttemptWithUserName": "{0} မှ အကောင့်ဝင်ရန် မအောင်မြင်ပါ", - "DeviceOnlineWithName": "{0} ကို ချိတ်ဆက်ထားသည်", - "DeviceOfflineWithName": "{0} နှင့် အဆက်ပြတ်သွားပါပြီ", "ChapterNameValue": "အခန်း {0}", - "CameraImageUploadedFrom": "ကင်မရာပုံအသစ်ကို {0} မှ ထည့်သွင်းလိုက်သည်", "AuthenticationSucceededWithUserName": "{0} အောင်မြင်စွာ စစ်မှန်ကြောင်း အတည်ပြုပြီးပါပြီ", - "Application": "အပလီကေးရှင်း", "AppDeviceValues": "အက်ပ်- {0}၊ စက်- {1}", "External": "ပြင်ပ", "TaskKeyframeExtractorDescription": "ပိုမိုတိကျသည့် အိတ်ချ်အယ်လ်အက်စ် အစဉ်လိုက်ပြသမှုများ ဖန်တီးနိုင်ရန်အတွက် ဗီဒီယိုဖိုင်များမှ ကီးဖရိန်များကို ထုတ်နှုတ်ယူမည် ဖြစ်သည်။ ဤလုပ်ဆောင်မှုသည် အချိန်ကြာရှည်နိုင်သည်။", - "TaskCleanCollectionsAndPlaylistsDescription": "စုစည်းမှုများနှင့် အစဉ်လိုက်ပြသမှုများမှ မရှိတော့သည်များကို ဖယ်ရှားမည်။", "TaskRefreshTrickplayImages": "ထရစ်ခ်ပလေး ပုံများကို ထုတ်မည်", "TaskKeyframeExtractor": "ကီးဖရိန်များကို ထုတ်နုတ်ခြင်း", - "TaskCleanCollectionsAndPlaylists": "စုစည်းမှုများနှင့် အစဉ်လိုက်ပြသမှုများကို ရှင်းလင်းမည်", - "HearingImpaired": "အကြားအာရုံ ချို့တဲ့သူ" + "HearingImpaired": "အကြားအာရုံ ချို့တဲ့သူ", + "TaskDownloadMissingLyrics": "ကျန်နေသောသီချင်းစာသားများအား ဒေါင်းလုတ်ဆွဲပါ", + "TaskDownloadMissingLyricsDescription": "သီချင်းများအတွက် သီချင်းစာသား ဒေါင်းလုတ်ဆွဲပါ" } diff --git a/Emby.Server.Implementations/Localization/Core/nb.json b/Emby.Server.Implementations/Localization/Core/nb.json index e73c56cb90..735bc2c793 100644 --- a/Emby.Server.Implementations/Localization/Core/nb.json +++ b/Emby.Server.Implementations/Localization/Core/nb.json @@ -1,41 +1,24 @@ { - "Albums": "Album", "AppDeviceValues": "App: {0}, Enhet: {1}", - "Application": "Program", "Artists": "Artister", "AuthenticationSucceededWithUserName": "{0} har logget inn", "Books": "Bøker", - "CameraImageUploadedFrom": "Et nytt kamerabilde har blitt lastet opp fra {0}", - "Channels": "Kanaler", "ChapterNameValue": "Kapittel {0}", "Collections": "Samlinger", - "DeviceOfflineWithName": "{0} har koblet fra", - "DeviceOnlineWithName": "{0} er tilkoblet", "FailedLoginAttemptWithUserName": "Mislykket påloggingsforsøk fra {0}", "Favorites": "Favoritter", "Folders": "Mapper", "Genres": "Sjangre", - "HeaderAlbumArtists": "Albumartister", "HeaderContinueWatching": "Fortsett å se", - "HeaderFavoriteAlbums": "Favorittalbum", - "HeaderFavoriteArtists": "Favorittartister", "HeaderFavoriteEpisodes": "Favorittepisoder", "HeaderFavoriteShows": "Favorittserier", - "HeaderFavoriteSongs": "Favorittsanger", "HeaderLiveTV": "Direkte-TV", "HeaderNextUp": "Neste", - "HeaderRecordingGroups": "Opptaksgrupper", "HomeVideos": "Hjemmelagde filmer", "Inherit": "Arve", - "ItemAddedWithName": "{0} ble lagt til i biblioteket", - "ItemRemovedWithName": "{0} ble fjernet fra biblioteket", "LabelIpAddressValue": "IP-adresse: {0}", "LabelRunningTimeValue": "Spilletid: {0}", "Latest": "Siste", - "MessageApplicationUpdated": "Jellyfin-serveren har blitt oppdatert", - "MessageApplicationUpdatedTo": "Jellyfin-serveren ble oppdatert til {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Serverkonfigurasjonsseksjon {0} har blitt oppdatert", - "MessageServerConfigurationUpdated": "Serverkonfigurasjon har blitt oppdatert", "MixedContent": "Blandet innhold", "Movies": "Filmer", "Music": "Musikk", @@ -61,24 +44,14 @@ "NotificationOptionVideoPlayback": "Videoavspilling startet", "NotificationOptionVideoPlaybackStopped": "Videoavspilling stoppet", "Photos": "Bilder", - "Playlists": "Spillelister", - "Plugin": "Programvareutvidelse", "PluginInstalledWithName": "{0} ble installert", "PluginUninstalledWithName": "{0} ble avinstallert", "PluginUpdatedWithName": "{0} ble oppdatert", - "ProviderValue": "Leverandør: {0}", "ScheduledTaskFailedWithName": "{0} mislykkes", - "ScheduledTaskStartedWithName": "{0} startet", - "ServerNameNeedsToBeRestarted": "{0} må startes på nytt", "Shows": "Serier", - "Songs": "Sanger", "StartupEmbyServerIsLoading": "Jellyfin Server laster. Prøv igjen snart.", - "SubtitleDownloadFailureForItem": "En feil oppstå under nedlasting av undertekster for {0}", "SubtitleDownloadFailureFromForItem": "Kunne ikke laste ned undertekster fra {0} for {1}", - "Sync": "Synkroniser", - "System": "System", "TvShows": "TV-serier", - "User": "Bruker", "UserCreatedWithName": "Bruker {0} er opprettet", "UserDeletedWithName": "Bruker {0} har blitt slettet", "UserDownloadingItemWithValues": "{0} laster ned {1}", @@ -86,11 +59,8 @@ "UserOfflineFromDevice": "{0} har koblet fra {1}", "UserOnlineFromDevice": "{0} er tilkoblet fra {1}", "UserPasswordChangedWithName": "Passordet for {0} er oppdatert", - "UserPolicyUpdatedWithName": "Brukerretningslinjene har blitt oppdatert for {0}", "UserStartedPlayingItemWithValues": "{0} har startet avspilling {1} på {2}", "UserStoppedPlayingItemWithValues": "{0} har stoppet avspilling {1}", - "ValueHasBeenAddedToLibrary": "{0} har blitt lagt til i mediebiblioteket ditt", - "ValueSpecialEpisodeName": "Spesialepisode - {0}", "VersionNumber": "Versjon {0}", "TasksChannelsCategory": "Internettkanaler", "TasksApplicationCategory": "Applikasjon", @@ -127,10 +97,8 @@ "HearingImpaired": "Hørselshemmet", "TaskRefreshTrickplayImages": "Generer Trickplay bilder", "TaskRefreshTrickplayImagesDescription": "Oppretter trickplay-forhåndsvisninger for videoer i aktiverte biblioteker.", - "TaskCleanCollectionsAndPlaylists": "Rydd kolleksjoner og spillelister", "TaskAudioNormalization": "Lydnormalisering", "TaskAudioNormalizationDescription": "Skan filer for lydnormaliserende data.", - "TaskCleanCollectionsAndPlaylistsDescription": "Fjerner elementer fra kolleksjoner og spillelister som ikke lengere finnes.", "TaskDownloadMissingLyrics": "Last ned manglende tekster", "TaskDownloadMissingLyricsDescription": "Last ned sangtekster", "TaskExtractMediaSegments": "Skann mediasegment", @@ -138,5 +106,15 @@ "TaskMoveTrickplayImagesDescription": "Flytter eksisterende Trickplay-filer i henhold til biblioteksinstillingene.", "TaskExtractMediaSegmentsDescription": "Trekker ut eller henter mediasegmenter fra plugins som støtter MediaSegment.", "CleanupUserDataTaskDescription": "Sletter all brukerdata (avspillings-status, favoritter osv.) fra innhold som har vært utilgjengelig i minst 90 dager.", - "CleanupUserDataTask": "Oppgave for opprydding av brukerdata" + "CleanupUserDataTask": "Oppgave for opprydding av brukerdata", + "NameExtraBehindTheScenes": "Bak kulissene", + "NameExtraDeletedScene": "Slettet scene", + "NameExtraFeaturette": "Presentasjonsfilm", + "NameExtraInterview": "Intervju", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Prøve", + "NameExtraScene": "Scene", + "NameExtraThemeSong": "Tema-låt", + "NameExtraThemeVideo": "Tema-video", + "NameExtraTrailer": "Trailer" } diff --git a/Emby.Server.Implementations/Localization/Core/ne.json b/Emby.Server.Implementations/Localization/Core/ne.json index 7c6b08fb36..ad6070c4c3 100644 --- a/Emby.Server.Implementations/Localization/Core/ne.json +++ b/Emby.Server.Implementations/Localization/Core/ne.json @@ -21,64 +21,39 @@ "Music": "संगीत", "Movies": "चलचित्रहरू", "MixedContent": "मिश्रित सामग्री", - "MessageServerConfigurationUpdated": "सर्भर कन्फिगरेसन अद्यावधिक गरिएको छ", - "MessageNamedServerConfigurationUpdatedWithValue": "सर्भर कन्फिगरेसन विभाग {0} अद्यावधिक गरिएको छ", - "MessageApplicationUpdatedTo": "जेलीफिन सर्भर {0} मा अद्यावधिक गरिएको छ", - "MessageApplicationUpdated": "जेलीफिन सर्भर अपडेट गरिएको छ", "Latest": "नविनतम", "LabelRunningTimeValue": "कुल समय: {0}", "LabelIpAddressValue": "आईपी ठेगाना: {0}", - "ItemRemovedWithName": "{0}लाई पुस्तकालयबाट हटाईयो", - "ItemAddedWithName": "{0} लाईब्रेरीमा थपियो", "Inherit": "उत्तराधिकार", "HomeVideos": "घरेलु भिडियोहरू", - "HeaderRecordingGroups": "रेकर्ड समूहहरू", "HeaderNextUp": "आगामी", "HeaderLiveTV": "प्रत्यक्ष टिभी", - "HeaderFavoriteSongs": "मनपर्ने गीतहरू", "HeaderFavoriteShows": "मनपर्ने कार्यक्रमहरू", "HeaderFavoriteEpisodes": "मनपर्ने एपिसोडहरू", - "HeaderFavoriteArtists": "मनपर्ने कलाकारहरू", - "HeaderFavoriteAlbums": "मनपर्ने एल्बमहरू", "HeaderContinueWatching": "हेर्न जारी राख्नुहोस्", - "HeaderAlbumArtists": "एल्बमका कलाकारहरू", "Genres": "विधाहरू", "Folders": "फोल्डरहरू", "Favorites": "मनपर्ने", - "FailedLoginAttemptWithUserName": "{0}को लग इन प्रयास असफल", - "DeviceOnlineWithName": "{0}को साथ जडित", - "DeviceOfflineWithName": "{0}बाट विच्छेदन भयो", + "FailedLoginAttemptWithUserName": "असफल लग इन प्रयास {0} देखि", "Collections": "संग्रह", "ChapterNameValue": "अध्याय {0}", - "Channels": "च्यानलहरू", "AppDeviceValues": "अनुप्रयोग: {0}, उपकरण: {1}", "AuthenticationSucceededWithUserName": "{0} सफलतापूर्वक प्रमाणीकरण गरियो", - "CameraImageUploadedFrom": "{0}बाट नयाँ क्यामेरा छवि अपलोड गरिएको छ", "Books": "पुस्तकहरु", "Artists": "कलाकारहरू", - "Application": "अनुप्रयोगहरू", - "Albums": "एल्बमहरू", "TasksLibraryCategory": "पुस्तकालय", "TasksApplicationCategory": "अनुप्रयोग", "TasksMaintenanceCategory": "मर्मत", - "UserPolicyUpdatedWithName": "प्रयोगकर्ता नीति को लागी अद्यावधिक गरिएको छ {0}", "UserPasswordChangedWithName": "पासवर्ड प्रयोगकर्ताका लागि परिवर्तन गरिएको छ {0}", "UserOnlineFromDevice": "{0} बाट अनलाइन छ {1}", "UserOfflineFromDevice": "{0} बाट विच्छेदन भएको छ {1}", "UserLockedOutWithName": "प्रयोगकर्ता {0} लक गरिएको छ", "UserDeletedWithName": "प्रयोगकर्ता {0} हटाइएको छ", "UserCreatedWithName": "प्रयोगकर्ता {0} सिर्जना गरिएको छ", - "User": "प्रयोगकर्ता", "PluginInstalledWithName": "{0} सभएको थियो", "StartupEmbyServerIsLoading": "Jellyfin सर्भर लोड हुँदैछ। कृपया छिट्टै फेरि प्रयास गर्नुहोस्।", - "Songs": "गीतहरू", "Shows": "शोहरू", - "ServerNameNeedsToBeRestarted": "{0} लाई पुन: सुरु गर्नु पर्छ", - "ScheduledTaskStartedWithName": "{0} सुरु भयो", "ScheduledTaskFailedWithName": "{0} असफल", - "ProviderValue": "प्रदायक: {0}", - "Plugin": "प्लगइनहरू", - "Playlists": "प्लेलिस्टहरू", "Photos": "तस्बिरहरु", "NotificationOptionVideoPlaybackStopped": "भिडियो प्लेब्याक रोकियो", "NotificationOptionVideoPlayback": "भिडियो प्लेब्याक सुरु भयो", @@ -98,15 +73,11 @@ "TaskCleanActivityLog": "गतिविधि लग सफा गर्नुहोस्", "TasksChannelsCategory": "इन्टरनेट च्यानलहरू", "VersionNumber": "संस्करण {0}", - "ValueSpecialEpisodeName": "विशेष - {0}", - "ValueHasBeenAddedToLibrary": "{0} तपाईंको मिडिया लाइब्रेरीमा थपिएको छ", "UserStoppedPlayingItemWithValues": "{2} मा {0} हेरिसकेको छ{1}", "UserStartedPlayingItemWithValues": "{0} हेर्दै {1} मा {2}", "UserDownloadingItemWithValues": "{0} डाउनलोड गर्दै छ {1}", "Undefined": "अपरिभाषित", "TvShows": "टेलिभिजन कार्यक्रमहरू", - "System": "प्रणाली", - "Sync": "समकालीन", "SubtitleDownloadFailureFromForItem": "उपशीर्षकहरू {0} बाट {1} को लागि डाउनलोड गर्न असफल", "PluginUpdatedWithName": "{0} अद्यावधिक गरिएको थियो", "PluginUninstalledWithName": "{0} को स्थापना रद्द गरिएको थियो", diff --git a/Emby.Server.Implementations/Localization/Core/nl.json b/Emby.Server.Implementations/Localization/Core/nl.json index 09246bd110..28ac66e70d 100644 --- a/Emby.Server.Implementations/Localization/Core/nl.json +++ b/Emby.Server.Implementations/Localization/Core/nl.json @@ -1,41 +1,23 @@ { - "Albums": "Albums", "AppDeviceValues": "App: {0}, Apparaat: {1}", - "Application": "Applicatie", "Artists": "Artiesten", "AuthenticationSucceededWithUserName": "{0} is succesvol geauthenticeerd", "Books": "Boeken", - "CameraImageUploadedFrom": "Nieuwe camera-afbeelding toegevoegd vanaf {0}", - "Channels": "Kanalen", "ChapterNameValue": "Hoofdstuk {0}", "Collections": "Collecties", - "DeviceOfflineWithName": "Verbinding met {0} is verbroken", - "DeviceOnlineWithName": "{0} is verbonden", "FailedLoginAttemptWithUserName": "Mislukte aanmeldpoging van {0}", "Favorites": "Favorieten", "Folders": "Mappen", - "Genres": "Genres", - "HeaderAlbumArtists": "Albumartiesten", "HeaderContinueWatching": "Verderkijken", - "HeaderFavoriteAlbums": "Favoriete albums", - "HeaderFavoriteArtists": "Favoriete artiesten", "HeaderFavoriteEpisodes": "Favoriete afleveringen", "HeaderFavoriteShows": "Favoriete series", - "HeaderFavoriteSongs": "Favoriete nummers", "HeaderLiveTV": "Live-tv", - "HeaderNextUp": "Als volgende", - "HeaderRecordingGroups": "Opnamegroepen", + "HeaderNextUp": "Volgende", "HomeVideos": "Homevideo's", - "Inherit": "Erven", - "ItemAddedWithName": "{0} is toegevoegd aan de bibliotheek", - "ItemRemovedWithName": "{0} is verwijderd uit de bibliotheek", + "Inherit": "Overnemen", "LabelIpAddressValue": "IP-adres: {0}", "LabelRunningTimeValue": "Looptijd: {0}", "Latest": "Nieuwste", - "MessageApplicationUpdated": "Jellyfin Server is bijgewerkt", - "MessageApplicationUpdatedTo": "Jellyfin Server is bijgewerkt naar {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Sectie {0} van de serverconfiguratie is bijgewerkt", - "MessageServerConfigurationUpdated": "Serverconfiguratie is bijgewerkt", "MixedContent": "Gemengde inhoud", "Movies": "Films", "Music": "Muziek", @@ -61,24 +43,14 @@ "NotificationOptionVideoPlayback": "Afspelen van video gestart", "NotificationOptionVideoPlaybackStopped": "Afspelen van video gestopt", "Photos": "Foto's", - "Playlists": "Afspeellijsten", - "Plugin": "Plug-in", "PluginInstalledWithName": "{0} is geïnstalleerd", "PluginUninstalledWithName": "{0} is verwijderd", "PluginUpdatedWithName": "{0} is bijgewerkt", - "ProviderValue": "Aanbieder: {0}", "ScheduledTaskFailedWithName": "{0} is mislukt", - "ScheduledTaskStartedWithName": "{0} is gestart", - "ServerNameNeedsToBeRestarted": "{0} moet herstart worden", "Shows": "Series", - "Songs": "Nummers", "StartupEmbyServerIsLoading": "Jellyfin Server is aan het laden. Probeer het later opnieuw.", - "SubtitleDownloadFailureForItem": "Downloaden van ondertiteling voor {0} is mislukt", "SubtitleDownloadFailureFromForItem": "Ondertiteling kon niet gedownload worden van {0} voor {1}", - "Sync": "Synchronisatie", - "System": "Systeem", "TvShows": "Tv-series", - "User": "Gebruiker", "UserCreatedWithName": "Gebruiker {0} is aangemaakt", "UserDeletedWithName": "Gebruiker {0} is verwijderd", "UserDownloadingItemWithValues": "{0} downloadt {1}", @@ -86,11 +58,8 @@ "UserOfflineFromDevice": "Verbinding van {0} via {1} is verbroken", "UserOnlineFromDevice": "{0} is verbonden via {1}", "UserPasswordChangedWithName": "Wachtwoord voor {0} is gewijzigd", - "UserPolicyUpdatedWithName": "Gebruikersbeleid gewijzigd voor {0}", "UserStartedPlayingItemWithValues": "{0} speelt {1} af op {2}", "UserStoppedPlayingItemWithValues": "{0} heeft afspelen van {1} gestopt op {2}", - "ValueHasBeenAddedToLibrary": "{0} is toegevoegd aan je mediabibliotheek", - "ValueSpecialEpisodeName": "Special - {0}", "VersionNumber": "Versie {0}", "TaskDownloadMissingSubtitlesDescription": "Zoekt op het internet naar ontbrekende ondertiteling gebaseerd op metadataconfiguratie.", "TaskDownloadMissingSubtitles": "Ontbrekende ondertiteling downloaden", @@ -117,7 +86,7 @@ "TaskCleanActivityLogDescription": "Verwijdert activiteitenlogs ouder dan de ingestelde leeftijd.", "TaskCleanActivityLog": "Activiteitenlogboek legen", "Undefined": "Niet gedefinieerd", - "Forced": "Gedwongen", + "Forced": "Geforceerd", "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", @@ -127,8 +96,6 @@ "HearingImpaired": "Slechthorenden", "TaskRefreshTrickplayImages": "Trickplay-afbeeldingen genereren", "TaskRefreshTrickplayImagesDescription": "Creëert trickplay-voorvertoningen voor video's in bibliotheken waarvoor dit is ingeschakeld.", - "TaskCleanCollectionsAndPlaylists": "Collecties en afspeellijsten opruimen", - "TaskCleanCollectionsAndPlaylistsDescription": "Verwijdert niet langer bestaande items uit collecties en afspeellijsten.", "TaskAudioNormalization": "Geluidsnormalisatie", "TaskAudioNormalizationDescription": "Scant bestanden op gegevens voor geluidsnormalisatie.", "TaskDownloadMissingLyrics": "Ontbrekende liedteksten downloaden", @@ -138,5 +105,21 @@ "TaskMoveTrickplayImagesDescription": "Verplaatst bestaande trickplay-bestanden op basis van de bibliotheekinstellingen.", "TaskExtractMediaSegments": "Scannen op mediasegmenten", "CleanupUserDataTaskDescription": "Wist alle gebruikersgegevens (kijkstatus, favorieten, etc.) van media die al minstens 90 dagen niet meer aanwezig zijn.", - "CleanupUserDataTask": "Opruimtaak gebruikersdata" + "CleanupUserDataTask": "Opruimtaak gebruikersdata", + "Genres": "Genres", + "Original": "Oorspronkelijk", + "LyricDownloadFailureFromForItem": "Downloaden van liedteksten voor {1} van {0} mislukt", + "NameExtraBehindTheScenes": "Achter de schermen", + "NameExtraClip": "Clip", + "NameExtraDeletedScene": "Geschrapte scène", + "NameExtraFeaturette": "Featurette", + "NameExtraInterview": "Vraaggesprek", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Voorbeeldfragment", + "NameExtraScene": "Scène", + "NameExtraShort": "Korte film", + "NameExtraThemeSong": "Themamuziek", + "NameExtraThemeVideo": "Themavideo", + "NameExtraTrailer": "Trailer", + "NameExtraUnknown": "Extra inhoud" } diff --git a/Emby.Server.Implementations/Localization/Core/nn.json b/Emby.Server.Implementations/Localization/Core/nn.json index feb5fe2154..8c5da8c1de 100644 --- a/Emby.Server.Implementations/Localization/Core/nn.json +++ b/Emby.Server.Implementations/Localization/Core/nn.json @@ -1,41 +1,24 @@ { - "MessageServerConfigurationUpdated": "Tenarkonfigurasjonen har blitt oppdatert", - "MessageNamedServerConfigurationUpdatedWithValue": "Tenar konfigurasjon seksjon {0} har blitt oppdatert", - "MessageApplicationUpdatedTo": "Jellyfin-tenaren har blitt oppdatert til {0}", - "MessageApplicationUpdated": "Jellyfin-tenaren har blitt oppdatert", "Latest": "Nyaste", "LabelRunningTimeValue": "Speletid: {0}", "LabelIpAddressValue": "IP-adresse: {0}", - "ItemRemovedWithName": "{0} vart fjerna frå biblioteket", - "ItemAddedWithName": "{0} vart lagt til i biblioteket", "Inherit": "Arve", "HomeVideos": "Heimevideoar", - "HeaderRecordingGroups": "Innspelingsgrupper", "HeaderNextUp": "Neste", "HeaderLiveTV": "Direkte TV", - "HeaderFavoriteSongs": "Favorittsongar", "HeaderFavoriteShows": "Favorittseriar", "HeaderFavoriteEpisodes": "Favorittepisodar", - "HeaderFavoriteArtists": "Favorittartistar", - "HeaderFavoriteAlbums": "Favorittalbum", "HeaderContinueWatching": "Fortsett å sjå", - "HeaderAlbumArtists": "Albumartist", "Genres": "Sjangrar", "Folders": "Mapper", "Favorites": "Favorittar", "FailedLoginAttemptWithUserName": "Mislukka påloggingsforsøk frå {0}", - "DeviceOnlineWithName": "{0} er tilkopla", - "DeviceOfflineWithName": "{0} har kopla frå", "Collections": "Samlingar", "ChapterNameValue": "Kapittel {0}", - "Channels": "Kanalar", - "CameraImageUploadedFrom": "Eit nytt kamerabilete har blitt lasta opp frå {0}", "Books": "Bøker", "AuthenticationSucceededWithUserName": "{0} har logga inn", "Artists": "Artistar", - "Application": "Program", "AppDeviceValues": "App: {0}, Eining: {1}", - "Albums": "Album", "NotificationOptionServerRestartRequired": "Tenaren krev omstart", "NotificationOptionPluginUpdateInstalled": "Programvaretilleggoppdatering vart installert", "NotificationOptionPluginUninstalled": "Programvaretillegg avinstallert", @@ -56,7 +39,6 @@ "Music": "Musikk", "Movies": "Filmar", "MixedContent": "Blanda innhald", - "Sync": "Synkroniser", "TaskDownloadMissingSubtitlesDescription": "Søk Internettet for manglande undertekstar basert på metadatainnstillingar.", "TaskDownloadMissingSubtitles": "Last ned manglande undertekstar", "TaskRefreshChannelsDescription": "Oppdater internettkanalinformasjon.", @@ -80,11 +62,8 @@ "TasksLibraryCategory": "Bibliotek", "TasksMaintenanceCategory": "Vedlikehald", "VersionNumber": "Versjon {0}", - "ValueSpecialEpisodeName": "Spesialepisode - {0}", - "ValueHasBeenAddedToLibrary": "{0} har blitt lagt til i mediebiblioteket ditt", "UserStoppedPlayingItemWithValues": "{0} har fullført avspeling {1} på {2}", "UserStartedPlayingItemWithValues": "{0} spelar {1} på {2}", - "UserPolicyUpdatedWithName": "Brukarreglar har blitt oppdatert for {0}", "UserPasswordChangedWithName": "Passordet for {0} er oppdatert", "UserOnlineFromDevice": "{0} er direktekopla frå {1}", "UserOfflineFromDevice": "{0} har kopla frå {1}", @@ -92,22 +71,14 @@ "UserDownloadingItemWithValues": "{0} lastar ned {1}", "UserDeletedWithName": "Brukar {0} er sletta", "UserCreatedWithName": "Brukar {0} er oppretta", - "User": "Brukar", "TvShows": "TV-seriar", - "System": "System", "SubtitleDownloadFailureFromForItem": "Feila å laste ned undertekstar frå {0} for {1}", "StartupEmbyServerIsLoading": "Jellyfin-tenaren laster. Prøv igjen seinare.", - "Songs": "Sangar", "Shows": "Seriar", - "ServerNameNeedsToBeRestarted": "{0} må omstartast", - "ScheduledTaskStartedWithName": "{0} starta", "ScheduledTaskFailedWithName": "{0} feila", - "ProviderValue": "Leverandør: {0}", "PluginUpdatedWithName": "{0} blei oppdatert", "PluginUninstalledWithName": "{0} blei avinstallert", "PluginInstalledWithName": "{0} blei installert", - "Plugin": "Programvaretillegg", - "Playlists": "Spelelister", "Photos": "Bilete", "NotificationOptionVideoPlaybackStopped": "Videoavspeling stoppa", "NotificationOptionVideoPlayback": "Videoavspeling starta", diff --git a/Emby.Server.Implementations/Localization/Core/oc.json b/Emby.Server.Implementations/Localization/Core/oc.json new file mode 100644 index 0000000000..4f573b3c58 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/oc.json @@ -0,0 +1,27 @@ +{ + "AppDeviceValues": "Aplicacion: {0}, Periferic: {1}", + "Books": "Libres", + "Artists": "Artistas", + "Collections": "Collecciones", + "ChapterNameValue": "Capitol {0}", + "External": "Extèrn", + "Folders": "Dorsièrs", + "Favorites": "Favorits", + "HeaderContinueWatching": "Contunhar de regardar", + "HeaderFavoriteEpisodes": "Episòdis Favorits", + "AuthenticationSucceededWithUserName": "{0} autentificat amb succès", + "HeaderFavoriteShows": "Serias Favoritas", + "HeaderLiveTV": "TV en dirècte", + "HeaderNextUp": "Seguent", + "HearingImpaired": "Amb de deficiéncias auditivas", + "Movies": "Filmes", + "Music": "Musica", + "Latest": "Darrièr", + "Forced": "Forçat", + "Default": "Defaut", + "Genres": "Genres", + "HomeVideos": "Vidèos d'Acuèlh", + "Inherit": "Eiretar", + "LabelIpAddressValue": "Adreça IP: {0}", + "LabelRunningTimeValue": "Temps d'execucion : {0}" +} diff --git a/Emby.Server.Implementations/Localization/Core/or.json b/Emby.Server.Implementations/Localization/Core/or.json index 8251c12907..febc98d1a4 100644 --- a/Emby.Server.Implementations/Localization/Core/or.json +++ b/Emby.Server.Implementations/Localization/Core/or.json @@ -1,11 +1,8 @@ { "External": "ବହିଃସ୍ଥ", "Genres": "ଧରଣ", - "Albums": "ଆଲବମଗୁଡ଼ିକ", "Artists": "କଳାକାରଗୁଡ଼ିକ", - "Application": "ଆପ୍ଲିକେସନ", "Books": "ବହିଗୁଡ଼ିକ", - "Channels": "ଚ୍ୟାନେଲଗୁଡ଼ିକ", "ChapterNameValue": "ବିଭାଗ {0}", "Collections": "ସଂଗ୍ରହଗୁଡ଼ିକ", "Folders": "ଫୋଲ୍ଡରଗୁଡ଼ିକ" diff --git a/Emby.Server.Implementations/Localization/Core/pa.json b/Emby.Server.Implementations/Localization/Core/pa.json index 6062d97003..e609ad52c8 100644 --- a/Emby.Server.Implementations/Localization/Core/pa.json +++ b/Emby.Server.Implementations/Localization/Core/pa.json @@ -24,11 +24,8 @@ "TasksLibraryCategory": "ਲਾਇਬ੍ਰੇਰੀ", "TasksMaintenanceCategory": "ਰੱਖ-ਰਖਾਅ", "VersionNumber": "ਵਰਜਨ {0}", - "ValueSpecialEpisodeName": "ਖਾਸ - {0}", - "ValueHasBeenAddedToLibrary": "{0} ਤੁਹਾਡੀ ਮੀਡੀਆ ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਸ਼ਾਮਲ ਕੀਤਾ ਗਿਆ ਹੈ", "UserStoppedPlayingItemWithValues": "{0} ਨੇ {2} 'ਤੇ {1} ਖੇਡਣਾ ਪੂਰਾ ਕਰ ਲਿਆ ਹੈ", "UserStartedPlayingItemWithValues": "{0} {2} 'ਤੇ {1} ਖੇਡ ਰਿਹਾ ਹੈ", - "UserPolicyUpdatedWithName": "ਵਰਤੋਂਕਾਰ ਨੀਤੀ ਨੂੰ {0} ਲਈ ਅਪਡੇਟ ਕੀਤਾ ਗਿਆ ਹੈ", "UserPasswordChangedWithName": "{0} ਵਰਤੋਂਕਾਰ ਲਈ ਪਾਸਵਰਡ ਬਦਲਿਆ ਗਿਆ ਸੀ", "UserOnlineFromDevice": "{0} ਨੂੰ {1} ਤੋਂ ਆਨਲਾਈਨ ਹੈ", "UserOfflineFromDevice": "{0} ਤੋਂ ਡਿਸਕਨੈਕਟ ਹੋ ਗਿਆ ਹੈ {1}", @@ -36,24 +33,15 @@ "UserDownloadingItemWithValues": "{0} {1} ਨੂੰ ਡਾਊਨਲੋਡ ਕਰ ਰਿਹਾ ਹੈ", "UserDeletedWithName": "ਵਰਤੋਂਕਾਰ {0} ਨੂੰ ਹਟਾਇਆ ਗਿਆ", "UserCreatedWithName": "ਵਰਤੋਂਕਾਰ {0} ਬਣਾਇਆ ਗਿਆ ਹੈ", - "User": "ਵਰਤੋਂਕਾਰ", "Undefined": "ਪਰਿਭਾਸ਼ਤ", "TvShows": "ਟੀਵੀ ਸ਼ੋਅ", - "System": "ਸਿਸਟਮ", - "Sync": "ਸਿੰਕ", "SubtitleDownloadFailureFromForItem": "ਉਪਸਿਰਲੇਖ {1} ਲਈ {0} ਤੋਂ ਡਾਊਨਲੋਡ ਕਰਨ ਵਿੱਚ ਅਸਫਲ ਰਹੇ", "StartupEmbyServerIsLoading": "Jellyfin ਸਰਵਰ ਲੋਡ ਹੋ ਰਿਹਾ ਹੈ। ਛੇਤੀ ਹੀ ਫ਼ੇਰ ਕੋਸ਼ਿਸ਼ ਕਰੋ।", - "Songs": "ਗਾਣੇ", "Shows": "ਸ਼ੋਅ", - "ServerNameNeedsToBeRestarted": "{0} ਮੁੜ ਚਾਲੂ ਕਰਨ ਦੀ ਲੋੜ ਹੈ", - "ScheduledTaskStartedWithName": "{0} ਸ਼ੁਰੂ ਹੋਇਆ", "ScheduledTaskFailedWithName": "{0} ਅਸਫਲ", - "ProviderValue": "ਦੇਣ ਵਾਲੇ: {0}", "PluginUpdatedWithName": "{0} ਅਪਡੇਟ ਕੀਤਾ ਗਿਆ ਸੀ", "PluginUninstalledWithName": "{0} ਅਣਇੰਸਟੌਲ ਕੀਤਾ ਗਿਆ ਸੀ", "PluginInstalledWithName": "{0} ਲਗਾਇਆ ਗਿਆ ਸੀ", - "Plugin": "ਪਲੱਗਇਨ", - "Playlists": "ਪਲੇਸੂਚੀਆਂ", "Photos": "ਫੋਟੋਆਂ", "NotificationOptionVideoPlaybackStopped": "ਵੀਡੀਓ ਪਲੇਬੈਕ ਰੋਕਿਆ ਗਿਆ", "NotificationOptionVideoPlayback": "ਵੀਡੀਓ ਪਲੇਬੈਕ ਸ਼ੁਰੂ ਹੋਇਆ", @@ -79,45 +67,28 @@ "Music": "ਸੰਗੀਤ", "Movies": "ਫਿਲਮਾਂ", "MixedContent": "ਮਿਸ਼ਰਤ ਸਮੱਗਰੀ", - "MessageServerConfigurationUpdated": "ਸਰਵਰ ਕੌਂਫਿਗਰੇਸ਼ਨ ਨੂੰ ਅਪਡੇਟ ਕੀਤਾ ਗਿਆ ਹੈ", - "MessageNamedServerConfigurationUpdatedWithValue": "ਸਰਵਰ ਕੌਂਫਿਗਰੇਸ਼ਨ ਸੈਕਸ਼ਨ {0} ਅਪਡੇਟ ਕੀਤਾ ਗਿਆ ਹੈ", - "MessageApplicationUpdatedTo": "ਜੈਲੀਫਿਨ ਸਰਵਰ ਨੂੰ ਅੱਪਡੇਟ ਕੀਤਾ ਗਿਆ ਹੈ {0}", - "MessageApplicationUpdated": "ਜੈਲੀਫਿਨ ਸਰਵਰ ਅਪਡੇਟ ਕੀਤਾ ਗਿਆ ਹੈ", "Latest": "ਤਾਜ਼ਾ", "LabelRunningTimeValue": "ਚੱਲਦਾ ਸਮਾਂ: {0}", "LabelIpAddressValue": "IP ਪਤਾ: {0}", - "ItemRemovedWithName": "{0} ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚੋਂ ਹਟਾ ਦਿੱਤਾ ਗਿਆ ਸੀ", - "ItemAddedWithName": "{0} ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਸ਼ਾਮਲ ਕੀਤਾ ਗਿਆ ਸੀ", "Inherit": "ਵਿਰਾਸਤ", "HomeVideos": "ਘਰੇਲੂ ਵੀਡੀਓ", - "HeaderRecordingGroups": "ਰਿਕਾਰਡਿੰਗ ਸਮੂਹ", "HeaderNextUp": "ਅੱਗੇ", "HeaderLiveTV": "ਲਾਈਵ ਟੀਵੀ", - "HeaderFavoriteSongs": "ਮਨਪਸੰਦ ਗਾਣੇ", "HeaderFavoriteShows": "ਮਨਪਸੰਦ ਸ਼ੋਅ", "HeaderFavoriteEpisodes": "ਮਨਪਸੰਦ ਐਪੀਸੋਡ", - "HeaderFavoriteArtists": "ਮਨਪਸੰਦ ਕਲਾਕਾਰ", - "HeaderFavoriteAlbums": "ਮਨਪਸੰਦ ਐਲਬਮ", "HeaderContinueWatching": "ਵੇਖਣਾ ਜਾਰੀ ਰੱਖੋ", - "HeaderAlbumArtists": "ਐਲਬਮ ਕਲਾਕਾਰ", "Genres": "ਸ਼ੈਲੀਆਂ", "Forced": "ਮਜਬੂਰ", "Folders": "ਫੋਲਡਰ", "Favorites": "ਮਨਪਸੰਦ", "FailedLoginAttemptWithUserName": "{0} ਤੋਂ ਲਾਗਇਨ ਦੀ ਕੋਸ਼ਿਸ਼ ਫੇਲ ਹੋਈ", - "DeviceOnlineWithName": "{0} ਜੁੜਿਆ ਹੋਇਆ ਹੈ", - "DeviceOfflineWithName": "{0} ਡਿਸਕਨੈਕਟ ਹੋ ਗਿਆ ਹੈ", "Default": "ਡਿਫੌਲਟ", "Collections": "ਸੰਗ੍ਰਹਿਣ", "ChapterNameValue": "ਚੈਪਟਰ {0}", - "Channels": "ਚੈਨਲ", - "CameraImageUploadedFrom": "{0} ਤੋਂ ਇੱਕ ਨਵਾਂ ਕੈਮਰਾ ਚਿੱਤਰ ਅਪਲੋਡ ਕੀਤਾ ਗਿਆ ਹੈ", "Books": "ਕਿਤਾਬਾਂ", "AuthenticationSucceededWithUserName": "{0} ਸਫਲਤਾਪੂਰਕ ਪ੍ਰਮਾਣਿਤ", "Artists": "ਕਲਾਕਾਰ", - "Application": "ਐਪਲੀਕੇਸ਼ਨ", "AppDeviceValues": "ਐਪ: {0}, ਜੰਤਰ: {1}", - "Albums": "ਐਲਬਮਾਂ", "TaskOptimizeDatabase": "ਡਾਟਾਬੇਸ ਅਨੁਕੂਲ ਬਣਾਓ", "External": "ਬਾਹਰੀ", "HearingImpaired": "ਸੁਨਣ ਵਿਚ ਕਮਜ਼ੋਰ", @@ -131,9 +102,9 @@ "TaskDownloadMissingLyrics": "ਅਧੂਰੇ ਬੋਲ ਡਾਊਨਲੋਡ ਕਰੋ", "TaskDownloadMissingLyricsDescription": "ਗੀਤਾਂ ਲਈ ਡਾਊਨਲੋਡ ਕਿਤੇ ਬੋਲ", "TaskKeyframeExtractor": "ਕੀ-ਫ੍ਰੇਮ ਐਕਸਟ੍ਰੈਕਟਰ", - "TaskCleanCollectionsAndPlaylistsDescription": "ਕਲੈਕਸ਼ਨਾਂ ਅਤੇ ਪਲੇਲਿਸਟਾਂ ਵਿੱਚੋਂ ਉਹ ਆਈਟਮ ਹਟਾਉਂਦਾ ਹੈ ਜੋ ਹੁਣ ਮੌਜੂਦ ਨਹੀਂ ਹਨ।", - "TaskCleanCollectionsAndPlaylists": "ਕਲੈਕਸ਼ਨਾਂ ਅਤੇ ਪਲੇਲਿਸਟਾਂ ਨੂੰ ਸਾਫ ਕਰੋ", "TaskAudioNormalization": "ਆਵਾਜ਼ ਸਧਾਰਣੀਕਰਨ", - "TaskRefreshTrickplayImagesDescription": "ਚਲ ਰਹੀ ਲਾਇਬ੍ਰੇਰੀਆਂ ਵਿੱਚ ਵੀਡੀਓਜ਼ ਲਈ ਟ੍ਰਿਕਪਲੇ ਪ੍ਰੀਵਿਊ ਬਣਾਉਂਦਾ ਹੈ।", - "TaskKeyframeExtractorDescription": "ਕੀ-ਫ੍ਰੇਮਜ਼ ਨੂੰ ਵੀਡੀਓ ਫਾਈਲਾਂ ਵਿੱਚੋਂ ਨਿਕਾਲਦਾ ਹੈ ਤਾਂ ਜੋ ਹੋਰ ਜ਼ਿਆਦਾ ਸਟਿਕ ਹੋਣ ਵਾਲੀਆਂ HLS ਪਲੇਲਿਸਟਾਂ ਬਣਾਈਆਂ ਜਾ ਸਕਣ। ਇਹ ਕੰਮ ਲੰਬੇ ਸਮੇਂ ਤੱਕ ਚੱਲ ਸਕਦਾ ਹੈ।" + "TaskRefreshTrickplayImagesDescription": "ਵੀਡੀਓ ਲਈ ਟ੍ਰਿਕਪਲੇ ਪ੍ਰੀਵਿਊ ਬਣਾਉਂਦਾ ਹੈ (ਜੇ ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਚੁਣਿਆ ਗਿਆ ਹੈ)।", + "TaskKeyframeExtractorDescription": "ਕੀ-ਫ੍ਰੇਮਜ਼ ਨੂੰ ਵੀਡੀਓ ਫਾਈਲਾਂ ਵਿੱਚੋਂ ਨਿਕਾਲਦਾ ਹੈ ਤਾਂ ਜੋ ਹੋਰ ਜ਼ਿਆਦਾ ਸਟਿਕ ਹੋਣ ਵਾਲੀਆਂ HLS ਪਲੇਲਿਸਟਾਂ ਬਣਾਈਆਂ ਜਾ ਸਕਣ। ਇਹ ਕੰਮ ਲੰਬੇ ਸਮੇਂ ਤੱਕ ਚੱਲ ਸਕਦਾ ਹੈ।", + "CleanupUserDataTaskDescription": "ਘੱਟੋ-ਘੱਟ 90 ਦਿਨਾਂ ਤੋਂ ਮੌਜੂਦ ਨਾ ਹੋਣ ਵਾਲੇ ਮੀਡੀਆ ਤੋਂ ਸਾਰੇ ਉਪਭੋਗਤਾ ਡੇਟਾ (ਵਾਚ ਸਟੇਟ, ਮਨਪਸੰਦ ਸਟੇਟਸ ਆਦਿ) ਨੂੰ ਸਾਫ਼ ਕਰਦਾ ਹੈ।", + "CleanupUserDataTask": "ਯੂਜ਼ਰ ਡਾਟਾ ਸਾਫ਼ ਕਰਨ ਦਾ ਕੰਮ" } diff --git a/Emby.Server.Implementations/Localization/Core/pl.json b/Emby.Server.Implementations/Localization/Core/pl.json index 3555ea4ae8..71909fd73a 100644 --- a/Emby.Server.Implementations/Localization/Core/pl.json +++ b/Emby.Server.Implementations/Localization/Core/pl.json @@ -1,41 +1,24 @@ { - "Albums": "Albumy", "AppDeviceValues": "Aplikacja: {0}, Urządzenie: {1}", - "Application": "Aplikacja", "Artists": "Wykonawcy", "AuthenticationSucceededWithUserName": "{0} został pomyślnie uwierzytelniony", "Books": "Książki", - "CameraImageUploadedFrom": "Nowy obraz został przekazany z {0}", - "Channels": "Kanały", "ChapterNameValue": "Rozdział {0}", "Collections": "Kolekcje", - "DeviceOfflineWithName": "{0} został rozłączony", - "DeviceOnlineWithName": "{0} połączył się", "FailedLoginAttemptWithUserName": "Nieudana próba logowania przez {0}", "Favorites": "Ulubione", "Folders": "Foldery", "Genres": "Gatunki", - "HeaderAlbumArtists": "Wykonawcy albumów", "HeaderContinueWatching": "Kontynuuj odtwarzanie", - "HeaderFavoriteAlbums": "Ulubione albumy", - "HeaderFavoriteArtists": "Ulubieni wykonawcy", "HeaderFavoriteEpisodes": "Ulubione odcinki", "HeaderFavoriteShows": "Ulubione seriale", - "HeaderFavoriteSongs": "Ulubione utwory", "HeaderLiveTV": "Telewizja", "HeaderNextUp": "Do obejrzenia", - "HeaderRecordingGroups": "Grupy nagrań", "HomeVideos": "Nagrania domowe", "Inherit": "Dziedzicz", - "ItemAddedWithName": "{0} zostało dodane do biblioteki", - "ItemRemovedWithName": "{0} zostało usunięte z biblioteki", "LabelIpAddressValue": "Adres IP: {0}", "LabelRunningTimeValue": "Czas trwania: {0}", "Latest": "Ostatnio dodane", - "MessageApplicationUpdated": "Serwer Jellyfin został zaktualizowany", - "MessageApplicationUpdatedTo": "Serwer Jellyfin został zaktualizowany do wersji {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Sekcja {0} konfiguracji serwera została zaktualizowana", - "MessageServerConfigurationUpdated": "Konfiguracja serwera została zaktualizowana", "MixedContent": "Zawartość mieszana", "Movies": "Filmy", "Music": "Muzyka", @@ -61,24 +44,14 @@ "NotificationOptionVideoPlayback": "Rozpoczęto odtwarzanie wideo", "NotificationOptionVideoPlaybackStopped": "Zatrzymano odtwarzanie wideo", "Photos": "Zdjęcia", - "Playlists": "Listy odtwarzania", - "Plugin": "Wtyczka", "PluginInstalledWithName": "{0} zostało zainstalowane", "PluginUninstalledWithName": "{0} odinstalowane", "PluginUpdatedWithName": "{0} zaktualizowane", - "ProviderValue": "Dostawca: {0}", "ScheduledTaskFailedWithName": "Nieudane {0}", - "ScheduledTaskStartedWithName": "Rozpoczęto {0}", - "ServerNameNeedsToBeRestarted": "{0} wymaga ponownego uruchomienia", "Shows": "Seriale", - "Songs": "Utwory", "StartupEmbyServerIsLoading": "Trwa wczytywanie serwera Jellyfin. Spróbuj ponownie za chwilę.", - "SubtitleDownloadFailureForItem": "Pobieranie napisów dla {0} zakończone niepowodzeniem", "SubtitleDownloadFailureFromForItem": "Nieudane pobieranie napisów z {0} dla {1}", - "Sync": "Synchronizacja", - "System": "System", "TvShows": "Seriale", - "User": "Użytkownik", "UserCreatedWithName": "Użytkownik {0} został utworzony", "UserDeletedWithName": "Użytkownik {0} został usunięty", "UserDownloadingItemWithValues": "{0} pobiera {1}", @@ -86,11 +59,8 @@ "UserOfflineFromDevice": "{0} z {1} został rozłączony", "UserOnlineFromDevice": "{0} połączył się z {1}", "UserPasswordChangedWithName": "Hasło użytkownika {0} zostało zmienione", - "UserPolicyUpdatedWithName": "Zmieniono zasady użytkowania dla {0}", "UserStartedPlayingItemWithValues": "{0} odtwarza {1} na {2}", "UserStoppedPlayingItemWithValues": "{0} zakończył odtwarzanie {1} na {2}", - "ValueHasBeenAddedToLibrary": "{0} został dodany do biblioteki mediów", - "ValueSpecialEpisodeName": "Specjalne - {0}", "VersionNumber": "Wersja {0}", "TaskDownloadMissingSubtitlesDescription": "Przeszukuje internet w poszukiwaniu brakujących napisów w oparciu o konfigurację metadanych.", "TaskDownloadMissingSubtitles": "Pobierz brakujące napisy", @@ -125,10 +95,8 @@ "TaskKeyframeExtractorDescription": "Wyodrębnia klatki kluczowe z plików wideo w celu utworzenia bardziej precyzyjnych list odtwarzania HLS. To zadanie może trwać przez długi czas.", "TaskKeyframeExtractor": "Ekstraktor klatek kluczowych", "HearingImpaired": "Niedosłyszący", - "TaskRefreshTrickplayImages": "Generuj obrazy trickplay", - "TaskRefreshTrickplayImagesDescription": "Tworzy podglądy trickplay dla filmów we włączonych bibliotekach.", - "TaskCleanCollectionsAndPlaylistsDescription": "Usuwa elementy z kolekcji i list odtwarzania, które już nie istnieją.", - "TaskCleanCollectionsAndPlaylists": "Oczyść kolekcje i listy odtwarzania", + "TaskRefreshTrickplayImages": "Generuj obrazy Trickplay", + "TaskRefreshTrickplayImagesDescription": "Tworzy podglądy Trickplay dla filmów we włączonych bibliotekach.", "TaskAudioNormalization": "Normalizacja dźwięku", "TaskAudioNormalizationDescription": "Skanuje pliki w poszukiwaniu danych normalizacji dźwięku.", "TaskDownloadMissingLyrics": "Pobierz brakujące słowa", @@ -138,5 +106,20 @@ "TaskExtractMediaSegmentsDescription": "Wyodrębnia lub pobiera segmenty mediów z wtyczek obsługujących MediaSegment.", "TaskMoveTrickplayImagesDescription": "Przenosi istniejące pliki Trickplay zgodnie z ustawieniami biblioteki.", "CleanupUserDataTaskDescription": "Usuwa wszystkie dane użytkownika (stan oglądanych, status ulubionych itp.) z mediów, które nie są dostępne od co najmniej 90 dni.", - "CleanupUserDataTask": "Zadanie czyszczenia danych użytkownika" + "CleanupUserDataTask": "Zadanie czyszczenia danych użytkownika", + "Original": "Oryginalny", + "LyricDownloadFailureFromForItem": "Błąd podczas pobierania tekstu piosenki z {0} dla {1}", + "NameExtraBehindTheScenes": "Za kulisami", + "NameExtraClip": "Urywek", + "NameExtraDeletedScene": "Usunięta scena", + "NameExtraFeaturette": "Film średniometrażowy", + "NameExtraInterview": "Wywiad", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Fragment", + "NameExtraScene": "Scena", + "NameExtraShort": "Film krótkometrażowy", + "NameExtraThemeSong": "Czołówka", + "NameExtraThemeVideo": "Wideo wprowadzające", + "NameExtraTrailer": "Zwiastun", + "NameExtraUnknown": "Dodatek" } diff --git a/Emby.Server.Implementations/Localization/Core/pr.json b/Emby.Server.Implementations/Localization/Core/pr.json index 9076b9c878..912f5c876a 100644 --- a/Emby.Server.Implementations/Localization/Core/pr.json +++ b/Emby.Server.Implementations/Localization/Core/pr.json @@ -2,48 +2,31 @@ "Books": "Scrolls", "AuthenticationSucceededWithUserName": "{0} passed yer trial", "Artists": "Artistas", - "Songs": "Shantees", - "Albums": "Tomes", "Photos": "Paintings", "NotificationOptionUserLockedOut": "Crewmate sent to the brig", "HeaderContinueWatching": "Continue Yer Journey", "Folders": "Chests", - "Application": "Captain", - "DeviceOnlineWithName": "{0} joined yer crew", - "DeviceOfflineWithName": "{0} abandoned ship", "AppDeviceValues": "Captain: {0}, Ship: {1}", - "CameraImageUploadedFrom": "Yer looking glass has glimpsed another painting from {0}", "Collections": "Barrels", - "ItemAddedWithName": "{0} is now with yer treasure", "Default": "Normal-like", - "FailedLoginAttemptWithUserName": "Ye failed to get in, try from {0}", + "FailedLoginAttemptWithUserName": "Ye failed to enter from {0}", "Favorites": "Finest Loot", - "ItemRemovedWithName": "{0} was taken from yer treasure", "LabelIpAddressValue": "Ship's coordinates: {0}", "Genres": "types o' booty", "TaskDownloadMissingSubtitlesDescription": "Scours the seven seas o' the internet for subtitles that be missin' based on the captain's map o' metadata.", - "HeaderAlbumArtists": "Buccaneers o' the musical arts", - "HeaderFavoriteAlbums": "Beloved booty o' musical adventures", - "HeaderFavoriteArtists": "Treasured scallywags o' the creative seas", - "Channels": "Channels", "Forced": "Pressed", "External": "Outboard", "HeaderFavoriteEpisodes": "Treasured Tales", "HeaderFavoriteShows": "Treasured Tales", "ChapterNameValue": "Piece {0}", - "HeaderFavoriteSongs": "Treasured Chimes", "HeaderNextUp": "Incoming", "HeaderLiveTV": "Scrying Glass", "HearingImpaired": "Hard o' Hearing", "LabelRunningTimeValue": "Journey duration: {0}", - "MessageApplicationUpdated": "Yer Map of the Seas has been scribbled", "HomeVideos": "Yer Onboard Booty", "MixedContent": "Jumbled loot", "Music": "Tunes", "NameInstallFailed": "Ye couldn't bring {0} aboard yer ship", - "MessageApplicationUpdatedTo": "Yer Map of the Seas has been scribbled with {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Yer Map Drawer has been rescribbled to {0}", - "MessageServerConfigurationUpdated": "Yer Map drawer has been rescribbled", "Inherit": "Carry on what be passed along", "Latest": "Newfangled", "Movies": "Moving pictures", @@ -55,17 +38,13 @@ "UserOfflineFromDevice": "{0} severed ties with {1}", "UserDownloadingItemWithValues": "{0} be haulin’ in {1}", "UserStartedPlayingItemWithValues": "{0} be playin’ {1} aboard {2}", - "ValueHasBeenAddedToLibrary": "{0} be stashed in yer treasure trove", "TaskCleanCacheDescription": "Wipes away cache cargo no longer called fer.", "TaskCleanLogsDescription": "Clears the logbook o’ entries older than {0} days.", "TaskRefreshPeopleDescription": "Refreshes the charts fer actors an’ directors in yer Treasure Trove.", "UserLockedOutWithName": "Matey {0} be denied boarding", "TaskAudioNormalization": "Steadyin’ the shanties", "TaskAudioNormalizationDescription": "Scans files fer shanty steadiyin’ data.", - "HeaderRecordingGroups": "Loggin' Groups", "MusicVideos": "Shanty films", - "Playlists": "Lists o’ plunder", - "Plugin": "Extra sail", "NotificationOptionVideoPlaybackStopped": "Video playback dropped anchor", "NameSeasonNumber": "Saga {0}", "NameSeasonUnknown": "Saga be Lost", @@ -87,23 +66,15 @@ "TaskRefreshPeople": "Freshen the Mateys", "PluginUninstalledWithName": "{0} sent t’ Davy Jones", "PluginUpdatedWithName": "{0} patched ‘n ready", - "ProviderValue": "Supplier o’ goods: {0}", - "ScheduledTaskStartedWithName": "{0} set sail", - "ServerNameNeedsToBeRestarted": "{0} be cravin’ a restart", "Shows": "Sagas", "SubtitleDownloadFailureFromForItem": "Subtitles be sunk fetchin’ from {0} fer {1}", - "Sync": "Match the tides", - "System": "The ship’s works", "TvShows": "TV Sagas", "Undefined": "Uncharted", - "User": "Matey", "UserCreatedWithName": "Matey {0} joined the crew", "UserDeletedWithName": "Matey {0} cast overboard", "UserOnlineFromDevice": "{0} be aboard ship from {1}", "UserPasswordChangedWithName": "New passphrase set fer Matey {0}", - "UserPolicyUpdatedWithName": "Ship rules be changed fer {0}", "UserStoppedPlayingItemWithValues": "{0} be done playin’ {1} on {2", - "ValueSpecialEpisodeName": "Special Tale – {0}", "VersionNumber": "Edition {0}", "TasksMaintenanceCategory": "Hull patchin’", "TasksLibraryCategory": "Treasure Trove", @@ -113,5 +84,10 @@ "TaskCleanCache": "Sweep the Cache Chest", "TaskRefreshChapterImages": "Claim chapter portraits", "TaskRefreshChapterImagesDescription": "Paints wee portraits fer videos that own chapters.", - "TaskRefreshLibrary": "Scan the Treasure Trove" + "TaskRefreshLibrary": "Scan the Treasure Trove", + "TasksChannelsCategory": "Channels o' thy Internet", + "TaskRefreshTrickplayImages": "Summon the picture tricks", + "TaskRefreshTrickplayImagesDescription": "Summons picture trick previews for videos in ye enabled book roost", + "TaskUpdatePlugins": "Resummon yer Plugins", + "TaskCleanTranscode": "Swab Ye Transcode Directory" } diff --git a/Emby.Server.Implementations/Localization/Core/pt-BR.json b/Emby.Server.Implementations/Localization/Core/pt-BR.json index dc5bff161a..997d534fea 100644 --- a/Emby.Server.Implementations/Localization/Core/pt-BR.json +++ b/Emby.Server.Implementations/Localization/Core/pt-BR.json @@ -1,41 +1,24 @@ { - "Albums": "Álbuns", "AppDeviceValues": "App: {0}, Dispositivo: {1}", - "Application": "Aplicativo", "Artists": "Artistas", "AuthenticationSucceededWithUserName": "{0} autenticado com sucesso", "Books": "Livros", - "CameraImageUploadedFrom": "Uma nova imagem da câmera foi enviada de {0}", - "Channels": "Canais", "ChapterNameValue": "Capítulo {0}", "Collections": "Coleções", - "DeviceOfflineWithName": "{0} se desconectou", - "DeviceOnlineWithName": "{0} se conectou", "FailedLoginAttemptWithUserName": "Falha na tentativa de login de {0}", "Favorites": "Favoritos", "Folders": "Pastas", "Genres": "Gêneros", - "HeaderAlbumArtists": "Artistas do Álbum", "HeaderContinueWatching": "Continuar assistindo", - "HeaderFavoriteAlbums": "Álbuns Favoritos", - "HeaderFavoriteArtists": "Artistas favoritos", "HeaderFavoriteEpisodes": "Episódios favoritos", "HeaderFavoriteShows": "Séries favoritas", - "HeaderFavoriteSongs": "Músicas favoritas", "HeaderLiveTV": "TV ao Vivo", "HeaderNextUp": "A Seguir", - "HeaderRecordingGroups": "Grupos de Gravação", "HomeVideos": "Vídeos caseiros", "Inherit": "Herdar", - "ItemAddedWithName": "{0} foi adicionado à biblioteca", - "ItemRemovedWithName": "{0} foi removido da biblioteca", "LabelIpAddressValue": "Endereço IP: {0}", "LabelRunningTimeValue": "Tempo de execução: {0}", "Latest": "Recentes", - "MessageApplicationUpdated": "Servidor Jellyfin atualizado", - "MessageApplicationUpdatedTo": "Servidor Jellyfin atualizado para {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "A seção {0} da configuração do servidor foi atualizada", - "MessageServerConfigurationUpdated": "A configuração do servidor foi atualizada", "MixedContent": "Conteúdo misto", "Movies": "Filmes", "Music": "Música", @@ -61,24 +44,14 @@ "NotificationOptionVideoPlayback": "Reprodução de vídeo iniciada", "NotificationOptionVideoPlaybackStopped": "Reprodução de vídeo parada", "Photos": "Fotos", - "Playlists": "Listas de Reprodução", - "Plugin": "Plugin", "PluginInstalledWithName": "{0} foi instalado", "PluginUninstalledWithName": "{0} foi desinstalado", "PluginUpdatedWithName": "{0} foi atualizado", - "ProviderValue": "Provedor: {0}", "ScheduledTaskFailedWithName": "{0} falhou", - "ScheduledTaskStartedWithName": "{0} iniciada", - "ServerNameNeedsToBeRestarted": "O servidor {0} precisa ser reiniciado", "Shows": "Séries", - "Songs": "Músicas", "StartupEmbyServerIsLoading": "O Servidor Jellyfin está carregando. Por favor, tente novamente mais tarde.", - "SubtitleDownloadFailureForItem": "Download de legendas falhou para {0}", "SubtitleDownloadFailureFromForItem": "Houve um problema ao baixar as legendas de {0} para {1}", - "Sync": "Sincronizar", - "System": "Sistema", "TvShows": "Séries", - "User": "Usuário", "UserCreatedWithName": "O usuário {0} foi criado", "UserDeletedWithName": "O usuário {0} foi excluído", "UserDownloadingItemWithValues": "{0} está baixando {1}", @@ -86,11 +59,8 @@ "UserOfflineFromDevice": "{0} se desconectou de {1}", "UserOnlineFromDevice": "{0} está online em {1}", "UserPasswordChangedWithName": "A senha foi alterada para o usuário {0}", - "UserPolicyUpdatedWithName": "A política de usuário foi atualizada para {0}", "UserStartedPlayingItemWithValues": "{0} está reproduzindo {1} em {2}", "UserStoppedPlayingItemWithValues": "{0} parou de reproduzir {1} em {2}", - "ValueHasBeenAddedToLibrary": "{0} foi adicionado à sua biblioteca de mídia", - "ValueSpecialEpisodeName": "Especial - {0}", "VersionNumber": "Versão {0}", "TaskDownloadMissingSubtitlesDescription": "Procurar na internet por legendas faltando baseado na configuração de metadados.", "TaskDownloadMissingSubtitles": "Baixar legendas que estão faltando", @@ -127,8 +97,6 @@ "HearingImpaired": "Deficiência Auditiva", "TaskRefreshTrickplayImages": "Gerar imagens Trickplay", "TaskRefreshTrickplayImagesDescription": "Cria prévias Trickplay para vídeos em bibliotecas em que o recurso está habilitado.", - "TaskCleanCollectionsAndPlaylists": "Limpe coleções e playlists", - "TaskCleanCollectionsAndPlaylistsDescription": "Remove itens de coleções e playlists que não existem mais.", "TaskAudioNormalization": "Normalização de áudio", "TaskAudioNormalizationDescription": "Examina os ficheiros em busca de dados de normalização de áudio.", "TaskDownloadMissingLyricsDescription": "Baixar letras para músicas", @@ -138,5 +106,20 @@ "TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos de mídia de plug-ins habilitados para MediaSegment.", "TaskMoveTrickplayImages": "Migrar o local da imagem do Trickplay", "CleanupUserDataTask": "Tarefa de limpeza de dados do usuário", - "CleanupUserDataTaskDescription": "Limpa todos os dados do usuário (estado de visualização, status de favorito, etc.) de mídias que não estão presentes por pelo menos 90 dias." + "CleanupUserDataTaskDescription": "Limpa todos os dados do usuário (estado de visualização, status de favorito, etc.) de mídias que não estão presentes por pelo menos 90 dias.", + "LyricDownloadFailureFromForItem": "Download das Letras falharam em {0} para o item {1}", + "Original": "Original", + "NameExtraBehindTheScenes": "Nos Bastidores", + "NameExtraClip": "Clipe", + "NameExtraDeletedScene": "cena Extra", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Trecho", + "NameExtraScene": "Cena", + "NameExtraShort": "Curta-metragem", + "NameExtraThemeSong": "Música Tema", + "NameExtraThemeVideo": "Vídeo de Abertura", + "NameExtraTrailer": "Trailer", + "NameExtraUnknown": "Extra", + "NameExtraFeaturette": "Nos Bastidores", + "NameExtraInterview": "Entrevista" } diff --git a/Emby.Server.Implementations/Localization/Core/pt-PT.json b/Emby.Server.Implementations/Localization/Core/pt-PT.json index f188822d6a..e71475afd8 100644 --- a/Emby.Server.Implementations/Localization/Core/pt-PT.json +++ b/Emby.Server.Implementations/Localization/Core/pt-PT.json @@ -1,142 +1,125 @@ { - "Albums": "Álbuns", "AppDeviceValues": "Aplicação: {0}, Dispositivo: {1}", - "Application": "Aplicação", "Artists": "Artistas", "AuthenticationSucceededWithUserName": "{0} autenticado com sucesso", "Books": "Livros", - "CameraImageUploadedFrom": "Uma nova imagem de câmara foi enviada a partir de {0}", - "Channels": "Canais", "ChapterNameValue": "Capítulo {0}", "Collections": "Coleções", - "DeviceOfflineWithName": "{0} desligou-se", - "DeviceOnlineWithName": "{0} ligou-se", - "FailedLoginAttemptWithUserName": "Tentativa de login falhada a partir de {0}", + "FailedLoginAttemptWithUserName": "Tentativa falhada de início de sessão do utilizador {0}", "Favorites": "Favoritos", "Folders": "Pastas", "Genres": "Géneros", - "HeaderAlbumArtists": "Artistas do álbum", "HeaderContinueWatching": "Continuar a ver", - "HeaderFavoriteAlbums": "Álbuns Favoritos", - "HeaderFavoriteArtists": "Artistas Favoritos", - "HeaderFavoriteEpisodes": "Episódios Favoritos", - "HeaderFavoriteShows": "Séries Favoritas", - "HeaderFavoriteSongs": "Músicas Favoritas", - "HeaderLiveTV": "TV em Direto", - "HeaderNextUp": "A Seguir", - "HeaderRecordingGroups": "Grupos de Gravação", - "HomeVideos": "Vídeos Caseiros", + "HeaderFavoriteEpisodes": "Episódios favoritos", + "HeaderFavoriteShows": "Séries favoritas", + "HeaderLiveTV": "TV em direto", + "HeaderNextUp": "A seguir", + "HomeVideos": "Vídeos caseiros", "Inherit": "Herdar", - "ItemAddedWithName": "{0} foi adicionado à mediateca", - "ItemRemovedWithName": "{0} foi removido da mediateca", "LabelIpAddressValue": "Endereço IP: {0}", "LabelRunningTimeValue": "Duração: {0}", - "Latest": "Mais Recente", - "MessageApplicationUpdated": "O servidor Jellyfin foi atualizado", - "MessageApplicationUpdatedTo": "O servidor Jellyfin foi atualizado para a versão {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Configurações de servidor na secção {0} foram atualizadas", - "MessageServerConfigurationUpdated": "A configuração do servidor foi atualizada", - "MixedContent": "Conteúdo Misto", + "Latest": "Mais recente", + "MixedContent": "Conteúdo misto", "Movies": "Filmes", "Music": "Música", "MusicVideos": "Videoclipes", - "NameInstallFailed": "{0} falha na instalação", + "NameInstallFailed": "Falha na instalação de {0}", "NameSeasonNumber": "Temporada {0}", - "NameSeasonUnknown": "Temporada Desconhecida", + "NameSeasonUnknown": "Temporada desconhecida", "NewVersionIsAvailable": "Está disponível para transferência uma nova versão do servidor Jellyfin.", "NotificationOptionApplicationUpdateAvailable": "Atualização de aplicação disponível", "NotificationOptionApplicationUpdateInstalled": "Atualização de aplicação instalada", - "NotificationOptionAudioPlayback": "Reprodução Iniciada", - "NotificationOptionAudioPlaybackStopped": "Reprodução Parada", + "NotificationOptionAudioPlayback": "Reprodução de áudio iniciada", + "NotificationOptionAudioPlaybackStopped": "Reprodução de áudio interrompida", "NotificationOptionCameraImageUploaded": "Imagem da câmara enviada", "NotificationOptionInstallationFailed": "Falha na instalação", "NotificationOptionNewLibraryContent": "Novo conteúdo adicionado", - "NotificationOptionPluginError": "Falha na extensão", - "NotificationOptionPluginInstalled": "Extensão instalada", - "NotificationOptionPluginUninstalled": "Extensão desinstalada", - "NotificationOptionPluginUpdateInstalled": "Extensão atualizada", + "NotificationOptionPluginError": "Falha no plugin", + "NotificationOptionPluginInstalled": "Plugin instalado", + "NotificationOptionPluginUninstalled": "Plugin desinstalado", + "NotificationOptionPluginUpdateInstalled": "Atualização de plugin instalada", "NotificationOptionServerRestartRequired": "Necessário reiniciar o servidor", "NotificationOptionTaskFailed": "Falha em tarefa agendada", "NotificationOptionUserLockedOut": "Utilizador bloqueado", - "NotificationOptionVideoPlayback": "Reprodução do vídeo iniciada", - "NotificationOptionVideoPlaybackStopped": "Reprodução do vídeo parada", + "NotificationOptionVideoPlayback": "Reprodução de vídeo iniciada", + "NotificationOptionVideoPlaybackStopped": "Reprodução de vídeo interrompida", "Photos": "Fotografias", - "Playlists": "Playlists", - "Plugin": "Extensão", "PluginInstalledWithName": "{0} foi instalado", "PluginUninstalledWithName": "{0} foi desinstalado", "PluginUpdatedWithName": "{0} foi atualizado", - "ProviderValue": "Provider: {0}", "ScheduledTaskFailedWithName": "{0} falhou", - "ScheduledTaskStartedWithName": "{0} iniciou", - "ServerNameNeedsToBeRestarted": "{0} necessita de ser reiniciado", "Shows": "Séries", - "Songs": "Músicas", - "StartupEmbyServerIsLoading": "O servidor Jellyfin está a iniciar. Tente novamente mais tarde.", - "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", - "SubtitleDownloadFailureFromForItem": "Falha na transferência de legendas a partir de {0} para {1}", - "Sync": "Sincronização", - "System": "Sistema", - "TvShows": "Séries", - "User": "Utilizador", + "StartupEmbyServerIsLoading": "O servidor Jellyfin está a iniciar. Tenta novamente dentro de instantes.", + "SubtitleDownloadFailureFromForItem": "Falha ao transferir legendas de {0} para {1}", + "TvShows": "Séries de TV", "UserCreatedWithName": "Utilizador {0} criado", - "UserDeletedWithName": "Utilizador {0} apagado", + "UserDeletedWithName": "Utilizador {0} eliminado", "UserDownloadingItemWithValues": "{0} está a transferir {1}", "UserLockedOutWithName": "Utilizador {0} bloqueado", - "UserOfflineFromDevice": "{0} desligou-se a partir de {1}", - "UserOnlineFromDevice": "{0} ligou-se a partir de {1}", + "UserOfflineFromDevice": "{0} desligou-se de {1}", + "UserOnlineFromDevice": "{0} está online em {1}", "UserPasswordChangedWithName": "Palavra-passe alterada para o utilizador {0}", - "UserPolicyUpdatedWithName": "Política de utilizador alterada para {0}", "UserStartedPlayingItemWithValues": "{0} está a reproduzir {1} em {2}", "UserStoppedPlayingItemWithValues": "{0} terminou a reprodução de {1} em {2}", - "ValueHasBeenAddedToLibrary": "{0} foi adicionado à sua mediateca", - "ValueSpecialEpisodeName": "Especial - {0}", "VersionNumber": "Versão {0}", - "TaskDownloadMissingSubtitlesDescription": "Procurar na internet por legendas em falta baseado na configuração de metadados.", + "TaskDownloadMissingSubtitlesDescription": "Procura na Internet legendas em falta com base na configuração dos metadados.", "TaskDownloadMissingSubtitles": "Transferir legendas em falta", - "TaskRefreshChannelsDescription": "Atualizar informação sobre canais da Internet.", - "TaskRefreshChannels": "Atualizar Canais", - "TaskCleanTranscodeDescription": "Apagar ficheiros de transcode com mais de um dia.", - "TaskCleanTranscode": "Limpar a Diretoria de Transcode", - "TaskUpdatePluginsDescription": "Faz o download e instala updates para os plugins que estão configurados para atualizar automaticamente.", - "TaskUpdatePlugins": "Atualizar Plugins", - "TaskRefreshPeopleDescription": "Atualizar metadados para elenco e equipa técnica da tua mediateca.", - "TaskRefreshPeople": "Atualizar Pessoas", - "TaskCleanLogsDescription": "Apagar ficheiros de log que têm mais de {0} dias.", - "TaskCleanLogs": "Limpar a Diretoria de Logs", - "TaskRefreshLibraryDescription": "Analisar a mediateca para novos ficheiros e atualizar os metadados.", - "TaskRefreshLibrary": "Analisar mediateca", - "TaskRefreshChapterImagesDescription": "Criar thumbnails para os vídeos que têm capítulos.", - "TaskRefreshChapterImages": "Extrair Imagens dos Capítulos", - "TaskCleanCacheDescription": "Apagar ficheiros em cache que já não são necessários.", - "TaskCleanCache": "Limpar Cache", + "TaskRefreshChannelsDescription": "Atualiza as informações dos canais da Internet.", + "TaskRefreshChannels": "Atualizar canais", + "TaskCleanTranscodeDescription": "Elimina ficheiros de transcodificação com mais de um dia.", + "TaskCleanTranscode": "Limpar pasta de transcodificação", + "TaskUpdatePluginsDescription": "Transfere e instala atualizações dos plugins configurados para atualização automática.", + "TaskUpdatePlugins": "Atualizar plugins", + "TaskRefreshPeopleDescription": "Atualiza os metadados de atores e realizadores na tua biblioteca multimédia.", + "TaskRefreshPeople": "Atualizar pessoas", + "TaskCleanLogsDescription": "Elimina ficheiros de registo com mais de {0} dias.", + "TaskCleanLogs": "Limpar pasta de registos", + "TaskRefreshLibraryDescription": "Analisa a biblioteca multimédia à procura de novos ficheiros e atualiza os metadados.", + "TaskRefreshLibrary": "Analisar biblioteca multimédia", + "TaskRefreshChapterImagesDescription": "Cria miniaturas para vídeos que têm capítulos.", + "TaskRefreshChapterImages": "Extrair imagens dos capítulos", + "TaskCleanCacheDescription": "Elimina ficheiros de cache que já não são necessários ao sistema.", + "TaskCleanCache": "Limpar pasta de cache", "TasksChannelsCategory": "Canais da Internet", "TasksApplicationCategory": "Aplicação", - "TasksLibraryCategory": "Mediateca", + "TasksLibraryCategory": "Biblioteca", "TasksMaintenanceCategory": "Manutenção", - "TaskCleanActivityLogDescription": "Apaga as entradas do registo de atividade anteriores à data configurada.", + "TaskCleanActivityLogDescription": "Elimina as entradas do registo de atividade mais antigas do que o período configurado.", "TaskCleanActivityLog": "Limpar registo de atividade", "Undefined": "Indefinido", "Forced": "Forçado", "Default": "Predefinição", - "TaskOptimizeDatabaseDescription": "Otimiza e liberta espaço livre na base de dados. A execução desta tarefa depois de analisar a mediateca ou efetuar outras alterações que impliquem modificações na base de dados pode melhorar o desempenho.", + "TaskOptimizeDatabaseDescription": "Compacta a base de dados e liberta espaço não utilizado. A execução desta tarefa depois de analisar a biblioteca ou de outras alterações que modifiquem a base de dados pode melhorar o desempenho.", "TaskOptimizeDatabase": "Otimizar base de dados", - "TaskKeyframeExtractorDescription": "Extrai quadros-chave de ficheiros de video para criar listas de reprodução HLS mais precisas. Esta tarefa pode demorar algum tempo.", - "TaskKeyframeExtractor": "Extrator de Quadros-chave", + "TaskKeyframeExtractorDescription": "Extrai fotogramas-chave de ficheiros de vídeo para criar playlists HLS mais precisas. Esta tarefa pode demorar algum tempo.", + "TaskKeyframeExtractor": "Extrator de fotogramas-chave", "External": "Externo", - "HearingImpaired": "Surdo", - "TaskRefreshTrickplayImages": "Gerar imagens de truques", - "TaskRefreshTrickplayImagesDescription": "Cria vizualizações de truques para videos nas librarias ativas.", - "TaskCleanCollectionsAndPlaylistsDescription": "Remove itens de coleções e listas de reprodução que já não existem.", - "TaskCleanCollectionsAndPlaylists": "Limpar coleções e listas de reprodução", + "HearingImpaired": "Deficiência auditiva", + "TaskRefreshTrickplayImages": "Gerar imagens de trickplay", + "TaskRefreshTrickplayImagesDescription": "Cria pré-visualizações de trickplay para vídeos nas bibliotecas ativadas.", "TaskAudioNormalizationDescription": "Analisa os ficheiros para obter dados de normalização de áudio.", "TaskAudioNormalization": "Normalização de áudio", - "TaskExtractMediaSegments": "Analisar segmentos de multimédia", - "TaskDownloadMissingLyrics": "Transferir letra em falta", - "TaskMoveTrickplayImages": "Migrar a localização da imagem do Trickplay", - "TaskDownloadMissingLyricsDescription": "Transferir letra para músicas", - "TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos de multimédia a partir de plugins com suporte para MediaSegment.", - "TaskMoveTrickplayImagesDescription": "Move os ficheiros trickplay existentes de acordo com as definições da mediateca.", - "CleanupUserDataTaskDescription": "Apaga todos os dados de utilizador (estados de reprodução, favoritos, etc) de arquivos média não presentes há 90 dias ou mais.", - "CleanupUserDataTask": "Limpeza de dados de utilizador" + "TaskExtractMediaSegments": "Analisar segmentos multimédia", + "TaskDownloadMissingLyrics": "Transferir letras em falta", + "TaskMoveTrickplayImages": "Migrar localização das imagens de trickplay", + "TaskDownloadMissingLyricsDescription": "Transfere letras de músicas", + "TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos multimédia de plugins com MediaSegment ativado.", + "TaskMoveTrickplayImagesDescription": "Move os ficheiros de trickplay existentes de acordo com as definições da biblioteca.", + "CleanupUserDataTaskDescription": "Remove todos os dados de utilizador (estado de reprodução, estado de favorito, etc.) de conteúdos multimédia que já não estejam presentes há pelo menos 90 dias.", + "CleanupUserDataTask": "Limpeza de dados de utilizador", + "Original": "Original", + "LyricDownloadFailureFromForItem": "Falha ao transferir letras de {0} para {1}", + "NameExtraDeletedScene": "Cena eliminada", + "NameExtraInterview": "Entrevista", + "NameExtraUnknown": "Extra", + "NameExtraBehindTheScenes": "Bastidores", + "NameExtraSample": "Amostra", + "NameExtraScene": "Cena", + "NameExtraClip": "Excerto", + "NameExtraFeaturette": "Minidocumentário", + "NameExtraThemeSong": "Tema musical", + "NameExtraThemeVideo": "Vídeo temático", + "NameExtraShort": "Curta-metragem", + "NameExtraNumbered": "{0} {1}", + "NameExtraTrailer": "Trailer" } diff --git a/Emby.Server.Implementations/Localization/Core/pt.json b/Emby.Server.Implementations/Localization/Core/pt.json index 74bb1c63a6..8299a25992 100644 --- a/Emby.Server.Implementations/Localization/Core/pt.json +++ b/Emby.Server.Implementations/Localization/Core/pt.json @@ -1,141 +1,125 @@ { - "HeaderLiveTV": "TV Em Direto", + "HeaderLiveTV": "TV em direto", "Collections": "Coleções", "Books": "Livros", "Artists": "Artistas", - "Albums": "Álbuns", - "HeaderNextUp": "A Seguir", - "HeaderFavoriteSongs": "Músicas Favoritas", - "HeaderFavoriteArtists": "Artistas Favoritos", - "HeaderFavoriteAlbums": "Álbuns Favoritos", - "HeaderFavoriteEpisodes": "Episódios Favoritos", - "HeaderFavoriteShows": "Séries Favoritas", + "HeaderNextUp": "A seguir", + "HeaderFavoriteEpisodes": "Episódios favoritos", + "HeaderFavoriteShows": "Séries favoritas", "HeaderContinueWatching": "Continuar a ver", - "HeaderAlbumArtists": "Artistas do Álbum", "Genres": "Géneros", "Folders": "Pastas", "Favorites": "Favoritos", - "Channels": "Canais", - "UserDownloadingItemWithValues": "{0} está sendo baixado {1}", + "UserDownloadingItemWithValues": "{0} está a transferir {1}", "VersionNumber": "Versão {0}", - "ValueHasBeenAddedToLibrary": "{0} foi adicionado à sua mediateca", "UserStoppedPlayingItemWithValues": "{0} terminou a reprodução de {1} em {2}", - "UserStartedPlayingItemWithValues": "{0} está reproduzindo {1} em {2}", - "UserPolicyUpdatedWithName": "A política do usuário {0} foi alterada", - "UserPasswordChangedWithName": "A senha do usuário {0} foi alterada", - "UserOnlineFromDevice": "{0} está online a partir de {1}", - "UserOfflineFromDevice": "{0} desconectou-se a partir de {1}", - "UserLockedOutWithName": "O usuário {0} foi bloqueado", - "UserDeletedWithName": "O usuário {0} foi removido", - "UserCreatedWithName": "O usuário {0} foi criado", - "User": "Usuário", - "TvShows": "Séries", - "System": "Sistema", - "SubtitleDownloadFailureFromForItem": "Falha na transferência de legendas de {0} para {1}", - "StartupEmbyServerIsLoading": "O servidor Jellyfin está iniciando. Tente novamente dentro de momentos.", - "ServerNameNeedsToBeRestarted": "{0} necessita ser reiniciado", - "ScheduledTaskStartedWithName": "{0} iniciou", + "UserStartedPlayingItemWithValues": "{0} está a reproduzir {1} em {2}", + "UserPasswordChangedWithName": "Palavra-passe alterada para o utilizador {0}", + "UserOnlineFromDevice": "{0} está online em {1}", + "UserOfflineFromDevice": "{0} desligou-se de {1}", + "UserLockedOutWithName": "Utilizador {0} bloqueado", + "UserDeletedWithName": "Utilizador {0} eliminado", + "UserCreatedWithName": "Utilizador {0} criado", + "TvShows": "Séries de TV", + "SubtitleDownloadFailureFromForItem": "Falha ao transferir legendas de {0} para {1}", + "StartupEmbyServerIsLoading": "O servidor Jellyfin está a iniciar. Tenta novamente dentro de instantes.", "ScheduledTaskFailedWithName": "{0} falhou", - "ProviderValue": "Fornecedor: {0}", "PluginUpdatedWithName": "{0} foi atualizado", "PluginUninstalledWithName": "{0} foi desinstalado", "PluginInstalledWithName": "{0} foi instalado", - "Plugin": "Plugin", - "NotificationOptionVideoPlaybackStopped": "Reprodução de vídeo parada", + "NotificationOptionVideoPlaybackStopped": "Reprodução de vídeo interrompida", "NotificationOptionVideoPlayback": "Reprodução de vídeo iniciada", - "NotificationOptionUserLockedOut": "Usuário bloqueado", - "NotificationOptionTaskFailed": "Falha na tarefa agendada", - "NotificationOptionServerRestartRequired": "É necessário reiniciar o servidor", - "NotificationOptionPluginUpdateInstalled": "Plugin atualizado", + "NotificationOptionUserLockedOut": "Utilizador bloqueado", + "NotificationOptionTaskFailed": "Falha em tarefa agendada", + "NotificationOptionServerRestartRequired": "Necessário reiniciar o servidor", + "NotificationOptionPluginUpdateInstalled": "Atualização de plugin instalada", "NotificationOptionPluginUninstalled": "Plugin desinstalado", "NotificationOptionPluginInstalled": "Plugin instalado", "NotificationOptionPluginError": "Falha no plugin", "NotificationOptionNewLibraryContent": "Novo conteúdo adicionado", - "NotificationOptionInstallationFailed": "Falha de instalação", - "NotificationOptionCameraImageUploaded": "Imagem de câmera enviada", - "NotificationOptionAudioPlaybackStopped": "Reprodução Parada", - "NotificationOptionAudioPlayback": "Reprodução Iniciada", - "NotificationOptionApplicationUpdateInstalled": "A atualização do aplicativo foi instalada", - "NotificationOptionApplicationUpdateAvailable": "Uma atualização do aplicativo está disponível", - "NewVersionIsAvailable": "Uma nova versão do servidor Jellyfin está disponível para download.", - "NameSeasonUnknown": "Temporada Desconhecida", + "NotificationOptionInstallationFailed": "Falha na instalação", + "NotificationOptionCameraImageUploaded": "Imagem da câmara enviada", + "NotificationOptionAudioPlaybackStopped": "Reprodução de áudio interrompida", + "NotificationOptionAudioPlayback": "Reprodução de áudio iniciada", + "NotificationOptionApplicationUpdateInstalled": "Atualização de aplicação instalada", + "NotificationOptionApplicationUpdateAvailable": "Atualização de aplicação disponível", + "NewVersionIsAvailable": "Está disponível para transferência uma nova versão do servidor Jellyfin.", + "NameSeasonUnknown": "Temporada desconhecida", "NameSeasonNumber": "Temporada {0}", "NameInstallFailed": "Falha na instalação de {0}", "MusicVideos": "Videoclipes", "Music": "Música", - "MixedContent": "Conteúdo diverso", - "MessageServerConfigurationUpdated": "A configuração do servidor foi atualizada", - "MessageNamedServerConfigurationUpdatedWithValue": "As configurações do servidor na seção {0} foram atualizadas", - "MessageApplicationUpdatedTo": "O servidor Jellyfin foi atualizado para a versão {0}", - "MessageApplicationUpdated": "O servidor Jellyfin foi atualizado", - "Latest": "Mais Recente", + "MixedContent": "Conteúdo misto", + "Latest": "Mais recente", "LabelRunningTimeValue": "Duração: {0}", - "LabelIpAddressValue": "Endereço de IP: {0}", - "ItemRemovedWithName": "{0} foi removido da mediateca", - "ItemAddedWithName": "{0} foi adicionado à mediateca", + "LabelIpAddressValue": "Endereço IP: {0}", "Inherit": "Herdar", - "HomeVideos": "Vídeos Caseiros", - "HeaderRecordingGroups": "Grupos de Gravação", - "ValueSpecialEpisodeName": "Especial - {0}", - "Sync": "Sincronização", - "Songs": "Músicas", + "HomeVideos": "Vídeos caseiros", "Shows": "Séries", - "Playlists": "Playlists", "Photos": "Fotografias", "Movies": "Filmes", - "FailedLoginAttemptWithUserName": "Tentativa de início de sessão falhada a partir de {0}", - "DeviceOnlineWithName": "{0} está ligado", - "DeviceOfflineWithName": "{0} desligou-se", + "FailedLoginAttemptWithUserName": "Tentativa falhada de início de sessão do utilizador {0}", "ChapterNameValue": "Capítulo {0}", - "CameraImageUploadedFrom": "Uma nova imagem da câmara foi enviada a partir de {0}", "AuthenticationSucceededWithUserName": "{0} autenticado com sucesso", - "Application": "Aplicação", "AppDeviceValues": "Aplicação: {0}, Dispositivo: {1}", - "TaskCleanCache": "Limpar Diretório de Cache", + "TaskCleanCache": "Limpar pasta de cache", "TasksApplicationCategory": "Aplicação", - "TasksLibraryCategory": "Mediateca", + "TasksLibraryCategory": "Biblioteca", "TasksMaintenanceCategory": "Manutenção", - "TaskRefreshChannels": "Atualizar Canais", - "TaskUpdatePlugins": "Atualizar Plugins", - "TaskCleanLogsDescription": "Deletar arquivos de log que existe a mais de {0} dias.", - "TaskCleanLogs": "Limpar diretório de logs", - "TaskRefreshLibrary": "Analisar mediateca", + "TaskRefreshChannels": "Atualizar canais", + "TaskUpdatePlugins": "Atualizar plugins", + "TaskCleanLogsDescription": "Elimina ficheiros de registo com mais de {0} dias.", + "TaskCleanLogs": "Limpar pasta de registos", + "TaskRefreshLibrary": "Analisar biblioteca multimédia", "TaskRefreshChapterImagesDescription": "Cria miniaturas para vídeos que têm capítulos.", - "TaskCleanCacheDescription": "Apaga ficheiros em cache que já não são usados pelo sistema.", - "TasksChannelsCategory": "Canais de Internet", - "TaskRefreshChapterImages": "Extrair Imagens do Capítulo", - "TaskDownloadMissingSubtitlesDescription": "Pesquisa na Internet as legendas em falta com base na configuração de metadados.", + "TaskCleanCacheDescription": "Elimina ficheiros de cache que já não são necessários ao sistema.", + "TasksChannelsCategory": "Canais da Internet", + "TaskRefreshChapterImages": "Extrair imagens dos capítulos", + "TaskDownloadMissingSubtitlesDescription": "Procura na Internet legendas em falta com base na configuração dos metadados.", "TaskDownloadMissingSubtitles": "Transferir legendas em falta", - "TaskRefreshChannelsDescription": "Atualiza as informações do canal da Internet.", - "TaskCleanTranscodeDescription": "Apagar os ficheiros com mais de um dia, de Transcode.", - "TaskCleanTranscode": "Limpar o diretório de Transcode", - "TaskUpdatePluginsDescription": "Baixa e instala as atualizações para plug-ins configurados para atualização automática.", - "TaskRefreshPeopleDescription": "Atualizar metadados para elenco e equipa técnica da tua mediateca.", + "TaskRefreshChannelsDescription": "Atualiza as informações dos canais da Internet.", + "TaskCleanTranscodeDescription": "Elimina ficheiros de transcodificação com mais de um dia.", + "TaskCleanTranscode": "Limpar pasta de transcodificação", + "TaskUpdatePluginsDescription": "Transfere e instala atualizações dos plugins configurados para atualização automática.", + "TaskRefreshPeopleDescription": "Atualiza os metadados de atores e realizadores na tua biblioteca multimédia.", "TaskRefreshPeople": "Atualizar pessoas", - "TaskRefreshLibraryDescription": "Analisar a mediateca para novos ficheiros e atualizar os metadados.", - "TaskCleanActivityLog": "Limpar registro de atividade", + "TaskRefreshLibraryDescription": "Analisa a biblioteca multimédia à procura de novos ficheiros e atualiza os metadados.", + "TaskCleanActivityLog": "Limpar registo de atividade", "Undefined": "Indefinido", "Forced": "Forçado", "Default": "Predefinição", - "TaskCleanActivityLogDescription": "Apaga itens no registro com idade acima do que é configurado.", + "TaskCleanActivityLogDescription": "Elimina as entradas do registo de atividade mais antigas do que o período configurado.", "TaskOptimizeDatabase": "Otimizar base de dados", - "TaskOptimizeDatabaseDescription": "Otimiza e liberta espaço livre na base de dados. A execução desta tarefa depois de analisar a mediateca ou efetuar outras alterações que impliquem modificações na base de dados pode melhorar o desempenho.", + "TaskOptimizeDatabaseDescription": "Compacta a base de dados e liberta espaço não utilizado. A execução desta tarefa depois de analisar a biblioteca ou de outras alterações que modifiquem a base de dados pode melhorar o desempenho.", "External": "Externo", - "HearingImpaired": "Problemas auditivos", - "TaskKeyframeExtractor": "Extrator de quadro-chave", - "TaskKeyframeExtractorDescription": "Retira frames chave do video para criar listas HLS precisas. Esta tarefa pode correr durante algum tempo.", - "TaskRefreshTrickplayImages": "Gerar miniaturas de vídeo", - "TaskRefreshTrickplayImagesDescription": "Cria miniaturas de vídeo para vídeos nas bibliotecas definidas.", - "TaskCleanCollectionsAndPlaylistsDescription": "Remove itens de coleções e listas de reprodução que já não existem.", - "TaskCleanCollectionsAndPlaylists": "Limpar coleções e listas de reprodução", + "HearingImpaired": "Deficiência auditiva", + "TaskKeyframeExtractor": "Extrator de fotogramas-chave", + "TaskKeyframeExtractorDescription": "Extrai fotogramas-chave de ficheiros de vídeo para criar playlists HLS mais precisas. Esta tarefa pode demorar algum tempo.", + "TaskRefreshTrickplayImages": "Gerar imagens de trickplay", + "TaskRefreshTrickplayImagesDescription": "Cria pré-visualizações de trickplay para vídeos nas bibliotecas ativadas.", "TaskAudioNormalizationDescription": "Analisa os ficheiros para obter dados de normalização de áudio.", "TaskAudioNormalization": "Normalização de áudio", - "TaskDownloadMissingLyrics": "Transferir letra em falta", - "TaskDownloadMissingLyricsDescription": "Transferir letra para músicas", - "TaskMoveTrickplayImagesDescription": "Move os ficheiros trickplay existentes de acordo com as definições da mediateca.", - "TaskExtractMediaSegments": "Analisar segmentos de multimédia", - "TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos de multimédia a partir de plugins com suporte para MediaSegment.", - "TaskMoveTrickplayImages": "Migrar a localização da imagem do Trickplay", - "CleanupUserDataTask": "Task de limpeza de dados do usuário", - "CleanupUserDataTaskDescription": "Remove todos os dados do usuário (progresso, favoritos etc) de mídias que não estão presentes há pelo menos 90 dias." + "TaskDownloadMissingLyrics": "Transferir letras em falta", + "TaskDownloadMissingLyricsDescription": "Transfere letras de músicas", + "TaskMoveTrickplayImagesDescription": "Move os ficheiros de trickplay existentes de acordo com as definições da biblioteca.", + "TaskExtractMediaSegments": "Analisar segmentos multimédia", + "TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos multimédia de plugins com MediaSegment ativado.", + "TaskMoveTrickplayImages": "Migrar localização das imagens de trickplay", + "CleanupUserDataTask": "Limpeza de dados de utilizador", + "CleanupUserDataTaskDescription": "Remove todos os dados de utilizador (estado de reprodução, estado de favorito, etc.) de conteúdos multimédia que já não estejam presentes há pelo menos 90 dias.", + "Original": "Original", + "LyricDownloadFailureFromForItem": "Falha ao transferir letras de {0} para {1}", + "NameExtraBehindTheScenes": "Bastidores", + "NameExtraClip": "Excerto", + "NameExtraDeletedScene": "Cena eliminada", + "NameExtraFeaturette": "Minidocumentário", + "NameExtraInterview": "Entrevista", + "NameExtraSample": "Amostra", + "NameExtraShort": "Curta-metragem", + "NameExtraThemeSong": "Tema musical", + "NameExtraThemeVideo": "Vídeo temático", + "NameExtraScene": "Cena", + "NameExtraUnknown": "Extra", + "NameExtraTrailer": "Trailer", + "NameExtraNumbered": "{0} {1}" } diff --git a/Emby.Server.Implementations/Localization/Core/ro.json b/Emby.Server.Implementations/Localization/Core/ro.json index bf71c5afa1..dccec8067d 100644 --- a/Emby.Server.Implementations/Localization/Core/ro.json +++ b/Emby.Server.Implementations/Localization/Core/ro.json @@ -1,11 +1,8 @@ { "HeaderNextUp": "Urmează", "VersionNumber": "Versiunea {0}", - "ValueSpecialEpisodeName": "Special - {0}", - "ValueHasBeenAddedToLibrary": "{0} a fost adăugat la biblioteca multimedia", "UserStoppedPlayingItemWithValues": "{0} a terminat rularea {1} pe {2}", "UserStartedPlayingItemWithValues": "{0} ruleaza {1} pe {2}", - "UserPolicyUpdatedWithName": "Politica utilizatorului {0} a fost actualizată", "UserPasswordChangedWithName": "Parola utilizatorului {0} a fost schimbată", "UserOnlineFromDevice": "{0} este conectat de la {1}", "UserOfflineFromDevice": "{0} s-a deconectat de la {1}", @@ -13,23 +10,14 @@ "UserDownloadingItemWithValues": "{0} descarcă {1}", "UserDeletedWithName": "Utilizatorul {0} a fost șters", "UserCreatedWithName": "Utilizatorul {0} a fost creat", - "User": "Utilizator", "TvShows": "Seriale TV", - "System": "Sistem", - "Sync": "Sincronizare", "SubtitleDownloadFailureFromForItem": "Subtitrările nu au putut fi descărcate de la {0} pentru {1}", "StartupEmbyServerIsLoading": "Se încarcă serverul Jellyfin. Încercați din nou în scurt timp.", - "Songs": "Melodii", "Shows": "Seriale", - "ServerNameNeedsToBeRestarted": "{0} trebuie să fie repornit", - "ScheduledTaskStartedWithName": "{0} pornit/ă", "ScheduledTaskFailedWithName": "{0} eșuat/ă", - "ProviderValue": "Furnizor: {0}", "PluginUpdatedWithName": "{0} a fost actualizat/ă", "PluginUninstalledWithName": "{0} a fost dezinstalat", "PluginInstalledWithName": "{0} a fost instalat", - "Plugin": "Extensie", - "Playlists": "Liste de redare", "Photos": "Fotografii", "NotificationOptionVideoPlaybackStopped": "Redarea video oprită", "NotificationOptionVideoPlayback": "Redare video începută", @@ -55,42 +43,25 @@ "Music": "Muzică", "Movies": "Filme", "MixedContent": "Conținut amestecat", - "MessageServerConfigurationUpdated": "Configurarea serverului a fost actualizată", - "MessageNamedServerConfigurationUpdatedWithValue": "Secțiunea de configurare a serverului {0} a fost acualizata", - "MessageApplicationUpdatedTo": "Jellyfin Server a fost actualizat la {0}", - "MessageApplicationUpdated": "Jellyfin Server a fost actualizat", "Latest": "Cele mai recente", "LabelRunningTimeValue": "Durată: {0}", "LabelIpAddressValue": "Adresa IP: {0}", - "ItemRemovedWithName": "{0} a fost eliminat din bibliotecă", - "ItemAddedWithName": "{0} a fost adăugat în bibliotecă", "Inherit": "Moștenit", "HomeVideos": "Filme personale", - "HeaderRecordingGroups": "Grupuri de înregistrare", "HeaderLiveTV": "TV în Direct", - "HeaderFavoriteSongs": "Melodii Favorite", "HeaderFavoriteShows": "Seriale TV Favorite", "HeaderFavoriteEpisodes": "Episoade Favorite", - "HeaderFavoriteArtists": "Artiști Favoriți", - "HeaderFavoriteAlbums": "Albume Favorite", "HeaderContinueWatching": "Vizionează în continuare", - "HeaderAlbumArtists": "Artiști album", "Genres": "Genuri", "Folders": "Dosare", "Favorites": "Preferate", "FailedLoginAttemptWithUserName": "Încercare de conectare eșuată pentru {0}", - "DeviceOnlineWithName": "{0} este conectat", - "DeviceOfflineWithName": "{0} s-a deconectat", "Collections": "Colecții", "ChapterNameValue": "Capitolul {0}", - "Channels": "Canale", - "CameraImageUploadedFrom": "O nouă fotografie a fost încărcată din {0}", "Books": "Cărți", "AuthenticationSucceededWithUserName": "{0} autentificare reușită", "Artists": "Artiști", - "Application": "Aplicație", "AppDeviceValues": "Aplicație: {0}, Dispozitiv: {1}", - "Albums": "Albume", "TaskDownloadMissingSubtitlesDescription": "Caută pe internet subtitrările lipsă pe baza configurației metadatelor.", "TaskDownloadMissingSubtitles": "Descarcă subtitrările lipsă", "TaskRefreshChannelsDescription": "Actualizează informațiile despre canalul de internet.", @@ -128,8 +99,6 @@ "TaskRefreshTrickplayImagesDescription": "Generează previzualizările trickplay pentru videourile din librăriile selectate.", "TaskAudioNormalizationDescription": "Scanează fișiere pentru date necesare normalizării sunetului.", "TaskAudioNormalization": "Normalizare sunet", - "TaskCleanCollectionsAndPlaylists": "Curăță colecțiile și listele de redare", - "TaskCleanCollectionsAndPlaylistsDescription": "Elimină elementele care nu mai există din colecții și liste de redare.", "TaskExtractMediaSegments": "Scanează segmentele media", "TaskMoveTrickplayImagesDescription": "Mută fișierele trickplay existente conform setărilor librăriei.", "TaskExtractMediaSegmentsDescription": "Extrage sau obține segmentele media de la pluginurile MediaSegment activate.", @@ -137,5 +106,19 @@ "TaskDownloadMissingLyrics": "Descarcă versurile lipsă", "TaskDownloadMissingLyricsDescription": "Descarcă versuri pentru melodii", "CleanupUserDataTask": "Sarcina de curatare a datelor utilizatorului", - "CleanupUserDataTaskDescription": "Sterge toate datele utilizatorului (starea vizionarii, starea favoritelor etc.) de pe suporturile media care nu mai sunt prezente timp de cel puțin 90 de zile." + "CleanupUserDataTaskDescription": "Sterge toate datele utilizatorului (starea vizionarii, starea favoritelor etc.) de pe suporturile media care nu mai sunt prezente timp de cel puțin 90 de zile.", + "LyricDownloadFailureFromForItem": "Versurile nu au putut fi descărcate din {0} pentru {1}", + "Original": "Original", + "NameExtraBehindTheScenes": "În culise", + "NameExtraClip": "Clip", + "NameExtraDeletedScene": "Scenă ștearsă", + "NameExtraFeaturette": "Material bonus", + "NameExtraInterview": "Interviu", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Monstră", + "NameExtraScene": "Scenă", + "NameExtraShort": "Scurt", + "NameExtraThemeSong": "Audio de Fundal", + "NameExtraThemeVideo": "Video de Fundal", + "NameExtraTrailer": "Trailer" } diff --git a/Emby.Server.Implementations/Localization/Core/ru.json b/Emby.Server.Implementations/Localization/Core/ru.json index 1470a538c2..c13fd3ebdc 100644 --- a/Emby.Server.Implementations/Localization/Core/ru.json +++ b/Emby.Server.Implementations/Localization/Core/ru.json @@ -1,41 +1,24 @@ { - "Albums": "Альбомы", "AppDeviceValues": "Приложение: {0}, Устройство: {1}", - "Application": "Приложение", "Artists": "Исполнители", "AuthenticationSucceededWithUserName": "{0} - авторизация успешна", "Books": "Книги", - "CameraImageUploadedFrom": "Новое фото загружено с камеры {0}", - "Channels": "Каналы", "ChapterNameValue": "Сцена {0}", "Collections": "Коллекции", - "DeviceOfflineWithName": "{0} - отключено", - "DeviceOnlineWithName": "{0} - подключено", "FailedLoginAttemptWithUserName": "Неудачная попытка входа с {0}", "Favorites": "Избранное", "Folders": "Папки", "Genres": "Жанры", - "HeaderAlbumArtists": "Исполнители альбома", "HeaderContinueWatching": "Продолжить просмотр", - "HeaderFavoriteAlbums": "Избранные альбомы", - "HeaderFavoriteArtists": "Избранные исполнители", "HeaderFavoriteEpisodes": "Избранные эпизоды", "HeaderFavoriteShows": "Избранные сериалы", - "HeaderFavoriteSongs": "Избранные композиции", "HeaderLiveTV": "Эфир", "HeaderNextUp": "Следующий", - "HeaderRecordingGroups": "Группы записей", "HomeVideos": "Домашние видео", "Inherit": "Наследуемое", - "ItemAddedWithName": "{0} - добавлено в медиатеку", - "ItemRemovedWithName": "{0} - изъято из медиатеки", "LabelIpAddressValue": "IP-адрес: {0}", "LabelRunningTimeValue": "Длительность: {0}", "Latest": "Последние", - "MessageApplicationUpdated": "Jellyfin Server был обновлён", - "MessageApplicationUpdatedTo": "Jellyfin Server был обновлён до {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Конфигурация сервера (раздел {0}) была обновлена", - "MessageServerConfigurationUpdated": "Конфигурация сервера была обновлена", "MixedContent": "Смешанное содержание", "Movies": "Фильмы", "Music": "Музыка", @@ -61,24 +44,14 @@ "NotificationOptionVideoPlayback": "Воспроизведение видео запущено", "NotificationOptionVideoPlaybackStopped": "Воспроизведение видео остановлено", "Photos": "Фото", - "Playlists": "Плей-листы", - "Plugin": "Плагин", "PluginInstalledWithName": "{0} - было установлено", "PluginUninstalledWithName": "{0} - было удалено", "PluginUpdatedWithName": "{0} - было обновлено", - "ProviderValue": "Поставщик: {0}", "ScheduledTaskFailedWithName": "{0} - неудачна", - "ScheduledTaskStartedWithName": "{0} - запущена", - "ServerNameNeedsToBeRestarted": "Необходим перезапуск {0}", "Shows": "Сериалы", - "Songs": "Композиции", "StartupEmbyServerIsLoading": "Jellyfin Server загружается. Повторите попытку в ближайшее время.", - "SubtitleDownloadFailureForItem": "Субтитры к {0} не удалось загрузить", "SubtitleDownloadFailureFromForItem": "Субтитры к {1} не удалось загрузить с {0}", - "Sync": "Синхронизация", - "System": "Система", "TvShows": "Телесериалы", - "User": "Пользователь", "UserCreatedWithName": "Пользователь {0} был создан", "UserDeletedWithName": "Пользователь {0} был удалён", "UserDownloadingItemWithValues": "{0} загружает {1}", @@ -86,11 +59,8 @@ "UserOfflineFromDevice": "{0} отключился с {1}", "UserOnlineFromDevice": "{0} подключился с {1}", "UserPasswordChangedWithName": "Пароль пользователя {0} был изменён", - "UserPolicyUpdatedWithName": "Политики пользователя {0} были обновлены", "UserStartedPlayingItemWithValues": "{0} - воспроизведение «{1}» на {2}", "UserStoppedPlayingItemWithValues": "{0} - воспроизведение остановлено «{1}» на {2}", - "ValueHasBeenAddedToLibrary": "{0} добавлено в медиатеку", - "ValueSpecialEpisodeName": "Спецэпизод - {0}", "VersionNumber": "Версия {0}", "TaskDownloadMissingSubtitles": "Загрузка отсутствующих субтитров", "TaskRefreshChannels": "Обновление каналов", @@ -127,8 +97,6 @@ "HearingImpaired": "Для слабослышащих", "TaskRefreshTrickplayImages": "Сгенерировать изображения для Trickplay", "TaskRefreshTrickplayImagesDescription": "Создает предпросмотры для Trickplay для видео в библиотеках, где эта функция включена.", - "TaskCleanCollectionsAndPlaylists": "Очистка коллекций и списков воспроизведения", - "TaskCleanCollectionsAndPlaylistsDescription": "Удаляет элементы из коллекций и списков воспроизведения, которые больше не существуют.", "TaskAudioNormalization": "Нормализация звука", "TaskAudioNormalizationDescription": "Сканирует файлы на наличие данных о нормализации звука.", "TaskDownloadMissingLyrics": "Загрузить недостающий текст", @@ -138,5 +106,20 @@ "TaskExtractMediaSegmentsDescription": "Извлекает или получает медиасегменты из плагинов MediaSegment.", "TaskMoveTrickplayImagesDescription": "Перемещает существующие файлы trickplay в соответствии с настройками медиатеки.", "CleanupUserDataTask": "Задача очистки пользовательских данных", - "CleanupUserDataTaskDescription": "Очищает все пользовательские данные (состояние просмотра, статус избранного и т.д.) с медиа, отсутствующих по меньшей мере в течение 90 дней." + "CleanupUserDataTaskDescription": "Очищает все пользовательские данные (состояние просмотра, статус избранного и т.д.) с медиа, отсутствующих по меньшей мере в течение 90 дней.", + "Original": "Оригинальный", + "LyricDownloadFailureFromForItem": "Не получилось скачать текст песни с {0} для {1}", + "NameExtraBehindTheScenes": "За кадром", + "NameExtraClip": "Отрывок", + "NameExtraDeletedScene": "Удалённая сцена", + "NameExtraFeaturette": "Короткометражка", + "NameExtraInterview": "Интервью", + "NameExtraSample": "Образец", + "NameExtraScene": "Сцена", + "NameExtraThemeSong": "Заглавная песня", + "NameExtraThemeVideo": "Заглавное видео", + "NameExtraTrailer": "Трейлер", + "NameExtraUnknown": "Дополнительный материал", + "NameExtraNumbered": "{0} {1}", + "NameExtraShort": "Короткометражка" } diff --git a/Emby.Server.Implementations/Localization/Core/si.json b/Emby.Server.Implementations/Localization/Core/si.json index 0967ef424b..8efc0d1f2e 100644 --- a/Emby.Server.Implementations/Localization/Core/si.json +++ b/Emby.Server.Implementations/Localization/Core/si.json @@ -1 +1,15 @@ -{} +{ + "AppDeviceValues": "ඇප්: {0}, උපාංග: {1}", + "Artists": "කලාකරුවන්", + "AuthenticationSucceededWithUserName": "{0} සාර්ථකව තහවුරු කරන ලදී", + "Books": "පොත්", + "ChapterNameValue": "{0} වෙනි පරිච්ඡේදය", + "Collections": "සංහිතා", + "Default": "පෙරනිමි", + "External": "බාහිර", + "FailedLoginAttemptWithUserName": "{0} වෙතින් සිදුකළ පිවිසීමේ උත්සාහය අසාර්ථක විය", + "Favorites": "ප්‍රියතමයන්", + "Folders": "ෆෝල්ඩර", + "Forced": "නියමිත", + "Genres": "ප්‍රභේද" +} diff --git a/Emby.Server.Implementations/Localization/Core/sk.json b/Emby.Server.Implementations/Localization/Core/sk.json index 1de78eeaeb..e49914c838 100644 --- a/Emby.Server.Implementations/Localization/Core/sk.json +++ b/Emby.Server.Implementations/Localization/Core/sk.json @@ -1,41 +1,24 @@ { - "Albums": "Albumy", "AppDeviceValues": "Aplikácia: {0}, Zariadenie: {1}", - "Application": "Aplikácia", "Artists": "Interpreti", "AuthenticationSucceededWithUserName": "{0} úspešne overený", "Books": "Knihy", - "CameraImageUploadedFrom": "Z {0} bola nahraná nová fotografia", - "Channels": "Kanály", "ChapterNameValue": "Kapitola {0}", "Collections": "Kolekcie", - "DeviceOfflineWithName": "{0} sa odpojil", - "DeviceOnlineWithName": "{0} je pripojený", "FailedLoginAttemptWithUserName": "Neúspešný pokus o prihlásenie z {0}", "Favorites": "Obľúbené", "Folders": "Priečinky", "Genres": "Žánre", - "HeaderAlbumArtists": "Interpreti albumu", "HeaderContinueWatching": "Pokračovať v pozeraní", - "HeaderFavoriteAlbums": "Obľúbené albumy", - "HeaderFavoriteArtists": "Obľúbení interpreti", "HeaderFavoriteEpisodes": "Obľúbené epizódy", "HeaderFavoriteShows": "Obľúbené seriály", - "HeaderFavoriteSongs": "Obľúbené skladby", "HeaderLiveTV": "Živá TV", "HeaderNextUp": "Nasleduje", - "HeaderRecordingGroups": "Skupiny nahrávok", "HomeVideos": "Domáce videá", "Inherit": "Zdediť", - "ItemAddedWithName": "{0} bol pridaný do knižnice", - "ItemRemovedWithName": "{0} bol odstránený z knižnice", "LabelIpAddressValue": "IP adresa: {0}", "LabelRunningTimeValue": "Dĺžka: {0}", "Latest": "Najnovšie", - "MessageApplicationUpdated": "Jellyfin Server bol aktualizovaný", - "MessageApplicationUpdatedTo": "Jellyfin Server bol aktualizovaný na verziu {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Sekcia {0} konfigurácie servera bola aktualizovaná", - "MessageServerConfigurationUpdated": "Konfigurácia servera bola aktualizovaná", "MixedContent": "Zmiešaný obsah", "Movies": "Filmy", "Music": "Hudba", @@ -61,24 +44,14 @@ "NotificationOptionVideoPlayback": "Spustené prehrávanie videa", "NotificationOptionVideoPlaybackStopped": "Zastavené prehrávanie videa", "Photos": "Fotky", - "Playlists": "Playlisty", - "Plugin": "Zásuvný modul", "PluginInstalledWithName": "{0} bol nainštalovaný", "PluginUninstalledWithName": "{0} bol odinštalovaný", "PluginUpdatedWithName": "{0} bol aktualizovaný", - "ProviderValue": "Poskytovateľ: {0}", "ScheduledTaskFailedWithName": "{0} zlyhalo", - "ScheduledTaskStartedWithName": "{0} zahájených", - "ServerNameNeedsToBeRestarted": "{0} vyžaduje reštart", "Shows": "Seriály", - "Songs": "Skladby", "StartupEmbyServerIsLoading": "Jellyfin Server sa spúšťa. Prosím, skúste to o chvíľu znova.", - "SubtitleDownloadFailureForItem": "Sťahovanie titulkov pre {0} zlyhalo", "SubtitleDownloadFailureFromForItem": "Sťahovanie titulkov z {0} pre {1} zlyhalo", - "Sync": "Synchronizácia", - "System": "Systém", "TvShows": "TV seriály", - "User": "Používateľ", "UserCreatedWithName": "Používateľ {0} bol vytvorený", "UserDeletedWithName": "Používateľ {0} bol vymazaný", "UserDownloadingItemWithValues": "{0} sťahuje {1}", @@ -86,11 +59,8 @@ "UserOfflineFromDevice": "{0} sa odpojil od {1}", "UserOnlineFromDevice": "{0} je online z {1}", "UserPasswordChangedWithName": "Heslo používateľa {0} bolo zmenené", - "UserPolicyUpdatedWithName": "Používateľské zásady pre {0} boli aktualizované", "UserStartedPlayingItemWithValues": "{0} spustil prehrávanie {1} na {2}", "UserStoppedPlayingItemWithValues": "{0} ukončil prehrávanie {1} na {2}", - "ValueHasBeenAddedToLibrary": "{0} bol pridaný do vašej knižnice médií", - "ValueSpecialEpisodeName": "Špeciál - {0}", "VersionNumber": "Verzia {0}", "TaskDownloadMissingSubtitlesDescription": "Vyhľadá na internete chýbajúce titulky podľa toho, ako sú nakonfigurované metadáta.", "TaskDownloadMissingSubtitles": "Stiahnuť chýbajúce titulky", @@ -121,14 +91,12 @@ "Default": "Predvolené", "TaskOptimizeDatabaseDescription": "Zmenší databázu a odstráni prázdne miesto. Spustenie tejto úlohy po skenovaní knižnice alebo po iných zmenách zahŕňajúcich úpravy databáze môže zlepšiť výkon.", "TaskOptimizeDatabase": "Optimalizovať databázu", - "TaskKeyframeExtractorDescription": "Extrahuje kľúčové snímky z video súborov na vytvorenie presnejších HLS playlistov. Táto úloha môže trvať dlhšiu dobu.", + "TaskKeyframeExtractorDescription": "Extrahuje kľúčové snímky z videosúborov na vytvorenie presnejších HLS zoznamov. Táto úloha môže trvať dlhší čas.", "TaskKeyframeExtractor": "Extraktor kľúčových snímkov", "External": "Externé", "HearingImpaired": "Sluchovo postihnutí", "TaskRefreshTrickplayImages": "Generovanie obrázkov Trickplay", "TaskRefreshTrickplayImagesDescription": "Vytvára trickplay náhľady pre videá v povolených knižniciach.", - "TaskCleanCollectionsAndPlaylists": "Vyčistiť kolekcie a playlisty", - "TaskCleanCollectionsAndPlaylistsDescription": "Odstráni položky z kolekcií a playlistov, ktoré už neexistujú.", "TaskAudioNormalization": "Normalizácia zvuku", "TaskAudioNormalizationDescription": "Skenovať súbory za účelom normalizácie zvuku.", "TaskExtractMediaSegments": "Skenovanie segmentov médií", @@ -138,5 +106,20 @@ "TaskDownloadMissingLyrics": "Stiahnuť chýbajúce texty piesní", "TaskDownloadMissingLyricsDescription": "Stiahne texty pre piesne", "CleanupUserDataTask": "Prečistiť používateľské dáta", - "CleanupUserDataTaskDescription": "Vyčistí všetky dáta používateľa (stav sledovania, stav obľúbených atď.) z médií, ktoré už neexistujú aspoň 90 dní." + "CleanupUserDataTaskDescription": "Vyčistí všetky dáta používateľa (stav sledovania, stav obľúbených atď.) z médií, ktoré už neexistujú aspoň 90 dní.", + "LyricDownloadFailureFromForItem": "Text piesne sa nepodarilo stiahnuť z {0} pre {1}", + "Original": "Originál", + "NameExtraBehindTheScenes": "Zo zákulisia", + "NameExtraClip": "Klip", + "NameExtraDeletedScene": "Vystrihnutá scéna", + "NameExtraFeaturette": "Bonus", + "NameExtraInterview": "Rozhovor", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Ukážka", + "NameExtraScene": "Scéna", + "NameExtraShort": "Krátky film", + "NameExtraThemeSong": "Úvodná pieseň", + "NameExtraThemeVideo": "Úvodné video", + "NameExtraTrailer": "Trailer", + "NameExtraUnknown": "Extra" } diff --git a/Emby.Server.Implementations/Localization/Core/sl-SI.json b/Emby.Server.Implementations/Localization/Core/sl-SI.json index ff92db2f2d..6ea625d66c 100644 --- a/Emby.Server.Implementations/Localization/Core/sl-SI.json +++ b/Emby.Server.Implementations/Localization/Core/sl-SI.json @@ -1,41 +1,24 @@ { - "Albums": "Albumi", "AppDeviceValues": "Aplikacija: {0}, Naprava: {1}", - "Application": "Aplikacija", "Artists": "Izvajalci", "AuthenticationSucceededWithUserName": "{0} se je uspešno prijavil/a", "Books": "Knjige", - "CameraImageUploadedFrom": "Nova fotografija je bila naložena iz {0}", - "Channels": "Kanali", "ChapterNameValue": "Poglavje {0}", "Collections": "Zbirke", - "DeviceOfflineWithName": "{0} je prekinil povezavo", - "DeviceOnlineWithName": "{0} je povezan", "FailedLoginAttemptWithUserName": "Neuspešen poskus prijave z {0}", "Favorites": "Priljubljeno", "Folders": "Mape", "Genres": "Zvrsti", - "HeaderAlbumArtists": "Izvajalci albuma", "HeaderContinueWatching": "Nadaljuj ogled", - "HeaderFavoriteAlbums": "Priljubljeni albumi", - "HeaderFavoriteArtists": "Priljubljeni izvajalci", "HeaderFavoriteEpisodes": "Priljubljene epizode", "HeaderFavoriteShows": "Priljubljene serije", - "HeaderFavoriteSongs": "Priljubljene pesmi", "HeaderLiveTV": "TV v živo", "HeaderNextUp": "Sledi", - "HeaderRecordingGroups": "Zbirke posnetkov", "HomeVideos": "Domači posnetki", "Inherit": "Podeduj", - "ItemAddedWithName": "{0} je dodan v knjižnico", - "ItemRemovedWithName": "{0} je bil odstranjen iz knjižnice", "LabelIpAddressValue": "IP naslov: {0}", "LabelRunningTimeValue": "Čas trajanja: {0}", "Latest": "Najnovejše", - "MessageApplicationUpdated": "Jellyfin strežnik je bil posodobljen", - "MessageApplicationUpdatedTo": "Jellyfin strežnik je bil posodobljen na {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Oddelek nastavitev {0} je bil posodobljen", - "MessageServerConfigurationUpdated": "Nastavitve strežnika so bile posodobljene", "MixedContent": "Mešane vsebine", "Movies": "Filmi", "Music": "Glasba", @@ -61,24 +44,14 @@ "NotificationOptionVideoPlayback": "Predvajanje videa se je začelo", "NotificationOptionVideoPlaybackStopped": "Predvajanje videa se je ustavilo", "Photos": "Fotografije", - "Playlists": "Seznami predvajanja", - "Plugin": "Dodatek", "PluginInstalledWithName": "{0} je bil nameščen", "PluginUninstalledWithName": "{0} je bil odstranjen", "PluginUpdatedWithName": "{0} je bil posodobljen", - "ProviderValue": "Ponudnik: {0}", "ScheduledTaskFailedWithName": "{0} ni uspelo", - "ScheduledTaskStartedWithName": "{0} začeto", - "ServerNameNeedsToBeRestarted": "{0} mora biti ponovno zagnan", "Shows": "Serije", - "Songs": "Pesmi", "StartupEmbyServerIsLoading": "Jellyfin strežnik se zaganja. Poskusite ponovno kasneje.", - "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", "SubtitleDownloadFailureFromForItem": "Neuspešen prenos podnapisov iz {0} za {1}", - "Sync": "Sinhroniziraj", - "System": "Sistem", "TvShows": "TV serije", - "User": "Uporabnik", "UserCreatedWithName": "Uporabnik {0} je bil ustvarjen", "UserDeletedWithName": "Uporabnik {0} je bil izbrisan", "UserDownloadingItemWithValues": "{0} prenaša {1}", @@ -86,11 +59,8 @@ "UserOfflineFromDevice": "{0} je prekinil povezavo z {1}", "UserOnlineFromDevice": "{0} je aktiven na {1}", "UserPasswordChangedWithName": "Geslo za uporabnika {0} je bilo spremenjeno", - "UserPolicyUpdatedWithName": "Pravilnik uporabe je bil posodobljen za uporabnika {0}", "UserStartedPlayingItemWithValues": "{0} predvaja {1} na {2}", "UserStoppedPlayingItemWithValues": "{0} je nehal predvajati {1} na {2}", - "ValueHasBeenAddedToLibrary": "{0} je bil dodan vaši knjižnici", - "ValueSpecialEpisodeName": "Posebna epizoda - {0}", "VersionNumber": "Različica {0}", "TaskDownloadMissingSubtitles": "Prenesi manjkajoče podnapise", "TaskRefreshChannelsDescription": "Osveži podatke spletnih kanalov.", @@ -133,10 +103,22 @@ "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č.", "CleanupUserDataTask": "Čiščenje uporabniških podatkov", - "CleanupUserDataTaskDescription": "Izbriše vse uporabniške podatke (stanje ogleda, priljubljene itd.) za vsebine, ki že več kot 90 dni niso na voljo." + "CleanupUserDataTaskDescription": "Izbriše vse uporabniške podatke (stanje ogleda, priljubljene itd.) za vsebine, ki že več kot 90 dni niso na voljo.", + "LyricDownloadFailureFromForItem": "Besedila ni bilo mogoče prenesti iz {0} za {1}", + "Original": "Original", + "NameExtraBehindTheScenes": "V zakulisju", + "NameExtraClip": "Klip", + "NameExtraDeletedScene": "Izbrisan prizor", + "NameExtraFeaturette": "Kratek dokumentarec o izdelavi filma", + "NameExtraInterview": "Intervju", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Vzorec", + "NameExtraScene": "Prizor", + "NameExtraShort": "Kratki film", + "NameExtraThemeSong": "Tematska Pesem", + "NameExtraThemeVideo": "Tematski Video", + "NameExtraTrailer": "Napovednik" } diff --git a/Emby.Server.Implementations/Localization/Core/sn.json b/Emby.Server.Implementations/Localization/Core/sn.json index 74720e7646..45a459cbe1 100644 --- a/Emby.Server.Implementations/Localization/Core/sn.json +++ b/Emby.Server.Implementations/Localization/Core/sn.json @@ -1,28 +1,18 @@ { - "HeaderAlbumArtists": "Vaimbi vemadambarefu", "HeaderContinueWatching": "Simudzira kuona", - "HeaderFavoriteSongs": "Nziyo dzaunofarira", - "Albums": "Dambarefu", "AppDeviceValues": "Apu: {0}, Dhivhaisi: {1}", - "Application": "Purogiramu", "Artists": "Vaimbi", "AuthenticationSucceededWithUserName": "apinda", "Books": "Mabhuku", - "CameraImageUploadedFrom": "Mufananidzo mutsva vabva pakamera {0}", - "Channels": "Machanewo", "ChapterNameValue": "Chikamu {0}", "Collections": "Akafanana", "Default": "Zvakasarudzwa Kare", - "DeviceOfflineWithName": "{0} haasisipo", - "DeviceOnlineWithName": "{0} aripo", "External": "Zvekunze", "FailedLoginAttemptWithUserName": "Vatadza kuloga chimboedza kushandisa {0}", "Favorites": "Zvaunofarira", "Folders": "Mafoodha", "Forced": "Zvekumanikidzira", "Genres": "Mhando", - "HeaderFavoriteAlbums": "Madambarefu aunofarira", - "HeaderFavoriteArtists": "Vaimbi vaunofarira", "HeaderFavoriteEpisodes": "Maepisodhi aunofarira", "HeaderFavoriteShows": "Masirisi aunofarira" } diff --git a/Emby.Server.Implementations/Localization/Core/sq.json b/Emby.Server.Implementations/Localization/Core/sq.json index 8f0079c94f..11b25e9795 100644 --- a/Emby.Server.Implementations/Localization/Core/sq.json +++ b/Emby.Server.Implementations/Localization/Core/sq.json @@ -1,5 +1,4 @@ { - "MessageApplicationUpdatedTo": "Serveri Jellyfin u përditesua në versionin {0}", "Inherit": "Trashgimi", "TaskDownloadMissingSubtitlesDescription": "Kërkon në internet për titra që mungojnë bazuar tek konfigurimi i metadata-ve.", "TaskDownloadMissingSubtitles": "Shkarko titra që mungojnë", @@ -24,11 +23,8 @@ "TasksLibraryCategory": "Libraria", "TasksMaintenanceCategory": "Mirëmbajtje", "VersionNumber": "Versioni {0}", - "ValueSpecialEpisodeName": "Speciale - {0}", - "ValueHasBeenAddedToLibrary": "{0} u shtua tek libraria juaj", "UserStoppedPlayingItemWithValues": "{0} mbaroi së shikuari {1} tek {2}", "UserStartedPlayingItemWithValues": "{0} po shikon {1} tek {2}", - "UserPolicyUpdatedWithName": "Politika e përdoruesit u përditësua për {0}", "UserPasswordChangedWithName": "Fjalëkalimi u ndryshua për përdoruesin {0}", "UserOnlineFromDevice": "{0} është në linjë nga {1}", "UserOfflineFromDevice": "{0} u shkëput nga {1}", @@ -36,23 +32,14 @@ "UserDownloadingItemWithValues": "{0} po shkarkon {1}", "UserDeletedWithName": "Përdoruesi {0} u fshi", "UserCreatedWithName": "Përdoruesi {0} u krijua", - "User": "Përdoruesi", "TvShows": "Seriale TV", - "System": "Sistemi", - "Sync": "Sinkronizo", "SubtitleDownloadFailureFromForItem": "Titrat deshtuan të shkarkohen nga {0} për {1}", "StartupEmbyServerIsLoading": "Serveri Jellyfin po ngarkohet. Ju lutemi provoni përseri pas pak.", - "Songs": "Këngët", "Shows": "Serialet", - "ServerNameNeedsToBeRestarted": "{0} duhet të ristartoj", - "ScheduledTaskStartedWithName": "{0} filloi", "ScheduledTaskFailedWithName": "{0} dështoi", - "ProviderValue": "Ofruesi: {0}", "PluginUpdatedWithName": "{0} u përditësua", "PluginUninstalledWithName": "{0} u çinstalua", "PluginInstalledWithName": "{0} u instalua", - "Plugin": "Plugin", - "Playlists": "Listat për luajtje", "Photos": "Fotografitë", "NotificationOptionVideoPlaybackStopped": "Luajtja e videos ndaloi", "NotificationOptionVideoPlayback": "Luajtja e videos filloi", @@ -78,41 +65,25 @@ "Music": "Muzikë", "Movies": "Filmat", "MixedContent": "Përmbajtje e përzier", - "MessageServerConfigurationUpdated": "Konfigurimet e serverit u përditësuan", - "MessageNamedServerConfigurationUpdatedWithValue": "Seksioni i konfigurimit të serverit {0} u përditësua", - "MessageApplicationUpdated": "Serveri Jellyfin u përditësua", "Latest": "Të fundit", "LabelRunningTimeValue": "Kohëzgjatja: {0}", "LabelIpAddressValue": "Adresa IP: {0}", - "ItemRemovedWithName": "{0} u fshi nga libraria", - "ItemAddedWithName": "{0} u shtua tek libraria", "HomeVideos": "Video personale", - "HeaderRecordingGroups": "Grupet e regjistrimit", "HeaderNextUp": "Në vazhdim", "HeaderLiveTV": "TV Live", - "HeaderFavoriteSongs": "Kënget e preferuara", "HeaderFavoriteShows": "Serialet e preferuar", "HeaderFavoriteEpisodes": "Episodet e preferuar", - "HeaderFavoriteArtists": "Artistët e preferuar", - "HeaderFavoriteAlbums": "Albumet e preferuar", "HeaderContinueWatching": "Vazhdo të shikosh", - "HeaderAlbumArtists": "Artistët e albumeve", "Genres": "Zhanret", "Folders": "Skedarët", "Favorites": "Të preferuarat", "FailedLoginAttemptWithUserName": "Përpjekja për hyrje dështoi nga {0}", - "DeviceOnlineWithName": "{0} u lidh", - "DeviceOfflineWithName": "{0} u shkëput", "Collections": "Koleksionet", "ChapterNameValue": "Kapituj", - "Channels": "Kanalet", - "CameraImageUploadedFrom": "Një foto e re nga kamera u ngarkua nga {0}", "Books": "Librat", "AuthenticationSucceededWithUserName": "{0} u identifikua me sukses", "Artists": "Artistët", - "Application": "Aplikacioni", "AppDeviceValues": "Aplikacioni: {0}, Pajisja: {1}", - "Albums": "Albumet", "TaskCleanActivityLogDescription": "Pastro të dhënat mbi aktivitetin më të vjetra sesa koha e përcaktuar.", "TaskCleanActivityLog": "Pastro të dhënat mbi aktivitetin", "Undefined": "I papërcaktuar", @@ -132,10 +103,23 @@ "TaskMoveTrickplayImagesDescription": "Zhvendos skedarët ekzistues të trickplay sipas cilësimeve të bibliotekës.", "TaskDownloadMissingLyrics": "Shkarko tekstet e këngëve që mungojnë", "TaskDownloadMissingLyricsDescription": "Shkarkon tekstet e këngëve", - "TaskCleanCollectionsAndPlaylists": "Pastron koleksionet dhe listat e këngëve", - "TaskCleanCollectionsAndPlaylistsDescription": "Heq elementet nga koleksionet dhe listat e këngëve që nuk ekzistojnë më.", "TaskAudioNormalization": "Normalizimi i audios", "TaskAudioNormalizationDescription": "Skannon skedarët për të dhëna të normalizimit të audios.", "CleanupUserDataTaskDescription": "Pastron të gjitha të dhënat e përdorueseve (gjendja e shikimit, statusi i të preferuarave etj.) nga mediat që nuk janë më të pranishme për të paktën 90 ditë.", - "CleanupUserDataTask": "Veprim për pastrimin të dhënave të përdorueseve" + "CleanupUserDataTask": "Veprim për pastrimin të dhënave të përdorueseve", + "LyricDownloadFailureFromForItem": "Teksti i këngës nuk arriti të shkarkohej nga {0} për {1}", + "Original": "Origjinal", + "NameExtraBehindTheScenes": "Pamje nga prapaskenat", + "NameExtraClip": "Pjesë", + "NameExtraDeletedScene": "Skenë e fshirë", + "NameExtraFeaturette": "Film i shkurtër", + "NameExtraInterview": "Intervistë", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Shembull", + "NameExtraScene": "Skenë", + "NameExtraShort": "Film i shkurtër", + "NameExtraThemeSong": "Kënga e temës", + "NameExtraThemeVideo": "Videoja e temës", + "NameExtraTrailer": "Parapamje", + "NameExtraUnknown": "Shtesë" } diff --git a/Emby.Server.Implementations/Localization/Core/sr.json b/Emby.Server.Implementations/Localization/Core/sr.json index 76a136cf56..de0e425098 100644 --- a/Emby.Server.Implementations/Localization/Core/sr.json +++ b/Emby.Server.Implementations/Localization/Core/sr.json @@ -1,9 +1,6 @@ { - "UserPolicyUpdatedWithName": "Корисничке смернице ажуриране за {0}", "NotificationOptionUserLockedOut": "Корисник закључан", "VersionNumber": "Верзија {0}", - "ValueSpecialEpisodeName": "Специјал - {0}", - "ValueHasBeenAddedToLibrary": "{0} је додато у вашу медијску библиотеку", "UserStoppedPlayingItemWithValues": "{0} завршио пуштање {1} на {2}", "UserStartedPlayingItemWithValues": "{0} пушта {1} на {2}", "UserPasswordChangedWithName": "Лозинка је промењена за корисника {0}", @@ -13,23 +10,14 @@ "UserDownloadingItemWithValues": "{0} преузима {1}", "UserDeletedWithName": "Корисник {0} је обрисан", "UserCreatedWithName": "Корисник {0} је направљен", - "User": "Корисник", "TvShows": "ТВ серије", - "System": "Систем", - "Sync": "Усклади", "SubtitleDownloadFailureFromForItem": "Неуспело преузимање титлова за {1} са {0}", "StartupEmbyServerIsLoading": "Џелифин сервер се подиже. Покушајте поново убрзо.", - "Songs": "Песме", "Shows": "Серије", - "ServerNameNeedsToBeRestarted": "{0} треба поново покренути", - "ScheduledTaskStartedWithName": "{0} покренуто", "ScheduledTaskFailedWithName": "{0} неуспело", - "ProviderValue": "Пружалац: {0}", "PluginUpdatedWithName": "{0} ажуриран", "PluginUninstalledWithName": "{0} деинсталиран", "PluginInstalledWithName": "{0} инсталиран", - "Plugin": "Прикључак", - "Playlists": "Листе", "Photos": "Фотографије", "NotificationOptionVideoPlaybackStopped": "Заустављено пуштање видеа", "NotificationOptionVideoPlayback": "Покренуто пуштање видеа", @@ -54,43 +42,26 @@ "Music": "Музика", "Movies": "Филмови", "MixedContent": "Мешовит садржај", - "MessageServerConfigurationUpdated": "Серверска поставка је ажурирана", - "MessageNamedServerConfigurationUpdatedWithValue": "Одељак серверске поставке {0} је ажуриран", - "MessageApplicationUpdatedTo": "Џелифин сервер је ажуриран на {0}", - "MessageApplicationUpdated": "Џелифин сервер је ажуриран", "Latest": "Последње", "LabelRunningTimeValue": "Време рада: {0}", "LabelIpAddressValue": "ИП адреса: {0}", - "ItemRemovedWithName": "{0} уклоњено из библиотеке", - "ItemAddedWithName": "{0} додато у библиотеку", "Inherit": "Наследи", "HomeVideos": "Кућни Видео", - "HeaderRecordingGroups": "Групе снимања", "HeaderNextUp": "Следи", "HeaderLiveTV": "ТВ уживо", - "HeaderFavoriteSongs": "Омиљене песме", "HeaderFavoriteShows": "Омиљене серије", "HeaderFavoriteEpisodes": "Омиљене епизоде", - "HeaderFavoriteArtists": "Омиљени извођачи", - "HeaderFavoriteAlbums": "Омиљени албуми", "HeaderContinueWatching": "Настави гледање", - "HeaderAlbumArtists": "Извођачи албума", "Genres": "Жанрови", "Folders": "Фасцикле", "Favorites": "Омиљено", "FailedLoginAttemptWithUserName": "Неуспели покушај пријавe са {0}", - "DeviceOnlineWithName": "{0} је повезан", - "DeviceOfflineWithName": "{0} је прекинуо везу", "Collections": "Колекције", "ChapterNameValue": "Поглавље {0}", - "Channels": "Канали", - "CameraImageUploadedFrom": "Нова фотографија је учитана са {0}", "Books": "Књиге", "AuthenticationSucceededWithUserName": "{0} Успешна аутентификација", "Artists": "Извођачи", - "Application": "Апликација", "AppDeviceValues": "Апликација: {0}, Уређај: {1}", - "Albums": "Албуми", "TaskDownloadMissingSubtitlesDescription": "Претражује интернет за недостајуће титлове на основу конфигурације метаподатака.", "TaskDownloadMissingSubtitles": "Преузмите недостајуће титлове", "TaskRefreshChannelsDescription": "Освежава информације о интернет каналу.", @@ -125,17 +96,30 @@ "TaskKeyframeExtractor": "Екстрактор кључних сличица", "HearingImpaired": "ослабљен слух", "TaskAudioNormalization": "Нормализација звука", - "TaskCleanCollectionsAndPlaylists": "Очистите колекције и плејлисте", "TaskAudioNormalizationDescription": "Скенира датотеке за податке о нормализацији звука.", "TaskRefreshTrickplayImages": "Направи сличице за визуелно премотавање", "TaskRefreshTrickplayImagesDescription": "Прављење сличица које помажу код визуелног премотавања видео-снимака.", "TaskDownloadMissingLyrics": "Преузми стихове који недостају", - "TaskCleanCollectionsAndPlaylistsDescription": "Уклања ставке које више не постоје из колекција и плејлиста.", "TaskExtractMediaSegments": "Скенирај сегменте медија", "TaskExtractMediaSegmentsDescription": "Извлачи или добавља сегменте медија у додацима који раде са MediaSegment-ом.", "TaskMoveTrickplayImagesDescription": "Премешта постојеће сличице за визуелно премотавање сходно подешавањима библиотеке.", "CleanupUserDataTask": "Задатак чишћења корисничких података", "CleanupUserDataTaskDescription": "Чисти све корисничке податке (напредак гледања, ознаке за омиљено...) медија који нису доступни 90 дана или дуже.", "TaskMoveTrickplayImages": "Промени локацију сличица за визуелно премотавање", - "TaskDownloadMissingLyricsDescription": "Преузми стихове песама" + "TaskDownloadMissingLyricsDescription": "Преузми стихове песама", + "LyricDownloadFailureFromForItem": "Није успело преузимање стихова са {0} за {1}", + "Original": "Изворно", + "NameExtraBehindTheScenes": "Иза кулиса", + "NameExtraClip": "Исечак", + "NameExtraDeletedScene": "Обрисана сцена", + "NameExtraFeaturette": "Кратки прилог", + "NameExtraInterview": "Интервју", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Узорак", + "NameExtraScene": "Сцена", + "NameExtraShort": "Кратки филм", + "NameExtraThemeSong": "Музичка тема", + "NameExtraThemeVideo": "Спот музичке теме", + "NameExtraTrailer": "Реклама", + "NameExtraUnknown": "Додатак" } diff --git a/Emby.Server.Implementations/Localization/Core/sv.json b/Emby.Server.Implementations/Localization/Core/sv.json index 1ee1a53662..7d741bca36 100644 --- a/Emby.Server.Implementations/Localization/Core/sv.json +++ b/Emby.Server.Implementations/Localization/Core/sv.json @@ -1,41 +1,24 @@ { - "Albums": "Album", "AppDeviceValues": "Applikation: {0}, Enhet: {1}", - "Application": "Applikation", "Artists": "Artister", "AuthenticationSucceededWithUserName": "{0} har autentiserats", "Books": "Böcker", - "CameraImageUploadedFrom": "En ny kamerabild har laddats upp från {0}", - "Channels": "Kanaler", "ChapterNameValue": "Kapitel {0}", "Collections": "Samlingar", - "DeviceOfflineWithName": "{0} har kopplat ned", - "DeviceOnlineWithName": "{0} är ansluten", "FailedLoginAttemptWithUserName": "Misslyckat inloggningsförsök från {0}", "Favorites": "Favoriter", "Folders": "Mappar", "Genres": "Genrer", - "HeaderAlbumArtists": "Albumartister", "HeaderContinueWatching": "Fortsätt titta", - "HeaderFavoriteAlbums": "Favoritalbum", - "HeaderFavoriteArtists": "Favoritartister", "HeaderFavoriteEpisodes": "Favoritavsnitt", "HeaderFavoriteShows": "Favoritserier", - "HeaderFavoriteSongs": "Favoritlåtar", "HeaderLiveTV": "Direktsänd TV", "HeaderNextUp": "Nästa", - "HeaderRecordingGroups": "Inspelningsgrupper", "HomeVideos": "Hemmavideor", "Inherit": "Ärv", - "ItemAddedWithName": "{0} lades till i biblioteket", - "ItemRemovedWithName": "{0} togs bort från biblioteket", "LabelIpAddressValue": "IP-adress: {0}", "LabelRunningTimeValue": "Speltid: {0}", "Latest": "Senaste", - "MessageApplicationUpdated": "Jellyfin Server har uppdaterats", - "MessageApplicationUpdatedTo": "Jellyfin Server har uppdaterats till {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Serverinställningarna {0} har uppdaterats", - "MessageServerConfigurationUpdated": "Serverkonfigurationen har uppdaterats", "MixedContent": "Blandat innehåll", "Movies": "Filmer", "Music": "Musik", @@ -61,24 +44,14 @@ "NotificationOptionVideoPlayback": "Videouppspelning har påbörjats", "NotificationOptionVideoPlaybackStopped": "Videouppspelning stoppades", "Photos": "Bilder", - "Playlists": "Spellistor", - "Plugin": "Tillägg", "PluginInstalledWithName": "{0} installerades", "PluginUninstalledWithName": "{0} avinstallerades", "PluginUpdatedWithName": "{0} uppdaterades", - "ProviderValue": "Leverantör: {0}", "ScheduledTaskFailedWithName": "{0} misslyckades", - "ScheduledTaskStartedWithName": "{0} startades", - "ServerNameNeedsToBeRestarted": "{0} behöver startas om", "Shows": "Serier", - "Songs": "Låtar", "StartupEmbyServerIsLoading": "Jellyfin Server arbetar. Pröva igen snart.", - "SubtitleDownloadFailureForItem": "Nerladdning av undertexter för {0} misslyckades", "SubtitleDownloadFailureFromForItem": "Undertexter kunde inte laddas ner från {0} till {1}", - "Sync": "Synk", - "System": "System", - "TvShows": "TV-serier", - "User": "Användare", + "TvShows": "Tv-serier", "UserCreatedWithName": "Användaren {0} har skapats", "UserDeletedWithName": "Användaren {0} har tagits bort", "UserDownloadingItemWithValues": "{0} laddar ner {1}", @@ -86,11 +59,8 @@ "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 {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}", "TaskDownloadMissingSubtitlesDescription": "Söker på internet efter saknade undertexter baserat på metadata-konfiguration.", "TaskDownloadMissingSubtitles": "Ladda ner saknade undertexter", @@ -127,9 +97,7 @@ "HearingImpaired": "Hörselskadad", "TaskRefreshTrickplayImages": "Generera Trickplay-bilder", "TaskRefreshTrickplayImagesDescription": "Skapar trickplay-förhandsvisningar för videor i aktiverade bibliotek.", - "TaskCleanCollectionsAndPlaylists": "Rensa upp samlingar och spellistor", "TaskAudioNormalization": "Ljudnormalisering", - "TaskCleanCollectionsAndPlaylistsDescription": "Tar bort objekt från samlingar och spellistor som inte längre finns.", "TaskAudioNormalizationDescription": "Skannar filer för ljudnormaliseringsdata.", "TaskDownloadMissingLyrics": "Ladda ner saknad låttext", "TaskDownloadMissingLyricsDescription": "Laddar ner låttexter", @@ -138,5 +106,20 @@ "TaskMoveTrickplayImages": "Migrera platsen för Trickplay-bilder", "TaskMoveTrickplayImagesDescription": "Flyttar befintliga trickplay-filer enligt bibliotekets inställningar.", "CleanupUserDataTaskDescription": "Tar bort all användardata (såsom vad du sett, favoriter med mera) för media som inte funnits på enheten på minst 90 dagar.", - "CleanupUserDataTask": "Uppgift för rensning av användardata" + "CleanupUserDataTask": "Uppgift för rensning av användardata", + "Original": "Original", + "LyricDownloadFailureFromForItem": "Misslyckades att ladda ner låttexter från {0} för {1}", + "NameExtraBehindTheScenes": "Bakom kulisserna", + "NameExtraDeletedScene": "Borttagen scen", + "NameExtraInterview": "Intervju", + "NameExtraNumbered": "{0} {1}", + "NameExtraScene": "Scen", + "NameExtraShort": "Kortfilm", + "NameExtraThemeSong": "Signaturmelodi", + "NameExtraTrailer": "Trailer", + "NameExtraClip": "Klipp", + "NameExtraFeaturette": "Kortfilm", + "NameExtraSample": "Prov", + "NameExtraThemeVideo": "Signaturvideo", + "NameExtraUnknown": "Extra" } diff --git a/Emby.Server.Implementations/Localization/Core/sw.json b/Emby.Server.Implementations/Localization/Core/sw.json new file mode 100644 index 0000000000..c117a0eae2 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/sw.json @@ -0,0 +1,5 @@ +{ + "Artists": "Wasanii", + "Books": "Vitabu", + "Collections": "Mikusanyiko" +} diff --git a/Emby.Server.Implementations/Localization/Core/ta.json b/Emby.Server.Implementations/Localization/Core/ta.json index defdc5925a..256f349926 100644 --- a/Emby.Server.Implementations/Localization/Core/ta.json +++ b/Emby.Server.Implementations/Localization/Core/ta.json @@ -1,13 +1,11 @@ { "VersionNumber": "பதிப்பு {0}", - "ValueSpecialEpisodeName": "சிறப்பு - {0}", "TasksMaintenanceCategory": "பராமரிப்பு", "TaskCleanCache": "தற்காலிக சேமிப்பு கோப்பகத்தை சுத்தம் செய்யவும்", "TaskRefreshChapterImages": "அத்தியாயப் படங்களை பிரித்தெடுக்கவும்", "TaskRefreshPeople": "மக்களைப் புதுப்பிக்கவும்", "TaskCleanTranscode": "டிரான்ஸ்கோட் கோப்பகத்தை சுத்தம் செய்யவும்", "TaskRefreshChannelsDescription": "இணையச் சேனல் தகவல்களைப் புதுப்பிக்கிறது.", - "System": "ஒருங்கியம்", "NotificationOptionTaskFailed": "திட்டமிடப்பட்ட பணி தோல்வியடைந்தது", "NotificationOptionPluginUpdateInstalled": "உட்செருகி புதுப்பிக்கப்பட்டது", "NotificationOptionPluginUninstalled": "உட்செருகி நீக்கப்பட்டது", @@ -15,17 +13,10 @@ "NotificationOptionPluginError": "உட்செருகி செயலிழந்தது", "NotificationOptionCameraImageUploaded": "புகைப்படம் பதிவேற்றப்பட்டது", "MixedContent": "கலப்பு உள்ளடக்கங்கள்", - "MessageServerConfigurationUpdated": "சேவையக அமைப்புகள் புதுப்பிக்கப்பட்டன", - "MessageApplicationUpdatedTo": "ஜெல்லிஃபின் சேவையகம் {0} இற்கு புதுப்பிக்கப்பட்டது", - "MessageApplicationUpdated": "ஜெல்லிஃபின் சேவையகம் புதுப்பிக்கப்பட்டது", "Inherit": "மரபுரிமையாகப் பெறு", - "HeaderRecordingGroups": "பதிவு குழுக்கள்", "Folders": "கோப்புறைகள்", "FailedLoginAttemptWithUserName": "{0} இலிருந்து உள்நுழைவு முயற்சி தோல்வியடைந்தது", - "DeviceOnlineWithName": "{0} இணைக்கப்பட்டது", - "DeviceOfflineWithName": "{0} துண்டிக்கப்பட்டது", "Collections": "தொகுப்புகள்", - "CameraImageUploadedFrom": "{0} இல் இருந்து புதிய புகைப்படம் பதிவேற்றப்பட்டது", "AppDeviceValues": "செயலி: {0}, சாதனம்: {1}", "TaskDownloadMissingSubtitles": "விடுபட்டுபோன வசன வரிகளைப் பதிவிறக்கு", "TaskRefreshChannels": "சேனல்களை புதுப்பி", @@ -34,27 +25,18 @@ "TasksChannelsCategory": "இணைய சேனல்கள்", "TasksApplicationCategory": "செயலி", "TasksLibraryCategory": "நூலகம்", - "UserPolicyUpdatedWithName": "பயனர் கொள்கை {0} இற்கு புதுப்பிக்கப்பட்டுள்ளது", "UserPasswordChangedWithName": "{0} பயனருக்கு கடவுச்சொல் மாற்றப்பட்டுள்ளது", "UserLockedOutWithName": "பயனர் {0} முடக்கப்பட்டார்", "UserDownloadingItemWithValues": "{0} ஆல் {1} பதிவிறக்கப்படுகிறது", "UserDeletedWithName": "பயனர் {0} நீக்கப்பட்டார்", "UserCreatedWithName": "பயனர் {0} உருவாக்கப்பட்டார்", - "User": "பயனர்", "TvShows": "தொலைக்காட்சித் தொடர்கள்", - "Sync": "ஒத்திசைவு", "StartupEmbyServerIsLoading": "ஜெல்லிஃபின் சேவையகம் துவங்குகிறது. சிறிது நேரம் கழித்து முயற்சிக்கவும்.", - "Songs": "பாடல்கள்", "Shows": "நிகழ்ச்சிகள்", - "ServerNameNeedsToBeRestarted": "{0} மறுதொடக்கம் செய்யப்பட வேண்டும்", - "ScheduledTaskStartedWithName": "{0} துவங்கியது", "ScheduledTaskFailedWithName": "{0} தோல்வியடைந்தது", - "ProviderValue": "வழங்குநர்: {0}", "PluginUpdatedWithName": "{0} புதுப்பிக்கப்பட்டது", "PluginUninstalledWithName": "{0} நீக்கப்பட்டது", "PluginInstalledWithName": "{0} நிறுவப்பட்டது", - "Plugin": "உட்செருகி", - "Playlists": "தொடர் பட்டியல்கள்", "Photos": "புகைப்படங்கள்", "NotificationOptionVideoPlaybackStopped": "நிகழ்பட ஒளிபரப்பு நிறுத்தப்பட்டது", "NotificationOptionVideoPlayback": "நிகழ்பட ஒளிபரப்பு துவங்கியது", @@ -75,28 +57,18 @@ "Latest": "புதியவை", "LabelRunningTimeValue": "ஓடும் நேரம்: {0}", "LabelIpAddressValue": "ஐபி முகவரி: {0}", - "ItemRemovedWithName": "{0} நூலகத்திலிருந்து அகற்றப்பட்டது", - "ItemAddedWithName": "{0} நூலகத்தில் சேர்க்கப்பட்டது", "HeaderNextUp": "அடுத்தது", "HeaderLiveTV": "நேரடித் தொலைக்காட்சி", - "HeaderFavoriteSongs": "பிடித்த பாடல்கள்", "HeaderFavoriteShows": "பிடித்த தொடர்கள்", "HeaderFavoriteEpisodes": "பிடித்த அத்தியாயங்கள்", - "HeaderFavoriteArtists": "பிடித்த கலைஞர்கள்", - "HeaderFavoriteAlbums": "பிடித்த ஆல்பங்கள்", "HeaderContinueWatching": "தொடர்ந்து பார்", - "HeaderAlbumArtists": "கலைஞரின் ஆல்பம்", "Genres": "வகைகள்", "Favorites": "பிடித்தவை", "ChapterNameValue": "அத்தியாயம் {0}", - "Channels": "சேனல்கள்", "Books": "புத்தகங்கள்", "AuthenticationSucceededWithUserName": "{0} வெற்றிகரமாக அங்கீகரிக்கப்பட்டது", "Artists": "கலைஞர்கள்", - "Application": "செயலி", - "Albums": "ஆல்பங்கள்", "NewVersionIsAvailable": "ஜெல்லிஃபின் சேவையகத்தின் புதிய பதிப்பு பதிவிறக்கத்திற்கு கிடைக்கிறது.", - "MessageNamedServerConfigurationUpdatedWithValue": "சேவையக உள்ளமைவு பிரிவு {0} புதுப்பிக்கப்பட்டது", "TaskCleanCacheDescription": "கணினிக்கு இனி தேவைப்படாத தற்காலிக கோப்புகளை நீக்கு.", "UserOfflineFromDevice": "{0} இலிருந்து {1} துண்டிக்கப்பட்டுள்ளது", "SubtitleDownloadFailureFromForItem": "வசன வரிகள் {0} இல் இருந்து {1} க்கு பதிவிறக்கத் தவறிவிட்டன", @@ -108,7 +80,6 @@ "TaskCleanLogs": "பதிவு அடைவை சுத்தம் செய்யுங்கள்", "TaskRefreshLibraryDescription": "புதிய கோப்புகளுக்காக உங்கள் ஊடக நூலகத்தை ஆராய்ந்து மீத்தரவை புதுப்பிக்கும்.", "TaskRefreshChapterImagesDescription": "அத்தியாயங்களைக் கொண்ட வீடியோக்களுக்கான சிறு உருவங்களை உருவாக்குகிறது.", - "ValueHasBeenAddedToLibrary": "உங்கள் மீடியா நூலகத்தில் {0} சேர்க்கப்பட்டது", "UserOnlineFromDevice": "{1} இருந்து {0} ஆன்லைன்", "HomeVideos": "முகப்பு வீடியோக்கள்", "UserStoppedPlayingItemWithValues": "{0} {2} இல் {1} முடித்துவிட்டது", @@ -126,8 +97,6 @@ "HearingImpaired": "செவித்திறன் குறைபாடுடையவர்", "TaskRefreshTrickplayImages": "முன்னோட்ட படங்களை உருவாக்கு", "TaskRefreshTrickplayImagesDescription": "செயல்பாட்டில் உள்ள தொகுப்புகளுக்கு முன்னோட்ட படங்களை உருவாக்கும்.", - "TaskCleanCollectionsAndPlaylists": "சேகரிப்புகள் மற்றும் பிளேலிஸ்ட்களை சுத்தம் செய்யவும்", - "TaskCleanCollectionsAndPlaylistsDescription": "சேகரிப்புகள் மற்றும் பிளேலிஸ்ட்களில் இருந்து உருப்படிகளை நீக்குகிறது.", "TaskAudioNormalization": "ஆடியோ இயல்பாக்கம்", "TaskAudioNormalizationDescription": "ஆடியோ இயல்பாக்குதல் தரவுக்காக கோப்புகளை ஸ்கேன் செய்கிறது.", "TaskDownloadMissingLyrics": "விடுபட்ட பாடல் வரிகளைப் பதிவிறக்கவும்", @@ -137,5 +106,20 @@ "TaskExtractMediaSegments": "மீடியா பிரிவு ஸ்கேன்", "TaskExtractMediaSegmentsDescription": "மீடியாசெக்மென்ட் இயக்கப்பட்ட செருகுநிரல்களிலிருந்து மீடியா பிரிவுகளைப் பிரித்தெடுக்கிறது அல்லது பெறுகிறது.", "CleanupUserDataTaskDescription": "குறைந்தது 90 நாட்களுக்கு இல்லாத மீடியாவிலிருந்து அனைத்து பயனர் தரவையும் (கண்காணிப்பு நிலை, பிடித்த நிலை போன்றவை) சுத்தம் செய்கிறது.", - "CleanupUserDataTask": "பயனர் தரவை சுத்தம் செய்யும் பணி" + "CleanupUserDataTask": "பயனர் தரவை சுத்தம் செய்யும் பணி", + "LyricDownloadFailureFromForItem": "{0} இலிருந்து {1} க்கு பாடல் வரிகளைப் பதிவிறக்க முடியவில்லை", + "NameExtraBehindTheScenes": "திரைக்குப் பின்னால்", + "NameExtraClip": "துண்டுக்காட்சி", + "NameExtraDeletedScene": "நீக்கப்பட்ட காட்சிகள்", + "NameExtraFeaturette": "திரைப்பின்னணித் தொகுப்பு", + "NameExtraInterview": "நேர்காணல்", + "NameExtraSample": "மாதிரி", + "NameExtraScene": "காட்சி", + "NameExtraShort": "குறுகிய", + "NameExtraThemeSong": "மையப் பாடல்", + "NameExtraThemeVideo": "மையக் காணொளி", + "NameExtraTrailer": "முன்னோட்டம்", + "NameExtraUnknown": "கூடுதல்", + "Original": "அசல்", + "NameExtraNumbered": "{0} {1}" } diff --git a/Emby.Server.Implementations/Localization/Core/te.json b/Emby.Server.Implementations/Localization/Core/te.json index ca9e345214..7ac770752e 100644 --- a/Emby.Server.Implementations/Localization/Core/te.json +++ b/Emby.Server.Implementations/Localization/Core/te.json @@ -1,51 +1,31 @@ { - "ValueSpecialEpisodeName": "ప్రత్యేక - {0}", - "Sync": "సమకాలీకరించు", - "Songs": "పాటలు", "Shows": "ప్రదర్శనలు", - "Playlists": "ప్లేజాబితాలు", "Photos": "ఫోటోలు", "MusicVideos": "మ్యూజిక్ వీడియోలు", "Music": "సంగీతం", "Movies": "సినిమాలు", "HeaderContinueWatching": "చూడటం కొనసాగించండి", - "HeaderAlbumArtists": "ఆల్బమ్ కళాకారులు", "Genres": "శైలులు", "Forced": "బలవంతంగా", "Folders": "ఫోల్డర్లు", "Favorites": "ఇష్టమైనవి", "Default": "డిఫాల్ట్", "Collections": "సేకరణలు", - "Channels": "ఛానెల్‌లు", "Books": "పుస్తకాలు", "Artists": "కళాకారులు", - "Albums": "ఆల్బమ్‌లు", "HearingImpaired": "వినికిడి లోపం", "HomeVideos": "హోమ్ వీడియోలు", "AppDeviceValues": "అప్లికేషన్ : {0}, పరికరం: {1}", - "Application": "అప్లికేషన్", "AuthenticationSucceededWithUserName": "విజయవంతంగా ఆమోదించబడింది", - "CameraImageUploadedFrom": "{0} నుండి కొత్త కెమెరా చిత్రం అప్‌లోడ్ చేయబడింది", "ChapterNameValue": "అధ్యాయం", - "DeviceOfflineWithName": "{0} డిస్‌కనెక్ట్ చేయబడింది", - "DeviceOnlineWithName": "{0} కనెక్ట్ చేయబడింది", "External": "బాహ్య", "FailedLoginAttemptWithUserName": "{0} నుండి విఫలమైన లాగిన్ ప్రయత్నం", - "HeaderFavoriteAlbums": "ఇష్టమైన ఆల్బమ్‌లు", - "HeaderFavoriteArtists": "ఇష్టమైన కళాకారులు", "HeaderFavoriteEpisodes": "ఇష్టమైన ఎపిసోడ్‌లు", "HeaderFavoriteShows": "ఇష్టమైన ప్రదర్శనలు", - "HeaderFavoriteSongs": "ఇష్టమైన పాటలు", "HeaderLiveTV": "ప్రత్యక్ష TV", "HeaderNextUp": "తదుపరి", - "HeaderRecordingGroups": "రికార్డింగ్ గుంపులు", - "MessageApplicationUpdated": "జెల్లీఫిన్ సర్వర్ అప్‌డేట్ చేయడం పూర్తి అయ్యింది", - "MessageApplicationUpdatedTo": "జెల్లీఫిన్ సర్వర్ {0} వెర్షన్ కి అప్‌డేట్ చెయ్యబడింది", - "MessageServerConfigurationUpdated": "సర్వర్ కన్ఫిగరేషన్ అప్డేట్ చేయబడింది", "NewVersionIsAvailable": "జెల్లీఫిన్ సర్వర్ యొక్క కొత్త వెర్షన్ డౌన్‌లోడ్ చేసుకోవడానికి అందుబాటులో ఉంది.", "NotificationOptionApplicationUpdateInstalled": "అప్లికేషన్ అప్‌డేట్ ఇన్‌స్టాల్ చేయబడింది", - "ItemAddedWithName": "{0} లైబ్రరీకి జోడించబడింది", - "ItemRemovedWithName": "లైబ్రరీ నుండి {0} తీసివేయబడింది", "LabelIpAddressValue": "ఐపీ చిరునామా: {0}", "LabelRunningTimeValue": "నడుస్తున్న సమయం: {0}", "Latest": "తాజా", diff --git a/Emby.Server.Implementations/Localization/Core/th.json b/Emby.Server.Implementations/Localization/Core/th.json index 113e4f30ff..77e526db74 100644 --- a/Emby.Server.Implementations/Localization/Core/th.json +++ b/Emby.Server.Implementations/Localization/Core/th.json @@ -1,10 +1,7 @@ { - "ProviderValue": "ผู้ให้บริการ: {0}", "PluginUpdatedWithName": "อัปเดต {0} แล้ว", "PluginUninstalledWithName": "ถอนการติดตั้ง {0} แล้ว", "PluginInstalledWithName": "ติดตั้ง {0} แล้ว", - "Plugin": "ปลั๊กอิน", - "Playlists": "เพลย์ลิสต์", "Photos": "รูปภาพ", "NotificationOptionVideoPlaybackStopped": "หยุดเล่นวิดีโอ", "NotificationOptionVideoPlayback": "เริ่มเล่นวิดีโอ", @@ -25,53 +22,33 @@ "NewVersionIsAvailable": "เวอร์ชันใหม่ของเซิร์ฟเวอร์ Jellyfin พร้อมให้ดาวน์โหลดแล้ว", "NameSeasonUnknown": "ไม่ทราบซีซัน", "NameSeasonNumber": "ซีซัน {0}", - "NameInstallFailed": "การติดตั้ง {0} ล้มเหลว", + "NameInstallFailed": "ติดตั้ง {0} ไม่สำเร็จ", "MusicVideos": "มิวสิควิดีโอ", - "Music": "ดนตรี", + "Music": "เพลง", "Movies": "ภาพยนตร์", - "MixedContent": "เนื้อหาผสม", - "MessageServerConfigurationUpdated": "อัปเดตการกำหนดค่าเซิร์ฟเวอร์แล้ว", - "MessageNamedServerConfigurationUpdatedWithValue": "อัปเดตการกำหนดค่าเซิร์ฟเวอร์ในส่วน {0} แล้ว", - "MessageApplicationUpdatedTo": "เซิร์ฟเวอร์ Jellyfin ได้รับการอัปเดตเป็น {0}", - "MessageApplicationUpdated": "อัพเดตเซิร์ฟเวอร์ Jellyfin แล้ว", - "Latest": "ล่าสุด", - "LabelRunningTimeValue": "ผ่านไปแล้ว: {0}", - "LabelIpAddressValue": "ที่อยู่ IP: {0}", - "ItemRemovedWithName": "{0} ถูกลบออกจากไลบรารี", - "ItemAddedWithName": "{0} ถูกเพิ่มลงในไลบรารีแล้ว", - "Inherit": "สืบทอด", - "HomeVideos": "โฮมวิดีโอ", - "HeaderRecordingGroups": "กลุ่มการบันทึก", - "HeaderNextUp": "ถัดไป", - "HeaderLiveTV": "ทีวีสด", - "HeaderFavoriteSongs": "เพลงที่ชื่นชอบ", - "HeaderFavoriteShows": "รายการที่ชื่นชอบ", - "HeaderFavoriteEpisodes": "ตอนที่ชื่นชอบ", - "HeaderFavoriteArtists": "ศิลปินที่ชื่นชอบ", - "HeaderFavoriteAlbums": "อัมบั้มที่ชื่นชอบ", + "MixedContent": "เนื้อหาหลากหลายประเภท", + "Latest": "มาใหม่ล่าสุด", + "LabelRunningTimeValue": "ความยาว: {0}", + "LabelIpAddressValue": "หมายเลข IP: {0}", + "Inherit": "ใช้ค่าเริ่มต้น", + "HomeVideos": "วิดีโอส่วนตัว", + "HeaderNextUp": "รายการถัดไป", + "HeaderLiveTV": "ทีวีถ่ายทอดสด", + "HeaderFavoriteShows": "รายการที่ชอบ", + "HeaderFavoriteEpisodes": "ตอนที่ชอบ", "HeaderContinueWatching": "ดูต่อ", - "HeaderAlbumArtists": "ศิลปินอัลบั้ม", "Genres": "ประเภท", "Folders": "โฟลเดอร์", "Favorites": "รายการโปรด", "FailedLoginAttemptWithUserName": "ความพยายามในการเข้าสู่ระบบล้มเหลวจาก {0}", - "DeviceOnlineWithName": "{0} เชื่อมต่อสำเร็จแล้ว", - "DeviceOfflineWithName": "{0} ยกเลิกการเชื่อมต่อแล้ว", "Collections": "คอลเลกชัน", "ChapterNameValue": "บทที่ {0}", - "Channels": "ช่อง", - "CameraImageUploadedFrom": "ภาพถ่ายใหม่ได้ถูกอัปโหลดมาจาก {0}", "Books": "หนังสือ", "AuthenticationSucceededWithUserName": "{0} ยืนยันตัวตนสำเร็จแล้ว", "Artists": "ศิลปิน", - "Application": "แอปพลิเคชัน", "AppDeviceValues": "แอป: {0}, อุปกรณ์: {1}", - "Albums": "อัลบั้ม", - "ScheduledTaskStartedWithName": "{0} เริ่มต้น", "ScheduledTaskFailedWithName": "{0} ล้มเหลว", - "Songs": "เพลง", "Shows": "รายการ", - "ServerNameNeedsToBeRestarted": "{0} ต้องการการรีสตาร์ท", "TaskDownloadMissingSubtitlesDescription": "ค้นหาคำบรรยายที่หายไปในอินเทอร์เน็ตตามค่ากำหนดในข้อมูลเมตา", "TaskDownloadMissingSubtitles": "ดาวน์โหลดคำบรรยายที่ขาดหายไป", "TaskRefreshChannelsDescription": "รีเฟรชข้อมูลช่องอินเทอร์เน็ต", @@ -95,11 +72,8 @@ "TasksLibraryCategory": "ไลบรารี", "TasksMaintenanceCategory": "ปิดซ่อมบำรุง", "VersionNumber": "เวอร์ชัน {0}", - "ValueSpecialEpisodeName": "พิเศษ - {0}", - "ValueHasBeenAddedToLibrary": "เพิ่ม {0} ลงในไลบรารีสื่อของคุณแล้ว", "UserStoppedPlayingItemWithValues": "{0} เล่นเสร็จแล้ว {1} บน {2}", "UserStartedPlayingItemWithValues": "{0} กำลังเล่น {1} บน {2}", - "UserPolicyUpdatedWithName": "มีการอัปเดตนโยบายผู้ใช้ของ {0}", "UserPasswordChangedWithName": "มีการเปลี่ยนรหัสผ่านของผู้ใช้ {0}", "UserOnlineFromDevice": "{0} ออนไลน์จาก {1}", "UserOfflineFromDevice": "{0} ได้ยกเลิกการเชื่อมต่อจาก {1}", @@ -107,10 +81,7 @@ "UserDownloadingItemWithValues": "{0} กำลังดาวน์โหลด {1}", "UserDeletedWithName": "ลบผู้ใช้ {0} แล้ว", "UserCreatedWithName": "สร้างผู้ใช้ {0} แล้ว", - "User": "ผู้ใช้งาน", "TvShows": "รายการทีวี", - "System": "ระบบ", - "Sync": "ซิงค์", "SubtitleDownloadFailureFromForItem": "ไม่สามารถดาวน์โหลดคำบรรยายจาก {0} สำหรับ {1} ได้", "StartupEmbyServerIsLoading": "กำลังโหลดเซิร์ฟเวอร์ Jellyfin โปรดลองอีกครั้งในอีกสักครู่", "Default": "ค่าเริ่มต้น", @@ -130,10 +101,25 @@ "TaskDownloadMissingLyricsDescription": "ดาวน์โหลดเนื้อเพลงสำหรับเพลง", "TaskAudioNormalization": "ปรับระดับเสียงให้สม่ำเสมอ", "TaskAudioNormalizationDescription": "สแกนไฟล์เพื่อค้นหาข้อมูลการปรับระดับเสียงให้สม่ำเสมอ", - "TaskCleanCollectionsAndPlaylists": "จัดระเบียบคอลเลกชันและเพลย์ลิสต์", - "TaskCleanCollectionsAndPlaylistsDescription": "ลบรายการออกจากคอลเลกชันและเพลย์ลิสต์ที่ไม่มีแล้ว", "TaskExtractMediaSegments": "การสแกนส่วนของสื่อมีเดีย", "TaskMoveTrickplayImagesDescription": "ย้ายไฟล์ Trickplay ตามการตั้งค่าของไลบรารี", "TaskExtractMediaSegmentsDescription": "แยกหรือดึงส่วนของสื่อจากปลั๊กอินที่เปิดใช้งาน MediaSegment", - "TaskMoveTrickplayImages": "ย้ายตำแหน่งเก็บภาพตัวอย่าง Trickplay" + "TaskMoveTrickplayImages": "ย้ายตำแหน่งเก็บภาพตัวอย่าง Trickplay", + "CleanupUserDataTask": "ส่วนงานล้างข้อมูลผู้ใช้", + "CleanupUserDataTaskDescription": "ล้างข้อมูลผู้ใช้ทั้งหมด (สถานะการรับชม สถานะรายการโปรด ฯลฯ) จากสื่อที่ไม่ได้ใช้งานแล้วอย่างน้อย 90 วัน", + "LyricDownloadFailureFromForItem": "ดาวน์โหลดเนื้อเพลงจาก {0} สำหรับ {1} ไม่สำเร็จ", + "Original": "ต้นฉบับ", + "NameExtraBehindTheScenes": "เบื้องหลังการถ่ายทำ", + "NameExtraClip": "คลิปวิดีโอ", + "NameExtraDeletedScene": "ฉากที่ถูกตัดออก", + "NameExtraFeaturette": "คลิปสั้นพิเศษ", + "NameExtraInterview": "บทสัมภาษณ์", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "ตัวอย่าง", + "NameExtraScene": "ฉาก", + "NameExtraShort": "ภาพยนตร์สั้น", + "NameExtraThemeSong": "เพลงประกอบ", + "NameExtraThemeVideo": "วิดีโอธีม", + "NameExtraTrailer": "ตัวอย่างภาพยนตร์", + "NameExtraUnknown": "เนื้อหาพิเศษ" } diff --git a/Emby.Server.Implementations/Localization/Core/tr.json b/Emby.Server.Implementations/Localization/Core/tr.json index 478111049f..67f4ecc4d5 100644 --- a/Emby.Server.Implementations/Localization/Core/tr.json +++ b/Emby.Server.Implementations/Localization/Core/tr.json @@ -1,48 +1,31 @@ { - "Albums": "Albümler", - "AppDeviceValues": "Uygulama: {0}, Aygıt: {1}", - "Application": "Uygulama", + "AppDeviceValues": "Uygulama: {0}, Cihaz: {1}", "Artists": "Sanatçılar", "AuthenticationSucceededWithUserName": "{0} kimliği başarıyla doğrulandı", "Books": "Kitaplar", - "CameraImageUploadedFrom": "{0} 'den yeni bir kamera resmi yüklendi", - "Channels": "Kanallar", "ChapterNameValue": "{0}. Bölüm", "Collections": "Koleksiyonlar", - "DeviceOfflineWithName": "{0} bağlantısı kesildi", - "DeviceOnlineWithName": "{0} bağlı", "FailedLoginAttemptWithUserName": "{0} kullanıcısının başarısız oturum açma girişimi", "Favorites": "Favoriler", "Folders": "Klasörler", "Genres": "Türler", - "HeaderAlbumArtists": "Albüm sanatçıları", "HeaderContinueWatching": "İzlemeye Devam Et", - "HeaderFavoriteAlbums": "Favori Albümler", - "HeaderFavoriteArtists": "Favori Sanatçılar", "HeaderFavoriteEpisodes": "Favori Bölümler", "HeaderFavoriteShows": "Favori Diziler", - "HeaderFavoriteSongs": "Favori Şarkılar", "HeaderLiveTV": "Canlı TV", "HeaderNextUp": "Sıradaki Bölümler", - "HeaderRecordingGroups": "Kayıt Grupları", "HomeVideos": "Ana Ekran Videoları", "Inherit": "Devral", - "ItemAddedWithName": "{0} kütüphaneye eklendi", - "ItemRemovedWithName": "{0} kütüphaneden silindi", "LabelIpAddressValue": "IP adresi: {0}", - "LabelRunningTimeValue": "Çalışma süresi: {0}", + "LabelRunningTimeValue": "Oynatma süresi: {0}", "Latest": "En son", - "MessageApplicationUpdated": "Jellyfin Sunucusu güncellendi", - "MessageApplicationUpdatedTo": "Jellyfin Sunucusu {0} sürümüne güncellendi", - "MessageNamedServerConfigurationUpdatedWithValue": "Sunucu yapılandırma bölümü {0} güncellendi", - "MessageServerConfigurationUpdated": "Sunucu yapılandırması güncellendi", "MixedContent": "Karışık içerik", "Movies": "Filmler", "Music": "Müzik", "MusicVideos": "Müzik Videoları", "NameInstallFailed": "{0} kurulumu başarısız", "NameSeasonNumber": "{0}. Sezon", - "NameSeasonUnknown": "Bilinmeyen Sezon", + "NameSeasonUnknown": "Sezon Bilinmiyor", "NewVersionIsAvailable": "Jellyfin Sunucusunun yeni bir sürümü indirmek için hazır.", "NotificationOptionApplicationUpdateAvailable": "Uygulama güncellemesi mevcut", "NotificationOptionApplicationUpdateInstalled": "Uygulama güncellemesi yüklendi", @@ -57,40 +40,27 @@ "NotificationOptionPluginUpdateInstalled": "Eklenti güncellemesi yüklendi", "NotificationOptionServerRestartRequired": "Sunucunun yeniden başlatılması gerekiyor", "NotificationOptionTaskFailed": "Zamanlanmış görev hatası", - "NotificationOptionUserLockedOut": "Kullanıcı kilitlendi", + "NotificationOptionUserLockedOut": "Kullanıcı hesabı kilitlendi", "NotificationOptionVideoPlayback": "Video oynatma başladı", "NotificationOptionVideoPlaybackStopped": "Video oynatma durduruldu", "Photos": "Fotoğraflar", - "Playlists": "Çalma listeleri", - "Plugin": "Eklenti", "PluginInstalledWithName": "{0} yüklendi", "PluginUninstalledWithName": "{0} kaldırıldı", "PluginUpdatedWithName": "{0} güncellendi", - "ProviderValue": "Sağlayıcı: {0}", "ScheduledTaskFailedWithName": "{0} başarısız oldu", - "ScheduledTaskStartedWithName": "{0} başladı", - "ServerNameNeedsToBeRestarted": "{0} yeniden başlatılması gerekiyor", "Shows": "Diziler", - "Songs": "Şarkılar", "StartupEmbyServerIsLoading": "Jellyfin Sunucusu yükleniyor. Lütfen kısa süre sonra tekrar deneyin.", - "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", - "SubtitleDownloadFailureFromForItem": "{1} için alt yazılar {0} sağlayıcısından indirilemedi", - "Sync": "Eşzamanlama", - "System": "Sistem", + "SubtitleDownloadFailureFromForItem": "{1} için altyazılar {0} sağlayıcısından indirilemedi", "TvShows": "Diziler", - "User": "Kullanıcı", "UserCreatedWithName": "{0} kullanıcısı oluşturuldu", "UserDeletedWithName": "{0} kullanıcısı silindi", "UserDownloadingItemWithValues": "{0} kullanıcısı {1} medyasını indiriyor", - "UserLockedOutWithName": "{0} adlı kullanıcı kilitlendi", + "UserLockedOutWithName": "{0} adlı kullanıcı hesabı kilitlendi", "UserOfflineFromDevice": "{0} kullanıcısının {1} ile bağlantısı kesildi", "UserOnlineFromDevice": "{0} kullanıcısı {1} ile çevrimiçi", "UserPasswordChangedWithName": "{0} kullanıcısının parolası değiştirildi", - "UserPolicyUpdatedWithName": "{0} için kullanıcı politikası güncellendi", "UserStartedPlayingItemWithValues": "{0}, {2} cihazında {1} izliyor", "UserStoppedPlayingItemWithValues": "{0}, {2} cihazında {1} izlemeyi bitirdi", - "ValueHasBeenAddedToLibrary": "{0} medya kütüphanenize eklendi", - "ValueSpecialEpisodeName": "Özel - {0}", "VersionNumber": "Sürüm {0}", "TaskCleanCache": "Önbellek Dizinini Temizle", "TasksChannelsCategory": "İnternet Kanalları", @@ -98,8 +68,8 @@ "TasksLibraryCategory": "Kütüphane", "TasksMaintenanceCategory": "Bakım", "TaskRefreshPeopleDescription": "Medya kütüphanenizdeki videoların oyuncu ve yönetmen bilgilerini günceller.", - "TaskDownloadMissingSubtitlesDescription": "Meta veri yapılandırmasına dayalı olarak eksik alt yazılar için internette arama yapar.", - "TaskDownloadMissingSubtitles": "Eksik alt yazıları indir", + "TaskDownloadMissingSubtitlesDescription": "Meta veri yapılandırmasına dayalı olarak eksik altyazılar için internette arama yapar.", + "TaskDownloadMissingSubtitles": "Eksik altyazıları indir", "TaskRefreshChannelsDescription": "Internet kanal bilgilerini yenile.", "TaskRefreshChannels": "Kanalları Yenile", "TaskCleanTranscodeDescription": "Bir günden daha eski kod dönüştürme dosyalarını siler.", @@ -125,18 +95,31 @@ "TaskKeyframeExtractor": "Ana Kare Çıkarıcı", "External": "Harici", "HearingImpaired": "Duyma Engelli", - "TaskRefreshTrickplayImages": "Trickplay Görselleri Oluştur", - "TaskRefreshTrickplayImagesDescription": "Etkin kütüphanelerdeki videolar için trickplay önizlemeleri oluşturur.", - "TaskCleanCollectionsAndPlaylistsDescription": "Artık var olmayan koleksiyon ve çalma listelerindeki ögeleri kaldırır.", - "TaskCleanCollectionsAndPlaylists": "Koleksiyonları ve çalma listelerini temizleyin", + "TaskRefreshTrickplayImages": "Hızlı Önizleme Görsellerini Oluştur", + "TaskRefreshTrickplayImagesDescription": "Etkin kütüphanelerdeki videolar için hızlı önizleme görselleri oluşturur.", "TaskAudioNormalizationDescription": "Ses normalleştirme verileri için dosyaları tarar.", "TaskAudioNormalization": "Ses Normalleştirme", "TaskExtractMediaSegments": "Medya Segmenti Tarama", - "TaskMoveTrickplayImages": "Trickplay Görsel Konumunu Taşıma", - "TaskMoveTrickplayImagesDescription": "Mevcut trickplay dosyalarını kütüphane ayarlarına göre taşır.", + "TaskMoveTrickplayImages": "Hızlı Önizleme Görsel Konumunu Taşıma", + "TaskMoveTrickplayImagesDescription": "Mevcut hızlı önizleme dosyalarını kütüphane ayarlarına göre taşır.", "TaskDownloadMissingLyrics": "Eksik şarkı sözlerini indir", "TaskDownloadMissingLyricsDescription": "Şarkı sözlerini indirir", "TaskExtractMediaSegmentsDescription": "MediaSegment özelliği etkin olan eklentilerden medya segmentlerini çıkarır veya alır.", "CleanupUserDataTask": "Kullanıcı verisi temizleme görevi", - "CleanupUserDataTaskDescription": "En az 90 gün boyunca artık mevcut olmayan medyadaki tüm kullanıcı verilerini (İzleme durumu, favori durumu vb.) temizler." + "CleanupUserDataTaskDescription": "En az 90 gün boyunca artık mevcut olmayan medyadaki tüm kullanıcı verilerini (İzleme durumu, favori durumu vb.) temizler.", + "LyricDownloadFailureFromForItem": "{1} şarkı sözleri {0} adresinden indirilemedi", + "Original": "Orijinal", + "NameExtraBehindTheScenes": "Kamera Arkası", + "NameExtraClip": "Klip", + "NameExtraDeletedScene": "Silinmiş Sahne", + "NameExtraFeaturette": "Kısa film", + "NameExtraInterview": "Röportaj", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Örnek", + "NameExtraScene": "Sahne", + "NameExtraShort": "Kısa", + "NameExtraThemeSong": "Tanıtım Müziği", + "NameExtraThemeVideo": "Tanıtım Videosu", + "NameExtraTrailer": "Fragman", + "NameExtraUnknown": "Fazladan" } diff --git a/Emby.Server.Implementations/Localization/Core/ug.json b/Emby.Server.Implementations/Localization/Core/ug.json index 0bcbffb41a..1d5adecb26 100644 --- a/Emby.Server.Implementations/Localization/Core/ug.json +++ b/Emby.Server.Implementations/Localization/Core/ug.json @@ -1,22 +1,15 @@ { "ChapterNameValue": "باب {0}", - "Channels": "قانال", - "CameraImageUploadedFrom": "{0} ئورۇندىن يېڭى سۈرەت چىقىرىلدى", "Books": "كىتاب", "AuthenticationSucceededWithUserName": "{0} تەستىقلاش مۇۋاپىقىيەتلىك بولدى", "Artists": "سەنئەتكار", - "Albums": "پىلاستىنكا", - "DeviceOnlineWithName": "{0} ئۇلاندى", - "DeviceOfflineWithName": "{0} ئۈزۈلدى", "Collections": "توپلام", - "Application": "ئەپ", "AppDeviceValues": "ئەپ: {0}، ئۈسكۈنە: {1}", "HeaderLiveTV": "تور تېلېۋىزىيەسى", "Default": "سۈكۈتتىكى", "Folders": "ھۆججەت خالتىسى", "Favorites": "ساقلىغۇچ", "LabelRunningTimeValue": "ئىجرا بولغان ۋاقتى:{0}", - "HeaderRecordingGroups": "خاتىرلەش گۇرۇپىسى", "Forced": "ئەڭ", "TaskKeyframeExtractor": "ھالقىلىق رامكا ئاجراتقۇچ", "TaskKeyframeExtractorDescription": "سىن ھۆججەتلىرىدىن رامكا ئاجرىتىپ، تېخىمۇ ئېنىق بولغان HLS قويۇلۇش تىزىملىكىنى قۇرۇلىدۇ. بۇ ۋەزىپە ئۇزۇن داۋام قىلىشى مۇمكىن.", @@ -46,35 +39,23 @@ "TasksLibraryCategory": "مېدىيا ئامبىرى", "TasksMaintenanceCategory": "ئاسراش", "VersionNumber": "نەشرى {0}", - "ValueSpecialEpisodeName": "خاسلىق - {0}", - "ValueHasBeenAddedToLibrary": "{0} مېدىيا ئامبىرىڭىزغا قوشۇلدى", "UserStoppedPlayingItemWithValues": "{0}،{1} نى {2} دە قويۇنشتىن توختىدى", "UserStartedPlayingItemWithValues": "{0}،{1} نى {2} دە قويۇۋاتىدۇ", - "UserPolicyUpdatedWithName": "ئابونتلار سىياسىتى {0} غا يېڭىلاندى", "UserPasswordChangedWithName": "ئابونت{0} ئۈچۈن پارول ئۆزگەرتىلدى", "UserOfflineFromDevice": "{0} بىلەن {1} نىڭ ئالاقىسى ئۈزۈلدى", "UserLockedOutWithName": "ئابونت {0} قۇلۇپلاندى", "UserDownloadingItemWithValues": "{0} چۈشۈرۈۋاتىدۇ {1}", "UserDeletedWithName": "{0} ئابونت ئۆچۈرۈلدى", "UserCreatedWithName": "{0} ئابونت يېڭىدىن قوشۇلدى", - "User": "ئابونت", "Undefined": "بېكىتىلمىگەن", "TvShows": "تىياتىرلار", - "System": "سىستېما", - "Sync": "ماس قەدەمدەش", "SubtitleDownloadFailureFromForItem": "{0} دىن {0} نىڭ فىلىم خېتىنى چۈشۈرگىلى بولمىدى", "StartupEmbyServerIsLoading": "Jellyfin مۇلازىمىتېرى يۈكلىنىۋاتىدۇ. سەل تۇرۇپ قايتا سىناڭ.", - "Songs": "ناخشىلار", "Shows": "پروگراممىلار", - "ServerNameNeedsToBeRestarted": "{0} قايتا قوزغىتىلىشى كېرەك", - "ScheduledTaskStartedWithName": "{0} باشلاندى", "ScheduledTaskFailedWithName": "{0} مەغلۇپ بولدى", - "ProviderValue": "تەمىنلىگۈچى: {0}", "PluginUpdatedWithName": "{0} يېڭىلاندى", "PluginUninstalledWithName": "{0} ئۆچۈرۈلدى", "PluginInstalledWithName": "{0} قاچىلاندى", - "Plugin": "قىستۇرما", - "Playlists": "قويۇش تىزىملىكى", "Photos": "رەسىملەر", "NotificationOptionVideoPlaybackStopped": "سىن قويۇلۇش توختىدى", "NotificationOptionVideoPlayback": "سىن قويۇلدى", @@ -100,24 +81,14 @@ "Music": "مۇزىكا", "Movies": "فىلىملەر", "MixedContent": "ئارىلاشما مەزمۇن", - "MessageNamedServerConfigurationUpdatedWithValue": "مۇلازىمىتېر تەڭشىكىنىڭ {0} قىسمى يېڭىلىنىپ بولدى", - "MessageServerConfigurationUpdated": "مۇلازىمىتېر يېڭىلىنىپ بولدى", - "MessageApplicationUpdated": "Jellyfin مۇلازىمىتېرى يېڭىلاندى", - "MessageApplicationUpdatedTo": "Jellyfin مۇلازىمىتېر نەشرى {0} گە يېڭىلاندى", "Latest": "ئەڭ يېڭى", "LabelIpAddressValue": "{0}: IP ئادرىسى", - "ItemRemovedWithName": "{0} ئامباردىن چىقىرىلدى", - "ItemAddedWithName": "{0} ئامبارغا قوشۇلدى", "Inherit": "داۋاملاشتۇرۇش", "HomeVideos": "ئائىلە سىنلىرى", "HeaderNextUp": "كېيىنكىسى", - "HeaderFavoriteSongs": "ئەڭ ياقتۇرىدىغان ناخشىلار", "HeaderFavoriteShows": "ئەڭ ياقتۇرىدىغان پروگراممىلار", "HeaderFavoriteEpisodes": "ئەڭ ياقتۇرىدىغان تىياتېرلار", - "HeaderFavoriteArtists": "ئەڭ ياقتۇرىدىغان سەنئەتكارلار", - "HeaderFavoriteAlbums": "ياقتۇرىدىغان پىلاستىنكىلار", "HeaderContinueWatching": "داۋاملىق كۆرۈش", - "HeaderAlbumArtists": "پىلاستىنكا سەنئەتكارلىرى", "Genres": "ئۇسلۇبلار", "FailedLoginAttemptWithUserName": "{0} كىرىش ئوڭۇشلۇق بولمىدى", "External": "سىرتقى" diff --git a/Emby.Server.Implementations/Localization/Core/uk.json b/Emby.Server.Implementations/Localization/Core/uk.json index 3ad772aa9c..856740545c 100644 --- a/Emby.Server.Implementations/Localization/Core/uk.json +++ b/Emby.Server.Implementations/Localization/Core/uk.json @@ -2,36 +2,22 @@ "MusicVideos": "Відеокліпи", "Music": "Музика", "Movies": "Фільми", - "MessageApplicationUpdatedTo": "Jellyfin Server оновлено до версії {0}", - "MessageApplicationUpdated": "Jellyfin Server оновлено", "Latest": "Останні", "LabelIpAddressValue": "IP-адреса: {0}", - "ItemRemovedWithName": "{0} видалено з медіатеки", - "ItemAddedWithName": "{0} додано до медіатеки", "HeaderNextUp": "Наступний", "HeaderLiveTV": "Ефірне ТБ", - "HeaderFavoriteSongs": "Обрані пісні", "HeaderFavoriteShows": "Обрані шоу", "HeaderFavoriteEpisodes": "Обрані епізоди", - "HeaderFavoriteArtists": "Обрані виконавці", - "HeaderFavoriteAlbums": "Обрані альбоми", "HeaderContinueWatching": "Продовжити перегляд", - "HeaderAlbumArtists": "Виконавці альбому", "Genres": "Жанри", "Folders": "Теки", "Favorites": "Обрані", - "DeviceOnlineWithName": "Пристрій {0} підключився", - "DeviceOfflineWithName": "Пристрій {0} відключився", "Collections": "Колекції", "ChapterNameValue": "Сцена {0}", - "Channels": "Канали", - "CameraImageUploadedFrom": "Нову фотографію завантажено з {0}", "Books": "Книги", "AuthenticationSucceededWithUserName": "{0} успішно авторизовано", "Artists": "Виконавці", - "Application": "Додаток", "AppDeviceValues": "Додаток: {0}, Пристрій: {1}", - "Albums": "Альбоми", "NotificationOptionServerRestartRequired": "Необхідно перезапустити сервер", "NotificationOptionPluginUpdateInstalled": "Встановлено оновлення плагіна", "NotificationOptionPluginUninstalled": "Плагін видалено", @@ -64,11 +50,8 @@ "TasksLibraryCategory": "Медіатека", "TasksMaintenanceCategory": "Обслуговування", "VersionNumber": "Версія {0}", - "ValueSpecialEpisodeName": "Спецепізод - {0}", - "ValueHasBeenAddedToLibrary": "{0} додано до медіатеки", "UserStoppedPlayingItemWithValues": "{0} закінчив відтворення {1} на {2}", "UserStartedPlayingItemWithValues": "{0} відтворює {1} на {2}", - "UserPolicyUpdatedWithName": "Політика користувача оновлена для {0}", "UserPasswordChangedWithName": "Пароль змінено для користувача {0}", "UserOnlineFromDevice": "{0} підключився з {1}", "UserOfflineFromDevice": "{0} відключився від {1}", @@ -76,23 +59,14 @@ "UserDownloadingItemWithValues": "{0} завантажує {1}", "UserDeletedWithName": "Користувача {0} видалено", "UserCreatedWithName": "Користувача {0} створено", - "User": "Користувач", "TvShows": "ТВ-шоу", - "System": "Система", - "Sync": "Синхронізація", "SubtitleDownloadFailureFromForItem": "Не вдалося завантажити субтитри з {0} для {1}", "StartupEmbyServerIsLoading": "Jellyfin Server завантажується. Будь ласка, спробуйте трішки пізніше.", - "Songs": "Пісні", "Shows": "Серіали", - "ServerNameNeedsToBeRestarted": "{0} потрібно перезапустити", - "ScheduledTaskStartedWithName": "{0} розпочато", "ScheduledTaskFailedWithName": "{0} незавершено, збій", - "ProviderValue": "Постачальник: {0}", "PluginUpdatedWithName": "{0} оновлено", "PluginUninstalledWithName": "{0} видалено", "PluginInstalledWithName": "{0} встановлено", - "Plugin": "Плагін", - "Playlists": "Плейлисти", "Photos": "Фотографії", "NotificationOptionVideoPlaybackStopped": "Відтворення відео зупинено", "NotificationOptionVideoPlayback": "Розпочато відтворення відео", @@ -109,10 +83,7 @@ "NameSeasonNumber": "Сезон {0}", "NameInstallFailed": "Не вдалося встановити {0}", "MixedContent": "Змішаний контент", - "MessageServerConfigurationUpdated": "Конфігурація сервера оновлена", - "MessageNamedServerConfigurationUpdatedWithValue": "Розділ конфігурації сервера {0} оновлено", "Inherit": "Успадкувати", - "HeaderRecordingGroups": "Групи запису", "Forced": "Форсовані", "TaskCleanActivityLogDescription": "Видаляє старші за встановлений термін записи з журналу активності.", "TaskCleanActivityLog": "Очистити журнал активності", @@ -124,18 +95,31 @@ "TaskKeyframeExtractor": "Екстрактор ключових кадрів", "External": "Зовнішній", "HearingImpaired": "З порушеннями слуху", - "TaskRefreshTrickplayImagesDescription": "Створює trickplay-зображення для відео у ввімкнених медіатеках.", - "TaskRefreshTrickplayImages": "Створити Trickplay-зображення", - "TaskCleanCollectionsAndPlaylists": "Очистити колекції і списки відтворення", - "TaskCleanCollectionsAndPlaylistsDescription": "Видаляє елементи з колекцій і списків відтворення, які більше не існують.", + "TaskRefreshTrickplayImagesDescription": "Створює прев'ю-зображення для відео у ввімкнених медіатеках.", + "TaskRefreshTrickplayImages": "Створити Прев'ю-зображення", "TaskAudioNormalizationDescription": "Сканує файли на наявність даних для нормалізації звуку.", "TaskAudioNormalization": "Нормалізація аудіо", "TaskDownloadMissingLyrics": "Завантажити відсутні тексти пісень", "TaskDownloadMissingLyricsDescription": "Завантаження текстів пісень", - "TaskMoveTrickplayImagesDescription": "Переміщує наявні Trickplay-зображення відповідно до налаштувань медіатеки.", + "TaskMoveTrickplayImagesDescription": "Переміщує наявні прев'ю-зображення відповідно до налаштувань медіатеки.", "TaskExtractMediaSegments": "Сканування медіа-сегментів", - "TaskMoveTrickplayImages": "Змінити місце розташування Trickplay-зображень", + "TaskMoveTrickplayImages": "Змінити місце розташування прев'ю-зображень", "TaskExtractMediaSegmentsDescription": "Витягує або отримує медіа-сегменти з плагінів з підтримкою MediaSegment.", "CleanupUserDataTask": "Завдання очищення даних користувача", - "CleanupUserDataTaskDescription": "Очищає всі дані користувача (стан перегляду, статус обраного тощо) з медіа, які перестали бути доступними щонайменше 90 днів тому." + "CleanupUserDataTaskDescription": "Очищає всі дані користувача (стан перегляду, статус обраного тощо) з медіа, які перестали бути доступними щонайменше 90 днів тому.", + "Original": "Оригінал", + "LyricDownloadFailureFromForItem": "Не вдалося завантажити текст пісні з {0} для {1}", + "NameExtraBehindTheScenes": "За лаштунками", + "NameExtraClip": "Кліп", + "NameExtraDeletedScene": "Видалена сцена", + "NameExtraFeaturette": "Фічуретка", + "NameExtraInterview": "Інтерв’ю", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Приклад", + "NameExtraScene": "Сцена", + "NameExtraShort": "Коротко", + "NameExtraThemeSong": "Тематична пісня", + "NameExtraThemeVideo": "Тематичне вiдео", + "NameExtraTrailer": "Трейлер", + "NameExtraUnknown": "Додатково" } diff --git a/Emby.Server.Implementations/Localization/Core/ur.json b/Emby.Server.Implementations/Localization/Core/ur.json index 94d9c8541e..07d309c270 100644 --- a/Emby.Server.Implementations/Localization/Core/ur.json +++ b/Emby.Server.Implementations/Localization/Core/ur.json @@ -1,16 +1,10 @@ { "Books": "کتابیں", "AppDeviceValues": "ایپ: {0}، ڈیوائس: {1}", - "Albums": "البمز", - "Application": "ایپلی کیشن", "Artists": "فنکار", "AuthenticationSucceededWithUserName": "{0} کی کامیابی سے تصدیق ہو چکی ہے", - "CameraImageUploadedFrom": "ایک نئی کیمرے کی تصویر {0} سے اپ لوڈ کی گئی ہے", - "Channels": "چینلز", "ChapterNameValue": "باب {0}", "Collections": "مجموعے", "Default": "ڈیفالٹ", - "DeviceOfflineWithName": "{0} نے رابطہ منقطع کر دیا ہے", - "DeviceOnlineWithName": "{0} منسلک ہے", "External": "بیرونی" } diff --git a/Emby.Server.Implementations/Localization/Core/ur_PK.json b/Emby.Server.Implementations/Localization/Core/ur_PK.json index f6539adff3..b3f24a31e3 100644 --- a/Emby.Server.Implementations/Localization/Core/ur_PK.json +++ b/Emby.Server.Implementations/Localization/Core/ur_PK.json @@ -1,27 +1,17 @@ { - "HeaderFavoriteAlbums": "پسندیدہ البمز", "HeaderNextUp": "اگلا", - "HeaderFavoriteArtists": "پسندیدہ فنکار", - "HeaderAlbumArtists": "البم کے فنکار", "Movies": "فلمیں", "HeaderFavoriteEpisodes": "پسندیدہ اقساط", "Collections": "مجموعے", "Folders": "فولڈرز", "HeaderLiveTV": "براہ راست ٹی وی", - "Channels": "چینلز", "HeaderContinueWatching": "دیکھنا جاری رکھیں", - "Playlists": "پلے لسٹس", - "ValueSpecialEpisodeName": "خصوصی - {0}", "Shows": "دکھاتا ہے", "Genres": "انواع", "Artists": "فنکار", - "Sync": "مطابقت پذیری", "Photos": "تصاویر", - "Albums": "البمز", "Favorites": "پسندیدہ", - "Songs": "گانے", "Books": "کتابیں", - "HeaderFavoriteSongs": "پسندیدہ گانے", "HeaderFavoriteShows": "پسندیدہ شوز", "TaskDownloadMissingSubtitlesDescription": "میٹا ڈیٹا کی تشکیل پر مبنی ذیلی عنوانات کے غائب عنوانات انٹرنیٹ پے تلاش کرتا ہے۔", "TaskDownloadMissingSubtitles": "غائب سب ٹائٹلز ڈاؤن لوڈ کریں", @@ -46,10 +36,8 @@ "TasksLibraryCategory": "لآیبریری", "TasksMaintenanceCategory": "مرمت", "VersionNumber": "ورژن {0}", - "ValueHasBeenAddedToLibrary": "{0} آپ کی میڈیا لائبریری میں شامل کر دیا گیا ہے", "UserStoppedPlayingItemWithValues": "{0} نے {1} چلانا ختم کر دیا ھے {2} پے", "UserStartedPlayingItemWithValues": "{0} چلا رہا ہے {1} {2} پے", - "UserPolicyUpdatedWithName": "صارف {0} کی پالیسی کیلئے تازہ کاری کی گئی ہے", "UserPasswordChangedWithName": "صارف {0} کے لئے پاس ورڈ تبدیل کر دیا گیا ہے", "UserOnlineFromDevice": "{0} آن لائن ہے {1} سے", "UserOfflineFromDevice": "{0} سے منقطع ہوگیا ہے {1}", @@ -57,19 +45,13 @@ "UserDownloadingItemWithValues": "{0} ڈاؤن لوڈ کر رھا ھے {1}", "UserDeletedWithName": "صارف {0} کو ہٹا دیا گیا ہے", "UserCreatedWithName": "صارف {0} تشکیل دیا گیا ہے", - "User": "صارف", "TvShows": "ٹی وی کے پروگرام", - "System": "نظام", "SubtitleDownloadFailureFromForItem": "ذیلی عنوانات {0} سے ڈاؤن لوڈ کرنے میں ناکام {1} کے لیے", "StartupEmbyServerIsLoading": "جیلیفن سرور لوڈ ہورہا ہے۔ براہ کرم جلد ہی دوبارہ کوشش کریں۔", - "ServerNameNeedsToBeRestarted": "{0} دوبارہ چلانے کرنے کی ضرورت ہے", - "ScheduledTaskStartedWithName": "{0} شروع", "ScheduledTaskFailedWithName": "{0} ناکام", - "ProviderValue": "فراہم کرنے والا: {0}", "PluginUpdatedWithName": "{0} تازہ کاری کی گئی تھی", "PluginUninstalledWithName": "[0} ہٹا دیا گیا تھا", "PluginInstalledWithName": "{0} انسٹال کیا گیا تھا", - "Plugin": "پلگن", "NotificationOptionVideoPlaybackStopped": "ویڈیو پلے بیک رک گیا", "NotificationOptionVideoPlayback": "ویڈیو پلے بیک شروع ہوا", "NotificationOptionUserLockedOut": "صارف کو لاک آؤٹ کیا گیا", @@ -93,25 +75,14 @@ "MusicVideos": "میوزک ویڈیوز", "Music": "موسیقی", "MixedContent": "مخلوط مواد", - "MessageServerConfigurationUpdated": "سرور کو اپ ڈیٹ کر دیا گیا ہے", - "MessageNamedServerConfigurationUpdatedWithValue": "سرور ضمن {0} کو ترتیب دے دیا گیا ھے", - "MessageApplicationUpdatedTo": "جیلیفن سرور کو اپ ڈیٹ کیا ہے {0}", - "MessageApplicationUpdated": "جیلیفن سرور کو اپ ڈیٹ کر دیا گیا ہے", "Latest": "تازہ ترین", "LabelRunningTimeValue": "چلانے کی مدت", "LabelIpAddressValue": "آئ پی ایڈریس {0}", - "ItemRemovedWithName": "لائبریری سے ہٹا دیا گیا ھے", - "ItemAddedWithName": "[0} لائبریری میں شامل کیا گیا ھے", "Inherit": "وراثت", "HomeVideos": "ہوم ویڈیوز", - "HeaderRecordingGroups": "ریکارڈنگ گروپس", "FailedLoginAttemptWithUserName": "{0} سے لاگ ان کی ناکام کوشش", - "DeviceOnlineWithName": "{0} متصل ھو چکا ھے", - "DeviceOfflineWithName": "{0} منقطع ھو چکا ھے", "ChapterNameValue": "باب", "AuthenticationSucceededWithUserName": "{0} کامیابی کے ساتھ تصدیق ھوچکی ھے", - "CameraImageUploadedFrom": "ایک نئی کیمرہ تصویر اپ لوڈ کی گئی ہے {0}", - "Application": "پروگرام", "AppDeviceValues": "پروگرام:{0}, ڈیوائس:{1}", "Forced": "جَبری", "Undefined": "غير وضاحتى", diff --git a/Emby.Server.Implementations/Localization/Core/uz.json b/Emby.Server.Implementations/Localization/Core/uz.json index e44b3f5167..3215733c1a 100644 --- a/Emby.Server.Implementations/Localization/Core/uz.json +++ b/Emby.Server.Implementations/Localization/Core/uz.json @@ -1,47 +1,32 @@ { "HeaderContinueWatching": "Ko‘rishda davom etish", - "HeaderAlbumArtists": "Albom ijrochilari", "Genres": "Janrlar", "Folders": "Jildlar", "Favorites": "Sevimlilar", "Collections": "To'plamlar", - "Channels": "Kanallar", "Books": "Kitoblar", "Artists": "Ijrochilar", - "Albums": "Albomlar", "AuthenticationSucceededWithUserName": "{0} muvaffaqiyatli tasdiqlandi", "AppDeviceValues": "Ilova: {0}, Qurilma: {1}", - "Application": "Ilova", - "CameraImageUploadedFrom": "{0}dan yangi kamera rasmi yuklandi", - "DeviceOnlineWithName": "{0} ulangan", - "ItemRemovedWithName": "{0} kutbxonadan o'chirildi", "External": "Tashqi", "FailedLoginAttemptWithUserName": "Muvafaqiyatsiz kirishlar soni {0}", "Forced": "Majburiy", "ChapterNameValue": "{0}chi bo'lim", - "DeviceOfflineWithName": "{0} aloqa uzildi", "HeaderLiveTV": "Jonli TV", "HeaderNextUp": "Keyingisi", - "ItemAddedWithName": "{0} kutbxonaga qo'shildi", "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.", @@ -67,10 +52,6 @@ "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", @@ -78,29 +59,19 @@ "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", @@ -111,5 +82,7 @@ "TaskRefreshChapterImages": "Sahnadan tasvirini chiqarish", "TaskRefreshChapterImagesDescription": "Sahnalarni o'z ichiga olgan videolar uchun eskizlarni yaratadi.", "TaskRefreshLibrary": "Media kutubxonangizni skanerlash", - "TaskCleanLogsDescription": "{0} kundan eski log fayllarni o'chiradi." + "TaskCleanLogsDescription": "{0} kundan eski log fayllarni o'chiradi.", + "Original": "Original", + "LyricDownloadFailureFromForItem": "{0} dan {1} gacha qo'shiq matninin yuklab olishda xatolik ketdi" } diff --git a/Emby.Server.Implementations/Localization/Core/vi.json b/Emby.Server.Implementations/Localization/Core/vi.json index d1c5166cb1..77619b24d0 100644 --- a/Emby.Server.Implementations/Localization/Core/vi.json +++ b/Emby.Server.Implementations/Localization/Core/vi.json @@ -3,17 +3,11 @@ "Favorites": "Yêu Thích", "Folders": "Thư Mục", "Genres": "Thể Loại", - "HeaderAlbumArtists": "Album nghệ sĩ", "HeaderContinueWatching": "Xem Tiếp", "HeaderLiveTV": "TV Trực Tiếp", "Movies": "Phim", "Photos": "Ảnh", - "Playlists": "Danh sách phát", "Shows": "Chương Trình TV", - "Songs": "Bài Hát", - "Sync": "Đồng Bộ", - "ValueSpecialEpisodeName": "Đặc Biệt - {0}", - "Albums": "Album", "Artists": "Ca Sĩ", "TaskDownloadMissingSubtitlesDescription": "Tìm kiếm phụ đề bị thiếu trên Internet dựa trên cấu hình dữ liệu mô tả.", "TaskDownloadMissingSubtitles": "Tải Xuống Phụ Đề Bị Thiếu", @@ -38,10 +32,8 @@ "TasksLibraryCategory": "Thư Viện", "TasksMaintenanceCategory": "Bảo Trì", "VersionNumber": "Phiên Bản {0}", - "ValueHasBeenAddedToLibrary": "{0} đã được thêm vào thư viện của bạn", "UserStoppedPlayingItemWithValues": "{0} đã phát xong {1} trên {2}", "UserStartedPlayingItemWithValues": "{0} đang phát {1} trên {2}", - "UserPolicyUpdatedWithName": "Chính sách người dùng đã được cập nhật cho {0}", "UserPasswordChangedWithName": "Mật khẩu đã được thay đổi cho người dùng {0}", "UserOnlineFromDevice": "{0} trực tuyến từ {1}", "UserOfflineFromDevice": "{0} đã ngắt kết nối từ {1}", @@ -49,19 +41,13 @@ "UserDownloadingItemWithValues": "{0} đang tải xuống {1}", "UserDeletedWithName": "Người Dùng {0} đã được xóa", "UserCreatedWithName": "Người Dùng {0} đã được tạo", - "User": "Người Dùng", "TvShows": "Chương Trình TV", - "System": "Hệ Thống", "SubtitleDownloadFailureFromForItem": "Không thể tải xuống phụ đề từ {0} cho {1}", "StartupEmbyServerIsLoading": "Jellyfin Server đang tải. Vui lòng thử lại trong thời gian ngắn.", - "ServerNameNeedsToBeRestarted": "{0} cần được khởi động lại", - "ScheduledTaskStartedWithName": "{0} đã bắt đầu", "ScheduledTaskFailedWithName": "{0} đã thất bại", - "ProviderValue": "Provider: {0}", "PluginUpdatedWithName": "{0} đã cập nhật", "PluginUninstalledWithName": "{0} đã được gỡ bỏ", "PluginInstalledWithName": "{0} đã được cài đặt", - "Plugin": "Plugin", "NotificationOptionVideoPlaybackStopped": "Đã dừng phát lại video", "NotificationOptionVideoPlayback": "Đã bắt đầu phát lại video", "NotificationOptionUserLockedOut": "Người dùng bị khóa", @@ -85,33 +71,18 @@ "MusicVideos": "Videos Nhạc", "Music": "Nhạc", "MixedContent": "Nội dung hỗn hợp", - "MessageServerConfigurationUpdated": "Cấu hình máy chủ đã được cập nhật", - "MessageNamedServerConfigurationUpdatedWithValue": "Phần cấu hình máy chủ {0} đã được cập nhật", - "MessageApplicationUpdatedTo": "Jellyfin Server đã được cập nhật lên {0}", - "MessageApplicationUpdated": "Jellyfin Server đã được cập nhật", "Latest": "Gần Nhất", "LabelRunningTimeValue": "Thời Gian Chạy: {0}", "LabelIpAddressValue": "Địa chỉ IP: {0}", - "ItemRemovedWithName": "{0} đã xóa khỏi thư viện", - "ItemAddedWithName": "{0} được thêm vào thư viện", "Inherit": "Thừa hưởng", "HomeVideos": "Video Nhà", - "HeaderRecordingGroups": "Nhóm Ghi Video", "HeaderNextUp": "Tiếp Theo", - "HeaderFavoriteSongs": "Bài Hát Yêu Thích", "HeaderFavoriteShows": "Chương Trình Yêu Thích", "HeaderFavoriteEpisodes": "Tập Phim Yêu Thích", - "HeaderFavoriteArtists": "Nghệ Sĩ Yêu Thích", - "HeaderFavoriteAlbums": "Album Ưa Thích", - "FailedLoginAttemptWithUserName": "Nỗ lực đăng nhập không thành công từ {0}", - "DeviceOnlineWithName": "{0} đã kết nối", - "DeviceOfflineWithName": "{0} đã ngắt kết nối", + "FailedLoginAttemptWithUserName": "Cố gắng đăng nhập thất bại từ {0}", "ChapterNameValue": "Phân Cảnh {0}", - "Channels": "Kênh", - "CameraImageUploadedFrom": "Một hình ảnh máy ảnh mới đã được tải lên từ {0}", "Books": "Sách", "AuthenticationSucceededWithUserName": "{0} xác thực thành công", - "Application": "Ứng Dụng", "AppDeviceValues": "Ứng Dụng: {0}, Thiết Bị: {1}", "TaskCleanActivityLogDescription": "Xóa các mục nhật ký hoạt động cũ hơn độ tuổi đã cài đặt.", "TaskCleanActivityLog": "Xóa Nhật Ký Hoạt Động", @@ -124,18 +95,31 @@ "TaskKeyframeExtractorDescription": "Trích xuất khung hình chính từ các tệp video để tạo danh sách phát HLS chính xác hơn. Tác vụ này có thể chạy trong một thời gian dài.", "External": "Bên ngoài", "HearingImpaired": "Khiếm Thính", - "TaskRefreshTrickplayImages": "Tạo Ảnh Xem Trước Trickplay", - "TaskRefreshTrickplayImagesDescription": "Tạo bản xem trước trịckplay cho video trong thư viện đã bật.", - "TaskCleanCollectionsAndPlaylists": "Dọn dẹp bộ sưu tập và danh sách phát", - "TaskCleanCollectionsAndPlaylistsDescription": "Xóa các mục khỏi bộ sưu tập và danh sách phát không còn tồn tại.", + "TaskRefreshTrickplayImages": "Tạo Ảnh Tua Nhanh (Trickplay)", + "TaskRefreshTrickplayImagesDescription": "Tạo ảnh tua nhanh (trịckplay) xem thử cho các video trong các thư viện được kích hoạt.", "TaskAudioNormalization": "Chuẩn Hóa Âm Thanh", "TaskAudioNormalizationDescription": "Quét tập tin để tìm dữ liệu chuẩn hóa âm thanh.", "TaskDownloadMissingLyricsDescription": "Tải xuống lời cho bài hát", "TaskDownloadMissingLyrics": "Tải xuống lời bị thiếu", "TaskExtractMediaSegmentsDescription": "Trích xuất hoặc lấy các phân đoạn phương tiện từ các plugin hỗ trợ MediaSegment.", - "TaskMoveTrickplayImages": "Di chuyển vị trí hình ảnh Trickplay", - "TaskMoveTrickplayImagesDescription": "Di chuyển các tập tin trickplay hiện có theo cài đặt thư viện.", + "TaskMoveTrickplayImages": "Di Chuyển Vị Trí Ảnh Tua Nhanh (Trickplay)", + "TaskMoveTrickplayImagesDescription": "Di chuyển các tệp ảnh tua nhanh (trickplay) hiện có theo cài đặt thư viện.", "TaskExtractMediaSegments": "Quét Phân Đoạn Phương Tiện", "CleanupUserDataTask": "Tác vụ dọn dẹp dữ liệu người dùng", - "CleanupUserDataTaskDescription": "Làm sạch tất cả dữ liệu người dùng (trạng thái xem, trạng thái yêu thích, v.v.) từ phương tiện không còn có mặt trong ít nhất 90 ngày." + "CleanupUserDataTaskDescription": "Làm sạch tất cả dữ liệu người dùng (trạng thái xem, trạng thái yêu thích, v.v.) từ phương tiện không còn có mặt trong ít nhất 90 ngày.", + "Original": "Gốc", + "LyricDownloadFailureFromForItem": "Lời bài hát không tải xuống được từ {0} cho {1}", + "NameExtraBehindTheScenes": "Hậu Trường", + "NameExtraDeletedScene": "Cảnh Bị Xóa", + "NameExtraInterview": "Phỏng vấn", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Mẫu", + "NameExtraScene": "Cảnh", + "NameExtraShort": "Ngắn", + "NameExtraThemeSong": "Bài Hát Chủ Đề", + "NameExtraThemeVideo": "Video Chủ Đề", + "NameExtraFeaturette": "Nội dung phụ", + "NameExtraClip": "Clip ngắn", + "NameExtraTrailer": "Đoạn giới thiệu", + "NameExtraUnknown": "Nội dung bổ sung" } diff --git a/Emby.Server.Implementations/Localization/Core/zh-CN.json b/Emby.Server.Implementations/Localization/Core/zh-CN.json index 1bfa4e3c30..9590d9c1c9 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-CN.json +++ b/Emby.Server.Implementations/Localization/Core/zh-CN.json @@ -1,96 +1,66 @@ { - "Albums": "专辑", "AppDeviceValues": "应用:{0},设备:{1}", - "Application": "应用程序", "Artists": "艺术家", "AuthenticationSucceededWithUserName": "{0} 认证成功", "Books": "书籍", - "CameraImageUploadedFrom": "新的相机图像已从 {0} 上传", - "Channels": "频道", "ChapterNameValue": "章节 {0}", "Collections": "合集", - "DeviceOfflineWithName": "{0} 已断开", - "DeviceOnlineWithName": "{0} 已连接", - "FailedLoginAttemptWithUserName": "来自 {0} 的登录尝试失败", - "Favorites": "我的最爱", + "FailedLoginAttemptWithUserName": "来自 {0} 的登录失败", + "Favorites": "收藏夹", "Folders": "文件夹", "Genres": "类型", - "HeaderAlbumArtists": "专辑艺术家", "HeaderContinueWatching": "继续观看", - "HeaderFavoriteAlbums": "收藏的专辑", - "HeaderFavoriteArtists": "最爱的艺术家", - "HeaderFavoriteEpisodes": "最爱的剧集", - "HeaderFavoriteShows": "最爱的节目", - "HeaderFavoriteSongs": "最爱的歌曲", + "HeaderFavoriteEpisodes": "收藏的剧集", + "HeaderFavoriteShows": "收藏的节目", "HeaderLiveTV": "电视直播", - "HeaderNextUp": "接下来", - "HeaderRecordingGroups": "录制组", + "HeaderNextUp": "接下来播放", "HomeVideos": "家庭视频", "Inherit": "继承", - "ItemAddedWithName": "{0} 已添加到媒体库", - "ItemRemovedWithName": "{0} 已从媒体库中移除", "LabelIpAddressValue": "IP 地址:{0}", "LabelRunningTimeValue": "运行时间:{0}", "Latest": "最新", - "MessageApplicationUpdated": "Jellyfin 服务器已更新", - "MessageApplicationUpdatedTo": "Jellyfin Server 版本已更新为 {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "服务器配置 {0} 部分已更新", - "MessageServerConfigurationUpdated": "服务器配置已更新", "MixedContent": "混合内容", "Movies": "电影", "Music": "音乐", - "MusicVideos": "音乐视频", + "MusicVideos": "MV", "NameInstallFailed": "{0} 安装失败", "NameSeasonNumber": "第 {0} 季", "NameSeasonUnknown": "未知季", - "NewVersionIsAvailable": "Jellyfin Server 有新版本可以下载。", + "NewVersionIsAvailable": "Jellyfin 服务器有新版本可供下载。", "NotificationOptionApplicationUpdateAvailable": "有可用的应用程序更新", "NotificationOptionApplicationUpdateInstalled": "应用程序更新已安装", - "NotificationOptionAudioPlayback": "音频开始播放", + "NotificationOptionAudioPlayback": "音频已开始播放", "NotificationOptionAudioPlaybackStopped": "音频播放已停止", - "NotificationOptionCameraImageUploaded": "相机图片已上传", + "NotificationOptionCameraImageUploaded": "相机照片已上传", "NotificationOptionInstallationFailed": "安装失败", "NotificationOptionNewLibraryContent": "已添加新内容", - "NotificationOptionPluginError": "插件失败", + "NotificationOptionPluginError": "插件出错", "NotificationOptionPluginInstalled": "插件已安装", "NotificationOptionPluginUninstalled": "插件已卸载", - "NotificationOptionPluginUpdateInstalled": "插件更新已安装", + "NotificationOptionPluginUpdateInstalled": "插件已更新", "NotificationOptionServerRestartRequired": "服务器需要重启", "NotificationOptionTaskFailed": "计划任务失败", - "NotificationOptionUserLockedOut": "用户已锁定", - "NotificationOptionVideoPlayback": "视频开始播放", + "NotificationOptionUserLockedOut": "用户已被锁定", + "NotificationOptionVideoPlayback": "视频已开始播放", "NotificationOptionVideoPlaybackStopped": "视频播放已停止", "Photos": "照片", - "Playlists": "播放列表", - "Plugin": "插件", "PluginInstalledWithName": "{0} 已安装", "PluginUninstalledWithName": "{0} 已卸载", "PluginUpdatedWithName": "{0} 已更新", - "ProviderValue": "提供商:{0}", "ScheduledTaskFailedWithName": "{0} 已失败", - "ScheduledTaskStartedWithName": "{0} 已开始", - "ServerNameNeedsToBeRestarted": "{0} 需要重新启动", "Shows": "节目", - "Songs": "歌曲", - "StartupEmbyServerIsLoading": "Jellyfin 服务器加载中。请稍后再试。", - "SubtitleDownloadFailureForItem": "为 {0} 下载字幕失败", + "StartupEmbyServerIsLoading": "Jellyfin 服务器正在启动,请稍后再试。", "SubtitleDownloadFailureFromForItem": "无法从 {0} 下载 {1} 的字幕", - "Sync": "同步", - "System": "系统", "TvShows": "电视剧", - "User": "用户", - "UserCreatedWithName": "用户 {0} 已创建", - "UserDeletedWithName": "用户 {0} 已删除", + "UserCreatedWithName": "已创建用户 {0}", + "UserDeletedWithName": "已删除用户 {0}", "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}", + "UserOnlineFromDevice": "{0} 已在 {1} 上线", + "UserPasswordChangedWithName": "用户 {0} 的密码已更改", + "UserStartedPlayingItemWithValues": "{0} 在 {2} 上开始播放 {1}", + "UserStoppedPlayingItemWithValues": "{0} 在 {2} 上停止播放 {1}", "VersionNumber": "版本 {0}", "TaskUpdatePluginsDescription": "为已设置为自动更新的插件下载和安装更新。", "TaskRefreshPeople": "刷新演职人员", @@ -127,8 +97,6 @@ "HearingImpaired": "听力障碍", "TaskRefreshTrickplayImages": "生成进度条预览图", "TaskRefreshTrickplayImagesDescription": "为启用的媒体库中的视频生成进度条预览图。", - "TaskCleanCollectionsAndPlaylists": "清理合集和播放列表", - "TaskCleanCollectionsAndPlaylistsDescription": "清理合集和播放列表中已不存在的项目。", "TaskAudioNormalization": "音频标准化", "TaskAudioNormalizationDescription": "扫描文件以寻找音频标准化数据。", "TaskDownloadMissingLyrics": "下载缺失的歌词", @@ -138,5 +106,20 @@ "TaskExtractMediaSegmentsDescription": "从支持 MediaSegment 的插件中提取或获取媒体分段。", "TaskMoveTrickplayImagesDescription": "根据媒体库设置移动现有的进度条预览图文件。", "CleanupUserDataTask": "用户数据清理任务", - "CleanupUserDataTaskDescription": "清理已被删除超过90天的媒体中的所有用户数据(观看状态、收藏夹状态等)。" + "CleanupUserDataTaskDescription": "清理已被删除超过90天的媒体中的所有用户数据(观看状态、收藏夹状态等)。", + "LyricDownloadFailureFromForItem": "无法从 {0} 下载 {1} 的歌词", + "Original": "原始", + "NameExtraBehindTheScenes": "幕后花絮", + "NameExtraClip": "片段", + "NameExtraDeletedScene": "删减场景", + "NameExtraFeaturette": "花絮", + "NameExtraInterview": "采访", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "试看", + "NameExtraScene": "精选片段", + "NameExtraShort": "短篇", + "NameExtraThemeSong": "主题曲", + "NameExtraThemeVideo": "主题视频", + "NameExtraTrailer": "预告片", + "NameExtraUnknown": "额外内容" } diff --git a/Emby.Server.Implementations/Localization/Core/zh-HK.json b/Emby.Server.Implementations/Localization/Core/zh-HK.json index 39141d8416..9557742d39 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-HK.json +++ b/Emby.Server.Implementations/Localization/Core/zh-HK.json @@ -1,141 +1,125 @@ { - "Albums": "專輯", - "AppDeviceValues": "程式:{0},設備:{1}", - "Application": "應用程式", + "AppDeviceValues": "程式:{0},裝置:{1}", "Artists": "藝人", - "AuthenticationSucceededWithUserName": "成功授權 {0}", + "AuthenticationSucceededWithUserName": "{0} 成功通過驗證", "Books": "書籍", - "CameraImageUploadedFrom": "{0} 成功上傳一張新照片", - "Channels": "頻道", "ChapterNameValue": "第 {0} 章", "Collections": "系列", - "DeviceOfflineWithName": "{0} 已中斷連接", - "DeviceOnlineWithName": "{0} 已連接", - "FailedLoginAttemptWithUserName": "{0} 登入失敗", - "Favorites": "我的最愛", + "FailedLoginAttemptWithUserName": "來自 {0} 嘅登入嘗試失敗咗", + "Favorites": "心水", "Folders": "資料夾", "Genres": "風格", - "HeaderAlbumArtists": "專輯歌手", - "HeaderContinueWatching": "繼續觀看", - "HeaderFavoriteAlbums": "最愛的專輯", - "HeaderFavoriteArtists": "最愛的藝人", - "HeaderFavoriteEpisodes": "最愛的劇集", - "HeaderFavoriteShows": "最愛的節目", - "HeaderFavoriteSongs": "最愛的歌曲", + "HeaderContinueWatching": "繼續睇返", + "HeaderFavoriteEpisodes": "心水劇集", + "HeaderFavoriteShows": "心水節目", "HeaderLiveTV": "電視直播", - "HeaderNextUp": "接著播放", - "HeaderRecordingGroups": "錄製組", + "HeaderNextUp": "跟住落嚟", "HomeVideos": "家庭影片", "Inherit": "繼承", - "ItemAddedWithName": "{0} 已被加入至媒體庫", - "ItemRemovedWithName": "{0} 已從媒體庫移除", - "LabelIpAddressValue": "IP 地址:{0}", - "LabelRunningTimeValue": "運作時間:{0}", + "LabelIpAddressValue": "IP 位址:{0}", + "LabelRunningTimeValue": "運行時間:{0}", "Latest": "最新", - "MessageApplicationUpdated": "Jellyfin 已被更新", - "MessageApplicationUpdatedTo": "Jellyfin 已被更新至 {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "伺服器設定 {0} 已被更新", - "MessageServerConfigurationUpdated": "已更新伺服器設定", "MixedContent": "混合內容", "Movies": "電影", "Music": "音樂", "MusicVideos": "MV", "NameInstallFailed": "{0} 安裝失敗", "NameSeasonNumber": "第 {0} 季", - "NameSeasonUnknown": "未知的季度", - "NewVersionIsAvailable": "有新版本的 Jellyfin 可供下載。", - "NotificationOptionApplicationUpdateAvailable": "有可用的更新", - "NotificationOptionApplicationUpdateInstalled": "完成更新應用程式", - "NotificationOptionAudioPlayback": "播放音訊", - "NotificationOptionAudioPlaybackStopped": "停止播放音訊", - "NotificationOptionCameraImageUploaded": "相片上傳", + "NameSeasonUnknown": "未知嘅季度", + "NewVersionIsAvailable": "有新版本嘅 Jellyfin 可以下載。", + "NotificationOptionApplicationUpdateAvailable": "有得更新應用程式", + "NotificationOptionApplicationUpdateInstalled": "應用程式更新好咗", + "NotificationOptionAudioPlayback": "開始播放音訊", + "NotificationOptionAudioPlaybackStopped": "停咗播放音訊", + "NotificationOptionCameraImageUploaded": "相機相片上載咗", "NotificationOptionInstallationFailed": "安裝失敗", - "NotificationOptionNewLibraryContent": "新增媒體", - "NotificationOptionPluginError": "插件錯誤", - "NotificationOptionPluginInstalled": "安裝插件", - "NotificationOptionPluginUninstalled": "解除安裝插件", - "NotificationOptionPluginUpdateInstalled": "完成更新插件", - "NotificationOptionServerRestartRequired": "伺服器需要重啟", - "NotificationOptionTaskFailed": "排程工作執行失敗", - "NotificationOptionUserLockedOut": "封鎖用戶", - "NotificationOptionVideoPlayback": "播放影片", - "NotificationOptionVideoPlaybackStopped": "停止播放影片", + "NotificationOptionNewLibraryContent": "加咗新內容", + "NotificationOptionPluginError": "外掛程式錯誤", + "NotificationOptionPluginInstalled": "安裝外掛程式", + "NotificationOptionPluginUninstalled": "解除安裝外掛程式", + "NotificationOptionPluginUpdateInstalled": "外掛程式更新好咗", + "NotificationOptionServerRestartRequired": "伺服器需要重新啟動", + "NotificationOptionTaskFailed": "排程工作失敗", + "NotificationOptionUserLockedOut": "用家被鎖定咗", + "NotificationOptionVideoPlayback": "開始播放影片", + "NotificationOptionVideoPlaybackStopped": "停咗播放影片", "Photos": "相片", - "Playlists": "播放清單", - "Plugin": "插件", - "PluginInstalledWithName": "已安裝 {0}", - "PluginUninstalledWithName": "已移除 {0}", - "PluginUpdatedWithName": "已更新 {0}", - "ProviderValue": "提供者:{0}", + "PluginInstalledWithName": "裝好咗 {0}", + "PluginUninstalledWithName": "剷走咗 {0}", + "PluginUpdatedWithName": "更新好咗 {0}", "ScheduledTaskFailedWithName": "{0} 執行失敗", - "ScheduledTaskStartedWithName": "開始執行 {0}", - "ServerNameNeedsToBeRestarted": "{0} 需要重啟", "Shows": "節目", - "Songs": "歌曲", - "StartupEmbyServerIsLoading": "正在載入 Jellyfin,請稍後再試。", - "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", - "SubtitleDownloadFailureFromForItem": "無法從 {0} 下載 {1} 的字幕", - "Sync": "同步", - "System": "系統", + "StartupEmbyServerIsLoading": "Jellyfin 伺服器載入緊,唔該稍後再試。", + "SubtitleDownloadFailureFromForItem": "經 {0} 下載 {1} 嘅字幕失敗咗", "TvShows": "電視節目", - "User": "用戶", - "UserCreatedWithName": "建立新用戶 {0}", - "UserDeletedWithName": "用戶 {0} 已被移除", - "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}", + "UserCreatedWithName": "經已建立咗新使用者 {0}", + "UserDeletedWithName": "使用者 {0} 經已被刪走", + "UserDownloadingItemWithValues": "{0} 下載緊 {1}", + "UserLockedOutWithName": "使用者 {0} 經已被鎖定", + "UserOfflineFromDevice": "{0} 經已由 {1} 斷開咗連線", + "UserOnlineFromDevice": "{0} 正喺 {1} 連線", + "UserPasswordChangedWithName": "使用者 {0} 嘅密碼經已更改咗", + "UserStartedPlayingItemWithValues": "{0} 正喺 {2} 播緊 {1}", + "UserStoppedPlayingItemWithValues": "{0} 已經喺 {2} 停止播放 {1}", "VersionNumber": "版本 {0}", - "TaskDownloadMissingSubtitles": "下載欠缺字幕", - "TaskUpdatePlugins": "更新插件", + "TaskDownloadMissingSubtitles": "下載漏咗嘅字幕", + "TaskUpdatePlugins": "更新外掛程式", "TasksApplicationCategory": "應用程式", - "TaskRefreshLibraryDescription": "掃描媒體庫以加入新增的檔案及重新載入元數據。", + "TaskRefreshLibraryDescription": "掃描媒體櫃嚟搵新檔案,同時重新載入媒體詳細資料。", "TasksMaintenanceCategory": "維護", - "TaskDownloadMissingSubtitlesDescription": "根據元數據中的設定,在網上搜尋欠缺的字幕。", - "TaskRefreshChannelsDescription": "重新載入網絡頻道的資訊。", + "TaskDownloadMissingSubtitlesDescription": "根據媒體詳細資料設定,喺網上幫你搵返啲欠缺嘅字幕。", + "TaskRefreshChannelsDescription": "重新整理網上頻道嘅資訊。", "TaskRefreshChannels": "重新載入頻道", - "TaskCleanTranscodeDescription": "刪除超過一天的轉碼檔案。", - "TaskCleanTranscode": "清理轉碼檔資料夾", - "TaskUpdatePluginsDescription": "下載並更新能夠被自動更新的插件。", - "TaskRefreshPeopleDescription": "更新你的媒體中有關的演員和導演的元數據。", - "TaskCleanLogsDescription": "刪除超過{0}天的紀錄檔。", - "TaskCleanLogs": "清理紀錄檔資料夾", - "TaskRefreshLibrary": "掃描媒體庫", - "TaskRefreshChapterImagesDescription": "為帶有章節的影片建立縮圖。", - "TaskRefreshChapterImages": "提取章節圖像", - "TaskCleanCacheDescription": "刪除系統不再需要的緩存文件。", - "TaskCleanCache": "清理緩存資料夾", - "TasksChannelsCategory": "網絡頻道", - "TasksLibraryCategory": "媒體庫", + "TaskCleanTranscodeDescription": "自動刪走超過一日嘅轉碼檔案。", + "TaskCleanTranscode": "清理轉碼資料夾", + "TaskUpdatePluginsDescription": "自動幫嗰啲設咗要自動更新嘅外掛程式進行下載同安裝。", + "TaskRefreshPeopleDescription": "更新媒體櫃入面演員同導演嘅媒體詳細資料。", + "TaskCleanLogsDescription": "自動刪走超過 {0} 日嘅紀錄檔。", + "TaskCleanLogs": "清理日誌資料夾", + "TaskRefreshLibrary": "掃描媒體櫃", + "TaskRefreshChapterImagesDescription": "幫有章節嘅影片整返啲章節預覽圖。", + "TaskRefreshChapterImages": "擷取章節圖像", + "TaskCleanCacheDescription": "刪走系統已經唔再需要嘅快取檔案。", + "TaskCleanCache": "清理快取(Cache)資料夾", + "TasksChannelsCategory": "網路頻道", + "TasksLibraryCategory": "媒體櫃", "TaskRefreshPeople": "重新載入人物", - "TaskCleanActivityLog": "清理活動記錄", + "TaskCleanActivityLog": "清理活動紀錄", "Undefined": "未定義", "Forced": "強制", - "Default": "預設", - "TaskOptimizeDatabaseDescription": "壓縮數據庫及釋放可用空間。完成任何會修改數據庫的工作(例如掃描媒體庫)後,執行此工作或可提升伺服器速度。", - "TaskOptimizeDatabase": "最佳化數據庫", - "TaskCleanActivityLogDescription": "刪除早於設定時間的活動記錄。", - "TaskKeyframeExtractorDescription": "提取關鍵影格(Keyframe)以建立更準確的 HLS playlist。此工作可能需要使用較長時間來完成。", + "Default": "初始", + "TaskOptimizeDatabaseDescription": "壓縮數據櫃並釋放剩餘空間。喺掃描媒體櫃或者做咗一啲會修改數據櫃嘅操作之後行呢個任務,或者可以提升效能。", + "TaskOptimizeDatabase": "最佳化數據櫃", + "TaskCleanActivityLogDescription": "刪走超過設定日期嘅活動記錄。", + "TaskKeyframeExtractorDescription": "提取關鍵影格(Keyframe)嚟建立更準確嘅 HLS 播放列表。呢個任務可能要行好耐。", "TaskKeyframeExtractor": "關鍵影格提取器", "External": "外部", "HearingImpaired": "聽力障礙", - "TaskRefreshTrickplayImages": "建立 Trickplay 圖像", - "TaskRefreshTrickplayImagesDescription": "為已啟用 Trickplay 的媒體庫內的影片建立 Trickplay 預覽圖。", - "TaskExtractMediaSegments": "掃描媒體段落", - "TaskExtractMediaSegmentsDescription": "從MediaSegment中被允許的插件獲取媒體段落。", - "TaskDownloadMissingLyrics": "下載欠缺歌詞", - "TaskDownloadMissingLyricsDescription": "下載歌詞", - "TaskCleanCollectionsAndPlaylists": "整理媒體與播放清單", + "TaskRefreshTrickplayImages": "產生搜畫預覽圖", + "TaskRefreshTrickplayImagesDescription": "為已啟用功能嘅媒體櫃影片製作快轉預覽圖。", + "TaskExtractMediaSegments": "掃描媒體分段資訊", + "TaskExtractMediaSegmentsDescription": "從支援 MediaSegment 功能嘅外掛程式入面提取媒體片段。", + "TaskDownloadMissingLyrics": "下載缺失嘅歌詞", + "TaskDownloadMissingLyricsDescription": "幫啲歌下載歌詞", "TaskAudioNormalization": "音訊同等化", - "TaskAudioNormalizationDescription": "掃描檔案裏的音訊同等化資料。", - "TaskCleanCollectionsAndPlaylistsDescription": "從資料庫及播放清單中移除已不存在的項目。", - "TaskMoveTrickplayImagesDescription": "根據媒體庫設定移動現有的 Trickplay 檔案。", - "TaskMoveTrickplayImages": "轉移 Trickplay 影像位置", - "CleanupUserDataTask": "用戶資料清理工作" + "TaskAudioNormalizationDescription": "掃描檔案入面嘅音訊標准化(Audio Normalization)數據。", + "TaskMoveTrickplayImagesDescription": "根據媒體櫃設定,將現有嘅 Trickplay(快轉預覽)檔案搬去對應位置。", + "TaskMoveTrickplayImages": "搬移快轉預覽圖嘅位置", + "CleanupUserDataTask": "清理使用者資料嘅任務", + "CleanupUserDataTaskDescription": "清理已消失至少 90 日嘅媒體用家數據(包括觀看狀態、心水狀態等)。", + "LyricDownloadFailureFromForItem": "冇辦法從 {0} 下載 {1} 嘅歌詞", + "Original": "原始", + "NameExtraBehindTheScenes": "幕後花絮", + "NameExtraClip": "片段", + "NameExtraDeletedScene": "刪減場景", + "NameExtraFeaturette": "花絮", + "NameExtraInterview": "采訪", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "試看", + "NameExtraScene": "精選片段", + "NameExtraShort": "短篇", + "NameExtraThemeSong": "主題曲", + "NameExtraThemeVideo": "主題影片", + "NameExtraTrailer": "預告片", + "NameExtraUnknown": "額外內容" } diff --git a/Emby.Server.Implementations/Localization/Core/zh-TW.json b/Emby.Server.Implementations/Localization/Core/zh-TW.json index b3bb9106b8..bcc5b0b44f 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-TW.json +++ b/Emby.Server.Implementations/Localization/Core/zh-TW.json @@ -1,39 +1,23 @@ { - "Albums": "專輯", "AppDeviceValues": "應用程式:{0},裝置:{1}", - "Application": "應用程式", "Artists": "藝人", "AuthenticationSucceededWithUserName": "成功授權 {0}", "Books": "書籍", - "CameraImageUploadedFrom": "已從 {0} 成功上傳一張照片", - "Channels": "頻道", "ChapterNameValue": "章節 {0}", "Collections": "系列作", - "DeviceOfflineWithName": "{0} 已中斷連接", - "DeviceOnlineWithName": "{0} 已連接", "FailedLoginAttemptWithUserName": "來自 {0} 的登入失敗嘗試", "Favorites": "我的最愛", "Folders": "資料夾", "Genres": "風格", - "HeaderAlbumArtists": "專輯演出者", "HeaderContinueWatching": "繼續觀看", - "HeaderFavoriteAlbums": "最愛專輯", - "HeaderFavoriteArtists": "最愛的藝人", "HeaderFavoriteEpisodes": "最愛的劇集", "HeaderFavoriteShows": "最愛的節目", - "HeaderFavoriteSongs": "最愛的歌曲", "HeaderLiveTV": "電視直播", "HeaderNextUp": "接下來", "HomeVideos": "家庭影片", - "ItemAddedWithName": "{0} 已新增至媒體庫", - "ItemRemovedWithName": "{0} 已從媒體庫移除", "LabelIpAddressValue": "IP 位址:{0}", "LabelRunningTimeValue": "運行時間:{0}", "Latest": "最新", - "MessageApplicationUpdated": "Jellyfin 伺服器已經更新", - "MessageApplicationUpdatedTo": "Jellyfin 伺服器已經更新至 {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "伺服器設定 {0} 部分已經更新", - "MessageServerConfigurationUpdated": "伺服器設定已經更新", "MixedContent": "混合內容", "Movies": "電影", "Music": "音樂", @@ -59,22 +43,13 @@ "NotificationOptionVideoPlayback": "影片播放已開始", "NotificationOptionVideoPlaybackStopped": "影片播放已停止", "Photos": "相片", - "Playlists": "播放清單", - "Plugin": "擴充功能", "PluginInstalledWithName": "已安裝 {0}", "PluginUninstalledWithName": "已移除 {0}", "PluginUpdatedWithName": "已更新 {0}", - "ProviderValue": "提供者:{0}", "ScheduledTaskFailedWithName": "排程任務 {0} 執行失敗", - "ScheduledTaskStartedWithName": "排程任務 {0} 已開始", - "ServerNameNeedsToBeRestarted": "伺服器 {0} 需要重新啟動", "Shows": "節目", - "Songs": "歌曲", "StartupEmbyServerIsLoading": "Jellyfin 伺服器載入中,請稍後再試。", - "Sync": "同步", - "System": "系統", "TvShows": "電視節目", - "User": "使用者", "UserCreatedWithName": "已建立使用者 {0}", "UserDeletedWithName": "已刪除使用者 {0}", "UserDownloadingItemWithValues": "使用者 {0} 正在下載 {1}", @@ -82,13 +57,9 @@ "UserOfflineFromDevice": "使用者 {0} 已從 {1} 斷線", "UserOnlineFromDevice": "使用者 {0} 已從 {1} 連線", "UserPasswordChangedWithName": "使用者 {0} 的密碼已變更", - "UserPolicyUpdatedWithName": "使用者權限已更新為 {0}", "UserStartedPlayingItemWithValues": "{0} 正在 {2} 上播放 {1}", "UserStoppedPlayingItemWithValues": "{0} 已在 {2} 上停止播放 {1}", - "ValueHasBeenAddedToLibrary": "{0} 已新增至您的媒體庫", - "ValueSpecialEpisodeName": "特輯 - {0}", "VersionNumber": "版本 {0}", - "HeaderRecordingGroups": "錄製組", "Inherit": "繼承", "SubtitleDownloadFailureFromForItem": "無法從 {0} 下載 {1} 的字幕", "TaskDownloadMissingSubtitlesDescription": "透過媒體資訊從網路上搜尋遺失的字幕。", @@ -126,8 +97,6 @@ "HearingImpaired": "聽力障礙", "TaskRefreshTrickplayImages": "生成快轉縮圖", "TaskRefreshTrickplayImagesDescription": "為啟用快轉縮圖的媒體庫生成快轉縮圖。", - "TaskCleanCollectionsAndPlaylists": "清理系列作和播放清單", - "TaskCleanCollectionsAndPlaylistsDescription": "清理系列作品與播放清單中已不存在的項目。", "TaskAudioNormalization": "音量標準化", "TaskAudioNormalizationDescription": "掃描文件以找出音量標準化資料。", "TaskDownloadMissingLyrics": "下載缺少的歌詞", @@ -137,5 +106,20 @@ "TaskMoveTrickplayImages": "遷移快轉縮圖位置", "TaskMoveTrickplayImagesDescription": "根據媒體庫的設定遷移快轉縮圖的檔案。", "CleanupUserDataTask": "用戶資料清理工作", - "CleanupUserDataTaskDescription": "從用戶資料中清除已被刪除超過 90 天的媒體的相關資料。" + "CleanupUserDataTaskDescription": "從用戶資料中清除已被刪除超過 90 天的媒體的相關資料。", + "Original": "原作", + "LyricDownloadFailureFromForItem": "無法從 {0} 下載 {1} 的歌詞", + "NameExtraBehindTheScenes": "幕後花絮", + "NameExtraClip": "片段", + "NameExtraDeletedScene": "刪減場景", + "NameExtraFeaturette": "花絮", + "NameExtraInterview": "采訪", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "試看", + "NameExtraScene": "精選片段", + "NameExtraShort": "短篇", + "NameExtraThemeSong": "主題曲", + "NameExtraThemeVideo": "主題影片", + "NameExtraTrailer": "預告片", + "NameExtraUnknown": "額外內容" } diff --git a/Emby.Server.Implementations/Localization/Core/zu.json b/Emby.Server.Implementations/Localization/Core/zu.json index aa056d4498..669ea0372d 100644 --- a/Emby.Server.Implementations/Localization/Core/zu.json +++ b/Emby.Server.Implementations/Localization/Core/zu.json @@ -2,37 +2,24 @@ "TasksApplicationCategory": "Ukusetshenziswa", "TasksLibraryCategory": "Umtapo", "TasksMaintenanceCategory": "Ukunakekela", - "User": "Umsebenzisi", "Undefined": "Akuchaziwe", - "System": "Isistimu", - "Sync": "Vumelanisa", - "Songs": "Amaculo", "Shows": "Izinhlelo", - "Plugin": "Isijobelelo", - "Playlists": "Izinhla Zokudlalayo", "Photos": "Izithombe", "Music": "Umculo", "Movies": "Amamuvi", "Latest": "lwakamuva", "Inherit": "Ngefa", "Forced": "Kuphoqiwe", - "Application": "Ukusetshenziswa", "Genres": "Izinhlobo", "Folders": "Izikhwama", "Favorites": "Izintandokazi", "Default": "Okumisiwe", "Collections": "Amaqoqo", - "Channels": "Amashaneli", "Books": "Izincwadi", "Artists": "Abadlali", - "Albums": "Ama-albhamu", - "CameraImageUploadedFrom": "Kulandelayo lwesithonjana sekhamera selithunyelwe kusuka ku {0}", - "HeaderFavoriteArtists": "Abasethi Abathandekayo", "HeaderFavoriteEpisodes": "Izilimi Ezithandekayo", "HeaderFavoriteShows": "Izisho Ezithandekayo", "External": "Kwezifungo", "FailedLoginAttemptWithUserName": "Ukushayiswa kwesithombe sokungena okungekho {0}", - "HeaderContinueWatching": "Buyela Ukubona", - "HeaderFavoriteAlbums": "Izimpahla Ezithandwayo", - "HeaderAlbumArtists": "Abasethi wenkulumo" + "HeaderContinueWatching": "Buyela Ukubona" } diff --git a/Emby.Server.Implementations/Localization/LocalizationManager.cs b/Emby.Server.Implementations/Localization/LocalizationManager.cs index bc80c2b405..0331ec39e5 100644 --- a/Emby.Server.Implementations/Localization/LocalizationManager.cs +++ b/Emby.Server.Implementations/Localization/LocalizationManager.cs @@ -3,6 +3,7 @@ using System.Collections.Concurrent; using System.Collections.Frozen; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.IO; using System.Linq; using System.Reflection; @@ -26,6 +27,7 @@ namespace Emby.Server.Implementations.Localization private const string RatingsPath = "Emby.Server.Implementations.Localization.Ratings."; private const string CulturesPath = "Emby.Server.Implementations.Localization.iso6392.txt"; private const string CountriesPath = "Emby.Server.Implementations.Localization.countries.json"; + private const string CoreResourcePrefix = "Emby.Server.Implementations.Localization.Core."; private static readonly Assembly _assembly = typeof(LocalizationManager).Assembly; private static readonly string[] _unratedValues = ["n/a", "unrated", "not rated", "nr"]; @@ -34,13 +36,21 @@ namespace Emby.Server.Implementations.Localization private readonly Dictionary<string, Dictionary<string, ParentalRatingScore?>> _allParentalRatings = new(StringComparer.OrdinalIgnoreCase); - private readonly ConcurrentDictionary<string, Dictionary<string, string>> _dictionaries = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary<string, Dictionary<string, string>> _cultureOnlyDictionaries = new(StringComparer.OrdinalIgnoreCase); private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; private readonly ConcurrentDictionary<string, CultureDto?> _cultureCache = new(StringComparer.OrdinalIgnoreCase); private List<CultureDto> _cultures = []; + private static readonly (IReadOnlyList<LocalizationOption> Options, FrozenDictionary<string, string> Bcp47ToJellyfinMap) _localizationData = BuildLocalizationData(); + private static readonly IReadOnlyList<LocalizationOption> _localizationOptions = _localizationData.Options; + + // Maps BCP-47 hyphenated culture codes (set by ASP.NET Core's RequestLocalizationMiddleware + // and used as CurrentUICulture.Name) to Jellyfin's underscore-based resource file codes. + // Built reflexively from the resource file scan so both directions stay in sync. + private static readonly FrozenDictionary<string, string> _bcp47ToJellyfinMap = _localizationData.Bcp47ToJellyfinMap; + private FrozenDictionary<string, string> _iso6392BtoT = null!; /// <summary> @@ -54,6 +64,59 @@ namespace Emby.Server.Implementations.Localization { _configurationManager = configurationManager; _logger = logger; + + _configurationManager.ConfigurationUpdated += OnConfigurationUpdated; + } + + /// <summary> + /// Gets the supported UI cultures. + /// </summary> + /// <returns>A list of <see cref="CultureInfo"/> objects covering every embedded translation.</returns> + public static IList<CultureInfo> GetSupportedUICultures() + { + var cultures = new List<CultureInfo>(); + foreach (var option in _localizationOptions) + { + // Skip novelty codes (e.g. "pr" Pirate, "jbo" Lojban) that .NET cannot resolve. + if (TryGetCultureInfo(option.Value, out var cultureInfo)) + { + cultures.Add(cultureInfo); + } + } + + return cultures; + } + + /// <summary> + /// Resolves a Jellyfin resource culture code (which may use underscores, e.g. <c>es_419</c>) + /// to a <see cref="CultureInfo"/>. Returns <see langword="false"/> for codes .NET cannot resolve. + /// </summary> + private static bool TryGetCultureInfo(string cultureCode, [NotNullWhen(true)] out CultureInfo? cultureInfo) + { + try + { + // Resource files use underscores for some variants (e.g. es_419); + // CultureInfo only accepts hyphenated BCP-47 codes. + cultureInfo = CultureInfo.GetCultureInfo(cultureCode.Replace('_', '-')); + return true; + } + catch (CultureNotFoundException) + { + cultureInfo = null; + return false; + } + } + + private static void OnConfigurationUpdated(object? sender, EventArgs e) + { + if (sender is IServerConfigurationManager configManager) + { + var uiCulture = configManager.Configuration.UICulture; + if (!string.IsNullOrEmpty(uiCulture)) + { + CultureInfo.DefaultThreadCurrentUICulture = new CultureInfo(uiCulture); + } + } } /// <summary> @@ -138,7 +201,7 @@ namespace Emby.Server.Implementations.Localization string twoCharName = parts[2]; if (string.IsNullOrWhiteSpace(twoCharName)) { - continue; + twoCharName = string.Empty; } else if (twoCharName.Contains('-', StringComparison.OrdinalIgnoreCase)) { @@ -198,6 +261,24 @@ namespace Emby.Server.Implementations.Localization _cultures); } + /// <inheritdoc /> + public string? GetLanguageDisplayName(string language) + { + if (string.IsNullOrEmpty(language)) + { + return null; + } + + var displayName = FindLanguageInfo(language)?.DisplayName; + if (displayName is null) + { + return null; + } + + // Truncate at the first delimiter to avoid cluttered display names + return displayName.Split([';', ','], StringSplitOptions.None)[0].Trim(); + } + /// <inheritdoc /> public IReadOnlyList<CountryInfo> GetCountries() { @@ -255,13 +336,13 @@ namespace Emby.Server.Implementations.Localization // 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", new(1000, null))); + ratings.Add(new ParentalRating("XXX", new(1000, null))); } // 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", new(1001, null))); + ratings.Add(new ParentalRating("Banned", new(1001, null))); } return [.. ratings.OrderBy(r => r.RatingScore?.Score).ThenBy(r => r.RatingScore?.SubScore)]; @@ -293,6 +374,27 @@ namespace Emby.Server.Implementations.Localization { ArgumentException.ThrowIfNullOrEmpty(rating); + // Some providers may list multiple ratings separated by '/' (e.g. "SE:15 / SE:15+ / SE:Från 15 år"). + // Try each one in order and use the first that resolves. + var ratingValues = rating.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + foreach (var ratingValue in ratingValues) + { + var score = GetSingleRatingScore(ratingValue, countryCode); + if (score is not null) + { + return score; + } + } + + return null; + } + + /// <summary> + /// Resolves a single rating value to a score. + /// </summary> + private ParentalRatingScore? GetSingleRatingScore(string rating, string? countryCode) + { // Handle unrated content if (_unratedValues.Contains(rating.AsSpan(), StringComparison.OrdinalIgnoreCase)) { @@ -301,7 +403,7 @@ namespace Emby.Server.Implementations.Localization // Convert ints directly // This may override some of the locale specific age ratings (but those always map to the same age) - if (int.TryParse(rating, out var ratingAge)) + if (TryParseRatingAsScore(rating, out var ratingAge)) { return new(ratingAge, null); } @@ -320,6 +422,14 @@ namespace Emby.Server.Implementations.Localization { return value; } + + if (ratingsDictionary is not null && rating.Length > countryCode.Length + && rating.StartsWith(countryCode, StringComparison.OrdinalIgnoreCase) + && (rating[countryCode.Length] == '-' || rating[countryCode.Length] == ':') + && ratingsDictionary.TryGetValue(rating[(countryCode.Length + 1)..].Trim(), out var normalizedValue)) + { + return normalizedValue; + } } else { @@ -345,37 +455,97 @@ namespace Emby.Server.Implementations.Localization } } - // Try splitting by : to handle "Germany: FSK-18" - if (rating.Contains(':', StringComparison.OrdinalIgnoreCase)) + // Try splitting by country prefix separator to handle "US:PG-13", "Germany: FSK-18", "DE-FSK-18" + if (TryGetRatingScoreBySeparator(rating, ':', out var result) + || TryGetRatingScoreBySeparator(rating, '-', out result)) { - var ratingLevelRightPart = rating.AsSpan().RightPart(':'); - if (ratingLevelRightPart.Length != 0) - { - return GetRatingScore(ratingLevelRightPart.ToString()); - } - } - - // Handle prefix country code to handle "DE-18" - if (rating.Contains('-', StringComparison.OrdinalIgnoreCase)) - { - var ratingSpan = rating.AsSpan(); - - // Extract culture from country prefix - var culture = FindLanguageInfo(ratingSpan.LeftPart('-').ToString()); - - var ratingLevelRightPart = ratingSpan.RightPart('-'); - if (ratingLevelRightPart.Length != 0) - { - // Check rating system of culture - return GetRatingScore(ratingLevelRightPart.ToString(), culture?.TwoLetterISOLanguageName); - } + return result; } return null; } + private bool TryGetRatingScoreBySeparator(string rating, char separator, out ParentalRatingScore? result) + { + result = null; + + if (rating.IndexOf(separator, StringComparison.Ordinal) < 0) + { + return false; + } + + var ratingSpan = rating.AsSpan(); + var countryPart = ratingSpan.LeftPart(separator).Trim().ToString(); + var ratingPart = ratingSpan.RightPart(separator).Trim().ToString(); + if (ratingPart.Length == 0) + { + return false; + } + + string? resolvedCountryCode = null; + + if (_allParentalRatings.ContainsKey(countryPart)) + { + resolvedCountryCode = countryPart; + } + else + { + var culture = FindLanguageInfo(countryPart); + if (culture is not null) + { + resolvedCountryCode = culture.TwoLetterISOLanguageName; + } + } + + if (resolvedCountryCode is not null + && _allParentalRatings.TryGetValue(resolvedCountryCode, out var countryRatings)) + { + if (countryRatings.TryGetValue(ratingPart, out result)) + { + return true; + } + + // If it's not a recognized rating string, fall back to using the number as the score + if (TryParseRatingAsScore(ratingPart, out var numericScore)) + { + result = new ParentalRatingScore(numericScore, null); + return true; + } + + _logger.LogWarning( + "Rating '{Rating}' not found in the '{CountryCode}' rating system, treating as unrated", + rating, + resolvedCountryCode); + + return true; + } + + // Country not identified or no rating data available, try recursive lookup + result = GetRatingScore(ratingPart, resolvedCountryCode); + + return true; + } + + /// <summary> + /// Tries to parse a rating as a number, allowing an optional trailing '+' (e.g. "16" or "18+"). + /// </summary> + /// <param name="ratingValue">Rating value to parse.</param> + /// <param name="score">Parsed score.</param> + /// <returns>Returns true if parsing was successful.</returns> + private static bool TryParseRatingAsScore(ReadOnlySpan<char> ratingValue, out int score) + { + var trimmed = ratingValue.TrimEnd('+'); + return int.TryParse(trimmed, out score); + } + /// <inheritdoc /> public string GetLocalizedString(string phrase) + { + return GetLocalizedString(phrase, CultureInfo.CurrentUICulture.Name); + } + + /// <inheritdoc /> + public string GetServerLocalizedString(string phrase) { return GetLocalizedString(phrase, _configurationManager.Configuration.UICulture); } @@ -393,6 +563,12 @@ namespace Emby.Server.Implementations.Localization culture = DefaultCulture; } + // Normalize BCP-47 hyphenated codes to Jellyfin's underscore-based codes + if (_bcp47ToJellyfinMap.TryGetValue(culture, out var mapped)) + { + culture = mapped; + } + var dictionary = GetLocalizationDictionary(culture); if (dictionary.TryGetValue(phrase, out var value)) @@ -400,6 +576,15 @@ namespace Emby.Server.Implementations.Localization return value; } + if (!string.Equals(culture, DefaultCulture, StringComparison.OrdinalIgnoreCase)) + { + var fallback = GetLocalizationDictionary(DefaultCulture); + if (fallback.TryGetValue(phrase, out var fallbackValue)) + { + return fallbackValue; + } + } + return phrase; } @@ -407,28 +592,19 @@ namespace Emby.Server.Implementations.Localization { ArgumentException.ThrowIfNullOrEmpty(culture); - const string Prefix = "Core"; - - return _dictionaries.GetOrAdd( + return _cultureOnlyDictionaries.GetOrAdd( culture, - static (key, localizationManager) => localizationManager.GetDictionary(Prefix, key, DefaultCulture + ".json").GetAwaiter().GetResult(), + static (key, localizationManager) => + { + var dictionary = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); + var namespaceName = localizationManager.GetType().Namespace + ".Core"; + localizationManager.CopyInto(dictionary, namespaceName + "." + GetResourceFilename(key)).GetAwaiter().GetResult(); + + return dictionary; + }, this); } - private async Task<Dictionary<string, string>> GetDictionary(string prefix, string culture, string baseFilename) - { - ArgumentException.ThrowIfNullOrEmpty(culture); - - var dictionary = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); - - var namespaceName = GetType().Namespace + "." + prefix; - - await CopyInto(dictionary, namespaceName + "." + baseFilename).ConfigureAwait(false); - await CopyInto(dictionary, namespaceName + "." + GetResourceFilename(culture)).ConfigureAwait(false); - - return dictionary; - } - private async Task CopyInto(IDictionary<string, string> dictionary, string resourcePath) { using var stream = _assembly.GetManifestResourceStream(resourcePath); @@ -448,11 +624,15 @@ namespace Emby.Server.Implementations.Localization private static string GetResourceFilename(string culture) { - var parts = culture.Split('-'); + // Region codes may use a '-' (BCP-47, e.g. "pt-BR") or '_' (e.g. "es_419", "ar_SA") separator. + // Normalize the casing (lower-case language, upper-case region) while preserving the separator + // so the result matches the embedded resource file name, which is case-sensitive. + var separatorIndex = culture.IndexOfAny(['-', '_']); - if (parts.Length == 2) + if (separatorIndex > 0) { - culture = parts[0].ToLowerInvariant() + "-" + parts[1].ToUpperInvariant(); + var separator = culture[separatorIndex]; + culture = culture[..separatorIndex].ToLowerInvariant() + separator + culture[(separatorIndex + 1)..].ToUpperInvariant(); } else { @@ -465,77 +645,55 @@ namespace Emby.Server.Implementations.Localization /// <inheritdoc /> public IEnumerable<LocalizationOption> GetLocalizationOptions() { - yield return new LocalizationOption("Afrikaans", "af"); - yield return new LocalizationOption("العربية", "ar"); - yield return new LocalizationOption("Беларуская", "be"); - yield return new LocalizationOption("Български", "bg-BG"); - yield return new LocalizationOption("বাংলা (বাংলাদেশ)", "bn"); - yield return new LocalizationOption("Català", "ca"); - yield return new LocalizationOption("Čeština", "cs"); - yield return new LocalizationOption("Cymraeg", "cy"); - yield return new LocalizationOption("Dansk", "da"); - yield return new LocalizationOption("Deutsch", "de"); - yield return new LocalizationOption("English (United Kingdom)", "en-GB"); - yield return new LocalizationOption("English", "en-US"); - yield return new LocalizationOption("Ελληνικά", "el"); - yield return new LocalizationOption("Esperanto", "eo"); - yield return new LocalizationOption("Español", "es"); - yield return new LocalizationOption("Español americano", "es_419"); - yield return new LocalizationOption("Español (Argentina)", "es-AR"); - yield return new LocalizationOption("Español (Dominicana)", "es_DO"); - yield return new LocalizationOption("Español (México)", "es-MX"); - yield return new LocalizationOption("Eesti", "et"); - yield return new LocalizationOption("Basque", "eu"); - yield return new LocalizationOption("فارسی", "fa"); - yield return new LocalizationOption("Suomi", "fi"); - yield return new LocalizationOption("Filipino", "fil"); - yield return new LocalizationOption("Français", "fr"); - yield return new LocalizationOption("Français (Canada)", "fr-CA"); - yield return new LocalizationOption("Galego", "gl"); - yield return new LocalizationOption("Schwiizerdütsch", "gsw"); - yield return new LocalizationOption("עִבְרִית", "he"); - yield return new LocalizationOption("हिन्दी", "hi"); - yield return new LocalizationOption("Hrvatski", "hr"); - yield return new LocalizationOption("Magyar", "hu"); - yield return new LocalizationOption("Bahasa Indonesia", "id"); - yield return new LocalizationOption("Íslenska", "is"); - yield return new LocalizationOption("Italiano", "it"); - yield return new LocalizationOption("日本語", "ja"); - yield return new LocalizationOption("Qazaqşa", "kk"); - yield return new LocalizationOption("한국어", "ko"); - yield return new LocalizationOption("Lietuvių", "lt"); - yield return new LocalizationOption("Latviešu", "lv"); - yield return new LocalizationOption("Македонски", "mk"); - yield return new LocalizationOption("മലയാളം", "ml"); - yield return new LocalizationOption("मराठी", "mr"); - yield return new LocalizationOption("Bahasa Melayu", "ms"); - yield return new LocalizationOption("Norsk bokmål", "nb"); - yield return new LocalizationOption("नेपाली", "ne"); - yield return new LocalizationOption("Nederlands", "nl"); - yield return new LocalizationOption("Norsk nynorsk", "nn"); - yield return new LocalizationOption("ਪੰਜਾਬੀ", "pa"); - yield return new LocalizationOption("Polski", "pl"); - yield return new LocalizationOption("Pirate", "pr"); - yield return new LocalizationOption("Português", "pt"); - yield return new LocalizationOption("Português (Brasil)", "pt-BR"); - yield return new LocalizationOption("Português (Portugal)", "pt-PT"); - yield return new LocalizationOption("Românește", "ro"); - yield return new LocalizationOption("Русский", "ru"); - yield return new LocalizationOption("Slovenčina", "sk"); - yield return new LocalizationOption("Slovenščina", "sl-SI"); - yield return new LocalizationOption("Shqip", "sq"); - yield return new LocalizationOption("Српски", "sr"); - yield return new LocalizationOption("Svenska", "sv"); - yield return new LocalizationOption("தமிழ்", "ta"); - yield return new LocalizationOption("తెలుగు", "te"); - yield return new LocalizationOption("ภาษาไทย", "th"); - yield return new LocalizationOption("Türkçe", "tr"); - yield return new LocalizationOption("Українська", "uk"); - yield return new LocalizationOption("اُردُو", "ur_PK"); - yield return new LocalizationOption("Tiếng Việt", "vi"); - yield return new LocalizationOption("汉语 (简体字)", "zh-CN"); - yield return new LocalizationOption("漢語 (繁體字)", "zh-TW"); - yield return new LocalizationOption("廣東話 (香港)", "zh-HK"); + return _localizationOptions; + } + + private static (IReadOnlyList<LocalizationOption> Options, FrozenDictionary<string, string> Bcp47ToJellyfinMap) BuildLocalizationData() + { + var options = new List<LocalizationOption>(); + var bcp47Map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); + var prefix = CoreResourcePrefix; + + foreach (var resource in _assembly.GetManifestResourceNames()) + { + if (!resource.StartsWith(prefix, StringComparison.Ordinal) + || !resource.EndsWith(".json", StringComparison.Ordinal)) + { + continue; + } + + // Extract culture code from resource name: "...Core.de.json" -> "de", "...Core.pt-BR.json" -> "pt-BR" + var code = resource[prefix.Length..^5]; + + // Record the BCP-47 → Jellyfin mapping for any resource file using underscores. + if (code.Contains('_', StringComparison.Ordinal)) + { + bcp47Map[code.Replace('_', '-')] = code; + } + + // Skip the base language file — en-US is added explicitly below + if (code.Equals(DefaultCulture, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var displayName = GetDisplayName(code); + options.Add(new LocalizationOption(displayName, code)); + } + + // Ensure en-US is always present + options.Add(new LocalizationOption("English", DefaultCulture)); + + options.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase)); + return (options, bcp47Map.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase)); + } + + private static string GetDisplayName(string cultureCode) + { + // Custom/novelty codes like "pr" (Pirate) — fall back to code itself + return TryGetCultureInfo(cultureCode, out var cultureInfo) + ? cultureInfo.NativeName + : cultureCode; } /// <inheritdoc /> diff --git a/Emby.Server.Implementations/Localization/Ratings/ca.json b/Emby.Server.Implementations/Localization/Ratings/ca.json index fa43a8f2b7..76550b64c3 100644 --- a/Emby.Server.Implementations/Localization/Ratings/ca.json +++ b/Emby.Server.Implementations/Localization/Ratings/ca.json @@ -3,7 +3,7 @@ "supportsSubScores": true, "ratings": [ { - "ratingStrings": ["E", "G", "TV-Y", "TV-G"], + "ratingStrings": ["C", "E", "G", "TV-Y", "TV-G"], "ratingScore": { "score": 0, "subScore": 0 @@ -23,11 +23,18 @@ "subScore": 1 } }, + { + "ratingStrings": ["C8"], + "ratingScore": { + "score": 8, + "subScore": 0 + } + }, { "ratingStrings": ["PG", "TV-PG"], "ratingScore": { - "score": 9, - "subScore": 0 + "score": 8, + "subScore": 1 } }, { @@ -38,7 +45,7 @@ } }, { - "ratingStrings": ["TV-14"], + "ratingStrings": ["14+", "TV-14"], "ratingScore": { "score": 14, "subScore": 1 diff --git a/Emby.Server.Implementations/Localization/Ratings/gr.json b/Emby.Server.Implementations/Localization/Ratings/gr.json index 794bf0b313..73abab72d3 100644 --- a/Emby.Server.Implementations/Localization/Ratings/gr.json +++ b/Emby.Server.Implementations/Localization/Ratings/gr.json @@ -10,21 +10,42 @@ } }, { - "ratingStrings": ["K12"], + "ratingStrings": ["K12", "K-12", "12"], + "ratingScore": { + "score": 12, + "subScore": null + } + }, + { + "ratingStrings": ["K13", "K-13", "13"], "ratingScore": { "score": 13, "subScore": null } }, { - "ratingStrings": ["K15"], + "ratingStrings": ["K15", "K-15", "15"], "ratingScore": { "score": 15, "subScore": null } }, { - "ratingStrings": ["K18"], + "ratingStrings": ["K16", "K-16", "16"], + "ratingScore": { + "score": 16, + "subScore": null + } + }, + { + "ratingStrings": ["K17", "K-17", "17"], + "ratingScore": { + "score": 17, + "subScore": null + } + }, + { + "ratingStrings": ["K18", "K-18", "18", "18+"], "ratingScore": { "score": 18, "subScore": null diff --git a/Emby.Server.Implementations/Localization/Ratings/se.json b/Emby.Server.Implementations/Localization/Ratings/se.json index 70084995d1..818565e16b 100644 --- a/Emby.Server.Implementations/Localization/Ratings/se.json +++ b/Emby.Server.Implementations/Localization/Ratings/se.json @@ -10,7 +10,7 @@ } }, { - "ratingStrings": ["7"], + "ratingStrings": ["7", "7+", "7 År", "Från 7 år"], "ratingScore": { "score": 7, "subScore": null @@ -31,7 +31,7 @@ } }, { - "ratingStrings": ["11"], + "ratingStrings": ["11", "11+", "11 År", "Från 11 år"], "ratingScore": { "score": 11, "subScore": null @@ -45,11 +45,18 @@ } }, { - "ratingStrings": ["15"], + "ratingStrings": ["15", "15+", "15 År", "Från 15 år"], "ratingScore": { "score": 15, "subScore": null } + }, + { + "ratingStrings": ["18", "18+", "Barnförbjuden", "Bfj"], + "ratingScore": { + "score": 18, + "subScore": null + } } ] } diff --git a/Emby.Server.Implementations/Localization/countries.json b/Emby.Server.Implementations/Localization/countries.json index d92dc880b1..811a7d4094 100644 --- a/Emby.Server.Implementations/Localization/countries.json +++ b/Emby.Server.Implementations/Localization/countries.json @@ -749,6 +749,12 @@ "ThreeLetterISORegionName": "TAJ", "TwoLetterISORegionName": "TJ" }, + { + "DisplayName": "Tanzania", + "Name": "TZ", + "ThreeLetterISORegionName": "TZA", + "TwoLetterISORegionName": "TZ" + }, { "DisplayName": "Thailand", "Name": "TH", diff --git a/Emby.Server.Implementations/Localization/iso6392.txt b/Emby.Server.Implementations/Localization/iso6392.txt index 4ce739c7e0..d5a7e866b8 100644 --- a/Emby.Server.Implementations/Localization/iso6392.txt +++ b/Emby.Server.Implementations/Localization/iso6392.txt @@ -347,8 +347,8 @@ pli||pi|Pali|pali pol||pl|Polish|polonais pon|||Pohnpeian|pohnpei por||pt|Portuguese|portugais -pop||pt-pt|Portuguese (Portugal)|portugais (pt-pt) -pob||pt-br|Portuguese (Brazil)|portugais (pt-br) +por||pt-pt|Portuguese (Portugal)|portugais (pt-pt) +por||pt-br|Portuguese (Brazil)|portugais (pt-br) pra|||Prakrit languages|prâkrit, langues pro|||Provençal, Old (to 1500)|provençal ancien (jusqu'à 1500) pus||ps|Pushto; Pashto|pachto diff --git a/Emby.Server.Implementations/MediaEncoding/RedisTranscodeSessionStore.cs b/Emby.Server.Implementations/MediaEncoding/RedisTranscodeSessionStore.cs index 6ac5c01205..1f75b59f47 100644 --- a/Emby.Server.Implementations/MediaEncoding/RedisTranscodeSessionStore.cs +++ b/Emby.Server.Implementations/MediaEncoding/RedisTranscodeSessionStore.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Linq; using System.Text.Json; +using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.MediaEncoding; @@ -18,26 +19,42 @@ namespace Emby.Server.Implementations.MediaEncoding; public sealed class RedisTranscodeSessionStore : ITranscodeSessionStore { private const string KeyPrefix = "jellyfin:transcode:"; - private const string LiveStreamKeyPrefix = "jellyfin:livestream:"; /// <summary> /// Lua script for atomic takeover: reads the stored session, checks whether the lease has - /// expired (comparing <c>LeaseExpiresUtc.Ticks</c> against the caller-supplied current ticks), - /// and if expired, updates the owner and expiry before returning 1; returns 0 otherwise. + /// expired (comparing the stored expiry against the caller-supplied current time) and, if it + /// has, claims it for the calling pod before returning 1; returns 0 otherwise. /// </summary> private const string TakeoverScript = @" local raw = redis.call('GET', KEYS[1]) if not raw then return 0 end local session = cjson.decode(raw) -local currentTicks = tonumber(ARGV[1]) -if session['LeaseExpiresUtc'] > currentTicks then return 0 end +if tonumber(session['LeaseExpiresUtc']) > tonumber(ARGV[1]) then return 0 end session['OwnerPod'] = ARGV[2] -local leaseDurationMs = tonumber(ARGV[3]) -local newTicks = currentTicks + (leaseDurationMs * 10000) -session['LeaseExpiresUtc'] = newTicks -redis.call('SET', KEYS[1], cjson.encode(session), 'PX', leaseDurationMs) +session['LeaseExpiresUtc'] = tonumber(ARGV[1]) + tonumber(ARGV[3]) +redis.call('SET', KEYS[1], cjson.encode(session), 'PX', tonumber(ARGV[4])) return 1"; + /// <summary> + /// Lua script for atomic, ownership-checked renewal: extends the lease only while the calling + /// pod still owns it, so a renewal racing a successful takeover cannot revert the new owner. + /// </summary> + private const string RenewScript = @" +local raw = redis.call('GET', KEYS[1]) +if not raw then return 0 end +local session = cjson.decode(raw) +if session['OwnerPod'] ~= ARGV[2] then return 0 end +session['LeaseExpiresUtc'] = tonumber(ARGV[1]) + tonumber(ARGV[3]) +redis.call('SET', KEYS[1], cjson.encode(session), 'PX', tonumber(ARGV[4])) +return 1"; + + // The lease expiry is serialized as unix milliseconds because the Lua scripts compare it + // numerically; an ISO-8601 string cannot be compared against a number in Lua. + private static readonly JsonSerializerOptions _jsonOptions = new JsonSerializerOptions + { + Converters = { new UnixMillisecondsDateTimeConverter() } + }; + private readonly IConnectionMultiplexer _redis; private readonly IDatabase _db; private readonly TranscodeStoreOptions _options; @@ -60,13 +77,16 @@ return 1"; _logger = logger; } + private long LeaseDurationMs => (long)_options.LeaseDurationSeconds * 1000; + + private long RetentionMs => Math.Max((long)_options.SessionRetentionSeconds * 1000, LeaseDurationMs); + /// <inheritdoc /> public async Task SetAsync(TranscodeSession session, CancellationToken cancellationToken = default) { var key = GetKey(session.PlaySessionId); - var json = JsonSerializer.Serialize(session); - var leaseDurationMs = (long)_options.LeaseDurationSeconds * 1000; - await _db.StringSetAsync(key, json, TimeSpan.FromMilliseconds(leaseDurationMs)).ConfigureAwait(false); + var json = JsonSerializer.Serialize(session, _jsonOptions); + await _db.StringSetAsync(key, json, TimeSpan.FromMilliseconds(RetentionMs)).ConfigureAwait(false); _logger.LogDebug("Set transcode session {PlaySessionId} in Redis.", session.PlaySessionId); } @@ -80,10 +100,10 @@ return 1"; return null; } - var session = JsonSerializer.Deserialize<TranscodeSession>(raw.ToString()); + var session = JsonSerializer.Deserialize<TranscodeSession>(raw.ToString(), _jsonOptions); - // Check LeaseExpiresUtc in addition to Redis TTL to guard against the window between - // Redis TTL evaluation and the GET result being returned to the caller. + // The record outlives the lease so that an orphaned session can still be taken over, + // so the lease has to be checked explicitly here. if (session is null || session.LeaseExpiresUtc <= DateTime.UtcNow) { return null; @@ -93,26 +113,24 @@ return 1"; } /// <inheritdoc /> - public async Task RenewLeaseAsync(string playSessionId, CancellationToken cancellationToken = default) + public async Task<bool> RenewLeaseAsync(string playSessionId, string ownerPod, CancellationToken cancellationToken = default) { - var key = GetKey(playSessionId); - var raw = await _db.StringGetAsync(key).ConfigureAwait(false); - if (!raw.HasValue) + var result = (long?)await _db.ScriptEvaluateAsync( + RenewScript, + keys: new RedisKey[] { GetKey(playSessionId) }, + values: new RedisValue[] { UnixMillisecondsNow(), ownerPod, LeaseDurationMs, RetentionMs }).ConfigureAwait(false); + + if (result != 1) { - return; + _logger.LogWarning( + "Pod {OwnerPod} no longer owns transcode session {PlaySessionId}; lease not renewed.", + ownerPod, + playSessionId); + return false; } - var session = JsonSerializer.Deserialize<TranscodeSession>(raw.ToString()); - if (session is null) - { - return; - } - - var leaseDurationMs = (long)_options.LeaseDurationSeconds * 1000; - session.LeaseExpiresUtc = DateTime.UtcNow.AddMilliseconds(leaseDurationMs); - var json = JsonSerializer.Serialize(session); - await _db.StringSetAsync(key, json, TimeSpan.FromMilliseconds(leaseDurationMs)).ConfigureAwait(false); _logger.LogDebug("Renewed lease for transcode session {PlaySessionId}.", playSessionId); + return true; } /// <inheritdoc /> @@ -126,14 +144,10 @@ return 1"; /// <inheritdoc /> public async Task<bool> TryTakeoverAsync(string playSessionId, string claimingPod, CancellationToken cancellationToken = default) { - var key = GetKey(playSessionId); - var leaseDurationMs = (long)_options.LeaseDurationSeconds * 1000; - var currentTicks = DateTime.UtcNow.Ticks; - var result = (long?)await _db.ScriptEvaluateAsync( TakeoverScript, - keys: new RedisKey[] { key }, - values: new RedisValue[] { currentTicks, claimingPod, leaseDurationMs }).ConfigureAwait(false); + keys: new RedisKey[] { GetKey(playSessionId) }, + values: new RedisValue[] { UnixMillisecondsNow(), claimingPod, LeaseDurationMs, RetentionMs }).ConfigureAwait(false); var succeeded = result == 1; if (succeeded) @@ -144,11 +158,6 @@ return 1"; return succeeded; } - private static string GetKey(string playSessionId) => KeyPrefix + playSessionId; - - private static string GetLiveStreamKey(string liveStreamId, string sessionIdOrPlaySessionId) - => LiveStreamKeyPrefix + liveStreamId + ":" + sessionIdOrPlaySessionId; - /// <inheritdoc /> public async Task<IEnumerable<TranscodeSession>> GetActiveSessionsAsync(CancellationToken cancellationToken = default) { @@ -181,7 +190,7 @@ return 1"; TranscodeSession? session; try { - session = JsonSerializer.Deserialize<TranscodeSession>(raw.ToString()); + session = JsonSerializer.Deserialize<TranscodeSession>(raw.ToString(), _jsonOptions); } catch (Exception ex) { @@ -189,7 +198,7 @@ return 1"; continue; } - if (session is not null) + if (session is not null && session.LeaseExpiresUtc > DateTime.UtcNow) { sessions.Add(session); } @@ -199,72 +208,28 @@ return 1"; return sessions; } - /// <inheritdoc /> - public async Task SetLiveStreamAsync(LiveStreamSession session, CancellationToken cancellationToken = default) - { - var key = GetLiveStreamKey(session.LiveStreamId, session.SessionId); - var json = JsonSerializer.Serialize(session); - // Live stream records use the same lease duration as transcode sessions. - var leaseDurationMs = (long)_options.LeaseDurationSeconds * 1000; - await _db.StringSetAsync(key, json, TimeSpan.FromMilliseconds(leaseDurationMs)).ConfigureAwait(false); + private static string GetKey(string playSessionId) => KeyPrefix + playSessionId; - // Also index by play session id so the caller can look up by either key. - if (!string.IsNullOrEmpty(session.PlaySessionId)) + private static long UnixMillisecondsNow() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + private sealed class UnixMillisecondsDateTimeConverter : JsonConverter<DateTime> + { + public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - var playKey = GetLiveStreamKey(session.LiveStreamId, session.PlaySessionId); - await _db.StringSetAsync(playKey, json, TimeSpan.FromMilliseconds(leaseDurationMs)).ConfigureAwait(false); + var milliseconds = reader.TryGetInt64(out var value) ? value : (long)reader.GetDouble(); + return DateTimeOffset.FromUnixTimeMilliseconds(milliseconds).UtcDateTime; } - _logger.LogDebug( - "Set live stream session {LiveStreamId}/{SessionId} in Redis.", - session.LiveStreamId, - session.SessionId); - } - - /// <inheritdoc /> - public async Task<LiveStreamSession?> TryGetLiveStreamAsync(string liveStreamId, string sessionIdOrPlaySessionId, CancellationToken cancellationToken = default) - { - var key = GetLiveStreamKey(liveStreamId, sessionIdOrPlaySessionId); - var raw = await _db.StringGetAsync(key).ConfigureAwait(false); - if (!raw.HasValue) + public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) { - return null; - } - - return JsonSerializer.Deserialize<LiveStreamSession>(raw.ToString()); - } - - /// <inheritdoc /> - public async Task DeleteLiveStreamAsync(string liveStreamId, string sessionIdOrPlaySessionId, CancellationToken cancellationToken = default) - { - var key = GetLiveStreamKey(liveStreamId, sessionIdOrPlaySessionId); - var raw = await _db.StringGetAsync(key).ConfigureAwait(false); - if (raw.HasValue) - { - var session = JsonSerializer.Deserialize<LiveStreamSession>(raw.ToString()); - if (session is not null) + var utc = value.Kind switch { - // Remove both the session-id key and the play-session-id key if present. - var keysToDelete = new System.Collections.Generic.List<RedisKey> - { - GetLiveStreamKey(liveStreamId, session.SessionId) - }; + DateTimeKind.Utc => value, + DateTimeKind.Local => value.ToUniversalTime(), + _ => DateTime.SpecifyKind(value, DateTimeKind.Utc) + }; - if (!string.IsNullOrEmpty(session.PlaySessionId)) - { - keysToDelete.Add(GetLiveStreamKey(liveStreamId, session.PlaySessionId)); - } - - await _db.KeyDeleteAsync(keysToDelete.ToArray()).ConfigureAwait(false); - _logger.LogDebug( - "Deleted live stream session {LiveStreamId}/{SessionId} from Redis.", - liveStreamId, - session.SessionId); - return; - } + writer.WriteNumberValue(new DateTimeOffset(utc, TimeSpan.Zero).ToUnixTimeMilliseconds()); } - - // Fallback: delete just the key that was supplied. - await _db.KeyDeleteAsync(key).ConfigureAwait(false); } } diff --git a/Emby.Server.Implementations/Playlists/PlaylistManager.cs b/Emby.Server.Implementations/Playlists/PlaylistManager.cs index 1577c5c9c7..308faed8cc 100644 --- a/Emby.Server.Implementations/Playlists/PlaylistManager.cs +++ b/Emby.Server.Implementations/Playlists/PlaylistManager.cs @@ -198,44 +198,36 @@ namespace Emby.Server.Implementations.Playlists return Playlist.GetPlaylistItems(items, user, options); } - public Task AddItemToPlaylistAsync(Guid playlistId, IReadOnlyCollection<Guid> itemIds, Guid userId) + public Task AddItemToPlaylistAsync(Guid playlistId, IReadOnlyCollection<Guid> itemIds, int? position, Guid userId) { var user = userId.IsEmpty() ? null : _userManager.GetUserById(userId); - return AddToPlaylistInternal(playlistId, itemIds, user, new DtoOptions(false) - { - EnableImages = true - }); + return AddToPlaylistInternal( + playlistId, + itemIds, + user, + new DtoOptions(false) + { + EnableImages = true + }, + position); } - private async Task AddToPlaylistInternal(Guid playlistId, IReadOnlyCollection<Guid> newItemIds, User user, DtoOptions options) + private async Task AddToPlaylistInternal(Guid playlistId, IReadOnlyCollection<Guid> newItemIds, User user, DtoOptions options, int? position = null) { // Retrieve the existing playlist var playlist = _libraryManager.GetItemById(playlistId) as Playlist ?? throw new ArgumentException("No Playlist exists with Id " + playlistId); - // Retrieve all the items to be added to the playlist + // Retrieve all the items to be added to the playlist. var newItems = GetPlaylistItems(newItemIds, user, options) .Where(i => i.SupportsAddingToPlaylist); - // 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 .Select(LinkedChild.Create) .ToList(); - // Log duplicates that have been ignored, if any - int numDuplicates = newItemIds.Count - childrenToAdd.Count; - if (numDuplicates > 0) - { - _logger.LogWarning("Ignored adding {DuplicateCount} duplicate items to playlist {PlaylistName}.", numDuplicates, playlist.Name); - } - // Do nothing else if there are no items to add to the playlist if (childrenToAdd.Count == 0) { @@ -243,7 +235,30 @@ namespace Emby.Server.Implementations.Playlists } // Update the playlist in the repository - playlist.LinkedChildren = [.. playlist.LinkedChildren, .. childrenToAdd]; + if (position.HasValue) + { + if (position.Value <= 0) + { + playlist.LinkedChildren = [.. childrenToAdd, .. playlist.LinkedChildren]; + } + else if (position.Value >= playlist.LinkedChildren.Length) + { + playlist.LinkedChildren = [.. playlist.LinkedChildren, .. childrenToAdd]; + } + else + { + playlist.LinkedChildren = [ + .. playlist.LinkedChildren[0..position.Value], + .. childrenToAdd, + .. playlist.LinkedChildren[position.Value..playlist.LinkedChildren.Length] + ]; + } + } + else + { + playlist.LinkedChildren = [.. playlist.LinkedChildren, .. childrenToAdd]; + } + playlist.DateLastMediaAdded = DateTime.UtcNow; await UpdatePlaylistInternal(playlist).ConfigureAwait(false); diff --git a/Emby.Server.Implementations/Playlists/PlaylistsFolder.cs b/Emby.Server.Implementations/Playlists/PlaylistsFolder.cs index a5be2b616e..3bbbdd43a0 100644 --- a/Emby.Server.Implementations/Playlists/PlaylistsFolder.cs +++ b/Emby.Server.Implementations/Playlists/PlaylistsFolder.cs @@ -43,8 +43,9 @@ namespace Emby.Server.Implementations.Playlists } query.Recursive = true; - query.IncludeItemTypes = new[] { BaseItemKind.Playlist }; - return QueryWithPostFiltering2(query); + query.IncludeItemTypes = [BaseItemKind.Playlist]; + + return QueryWithPostFiltering(query); } public override string GetClientTypeName() diff --git a/Emby.Server.Implementations/Plugins/PluginManager.cs b/Emby.Server.Implementations/Plugins/PluginManager.cs index 91ccb16ef9..f7ad9b9498 100644 --- a/Emby.Server.Implementations/Plugins/PluginManager.cs +++ b/Emby.Server.Implementations/Plugins/PluginManager.cs @@ -255,6 +255,14 @@ namespace Emby.Server.Implementations.Plugins } _plugins.Add(plugin); + + // Updating a disabled plugin must not enable it again. + if (plugin.Manifest.Status == PluginStatus.Disabled) + { + ProcessAlternative(plugin); + return; + } + EnablePlugin(plugin); } @@ -387,29 +395,11 @@ namespace Emby.Server.Implementations.Plugins var url = new Uri(packageInfo.ImageUrl); imagePath = Path.Join(path, url.Segments[^1]); - var fileStream = AsyncFile.OpenWrite(imagePath); - Stream? downloadStream = null; - try + // The catalog is refreshed on every dashboard visit and rewrites the manifest of + // every installed plugin, so only fetch an image that is actually missing. + if (!ImageExists(imagePath)) { - downloadStream = await HttpClientFactory - .CreateClient(NamedClient.Default) - .GetStreamAsync(url) - .ConfigureAwait(false); - - await downloadStream.CopyToAsync(fileStream).ConfigureAwait(false); - } - catch (HttpRequestException ex) - { - _logger.LogError(ex, "Failed to download image to path {Path} on disk.", imagePath); - imagePath = string.Empty; - } - finally - { - await fileStream.DisposeAsync().ConfigureAwait(false); - if (downloadStream is not null) - { - await downloadStream.DisposeAsync().ConfigureAwait(false); - } + imagePath = await DownloadImage(url, imagePath).ConfigureAwait(false); } } @@ -448,6 +438,67 @@ namespace Emby.Server.Implementations.Plugins } } + private static bool ImageExists(string imagePath) + { + var image = new FileInfo(imagePath); + + // A previous download may have been interrupted, leaving an empty file behind. + return image.Exists && image.Length > 0; + } + + private async Task<string> DownloadImage(Uri url, string imagePath) + { + // Download to a temporary file and move it into place, so that neither a failed download + // nor a concurrent one can be observed as a partially written image. + var tempPath = imagePath + "." + Path.GetRandomFileName(); + + try + { + var fileStream = AsyncFile.Create(tempPath); + Stream? downloadStream = null; + try + { + downloadStream = await HttpClientFactory + .CreateClient(NamedClient.Default) + .GetStreamAsync(url) + .ConfigureAwait(false); + + await downloadStream.CopyToAsync(fileStream).ConfigureAwait(false); + } + finally + { + await fileStream.DisposeAsync().ConfigureAwait(false); + if (downloadStream is not null) + { + await downloadStream.DisposeAsync().ConfigureAwait(false); + } + } + + File.Move(tempPath, imagePath, true); + + return imagePath; + } + catch (Exception ex) when (ex is HttpRequestException or IOException or UnauthorizedAccessException) + { + _logger.LogError(ex, "Failed to download image to path {Path} on disk.", imagePath); + TryDeleteFile(tempPath); + + return string.Empty; + } + } + + private void TryDeleteFile(string path) + { + try + { + File.Delete(path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _logger.LogWarning(ex, "Unable to delete {Path}.", path); + } + } + /// <summary> /// Reconciles the manifest against any properties that exist locally in a pre-packaged meta.json found at the path. /// If no file is found, no reconciliation occurs. @@ -564,7 +615,8 @@ namespace Emby.Server.Implementations.Plugins Id = instance.Id, Status = PluginStatus.Active, Name = instance.Name, - Version = instance.Version.ToString() + Version = instance.Version.ToString(), + ImageResourceName = (instance as IHasEmbeddedImage)?.ImageResourceName }) { Instance = instance @@ -631,9 +683,10 @@ namespace Emby.Server.Implementations.Plugins return; } - var predecessor = _plugins.OrderByDescending(p => p.Version) - .FirstOrDefault(p => p.Id.Equals(plugin.Id) && p.IsEnabledAndSupported && p.Version != plugin.Version); - if (predecessor is not null) + var successor = _plugins.FirstOrDefault(p => p.Id.Equals(plugin.Id) + && p.Version > plugin.Version + && (p.IsEnabledAndSupported || p.Manifest.Status == PluginStatus.Disabled)); + if (successor is not null) { return; } @@ -762,6 +815,8 @@ namespace Emby.Server.Implementations.Plugins var entry = versions[x]; if (!string.Equals(lastName, entry.Name, StringComparison.OrdinalIgnoreCase)) { + lastName = string.Empty; + if (!TryGetPluginDlls(entry, out var allowedDlls)) { _logger.LogError("One or more assembly paths was invalid. Marking plugin {Plugin} as \"Malfunctioned\".", entry.Name); @@ -771,15 +826,18 @@ namespace Emby.Server.Implementations.Plugins entry.DllFiles = allowedDlls; + // Only clean up older versions when this version will actually be loaded. if (entry.IsEnabledAndSupported) { lastName = entry.Name; - continue; } + + continue; } if (string.IsNullOrEmpty(lastName)) { + // Unnamed plugin, so there is nothing to match older versions against. continue; } @@ -890,9 +948,9 @@ namespace Emby.Server.Implementations.Plugins if (previousVersion is null) { - // This value is memory only - so that the web will show restart required. - plugin.Manifest.Status = PluginStatus.Restart; - plugin.Manifest.AutoUpdate = false; + // Memory only, so that the web will show restart required. The manifest must keep + // holding the persisted state, or a later save would write the wrong state to disk. + plugin.RestartRequired = true; return; } @@ -905,9 +963,7 @@ namespace Emby.Server.Implementations.Plugins _logger.LogError("Unable to supercede version {Version} of {Name}", previousVersion.Version, previousVersion.Name); } - // This value is memory only - so that the web will show restart required. - plugin.Manifest.Status = PluginStatus.Restart; - plugin.Manifest.AutoUpdate = false; + plugin.RestartRequired = true; } } } diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs index 36708e2582..29b633530f 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs @@ -4,6 +4,7 @@ using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; +using System.Text; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; @@ -26,7 +27,7 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks; /// </summary> public partial class AudioNormalizationTask : IScheduledTask { - private readonly IItemRepository _itemRepository; + private readonly IItemPersistenceService _persistenceService; private readonly ILibraryManager _libraryManager; private readonly IMediaEncoder _mediaEncoder; private readonly IApplicationPaths _applicationPaths; @@ -38,21 +39,21 @@ public partial class AudioNormalizationTask : IScheduledTask /// <summary> /// Initializes a new instance of the <see cref="AudioNormalizationTask"/> class. /// </summary> - /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param> + /// <param name="persistenceService">Instance of the <see cref="IItemPersistenceService"/> interface.</param> /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> /// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param> /// <param name="localizationManager">Instance of the <see cref="ILocalizationManager"/> interface.</param> /// <param name="logger">Instance of the <see cref="ILogger{AudioNormalizationTask}"/> interface.</param> public AudioNormalizationTask( - IItemRepository itemRepository, + IItemPersistenceService persistenceService, ILibraryManager libraryManager, IMediaEncoder mediaEncoder, IApplicationPaths applicationPaths, ILocalizationManager localizationManager, ILogger<AudioNormalizationTask> logger) { - _itemRepository = itemRepository; + _persistenceService = persistenceService; _libraryManager = libraryManager; _mediaEncoder = mediaEncoder; _applicationPaths = applicationPaths; @@ -138,7 +139,7 @@ public partial class AudioNormalizationTask : IScheduledTask { if (toSaveDbItems.Count > 1) { - _itemRepository.SaveItems(toSaveDbItems, cancellationToken); + _persistenceService.SaveItems(toSaveDbItems, cancellationToken); toSaveDbItems.Clear(); } @@ -158,7 +159,7 @@ public partial class AudioNormalizationTask : IScheduledTask if (toSaveDbItems.Count > 1) { - _itemRepository.SaveItems(toSaveDbItems, cancellationToken); + _persistenceService.SaveItems(toSaveDbItems, cancellationToken); toSaveDbItems.Clear(); } @@ -173,7 +174,7 @@ public partial class AudioNormalizationTask : IScheduledTask if (!t.NormalizationGain.HasValue && !t.LUFS.HasValue && t.IsFileProtocol) { t.LUFS = await CalculateLUFSAsync( - string.Format(CultureInfo.InvariantCulture, "-i \"{0}\"", t.Path.Replace("\"", "\\\"", StringComparison.Ordinal)), + string.Format(CultureInfo.InvariantCulture, "-i \"{0}\"", t.Path.EscapeProcessArgument()), false, cancellationToken).ConfigureAwait(false); toSaveDbItems.Add(t); @@ -183,7 +184,7 @@ public partial class AudioNormalizationTask : IScheduledTask { if (toSaveDbItems.Count > 1) { - _itemRepository.SaveItems(toSaveDbItems, cancellationToken); + _persistenceService.SaveItems(toSaveDbItems, cancellationToken); toSaveDbItems.Clear(); } @@ -200,7 +201,7 @@ public partial class AudioNormalizationTask : IScheduledTask if (toSaveDbItems.Count > 1) { - _itemRepository.SaveItems(toSaveDbItems, cancellationToken); + _persistenceService.SaveItems(toSaveDbItems, cancellationToken); } // Update progress @@ -234,7 +235,7 @@ public partial class AudioNormalizationTask : IScheduledTask { FileName = _mediaEncoder.EncoderPath, Arguments = args, - RedirectStandardOutput = false, + StandardErrorEncoding = Encoding.UTF8, RedirectStandardError = true }, }) diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs index f81309560e..f1e1579a1d 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs @@ -92,7 +92,8 @@ public class ChapterImagesTask : IScheduledTask EnableImages = false }, SourceTypes = [SourceType.Library], - IsVirtualItem = false + IsVirtualItem = false, + IncludeOwnedItems = true }) .OfType<Video>() .ToList(); diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanupCollectionAndPlaylistPathsTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanupCollectionAndPlaylistPathsTask.cs deleted file mode 100644 index 7f68f7701e..0000000000 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanupCollectionAndPlaylistPathsTask.cs +++ /dev/null @@ -1,139 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Controller.Collections; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Entities.Movies; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Playlists; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Globalization; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.Tasks; -using Microsoft.Extensions.Logging; - -namespace Emby.Server.Implementations.ScheduledTasks.Tasks; - -/// <summary> -/// Deletes path references from collections and playlists that no longer exists. -/// </summary> -public class CleanupCollectionAndPlaylistPathsTask : IScheduledTask -{ - private readonly ILocalizationManager _localization; - private readonly ICollectionManager _collectionManager; - private readonly IPlaylistManager _playlistManager; - private readonly ILogger<CleanupCollectionAndPlaylistPathsTask> _logger; - private readonly IProviderManager _providerManager; - - /// <summary> - /// Initializes a new instance of the <see cref="CleanupCollectionAndPlaylistPathsTask"/> class. - /// </summary> - /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param> - /// <param name="collectionManager">Instance of the <see cref="ICollectionManager"/> interface.</param> - /// <param name="playlistManager">Instance of the <see cref="IPlaylistManager"/> interface.</param> - /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param> - /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param> - public CleanupCollectionAndPlaylistPathsTask( - ILocalizationManager localization, - ICollectionManager collectionManager, - IPlaylistManager playlistManager, - ILogger<CleanupCollectionAndPlaylistPathsTask> logger, - IProviderManager providerManager) - { - _localization = localization; - _collectionManager = collectionManager; - _playlistManager = playlistManager; - _logger = logger; - _providerManager = providerManager; - } - - /// <inheritdoc /> - public string Name => _localization.GetLocalizedString("TaskCleanCollectionsAndPlaylists"); - - /// <inheritdoc /> - public string Key => "CleanCollectionsAndPlaylists"; - - /// <inheritdoc /> - public string Description => _localization.GetLocalizedString("TaskCleanCollectionsAndPlaylistsDescription"); - - /// <inheritdoc /> - public string Category => _localization.GetLocalizedString("TasksMaintenanceCategory"); - - /// <inheritdoc /> - public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) - { - var collectionsFolder = await _collectionManager.GetCollectionsFolder(false).ConfigureAwait(false); - if (collectionsFolder is null) - { - _logger.LogDebug("There is no collections folder to be found"); - } - else - { - var collections = collectionsFolder.Children.OfType<BoxSet>().ToArray(); - _logger.LogDebug("Found {CollectionLength} boxsets", collections.Length); - - for (var index = 0; index < collections.Length; index++) - { - var collection = collections[index]; - _logger.LogDebug("Checking boxset {CollectionName}", collection.Name); - - await CleanupLinkedChildrenAsync(collection, cancellationToken).ConfigureAwait(false); - progress.Report(50D / collections.Length * (index + 1)); - } - } - - var playlistsFolder = _playlistManager.GetPlaylistsFolder(); - if (playlistsFolder is null) - { - _logger.LogDebug("There is no playlists folder to be found"); - return; - } - - var playlists = playlistsFolder.Children.OfType<Playlist>().ToArray(); - _logger.LogDebug("Found {PlaylistLength} playlists", playlists.Length); - - for (var index = 0; index < playlists.Length; index++) - { - var playlist = playlists[index]; - _logger.LogDebug("Checking playlist {PlaylistName}", playlist.Name); - - await CleanupLinkedChildrenAsync(playlist, cancellationToken).ConfigureAwait(false); - progress.Report(50D / playlists.Length * (index + 1)); - } - } - - private async Task CleanupLinkedChildrenAsync<T>(T folder, CancellationToken cancellationToken) - where T : Folder - { - List<LinkedChild>? itemsToRemove = null; - foreach (var linkedChild in folder.LinkedChildren) - { - var path = linkedChild.Path; - if (!File.Exists(path) && !Directory.Exists(path)) - { - _logger.LogInformation("Item in {FolderName} cannot be found at {ItemPath}", folder.Name, path); - (itemsToRemove ??= new List<LinkedChild>()).Add(linkedChild); - } - } - - if (itemsToRemove is not null) - { - _logger.LogDebug("Updating {FolderName}", folder.Name); - folder.LinkedChildren = folder.LinkedChildren.Except(itemsToRemove).ToArray(); - await _providerManager.SaveMetadataAsync(folder, ItemUpdateType.MetadataEdit).ConfigureAwait(false); - await folder.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false); - } - } - - /// <inheritdoc /> - public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() - { - yield return new TaskTriggerInfo - { - Type = TaskTriggerInfoType.StartupTrigger, - }; - } -} diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/MediaSegmentExtractionTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/MediaSegmentExtractionTask.cs index 51920c5b14..9cc6eb265a 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/MediaSegmentExtractionTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/MediaSegmentExtractionTask.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Threading; @@ -68,6 +68,7 @@ public class MediaSegmentExtractionTask : IScheduledTask DtoOptions = new DtoOptions(true), SourceTypes = [SourceType.Library], Recursive = true, + IncludeOwnedItems = true, Limit = pagesize }; diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs index 92d7a3907a..687947616f 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Jellyfin.Database.Implementations; +using MediaBrowser.Controller.Library; using MediaBrowser.Model.Globalization; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; @@ -10,13 +11,14 @@ using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.ScheduledTasks.Tasks; /// <summary> -/// Optimizes Jellyfin's database by issuing a VACUUM command. +/// Optimizes Jellyfin's database by issuing VACUUM and ANALYZE commands. /// </summary> public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask { private readonly ILogger<OptimizeDatabaseTask> _logger; private readonly ILocalizationManager _localization; private readonly IJellyfinDatabaseProvider _jellyfinDatabaseProvider; + private readonly ILibraryManager _libraryManager; /// <summary> /// Initializes a new instance of the <see cref="OptimizeDatabaseTask" /> class. @@ -24,14 +26,17 @@ public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param> /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param> /// <param name="jellyfinDatabaseProvider">Instance of the JellyfinDatabaseProvider that can be used for provider specific operations.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> public OptimizeDatabaseTask( ILogger<OptimizeDatabaseTask> logger, ILocalizationManager localization, - IJellyfinDatabaseProvider jellyfinDatabaseProvider) + IJellyfinDatabaseProvider jellyfinDatabaseProvider, + ILibraryManager libraryManager) { _logger = logger; _localization = localization; _jellyfinDatabaseProvider = jellyfinDatabaseProvider; + _libraryManager = libraryManager; } /// <inheritdoc /> @@ -68,7 +73,16 @@ public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask /// <inheritdoc /> public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { - _logger.LogInformation("Optimizing and vacuuming jellyfin.db..."); + // Vacuuming/checkpointing requires an exclusive lock on the database. Running it while a library scan is in + // progress causes both operations to contend for the database and can stall the scan, so defer optimization + // until no scan is running. The task will run again on its next trigger. + if (_libraryManager.IsScanRunning) + { + _logger.LogInformation("Skipping database optimization because a library scan is currently running."); + return; + } + + _logger.LogInformation("Vacuuming and analyzing jellyfin.db..."); try { diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs index 6e4e5c7808..092a621bfc 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs @@ -4,11 +4,19 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Emby.Server.Implementations.Library.Validators; +using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Globalization; +using MediaBrowser.Model.IO; using MediaBrowser.Model.Tasks; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.ScheduledTasks.Tasks; @@ -20,6 +28,10 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask private readonly ILibraryManager _libraryManager; private readonly ILocalizationManager _localization; private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory; + private readonly IFileSystem _fileSystem; + private readonly ILogger<PeopleValidationTask> _logger; + private readonly ILogger<PeopleValidator> _validatorLogger; + private readonly IItemTypeLookup _itemTypeLookup; /// <summary> /// Initializes a new instance of the <see cref="PeopleValidationTask" /> class. @@ -27,11 +39,26 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param> /// <param name="dbContextFactory">Instance of the <see cref="IDbContextFactory{TContext}"/> interface.</param> - public PeopleValidationTask(ILibraryManager libraryManager, ILocalizationManager localization, IDbContextFactory<JellyfinDbContext> dbContextFactory) + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="logger">Instance of the <see cref="ILogger{PeopleValidationTask}"/> interface.</param> + /// <param name="validatorLogger">Instance of the <see cref="ILogger{PeopleValidator}"/> interface.</param> + /// <param name="itemTypeLookup">Instance of the <see cref="IItemTypeLookup"/> interface.</param> + public PeopleValidationTask( + ILibraryManager libraryManager, + ILocalizationManager localization, + IDbContextFactory<JellyfinDbContext> dbContextFactory, + IFileSystem fileSystem, + ILogger<PeopleValidationTask> logger, + ILogger<PeopleValidator> validatorLogger, + IItemTypeLookup itemTypeLookup) { _libraryManager = libraryManager; _localization = localization; _dbContextFactory = dbContextFactory; + _fileSystem = fileSystem; + _logger = logger; + _validatorLogger = validatorLogger; + _itemTypeLookup = itemTypeLookup; } /// <inheritdoc /> @@ -71,16 +98,24 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask /// <inheritdoc /> public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { - IProgress<double> subProgress = new Progress<double>((val) => progress.Report(val / 2)); - await _libraryManager.ValidatePeopleAsync(subProgress, cancellationToken).ConfigureAwait(false); + // People validation performs heavy database writes that contend with an active library scan. + // Defer it until the scan has finished; the task will run again on its next trigger. + if (_libraryManager.IsScanRunning) + { + _logger.LogInformation("Skipping people validation because a library scan is currently running."); + return; + } - subProgress = new Progress<double>((val) => progress.Report((val / 2) + 50)); + // Phase 1: Deduplicate and remove orphaned people (0-33%) var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); await using (context.ConfigureAwait(false)) { + IProgress<double> subProgress = new Progress<double>((val) => progress.Report(val / 3)); var dupQuery = context.Peoples .GroupBy(e => new { e.Name, e.PersonType }) .Where(e => e.Count() > 1) + .OrderBy(e => e.Key.Name) + .ThenBy(e => e.Key.PersonType) .Select(e => e.Select(f => f.Id).ToArray()); var total = dupQuery.Count(); @@ -123,7 +158,110 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask ArrayPool<Guid[]>.Shared.Return(buffer); } + var peopleToDelete = await context.Peoples + .Where(p => !context.PeopleBaseItemMap.Any(m => m.PeopleId.Equals(p.Id))) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + _logger.LogInformation("Removed {Count} orphaned people.", peopleToDelete); + subProgress.Report(100); } + + // Phase 2: Validate people (33-66%). Runs after orphaned PeopleBaseItemMap entries are + // cleaned up above, so dead people are removed in a single pass instead of requiring a second run. + IProgress<double> validateProgress = new Progress<double>((val) => progress.Report((val / 3) + 33)); + await new PeopleValidator(_libraryManager, _validatorLogger) + .Run(validateProgress, cancellationToken) + .ConfigureAwait(false); + + // Phase 3: Refresh images for people missing them (66-100%) + IProgress<double> refreshProgress = new Progress<double>((val) => progress.Report((val / 3) + 66)); + await RefreshPeopleImagesAsync(refreshProgress, cancellationToken).ConfigureAwait(false); + + progress.Report(100); + } + + private async Task RefreshPeopleImagesAsync(IProgress<double> progress, CancellationToken cancellationToken) + { + var thirtyDaysAgo = DateTime.UtcNow.AddDays(-30); + var personTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Person]; + + List<Guid> peopleIds; + + var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (context.ConfigureAwait(false)) + { + // Read the candidates in one go rather than paging them. A refresh stamps the person and takes + // it out of this set, so a growing offset over a shrinking set walks past people it never visits. + peopleIds = await context.BaseItems + .AsNoTracking() + .Where(b => b.Type == personTypeName) + .Where(b => b.DateLastRefreshed == null || b.DateLastRefreshed < thirtyDaysAgo) + .Where(b => + !b.Images!.Any(i => i.ImageType == ImageInfoImageType.Primary) || + string.IsNullOrEmpty(b.Overview)) + .OrderBy(b => b.Id) + .Select(b => b.Id) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + } + + _logger.LogDebug("Found {Count} people needing image/overview refresh", peopleIds.Count); + + if (peopleIds.Count == 0) + { + progress.Report(100); + return; + } + + var numComplete = 0; + var numRefreshed = 0; + + foreach (var personId in peopleIds) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (await RefreshPersonAsync(personId, cancellationToken).ConfigureAwait(false)) + { + numRefreshed++; + } + + numComplete++; + progress.Report(100.0 * numComplete / peopleIds.Count); + } + + _logger.LogInformation("Refreshed metadata for {Count} people missing images or overview", numRefreshed); + } + + private async Task<bool> RefreshPersonAsync(Guid personId, CancellationToken cancellationToken) + { + try + { + if (_libraryManager.GetItemById(personId) is not Person item) + { + return false; + } + + var hasImage = item.HasImage(MediaBrowser.Model.Entities.ImageType.Primary); + var hasOverview = !string.IsNullOrEmpty(item.Overview); + + var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem)) + { + ImageRefreshMode = hasImage ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.FullRefresh, + MetadataRefreshMode = hasOverview ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.FullRefresh + }; + + await item.RefreshMetadata(options, cancellationToken).ConfigureAwait(false); + return true; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error refreshing images for person {PersonId}", personId); + return false; + } } } diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs index 31153af20f..dd3da2214a 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs @@ -107,6 +107,11 @@ public class PluginUpdateTask : IScheduledTask, IConfigurableScheduledTask { _logger.LogError(ex, "Error updating {Name}", package.Name); } + catch (TimeoutException ex) + { + // One slow download must not abort the updates for the remaining plugins. + _logger.LogError(ex, "Error downloading {Name}", package.Name); + } catch (InvalidDataException ex) { _logger.LogError(ex, "Error updating {Name}", package.Name); diff --git a/Emby.Server.Implementations/Serialization/MyXmlSerializer.cs b/Emby.Server.Implementations/Serialization/MyXmlSerializer.cs index aa5fbbdf73..5c9a94cd36 100644 --- a/Emby.Server.Implementations/Serialization/MyXmlSerializer.cs +++ b/Emby.Server.Implementations/Serialization/MyXmlSerializer.cs @@ -85,9 +85,17 @@ namespace Emby.Server.Implementations.Serialization /// <returns>System.Object.</returns> public object? DeserializeFromFile(Type type, string file) { - using (var stream = File.OpenRead(file)) + try { - return DeserializeFromStream(type, stream); + using (var stream = File.OpenRead(file)) + { + return DeserializeFromStream(type, stream); + } + } + catch (Exception ex) + { + ex.Data.Add("Filename", file); + throw; } } diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs index 01b1bb7db1..94215bed79 100644 --- a/Emby.Server.Implementations/Session/SessionManager.cs +++ b/Emby.Server.Implementations/Session/SessionManager.cs @@ -28,7 +28,6 @@ using MediaBrowser.Controller.Events; using MediaBrowser.Controller.Events.Authentication; using MediaBrowser.Controller.Events.Session; using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Session; using MediaBrowser.Model.Dto; @@ -61,7 +60,6 @@ namespace Emby.Server.Implementations.Session private readonly IMediaSourceManager _mediaSourceManager; private readonly IServerApplicationHost _appHost; private readonly IDeviceManager _deviceManager; - private readonly ITranscodeSessionStore _transcodeSessionStore; private readonly CancellationTokenRegistration _shutdownCallback; private readonly ConcurrentDictionary<string, SessionInfo> _activeConnections = new(StringComparer.OrdinalIgnoreCase); @@ -91,7 +89,6 @@ namespace Emby.Server.Implementations.Session /// <param name="deviceManager">Instance of <see cref="IDeviceManager"/> interface.</param> /// <param name="mediaSourceManager">Instance of <see cref="IMediaSourceManager"/> interface.</param> /// <param name="hostApplicationLifetime">Instance of <see cref="IHostApplicationLifetime"/> interface.</param> - /// <param name="transcodeSessionStore">Instance of <see cref="ITranscodeSessionStore"/> interface.</param> public SessionManager( ILogger<SessionManager> logger, IEventManager eventManager, @@ -105,8 +102,7 @@ namespace Emby.Server.Implementations.Session IServerApplicationHost appHost, IDeviceManager deviceManager, IMediaSourceManager mediaSourceManager, - IHostApplicationLifetime hostApplicationLifetime, - ITranscodeSessionStore transcodeSessionStore) + IHostApplicationLifetime hostApplicationLifetime) { _logger = logger; _eventManager = eventManager; @@ -120,7 +116,6 @@ namespace Emby.Server.Implementations.Session _appHost = appHost; _deviceManager = deviceManager; _mediaSourceManager = mediaSourceManager; - _transcodeSessionStore = transcodeSessionStore; _shutdownCallback = hostApplicationLifetime.ApplicationStopping.Register(OnApplicationStopping); _deviceManager.DeviceOptionsUpdated += OnDeviceManagerDeviceOptionsUpdated; @@ -276,9 +271,9 @@ namespace Emby.Server.Implementations.Session user.LastActivityDate = activityDate; await _userManager.UpdateUserAsync(user).ConfigureAwait(false); } - catch (DbUpdateConcurrencyException e) + catch (DbUpdateConcurrencyException) { - _logger.LogDebug(e, "Error updating user's last activity date."); + _logger.LogDebug("Error updating user's last activity date due to concurrency conflict. This is an expected event."); } } } @@ -314,7 +309,7 @@ namespace Emby.Server.Implementations.Session { if (!session.SessionControllers.Any(i => i.IsSessionActive)) { - var key = GetSessionKey(session.Client, session.DeviceId); + var key = GetSessionKey(session.Client, session.DeviceId, session.UserId); _activeConnections.TryRemove(key, out _); if (!string.IsNullOrEmpty(session.PlayState?.LiveStreamId)) @@ -350,36 +345,11 @@ namespace Emby.Server.Implementations.Session } else { - // In-memory state is absent — this pod may have taken over from a crashed pod. - // Check the durable store to determine whether the live stream record exists. - LiveStreamSession durableRecord = null; - try - { - durableRecord = await _transcodeSessionStore.TryGetLiveStreamAsync(liveStreamId, sessionIdOrPlaySessionId).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to query live stream session {LiveStreamId}/{SessionId} from durable store.", liveStreamId, sessionIdOrPlaySessionId); - } - - if (durableRecord is not null) - { - liveStreamNeedsToBeClosed = true; - } + liveStreamNeedsToBeClosed = true; } - // Remove the durable record regardless of which code path set liveStreamNeedsToBeClosed. if (liveStreamNeedsToBeClosed) { - try - { - await _transcodeSessionStore.DeleteLiveStreamAsync(liveStreamId, sessionIdOrPlaySessionId).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to delete live stream session {LiveStreamId}/{SessionId} from durable store.", liveStreamId, sessionIdOrPlaySessionId); - } - try { await _mediaSourceManager.CloseLiveStream(liveStreamId).ConfigureAwait(false); @@ -399,7 +369,7 @@ namespace Emby.Server.Implementations.Session if (session is not null) { - var key = GetSessionKey(session.Client, session.DeviceId); + var key = GetSessionKey(session.Client, session.DeviceId, session.UserId); _activeConnections.TryRemove(key, out _); @@ -420,7 +390,7 @@ namespace Emby.Server.Implementations.Session { if (session is null) { - return; + return; } if (string.IsNullOrEmpty(info.MediaSourceId)) @@ -487,18 +457,6 @@ namespace Emby.Server.Implementations.Session session.PlayState.RepeatMode = info.RepeatMode; session.PlayState.PlaybackOrder = info.PlaybackOrder; session.PlaylistItemId = info.PlaylistItemId; - - var nowPlayingQueue = info.NowPlayingQueue; - - if (nowPlayingQueue?.Length > 0 && !nowPlayingQueue.SequenceEqual(session.NowPlayingQueue)) - { - session.NowPlayingQueue = nowPlayingQueue; - - var itemIds = Array.ConvertAll(nowPlayingQueue, queue => queue.Id); - session.NowPlayingQueueFullItems = _dtoService.GetBaseItemDtos( - _libraryManager.GetItemList(new InternalItemsQuery { ItemIds = itemIds }), - new DtoOptions(true)); - } } /// <summary> @@ -517,8 +475,11 @@ namespace Emby.Server.Implementations.Session } } - private static string GetSessionKey(string appName, string deviceId) - => appName + deviceId; + // The user is part of the key because the client name and the device id are taken from the + // request headers and are not bound to the access token. Without it, any authenticated user + // could claim another user's client/device pair and take over their session. + private static string GetSessionKey(string appName, string deviceId, Guid userId) + => appName + deviceId + userId.ToString("N", CultureInfo.InvariantCulture); /// <summary> /// Gets the connection. @@ -542,7 +503,7 @@ namespace Emby.Server.Implementations.Session ArgumentException.ThrowIfNullOrEmpty(deviceId); - var key = GetSessionKey(appName, deviceId); + var key = GetSessionKey(appName, deviceId, user?.Id ?? Guid.Empty); SessionInfo newSession = CreateSessionInfo(key, appName, appVersion, deviceId, deviceName, remoteEndPoint, user); SessionInfo sessionInfo = _activeConnections.GetOrAdd(key, newSession); if (ReferenceEquals(newSession, sessionInfo)) @@ -683,8 +644,7 @@ namespace Emby.Server.Implementations.Session if (playingSessions.Count > 0) { var idle = playingSessions - .Where(i => (DateTime.UtcNow - i.LastPlaybackCheckIn).TotalMinutes > 5) - .ToList(); + .Where(i => (DateTime.UtcNow - i.LastPlaybackCheckIn).TotalMinutes > 5); foreach (var session in idle) { @@ -698,7 +658,7 @@ namespace Emby.Server.Implementations.Session ItemId = session.NowPlayingItem is null ? Guid.Empty : session.NowPlayingItem.Id, SessionId = session.Id, MediaSourceId = session.PlayState?.MediaSourceId, - PositionTicks = session.PlayState?.PositionTicks + PositionTicks = session.LastPlaybackCheckInPositionTicks }).ConfigureAwait(false); } catch (Exception ex) @@ -771,6 +731,31 @@ namespace Emby.Server.Implementations.Session return item; } + /// <summary> + /// Resolves the item whose user data (playback position, played status) should be updated + /// for a playback report. When an alternate version is played the client reports the displayed + /// item as <c>ItemId</c> and the played version as <c>MediaSourceId</c>. + /// </summary> + /// <param name="libraryItem">The now playing (displayed) item.</param> + /// <param name="mediaSourceId">The reported media source id.</param> + /// <returns>The item to track progress against.</returns> + private BaseItem GetProgressItem(BaseItem libraryItem, string mediaSourceId) + { + if (libraryItem is Video libraryVideo + && !string.IsNullOrEmpty(mediaSourceId) + && Guid.TryParse(mediaSourceId, out var mediaSourceItemId) + && !mediaSourceItemId.Equals(libraryVideo.Id)) + { + var versionItem = libraryVideo.GetAlternateVersion(mediaSourceItemId); + if (versionItem is not null) + { + return versionItem; + } + } + + return libraryItem; + } + /// <summary> /// Used to report that playback has started for an item. /// </summary> @@ -802,15 +787,16 @@ namespace Emby.Server.Implementations.Session if (libraryItem is not null) { + var progressItem = GetProgressItem(libraryItem, info.MediaSourceId); foreach (var user in users) { - OnPlaybackStart(user, libraryItem); + OnPlaybackStart(user, progressItem); } } if (!string.IsNullOrEmpty(info.LiveStreamId)) { - await UpdateLiveStreamActiveSessionMappings(info.LiveStreamId, info.SessionId, info.PlaySessionId).ConfigureAwait(false); + UpdateLiveStreamActiveSessionMappings(info.LiveStreamId, info.SessionId, info.PlaySessionId); } var eventArgs = new PlaybackStartEventArgs @@ -827,6 +813,16 @@ namespace Emby.Server.Implementations.Session PlaySessionId = info.PlaySessionId }; + if (info.Item is not null) + { + _logger.LogInformation( + "User {0} started playback of '{1}' ({2} {3})", + session.UserName, + info.Item.Name, + session.Client, + session.ApplicationVersion); + } + await _eventManager.PublishAsync(eventArgs).ConfigureAwait(false); // Nothing to save here @@ -856,10 +852,6 @@ namespace Emby.Server.Implementations.Session { data.Played = true; } - else - { - data.Played = false; - } _userDataManager.SaveUserData(user, item, data, UserDataSaveReason.PlaybackStart, CancellationToken.None); } @@ -870,7 +862,7 @@ namespace Emby.Server.Implementations.Session return OnPlaybackProgress(info, false); } - private async Task UpdateLiveStreamActiveSessionMappings(string liveStreamId, string sessionId, string playSessionId) + private void UpdateLiveStreamActiveSessionMappings(string liveStreamId, string sessionId, string playSessionId) { var activeSessionMappings = _activeLiveStreamSessions.GetOrAdd(liveStreamId, _ => new ConcurrentDictionary<string, string>()); @@ -894,26 +886,6 @@ namespace Emby.Server.Implementations.Session activeSessionMappings[sessionId] = string.Empty; } } - - // Persist to the durable store so a takeover pod can discover open live streams. - var ownerPod = Environment.GetEnvironmentVariable("JELLYFIN_INSTANCE_ID") ?? Environment.MachineName; - var liveStreamSession = new LiveStreamSession - { - LiveStreamId = liveStreamId, - SessionId = sessionId, - PlaySessionId = playSessionId ?? string.Empty, - OwnerPod = ownerPod, - OpenedAtUtc = DateTime.UtcNow, - }; - - try - { - await _transcodeSessionStore.SetLiveStreamAsync(liveStreamSession).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to persist live stream session {LiveStreamId}/{SessionId} to durable store.", liveStreamId, sessionId); - } } /// <summary> @@ -950,15 +922,16 @@ namespace Emby.Server.Implementations.Session // only update saved user data on actual check-ins, not automated ones if (libraryItem is not null && !isAutomated) { + var progressItem = GetProgressItem(libraryItem, info.MediaSourceId); foreach (var user in users) { - OnPlaybackProgress(user, libraryItem, info); + OnPlaybackProgress(user, progressItem, info); } } if (!string.IsNullOrEmpty(info.LiveStreamId)) { - await UpdateLiveStreamActiveSessionMappings(info.LiveStreamId, info.SessionId, info.PlaySessionId).ConfigureAwait(false); + UpdateLiveStreamActiveSessionMappings(info.LiveStreamId, info.SessionId, info.PlaySessionId); } var eventArgs = new PlaybackProgressEventArgs @@ -1004,7 +977,7 @@ namespace Emby.Server.Implementations.Session } var tracksChanged = UpdatePlaybackSettings(user, info, data); - if (!tracksChanged) + if (tracksChanged) { changed = true; } @@ -1012,6 +985,20 @@ namespace Emby.Server.Implementations.Session if (changed) { _userDataManager.SaveUserData(user, item, data, UserDataSaveReason.PlaybackProgress, CancellationToken.None); + + // A completed version marks every alternate version played and clears their resume points, so the + // whole movie leaves Continue Watching and reads as watched everywhere. (Per-version resume positions + // only persist while nothing has been completed yet.) + if (data.Played == true && item is Video playedVideo) + { + playedVideo.PropagatePlayedState(user, true); + } + } + + if ((!user.RememberAudioSelections && info.AudioStreamIndex.HasValue) + || (!user.RememberSubtitleSelections && info.SubtitleStreamIndex.HasValue)) + { + _userDataManager.ResetPlaybackStreamSelections(user, item); } } @@ -1021,7 +1008,7 @@ namespace Emby.Server.Implementations.Session if (user.RememberAudioSelections) { - if (data.AudioStreamIndex != info.AudioStreamIndex) + if (info.AudioStreamIndex.HasValue && data.AudioStreamIndex != info.AudioStreamIndex) { data.AudioStreamIndex = info.AudioStreamIndex; changed = true; @@ -1038,7 +1025,7 @@ namespace Emby.Server.Implementations.Session if (user.RememberSubtitleSelections) { - if (data.SubtitleStreamIndex != info.SubtitleStreamIndex) + if (info.SubtitleStreamIndex.HasValue && data.SubtitleStreamIndex != info.SubtitleStreamIndex) { data.SubtitleStreamIndex = info.SubtitleStreamIndex; changed = true; @@ -1069,15 +1056,22 @@ namespace Emby.Server.Implementations.Session ArgumentNullException.ThrowIfNull(info); - if (info.PositionTicks.HasValue && info.PositionTicks.Value < 0) - { - throw new ArgumentOutOfRangeException(nameof(info), "The PlaybackStopInfo's PositionTicks was negative."); - } - var session = GetSession(info.SessionId); session.StopAutomaticProgress(); + if (info.PositionTicks.HasValue && info.PositionTicks.Value < 0) + { + // Ensure live stream is cleaned up before throwing, to prevent tuner + // resource leaks when stalled clients report a negative PositionTicks. + if (!string.IsNullOrEmpty(info.LiveStreamId)) + { + await CloseLiveStreamIfNeededAsync(info.LiveStreamId, session.Id).ConfigureAwait(false); + } + + throw new ArgumentOutOfRangeException(nameof(info), "The PlaybackStopInfo's PositionTicks was negative."); + } + var libraryItem = info.ItemId.IsEmpty() ? null : GetNowPlayingItem(session, info.ItemId); @@ -1114,11 +1108,12 @@ namespace Emby.Server.Implementations.Session var msString = info.PositionTicks.HasValue ? (info.PositionTicks.Value / 10000).ToString(CultureInfo.InvariantCulture) : "unknown"; _logger.LogInformation( - "Playback stopped reported by app {0} {1} playing {2}. Stopped at {3} ms", - session.Client, - session.ApplicationVersion, + "User {0} stopped playback of '{1}' at {2}ms ({3} {4})", + session.UserName, info.Item.Name, - msString); + msString, + session.Client, + session.ApplicationVersion); } if (info.NowPlayingQueue is not null) @@ -1135,9 +1130,10 @@ namespace Emby.Server.Implementations.Session if (libraryItem is not null) { + var progressItem = GetProgressItem(libraryItem, info.MediaSourceId); foreach (var user in users) { - playedToCompletion = OnPlaybackStopped(user, libraryItem, info.PositionTicks, info.Failed); + playedToCompletion = OnPlaybackStopped(user, progressItem, info.PositionTicks, info.Failed); } } @@ -1190,6 +1186,14 @@ namespace Emby.Server.Implementations.Session _userDataManager.SaveUserData(user, item, data, UserDataSaveReason.PlaybackFinished, CancellationToken.None); + // A completed version marks every alternate version played and clears their resume points, so the + // whole movie leaves Continue Watching and reads as watched everywhere. (Per-version resume positions + // only persist while nothing has been completed yet.) + if (data.Played == true && item is Video playedVideo) + { + playedVideo.PropagatePlayedState(user, true); + } + return playedToCompletion; } @@ -1257,7 +1261,6 @@ namespace Emby.Server.Implementations.Session SupportsMediaControl = sessionInfo.SupportsMediaControl, SupportsRemoteControl = sessionInfo.SupportsRemoteControl, NowPlayingQueue = sessionInfo.NowPlayingQueue, - NowPlayingQueueFullItems = sessionInfo.NowPlayingQueueFullItems, HasCustomDeviceName = sessionInfo.HasCustomDeviceName, PlaylistItemId = sessionInfo.PlaylistItemId, ServerId = sessionInfo.ServerId, @@ -1537,11 +1540,52 @@ namespace Emby.Server.Implementations.Session return SendMessageToSession(session, SessionMessageType.Playstate, command, cancellationToken); } - private static void AssertCanControl(SessionInfo session, SessionInfo controllingSession) + private void AssertCanControl(SessionInfo session, SessionInfo controllingSession) { ArgumentNullException.ThrowIfNull(session); ArgumentNullException.ThrowIfNull(controllingSession); + + var controllingUserId = controllingSession.UserId; + + // Controlling a session is always allowed when: + // - the caller has no associated user (an API key, which is a privileged context), + // - the target session is public (has no owning user), or + // - the caller's user is associated with the target session. + // Controlling a session owned by a different user requires the + // EnableRemoteControlOfOtherUsers permission. + if (controllingUserId.IsEmpty() + || session.UserId.IsEmpty() + || session.ContainsUser(controllingUserId)) + { + return; + } + + var controllingUser = _userManager.GetUserById(controllingUserId); + if (controllingUser is null + || !controllingUser.HasPermission(PermissionKind.EnableRemoteControlOfOtherUsers)) + { + throw new SecurityException("The current user does not have permission to remote control other users."); + } + } + + private void AssertCanAttachUser(SessionInfo controllingSession, Guid userId) + { + var controllingUserId = controllingSession.UserId; + + // Playback reported by a session is also written to the user data of its additional users, + // so attaching anyone but the calling user requires administrative privileges. + if (controllingUserId.IsEmpty() || controllingUserId.Equals(userId)) + { + return; + } + + var controllingUser = _userManager.GetUserById(controllingUserId); + if (controllingUser is null + || !controllingUser.HasPermission(PermissionKind.IsAdministrator)) + { + throw new SecurityException("The current user does not have permission to attach another user to a session."); + } } /// <summary> @@ -1559,16 +1603,24 @@ namespace Emby.Server.Implementations.Session /// <summary> /// Adds the additional user. /// </summary> + /// <param name="controllingSessionId">The controlling session identifier.</param> /// <param name="sessionId">The session identifier.</param> /// <param name="userId">The user identifier.</param> - /// <exception cref="UnauthorizedAccessException">Cannot modify additional users without authenticating first.</exception> + /// <exception cref="SecurityException">The controlling user is not allowed to attach the user to the session.</exception> /// <exception cref="ArgumentException">The requested user is already the primary user of the session.</exception> - public void AddAdditionalUser(string sessionId, Guid userId) + public void AddAdditionalUser(string controllingSessionId, string sessionId, Guid userId) { CheckDisposed(); var session = GetSession(sessionId); + if (!string.IsNullOrEmpty(controllingSessionId)) + { + var controllingSession = GetSession(controllingSessionId); + AssertCanControl(session, controllingSession); + AssertCanAttachUser(controllingSession, userId); + } + if (session.UserId.Equals(userId)) { throw new ArgumentException("The requested user is already the primary user of the session."); @@ -1576,7 +1628,8 @@ namespace Emby.Server.Implementations.Session if (session.AdditionalUsers.All(i => !i.UserId.Equals(userId))) { - var user = _userManager.GetUserById(userId); + var user = _userManager.GetUserById(userId) + ?? throw new ArgumentException("The requested user does not exist."); var newUser = new SessionUserInfo { UserId = userId, @@ -1590,16 +1643,22 @@ namespace Emby.Server.Implementations.Session /// <summary> /// Removes the additional user. /// </summary> + /// <param name="controllingSessionId">The controlling session identifier.</param> /// <param name="sessionId">The session identifier.</param> /// <param name="userId">The user identifier.</param> - /// <exception cref="UnauthorizedAccessException">Cannot modify additional users without authenticating first.</exception> + /// <exception cref="SecurityException">The controlling user is not allowed to control the session.</exception> /// <exception cref="ArgumentException">The requested user is already the primary user of the session.</exception> - public void RemoveAdditionalUser(string sessionId, Guid userId) + public void RemoveAdditionalUser(string controllingSessionId, string sessionId, Guid userId) { CheckDisposed(); var session = GetSession(sessionId); + if (!string.IsNullOrEmpty(controllingSessionId)) + { + AssertCanControl(session, GetSession(controllingSessionId)); + } + if (session.UserId.Equals(userId)) { throw new ArgumentException("The requested user is already the primary user of the session."); @@ -1803,14 +1862,21 @@ namespace Emby.Server.Implementations.Session /// <summary> /// Reports the capabilities. /// </summary> + /// <param name="controllingSessionId">The controlling session identifier.</param> /// <param name="sessionId">The session identifier.</param> /// <param name="capabilities">The capabilities.</param> - public void ReportCapabilities(string sessionId, ClientCapabilities capabilities) + /// <exception cref="SecurityException">The controlling user is not allowed to control the session.</exception> + public void ReportCapabilities(string controllingSessionId, string sessionId, ClientCapabilities capabilities) { CheckDisposed(); var session = GetSession(sessionId); + if (!string.IsNullOrEmpty(controllingSessionId)) + { + AssertCanControl(session, GetSession(controllingSessionId)); + } + ReportCapabilities(session, capabilities, true); } @@ -1875,7 +1941,6 @@ namespace Emby.Server.Implementations.Session fields.Remove(ItemFields.Settings); fields.Remove(ItemFields.SortName); fields.Remove(ItemFields.Tags); - fields.Remove(ItemFields.ExtraIds); dtoOptions.Fields = fields.ToArray(); @@ -1906,13 +1971,18 @@ namespace Emby.Server.Implementations.Session } /// <inheritdoc /> - public void ReportNowViewingItem(string sessionId, string itemId) + public void ReportNowViewingItem(string controllingSessionId, string sessionId, string itemId) { ArgumentException.ThrowIfNullOrEmpty(itemId); var item = _libraryManager.GetItemById(new Guid(itemId)); var session = GetSession(sessionId); + if (!string.IsNullOrEmpty(controllingSessionId)) + { + AssertCanControl(session, GetSession(controllingSessionId)); + } + session.NowViewingItem = GetItemInfo(item, null); } @@ -2097,7 +2167,7 @@ namespace Emby.Server.Implementations.Session { CheckDisposed(); - var adminUserIds = _userManager.Users + var adminUserIds = _userManager.GetUsers() .Where(i => i.HasPermission(PermissionKind.IsAdministrator)) .Select(i => i.Id) .ToList(); diff --git a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs index 6a26e92e14..e81edc82c6 100644 --- a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs +++ b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs @@ -223,7 +223,7 @@ namespace Emby.Server.Implementations.Session if (inactive.Count > 0) { - _logger.LogInformation("Sending ForceKeepAlive message to {0} inactive WebSockets.", inactive.Count); + _logger.LogDebug("Sending ForceKeepAlive message to {0} inactive WebSockets.", inactive.Count); } foreach (var webSocket in inactive) @@ -246,8 +246,21 @@ namespace Emby.Server.Implementations.Session _logger.LogInformation("Lost {0} WebSockets.", lost.Count); foreach (var webSocket in lost) { - // TODO: handle session relative to the lost webSocket RemoveWebSocket(webSocket); + + // The connection stopped answering keep-alives, so a close frame will + // never arrive and the pending receive loop would hang forever, keeping + // the session (and e.g. its SyncPlay group membership) alive. Disposing + // the connection aborts the receive loop, which raises Closed and lets + // the session end normally. + try + { + webSocket.Dispose(); + } + catch (Exception exception) + { + _logger.LogWarning(exception, "Error disposing lost WebSocket from {RemoteEndPoint}.", webSocket.RemoteEndPoint); + } } } } diff --git a/Emby.Server.Implementations/Sorting/DateLastMediaAddedComparer.cs b/Emby.Server.Implementations/Sorting/DateLastMediaAddedComparer.cs index f10e7fcbb7..4159f8cf7d 100644 --- a/Emby.Server.Implementations/Sorting/DateLastMediaAddedComparer.cs +++ b/Emby.Server.Implementations/Sorting/DateLastMediaAddedComparer.cs @@ -3,34 +3,14 @@ using System; using Jellyfin.Data.Enums; -using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Sorting; using MediaBrowser.Model.Querying; namespace Emby.Server.Implementations.Sorting { - public class DateLastMediaAddedComparer : IUserBaseItemComparer + public class DateLastMediaAddedComparer : IBaseItemComparer { - /// <summary> - /// Gets or sets the user. - /// </summary> - /// <value>The user.</value> - public User User { get; set; } - - /// <summary> - /// Gets or sets the user manager. - /// </summary> - /// <value>The user manager.</value> - public IUserManager UserManager { get; set; } - - /// <summary> - /// Gets or sets the user data manager. - /// </summary> - /// <value>The user data manager.</value> - public IUserDataManager UserDataManager { get; set; } - /// <summary> /// Gets the name. /// </summary> diff --git a/Emby.Server.Implementations/Sorting/OfficialRatingComparer.cs b/Emby.Server.Implementations/Sorting/OfficialRatingComparer.cs index 789af01cc3..c0e453d63d 100644 --- a/Emby.Server.Implementations/Sorting/OfficialRatingComparer.cs +++ b/Emby.Server.Implementations/Sorting/OfficialRatingComparer.cs @@ -41,8 +41,8 @@ public class OfficialRatingComparer : IBaseItemComparer 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 ratingX = string.IsNullOrEmpty(x.OfficialRating) ? zeroRating : _localizationManager.GetRatingScore(x.OfficialRating, x.GetPreferredMetadataCountryCode()) ?? zeroRating; + var ratingY = string.IsNullOrEmpty(y.OfficialRating) ? zeroRating : _localizationManager.GetRatingScore(y.OfficialRating, y.GetPreferredMetadataCountryCode()) ?? zeroRating; var scoreCompare = ratingX.Score.CompareTo(ratingY.Score); if (scoreCompare is 0) { diff --git a/Emby.Server.Implementations/Sorting/PremiereDateComparer.cs b/Emby.Server.Implementations/Sorting/PremiereDateComparer.cs index 8c8b8824f3..30b268bb60 100644 --- a/Emby.Server.Implementations/Sorting/PremiereDateComparer.cs +++ b/Emby.Server.Implementations/Sorting/PremiereDateComparer.cs @@ -45,7 +45,7 @@ namespace Emby.Server.Implementations.Sorting return x.PremiereDate.Value; } - if (x.ProductionYear.HasValue) + if (x.ProductionYear is not null) { try { diff --git a/Emby.Server.Implementations/Sorting/ProductionYearComparer.cs b/Emby.Server.Implementations/Sorting/ProductionYearComparer.cs index 9aec87f183..8774bd8d4f 100644 --- a/Emby.Server.Implementations/Sorting/ProductionYearComparer.cs +++ b/Emby.Server.Implementations/Sorting/ProductionYearComparer.cs @@ -39,7 +39,7 @@ namespace Emby.Server.Implementations.Sorting return 0; } - if (x.ProductionYear.HasValue) + if (x.ProductionYear is not null) { return x.ProductionYear.Value; } diff --git a/Emby.Server.Implementations/Sorting/StudioComparer.cs b/Emby.Server.Implementations/Sorting/StudioComparer.cs index 0edffb783b..6d041cf112 100644 --- a/Emby.Server.Implementations/Sorting/StudioComparer.cs +++ b/Emby.Server.Implementations/Sorting/StudioComparer.cs @@ -1,11 +1,11 @@ #pragma warning disable CS1591 using System; +using System.Globalization; using Jellyfin.Data.Enums; using Jellyfin.Extensions; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Sorting; -using MediaBrowser.Model.Querying; namespace Emby.Server.Implementations.Sorting { @@ -28,7 +28,7 @@ namespace Emby.Server.Implementations.Sorting ArgumentNullException.ThrowIfNull(x); ArgumentNullException.ThrowIfNull(y); - return AlphanumericComparator.CompareValues(x.Studios.FirstOrDefault(), y.Studios.FirstOrDefault()); + return CultureInfo.InvariantCulture.CompareInfo.Compare(x.Studios.FirstOrDefault(), y.Studios.FirstOrDefault(), CompareOptions.NumericOrdering); } } } diff --git a/Emby.Server.Implementations/SyncPlay/Group.cs b/Emby.Server.Implementations/SyncPlay/Group.cs index c2e834ad58..923bfc67aa 100644 --- a/Emby.Server.Implementations/SyncPlay/Group.cs +++ b/Emby.Server.Implementations/SyncPlay/Group.cs @@ -90,6 +90,18 @@ namespace Emby.Server.Implementations.SyncPlay /// <value>The default ping.</value> public long DefaultPing { get; } = 500; + /// <summary> + /// Gets the maximum ping, in milliseconds, accepted from a session. + /// </summary> + /// <remarks> + /// Pings are reported by clients and are scaled into the delays used to schedule playback, + /// so an unbounded value lets a single session push the whole group's resume point + /// arbitrarily far out, or overflow the arithmetic entirely. Anything above this is not a + /// usable measurement for synchronisation. + /// </remarks> + /// <value>The maximum ping.</value> + public long MaxPing { get; } = 10000; + /// <summary> /// Gets the maximum time offset error accepted for dates reported by clients, in milliseconds. /// </summary> @@ -206,7 +218,8 @@ namespace Emby.Server.Implementations.SyncPlay foreach (var itemId in queue) { var item = _libraryManager.GetItemById(itemId); - if (!item.IsVisibleStandalone(user)) + + if (item is null || !item.IsVisibleStandalone(user)) { return false; } @@ -437,7 +450,7 @@ namespace Emby.Server.Implementations.SyncPlay { if (_participants.TryGetValue(session.Id, out GroupMember value)) { - value.Ping = ping; + value.Ping = Math.Clamp(ping, 0, MaxPing); } } @@ -450,7 +463,9 @@ namespace Emby.Server.Implementations.SyncPlay max = Math.Max(max, session.Ping); } - return max; + // A group with no participants has no ping to report. Returning long.MinValue would + // overflow the callers that scale this value into ticks, so fall back to the default. + return max == long.MinValue ? DefaultPing : max; } /// <inheritdoc /> diff --git a/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs b/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs index b45d754554..b88ee33358 100644 --- a/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs +++ b/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs @@ -181,8 +181,8 @@ namespace Emby.Server.Implementations.SyncPlay { if (existingGroup.GroupId.Equals(request.GroupId)) { - // Restore session. - UpdateSessionsCounter(session.UserId, 1); + // Restore session. The session is already in the group and has already + // been counted, so the counter must not be incremented a second time. group.SessionJoin(session, request, cancellationToken); return; } @@ -332,8 +332,11 @@ namespace Emby.Server.Implementations.SyncPlay // Group lock required as Group is not thread-safe. lock (group) { - // Make sure that session still belongs to this group. - if (_sessionToGroupMap.TryGetValue(session.Id, out var checkGroup) && !checkGroup.GroupId.Equals(group.GroupId)) + // Make sure that session still belongs to this group. The lookup can fail + // outright when the session left while this request was waiting on the group + // lock, which is exactly the case this re-check exists to catch. + if (!_sessionToGroupMap.TryGetValue(session.Id, out var checkGroup) + || !checkGroup.GroupId.Equals(group.GroupId)) { // Drop request. return; @@ -400,7 +403,7 @@ namespace Emby.Server.Implementations.SyncPlay // Update sessions counter. var newSessionsCounter = _activeUsers.AddOrUpdate( userId, - 1, + toAdd, (_, sessionsCounter) => sessionsCounter + toAdd); // Should never happen. diff --git a/Emby.Server.Implementations/SystemManager.cs b/Emby.Server.Implementations/SystemManager.cs index d140426ddf..11a94648f8 100644 --- a/Emby.Server.Implementations/SystemManager.cs +++ b/Emby.Server.Implementations/SystemManager.cs @@ -89,11 +89,11 @@ public class SystemManager : ISystemManager .GetVirtualFolders() .Where(e => !string.IsNullOrWhiteSpace(e.ItemId)) // this should not be null but for some users it is. .Select(e => new LibraryStorageInfo() - { - Id = Guid.Parse(e.ItemId), - Name = e.Name, - Folders = e.Locations.Select(f => StorageHelper.GetFreeSpaceOf(f)).ToArray() - }); + { + Id = Guid.Parse(e.ItemId), + Name = e.Name, + Folders = e.Locations.Select(f => StorageHelper.GetFreeSpaceOf(f)).ToArray() + }); return new SystemStorageInfo() { diff --git a/Emby.Server.Implementations/TV/TVSeriesManager.cs b/Emby.Server.Implementations/TV/TVSeriesManager.cs index ee2e18f735..459ad1a17e 100644 --- a/Emby.Server.Implementations/TV/TVSeriesManager.cs +++ b/Emby.Server.Implementations/TV/TVSeriesManager.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using Jellyfin.Data; using Jellyfin.Data.Enums; @@ -47,7 +48,7 @@ namespace Emby.Server.Implementations.TV if (!string.IsNullOrEmpty(presentationUniqueKey)) { - return GetResult(GetNextUpEpisodes(query, user, new[] { presentationUniqueKey }, options), query); + return GetNextUpBatched(query, user, [presentationUniqueKey], options); } BaseItem[] parents; @@ -58,11 +59,11 @@ namespace Emby.Server.Implementations.TV if (parent is not null) { - parents = new[] { parent }; + parents = [parent]; } else { - parents = Array.Empty<BaseItem>(); + parents = []; } } else @@ -93,7 +94,7 @@ namespace Emby.Server.Implementations.TV if (!string.IsNullOrEmpty(presentationUniqueKey)) { - return GetResult(GetNextUpEpisodes(request, user, [presentationUniqueKey], options), request); + return GetNextUpBatched(request, user, [presentationUniqueKey], options); } if (limit.HasValue) @@ -103,25 +104,215 @@ namespace Emby.Server.Implementations.TV var nextUpSeriesKeys = _libraryManager.GetNextUpSeriesKeys(new InternalItemsQuery(user) { Limit = limit }, parentsFolders, request.NextUpDateCutoff); - var episodes = GetNextUpEpisodes(request, user, nextUpSeriesKeys, options); - - return GetResult(episodes, request); + return GetNextUpBatched(request, user, nextUpSeriesKeys, options); } - private IEnumerable<Episode> GetNextUpEpisodes(NextUpQuery request, User user, IReadOnlyList<string> seriesKeys, DtoOptions dtoOptions) + private QueryResult<BaseItem> GetNextUpBatched(NextUpQuery request, User user, IReadOnlyList<string> seriesKeys, DtoOptions dtoOptions) { - var allNextUp = seriesKeys.Select(i => GetNextUp(i, user, dtoOptions, request.EnableResumable, false)); - - if (request.EnableRewatching) + if (seriesKeys.Count == 0) { - allNextUp = allNextUp - .Concat(seriesKeys.Select(i => GetNextUp(i, user, dtoOptions, false, true))) - .OrderByDescending(i => i.LastWatchedDate); + return new QueryResult<BaseItem>(); } - return allNextUp - .Select(i => i.GetEpisodeFunction()) - .Where(i => i is not null)!; + var includeSpecials = _configurationManager.Configuration.DisplaySpecialsWithinSeasons; + var includeRewatching = request.EnableRewatching; + + var query = new InternalItemsQuery(user) + { + DtoOptions = dtoOptions + }; + + var batchResult = _libraryManager.GetNextUpEpisodesBatch(query, seriesKeys, includeSpecials, includeRewatching); + + var nextUpList = new List<(DateTime LastWatchedDate, Episode Episode)>(); + + foreach (var seriesKey in seriesKeys) + { + if (!batchResult.TryGetValue(seriesKey, out var result)) + { + continue; + } + + var nextEpisode = DetermineNextEpisode(result, user, includeSpecials, request.EnableResumable, false); + + if (nextEpisode is not null) + { + // The last played date and the version that was actually played live on the version item's user data + // The played state propagated to the sibling versions carries no date + var (playedVersion, lastPlayedDate) = GetMostRecentlyPlayedVersion(result.LastWatched, user); + nextEpisode = GetPreferredVersion(nextEpisode, result.LastWatched, playedVersion); + + DateTime lastWatchedDate = DateTime.MinValue; + if (result.LastWatched is not null) + { + lastWatchedDate = lastPlayedDate ?? DateTime.MinValue.AddDays(1); + } + + nextUpList.Add((lastWatchedDate, nextEpisode)); + } + + if (includeRewatching) + { + var nextPlayedEpisode = DetermineNextEpisodeForRewatching(result, user, includeSpecials); + + if (nextPlayedEpisode is not null) + { + var (playedVersion, lastPlayedDate) = GetMostRecentlyPlayedVersion(result.LastWatchedForRewatching, user); + nextPlayedEpisode = GetPreferredVersion(nextPlayedEpisode, result.LastWatchedForRewatching, playedVersion); + + DateTime rewatchLastWatchedDate = DateTime.MinValue; + if (result.LastWatchedForRewatching is not null) + { + rewatchLastWatchedDate = lastPlayedDate ?? DateTime.MinValue.AddDays(1); + } + + nextUpList.Add((rewatchLastWatchedDate, nextPlayedEpisode)); + } + } + } + + var sortedEpisodes = nextUpList + .OrderByDescending(x => x.LastWatchedDate) + .Select(x => (BaseItem)x.Episode); + + return GetResult(sortedEpisodes, request); + } + + private Episode? DetermineNextEpisode( + MediaBrowser.Controller.Persistence.NextUpEpisodeBatchResult result, + User user, + bool includeSpecials, + bool includeResumable, + bool includePlayed) + { + var nextEpisode = (includePlayed ? result.NextPlayedForRewatching : result.NextUp) as Episode; + var lastWatchedEpisode = (includePlayed ? result.LastWatchedForRewatching : result.LastWatched) as Episode; + + if (includeSpecials && result.Specials?.Count > 0) + { + var consideredEpisodes = result.Specials + .Cast<Episode>() + .Where(episode => episode.AirsBeforeSeasonNumber is not null || episode.AirsAfterSeasonNumber is not null) + .ToList(); + + if (lastWatchedEpisode is not null) + { + consideredEpisodes.Add(lastWatchedEpisode); + } + + if (nextEpisode is not null) + { + consideredEpisodes.Add(nextEpisode); + } + + if (consideredEpisodes.Count > 0) + { + var sortedEpisodes = _libraryManager.Sort(consideredEpisodes, user, [(ItemSortBy.AiredEpisodeOrder, SortOrder.Ascending)]) + .Cast<Episode>(); + + if (lastWatchedEpisode is not null) + { + sortedEpisodes = sortedEpisodes.SkipWhile(episode => !episode.Id.Equals(lastWatchedEpisode.Id)).Skip(1); + } + + if (!includePlayed) + { + sortedEpisodes = sortedEpisodes.Where(episode => _userDataManager.GetUserData(user, episode) is not { Played: true }); + } + + nextEpisode = sortedEpisodes.FirstOrDefault(); + } + } + + if (nextEpisode is not null && !includeResumable) + { + // The resume progress may live on an alternate version + foreach (var version in nextEpisode.GetAllVersions()) + { + if (_userDataManager.GetUserData(user, version)?.PlaybackPositionTicks > 0) + { + return null; + } + } + } + + return nextEpisode; + } + + private Episode? DetermineNextEpisodeForRewatching( + MediaBrowser.Controller.Persistence.NextUpEpisodeBatchResult result, + User user, + bool includeSpecials) + { + return DetermineNextEpisode(result, user, includeSpecials, includeResumable: false, includePlayed: true); + } + + /// <summary> + /// Gets the version of the last watched episode that was actually played, together with its last played date. + /// The version that was played carries the most recent LastPlayedDate. + /// dates. + /// </summary> + /// <param name="lastWatched">The last watched episode (any version).</param> + /// <param name="user">The user.</param> + /// <returns>The played version and its last played date.</returns> + private (Video? PlayedVersion, DateTime? LastPlayedDate) GetMostRecentlyPlayedVersion(BaseItem? lastWatched, User user) + { + if (lastWatched is not Video lastWatchedVideo) + { + return (null, null); + } + + var versions = lastWatchedVideo.GetAllVersions(); + var userDataByVersion = _userDataManager.GetUserDataBatch(versions, user); + + var playedVersion = VersionPlaybackSelector.SelectMostRecentlyPlayed( + versions, + version => userDataByVersion.GetValueOrDefault(version.Id), + data => data.LastPlayedDate.HasValue); + + return (playedVersion, playedVersion is null ? null : userDataByVersion[playedVersion.Id].LastPlayedDate); + } + + /// <summary> + /// When the last watched episode was played as an alternate version, prefer the next episode's version with the matching name, + /// so Next Up continues in the version the user has been watching instead of falling back to the primary. + /// </summary> + /// <param name="nextEpisode">The determined next episode (a primary).</param> + /// <param name="lastWatched">The last watched episode.</param> + /// <param name="playedVersion">The version of the last watched episode that was played.</param> + /// <returns>The matching version of the next episode, or the episode itself.</returns> + private Episode GetPreferredVersion(Episode nextEpisode, BaseItem? lastWatched, Video? playedVersion) + { + // No version preference, or the primary was played + if (lastWatched is not Video lastWatchedVideo + || playedVersion is null + || !playedVersion.PrimaryVersionId.HasValue) + { + return nextEpisode; + } + + // Match by version name + var playedVersionId = playedVersion.Id.ToString("N", CultureInfo.InvariantCulture); + var playedVersionName = lastWatchedVideo.GetMediaSources(false) + .FirstOrDefault(source => string.Equals(source.Id, playedVersionId, StringComparison.OrdinalIgnoreCase))?.Name; + + if (string.IsNullOrEmpty(playedVersionName)) + { + return nextEpisode; + } + + var matchingSource = nextEpisode.GetMediaSources(false) + .FirstOrDefault(source => string.Equals(source.Name, playedVersionName, StringComparison.OrdinalIgnoreCase)); + + if (matchingSource is not null + && Guid.TryParse(matchingSource.Id, out var matchingId) + && !matchingId.Equals(nextEpisode.Id) + && _libraryManager.GetItemById<Episode>(matchingId) is { } matchingVersion) + { + return matchingVersion; + } + + return nextEpisode; } private static string GetUniqueSeriesKey(Series series) @@ -129,127 +320,6 @@ namespace Emby.Server.Implementations.TV return series.GetPresentationUniqueKey(); } - /// <summary> - /// Gets the next up. - /// </summary> - /// <returns>Task{Episode}.</returns> - private (DateTime LastWatchedDate, Func<Episode?> GetEpisodeFunction) GetNextUp(string seriesKey, User user, DtoOptions dtoOptions, bool includeResumable, bool includePlayed) - { - var lastQuery = new InternalItemsQuery(user) - { - AncestorWithPresentationUniqueKey = null, - SeriesPresentationUniqueKey = seriesKey, - IncludeItemTypes = [BaseItemKind.Episode], - IsPlayed = true, - Limit = 1, - ParentIndexNumberNotEquals = 0, - DtoOptions = new DtoOptions - { - Fields = [ItemFields.SortName], - EnableImages = false - } - }; - - // If including played results, sort first by date played and then by season and episode numbers - lastQuery.OrderBy = includePlayed - ? new[] { (ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.ParentIndexNumber, SortOrder.Descending), (ItemSortBy.IndexNumber, SortOrder.Descending) } - : new[] { (ItemSortBy.ParentIndexNumber, SortOrder.Descending), (ItemSortBy.IndexNumber, SortOrder.Descending) }; - - var lastWatchedEpisode = _libraryManager.GetItemList(lastQuery).Cast<Episode>().FirstOrDefault(); - - Episode? GetEpisode() - { - var nextQuery = new InternalItemsQuery(user) - { - AncestorWithPresentationUniqueKey = null, - SeriesPresentationUniqueKey = seriesKey, - IncludeItemTypes = [BaseItemKind.Episode], - OrderBy = [(ItemSortBy.ParentIndexNumber, SortOrder.Ascending), (ItemSortBy.IndexNumber, SortOrder.Ascending)], - Limit = 1, - IsPlayed = includePlayed, - IsVirtualItem = false, - ParentIndexNumberNotEquals = 0, - DtoOptions = dtoOptions - }; - - // Locate the next up episode based on the last watched episode's season and episode number - var lastWatchedParentIndexNumber = lastWatchedEpisode?.ParentIndexNumber; - var lastWatchedIndexNumber = lastWatchedEpisode?.IndexNumberEnd ?? lastWatchedEpisode?.IndexNumber; - if (lastWatchedParentIndexNumber.HasValue && lastWatchedIndexNumber.HasValue) - { - nextQuery.MinParentAndIndexNumber = (lastWatchedParentIndexNumber.Value, lastWatchedIndexNumber.Value + 1); - } - - var nextEpisode = _libraryManager.GetItemList(nextQuery).Cast<Episode>().FirstOrDefault(); - - if (_configurationManager.Configuration.DisplaySpecialsWithinSeasons) - { - var consideredEpisodes = _libraryManager.GetItemList(new InternalItemsQuery(user) - { - AncestorWithPresentationUniqueKey = null, - SeriesPresentationUniqueKey = seriesKey, - ParentIndexNumber = 0, - IncludeItemTypes = [BaseItemKind.Episode], - IsPlayed = includePlayed, - IsVirtualItem = false, - DtoOptions = dtoOptions - }) - .Cast<Episode>() - .Where(episode => episode.AirsBeforeSeasonNumber is not null || episode.AirsAfterSeasonNumber is not null) - .ToList(); - - if (lastWatchedEpisode is not null) - { - // Last watched episode is added, because there could be specials that aired before the last watched episode - consideredEpisodes.Add(lastWatchedEpisode); - } - - if (nextEpisode is not null) - { - consideredEpisodes.Add(nextEpisode); - } - - var sortedConsideredEpisodes = _libraryManager.Sort(consideredEpisodes, user, [(ItemSortBy.AiredEpisodeOrder, SortOrder.Ascending)]) - .Cast<Episode>(); - if (lastWatchedEpisode is not null) - { - sortedConsideredEpisodes = sortedConsideredEpisodes.SkipWhile(episode => !episode.Id.Equals(lastWatchedEpisode.Id)).Skip(1); - } - - nextEpisode = sortedConsideredEpisodes.FirstOrDefault(); - } - - if (nextEpisode is not null && !includeResumable) - { - var userData = _userDataManager.GetUserData(user, nextEpisode); - - if (userData?.PlaybackPositionTicks > 0) - { - return null; - } - } - - return nextEpisode; - } - - if (lastWatchedEpisode is not null) - { - 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); - } - - // Return the first episode - return (DateTime.MinValue, GetEpisode); - } - private static QueryResult<BaseItem> GetResult(IEnumerable<BaseItem> items, NextUpQuery query) { int totalCount = 0; @@ -266,7 +336,7 @@ namespace Emby.Server.Implementations.TV items = items.Skip(query.StartIndex.Value); } - if (query.Limit.HasValue) + if (query.Limit.HasValue && query.Limit.Value > 0) { items = items.Take(query.Limit.Value); } diff --git a/Emby.Server.Implementations/Updates/InstallationManager.cs b/Emby.Server.Implementations/Updates/InstallationManager.cs index 5f9e29b563..cccdb3e6aa 100644 --- a/Emby.Server.Implementations/Updates/InstallationManager.cs +++ b/Emby.Server.Implementations/Updates/InstallationManager.cs @@ -1,4 +1,5 @@ using System; +using System.Buffers; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; @@ -10,7 +11,6 @@ using System.Security.Cryptography; using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Data.Events; using Jellyfin.Extensions; using Jellyfin.Extensions.Json; using MediaBrowser.Common.Configuration; @@ -32,6 +32,11 @@ namespace Emby.Server.Implementations.Updates /// </summary> public class InstallationManager : IInstallationManager { + private static readonly SearchValues<char> InvalidPackageNameChars = SearchValues.Create([.. Path.GetInvalidFileNameChars(), '/', '\\']); + // Budget for the whole package download. The response headers are already bounded by the + // HttpClient timeout; this covers reading the package body, which can be large and slow. + private static readonly TimeSpan PackageDownloadTimeout = TimeSpan.FromMinutes(10); + /// <summary> /// The logger. /// </summary> @@ -79,8 +84,8 @@ namespace Emby.Server.Implementations.Updates IServerConfigurationManager config, IPluginManager pluginManager) { - _currentInstallations = new List<(InstallationInfo, CancellationTokenSource)>(); - _completedInstallationsInternal = new ConcurrentBag<InstallationInfo>(); + _currentInstallations = []; + _completedInstallationsInternal = []; _logger = logger; _applicationHost = appHost; @@ -338,8 +343,9 @@ namespace Emby.Server.Implementations.Updates _applicationHost.NotifyPendingRestart(); } - catch (OperationCanceledException) + catch (OperationCanceledException) when (linkedToken.IsCancellationRequested) { + // Only an actually cancelled token is a cancellation. lock (_currentInstallationsLock) { _currentInstallations.Remove(tuple); @@ -353,7 +359,7 @@ namespace Emby.Server.Implementations.Updates } catch (Exception ex) { - _logger.LogError(ex, "Package installation failed"); + _logger.LogError(ex, "Package installation failed: {Name} {Version}", package.Name, package.Version); lock (_currentInstallationsLock) { @@ -497,8 +503,9 @@ namespace Emby.Server.Implementations.Updates var plugins = _pluginManager.Plugins; foreach (var plugin in plugins) { - // Don't auto update when plugin marked not to, or when it's disabled. - if (plugin.Manifest?.AutoUpdate == false || plugin.Manifest?.Status == PluginStatus.Disabled) + // Don't auto update when plugin marked not to, or when it's disabled or pending removal. + if (plugin.Manifest?.AutoUpdate == false + || plugin.Manifest?.Status is PluginStatus.Disabled or PluginStatus.Deleted) { continue; } @@ -521,48 +528,92 @@ namespace Emby.Server.Implementations.Updates return; } + if (!IsValidPackageDirectoryName(package.Name)) + { + _logger.LogError("Refusing to install package with invalid name {PackageName}.", package.Name); + throw new InvalidDataException($"Plugin package name '{package.Name}' is not a valid directory name."); + } + // Always override the passed-in target (which is a file) and figure it out again string targetDir = Path.Combine(_appPaths.PluginsPath, package.Name); - using var response = await _httpClientFactory.CreateClient(NamedClient.Default) - .GetAsync(new Uri(package.SourceUrl), cancellationToken).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - - // CA5351: Do Not Use Broken Cryptographic Algorithms -#pragma warning disable CA5351 - cancellationToken.ThrowIfCancellationRequested(); - - var hash = Convert.ToHexString(await MD5.HashDataAsync(stream, cancellationToken).ConfigureAwait(false)); - if (!string.Equals(package.Checksum, hash, StringComparison.OrdinalIgnoreCase)) + var pluginsRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(_appPaths.PluginsPath)); + var resolvedTarget = Path.GetFullPath(targetDir); + if (!resolvedTarget.StartsWith(pluginsRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) { _logger.LogError( - "The checksums didn't match while installing {Package}, expected: {Expected}, got: {Received}", + "Refusing to install package {PackageName}: resolved target {Resolved} is outside plugins directory {Root}.", package.Name, - package.Checksum, - hash); - throw new InvalidDataException("The checksum of the received data doesn't match."); + resolvedTarget, + pluginsRoot); + throw new InvalidDataException($"Plugin package name '{package.Name}' resolves outside the plugins directory."); } - // Version folder as they cannot be overwritten in Windows. - targetDir += "_" + package.Version; + // ResponseHeadersRead keeps the body out of the HttpClient timeout, which otherwise covers + // the whole download; the package gets the longer budget below instead. + using var downloadTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + downloadTokenSource.CancelAfter(PackageDownloadTimeout); + var downloadToken = downloadTokenSource.Token; - if (Directory.Exists(targetDir)) + var buffer = new MemoryStream(); + await using (buffer.ConfigureAwait(false)) { try { - Directory.Delete(targetDir, true); - } -#pragma warning disable CA1031 // Do not catch general exception types - catch -#pragma warning restore CA1031 // Do not catch general exception types - { - // Ignore any exceptions. - } - } + using var response = await _httpClientFactory.CreateClient(NamedClient.Default) + .GetAsync(new Uri(package.SourceUrl), HttpCompletionOption.ResponseHeadersRead, downloadToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); - stream.Position = 0; - ZipFile.ExtractToDirectory(stream, targetDir, true); + // The package is read twice, for the checksum and for the extraction, so it has + // to be buffered: the response stream is not seekable. + await response.Content.CopyToAsync(buffer, downloadToken).ConfigureAwait(false); + } + catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested) + { + // Either our budget above or the HttpClient timeout ran out. + throw new TimeoutException( + $"Downloading the package {package.Name} {package.Version} from {package.SourceUrl} timed out.", + ex); + } + + buffer.Position = 0; + Stream stream = buffer; + + // CA5351: Do Not Use Broken Cryptographic Algorithms +#pragma warning disable CA5351 + cancellationToken.ThrowIfCancellationRequested(); + + var hash = Convert.ToHexString(await MD5.HashDataAsync(stream, cancellationToken).ConfigureAwait(false)); + if (!string.Equals(package.Checksum, hash, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogError( + "The checksums didn't match while installing {Package}, expected: {Expected}, got: {Received}", + package.Name, + package.Checksum, + hash); + throw new InvalidDataException("The checksum of the received data doesn't match."); + } + + // Version folder as they cannot be overwritten in Windows. + targetDir += "_" + package.Version; + + if (Directory.Exists(targetDir)) + { + try + { + Directory.Delete(targetDir, true); + } +#pragma warning disable CA1031 // Do not catch general exception types + catch +#pragma warning restore CA1031 // Do not catch general exception types + { + // Ignore any exceptions. + } + } + + stream.Position = 0; + await ZipFile.ExtractToDirectoryAsync(stream, targetDir, true, cancellationToken).ConfigureAwait(false); + } // Ensure we create one or populate existing ones with missing data. await _pluginManager.PopulateManifest(package.PackageInfo, package.Version, targetDir, status).ConfigureAwait(false); @@ -570,6 +621,26 @@ namespace Emby.Server.Implementations.Updates _pluginManager.ImportPluginFrom(targetDir); } + private static bool IsValidPackageDirectoryName(string? name) + { + if (string.IsNullOrWhiteSpace(name)) + { + return false; + } + + if (name.Equals(".", StringComparison.Ordinal) || name.Equals("..", StringComparison.Ordinal)) + { + return false; + } + + if (name.IndexOfAny(InvalidPackageNameChars) >= 0) + { + return false; + } + + return true; + } + private async Task<bool> InstallPackageInternal(InstallationInfo package, CancellationToken cancellationToken) { LocalPlugin? plugin = _pluginManager.Plugins.FirstOrDefault(p => p.Id.Equals(package.Id) && p.Version.Equals(package.Version)) diff --git a/Jellyfin.Api/Controllers/ActivityLogController.cs b/Jellyfin.Api/Controllers/ActivityLogController.cs index a19a203b51..8e17dd7d53 100644 --- a/Jellyfin.Api/Controllers/ActivityLogController.cs +++ b/Jellyfin.Api/Controllers/ActivityLogController.cs @@ -1,13 +1,16 @@ using System; using System.Threading.Tasks; -using Jellyfin.Api.Constants; +using Jellyfin.Api.Helpers; +using Jellyfin.Data.Enums; using Jellyfin.Data.Queries; +using Jellyfin.Database.Implementations.Enums; using MediaBrowser.Common.Api; using MediaBrowser.Model.Activity; using MediaBrowser.Model.Querying; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; namespace Jellyfin.Api.Controllers; @@ -16,6 +19,7 @@ namespace Jellyfin.Api.Controllers; /// </summary> [Route("System/ActivityLog")] [Authorize(Policy = Policies.RequiresElevation)] +[Tags("System")] public class ActivityLogController : BaseJellyfinApiController { private readonly IActivityManager _activityManager; @@ -32,10 +36,20 @@ public class ActivityLogController : BaseJellyfinApiController /// <summary> /// Gets activity log entries. /// </summary> - /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> - /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="minDate">Optional. The minimum date. Format = ISO.</param> - /// <param name="hasUserId">Optional. Filter log entries if it has user id, or not.</param> + /// <param name="startIndex">The record index to start at. All items with a lower index will be dropped from the results.</param> + /// <param name="limit">The maximum number of records to return.</param> + /// <param name="minDate">The minimum date.</param> + /// <param name="maxDate">The maximum date.</param> + /// <param name="hasUserId">Filter log entries if it has user id, or not.</param> + /// <param name="name">Filter by name.</param> + /// <param name="overview">Filter by overview.</param> + /// <param name="shortOverview">Filter by short overview.</param> + /// <param name="type">Filter by type.</param> + /// <param name="itemId">Filter by item id.</param> + /// <param name="username">Filter by username.</param> + /// <param name="severity">Filter by log severity.</param> + /// <param name="sortBy">Specify one or more sort orders. Format: SortBy=Name,Type.</param> + /// <param name="sortOrder">Sort Order..</param> /// <response code="200">Activity log returned.</response> /// <returns>A <see cref="QueryResult{ActivityLogEntry}"/> containing the log entries.</returns> [HttpGet("Entries")] @@ -44,14 +58,35 @@ public class ActivityLogController : BaseJellyfinApiController [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery] DateTime? minDate, - [FromQuery] bool? hasUserId) + [FromQuery] DateTime? maxDate, + [FromQuery] bool? hasUserId, + [FromQuery] string? name, + [FromQuery] string? overview, + [FromQuery] string? shortOverview, + [FromQuery] string? type, + [FromQuery] Guid? itemId, + [FromQuery] string? username, + [FromQuery] LogLevel? severity, + [FromQuery] ActivityLogSortBy[]? sortBy, + [FromQuery] SortOrder[]? sortOrder) { - return await _activityManager.GetPagedResultAsync(new ActivityLogQuery + var query = new ActivityLogQuery { Skip = startIndex, Limit = limit, MinDate = minDate, - HasUserId = hasUserId - }).ConfigureAwait(false); + MaxDate = maxDate, + HasUserId = hasUserId, + Name = name, + Overview = overview, + ShortOverview = shortOverview, + Type = type, + ItemId = itemId, + Username = username, + Severity = severity, + OrderBy = RequestHelpers.GetOrderBy(sortBy ?? [], sortOrder ?? []), + }; + + return await _activityManager.GetPagedResultAsync(query).ConfigureAwait(false); } } diff --git a/Jellyfin.Api/Controllers/ApiKeyController.cs b/Jellyfin.Api/Controllers/ApiKeyController.cs index 3363d7bad2..161479e4ca 100644 --- a/Jellyfin.Api/Controllers/ApiKeyController.cs +++ b/Jellyfin.Api/Controllers/ApiKeyController.cs @@ -14,6 +14,7 @@ namespace Jellyfin.Api.Controllers; /// Authentication controller. /// </summary> [Route("Auth")] +[Tags("Authentication")] public class ApiKeyController : BaseJellyfinApiController { private readonly IAuthenticationManager _authenticationManager; diff --git a/Jellyfin.Api/Controllers/ArtistsController.cs b/Jellyfin.Api/Controllers/ArtistsController.cs index 7ba75dc243..fdbbace1e7 100644 --- a/Jellyfin.Api/Controllers/ArtistsController.cs +++ b/Jellyfin.Api/Controllers/ArtistsController.cs @@ -25,6 +25,7 @@ namespace Jellyfin.Api.Controllers; /// </summary> [Route("Artists")] [Authorize] +[Tags("Artist")] public class ArtistsController : BaseJellyfinApiController { private readonly ILibraryManager _libraryManager; @@ -86,6 +87,7 @@ public class ArtistsController : BaseJellyfinApiController /// <returns>An <see cref="OkResult"/> containing the artists.</returns> [HttpGet] [ProducesResponseType(StatusCodes.Status200OK)] + [Obsolete("Use GetPersons")] public ActionResult<QueryResult<BaseItemDto>> GetArtists( [FromQuery] double? minCommunityRating, [FromQuery] int? startIndex, @@ -122,9 +124,14 @@ public class ArtistsController : BaseJellyfinApiController { userId = RequestHelpers.GetUserId(User, userId); var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + // Asking for a type filter has always implied wanting that type's counts back. + if (includeItemTypes.Length != 0 && !dtoOptions.ContainsField(ItemFields.ItemCounts)) + { + dtoOptions.Fields = [.. dtoOptions.Fields, ItemFields.ItemCounts]; + } + User? user = null; BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId); @@ -188,67 +195,11 @@ public class ArtistsController : BaseJellyfinApiController }).Where(i => i is not null).Select(i => i!.Id).ToArray(); } - foreach (var filter in filters) - { - switch (filter) - { - case ItemFilter.Dislikes: - query.IsLiked = false; - break; - case ItemFilter.IsFavorite: - query.IsFavorite = true; - break; - case ItemFilter.IsFavoriteOrLikes: - query.IsFavoriteOrLiked = true; - break; - case ItemFilter.IsFolder: - query.IsFolder = true; - break; - case ItemFilter.IsNotFolder: - query.IsFolder = false; - break; - case ItemFilter.IsPlayed: - query.IsPlayed = true; - break; - case ItemFilter.IsResumable: - query.IsResumable = true; - break; - case ItemFilter.IsUnplayed: - query.IsPlayed = false; - break; - case ItemFilter.Likes: - query.IsLiked = true; - break; - } - } + query.ApplyFilters(filters); var result = _libraryManager.GetArtists(query); - var dtos = result.Items.Select(i => - { - var (baseItem, itemCounts) = i; - var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user); - - if (includeItemTypes.Length != 0) - { - dto.ChildCount = itemCounts.ItemCount; - dto.ProgramCount = itemCounts.ProgramCount; - dto.SeriesCount = itemCounts.SeriesCount; - dto.EpisodeCount = itemCounts.EpisodeCount; - dto.MovieCount = itemCounts.MovieCount; - dto.TrailerCount = itemCounts.TrailerCount; - dto.AlbumCount = itemCounts.AlbumCount; - dto.SongCount = itemCounts.SongCount; - dto.ArtistCount = itemCounts.ArtistCount; - } - - return dto; - }); - - return new QueryResult<BaseItemDto>( - query.StartIndex, - result.TotalRecordCount, - dtos.ToArray()); + return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, user); } /// <summary> @@ -290,6 +241,7 @@ public class ArtistsController : BaseJellyfinApiController /// <returns>An <see cref="OkResult"/> containing the album artists.</returns> [HttpGet("AlbumArtists")] [ProducesResponseType(StatusCodes.Status200OK)] + [Obsolete("Use GetPersons")] public ActionResult<QueryResult<BaseItemDto>> GetAlbumArtists( [FromQuery] double? minCommunityRating, [FromQuery] int? startIndex, @@ -326,9 +278,14 @@ public class ArtistsController : BaseJellyfinApiController { userId = RequestHelpers.GetUserId(User, userId); var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + // Asking for a type filter has always implied wanting that type's counts back. + if (includeItemTypes.Length != 0 && !dtoOptions.ContainsField(ItemFields.ItemCounts)) + { + dtoOptions.Fields = [.. dtoOptions.Fields, ItemFields.ItemCounts]; + } + User? user = null; BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId); @@ -392,67 +349,11 @@ public class ArtistsController : BaseJellyfinApiController }).Where(i => i is not null).Select(i => i!.Id).ToArray(); } - foreach (var filter in filters) - { - switch (filter) - { - case ItemFilter.Dislikes: - query.IsLiked = false; - break; - case ItemFilter.IsFavorite: - query.IsFavorite = true; - break; - case ItemFilter.IsFavoriteOrLikes: - query.IsFavoriteOrLiked = true; - break; - case ItemFilter.IsFolder: - query.IsFolder = true; - break; - case ItemFilter.IsNotFolder: - query.IsFolder = false; - break; - case ItemFilter.IsPlayed: - query.IsPlayed = true; - break; - case ItemFilter.IsResumable: - query.IsResumable = true; - break; - case ItemFilter.IsUnplayed: - query.IsPlayed = false; - break; - case ItemFilter.Likes: - query.IsLiked = true; - break; - } - } + query.ApplyFilters(filters); var result = _libraryManager.GetAlbumArtists(query); - var dtos = result.Items.Select(i => - { - var (baseItem, itemCounts) = i; - var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user); - - if (includeItemTypes.Length != 0) - { - dto.ChildCount = itemCounts.ItemCount; - dto.ProgramCount = itemCounts.ProgramCount; - dto.SeriesCount = itemCounts.SeriesCount; - dto.EpisodeCount = itemCounts.EpisodeCount; - dto.MovieCount = itemCounts.MovieCount; - dto.TrailerCount = itemCounts.TrailerCount; - dto.AlbumCount = itemCounts.AlbumCount; - dto.SongCount = itemCounts.SongCount; - dto.ArtistCount = itemCounts.ArtistCount; - } - - return dto; - }); - - return new QueryResult<BaseItemDto>( - query.StartIndex, - result.TotalRecordCount, - dtos.ToArray()); + return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, user); } /// <summary> @@ -464,10 +365,11 @@ public class ArtistsController : BaseJellyfinApiController /// <returns>An <see cref="OkResult"/> containing the artist.</returns> [HttpGet("{name}")] [ProducesResponseType(StatusCodes.Status200OK)] + [Obsolete("Use GetPerson")] public ActionResult<BaseItemDto> GetArtistByName([FromRoute, Required] string name, [FromQuery] Guid? userId) { userId = RequestHelpers.GetUserId(User, userId); - var dtoOptions = new DtoOptions().AddClientFields(User); + var dtoOptions = new DtoOptions(); var item = _libraryManager.GetArtist(name, dtoOptions); diff --git a/Jellyfin.Api/Controllers/AudioController.cs b/Jellyfin.Api/Controllers/AudioController.cs index 552030e9e3..77bb6ee7e7 100644 --- a/Jellyfin.Api/Controllers/AudioController.cs +++ b/Jellyfin.Api/Controllers/AudioController.cs @@ -50,7 +50,6 @@ public class AudioController : BaseJellyfinApiController /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> - /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> @@ -92,29 +91,28 @@ public class AudioController : BaseJellyfinApiController [ProducesAudioFile] public async Task<ActionResult> GetAudioStream( [FromRoute, Required] Guid itemId, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? container, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? container, [FromQuery] bool? @static, [FromQuery] string? @params, [FromQuery] string? tag, [FromQuery, ParameterObsolete] string? deviceProfileId, [FromQuery] string? playSessionId, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer, [FromQuery] int? segmentLength, [FromQuery] int? minSegments, [FromQuery] string? mediaSourceId, [FromQuery] string? deviceId, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec, [FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowAudioStreamCopy, - [FromQuery] bool? breakOnNonKeyFrames, [FromQuery] int? audioSampleRate, [FromQuery] int? maxAudioBitDepth, [FromQuery] int? audioBitRate, [FromQuery] int? audioChannels, [FromQuery] int? maxAudioChannels, [FromQuery] string? profile, - [FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level, + [FromQuery][RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level, [FromQuery] float? framerate, [FromQuery] float? maxFramerate, [FromQuery] bool? copyTimestamps, @@ -133,8 +131,8 @@ public class AudioController : BaseJellyfinApiController [FromQuery] int? cpuCoreLimit, [FromQuery] string? liveStreamId, [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec, [FromQuery] string? transcodeReasons, [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, @@ -159,7 +157,6 @@ public class AudioController : BaseJellyfinApiController EnableAutoStreamCopy = enableAutoStreamCopy ?? true, AllowAudioStreamCopy = allowAudioStreamCopy ?? true, AllowVideoStreamCopy = allowVideoStreamCopy ?? true, - BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, AudioSampleRate = audioSampleRate, MaxAudioChannels = maxAudioChannels, AudioBitRate = audioBitRate, @@ -217,7 +214,6 @@ public class AudioController : BaseJellyfinApiController /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> - /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> @@ -259,29 +255,28 @@ public class AudioController : BaseJellyfinApiController [ProducesAudioFile] public async Task<ActionResult> GetAudioStreamByContainer( [FromRoute, Required] Guid itemId, - [FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string container, + [FromRoute, Required][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string container, [FromQuery] bool? @static, [FromQuery] string? @params, [FromQuery] string? tag, [FromQuery, ParameterObsolete] string? deviceProfileId, [FromQuery] string? playSessionId, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer, [FromQuery] int? segmentLength, [FromQuery] int? minSegments, [FromQuery] string? mediaSourceId, [FromQuery] string? deviceId, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec, [FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowAudioStreamCopy, - [FromQuery] bool? breakOnNonKeyFrames, [FromQuery] int? audioSampleRate, [FromQuery] int? maxAudioBitDepth, [FromQuery] int? audioBitRate, [FromQuery] int? audioChannels, [FromQuery] int? maxAudioChannels, [FromQuery] string? profile, - [FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level, + [FromQuery][RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level, [FromQuery] float? framerate, [FromQuery] float? maxFramerate, [FromQuery] bool? copyTimestamps, @@ -300,8 +295,8 @@ public class AudioController : BaseJellyfinApiController [FromQuery] int? cpuCoreLimit, [FromQuery] string? liveStreamId, [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec, [FromQuery] string? transcodeReasons, [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, @@ -326,7 +321,6 @@ public class AudioController : BaseJellyfinApiController EnableAutoStreamCopy = enableAutoStreamCopy ?? true, AllowAudioStreamCopy = allowAudioStreamCopy ?? true, AllowVideoStreamCopy = allowVideoStreamCopy ?? true, - BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, AudioSampleRate = audioSampleRate, MaxAudioChannels = maxAudioChannels, AudioBitRate = audioBitRate, diff --git a/Jellyfin.Api/Controllers/ChannelsController.cs b/Jellyfin.Api/Controllers/ChannelsController.cs index 880b3a82d4..e46ef0e31d 100644 --- a/Jellyfin.Api/Controllers/ChannelsController.cs +++ b/Jellyfin.Api/Controllers/ChannelsController.cs @@ -25,6 +25,7 @@ namespace Jellyfin.Api.Controllers; /// Channels Controller. /// </summary> [Authorize] +[Tags("Channel")] public class ChannelsController : BaseJellyfinApiController { private readonly IChannelManager _channelManager; @@ -136,45 +137,13 @@ public class ChannelsController : BaseJellyfinApiController { Limit = limit, StartIndex = startIndex, - ChannelIds = new[] { channelId }, + ChannelIds = [channelId], ParentId = folderId ?? Guid.Empty, OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder), DtoOptions = new DtoOptions { Fields = fields } }; - foreach (var filter in filters) - { - switch (filter) - { - case ItemFilter.IsFolder: - query.IsFolder = true; - break; - case ItemFilter.IsNotFolder: - query.IsFolder = false; - break; - case ItemFilter.IsUnplayed: - query.IsPlayed = false; - break; - case ItemFilter.IsPlayed: - query.IsPlayed = true; - break; - case ItemFilter.IsFavorite: - query.IsFavorite = true; - break; - case ItemFilter.IsResumable: - query.IsResumable = true; - break; - case ItemFilter.Likes: - query.IsLiked = true; - break; - case ItemFilter.Dislikes: - query.IsLiked = false; - break; - case ItemFilter.IsFavoriteOrLikes: - query.IsFavoriteOrLiked = true; - break; - } - } + query.ApplyFilters(filters); return await _channelManager.GetChannelItems(query, CancellationToken.None).ConfigureAwait(false); } @@ -215,39 +184,7 @@ public class ChannelsController : BaseJellyfinApiController DtoOptions = new DtoOptions { Fields = fields } }; - foreach (var filter in filters) - { - switch (filter) - { - case ItemFilter.IsFolder: - query.IsFolder = true; - break; - case ItemFilter.IsNotFolder: - query.IsFolder = false; - break; - case ItemFilter.IsUnplayed: - query.IsPlayed = false; - break; - case ItemFilter.IsPlayed: - query.IsPlayed = true; - break; - case ItemFilter.IsFavorite: - query.IsFavorite = true; - break; - case ItemFilter.IsResumable: - query.IsResumable = true; - break; - case ItemFilter.Likes: - query.IsLiked = true; - break; - case ItemFilter.Dislikes: - query.IsLiked = false; - break; - case ItemFilter.IsFavoriteOrLikes: - query.IsFavoriteOrLiked = true; - break; - } - } + query.ApplyFilters(filters); return await _channelManager.GetLatestChannelItems(query, CancellationToken.None).ConfigureAwait(false); } diff --git a/Jellyfin.Api/Controllers/ClientLogController.cs b/Jellyfin.Api/Controllers/ClientLogController.cs index 139888bde8..c213b87940 100644 --- a/Jellyfin.Api/Controllers/ClientLogController.cs +++ b/Jellyfin.Api/Controllers/ClientLogController.cs @@ -15,6 +15,7 @@ namespace Jellyfin.Api.Controllers; /// Client log controller. /// </summary> [Authorize] +[Tags("System")] public class ClientLogController : BaseJellyfinApiController { private const int MaxDocumentSize = 1_000_000; diff --git a/Jellyfin.Api/Controllers/CollectionController.cs b/Jellyfin.Api/Controllers/CollectionController.cs index c37f376335..aa2b24c1e7 100644 --- a/Jellyfin.Api/Controllers/CollectionController.cs +++ b/Jellyfin.Api/Controllers/CollectionController.cs @@ -65,7 +65,7 @@ public class CollectionController : BaseJellyfinApiController UserIds = new[] { userId } }).ConfigureAwait(false); - var dtoOptions = new DtoOptions().AddClientFields(User); + var dtoOptions = new DtoOptions(); var dto = _dtoService.GetBaseItemDto(item, dtoOptions); @@ -88,7 +88,7 @@ public class CollectionController : BaseJellyfinApiController [FromRoute, Required] Guid collectionId, [FromQuery, Required, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] ids) { - await _collectionManager.AddToCollectionAsync(collectionId, ids).ConfigureAwait(true); + await _collectionManager.AddToCollectionAsync(collectionId, ids).ConfigureAwait(false); return NoContent(); } diff --git a/Jellyfin.Api/Controllers/ConfigurationController.cs b/Jellyfin.Api/Controllers/ConfigurationController.cs index 8dcaebf6db..ecd667b2e8 100644 --- a/Jellyfin.Api/Controllers/ConfigurationController.cs +++ b/Jellyfin.Api/Controllers/ConfigurationController.cs @@ -3,8 +3,6 @@ using System.ComponentModel.DataAnnotations; using System.Net.Mime; using System.Text.Json; using Jellyfin.Api.Attributes; -using Jellyfin.Api.Constants; -using Jellyfin.Api.Models.ConfigurationDtos; using Jellyfin.Extensions.Json; using MediaBrowser.Common.Api; using MediaBrowser.Controller.Configuration; @@ -22,6 +20,7 @@ namespace Jellyfin.Api.Controllers; /// </summary> [Route("System")] [Authorize] +[Tags("System")] public class ConfigurationController : BaseJellyfinApiController { private readonly IServerConfigurationManager _configurationManager; @@ -143,22 +142,4 @@ public class ConfigurationController : BaseJellyfinApiController return NoContent(); } - - /// <summary> - /// Updates the path to the media encoder. - /// </summary> - /// <param name="mediaEncoderPath">Media encoder path form body.</param> - /// <response code="204">Media encoder path updated.</response> - /// <returns>Status.</returns> - [Obsolete("This endpoint is obsolete.")] - [ApiExplorerSettings(IgnoreApi = true)] - [HttpPost("MediaEncoder/Path")] - [Authorize(Policy = Policies.FirstTimeSetupOrElevated)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult UpdateMediaEncoderPath([FromBody, Required] MediaEncoderPathDto mediaEncoderPath) - { - // API ENDPOINT DISABLED (NOOP) FOR SECURITY PURPOSES - // _mediaEncoder.UpdateEncoderPath(mediaEncoderPath.Path, mediaEncoderPath.PathType); - return NoContent(); - } } diff --git a/Jellyfin.Api/Controllers/DashboardController.cs b/Jellyfin.Api/Controllers/DashboardController.cs index ee912a9be8..b9958867e7 100644 --- a/Jellyfin.Api/Controllers/DashboardController.cs +++ b/Jellyfin.Api/Controllers/DashboardController.cs @@ -20,6 +20,7 @@ namespace Jellyfin.Api.Controllers; /// The dashboard controller. /// </summary> [Route("")] +[Tags("Plugin")] public class DashboardController : BaseJellyfinApiController { private readonly ILogger<DashboardController> _logger; diff --git a/Jellyfin.Api/Controllers/DevicesController.cs b/Jellyfin.Api/Controllers/DevicesController.cs index 50050262f0..2bbfeb40b8 100644 --- a/Jellyfin.Api/Controllers/DevicesController.cs +++ b/Jellyfin.Api/Controllers/DevicesController.cs @@ -1,7 +1,11 @@ using System; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using System.Linq; using System.Threading.Tasks; +using Jellyfin.Api.Attributes; using Jellyfin.Api.Helpers; +using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Dtos; using Jellyfin.Data.Queries; using MediaBrowser.Common.Api; @@ -19,6 +23,7 @@ namespace Jellyfin.Api.Controllers; /// Devices Controller. /// </summary> [Authorize(Policy = Policies.RequiresElevation)] +[Tags("Device")] public class DevicesController : BaseJellyfinApiController { private readonly IDeviceManager _deviceManager; @@ -111,28 +116,31 @@ public class DevicesController : BaseJellyfinApiController } /// <summary> - /// Deletes a device. + /// Deletes devices. /// </summary> - /// <param name="id">Device Id.</param> + /// <param name="id">Device Ids.</param> /// <response code="204">Device deleted.</response> - /// <response code="404">Device not found.</response> - /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns> + /// <response code="400">A requested device is invalid.</response> + /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="BadRequestResult"/> if a requested device is invalid.</returns> [HttpDelete] [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task<ActionResult> DeleteDevice([FromQuery, Required] string id) + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task<ActionResult> DeleteDevice([FromQuery] string[] id) { - var existingDevice = _deviceManager.GetDevice(id); - if (existingDevice is null) + var devices = id.Select(_deviceManager.GetDevice).ToArray(); + if (devices.Any(f => f is null)) { - return NotFound(); + return BadRequest(); } - var sessions = _deviceManager.GetDevices(new DeviceQuery { DeviceId = id }); - - foreach (var session in sessions.Items) + foreach (var device in devices) { - await _sessionManager.Logout(session).ConfigureAwait(false); + var sessions = _deviceManager.GetDevices(new DeviceQuery { DeviceId = device!.Id }); + + foreach (var session in sessions.Items) + { + await _sessionManager.Logout(session).ConfigureAwait(false); + } } return NoContent(); diff --git a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs index 585318d245..c1287fe3f4 100644 --- a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs +++ b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs @@ -20,6 +20,7 @@ namespace Jellyfin.Api.Controllers; /// Display Preferences Controller. /// </summary> [Authorize] +[Tags("DisplayPreference")] public class DisplayPreferencesController : BaseJellyfinApiController { private readonly IDisplayPreferencesManager _displayPreferencesManager; @@ -156,13 +157,13 @@ public class DisplayPreferencesController : BaseJellyfinApiController existingDisplayPreferences.SkipBackwardLength = displayPreferences.CustomPrefs.TryGetValue("skipBackLength", out var skipBackLength) && !string.IsNullOrEmpty(skipBackLength) ? int.Parse(skipBackLength, CultureInfo.InvariantCulture) - : 10000; + : 15000; displayPreferences.CustomPrefs.Remove("skipBackLength"); existingDisplayPreferences.SkipForwardLength = displayPreferences.CustomPrefs.TryGetValue("skipForwardLength", out var skipForwardLength) && !string.IsNullOrEmpty(skipForwardLength) ? int.Parse(skipForwardLength, CultureInfo.InvariantCulture) - : 30000; + : 15000; displayPreferences.CustomPrefs.Remove("skipForwardLength"); existingDisplayPreferences.DashboardTheme = displayPreferences.CustomPrefs.TryGetValue("dashboardTheme", out var theme) @@ -191,9 +192,17 @@ public class DisplayPreferencesController : BaseJellyfinApiController foreach (var key in displayPreferences.CustomPrefs.Keys.Where(key => key.StartsWith("landing-", StringComparison.OrdinalIgnoreCase))) { - if (!Enum.TryParse<ViewType>(displayPreferences.CustomPrefs[key], true, out _)) + var viewType = displayPreferences.CustomPrefs[key]; + + if (string.IsNullOrEmpty(viewType)) { - _logger.LogError("Invalid ViewType: {LandingScreenOption}", displayPreferences.CustomPrefs[key]); + displayPreferences.CustomPrefs.Remove(key); + continue; + } + + if (!Enum.TryParse<ViewType>(viewType, true, out _)) + { + _logger.LogError("Invalid ViewType: {LandingScreenOption}", viewType); displayPreferences.CustomPrefs.Remove(key); } } diff --git a/Jellyfin.Api/Controllers/DynamicHlsController.cs b/Jellyfin.Api/Controllers/DynamicHlsController.cs index da3d934202..291ca5b0ed 100644 --- a/Jellyfin.Api/Controllers/DynamicHlsController.cs +++ b/Jellyfin.Api/Controllers/DynamicHlsController.cs @@ -20,7 +20,6 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Streaming; -using MediaBrowser.MediaEncoding.Encoder; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Entities; @@ -30,6 +29,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace Jellyfin.Api.Controllers; @@ -38,12 +38,15 @@ namespace Jellyfin.Api.Controllers; /// </summary> [Route("")] [Authorize] +[ApiExplorerSettings(IgnoreApi = true)] public class DynamicHlsController : BaseJellyfinApiController { private const EncoderPreset DefaultVodEncoderPreset = EncoderPreset.veryfast; private const EncoderPreset DefaultEventEncoderPreset = EncoderPreset.superfast; private const TranscodingJobType TranscodingJobType = MediaBrowser.Controller.MediaEncoding.TranscodingJobType.Hls; + private static readonly string _podIdentity = Environment.GetEnvironmentVariable("JELLYFIN_INSTANCE_ID") ?? Environment.MachineName; + private readonly Version _minFFmpegFlacInMp4 = new Version(6, 0); private readonly Version _minFFmpegX265BframeInFmp4 = new Version(7, 0, 1); private readonly Version _minFFmpegHlsSegmentOptions = new Version(5, 0); @@ -61,6 +64,7 @@ public class DynamicHlsController : BaseJellyfinApiController private readonly DynamicHlsHelper _dynamicHlsHelper; private readonly EncodingOptions _encodingOptions; private readonly ITranscodeSessionStore _transcodeSessionStore; + private readonly TranscodeStoreOptions _transcodeStoreOptions; /// <summary> /// Initializes a new instance of the <see cref="DynamicHlsController"/> class. @@ -77,6 +81,7 @@ public class DynamicHlsController : BaseJellyfinApiController /// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param> /// <param name="dynamicHlsPlaylistGenerator">Instance of <see cref="IDynamicHlsPlaylistGenerator"/>.</param> /// <param name="transcodeSessionStore">Instance of the <see cref="ITranscodeSessionStore"/> interface used to register and renew HLS transcoding session leases in the durable store.</param> + /// <param name="transcodeStoreOptions">The <see cref="TranscodeStoreOptions"/> holding the session lease duration.</param> public DynamicHlsController( ILibraryManager libraryManager, IUserManager userManager, @@ -89,7 +94,8 @@ public class DynamicHlsController : BaseJellyfinApiController DynamicHlsHelper dynamicHlsHelper, EncodingHelper encodingHelper, IDynamicHlsPlaylistGenerator dynamicHlsPlaylistGenerator, - ITranscodeSessionStore transcodeSessionStore) + ITranscodeSessionStore transcodeSessionStore, + IOptions<TranscodeStoreOptions> transcodeStoreOptions) { _libraryManager = libraryManager; _userManager = userManager; @@ -103,6 +109,7 @@ public class DynamicHlsController : BaseJellyfinApiController _encodingHelper = encodingHelper; _dynamicHlsPlaylistGenerator = dynamicHlsPlaylistGenerator; _transcodeSessionStore = transcodeSessionStore; + _transcodeStoreOptions = transcodeStoreOptions.Value; _encodingOptions = serverConfigurationManager.GetEncodingOptions(); } @@ -126,7 +133,6 @@ public class DynamicHlsController : BaseJellyfinApiController /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> - /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> @@ -171,29 +177,28 @@ public class DynamicHlsController : BaseJellyfinApiController [ProducesPlaylistFile] public async Task<ActionResult> GetLiveHlsStream( [FromRoute, Required] Guid itemId, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? container, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? container, [FromQuery] bool? @static, [FromQuery] string? @params, [FromQuery] string? tag, [FromQuery, ParameterObsolete] string? deviceProfileId, [FromQuery] string? playSessionId, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer, [FromQuery] int? segmentLength, [FromQuery] int? minSegments, [FromQuery] string? mediaSourceId, [FromQuery] string? deviceId, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec, [FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowAudioStreamCopy, - [FromQuery] bool? breakOnNonKeyFrames, [FromQuery] int? audioSampleRate, [FromQuery] int? maxAudioBitDepth, [FromQuery] int? audioBitRate, [FromQuery] int? audioChannels, [FromQuery] int? maxAudioChannels, [FromQuery] string? profile, - [FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level, + [FromQuery][RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level, [FromQuery] float? framerate, [FromQuery] float? maxFramerate, [FromQuery] bool? copyTimestamps, @@ -212,8 +217,8 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] int? cpuCoreLimit, [FromQuery] string? liveStreamId, [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec, [FromQuery] string? transcodeReasons, [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, @@ -242,7 +247,6 @@ public class DynamicHlsController : BaseJellyfinApiController EnableAutoStreamCopy = enableAutoStreamCopy ?? true, AllowAudioStreamCopy = allowAudioStreamCopy ?? true, AllowVideoStreamCopy = allowVideoStreamCopy ?? true, - BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, AudioSampleRate = audioSampleRate, MaxAudioChannels = maxAudioChannels, AudioBitRate = audioBitRate, @@ -313,20 +317,7 @@ public class DynamicHlsController : BaseJellyfinApiController // If the playlist doesn't already exist, startup ffmpeg try { - // Check whether this session is already registered in the HA store (takeover scenario). - var isHaMode = false; - if (!string.IsNullOrEmpty(playSessionId)) - { - try - { - var existingSession = await _transcodeSessionStore.TryGetAsync(playSessionId, cancellationToken).ConfigureAwait(false); - isHaMode = existingSession is not null; - } - catch (Exception haEx) - { - _logger.LogWarning(haEx, "Failed to check HA mode for live-stream session {PlaySessionId}.", playSessionId); - } - } + var isHaMode = await IsHaTakeoverAsync(playSessionId, cancellationToken).ConfigureAwait(false); job = await _transcodeManager.StartFfMpeg( state, @@ -340,6 +331,7 @@ public class DynamicHlsController : BaseJellyfinApiController await RegisterTranscodeSessionAsync( playSessionId ?? string.Empty, mediaSourceId ?? string.Empty, + playlistPath, cancellationToken) .ConfigureAwait(false); StartLeaseRenewal(playSessionId ?? string.Empty, cancellationToken); @@ -389,7 +381,6 @@ public class DynamicHlsController : BaseJellyfinApiController /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> - /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> @@ -441,23 +432,22 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] string? tag, [FromQuery, ParameterObsolete] string? deviceProfileId, [FromQuery] string? playSessionId, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer, [FromQuery] int? segmentLength, [FromQuery] int? minSegments, [FromQuery, Required] string mediaSourceId, [FromQuery] string? deviceId, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec, [FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowAudioStreamCopy, - [FromQuery] bool? breakOnNonKeyFrames, [FromQuery] int? audioSampleRate, [FromQuery] int? maxAudioBitDepth, [FromQuery] int? audioBitRate, [FromQuery] int? audioChannels, [FromQuery] int? maxAudioChannels, [FromQuery] string? profile, - [FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level, + [FromQuery][RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level, [FromQuery] float? framerate, [FromQuery] float? maxFramerate, [FromQuery] bool? copyTimestamps, @@ -478,8 +468,8 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] int? cpuCoreLimit, [FromQuery] string? liveStreamId, [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec, [FromQuery] string? transcodeReasons, [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, @@ -506,7 +496,6 @@ public class DynamicHlsController : BaseJellyfinApiController EnableAutoStreamCopy = enableAutoStreamCopy ?? true, AllowAudioStreamCopy = allowAudioStreamCopy ?? true, AllowVideoStreamCopy = allowVideoStreamCopy ?? true, - BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, AudioSampleRate = audioSampleRate, MaxAudioChannels = maxAudioChannels, AudioBitRate = audioBitRate, @@ -568,7 +557,6 @@ public class DynamicHlsController : BaseJellyfinApiController /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> - /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> /// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param> @@ -617,16 +605,15 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] string? tag, [FromQuery, ParameterObsolete] string? deviceProfileId, [FromQuery] string? playSessionId, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer, [FromQuery] int? segmentLength, [FromQuery] int? minSegments, [FromQuery, Required] string mediaSourceId, [FromQuery] string? deviceId, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec, [FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowAudioStreamCopy, - [FromQuery] bool? breakOnNonKeyFrames, [FromQuery] int? audioSampleRate, [FromQuery] int? maxAudioBitDepth, [FromQuery] int? maxStreamingBitrate, @@ -634,7 +621,7 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] int? audioChannels, [FromQuery] int? maxAudioChannels, [FromQuery] string? profile, - [FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level, + [FromQuery][RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level, [FromQuery] float? framerate, [FromQuery] float? maxFramerate, [FromQuery] bool? copyTimestamps, @@ -653,8 +640,8 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] int? cpuCoreLimit, [FromQuery] string? liveStreamId, [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec, [FromQuery] string? transcodeReasons, [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, @@ -679,7 +666,6 @@ public class DynamicHlsController : BaseJellyfinApiController EnableAutoStreamCopy = enableAutoStreamCopy ?? true, AllowAudioStreamCopy = allowAudioStreamCopy ?? true, AllowVideoStreamCopy = allowVideoStreamCopy ?? true, - BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, AudioSampleRate = audioSampleRate, MaxAudioChannels = maxAudioChannels, AudioBitRate = audioBitRate ?? maxStreamingBitrate, @@ -738,7 +724,6 @@ public class DynamicHlsController : BaseJellyfinApiController /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> - /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> @@ -787,23 +772,22 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] string? tag, [FromQuery, ParameterObsolete] string? deviceProfileId, [FromQuery] string? playSessionId, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer, [FromQuery] int? segmentLength, [FromQuery] int? minSegments, [FromQuery] string? mediaSourceId, [FromQuery] string? deviceId, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec, [FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowAudioStreamCopy, - [FromQuery] bool? breakOnNonKeyFrames, [FromQuery] int? audioSampleRate, [FromQuery] int? maxAudioBitDepth, [FromQuery] int? audioBitRate, [FromQuery] int? audioChannels, [FromQuery] int? maxAudioChannels, [FromQuery] string? profile, - [FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level, + [FromQuery][RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level, [FromQuery] float? framerate, [FromQuery] float? maxFramerate, [FromQuery] bool? copyTimestamps, @@ -824,8 +808,8 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] int? cpuCoreLimit, [FromQuery] string? liveStreamId, [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec, [FromQuery] string? transcodeReasons, [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, @@ -851,7 +835,6 @@ public class DynamicHlsController : BaseJellyfinApiController EnableAutoStreamCopy = enableAutoStreamCopy ?? true, AllowAudioStreamCopy = allowAudioStreamCopy ?? true, AllowVideoStreamCopy = allowVideoStreamCopy ?? true, - BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, AudioSampleRate = audioSampleRate, MaxAudioChannels = maxAudioChannels, AudioBitRate = audioBitRate, @@ -912,7 +895,6 @@ public class DynamicHlsController : BaseJellyfinApiController /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> - /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> /// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param> @@ -959,16 +941,15 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] string? tag, [FromQuery, ParameterObsolete] string? deviceProfileId, [FromQuery] string? playSessionId, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer, [FromQuery] int? segmentLength, [FromQuery] int? minSegments, [FromQuery] string? mediaSourceId, [FromQuery] string? deviceId, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec, [FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowAudioStreamCopy, - [FromQuery] bool? breakOnNonKeyFrames, [FromQuery] int? audioSampleRate, [FromQuery] int? maxAudioBitDepth, [FromQuery] int? maxStreamingBitrate, @@ -976,7 +957,7 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] int? audioChannels, [FromQuery] int? maxAudioChannels, [FromQuery] string? profile, - [FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level, + [FromQuery][RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level, [FromQuery] float? framerate, [FromQuery] float? maxFramerate, [FromQuery] bool? copyTimestamps, @@ -995,8 +976,8 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] int? cpuCoreLimit, [FromQuery] string? liveStreamId, [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec, [FromQuery] string? transcodeReasons, [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, @@ -1021,7 +1002,6 @@ public class DynamicHlsController : BaseJellyfinApiController EnableAutoStreamCopy = enableAutoStreamCopy ?? true, AllowAudioStreamCopy = allowAudioStreamCopy ?? true, AllowVideoStreamCopy = allowVideoStreamCopy ?? true, - BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, AudioSampleRate = audioSampleRate, MaxAudioChannels = maxAudioChannels, AudioBitRate = audioBitRate ?? maxStreamingBitrate, @@ -1085,7 +1065,6 @@ public class DynamicHlsController : BaseJellyfinApiController /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> - /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> @@ -1132,7 +1111,7 @@ public class DynamicHlsController : BaseJellyfinApiController [FromRoute, Required] Guid itemId, [FromRoute, Required] string playlistId, [FromRoute, Required] int segmentId, - [FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string container, + [FromRoute, Required][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string container, [FromQuery, Required] long runtimeTicks, [FromQuery, Required] long actualSegmentLengthTicks, [FromQuery] bool? @static, @@ -1140,23 +1119,22 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] string? tag, [FromQuery, ParameterObsolete] string? deviceProfileId, [FromQuery] string? playSessionId, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer, [FromQuery] int? segmentLength, [FromQuery] int? minSegments, [FromQuery] string? mediaSourceId, [FromQuery] string? deviceId, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec, [FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowAudioStreamCopy, - [FromQuery] bool? breakOnNonKeyFrames, [FromQuery] int? audioSampleRate, [FromQuery] int? maxAudioBitDepth, [FromQuery] int? audioBitRate, [FromQuery] int? audioChannels, [FromQuery] int? maxAudioChannels, [FromQuery] string? profile, - [FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level, + [FromQuery][RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level, [FromQuery] float? framerate, [FromQuery] float? maxFramerate, [FromQuery] bool? copyTimestamps, @@ -1177,8 +1155,8 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] int? cpuCoreLimit, [FromQuery] string? liveStreamId, [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec, [FromQuery] string? transcodeReasons, [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, @@ -1206,7 +1184,6 @@ public class DynamicHlsController : BaseJellyfinApiController EnableAutoStreamCopy = enableAutoStreamCopy ?? true, AllowAudioStreamCopy = allowAudioStreamCopy ?? true, AllowVideoStreamCopy = allowVideoStreamCopy ?? true, - BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, AudioSampleRate = audioSampleRate, MaxAudioChannels = maxAudioChannels, AudioBitRate = audioBitRate, @@ -1272,7 +1249,6 @@ public class DynamicHlsController : BaseJellyfinApiController /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> - /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> /// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param> @@ -1317,7 +1293,7 @@ public class DynamicHlsController : BaseJellyfinApiController [FromRoute, Required] Guid itemId, [FromRoute, Required] string playlistId, [FromRoute, Required] int segmentId, - [FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string container, + [FromRoute, Required][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string container, [FromQuery, Required] long runtimeTicks, [FromQuery, Required] long actualSegmentLengthTicks, [FromQuery] bool? @static, @@ -1325,16 +1301,15 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] string? tag, [FromQuery, ParameterObsolete] string? deviceProfileId, [FromQuery] string? playSessionId, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer, [FromQuery] int? segmentLength, [FromQuery] int? minSegments, [FromQuery] string? mediaSourceId, [FromQuery] string? deviceId, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec, [FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowAudioStreamCopy, - [FromQuery] bool? breakOnNonKeyFrames, [FromQuery] int? audioSampleRate, [FromQuery] int? maxAudioBitDepth, [FromQuery] int? maxStreamingBitrate, @@ -1342,7 +1317,7 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] int? audioChannels, [FromQuery] int? maxAudioChannels, [FromQuery] string? profile, - [FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level, + [FromQuery][RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level, [FromQuery] float? framerate, [FromQuery] float? maxFramerate, [FromQuery] bool? copyTimestamps, @@ -1361,8 +1336,8 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] int? cpuCoreLimit, [FromQuery] string? liveStreamId, [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec, [FromQuery] string? transcodeReasons, [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, @@ -1389,7 +1364,6 @@ public class DynamicHlsController : BaseJellyfinApiController EnableAutoStreamCopy = enableAutoStreamCopy ?? true, AllowAudioStreamCopy = allowAudioStreamCopy ?? true, AllowVideoStreamCopy = allowVideoStreamCopy ?? true, - BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, AudioSampleRate = audioSampleRate, MaxAudioChannels = maxAudioChannels, AudioBitRate = audioBitRate ?? maxStreamingBitrate, @@ -1501,22 +1475,16 @@ public class DynamicHlsController : BaseJellyfinApiController var segmentExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer); - TranscodingJob? job; - - if (System.IO.File.Exists(segmentPath)) - { - job = _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); - _logger.LogDebug("returning {0} [it exists, try 1]", segmentPath); - return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false); - } - + // Keep segment selection and transcoding replacement under the same playlist lock. + // An out-of-order request must not replace a job while another request is using its output. using (await _transcodeManager.LockAsync(playlistPath, cancellationToken).ConfigureAwait(false)) { + TranscodingJob? job; var startTranscoding = false; if (System.IO.File.Exists(segmentPath)) { job = _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); - _logger.LogDebug("returning {0} [it exists, try 2]", segmentPath); + _logger.LogDebug("returning {0} [it exists]", segmentPath); return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false); } @@ -1550,6 +1518,9 @@ public class DynamicHlsController : BaseJellyfinApiController // If the playlist doesn't already exist, startup ffmpeg try { + var currentJob = _transcodeManager.GetTranscodingJob(playlistPath, TranscodingJobType); + await WaitForActiveTranscodingRequests(currentJob, cancellationToken).ConfigureAwait(false); + await _transcodeManager.KillTranscodingJobs(streamingRequest.DeviceId, streamingRequest.PlaySessionId, p => false) .ConfigureAwait(false); @@ -1560,20 +1531,7 @@ public class DynamicHlsController : BaseJellyfinApiController streamingRequest.StartTimeTicks = streamingRequest.CurrentRuntimeTicks; - // Check whether this session is already registered in the HA store (takeover scenario). - var isHaMode = false; - if (!string.IsNullOrEmpty(streamingRequest.PlaySessionId)) - { - try - { - var existingSession = await _transcodeSessionStore.TryGetAsync(streamingRequest.PlaySessionId, cancellationToken).ConfigureAwait(false); - isHaMode = existingSession is not null; - } - catch (Exception haEx) - { - _logger.LogWarning(haEx, "Failed to check HA mode for segment session {PlaySessionId}.", streamingRequest.PlaySessionId); - } - } + var isHaMode = await IsHaTakeoverAsync(streamingRequest.PlaySessionId, cancellationToken).ConfigureAwait(false); state.WaitForPath = segmentPath; job = await _transcodeManager.StartFfMpeg( @@ -1586,6 +1544,7 @@ public class DynamicHlsController : BaseJellyfinApiController await RegisterTranscodeSessionAsync( streamingRequest.PlaySessionId ?? string.Empty, streamingRequest.MediaSourceId ?? string.Empty, + playlistPath, cancellationToken) .ConfigureAwait(false); StartLeaseRenewal(streamingRequest.PlaySessionId ?? string.Empty, cancellationToken); @@ -1606,32 +1565,93 @@ public class DynamicHlsController : BaseJellyfinApiController await job.TranscodingThrottler.UnpauseTranscoding().ConfigureAwait(false); } } - } - _logger.LogDebug("returning {0} [general case]", segmentPath); - job ??= _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); - return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false); + _logger.LogDebug("returning {0} [general case]", segmentPath); + job ??= _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); + return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false); + } + } + + /// <summary> + /// Gets the segment length ffmpeg is told to use. HA mode shortens segments so a takeover pod + /// has to re-encode less; the configured value is user-editable so it is clamped. + /// </summary> + /// <param name="isHaMode">Whether the session is being resumed from the durable store.</param> + /// <param name="requestedSegmentLength">The segment length requested by the streaming pipeline.</param> + /// <param name="encodingOptions">The encoding options holding the recovery segment length.</param> + /// <returns>The segment length in seconds.</returns> + internal static int GetEffectiveSegmentLength(bool isHaMode, int requestedSegmentLength, EncodingOptions encodingOptions) + => isHaMode + ? Math.Clamp(encodingOptions.RecoverySegmentLengthSeconds, 1, 6) + : requestedSegmentLength; + + /// <summary> + /// Gets the ffmpeg <c>hls_list_size</c>. HA mode keeps a bounded rolling buffer; 0 (unbounded) + /// otherwise. + /// </summary> + /// <param name="isHaMode">Whether the session is being resumed from the durable store.</param> + /// <param name="encodingOptions">The encoding options holding the recovery buffer count.</param> + /// <returns>The number of segments to keep in the playlist, or 0 for all of them.</returns> + internal static int GetHlsListSize(bool isHaMode, EncodingOptions encodingOptions) + => isHaMode + ? Math.Clamp(encodingOptions.RecoverySegmentBufferCount, 2, 10) + : 0; + + internal static async Task WaitForActiveTranscodingRequests(TranscodingJob? job, CancellationToken cancellationToken) + { + while (job?.ActiveRequestCount > 0) + { + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + } } private static double[] GetSegmentLengths(StreamState state) => GetSegmentLengthsInternal(state.RunTimeTicks ?? 0, state.SegmentLength); - private async Task RegisterTranscodeSessionAsync(string playSessionId, string mediaSourceId, CancellationToken cancellationToken) + /// <summary> + /// Determines whether the play session is already present in the durable store, which means + /// another pod owned it and this request is resuming it after a failover. + /// </summary> + private async Task<bool> IsHaTakeoverAsync(string? playSessionId, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(playSessionId)) + { + return false; + } + + try + { + return await _transcodeSessionStore.TryGetAsync(playSessionId, cancellationToken).ConfigureAwait(false) is not null; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to check HA mode for session {PlaySessionId}.", playSessionId); + return false; + } + } + + /// <summary> + /// Builds the durable record for an HLS output owned by this instance. The manifest and segment + /// paths have to be real, otherwise <c>DeleteTranscodeFileTask</c> cannot tell which files + /// belong to a live session. + /// </summary> + /// <param name="playSessionId">The play session identifier.</param> + /// <param name="mediaSourceId">The media source identifier.</param> + /// <param name="playlistPath">The absolute path of the HLS playlist this instance is writing.</param> + /// <param name="leaseDuration">The initial lease duration.</param> + /// <returns>The session record to store.</returns> + internal static TranscodeSession CreateSessionRecord(string playSessionId, string mediaSourceId, string playlistPath, TimeSpan leaseDuration) + => TranscodeSession.CreateForPlaylist(playSessionId, mediaSourceId, _podIdentity, playlistPath, leaseDuration); + + private async Task RegisterTranscodeSessionAsync(string playSessionId, string mediaSourceId, string playlistPath, CancellationToken cancellationToken) { try { - var session = new TranscodeSession - { - PlaySessionId = playSessionId, - OwnerPod = Environment.GetEnvironmentVariable("JELLYFIN_INSTANCE_ID") - ?? Environment.MachineName, - LeaseExpiresUtc = DateTime.UtcNow.AddSeconds(30), - ManifestPath = string.Empty, - SegmentPathPrefix = string.Empty, - MediaSourceId = mediaSourceId, - LastCompletedSegmentIndex = 0, - LastDurablePlaybackOffset = 0L, - }; + var session = CreateSessionRecord( + playSessionId, + mediaSourceId, + playlistPath, + TimeSpan.FromSeconds(_transcodeStoreOptions.LeaseDurationSeconds)); await _transcodeSessionStore.SetAsync(session, cancellationToken).ConfigureAwait(false); } catch (Exception ex) @@ -1642,16 +1662,19 @@ public class DynamicHlsController : BaseJellyfinApiController private void StartLeaseRenewal(string playSessionId, CancellationToken cancellationToken) { + var renewalInterval = TimeSpan.FromSeconds(Math.Max(1, _transcodeStoreOptions.LeaseDurationSeconds / 3)); + _ = Task.Run( async () => { + var ownsLease = true; try { while (!cancellationToken.IsCancellationRequested) { try { - await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken).ConfigureAwait(false); + await Task.Delay(renewalInterval, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { @@ -1660,7 +1683,12 @@ public class DynamicHlsController : BaseJellyfinApiController try { - await _transcodeSessionStore.RenewLeaseAsync(playSessionId, cancellationToken).ConfigureAwait(false); + if (!await _transcodeSessionStore.RenewLeaseAsync(playSessionId, _podIdentity, cancellationToken).ConfigureAwait(false)) + { + // Another pod owns the session now; its record must not be touched. + ownsLease = false; + break; + } } catch (Exception ex) { @@ -1674,13 +1702,16 @@ public class DynamicHlsController : BaseJellyfinApiController } finally { - try + if (ownsLease) { - await _transcodeSessionStore.DeleteAsync(playSessionId, CancellationToken.None).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to delete HLS session {PlaySessionId} from durable store.", playSessionId); + try + { + await _transcodeSessionStore.DeleteAsync(playSessionId, CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to delete HLS session {PlaySessionId} from durable store.", playSessionId); + } } } }, @@ -1713,16 +1744,6 @@ public class DynamicHlsController : BaseJellyfinApiController var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions); var threads = EncodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec); - if (state.BaseRequest.BreakOnNonKeyFrames) - { - // FIXME: this is actually a workaround, as ideally it really should be the client which decides whether non-keyframe - // breakpoints are supported; but current implementation always uses "ffmpeg input seeking" which is liable - // to produce a missing part of video stream before first keyframe is encountered, which may lead to - // awkward cases like a few starting HLS segments having no video whatsoever, which breaks hls.js - _logger.LogInformation("Current HLS implementation doesn't support non-keyframe breaks but one is requested, ignoring that request"); - state.BaseRequest.BreakOnNonKeyFrames = false; - } - var mapArgs = state.IsOutputVideo ? _encodingHelper.GetMapArgs(state) : string.Empty; var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath)); @@ -1731,15 +1752,8 @@ public class DynamicHlsController : BaseJellyfinApiController var outputExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer); var outputTsArg = outputPrefix + "%d" + outputExtension; - // In HA mode, use shorter segments and a bounded rolling buffer for faster failover recovery. - // state.SegmentLength is already validated by the streaming pipeline; RecoverySegmentLengthSeconds - // comes from EncodingOptions (user-editable config) so it is clamped here. - var effectiveSegmentLength = isHaMode - ? Math.Clamp(_encodingOptions.RecoverySegmentLengthSeconds, 1, 6) - : state.SegmentLength; - var hlsListSize = isHaMode - ? Math.Clamp(_encodingOptions.RecoverySegmentBufferCount, 2, 10) - : 0; + var effectiveSegmentLength = GetEffectiveSegmentLength(isHaMode, state.SegmentLength, _encodingOptions); + var hlsListSize = GetHlsListSize(isHaMode, _encodingOptions); var segmentFormat = string.Empty; var segmentContainer = outputExtension.TrimStart('.'); @@ -1764,8 +1778,9 @@ public class DynamicHlsController : BaseJellyfinApiController if (state.VideoStream is not null && state.IsOutputVideo) { - // fMP4 needs this flag to write the audio packet DTS/PTS including the initial delay into MOOF::TRAF::TFDT - hlsArguments += $" {(useLegacySegmentOption ? "-hls_ts_options" : "-hls_segment_options")} movflags=+frag_discont"; + // fMP4 needs frag_discont to write the audio packet DTS/PTS including the initial delay into MOOF::TRAF::TFDT + // HLS does not use SIDX, and skipping it avoids FFmpeg rewriting open-GOP boundary packet PTS + hlsArguments += $" {(useLegacySegmentOption ? "-hls_ts_options" : "-hls_segment_options")} movflags=+frag_discont+skip_sidx"; } segmentFormat = "fmp4" + outputFmp4HeaderArg; @@ -1803,9 +1818,9 @@ public class DynamicHlsController : BaseJellyfinApiController segmentFormat, startNumber.ToString(CultureInfo.InvariantCulture), baseUrlParam, - EncodingUtils.NormalizePath(outputTsArg), + outputTsArg.EscapeProcessArgument(), hlsArguments, - EncodingUtils.NormalizePath(outputPath)).Trim(); + outputPath.EscapeProcessArgument()).Trim(); } /// <summary> @@ -1883,11 +1898,6 @@ public class DynamicHlsController : BaseJellyfinApiController var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions); var copyArgs = "-codec:a:0 copy" + bitStreamArgs + strictArgs; - if (EncodingHelper.IsCopyCodec(videoCodec) && state.EnableBreakOnNonKeyFrames(videoCodec)) - { - return copyArgs + " -copypriorss:a:0 0"; - } - return copyArgs; } diff --git a/Jellyfin.Api/Controllers/EnvironmentController.cs b/Jellyfin.Api/Controllers/EnvironmentController.cs index 284a97621d..794ca96932 100644 --- a/Jellyfin.Api/Controllers/EnvironmentController.cs +++ b/Jellyfin.Api/Controllers/EnvironmentController.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.IO; using System.Linq; -using Jellyfin.Api.Constants; using Jellyfin.Api.Models.EnvironmentDtos; using MediaBrowser.Common.Api; using MediaBrowser.Common.Extensions; @@ -128,20 +127,6 @@ public class EnvironmentController : BaseJellyfinApiController return NoContent(); } - /// <summary> - /// Gets network paths. - /// </summary> - /// <response code="200">Empty array returned.</response> - /// <returns>List of entries.</returns> - [Obsolete("This endpoint is obsolete.")] - [HttpGet("NetworkShares")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<IEnumerable<FileSystemEntryInfo>> GetNetworkShares() - { - _logger.LogWarning("Obsolete endpoint accessed: /Environment/NetworkShares"); - return Array.Empty<FileSystemEntryInfo>(); - } - /// <summary> /// Gets available drives from the server's file system. /// </summary> diff --git a/Jellyfin.Api/Controllers/FilterController.cs b/Jellyfin.Api/Controllers/FilterController.cs index 3f9aa93a64..b458bd90f3 100644 --- a/Jellyfin.Api/Controllers/FilterController.cs +++ b/Jellyfin.Api/Controllers/FilterController.cs @@ -8,6 +8,8 @@ using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Globalization; using MediaBrowser.Model.Querying; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -24,16 +26,19 @@ public class FilterController : BaseJellyfinApiController { private readonly ILibraryManager _libraryManager; private readonly IUserManager _userManager; + private readonly ILocalizationManager _localization; /// <summary> /// Initializes a new instance of the <see cref="FilterController"/> class. /// </summary> /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - public FilterController(ILibraryManager libraryManager, IUserManager userManager) + /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param> + public FilterController(ILibraryManager libraryManager, IUserManager userManager, ILocalizationManager localization) { _libraryManager = libraryManager; _userManager = userManager; + _localization = localization; } /// <summary> @@ -60,61 +65,33 @@ public class FilterController : BaseJellyfinApiController BaseItem? item = null; if (includeItemTypes.Length != 1 - || !(includeItemTypes[0] == BaseItemKind.BoxSet - || includeItemTypes[0] == BaseItemKind.Playlist - || includeItemTypes[0] == BaseItemKind.Trailer + || !(includeItemTypes[0] == BaseItemKind.Trailer || includeItemTypes[0] == BaseItemKind.Program)) { item = _libraryManager.GetParentItem(parentId, user?.Id); } - var query = new InternalItemsQuery - { - User = user, - MediaTypes = mediaTypes, - IncludeItemTypes = includeItemTypes, - Recursive = true, - EnableTotalRecordCount = false, - DtoOptions = new DtoOptions - { - Fields = new[] { ItemFields.Genres, ItemFields.Tags }, - EnableImages = false, - EnableUserData = false - } - }; - if (item is not Folder folder) { return new QueryFiltersLegacy(); } - var itemList = folder.GetItemList(query); - return new QueryFiltersLegacy + var query = new InternalItemsQuery(user) { - Years = itemList.Select(i => i.ProductionYear ?? -1) - .Where(i => i > 0) - .Distinct() - .Order() - .ToArray(), - - Genres = itemList.SelectMany(i => i.Genres) - .DistinctNames() - .Order() - .ToArray(), - - Tags = itemList - .SelectMany(i => i.Tags) - .Distinct(StringComparer.OrdinalIgnoreCase) - .Order() - .ToArray(), - - OfficialRatings = itemList - .Select(i => i.OfficialRating) - .Where(i => !string.IsNullOrWhiteSpace(i)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .Order() - .ToArray() + MediaTypes = mediaTypes, + IncludeItemTypes = includeItemTypes, + Recursive = true, + EnableTotalRecordCount = false, + AncestorIds = [folder.Id], + DtoOptions = new DtoOptions + { + Fields = [], + EnableImages = false, + EnableUserData = false + } }; + + return _libraryManager.GetQueryFiltersLegacy(query); } /// <summary> @@ -153,9 +130,7 @@ public class FilterController : BaseJellyfinApiController BaseItem? parentItem = null; if (includeItemTypes.Length == 1 - && (includeItemTypes[0] == BaseItemKind.BoxSet - || includeItemTypes[0] == BaseItemKind.Playlist - || includeItemTypes[0] == BaseItemKind.Trailer + && (includeItemTypes[0] == BaseItemKind.Trailer || includeItemTypes[0] == BaseItemKind.Program)) { parentItem = null; @@ -183,13 +158,43 @@ public class FilterController : BaseJellyfinApiController IsSeries = isSeries }; + var streamLanguageQuery = new InternalItemsQuery(user) + { + // It's possible that different langauges are only available on alternative versions. + // To fetch them all, owned items are included. + IncludeOwnedItems = true, + IncludeItemTypes = includeItemTypes, + DtoOptions = new DtoOptions + { + Fields = Array.Empty<ItemFields>(), + EnableImages = false, + EnableUserData = false + }, + IsAiring = isAiring, + IsMovie = isMovie, + IsSports = isSports, + IsKids = isKids, + IsNews = isNews, + IsSeries = isSeries + }; + if ((recursive ?? true) || parentItem is UserView || parentItem is ICollectionFolder) { - genreQuery.AncestorIds = parentItem is null ? Array.Empty<Guid>() : new[] { parentItem.Id }; + var ancestorIds = parentItem is null ? Array.Empty<Guid>() : new[] { parentItem.Id }; + genreQuery.AncestorIds = ancestorIds; + streamLanguageQuery.AncestorIds = ancestorIds; } else { genreQuery.Parent = parentItem; + streamLanguageQuery.Parent = parentItem; + } + + if ((includeItemTypes.Contains(BaseItemKind.Series) || includeItemTypes.Contains(BaseItemKind.Season)) + && !includeItemTypes.Contains(BaseItemKind.Episode)) + { + // streams are joined on epsiodes not shows or seasons + streamLanguageQuery.IncludeItemTypes = [.. includeItemTypes, BaseItemKind.Episode]; } if (includeItemTypes.Length == 1 @@ -213,6 +218,39 @@ public class FilterController : BaseJellyfinApiController }).ToArray(); } + if (includeItemTypes.Contains(BaseItemKind.Movie) + || includeItemTypes.Contains(BaseItemKind.Series) + || includeItemTypes.Contains(BaseItemKind.Season) + || includeItemTypes.Contains(BaseItemKind.Episode)) + { + filters.AudioLanguages = _libraryManager + .GetMediaStreamLanguages(MediaStreamType.Audio, streamLanguageQuery) + .Select(language => + { + var culture = _localization.FindLanguageInfo(language); + return new NameValuePair + { + Name = culture is null ? language : $"{culture.DisplayName} ({language})", + Value = language + }; + }) + .OrderBy(l => l.Name) + .ToArray(); + filters.SubtitleLanguages = _libraryManager + .GetMediaStreamLanguages(MediaStreamType.Subtitle, streamLanguageQuery) + .Select(language => + { + var culture = _localization.FindLanguageInfo(language); + return new NameValuePair + { + Name = culture is null ? language : $"{culture.DisplayName} ({language})", + Value = language + }; + }) + .OrderBy(l => l.Name) + .ToArray(); + } + return filters; } } diff --git a/Jellyfin.Api/Controllers/GenresController.cs b/Jellyfin.Api/Controllers/GenresController.cs index dd60d01e0c..18a8b67be2 100644 --- a/Jellyfin.Api/Controllers/GenresController.cs +++ b/Jellyfin.Api/Controllers/GenresController.cs @@ -25,6 +25,7 @@ namespace Jellyfin.Api.Controllers; /// The genres controller. /// </summary> [Authorize] +[Tags("Genre")] public class GenresController : BaseJellyfinApiController { private readonly IUserManager _userManager; @@ -94,9 +95,14 @@ public class GenresController : BaseJellyfinApiController { userId = RequestHelpers.GetUserId(User, userId); var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) .AddAdditionalDtoOptions(enableImages, false, imageTypeLimit, enableImageTypes); + // Asking for a type filter has always implied wanting that type's counts back. + if (includeItemTypes.Length != 0 && !dtoOptions.ContainsField(ItemFields.ItemCounts)) + { + dtoOptions.Fields = [.. dtoOptions.Fields, ItemFields.ItemCounts]; + } + User? user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); @@ -143,8 +149,7 @@ public class GenresController : BaseJellyfinApiController result = _libraryManager.GetGenres(query); } - var shouldIncludeItemTypes = includeItemTypes.Length != 0; - return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user); + return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, user); } /// <summary> @@ -159,8 +164,7 @@ public class GenresController : BaseJellyfinApiController public ActionResult<BaseItemDto> GetGenre([FromRoute, Required] string genreName, [FromQuery] Guid? userId) { userId = RequestHelpers.GetUserId(User, userId); - var dtoOptions = new DtoOptions() - .AddClientFields(User); + var dtoOptions = new DtoOptions(); Genre? item; if (genreName.Contains(BaseItem.SlugChar, StringComparison.OrdinalIgnoreCase)) diff --git a/Jellyfin.Api/Controllers/HlsSegmentController.cs b/Jellyfin.Api/Controllers/HlsSegmentController.cs index 1927a332b2..c61ee45830 100644 --- a/Jellyfin.Api/Controllers/HlsSegmentController.cs +++ b/Jellyfin.Api/Controllers/HlsSegmentController.cs @@ -20,6 +20,7 @@ namespace Jellyfin.Api.Controllers; /// The hls segment controller. /// </summary> [Route("")] +[ApiExplorerSettings(IgnoreApi = true)] public class HlsSegmentController : BaseJellyfinApiController { private readonly IFileSystem _fileSystem; @@ -59,11 +60,8 @@ public class HlsSegmentController : BaseJellyfinApiController public ActionResult GetHlsAudioSegmentLegacy([FromRoute, Required] string itemId, [FromRoute, Required] string segmentId) { // TODO: Deprecate with new iOS app - var file = string.Concat(segmentId, Path.GetExtension(Request.Path.Value.AsSpan())); - var transcodePath = _serverConfigurationManager.GetTranscodePath(); - file = Path.GetFullPath(Path.Combine(transcodePath, file)); - var fileDir = Path.GetDirectoryName(file); - if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodePath, StringComparison.InvariantCulture)) + var file = ValidateTranscodePath(string.Concat(segmentId, Path.GetExtension(Request.Path.Value.AsSpan()))); + if (file is null) { return BadRequest("Invalid segment."); } @@ -85,12 +83,9 @@ public class HlsSegmentController : BaseJellyfinApiController [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")] public ActionResult GetHlsPlaylistLegacy([FromRoute, Required] string itemId, [FromRoute, Required] string playlistId) { - var file = string.Concat(playlistId, Path.GetExtension(Request.Path.Value.AsSpan())); - var transcodePath = _serverConfigurationManager.GetTranscodePath(); - file = Path.GetFullPath(Path.Combine(transcodePath, file)); - var fileDir = Path.GetDirectoryName(file); - if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodePath, StringComparison.InvariantCulture) - || Path.GetExtension(file.AsSpan()).Equals(".m3u8", StringComparison.OrdinalIgnoreCase)) + var file = ValidateTranscodePath(string.Concat(playlistId, Path.GetExtension(Request.Path.Value.AsSpan()))); + if (file is null + || !Path.GetExtension(file.AsSpan()).Equals(".m3u8", StringComparison.OrdinalIgnoreCase)) { return BadRequest("Invalid segment."); } @@ -139,18 +134,13 @@ public class HlsSegmentController : BaseJellyfinApiController [FromRoute, Required] string segmentId, [FromRoute, Required] string segmentContainer) { - var file = string.Concat(segmentId, Path.GetExtension(Request.Path.Value.AsSpan())); - var transcodeFolderPath = _serverConfigurationManager.GetTranscodePath(); - - file = Path.GetFullPath(Path.Combine(transcodeFolderPath, file)); - var fileDir = Path.GetDirectoryName(file); - if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodeFolderPath, StringComparison.InvariantCulture)) + var file = ValidateTranscodePath(string.Concat(segmentId, Path.GetExtension(Request.Path.Value.AsSpan()))); + if (file is null) { return BadRequest("Invalid segment."); } - var normalizedPlaylistId = playlistId; - + var transcodeFolderPath = _serverConfigurationManager.GetTranscodePath(); var filePaths = _fileSystem.GetFilePaths(transcodeFolderPath); // Add . to start of segment container for future use. segmentContainer = segmentContainer.Insert(0, "."); @@ -160,7 +150,7 @@ public class HlsSegmentController : BaseJellyfinApiController var pathExtension = Path.GetExtension(path); if ((string.Equals(pathExtension, segmentContainer, StringComparison.OrdinalIgnoreCase) || string.Equals(pathExtension, ".m3u8", StringComparison.OrdinalIgnoreCase)) - && path.Contains(normalizedPlaylistId, StringComparison.OrdinalIgnoreCase)) + && path.Contains(playlistId, StringComparison.OrdinalIgnoreCase)) { playlistPath = path; break; @@ -172,6 +162,19 @@ public class HlsSegmentController : BaseJellyfinApiController : GetFileResult(file, playlistPath); } + private string? ValidateTranscodePath(string filename) + { + var transcodePath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(_serverConfigurationManager.GetTranscodePath())); + var file = Path.GetFullPath(filename, transcodePath); + // Require a separator after the transcode path so a sibling like "<transcodePath>-evil" can't pass. + if (!file.StartsWith(transcodePath + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + return file; + } + private ActionResult GetFileResult(string path, string playlistPath) { var transcodingJob = _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType.Hls); diff --git a/Jellyfin.Api/Controllers/ImageController.cs b/Jellyfin.Api/Controllers/ImageController.cs index cd8132d215..d492a2f5ba 100644 --- a/Jellyfin.Api/Controllers/ImageController.cs +++ b/Jellyfin.Api/Controllers/ImageController.cs @@ -125,7 +125,13 @@ public class ImageController : BaseJellyfinApiController { // Handle image/png; charset=utf-8 var mimeType = Request.ContentType?.Split(';').FirstOrDefault(); - var userDataPath = Path.Combine(_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username); + var userConfigurationDirectoryPath = _serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath; + var userDataPath = Path.Combine(userConfigurationDirectoryPath, user.Username); + if (!PathHelper.IsContainedIn(userConfigurationDirectoryPath, userDataPath)) + { + return BadRequest("Invalid user."); + } + if (user.ProfileImage is not null) { await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false); @@ -1698,7 +1704,8 @@ public class ImageController : BaseJellyfinApiController return await GetImageResult( options, cacheDuration, - ImmutableDictionary<string, string>.Empty) + ImmutableDictionary<string, string>.Empty, + tag) .ConfigureAwait(false); } @@ -1913,7 +1920,8 @@ public class ImageController : BaseJellyfinApiController return await GetImageResult( options, cacheDuration, - responseHeaders).ConfigureAwait(false); + responseHeaders, + tag).ConfigureAwait(false); } private ImageFormat[] GetOutputFormats(ImageFormat? format) @@ -1992,18 +2000,13 @@ public class ImageController : BaseJellyfinApiController private async Task<ActionResult> GetImageResult( ImageProcessingOptions imageProcessingOptions, TimeSpan? cacheDuration, - IDictionary<string, string> headers) + IDictionary<string, string> headers, + string? tag) { var (imagePath, imageContentType, dateImageModified) = await _imageProcessor.ProcessImage(imageProcessingOptions).ConfigureAwait(false); var disableCaching = Request.Headers[HeaderNames.CacheControl].Contains("no-cache"); - var parsingSuccessful = DateTime.TryParse(Request.Headers[HeaderNames.IfModifiedSince], out var ifModifiedSinceHeader); - - // if the parsing of the IfModifiedSince header was not successful, disable caching - if (!parsingSuccessful) - { - // disableCaching = true; - } + var hasTag = !string.IsNullOrEmpty(tag); foreach (var (key, value) in headers) { @@ -2025,7 +2028,8 @@ public class ImageController : BaseJellyfinApiController { if (cacheDuration.HasValue) { - Response.Headers.Append(HeaderNames.CacheControl, "public, max-age=" + cacheDuration.Value.TotalSeconds); + // When tag is provided, the URL is effectively immutable - the tag changes when the image changes + Response.Headers.Append(HeaderNames.CacheControl, "public, max-age=" + cacheDuration.Value.TotalSeconds + ", immutable"); } else { @@ -2034,10 +2038,27 @@ public class ImageController : BaseJellyfinApiController Response.Headers.Append(HeaderNames.LastModified, dateImageModified.ToUniversalTime().ToString("ddd, dd MMM yyyy HH:mm:ss \"GMT\"", CultureInfo.InvariantCulture)); - // if the image was not modified since "ifModifiedSinceHeader"-header, return a HTTP status code 304 not modified - if (!(dateImageModified > ifModifiedSinceHeader) && cacheDuration.HasValue) + // Add ETag header for stronger cache validation when tag is provided + if (hasTag) { - if (ifModifiedSinceHeader.Add(cacheDuration.Value) < DateTime.UtcNow) + Response.Headers.Append(HeaderNames.ETag, $"\"{tag}\""); + + // Check If-None-Match header for ETag-based validation (preferred over If-Modified-Since) + var ifNoneMatch = Request.Headers[HeaderNames.IfNoneMatch].ToString(); + if (!string.IsNullOrEmpty(ifNoneMatch) + && (string.Equals(ifNoneMatch, $"\"{tag}\"", StringComparison.Ordinal) + || string.Equals(ifNoneMatch, tag, StringComparison.Ordinal))) + { + Response.StatusCode = StatusCodes.Status304NotModified; + return new ContentResult(); + } + } + + // Check If-Modified-Since header for time-based validation + if (DateTime.TryParse(Request.Headers[HeaderNames.IfModifiedSince], CultureInfo.InvariantCulture, out var ifModifiedSinceHeader)) + { + // Return 304 if the image has not been modified since the client's cached version + if (dateImageModified <= ifModifiedSinceHeader) { Response.StatusCode = StatusCodes.Status304NotModified; return new ContentResult(); diff --git a/Jellyfin.Api/Controllers/InstantMixController.cs b/Jellyfin.Api/Controllers/InstantMixController.cs index c4b9767565..8cd79645a8 100644 --- a/Jellyfin.Api/Controllers/InstantMixController.cs +++ b/Jellyfin.Api/Controllers/InstantMixController.cs @@ -90,7 +90,6 @@ public class InstantMixController : BaseJellyfinApiController } var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions); return GetResult(items, user, limit, dtoOptions); @@ -134,7 +133,6 @@ public class InstantMixController : BaseJellyfinApiController } var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions); return GetResult(items, user, limit, dtoOptions); @@ -178,7 +176,6 @@ public class InstantMixController : BaseJellyfinApiController } var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions); return GetResult(items, user, limit, dtoOptions); @@ -199,6 +196,7 @@ public class InstantMixController : BaseJellyfinApiController /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> [HttpGet("MusicGenres/{name}/InstantMix")] [ProducesResponseType(StatusCodes.Status200OK)] + [Obsolete("Use GetInstantMixFromItem")] public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromMusicGenreByName( [FromRoute, Required] string name, [FromQuery] Guid? userId, @@ -214,7 +212,6 @@ public class InstantMixController : BaseJellyfinApiController ? null : _userManager.GetUserById(userId.Value); var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); var items = _musicManager.GetInstantMixFromGenres(new[] { name }, user, dtoOptions); return GetResult(items, user, limit, dtoOptions); @@ -258,7 +255,6 @@ public class InstantMixController : BaseJellyfinApiController } var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions); return GetResult(items, user, limit, dtoOptions); @@ -302,7 +298,6 @@ public class InstantMixController : BaseJellyfinApiController } var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions); return GetResult(items, user, limit, dtoOptions); @@ -326,6 +321,7 @@ public class InstantMixController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [Obsolete("Use GetInstantMixFromArtists")] + [ApiExplorerSettings(IgnoreApi = true)] public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromArtists2( [FromQuery, Required] Guid id, [FromQuery] Guid? userId, @@ -364,6 +360,7 @@ public class InstantMixController : BaseJellyfinApiController [HttpGet("MusicGenres/InstantMix")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] + [Obsolete("Use GetInstantMixFromItem")] public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromMusicGenreById( [FromQuery, Required] Guid id, [FromQuery] Guid? userId, @@ -385,7 +382,6 @@ public class InstantMixController : BaseJellyfinApiController } var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions); return GetResult(items, user, limit, dtoOptions); diff --git a/Jellyfin.Api/Controllers/ItemLookupController.cs b/Jellyfin.Api/Controllers/ItemLookupController.cs index d009f80a96..39ba5ab186 100644 --- a/Jellyfin.Api/Controllers/ItemLookupController.cs +++ b/Jellyfin.Api/Controllers/ItemLookupController.cs @@ -13,6 +13,7 @@ using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.Providers; using Microsoft.AspNetCore.Authorization; @@ -263,7 +264,7 @@ public class ItemLookupController : BaseJellyfinApiController searchResult.ProviderIds); // Since the refresh process won't erase provider Ids, we need to set this explicitly now. - item.ProviderIds = searchResult.ProviderIds; + item.SetProviderIds(searchResult.ProviderIds); await _providerManager.RefreshFullItem( item, new MetadataRefreshOptions(new DirectoryService(_fileSystem)) diff --git a/Jellyfin.Api/Controllers/ItemRefreshController.cs b/Jellyfin.Api/Controllers/ItemRefreshController.cs index 7effe61e49..5fc4ad88b6 100644 --- a/Jellyfin.Api/Controllers/ItemRefreshController.cs +++ b/Jellyfin.Api/Controllers/ItemRefreshController.cs @@ -20,6 +20,7 @@ namespace Jellyfin.Api.Controllers; /// </summary> [Route("Items")] [Authorize(Policy = Policies.RequiresElevation)] +[Tags("Library")] public class ItemRefreshController : BaseJellyfinApiController { private readonly ILibraryManager _libraryManager; diff --git a/Jellyfin.Api/Controllers/ItemUpdateController.cs b/Jellyfin.Api/Controllers/ItemUpdateController.cs index e8a50666be..65fffc4181 100644 --- a/Jellyfin.Api/Controllers/ItemUpdateController.cs +++ b/Jellyfin.Api/Controllers/ItemUpdateController.cs @@ -180,11 +180,14 @@ public class ItemUpdateController : BaseJellyfinApiController info.ContentTypeOptions = GetContentTypeOptions(true).ToArray(); info.ContentType = configuredContentType; - if (inheritedContentType is null || inheritedContentType == CollectionType.tvshows) + if (inheritedContentType is null + || inheritedContentType == CollectionType.tvshows + || inheritedContentType == CollectionType.movies) { info.ContentTypeOptions = info.ContentTypeOptions .Where(i => string.IsNullOrWhiteSpace(i.Value) - || string.Equals(i.Value, "TvShows", StringComparison.OrdinalIgnoreCase)) + || string.Equals(i.Value, "TvShows", StringComparison.OrdinalIgnoreCase) + || string.Equals(i.Value, "Movies", StringComparison.OrdinalIgnoreCase)) .ToArray(); } } @@ -233,12 +236,13 @@ public class ItemUpdateController : BaseJellyfinApiController return NoContent(); } - private async Task UpdateItem(BaseItemDto request, BaseItem item) + internal async Task UpdateItem(BaseItemDto request, BaseItem item) { item.Name = request.Name; item.ForcedSortName = request.ForcedSortName; item.OriginalTitle = string.IsNullOrWhiteSpace(request.OriginalTitle) ? null : request.OriginalTitle; + item.OriginalLanguage = string.IsNullOrWhiteSpace(request.OriginalLanguage) ? null : request.OriginalLanguage; item.CriticRating = request.CriticRating; @@ -246,7 +250,11 @@ public class ItemUpdateController : BaseJellyfinApiController item.IndexNumber = request.IndexNumber; item.ParentIndexNumber = request.ParentIndexNumber; item.Overview = request.Overview; - item.Genres = request.Genres; + + if (request.Genres is not null) + { + item.Genres = request.Genres.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + } if (item is Episode episode) { @@ -267,7 +275,7 @@ public class ItemUpdateController : BaseJellyfinApiController if (request.Studios is not null) { - item.Studios = Array.ConvertAll(request.Studios, x => x.Name); + item.Studios = Array.ConvertAll(request.Studios, x => x.Name).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); } if (request.DateCreated.HasValue) @@ -275,6 +283,11 @@ public class ItemUpdateController : BaseJellyfinApiController item.DateCreated = NormalizeDateTime(request.DateCreated.Value); } + if (request.SeriesName is not null && item is IHasSeries hasSeries) + { + hasSeries.SeriesName = request.SeriesName; + } + item.EndDate = request.EndDate.HasValue ? NormalizeDateTime(request.EndDate.Value) : null; item.PremiereDate = request.PremiereDate.HasValue ? NormalizeDateTime(request.PremiereDate.Value) : null; item.ProductionYear = request.ProductionYear; @@ -284,15 +297,27 @@ public class ItemUpdateController : BaseJellyfinApiController item.CustomRating = request.CustomRating; var currentTags = item.Tags; - var newTags = request.Tags; - var removedTags = currentTags.Except(newTags).ToList(); - var addedTags = newTags.Except(currentTags).ToList(); - item.Tags = newTags; + List<string> removedTags; + List<string> addedTags; + if (request.Tags is not null) + { + var newTags = request.Tags.Select(t => t.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + removedTags = currentTags.Except(newTags).ToList(); + addedTags = newTags.Except(currentTags).ToList(); + item.Tags = newTags; + } + else + { + removedTags = []; + addedTags = []; + } if (item is Series rseries) { foreach (var season in rseries.Children.OfType<Season>()) { + season.SeriesName = rseries.Name; + if (!season.LockedFields.Contains(MetadataField.OfficialRating)) { season.OfficialRating = request.OfficialRating; @@ -310,6 +335,8 @@ public class ItemUpdateController : BaseJellyfinApiController foreach (var ep in season.Children.OfType<Episode>()) { + ep.SeriesName = rseries.Name; + if (!ep.LockedFields.Contains(MetadataField.OfficialRating)) { ep.OfficialRating = request.OfficialRating; @@ -370,7 +397,7 @@ public class ItemUpdateController : BaseJellyfinApiController if (request.ProductionLocations is not null) { - item.ProductionLocations = request.ProductionLocations; + item.ProductionLocations = request.ProductionLocations.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); } item.PreferredMetadataCountryCode = request.PreferredMetadataCountryCode; @@ -399,16 +426,11 @@ public class ItemUpdateController : BaseJellyfinApiController item.RunTimeTicks = request.RunTimeTicks; } - foreach (var pair in request.ProviderIds.ToList()) + if (request.ProviderIds is not null) { - if (string.IsNullOrEmpty(pair.Value)) - { - request.ProviderIds.Remove(pair.Key); - } + item.SetProviderIds(request.ProviderIds); } - item.ProviderIds = request.ProviderIds; - if (item is Video video) { video.Video3DFormat = request.Video3DFormat; @@ -418,7 +440,7 @@ public class ItemUpdateController : BaseJellyfinApiController { if (item is IHasAlbumArtist hasAlbumArtists) { - hasAlbumArtists.AlbumArtists = Array.ConvertAll(request.AlbumArtists, i => i.Name.Trim()); + hasAlbumArtists.AlbumArtists = Array.ConvertAll(request.AlbumArtists, i => i.Name.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); } } @@ -426,7 +448,7 @@ public class ItemUpdateController : BaseJellyfinApiController { if (item is IHasArtist hasArtists) { - hasArtists.Artists = Array.ConvertAll(request.ArtistItems, i => i.Name.Trim()); + hasArtists.Artists = Array.ConvertAll(request.ArtistItems, i => i.Name.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); } } diff --git a/Jellyfin.Api/Controllers/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs index a491283363..385795e9f7 100644 --- a/Jellyfin.Api/Controllers/ItemsController.cs +++ b/Jellyfin.Api/Controllers/ItemsController.cs @@ -1,6 +1,8 @@ using System; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; +using System.Threading.Tasks; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; @@ -11,7 +13,9 @@ using Jellyfin.Extensions; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Playlists; using MediaBrowser.Controller.Session; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; @@ -29,6 +33,7 @@ namespace Jellyfin.Api.Controllers; /// </summary> [Route("")] [Authorize] +[Tags("Library")] public class ItemsController : BaseJellyfinApiController { private readonly IUserManager _userManager; @@ -38,6 +43,7 @@ public class ItemsController : BaseJellyfinApiController private readonly ILogger<ItemsController> _logger; private readonly ISessionManager _sessionManager; private readonly IUserDataManager _userDataRepository; + private readonly ISearchManager _searchManager; /// <summary> /// Initializes a new instance of the <see cref="ItemsController"/> class. @@ -49,6 +55,7 @@ public class ItemsController : BaseJellyfinApiController /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param> /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param> /// <param name="userDataRepository">Instance of the <see cref="IUserDataManager"/> interface.</param> + /// <param name="searchManager">Instance of the <see cref="ISearchManager"/> interface.</param> public ItemsController( IUserManager userManager, ILibraryManager libraryManager, @@ -56,7 +63,8 @@ public class ItemsController : BaseJellyfinApiController IDtoService dtoService, ILogger<ItemsController> logger, ISessionManager sessionManager, - IUserDataManager userDataRepository) + IUserDataManager userDataRepository, + ISearchManager searchManager) { _userManager = userManager; _libraryManager = libraryManager; @@ -65,6 +73,7 @@ public class ItemsController : BaseJellyfinApiController _logger = logger; _sessionManager = sessionManager; _userDataRepository = userDataRepository; + _searchManager = searchManager; } /// <summary> @@ -154,12 +163,14 @@ public class ItemsController : BaseJellyfinApiController /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param> /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param> /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param> + /// <param name="audioLanguages">Optional. If specified, results will be filtered based on audio language. This allows multiple, comma delimited values.</param> + /// <param name="subtitleLanguages">Optional. If specified, results will be filtered based on subtitle language. This allows multiple, comma delimited values.</param> /// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param> /// <param name="enableImages">Optional, include image information in output.</param> /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the items.</returns> [HttpGet("Items")] [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetItems( + public async Task<ActionResult<QueryResult<BaseItemDto>>> GetItems( [FromQuery] Guid? userId, [FromQuery] string? maxOfficialRating, [FromQuery] bool? hasThemeSong, @@ -244,6 +255,8 @@ public class ItemsController : BaseJellyfinApiController [FromQuery] string? nameLessThan, [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] studioIds, [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] genreIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] audioLanguages, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] subtitleLanguages, [FromQuery] bool enableTotalRecordCount = true, [FromQuery] bool? enableImages = true) { @@ -264,22 +277,36 @@ public class ItemsController : BaseJellyfinApiController && user.GetPreference(PreferenceKind.AllowedTags).Length != 0 && !fields.Contains(ItemFields.Tags)) { - fields = [..fields, ItemFields.Tags]; + fields = [.. fields, ItemFields.Tags]; } var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - if (includeItemTypes.Length == 1 - && includeItemTypes[0] == BaseItemKind.BoxSet) - { - parentId = null; - } - var item = _libraryManager.GetParentItem(parentId, userId); QueryResult<BaseItem> result; + Guid[] linkedChildAncestorIds = []; + + includeItemTypes ??= []; + if (includeItemTypes.Length == 1 + && (includeItemTypes[0] == BaseItemKind.BoxSet || includeItemTypes[0] == BaseItemKind.Playlist) + && item is not BoxSet + && item is not Playlist) + { + var itemCollectionType = item is IHasCollectionType hct ? hct.CollectionType : null; + var targetCollectionType = includeItemTypes[0] == BaseItemKind.BoxSet + ? CollectionType.boxsets + : CollectionType.playlists; + if (parentId.HasValue && item is not UserRootFolder && itemCollectionType != targetCollectionType) + { + linkedChildAncestorIds = [parentId.Value]; + } + + parentId = null; + item = _libraryManager.GetUserRootFolder(); + } + if (item is not Folder folder) { folder = _libraryManager.GetUserRootFolder(); @@ -294,7 +321,22 @@ public class ItemsController : BaseJellyfinApiController if (collectionType == CollectionType.playlists) { recursive = true; - includeItemTypes = new[] { BaseItemKind.Playlist }; + includeItemTypes = [BaseItemKind.Playlist]; + } + else if (folder is ICollectionFolder && includeItemTypes.Length == 0) + { + includeItemTypes = collectionType switch + { + CollectionType.boxsets => [BaseItemKind.BoxSet], + _ => [] + }; + } + + // includeItemTypes on a library lists its contents recursively rather than just its + // immediate children, so default to a recursive query when the client didn't choose. + if (folder is ICollectionFolder && includeItemTypes.Length > 0) + { + recursive ??= true; } if (item is not UserRootFolder @@ -307,220 +349,273 @@ public class ItemsController : BaseJellyfinApiController return Unauthorized($"{user.Username} is not permitted to access Library {item.Name}."); } - if ((recursive.HasValue && recursive.Value) || ids.Length != 0 || item is not UserRootFolder) + // Build the query up front so the dispatch below can decide the path from it. + // Use search providers when searchTerm is provided. Providers return only IDs and scores; + // items are loaded server-side via folder.GetItems below, which applies user-access filtering. + Dictionary<Guid, float>? searchResultScores = null; + Guid[] itemIds = ids; + + if (!string.IsNullOrWhiteSpace(searchTerm)) { - var query = new InternalItemsQuery(user) + var searchProviderQuery = new SearchProviderQuery { - IsPlayed = isPlayed, - MediaTypes = mediaTypes, + SearchTerm = searchTerm, + UserId = userId, IncludeItemTypes = includeItemTypes, ExcludeItemTypes = excludeItemTypes, - Recursive = recursive ?? false, - OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder), - IsFavorite = isFavorite, - Limit = limit, - StartIndex = startIndex, - IsMissing = isMissing, - IsUnaired = isUnaired, - CollapseBoxSetItems = collapseBoxSetItems, - NameLessThan = nameLessThan, - NameStartsWith = nameStartsWith, - NameStartsWithOrGreater = nameStartsWithOrGreater, - HasImdbId = hasImdbId, - IsPlaceHolder = isPlaceHolder, - IsLocked = isLocked, - MinWidth = minWidth, - MinHeight = minHeight, - MaxWidth = maxWidth, - MaxHeight = maxHeight, - Is3D = is3D, - HasTvdbId = hasTvdbId, - HasTmdbId = hasTmdbId, - IsMovie = isMovie, - IsSeries = isSeries, - IsNews = isNews, - IsKids = isKids, - IsSports = isSports, - HasOverview = hasOverview, - HasOfficialRating = hasOfficialRating, - HasParentalRating = hasParentalRating, - HasSpecialFeature = hasSpecialFeature, - HasSubtitles = hasSubtitles, - HasThemeSong = hasThemeSong, - HasThemeVideo = hasThemeVideo, - HasTrailer = hasTrailer, - IsHD = isHd, - Is4K = is4K, - Tags = tags, - OfficialRatings = officialRatings, - Genres = genres, - ArtistIds = artistIds, - AlbumArtistIds = albumArtistIds, - ContributingArtistIds = contributingArtistIds, - GenreIds = genreIds, - StudioIds = studioIds, - Person = person, - PersonIds = personIds, - PersonTypes = personTypes, - Years = years, - ImageTypes = imageTypes, - VideoTypes = videoTypes, - AdjacentTo = adjacentTo, - ItemIds = ids, - MinCommunityRating = minCommunityRating, - MinCriticRating = minCriticRating, - ParentId = parentId ?? Guid.Empty, - IndexNumber = indexNumber, - ParentIndexNumber = parentIndexNumber, - EnableTotalRecordCount = enableTotalRecordCount, - ExcludeItemIds = excludeItemIds, - DtoOptions = dtoOptions, - SearchTerm = searchTerm, - MinDateLastSaved = minDateLastSaved?.ToUniversalTime(), - MinDateLastSavedForUser = minDateLastSavedForUser?.ToUniversalTime(), - MinPremiereDate = minPremiereDate?.ToUniversalTime(), - MaxPremiereDate = maxPremiereDate?.ToUniversalTime(), + MediaTypes = mediaTypes, + Limit = limit.HasValue ? limit.Value * 3 : null, + ParentId = parentId }; - if (ids.Length != 0 || !string.IsNullOrWhiteSpace(searchTerm)) + var searchResults = await _searchManager.GetSearchResultsAsync(searchProviderQuery, HttpContext.RequestAborted).ConfigureAwait(false); + if (searchResults.Count > 0) { - query.CollapseBoxSetItems = false; + searchResultScores = searchResults.ToDictionary(r => r.ItemId, r => r.Score); + itemIds = ids.Length > 0 + ? ids.Concat(searchResultScores.Keys).Distinct().ToArray() + : searchResultScores.Keys.ToArray(); } + } - foreach (var filter in filters) + var query = new InternalItemsQuery(user) + { + IsPlayed = isPlayed, + MediaTypes = mediaTypes, + IncludeItemTypes = includeItemTypes, + ExcludeItemTypes = excludeItemTypes, + Recursive = recursive ?? false, + OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder), + IsFavorite = isFavorite, + Limit = searchResultScores is null ? limit : null, + StartIndex = searchResultScores is null ? startIndex : null, + IsMissing = isMissing, + IsUnaired = isUnaired, + CollapseBoxSetItems = collapseBoxSetItems, + NameLessThan = nameLessThan, + NameStartsWith = nameStartsWith, + NameStartsWithOrGreater = nameStartsWithOrGreater, + HasImdbId = hasImdbId, + IsPlaceHolder = isPlaceHolder, + IsLocked = isLocked, + MinWidth = minWidth, + MinHeight = minHeight, + MaxWidth = maxWidth, + MaxHeight = maxHeight, + Is3D = is3D, + HasTvdbId = hasTvdbId, + HasTmdbId = hasTmdbId, + IsMovie = isMovie, + IsSeries = isSeries, + IsNews = isNews, + IsKids = isKids, + IsSports = isSports, + HasOverview = hasOverview, + HasOfficialRating = hasOfficialRating, + HasParentalRating = hasParentalRating, + HasSpecialFeature = hasSpecialFeature, + HasSubtitles = hasSubtitles, + HasThemeSong = hasThemeSong, + HasThemeVideo = hasThemeVideo, + HasTrailer = hasTrailer, + IsHD = isHd, + Is4K = is4K, + Tags = tags, + OfficialRatings = officialRatings, + Genres = genres, + ArtistIds = artistIds, + AlbumArtistIds = albumArtistIds, + ContributingArtistIds = contributingArtistIds, + GenreIds = genreIds, + StudioIds = studioIds, + Person = person, + PersonIds = personIds, + PersonTypes = personTypes, + Years = years, + ImageTypes = imageTypes, + VideoTypes = videoTypes, + AdjacentTo = adjacentTo, + ItemIds = itemIds, + MinCommunityRating = minCommunityRating, + MinCriticRating = minCriticRating, + ParentId = parentId ?? Guid.Empty, + IndexNumber = indexNumber, + ParentIndexNumber = parentIndexNumber, + EnableTotalRecordCount = enableTotalRecordCount, + ExcludeItemIds = excludeItemIds, + DtoOptions = dtoOptions, + SearchTerm = searchResultScores is null ? searchTerm : null, + MinDateLastSaved = minDateLastSaved?.ToUniversalTime(), + MinDateLastSavedForUser = minDateLastSavedForUser?.ToUniversalTime(), + MinPremiereDate = minPremiereDate?.ToUniversalTime(), + MaxPremiereDate = maxPremiereDate?.ToUniversalTime(), + AudioLanguages = audioLanguages, + SubtitleLanguages = subtitleLanguages, + LinkedChildAncestorIds = linkedChildAncestorIds, + }; + + if (ids.Length != 0 || !string.IsNullOrWhiteSpace(searchTerm)) + { + query.CollapseBoxSetItems = false; + } + + if (query.SubtitleLanguages.Count > 0 && query.HasSubtitles.HasValue) + { + if (query.HasSubtitles.Value) { - switch (filter) + // if we check for specific subtitles we don't need a separate check for subtitle existence + query.HasSubtitles = null; + } + else + { + // if we search for items without subtitles, we don't need to check for subtitles of a specific language + query.SubtitleLanguages = []; + } + } + + // for filter values that rely on media streams, we need to include alternative and linked versions + if (query.HasSubtitles.HasValue + || query.SubtitleLanguages.Count > 0 + || query.AudioLanguages.Count > 0 + || query.Is3D.HasValue + || query.IsHD.HasValue + || query.Is4K.HasValue + || query.VideoTypes.Length > 0 + ) + { + query.IncludeOwnedItems = true; + } + + query.ApplyFilters(filters); + + // Filter by Series Status + if (seriesStatus.Length != 0) + { + query.SeriesStatuses = seriesStatus; + } + + // Exclude Blocked Unrated Items + var blockedUnratedItems = user?.GetPreferenceValues<UnratedItem>(PreferenceKind.BlockUnratedItems); + if (blockedUnratedItems is not null) + { + query.BlockUnratedItems = blockedUnratedItems; + } + + // ExcludeLocationTypes + if (excludeLocationTypes.Any(t => t == LocationType.Virtual)) + { + query.IsVirtualItem = false; + } + + if (locationTypes.Length > 0 && locationTypes.Length < 4) + { + query.IsVirtualItem = locationTypes.Contains(LocationType.Virtual); + } + + // Min official rating + if (!string.IsNullOrWhiteSpace(minOfficialRating)) + { + query.MinParentalRating = _localization.GetRatingScore(minOfficialRating); + } + + // Max official rating + if (!string.IsNullOrWhiteSpace(maxOfficialRating)) + { + query.MaxParentalRating = _localization.GetRatingScore(maxOfficialRating); + } + + // Artists + if (artists.Length != 0) + { + query.ArtistIds = artists.Select(i => + { + try { - case ItemFilter.Dislikes: - query.IsLiked = false; - break; - case ItemFilter.IsFavorite: - query.IsFavorite = true; - break; - case ItemFilter.IsFavoriteOrLikes: - query.IsFavoriteOrLiked = true; - break; - case ItemFilter.IsFolder: - query.IsFolder = true; - break; - case ItemFilter.IsNotFolder: - query.IsFolder = false; - break; - case ItemFilter.IsPlayed: - query.IsPlayed = true; - break; - case ItemFilter.IsResumable: - query.IsResumable = true; - break; - case ItemFilter.IsUnplayed: - query.IsPlayed = false; - break; - case ItemFilter.Likes: - query.IsLiked = true; - break; + return _libraryManager.GetArtist(i, new DtoOptions(false)); } - } - - // Filter by Series Status - if (seriesStatus.Length != 0) - { - query.SeriesStatuses = seriesStatus; - } - - // Exclude Blocked Unrated Items - var blockedUnratedItems = user?.GetPreferenceValues<UnratedItem>(PreferenceKind.BlockUnratedItems); - if (blockedUnratedItems is not null) - { - query.BlockUnratedItems = blockedUnratedItems; - } - - // ExcludeLocationTypes - if (excludeLocationTypes.Any(t => t == LocationType.Virtual)) - { - query.IsVirtualItem = false; - } - - if (locationTypes.Length > 0 && locationTypes.Length < 4) - { - query.IsVirtualItem = locationTypes.Contains(LocationType.Virtual); - } - - // Min official rating - if (!string.IsNullOrWhiteSpace(minOfficialRating)) - { - query.MinParentalRating = _localization.GetRatingScore(minOfficialRating); - } - - // Max official rating - if (!string.IsNullOrWhiteSpace(maxOfficialRating)) - { - query.MaxParentalRating = _localization.GetRatingScore(maxOfficialRating); - } - - // Artists - if (artists.Length != 0) - { - query.ArtistIds = artists.Select(i => + catch { - try - { - return _libraryManager.GetArtist(i, new DtoOptions(false)); - } - catch - { - return null; - } - }).Where(i => i is not null).Select(i => i!.Id).ToArray(); - } - - // ExcludeArtistIds - if (excludeArtistIds.Length != 0) - { - query.ExcludeArtistIds = excludeArtistIds; - } - - if (albumIds.Length != 0) - { - query.AlbumIds = albumIds; - } - - // Albums - if (albums.Length != 0) - { - query.AlbumIds = albums.SelectMany(i => - { - return _libraryManager.GetItemIds(new InternalItemsQuery { IncludeItemTypes = new[] { BaseItemKind.MusicAlbum }, Name = i, Limit = 1 }); - }).ToArray(); - } - - // Studios - if (studios.Length != 0) - { - query.StudioIds = studios.Select(i => - { - try - { - return _libraryManager.GetStudio(i); - } - catch - { - return null; - } - }).Where(i => i is not null).Select(i => i!.Id).ToArray(); - } - - // Apply default sorting if none requested - if (query.OrderBy.Count == 0) - { - // Albums by artist - if (query.ArtistIds.Length > 0 && query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes[0] == BaseItemKind.MusicAlbum) - { - query.OrderBy = new[] { (ItemSortBy.ProductionYear, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Ascending) }; + return null; } - } + }).Where(i => i is not null).Select(i => i!.Id).ToArray(); + } - query.Parent = null; + // ExcludeArtistIds + if (excludeArtistIds.Length != 0) + { + query.ExcludeArtistIds = excludeArtistIds; + } + + if (albumIds.Length != 0) + { + query.AlbumIds = albumIds; + } + + // Albums + if (albums.Length != 0) + { + query.AlbumIds = albums.SelectMany(i => + { + return _libraryManager.GetItemIds(new InternalItemsQuery { IncludeItemTypes = [BaseItemKind.MusicAlbum], Name = i, Limit = 1 }); + }).ToArray(); + } + + // Studios + if (studios.Length != 0) + { + query.StudioIds = studios.Select(i => + { + try + { + return _libraryManager.GetStudio(i); + } + catch + { + return null; + } + }).Where(i => i is not null).Select(i => i!.Id).ToArray(); + } + + // Apply default sorting if none requested + if (query.OrderBy.Count == 0) + { + // Albums by artist + if (query.ArtistIds.Length > 0 && query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes[0] == BaseItemKind.MusicAlbum) + { + query.OrderBy = [(ItemSortBy.ProductionYear, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Ascending)]; + } + } + + query.Parent = null; + + // At the user root an unfiltered, non-recursive request is a plain listing of the user's libraries + if ((recursive.HasValue && recursive.Value) || ids.Length != 0 || item is not UserRootFolder || query.HasFilters) + { + // folder.GetItems applies user-access filtering via the InternalItemsQuery's User. result = folder.GetItems(query); + if (searchResultScores is not null && searchResultScores.Count > 0) + { + var orderedItems = result.Items + .OrderByDescending(item => searchResultScores.GetValueOrDefault(item.Id, 0f)) + .ThenBy(item => item.SortName) + .ToArray(); + + var totalCount = orderedItems.Length; + if (startIndex.HasValue && startIndex.Value > 0) + { + orderedItems = orderedItems.Skip(startIndex.Value).ToArray(); + } + + if (limit.HasValue) + { + orderedItems = orderedItems.Take(limit.Value).ToArray(); + } + + return new QueryResult<BaseItemDto>( + startIndex, + totalCount, + _dtoService.GetBaseItemDtos(orderedItems, dtoOptions, user)); + } } else { @@ -531,7 +626,7 @@ public class ItemsController : BaseJellyfinApiController return new QueryResult<BaseItemDto>( startIndex, result.TotalRecordCount, - _dtoService.GetBaseItemDtos(result.Items, dtoOptions, user)); + _dtoService.GetBaseItemDtos(result.Items, dtoOptions, user, skipVisibilityCheck: true)); } /// <summary> @@ -627,7 +722,7 @@ public class ItemsController : BaseJellyfinApiController [Obsolete("Kept for backwards compatibility")] [ApiExplorerSettings(IgnoreApi = true)] [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetItemsByUserIdLegacy( + public async Task<ActionResult<QueryResult<BaseItemDto>>> GetItemsByUserIdLegacy( [FromRoute] Guid userId, [FromQuery] string? maxOfficialRating, [FromQuery] bool? hasThemeSong, @@ -713,7 +808,7 @@ public class ItemsController : BaseJellyfinApiController [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] genreIds, [FromQuery] bool enableTotalRecordCount = true, [FromQuery] bool? enableImages = true) - => GetItems( + => await GetItems( userId, maxOfficialRating, hasThemeSong, @@ -798,8 +893,10 @@ public class ItemsController : BaseJellyfinApiController nameLessThan, studioIds, genreIds, + [], + [], enableTotalRecordCount, - enableImages); + enableImages).ConfigureAwait(false); /// <summary> /// Gets items based on a query. @@ -849,7 +946,6 @@ public class ItemsController : BaseJellyfinApiController var parentIdGuid = parentId ?? Guid.Empty; var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); var ancestorIds = Array.Empty<Guid>(); @@ -867,15 +963,21 @@ public class ItemsController : BaseJellyfinApiController var excludeItemIds = Array.Empty<Guid>(); if (excludeActiveSessions) { + // NowPlayingItem.Id is the displayed/primary id, but resume queries surface the actually-played + // alternate version's own id. Expand each active session to every version id so an in-progress + // alternate is excluded too, instead of leaking back into the resume list. excludeItemIds = _sessionManager.Sessions .Where(s => s.UserId.Equals(requestUserId) && s.NowPlayingItem is not null) - .Select(s => s.NowPlayingItem.Id) + .SelectMany(s => _libraryManager.GetItemById(s.NowPlayingItem.Id) is Video video + ? video.GetAllVersions().Select(v => v.Id) + : [s.NowPlayingItem.Id]) + .Distinct() .ToArray(); } var itemsResult = _libraryManager.GetItemsResult(new InternalItemsQuery(user) { - OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending) }, + OrderBy = [(ItemSortBy.DatePlayed, SortOrder.Descending)], IsResumable = true, StartIndex = startIndex, Limit = limit, @@ -885,6 +987,7 @@ public class ItemsController : BaseJellyfinApiController MediaTypes = mediaTypes, IsVirtualItem = false, CollapseBoxSetItems = false, + IncludeOwnedItems = true, EnableTotalRecordCount = enableTotalRecordCount, AncestorIds = ancestorIds, IncludeItemTypes = includeItemTypes, @@ -969,6 +1072,7 @@ public class ItemsController : BaseJellyfinApiController [HttpGet("UserItems/{itemId}/UserData")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] + [Tags("UserData")] public ActionResult<UserItemDataDto?> GetItemUserData( [FromQuery] Guid? userId, [FromRoute, Required] Guid itemId) @@ -1024,6 +1128,7 @@ public class ItemsController : BaseJellyfinApiController [HttpPost("UserItems/{itemId}/UserData")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] + [Tags("UserData")] public ActionResult<UserItemDataDto?> UpdateItemUserData( [FromQuery] Guid? userId, [FromRoute, Required] Guid itemId, diff --git a/Jellyfin.Api/Controllers/LibraryController.cs b/Jellyfin.Api/Controllers/LibraryController.cs index 7566e03eb7..39a6fbace8 100644 --- a/Jellyfin.Api/Controllers/LibraryController.cs +++ b/Jellyfin.Api/Controllers/LibraryController.cs @@ -17,6 +17,7 @@ using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using MediaBrowser.Common.Api; using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Collections; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -47,8 +48,10 @@ namespace Jellyfin.Api.Controllers; public class LibraryController : BaseJellyfinApiController { private readonly IProviderManager _providerManager; + private readonly ISimilarItemsManager _similarItemsManager; private readonly ILibraryManager _libraryManager; private readonly IUserManager _userManager; + private readonly ICollectionManager _collectionManager; private readonly IDtoService _dtoService; private readonly IActivityManager _activityManager; private readonly ILocalizationManager _localization; @@ -60,8 +63,10 @@ public class LibraryController : BaseJellyfinApiController /// Initializes a new instance of the <see cref="LibraryController"/> class. /// </summary> /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param> + /// <param name="similarItemsManager">Instance of the <see cref="ISimilarItemsManager"/> interface.</param> /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="collectionManager">Instance of the <see cref="ICollectionManager"/> interface.</param> /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> /// <param name="activityManager">Instance of the <see cref="IActivityManager"/> interface.</param> /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param> @@ -70,8 +75,10 @@ public class LibraryController : BaseJellyfinApiController /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> public LibraryController( IProviderManager providerManager, + ISimilarItemsManager similarItemsManager, ILibraryManager libraryManager, IUserManager userManager, + ICollectionManager collectionManager, IDtoService dtoService, IActivityManager activityManager, ILocalizationManager localization, @@ -80,8 +87,10 @@ public class LibraryController : BaseJellyfinApiController IServerConfigurationManager serverConfigurationManager) { _providerManager = providerManager; + _similarItemsManager = similarItemsManager; _libraryManager = libraryManager; _userManager = userManager; + _collectionManager = collectionManager; _dtoService = dtoService; _activityManager = activityManager; _localization = localization; @@ -110,21 +119,18 @@ public class LibraryController : BaseJellyfinApiController return NotFound(); } - return PhysicalFile(item.Path, MimeTypes.GetMimeType(item.Path), true); - } + var filePath = item.Path; + if (item.IsFileProtocol) + { + // PhysicalFile does not work well with symlinks at the moment. + var resolved = FileSystemHelper.ResolveLinkTarget(filePath, returnFinalTarget: true); + if (resolved is not null && resolved.Exists) + { + filePath = resolved.FullName; + } + } - /// <summary> - /// Gets critic review for an item. - /// </summary> - /// <response code="200">Critic reviews returned.</response> - /// <returns>The list of critic reviews.</returns> - [HttpGet("Items/{itemId}/CriticReviews")] - [Authorize] - [Obsolete("This endpoint is obsolete.")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetCriticReviews() - { - return new QueryResult<BaseItemDto>(); + return PhysicalFile(filePath, MimeTypes.GetMimeType(filePath), true); } /// <summary> @@ -188,7 +194,7 @@ public class LibraryController : BaseJellyfinApiController item = parent; } - var dtoOptions = new DtoOptions().AddClientFields(User); + var dtoOptions = new DtoOptions(); var items = themeItems .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item)) .ToArray(); @@ -261,7 +267,7 @@ public class LibraryController : BaseJellyfinApiController item = parent; } - var dtoOptions = new DtoOptions().AddClientFields(User); + var dtoOptions = new DtoOptions(); var items = themeItems .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item)) .ToArray(); @@ -456,19 +462,18 @@ public class LibraryController : BaseJellyfinApiController ? null : _userManager.GetUserById(userId.Value); - var counts = new ItemCounts + var query = new InternalItemsQuery(user) { - AlbumCount = GetCount(BaseItemKind.MusicAlbum, user, isFavorite), - EpisodeCount = GetCount(BaseItemKind.Episode, user, isFavorite), - MovieCount = GetCount(BaseItemKind.Movie, user, isFavorite), - SeriesCount = GetCount(BaseItemKind.Series, user, isFavorite), - SongCount = GetCount(BaseItemKind.Audio, user, isFavorite), - MusicVideoCount = GetCount(BaseItemKind.MusicVideo, user, isFavorite), - BoxSetCount = GetCount(BaseItemKind.BoxSet, user, isFavorite), - BookCount = GetCount(BaseItemKind.Book, user, isFavorite) + Recursive = true, + IsVirtualItem = false, + IsFavorite = isFavorite, + DtoOptions = new DtoOptions(false) + { + EnableImages = false + } }; - return counts; + return _libraryManager.GetItemCounts(query); } /// <summary> @@ -497,7 +502,7 @@ public class LibraryController : BaseJellyfinApiController var baseItemDtos = new List<BaseItemDto>(); - var dtoOptions = new DtoOptions().AddClientFields(User); + var dtoOptions = new DtoOptions(); BaseItem? parent = item.GetParent(); while (parent is not null) @@ -557,7 +562,7 @@ public class LibraryController : BaseJellyfinApiController items = items.Where(i => i.IsHidden == val).ToList(); } - var dtoOptions = new DtoOptions().AddClientFields(User); + var dtoOptions = new DtoOptions(); var resultArray = _dtoService.GetBaseItemDtos(items, dtoOptions); return new QueryResult<BaseItemDto>(resultArray); } @@ -715,6 +720,72 @@ public class LibraryController : BaseJellyfinApiController return PhysicalFile(filePath, MimeTypes.GetMimeType(filePath), filename, true); } + /// <summary> + /// Gets the collections that include the specified item. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <param name="startIndex">Optional. The index of the first record in the output.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> + /// <response code="200">Collections returned.</response> + /// <response code="401">User context missing.</response> + /// <response code="404">Item not found.</response> + /// <returns>The collections that contain the requested item.</returns> + [HttpGet("Items/{itemId}/Collections")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<QueryResult<BaseItemDto>> GetItemCollections( + [FromRoute, Required] Guid itemId, + [FromQuery] Guid? userId, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields) + { + userId = RequestHelpers.GetUserId(User, userId); + var user = userId.IsNullOrEmpty() + ? null + : _userManager.GetUserById(userId.Value); + + if (user is null) + { + return Unauthorized(); + } + + var item = _libraryManager.GetItemById<BaseItem>(itemId, user); + if (item is null) + { + return NotFound(); + } + + var dtoOptions = new DtoOptions { Fields = fields }; + + var visibleCollections = _collectionManager + .GetCollectionsContainingItem(user, item.Id) + .OrderBy(i => i.SortName, StringComparer.OrdinalIgnoreCase) + .ThenBy(i => i.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + + IEnumerable<BaseItem> pagedCollections = visibleCollections; + if (startIndex.HasValue) + { + pagedCollections = pagedCollections.Skip(startIndex.Value); + } + + if (limit.HasValue) + { + pagedCollections = pagedCollections.Take(limit.Value); + } + + var dtos = _dtoService.GetBaseItemDtos(pagedCollections.ToList(), dtoOptions, user); + + return new QueryResult<BaseItemDto>( + startIndex, + visibleCollections.Count, + dtos); + } + /// <summary> /// Gets similar items. /// </summary> @@ -723,6 +794,7 @@ public class LibraryController : BaseJellyfinApiController /// <param name="userId">Optional. Filter by user id, and attach user data.</param> /// <param name="limit">Optional. The maximum number of records to return.</param> /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param> + /// <param name="cancellationToken">The cancellation token.</param> /// <response code="200">Similar items returned.</response> /// <returns>A <see cref="QueryResult{BaseItemDto}"/> containing the similar items.</returns> [HttpGet("Artists/{itemId}/Similar", Name = "GetSimilarArtists")] @@ -733,12 +805,13 @@ public class LibraryController : BaseJellyfinApiController [HttpGet("Trailers/{itemId}/Similar", Name = "GetSimilarTrailers")] [Authorize] [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetSimilarItems( + public async Task<ActionResult<QueryResult<BaseItemDto>>> GetSimilarItems( [FromRoute, Required] Guid itemId, [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] excludeArtistIds, [FromQuery] Guid? userId, [FromQuery] int? limit, - [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields) + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields, + CancellationToken cancellationToken) { userId = RequestHelpers.GetUserId(User, userId); var user = userId.IsNullOrEmpty() @@ -759,60 +832,24 @@ public class LibraryController : BaseJellyfinApiController return new QueryResult<BaseItemDto>(); } - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User); + var dtoOptions = new DtoOptions { Fields = fields }; - var program = item as IHasProgramAttributes; - bool? isMovie = item is Movie || (program is not null && program.IsMovie) || item is Trailer; - bool? isSeries = item is Series || (program is not null && program.IsSeries); + // Get library options for provider configuration + var libraryOptions = _libraryManager.GetLibraryOptions(item); - var includeItemTypes = new List<BaseItemKind>(); - if (isMovie.Value) - { - includeItemTypes.Add(BaseItemKind.Movie); - if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) - { - includeItemTypes.Add(BaseItemKind.Trailer); - includeItemTypes.Add(BaseItemKind.LiveTvProgram); - } - } - else if (isSeries.Value) - { - includeItemTypes.Add(BaseItemKind.Series); - } - else - { - // For non series and movie types these columns are typically null - // isSeries = null; - isMovie = null; - includeItemTypes.Add(item.GetBaseItemKind()); - } - - var query = new InternalItemsQuery(user) - { - Genres = item.Genres, - Tags = item.Tags, - Limit = limit, - IncludeItemTypes = includeItemTypes.ToArray(), - DtoOptions = dtoOptions, - EnableTotalRecordCount = !isMovie ?? true, - EnableGroupByMetadataKey = isMovie ?? false, - ExcludeItemIds = [itemId], - OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)] - }; - - // ExcludeArtistIds - if (excludeArtistIds.Length != 0) - { - query.ExcludeArtistIds = excludeArtistIds; - } - - var itemsResult = _libraryManager.GetItemList(query); + var itemsResult = await _similarItemsManager.GetSimilarItemsAsync( + item, + excludeArtistIds, + user, + dtoOptions, + limit, + libraryOptions, + cancellationToken).ConfigureAwait(false); var returnList = _dtoService.GetBaseItemDtos(itemsResult, dtoOptions, user); return new QueryResult<BaseItemDto>( - query.StartIndex, + 0, itemsResult.Count, returnList); } @@ -923,6 +960,17 @@ public class LibraryController : BaseJellyfinApiController .DistinctBy(i => i.Name, StringComparer.OrdinalIgnoreCase) .ToArray(), + SimilarItemProviders = plugins + .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase)) + .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.LocalSimilarityProvider || p.Type == MetadataPluginType.SimilarityProvider)) + .Select(i => new LibraryOptionInfoDto + { + Name = i.Name, + DefaultEnabled = i.Type == MetadataPluginType.LocalSimilarityProvider + }) + .DistinctBy(i => i.Name, StringComparer.OrdinalIgnoreCase) + .ToArray(), + SupportedImageTypes = plugins .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase)) .SelectMany(i => i.SupportedImageTypes ?? Array.Empty<ImageType>()) @@ -938,24 +986,6 @@ public class LibraryController : BaseJellyfinApiController return result; } - private int GetCount(BaseItemKind itemKind, User? user, bool? isFavorite) - { - var query = new InternalItemsQuery(user) - { - IncludeItemTypes = new[] { itemKind }, - Limit = 0, - Recursive = true, - IsVirtualItem = false, - IsFavorite = isFavorite, - DtoOptions = new DtoOptions(false) - { - EnableImages = false - } - }; - - return _libraryManager.GetItemsResult(query).TotalRecordCount; - } - private BaseItem? TranslateParentItem(BaseItem item, User user) { return item.GetParent() is AggregateFolder @@ -969,11 +999,11 @@ public class LibraryController : BaseJellyfinApiController try { await _activityManager.CreateAsync(new ActivityLog( - string.Format(CultureInfo.InvariantCulture, _localization.GetLocalizedString("UserDownloadingItemWithValues"), user.Username, item.Name), + string.Format(CultureInfo.InvariantCulture, _localization.GetServerLocalizedString("UserDownloadingItemWithValues"), user.Username, item.Name), "UserDownloadingContent", User.GetUserId()) { - ShortOverview = string.Format(CultureInfo.InvariantCulture, _localization.GetLocalizedString("AppDeviceValues"), User.GetClient(), User.GetDevice()), + ShortOverview = string.Format(CultureInfo.InvariantCulture, _localization.GetServerLocalizedString("AppDeviceValues"), User.GetClient(), User.GetDevice()), ItemId = item.Id.ToString("N", CultureInfo.InvariantCulture) }).ConfigureAwait(false); } @@ -991,7 +1021,7 @@ public class LibraryController : BaseJellyfinApiController CollectionType.playlists => new[] { "Playlist" }, CollectionType.movies => new[] { "Movie" }, CollectionType.tvshows => new[] { "Series", "Season", "Episode" }, - CollectionType.books => new[] { "Book" }, + CollectionType.books => new[] { "Book", "AudioBook" }, CollectionType.music => new[] { "MusicArtist", "MusicAlbum", "Audio", "MusicVideo" }, CollectionType.homevideos => new[] { "Video", "Photo" }, CollectionType.photos => new[] { "Video", "Photo" }, diff --git a/Jellyfin.Api/Controllers/LibraryStructureController.cs b/Jellyfin.Api/Controllers/LibraryStructureController.cs index 117811429a..65bfe25d21 100644 --- a/Jellyfin.Api/Controllers/LibraryStructureController.cs +++ b/Jellyfin.Api/Controllers/LibraryStructureController.cs @@ -14,7 +14,9 @@ using MediaBrowser.Common.Api; using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Entities; using Microsoft.AspNetCore.Authorization; @@ -33,6 +35,7 @@ public class LibraryStructureController : BaseJellyfinApiController private readonly IServerApplicationPaths _appPaths; private readonly ILibraryManager _libraryManager; private readonly ILibraryMonitor _libraryMonitor; + private readonly IDirectoryService _directoryService; /// <summary> /// Initializes a new instance of the <see cref="LibraryStructureController"/> class. @@ -40,14 +43,17 @@ public class LibraryStructureController : BaseJellyfinApiController /// <param name="serverConfigurationManager">Instance of <see cref="IServerConfigurationManager"/> interface.</param> /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param> /// <param name="libraryMonitor">Instance of <see cref="ILibraryMonitor"/> interface.</param> + /// <param name="directoryService">Instance of <see cref="IDirectoryService"/> interface.</param> public LibraryStructureController( IServerConfigurationManager serverConfigurationManager, ILibraryManager libraryManager, - ILibraryMonitor libraryMonitor) + ILibraryMonitor libraryMonitor, + IDirectoryService directoryService) { _appPaths = serverConfigurationManager.ApplicationPaths; _libraryManager = libraryManager; _libraryMonitor = libraryMonitor; + _directoryService = directoryService; } /// <summary> @@ -75,7 +81,9 @@ public class LibraryStructureController : BaseJellyfinApiController [HttpPost] [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task<ActionResult> AddVirtualFolder( - [FromQuery] string name, + [FromQuery] + [RegularExpression(@"^(?:\S(?:.*\S)?)$", ErrorMessage = "Library name cannot be empty or have leading/trailing spaces.")] + string name, [FromQuery] CollectionTypeOptions? collectionType, [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] paths, [FromBody] AddVirtualFolderDto? libraryOptionsDto, @@ -120,12 +128,14 @@ public class LibraryStructureController : BaseJellyfinApiController /// <param name="newName">The new name.</param> /// <param name="refreshLibrary">Whether to refresh the library.</param> /// <response code="204">Folder renamed.</response> + /// <response code="400">The new name is not a valid library name.</response> /// <response code="404">Library doesn't exist.</response> /// <response code="409">Library already exists.</response> - /// <returns>A <see cref="NoContentResult"/> on success, a <see cref="NotFoundResult"/> if the library doesn't exist, a <see cref="ConflictResult"/> if the new name is already taken.</returns> + /// <returns>A <see cref="NoContentResult"/> on success, a <see cref="BadRequestResult"/> if the new name is invalid, a <see cref="NotFoundResult"/> if the library doesn't exist, a <see cref="ConflictResult"/> if the new name is already taken.</returns> /// <exception cref="ArgumentNullException">The new name may not be null.</exception> [HttpPost("Name")] [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status409Conflict)] public ActionResult RenameVirtualFolder( @@ -145,10 +155,15 @@ public class LibraryStructureController : BaseJellyfinApiController var rootFolderPath = _appPaths.DefaultUserViewsPath; - var currentPath = Path.Combine(rootFolderPath, name); - var newPath = Path.Combine(rootFolderPath, newName); + // Both names are caller supplied, so they have to be confined to the libraries root. + var newPath = FileSystemHelper.GetChildPath(rootFolderPath, newName); + if (newPath is null) + { + return BadRequest("The new name is not a valid library name."); + } - if (!Directory.Exists(currentPath)) + var currentPath = FileSystemHelper.GetChildPath(rootFolderPath, name); + if (currentPath is null || !Directory.Exists(currentPath)) { return NotFound("The media collection does not exist."); } @@ -168,11 +183,11 @@ public class LibraryStructureController : BaseJellyfinApiController var tempPath = Path.Combine( rootFolderPath, Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)); - Directory.Move(currentPath, tempPath); + _directoryService.Move(currentPath, tempPath); currentPath = tempPath; } - Directory.Move(currentPath, newPath); + _directoryService.Move(currentPath, newPath); } finally { @@ -187,6 +202,7 @@ public class LibraryStructureController : BaseJellyfinApiController var newLib = _libraryManager.GetUserRootFolder().Children.FirstOrDefault(f => f.Path.Equals(newPath, StringComparison.OrdinalIgnoreCase)); if (newLib is CollectionFolder folder) { + _libraryManager.ClearIgnoreRuleCache(); foreach (var child in folder.GetPhysicalFolders()) { await child.RefreshMetadata(CancellationToken.None).ConfigureAwait(false); @@ -195,9 +211,12 @@ public class LibraryStructureController : BaseJellyfinApiController } else { + _libraryManager.ClearIgnoreRuleCache(); // We don't know if this one can be validated individually, trigger a new validation await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(false); } + + _libraryManager.ClearIgnoreRuleCache(); } else { diff --git a/Jellyfin.Api/Controllers/LiveTvController.cs b/Jellyfin.Api/Controllers/LiveTvController.cs index 77e3e301a6..1c570daf21 100644 --- a/Jellyfin.Api/Controllers/LiveTvController.cs +++ b/Jellyfin.Api/Controllers/LiveTvController.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; using System.Linq; -using System.Net.Http; using System.Net.Mime; using System.Security.Cryptography; using System.Text; @@ -18,8 +17,6 @@ using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using MediaBrowser.Common.Api; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Common.Net; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; @@ -49,12 +46,11 @@ public class LiveTvController : BaseJellyfinApiController private readonly IListingsManager _listingsManager; private readonly IRecordingsManager _recordingsManager; private readonly IUserManager _userManager; - private readonly IHttpClientFactory _httpClientFactory; private readonly ILibraryManager _libraryManager; private readonly IDtoService _dtoService; private readonly IMediaSourceManager _mediaSourceManager; - private readonly IConfigurationManager _configurationManager; private readonly ITranscodeManager _transcodeManager; + private readonly ISchedulesDirectService _schedulesDirectService; /// <summary> /// Initializes a new instance of the <see cref="LiveTvController"/> class. @@ -65,12 +61,11 @@ public class LiveTvController : BaseJellyfinApiController /// <param name="listingsManager">Instance of the <see cref="IListingsManager"/> interface.</param> /// <param name="recordingsManager">Instance of the <see cref="IRecordingsManager"/> interface.</param> /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param> /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> - /// <param name="configurationManager">Instance of the <see cref="IConfigurationManager"/> interface.</param> /// <param name="transcodeManager">Instance of the <see cref="ITranscodeManager"/> interface.</param> + /// <param name="schedulesDirectService">Instance of the <see cref="ISchedulesDirectService"/> interface.</param> public LiveTvController( ILiveTvManager liveTvManager, IGuideManager guideManager, @@ -78,12 +73,11 @@ public class LiveTvController : BaseJellyfinApiController IListingsManager listingsManager, IRecordingsManager recordingsManager, IUserManager userManager, - IHttpClientFactory httpClientFactory, ILibraryManager libraryManager, IDtoService dtoService, IMediaSourceManager mediaSourceManager, - IConfigurationManager configurationManager, - ITranscodeManager transcodeManager) + ITranscodeManager transcodeManager, + ISchedulesDirectService schedulesDirectService) { _liveTvManager = liveTvManager; _guideManager = guideManager; @@ -91,12 +85,11 @@ public class LiveTvController : BaseJellyfinApiController _listingsManager = listingsManager; _recordingsManager = recordingsManager; _userManager = userManager; - _httpClientFactory = httpClientFactory; _libraryManager = libraryManager; _dtoService = dtoService; _mediaSourceManager = mediaSourceManager; - _configurationManager = configurationManager; _transcodeManager = transcodeManager; + _schedulesDirectService = schedulesDirectService; } /// <summary> @@ -170,7 +163,6 @@ public class LiveTvController : BaseJellyfinApiController { userId = RequestHelpers.GetUserId(User, userId); var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); var channelResult = _liveTvManager.GetInternalChannels( @@ -242,8 +234,7 @@ public class LiveTvController : BaseJellyfinApiController return NotFound(); } - var dtoOptions = new DtoOptions() - .AddClientFields(User); + var dtoOptions = new DtoOptions(); return _dtoService.GetBaseItemDto(item, dtoOptions, user); } @@ -297,7 +288,6 @@ public class LiveTvController : BaseJellyfinApiController { userId = RequestHelpers.GetUserId(User, userId); var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); return await _liveTvManager.GetRecordingsAsync( @@ -347,20 +337,7 @@ public class LiveTvController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status200OK)] [Authorize(Policy = Policies.LiveTvAccess)] [Obsolete("This endpoint is obsolete.")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "channelId", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "groupId", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "startIndex", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "limit", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "status", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "isInProgress", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "seriesTimerId", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableImages", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageTypeLimit", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableImageTypes", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "fields", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableUserData", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableTotalRecordCount", Justification = "Imported from ServiceStack")] + [ApiExplorerSettings(IgnoreApi = true)] public ActionResult<QueryResult<BaseItemDto>> GetRecordingsSeries( [FromQuery] string? channelId, [FromQuery] Guid? userId, @@ -390,7 +367,7 @@ public class LiveTvController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status200OK)] [Authorize(Policy = Policies.LiveTvAccess)] [Obsolete("This endpoint is obsolete.")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Imported from ServiceStack")] + [ApiExplorerSettings(IgnoreApi = true)] public ActionResult<QueryResult<BaseItemDto>> GetRecordingGroups([FromQuery] Guid? userId) { return new QueryResult<BaseItemDto>(); @@ -444,8 +421,7 @@ public class LiveTvController : BaseJellyfinApiController return NotFound(); } - var dtoOptions = new DtoOptions() - .AddClientFields(User); + var dtoOptions = new DtoOptions(); return _dtoService.GetBaseItemDto(item, dtoOptions, user); } @@ -635,7 +611,6 @@ public class LiveTvController : BaseJellyfinApiController } var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); return await _liveTvManager.GetPrograms(query, dtoOptions, CancellationToken.None).ConfigureAwait(false); } @@ -690,7 +665,6 @@ public class LiveTvController : BaseJellyfinApiController } var dtoOptions = new DtoOptions { Fields = body.Fields ?? [] } - .AddClientFields(User) .AddAdditionalDtoOptions(body.EnableImages, body.EnableUserData, body.ImageTypeLimit, body.EnableImageTypes ?? []); return await _liveTvManager.GetPrograms(query, dtoOptions, CancellationToken.None).ConfigureAwait(false); } @@ -760,7 +734,6 @@ public class LiveTvController : BaseJellyfinApiController }; var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); return await _liveTvManager.GetRecommendedProgramsAsync(query, dtoOptions, CancellationToken.None).ConfigureAwait(false); } @@ -771,10 +744,12 @@ public class LiveTvController : BaseJellyfinApiController /// <param name="programId">Program id.</param> /// <param name="userId">Optional. Attach user data.</param> /// <response code="200">Program returned.</response> + /// <response code="404">Program not found.</response> /// <returns>An <see cref="OkResult"/> containing the livetv program.</returns> [HttpGet("Programs/{programId}")] [Authorize(Policy = Policies.LiveTvAccess)] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task<ActionResult<BaseItemDto>> GetProgram( [FromRoute, Required] string programId, [FromQuery] Guid? userId) @@ -783,8 +758,14 @@ public class LiveTvController : BaseJellyfinApiController var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); + var result = await _liveTvManager.GetProgram(programId, CancellationToken.None, user).ConfigureAwait(false); - return await _liveTvManager.GetProgram(programId, CancellationToken.None, user).ConfigureAwait(false); + if (result is null) + { + return NotFound(); + } + + return Ok(result); } /// <summary> @@ -839,7 +820,6 @@ public class LiveTvController : BaseJellyfinApiController [HttpPost("Timers/{timerId}")] [Authorize(Policy = Policies.LiveTvManagement)] [ProducesResponseType(StatusCodes.Status204NoContent)] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "timerId", Justification = "Imported from ServiceStack")] public async Task<ActionResult> UpdateTimer([FromRoute, Required] string timerId, [FromBody] TimerInfoDto timerInfo) { await _liveTvManager.UpdateTimer(timerInfo, CancellationToken.None).ConfigureAwait(false); @@ -929,7 +909,6 @@ public class LiveTvController : BaseJellyfinApiController [HttpPost("SeriesTimers/{timerId}")] [Authorize(Policy = Policies.LiveTvManagement)] [ProducesResponseType(StatusCodes.Status204NoContent)] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "timerId", Justification = "Imported from ServiceStack")] public async Task<ActionResult> UpdateSeriesTimer([FromRoute, Required] string timerId, [FromBody] SeriesTimerInfoDto seriesTimerInfo) { await _liveTvManager.UpdateSeriesTimer(seriesTimerInfo, CancellationToken.None).ConfigureAwait(false); @@ -951,20 +930,6 @@ public class LiveTvController : BaseJellyfinApiController return NoContent(); } - /// <summary> - /// Get recording group. - /// </summary> - /// <param name="groupId">Group id.</param> - /// <returns>A <see cref="NotFoundResult"/>.</returns> - [HttpGet("Recordings/Groups/{groupId}")] - [Authorize(Policy = Policies.LiveTvAccess)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [Obsolete("This endpoint is obsolete.")] - public ActionResult<BaseItemDto> GetRecordingGroup([FromRoute, Required] Guid groupId) - { - return NotFound(); - } - /// <summary> /// Get guide info. /// </summary> @@ -999,9 +964,7 @@ public class LiveTvController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult DeleteTunerHost([FromQuery] string? id) { - var config = _configurationManager.GetConfiguration<LiveTvOptions>("livetv"); - config.TunerHosts = config.TunerHosts.Where(i => !string.Equals(id, i.Id, StringComparison.OrdinalIgnoreCase)).ToArray(); - _configurationManager.SaveConfiguration("livetv", config); + _tunerHostManager.DeleteTunerHost(id); return NoContent(); } @@ -1039,9 +1002,7 @@ public class LiveTvController : BaseJellyfinApiController { if (!string.IsNullOrEmpty(pw)) { - // TODO: remove ToLower when Convert.ToHexString supports lowercase - // Schedules Direct requires the hex to be lowercase - listingsProviderInfo.Password = Convert.ToHexString(SHA1.HashData(Encoding.UTF8.GetBytes(pw))).ToLowerInvariant(); + listingsProviderInfo.Password = Convert.ToHexStringLower(SHA1.HashData(Encoding.UTF8.GetBytes(pw))); } return await _listingsManager.SaveListingProvider(listingsProviderInfo, validateLogin, validateListings).ConfigureAwait(false); @@ -1092,13 +1053,8 @@ public class LiveTvController : BaseJellyfinApiController [ProducesFile(MediaTypeNames.Application.Json)] public async Task<ActionResult> GetSchedulesDirectCountries() { - var client = _httpClientFactory.CreateClient(NamedClient.Default); - // https://json.schedulesdirect.org/20141201/available/countries - // Can't dispose the response as it's required up the call chain. - var response = await client.GetAsync(new Uri("https://json.schedulesdirect.org/20141201/available/countries")) - .ConfigureAwait(false); - - return File(await response.Content.ReadAsStreamAsync().ConfigureAwait(false), MediaTypeNames.Application.Json); + var stream = await _schedulesDirectService.GetAvailableCountries(CancellationToken.None).ConfigureAwait(false); + return File(stream, MediaTypeNames.Application.Json); } /// <summary> @@ -1192,7 +1148,7 @@ public class LiveTvController : BaseJellyfinApiController [ProducesVideoFile] public ActionResult GetLiveStreamFile( [FromRoute, Required] string streamId, - [FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string container) + [FromRoute, Required][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string container) { var liveStreamInfo = _mediaSourceManager.GetLiveStreamInfoByUniqueId(streamId); if (liveStreamInfo is null) diff --git a/Jellyfin.Api/Controllers/LyricsController.cs b/Jellyfin.Api/Controllers/LyricsController.cs index 8eb4cadf88..5a27b2719e 100644 --- a/Jellyfin.Api/Controllers/LyricsController.cs +++ b/Jellyfin.Api/Controllers/LyricsController.cs @@ -7,7 +7,6 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Attributes; using Jellyfin.Api.Extensions; -using Jellyfin.Api.Helpers; using Jellyfin.Extensions; using MediaBrowser.Common.Api; using MediaBrowser.Controller.Entities.Audio; @@ -27,6 +26,7 @@ namespace Jellyfin.Api.Controllers; /// Lyrics controller. /// </summary> [Route("")] +[Tags("Lyric")] public class LyricsController : BaseJellyfinApiController { private readonly ILibraryManager _libraryManager; diff --git a/Jellyfin.Api/Controllers/MediaInfoController.cs b/Jellyfin.Api/Controllers/MediaInfoController.cs index f22ac0b73a..aa942e7642 100644 --- a/Jellyfin.Api/Controllers/MediaInfoController.cs +++ b/Jellyfin.Api/Controllers/MediaInfoController.cs @@ -84,7 +84,7 @@ public class MediaInfoController : BaseJellyfinApiController return NotFound(); } - return await _mediaInfoHelper.GetPlaybackInfo(item, user).ConfigureAwait(false); + return await _mediaInfoHelper.GetPlaybackInfo(item, user, Request).ConfigureAwait(false); } /// <summary> @@ -177,6 +177,7 @@ public class MediaInfoController : BaseJellyfinApiController var info = await _mediaInfoHelper.GetPlaybackInfo( item, user, + Request, mediaSourceId, liveStreamId) .ConfigureAwait(false); @@ -213,7 +214,7 @@ public class MediaInfoController : BaseJellyfinApiController Request.HttpContext.GetNormalizedRemoteIP()); } - _mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate); + _mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate, item.Id); } if (autoOpenLiveStream.Value) diff --git a/Jellyfin.Api/Controllers/MediaSegmentsController.cs b/Jellyfin.Api/Controllers/MediaSegmentsController.cs index b8836d7cf1..65565826a4 100644 --- a/Jellyfin.Api/Controllers/MediaSegmentsController.cs +++ b/Jellyfin.Api/Controllers/MediaSegmentsController.cs @@ -20,6 +20,7 @@ namespace Jellyfin.Api.Controllers; /// Media Segments api. /// </summary> [Authorize] +[Tags("MediaSegment")] public class MediaSegmentsController : BaseJellyfinApiController { private readonly IMediaSegmentManager _mediaSegmentManager; diff --git a/Jellyfin.Api/Controllers/MoviesController.cs b/Jellyfin.Api/Controllers/MoviesController.cs index 363acf815a..a1f2fe7ce7 100644 --- a/Jellyfin.Api/Controllers/MoviesController.cs +++ b/Jellyfin.Api/Controllers/MoviesController.cs @@ -1,23 +1,19 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; -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; using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; namespace Jellyfin.Api.Controllers; @@ -26,30 +22,27 @@ namespace Jellyfin.Api.Controllers; /// Movies controller. /// </summary> [Authorize] +[Tags("Movie")] public class MoviesController : BaseJellyfinApiController { private readonly IUserManager _userManager; - private readonly ILibraryManager _libraryManager; private readonly IDtoService _dtoService; - private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly ISimilarItemsManager _similarItemsManager; /// <summary> /// Initializes a new instance of the <see cref="MoviesController"/> class. /// </summary> /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> - /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="similarItemsManager">Instance of the <see cref="ISimilarItemsManager"/> interface.</param> public MoviesController( IUserManager userManager, - ILibraryManager libraryManager, IDtoService dtoService, - IServerConfigurationManager serverConfigurationManager) + ISimilarItemsManager similarItemsManager) { _userManager = userManager; - _libraryManager = libraryManager; _dtoService = dtoService; - _serverConfigurationManager = serverConfigurationManager; + _similarItemsManager = similarItemsManager; } /// <summary> @@ -60,268 +53,34 @@ public class MoviesController : BaseJellyfinApiController /// <param name="fields">Optional. The fields to return.</param> /// <param name="categoryLimit">The max number of categories to return.</param> /// <param name="itemLimit">The max number of items to return per category.</param> + /// <param name="cancellationToken">The cancellation token.</param> /// <response code="200">Movie recommendations returned.</response> /// <returns>The list of movie recommendations.</returns> [HttpGet("Recommendations")] - public ActionResult<IEnumerable<RecommendationDto>> GetMovieRecommendations( + public async Task<ActionResult<IEnumerable<RecommendationDto>>> GetMovieRecommendations( [FromQuery] Guid? userId, [FromQuery] Guid? parentId, [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields, [FromQuery] int categoryLimit = 5, - [FromQuery] int itemLimit = 8) + [FromQuery] int itemLimit = 8, + CancellationToken cancellationToken = default) { userId = RequestHelpers.GetUserId(User, userId); var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User); + var dtoOptions = new DtoOptions { Fields = fields }; - var categories = new List<RecommendationDto>(); + var recommendations = await _similarItemsManager + .GetMovieRecommendationsAsync(user, parentId ?? Guid.Empty, categoryLimit, itemLimit, dtoOptions, cancellationToken) + .ConfigureAwait(false); - var parentIdGuid = parentId ?? Guid.Empty; - - var query = new InternalItemsQuery(user) + return Ok(recommendations.Select(r => new RecommendationDto { - IncludeItemTypes = new[] - { - BaseItemKind.Movie, - // nameof(Trailer), - // nameof(LiveTvProgram) - }, - // IsMovie = true - OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.Random, SortOrder.Descending) }, - Limit = 7, - ParentId = parentIdGuid, - Recursive = true, - IsPlayed = true, - DtoOptions = dtoOptions - }; - - var recentlyPlayedMovies = _libraryManager.GetItemList(query); - - var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie }; - if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) - { - itemTypes.Add(BaseItemKind.Trailer); - itemTypes.Add(BaseItemKind.LiveTvProgram); - } - - var likedMovies = _libraryManager.GetItemList(new InternalItemsQuery(user) - { - IncludeItemTypes = itemTypes.ToArray(), - IsMovie = true, - OrderBy = new[] { (ItemSortBy.Random, SortOrder.Descending) }, - Limit = 10, - IsFavoriteOrLiked = true, - ExcludeItemIds = recentlyPlayedMovies.Select(i => i.Id).ToArray(), - EnableGroupByMetadataKey = true, - ParentId = parentIdGuid, - Recursive = true, - DtoOptions = dtoOptions - }); - - var mostRecentMovies = recentlyPlayedMovies.Take(Math.Min(recentlyPlayedMovies.Count, 6)).ToList(); - // Get recently played directors - var recentDirectors = GetDirectors(mostRecentMovies) - .ToList(); - - // Get recently played actors - var recentActors = GetActors(mostRecentMovies) - .ToList(); - - var similarToRecentlyPlayed = GetSimilarTo(user, recentlyPlayedMovies, itemLimit, dtoOptions, RecommendationType.SimilarToRecentlyPlayed).GetEnumerator(); - var similarToLiked = GetSimilarTo(user, likedMovies, itemLimit, dtoOptions, RecommendationType.SimilarToLikedItem).GetEnumerator(); - - var hasDirectorFromRecentlyPlayed = GetWithDirector(user, recentDirectors, itemLimit, dtoOptions, RecommendationType.HasDirectorFromRecentlyPlayed).GetEnumerator(); - var hasActorFromRecentlyPlayed = GetWithActor(user, recentActors, itemLimit, dtoOptions, RecommendationType.HasActorFromRecentlyPlayed).GetEnumerator(); - - var categoryTypes = new List<IEnumerator<RecommendationDto>> - { - // Give this extra weight - similarToRecentlyPlayed, - similarToRecentlyPlayed, - - // Give this extra weight - similarToLiked, - similarToLiked, - hasDirectorFromRecentlyPlayed, - hasActorFromRecentlyPlayed - }; - - while (categories.Count < categoryLimit) - { - var allEmpty = true; - - foreach (var category in categoryTypes) - { - if (category.MoveNext()) - { - categories.Add(category.Current); - allEmpty = false; - - if (categories.Count >= categoryLimit) - { - break; - } - } - } - - if (allEmpty) - { - break; - } - } - - return Ok(categories.OrderBy(i => i.RecommendationType).AsEnumerable()); - } - - private IEnumerable<RecommendationDto> GetWithDirector( - User? user, - IEnumerable<string> names, - int itemLimit, - DtoOptions dtoOptions, - RecommendationType type) - { - var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie }; - if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) - { - itemTypes.Add(BaseItemKind.Trailer); - itemTypes.Add(BaseItemKind.LiveTvProgram); - } - - foreach (var name in names) - { - var items = _libraryManager.GetItemList( - new InternalItemsQuery(user) - { - Person = name, - // Account for duplicates by IMDb id, since the database doesn't support this yet - Limit = itemLimit + 2, - PersonTypes = new[] { PersonType.Director }, - IncludeItemTypes = itemTypes.ToArray(), - IsMovie = true, - EnableGroupByMetadataKey = true, - DtoOptions = dtoOptions - }).DistinctBy(i => i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)) - .Take(itemLimit) - .ToList(); - - if (items.Count > 0) - { - var returnItems = _dtoService.GetBaseItemDtos(items, dtoOptions, user); - - yield return new RecommendationDto - { - BaselineItemName = name, - CategoryId = name.GetMD5(), - RecommendationType = type, - Items = returnItems - }; - } - } - } - - private IEnumerable<RecommendationDto> GetWithActor(User? user, IEnumerable<string> names, int itemLimit, DtoOptions dtoOptions, RecommendationType type) - { - var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie }; - if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) - { - itemTypes.Add(BaseItemKind.Trailer); - itemTypes.Add(BaseItemKind.LiveTvProgram); - } - - foreach (var name in names) - { - var items = _libraryManager.GetItemList(new InternalItemsQuery(user) - { - Person = name, - // Account for duplicates by IMDb id, since the database doesn't support this yet - Limit = itemLimit + 2, - IncludeItemTypes = itemTypes.ToArray(), - IsMovie = true, - EnableGroupByMetadataKey = true, - DtoOptions = dtoOptions - }).DistinctBy(i => i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)) - .Take(itemLimit) - .ToList(); - - if (items.Count > 0) - { - var returnItems = _dtoService.GetBaseItemDtos(items, dtoOptions, user); - - yield return new RecommendationDto - { - BaselineItemName = name, - CategoryId = name.GetMD5(), - RecommendationType = type, - Items = returnItems - }; - } - } - } - - private IEnumerable<RecommendationDto> GetSimilarTo(User? user, IEnumerable<BaseItem> baselineItems, int itemLimit, DtoOptions dtoOptions, RecommendationType type) - { - var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie }; - if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) - { - itemTypes.Add(BaseItemKind.Trailer); - itemTypes.Add(BaseItemKind.LiveTvProgram); - } - - foreach (var item in baselineItems) - { - var similar = _libraryManager.GetItemList(new InternalItemsQuery(user) - { - Limit = itemLimit, - IncludeItemTypes = itemTypes.ToArray(), - IsMovie = true, - EnableGroupByMetadataKey = true, - DtoOptions = dtoOptions - }); - - if (similar.Count > 0) - { - var returnItems = _dtoService.GetBaseItemDtos(similar, dtoOptions, user); - - yield return new RecommendationDto - { - BaselineItemName = item.Name, - CategoryId = item.Id, - RecommendationType = type, - Items = returnItems - }; - } - } - } - - private IEnumerable<string> GetActors(IEnumerable<BaseItem> items) - { - var people = _libraryManager.GetPeople(new InternalPeopleQuery(Array.Empty<string>(), new[] { PersonType.Director }) - { - MaxListOrder = 3 - }); - - var itemIds = items.Select(i => i.Id).ToList(); - - return people - .Where(i => itemIds.Contains(i.ItemId)) - .Select(i => i.Name) - .DistinctNames(); - } - - private IEnumerable<string> GetDirectors(IEnumerable<BaseItem> items) - { - var people = _libraryManager.GetPeople(new InternalPeopleQuery( - new[] { PersonType.Director }, - Array.Empty<string>())); - - var itemIds = items.Select(i => i.Id).ToList(); - - return people - .Where(i => itemIds.Contains(i.ItemId)) - .Select(i => i.Name) - .DistinctNames(); + BaselineItemName = r.BaselineItemName, + CategoryId = r.CategoryId, + RecommendationType = r.RecommendationType, + Items = _dtoService.GetBaseItemDtos(r.Items, dtoOptions, user) + })); } } diff --git a/Jellyfin.Api/Controllers/MusicGenresController.cs b/Jellyfin.Api/Controllers/MusicGenresController.cs index 1e45e53ca1..4ebf914895 100644 --- a/Jellyfin.Api/Controllers/MusicGenresController.cs +++ b/Jellyfin.Api/Controllers/MusicGenresController.cs @@ -25,6 +25,7 @@ namespace Jellyfin.Api.Controllers; /// The music genres controller. /// </summary> [Authorize] +[Tags("MusicGenre")] public class MusicGenresController : BaseJellyfinApiController { private readonly ILibraryManager _libraryManager; @@ -72,6 +73,7 @@ public class MusicGenresController : BaseJellyfinApiController /// <returns>An <see cref="OkResult"/> containing the queryresult of music genres.</returns> [HttpGet] [Obsolete("Use GetGenres instead")] + [ApiExplorerSettings(IgnoreApi = true)] public ActionResult<QueryResult<BaseItemDto>> GetMusicGenres( [FromQuery] int? startIndex, [FromQuery] int? limit, @@ -94,9 +96,14 @@ public class MusicGenresController : BaseJellyfinApiController { userId = RequestHelpers.GetUserId(User, userId); var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) .AddAdditionalDtoOptions(enableImages, false, imageTypeLimit, enableImageTypes); + // Asking for a type filter has always implied wanting that type's counts back. + if (includeItemTypes.Length != 0 && !dtoOptions.ContainsField(ItemFields.ItemCounts)) + { + dtoOptions.Fields = [.. dtoOptions.Fields, ItemFields.ItemCounts]; + } + User? user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); @@ -133,8 +140,7 @@ public class MusicGenresController : BaseJellyfinApiController var result = _libraryManager.GetMusicGenres(query); - var shouldIncludeItemTypes = includeItemTypes.Length != 0; - return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user); + return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, user); } /// <summary> @@ -145,10 +151,11 @@ public class MusicGenresController : BaseJellyfinApiController /// <returns>An <see cref="OkResult"/> containing a <see cref="BaseItemDto"/> with the music genre.</returns> [HttpGet("{genreName}")] [ProducesResponseType(StatusCodes.Status200OK)] + [Obsolete("Use GetGenre instead")] public ActionResult<BaseItemDto> GetMusicGenre([FromRoute, Required] string genreName, [FromQuery] Guid? userId) { userId = RequestHelpers.GetUserId(User, userId); - var dtoOptions = new DtoOptions().AddClientFields(User); + var dtoOptions = new DtoOptions(); MusicGenre? item; diff --git a/Jellyfin.Api/Controllers/PackageController.cs b/Jellyfin.Api/Controllers/PackageController.cs index 274e94ee6d..10bcf4e717 100644 --- a/Jellyfin.Api/Controllers/PackageController.cs +++ b/Jellyfin.Api/Controllers/PackageController.cs @@ -3,8 +3,9 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; using System.Threading.Tasks; -using Jellyfin.Api.Constants; +using Jellyfin.Extensions; using MediaBrowser.Common.Api; +using MediaBrowser.Common.Plugins; using MediaBrowser.Common.Updates; using MediaBrowser.Controller.Configuration; using MediaBrowser.Model.Updates; @@ -19,20 +20,27 @@ namespace Jellyfin.Api.Controllers; /// </summary> [Route("")] [Authorize(Policy = Policies.RequiresElevation)] +[Tags("Plugin")] public class PackageController : BaseJellyfinApiController { private readonly IInstallationManager _installationManager; private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly IPluginManager _pluginManager; /// <summary> /// Initializes a new instance of the <see cref="PackageController"/> class. /// </summary> /// <param name="installationManager">Instance of the <see cref="IInstallationManager"/> interface.</param> /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> - public PackageController(IInstallationManager installationManager, IServerConfigurationManager serverConfigurationManager) + /// <param name="pluginManager">Instance of the <see cref="IPluginManager"/> interface.</param> + public PackageController( + IInstallationManager installationManager, + IServerConfigurationManager serverConfigurationManager, + IPluginManager pluginManager) { _installationManager = installationManager; _serverConfigurationManager = serverConfigurationManager; + _pluginManager = pluginManager; } /// <summary> @@ -48,6 +56,13 @@ public class PackageController : BaseJellyfinApiController [FromRoute, Required] string name, [FromQuery] Guid? assemblyGuid) { + // Plugins bundled with the server are not published to any repository, so querying + // the configured repositories for them can only ever fail, and does so slowly. + if (IsBundledPlugin(name, assemblyGuid)) + { + return NotFound(); + } + var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false); var result = _installationManager.FilterPackages( packages, @@ -96,6 +111,11 @@ public class PackageController : BaseJellyfinApiController [FromQuery] string? version, [FromQuery] string? repositoryUrl) { + if (IsBundledPlugin(name, assemblyGuid)) + { + return NotFound(); + } + var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false); if (!string.IsNullOrEmpty(repositoryUrl)) { @@ -161,4 +181,13 @@ public class PackageController : BaseJellyfinApiController _serverConfigurationManager.SaveConfiguration(); return NoContent(); } + + private bool IsBundledPlugin(string name, Guid? assemblyGuid) + { + var plugin = assemblyGuid is Guid id && !id.IsEmpty() + ? _pluginManager.GetPlugin(id) + : _pluginManager.Plugins.FirstOrDefault(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + + return plugin?.Instance?.CanUninstall == false; + } } diff --git a/Jellyfin.Api/Controllers/PersonsController.cs b/Jellyfin.Api/Controllers/PersonsController.cs index 4d12dc18fc..51d4081ecf 100644 --- a/Jellyfin.Api/Controllers/PersonsController.cs +++ b/Jellyfin.Api/Controllers/PersonsController.cs @@ -4,6 +4,7 @@ using System.Linq; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; +using Jellyfin.Data; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Extensions; using MediaBrowser.Controller.Dto; @@ -22,6 +23,7 @@ namespace Jellyfin.Api.Controllers; /// Persons controller. /// </summary> [Authorize] +[Tags("Person")] public class PersonsController : BaseJellyfinApiController { private readonly ILibraryManager _libraryManager; @@ -47,8 +49,12 @@ public class PersonsController : BaseJellyfinApiController /// <summary> /// Gets all persons. /// </summary> + /// <param name="startIndex">Optional. All items with a lower index will be dropped from the response.</param> /// <param name="limit">Optional. The maximum number of records to return.</param> /// <param name="searchTerm">The search term.</param> + /// <param name="nameStartsWith">Optional. Filter by items whose name starts with the given input string.</param> + /// <param name="nameLessThan">Optional. Filter by items whose name will appear before this value when sorted alphabetically.</param> + /// <param name="nameStartsWithOrGreater">Optional. Filter by items whose name will appear after this value when sorted alphabetically.</param> /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> /// <param name="filters">Optional. Specify additional filters to apply.</param> /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not. userId is required.</param> @@ -57,6 +63,7 @@ public class PersonsController : BaseJellyfinApiController /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> /// <param name="excludePersonTypes">Optional. If specified results will be filtered to exclude those containing the specified PersonType. Allows multiple, comma-delimited.</param> /// <param name="personTypes">Optional. If specified results will be filtered to include only those containing the specified PersonType. Allows multiple, comma-delimited.</param> + /// <param name="parentId">Optional. Specify this to localize the search to a specific library. Omit to use the root.</param> /// <param name="appearsInItemId">Optional. If specified, person results will be filtered on items related to said persons.</param> /// <param name="userId">User id.</param> /// <param name="enableImages">Optional, include image information in output.</param> @@ -65,8 +72,12 @@ public class PersonsController : BaseJellyfinApiController [HttpGet] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<QueryResult<BaseItemDto>> GetPersons( + [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery] string? searchTerm, + [FromQuery] string? nameStartsWith, + [FromQuery] string? nameLessThan, + [FromQuery] string? nameStartsWithOrGreater, [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields, [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFilter[] filters, [FromQuery] bool? isFavorite, @@ -75,13 +86,13 @@ public class PersonsController : BaseJellyfinApiController [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] excludePersonTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] personTypes, + [FromQuery] Guid? parentId, [FromQuery] Guid? appearsInItemId, [FromQuery] Guid? userId, [FromQuery] bool? enableImages = true) { userId = RequestHelpers.GetUserId(User, userId); var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); User? user = userId.IsNullOrEmpty() @@ -93,17 +104,39 @@ public class PersonsController : BaseJellyfinApiController personTypes, excludePersonTypes) { + AccessFilter = BuildAccessFilter(user), NameContains = searchTerm, + NameStartsWith = nameStartsWith, + NameLessThan = nameLessThan, + NameStartsWithOrGreater = nameStartsWithOrGreater, User = user, IsFavorite = !isFavorite.HasValue && isFavoriteInFilters ? true : isFavorite, AppearsInItemId = appearsInItemId ?? Guid.Empty, + ParentId = parentId, + StartIndex = startIndex, Limit = limit ?? 0 }); return new QueryResult<BaseItemDto>( - peopleItems - .Select(person => _dtoService.GetItemByNameDto(person, dtoOptions, null, user)) - .ToArray()); + peopleItems.StartIndex, + peopleItems.TotalRecordCount, + peopleItems.Items + .Select(person => _dtoService.GetItemByNameDto(person, dtoOptions, null, user)) + .ToArray()); + } + + // People are not owned by a library, so nothing in the Peoples table says which of them a user is + // allowed to see; that only follows from the items they are credited on. + private InternalItemsQuery? BuildAccessFilter(User? user) + { + if (user is null || !user.HasContentRestrictions()) + { + return null; + } + + var accessFilter = new InternalItemsQuery(user) { IncludeOwnedItems = true }; + _libraryManager.ConfigureUserAccess(accessFilter, user); + return accessFilter; } /// <summary> @@ -121,8 +154,7 @@ public class PersonsController : BaseJellyfinApiController public ActionResult<BaseItemDto> GetPerson([FromRoute, Required] string name, [FromQuery] Guid? userId) { userId = RequestHelpers.GetUserId(User, userId); - var dtoOptions = new DtoOptions() - .AddClientFields(User); + var dtoOptions = new DtoOptions(); var item = _libraryManager.GetPerson(name); if (item is null) diff --git a/Jellyfin.Api/Controllers/PlaylistsController.cs b/Jellyfin.Api/Controllers/PlaylistsController.cs index 79c71d23a4..9bfe0f2570 100644 --- a/Jellyfin.Api/Controllers/PlaylistsController.cs +++ b/Jellyfin.Api/Controllers/PlaylistsController.cs @@ -29,6 +29,7 @@ namespace Jellyfin.Api.Controllers; /// Playlists controller. /// </summary> [Authorize] +[Tags("Playlist")] public class PlaylistsController : BaseJellyfinApiController { private readonly IPlaylistManager _playlistManager; @@ -359,6 +360,7 @@ public class PlaylistsController : BaseJellyfinApiController /// </summary> /// <param name="playlistId">The playlist id.</param> /// <param name="ids">Item id, comma delimited.</param> + /// <param name="position">Optional. 0-based index where to place the items or at the end if <c>null</c>.</param> /// <param name="userId">The userId.</param> /// <response code="204">Items added to playlist.</response> /// <response code="403">Access forbidden.</response> @@ -371,6 +373,7 @@ public class PlaylistsController : BaseJellyfinApiController public async Task<ActionResult> AddItemToPlaylist( [FromRoute, Required] Guid playlistId, [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] ids, + [FromQuery] int? position, [FromQuery] Guid? userId) { userId = RequestHelpers.GetUserId(User, userId); @@ -388,7 +391,7 @@ public class PlaylistsController : BaseJellyfinApiController return Forbid(); } - await _playlistManager.AddItemToPlaylistAsync(playlistId, ids, userId.Value).ConfigureAwait(false); + await _playlistManager.AddItemToPlaylistAsync(playlistId, ids, position, userId.Value).ConfigureAwait(false); return NoContent(); } @@ -518,7 +521,7 @@ public class PlaylistsController : BaseJellyfinApiController [FromQuery] int? imageTypeLimit, [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes) { - var callingUserId = userId ?? User.GetUserId(); + var callingUserId = RequestHelpers.GetUserId(User, userId); var playlist = _playlistManager.GetPlaylistForUser(playlistId, callingUserId); if (playlist is null) { @@ -548,7 +551,6 @@ public class PlaylistsController : BaseJellyfinApiController } var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); var dtos = _dtoService.GetBaseItemDtos(items.Select(i => i.Item2).ToList(), dtoOptions, user); diff --git a/Jellyfin.Api/Controllers/PlaystateController.cs b/Jellyfin.Api/Controllers/PlaystateController.cs index ade0906b34..4ff1eef413 100644 --- a/Jellyfin.Api/Controllers/PlaystateController.cs +++ b/Jellyfin.Api/Controllers/PlaystateController.cs @@ -6,7 +6,6 @@ using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Database.Implementations.Entities; -using Jellyfin.Extensions; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; @@ -25,6 +24,7 @@ namespace Jellyfin.Api.Controllers; /// </summary> [Route("")] [Authorize] +[Tags("Session")] public class PlaystateController : BaseJellyfinApiController { private readonly IUserManager _userManager; @@ -72,6 +72,7 @@ public class PlaystateController : BaseJellyfinApiController [HttpPost("UserPlayedItems/{itemId}")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] + [Tags("UserData")] public async Task<ActionResult<UserItemDataDto?>> MarkPlayedItem( [FromQuery] Guid? userId, [FromRoute, Required] Guid itemId, @@ -138,6 +139,7 @@ public class PlaystateController : BaseJellyfinApiController [HttpDelete("UserPlayedItems/{itemId}")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] + [Tags("UserData")] public async Task<ActionResult<UserItemDataDto?>> MarkUnplayedItem( [FromQuery] Guid? userId, [FromRoute, Required] Guid itemId) @@ -273,6 +275,7 @@ public class PlaystateController : BaseJellyfinApiController [HttpPost("PlayingItems/{itemId}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [Obsolete("This endpoint is obsolete. Use ReportPlaybackStart instead")] + [ApiExplorerSettings(IgnoreApi = true)] public async Task<ActionResult> OnPlaybackStart( [FromRoute, Required] Guid itemId, [FromQuery] string? mediaSourceId, @@ -352,6 +355,7 @@ public class PlaystateController : BaseJellyfinApiController [HttpPost("PlayingItems/{itemId}/Progress")] [ProducesResponseType(StatusCodes.Status204NoContent)] [Obsolete("This endpoint is obsolete. Use ReportPlaybackProgress instead")] + [ApiExplorerSettings(IgnoreApi = true)] public async Task<ActionResult> OnPlaybackProgress( [FromRoute, Required] Guid itemId, [FromQuery] string? mediaSourceId, @@ -441,6 +445,7 @@ public class PlaystateController : BaseJellyfinApiController [HttpDelete("PlayingItems/{itemId}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [Obsolete("This endpoint is obsolete. Use ReportPlaybackStop instead")] + [ApiExplorerSettings(IgnoreApi = true)] public async Task<ActionResult> OnPlaybackStopped( [FromRoute, Required] Guid itemId, [FromQuery] string? mediaSourceId, diff --git a/Jellyfin.Api/Controllers/PluginsController.cs b/Jellyfin.Api/Controllers/PluginsController.cs index 53b7349e7d..79b8b60cb7 100644 --- a/Jellyfin.Api/Controllers/PluginsController.cs +++ b/Jellyfin.Api/Controllers/PluginsController.cs @@ -6,7 +6,7 @@ using System.Linq; using System.Text.Json; using System.Threading.Tasks; using Jellyfin.Api.Attributes; -using Jellyfin.Api.Constants; +using Jellyfin.Extensions; using Jellyfin.Extensions.Json; using MediaBrowser.Common.Api; using MediaBrowser.Common.Plugins; @@ -23,6 +23,7 @@ namespace Jellyfin.Api.Controllers; /// Plugins controller. /// </summary> [Authorize(Policy = Policies.RequiresElevation)] +[Tags("Plugin")] public class PluginsController : BaseJellyfinApiController { private readonly IInstallationManager _installationManager; @@ -136,7 +137,6 @@ public class PluginsController : BaseJellyfinApiController [HttpDelete("{pluginId}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] - [Obsolete("Please use the UninstallPluginByVersion API.")] public ActionResult UninstallPlugin([FromRoute, Required] Guid pluginId) { // If no version is given, return the current instance. @@ -227,16 +227,35 @@ public class PluginsController : BaseJellyfinApiController return NotFound(); } - var imagePath = Path.Combine(plugin.Path, plugin.Manifest.ImagePath ?? string.Empty); - if (plugin.Manifest.ImagePath is null || !System.IO.File.Exists(imagePath)) + string? imagePath = plugin.Manifest.ImagePath; + if (!string.IsNullOrWhiteSpace(imagePath)) { - return NotFound(); + var pluginPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(plugin.Path)); + imagePath = Path.GetFullPath(imagePath, pluginPath); + // Require a separator after the plugin path so a sibling like "<pluginPath>-evil" can't pass. + if (imagePath.StartsWith(pluginPath + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) is false || System.IO.File.Exists(imagePath) is false) + { + return NotFound(); + } + + Response.Headers.ContentDisposition = "attachment"; + return PhysicalFile(imagePath, MimeTypes.GetMimeType(imagePath)); } - Response.Headers.ContentDisposition = "attachment"; + var resourceName = plugin.Manifest.ImageResourceName; + if (!string.IsNullOrEmpty(resourceName) && plugin.Instance is not null) + { + var stream = plugin.Instance.GetType().Assembly.GetManifestResourceStream(resourceName); + if (stream is null) + { + return NotFound(); + } - imagePath = Path.Combine(plugin.Path, plugin.Manifest.ImagePath); - return PhysicalFile(imagePath, MimeTypes.GetMimeType(imagePath)); + Response.Headers.ContentDisposition = "attachment"; + return File(stream, MimeTypes.GetMimeType(resourceName)); + } + + return NotFound(); } /// <summary> diff --git a/Jellyfin.Api/Controllers/QuickConnectController.cs b/Jellyfin.Api/Controllers/QuickConnectController.cs index 14f5265aa7..5c7b38e137 100644 --- a/Jellyfin.Api/Controllers/QuickConnectController.cs +++ b/Jellyfin.Api/Controllers/QuickConnectController.cs @@ -16,6 +16,7 @@ namespace Jellyfin.Api.Controllers; /// <summary> /// Quick connect controller. /// </summary> +[Tags("Authentication")] public class QuickConnectController : BaseJellyfinApiController { private readonly IQuickConnect _quickConnect; @@ -52,6 +53,7 @@ public class QuickConnectController : BaseJellyfinApiController /// <returns>A <see cref="QuickConnectResult"/> with a secret and code for future use or an error message.</returns> [HttpPost("Initiate")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] public async Task<ActionResult<QuickConnectResult>> InitiateQuickConnect() { try @@ -65,16 +67,6 @@ public class QuickConnectController : BaseJellyfinApiController } } - /// <summary> - /// Old version of <see cref="InitiateQuickConnect" /> using a GET method. - /// Still available to avoid breaking compatibility. - /// </summary> - /// <returns>The result of <see cref="InitiateQuickConnect" />.</returns> - [Obsolete("Use POST request instead")] - [HttpGet("Initiate")] - [ApiExplorerSettings(IgnoreApi = true)] - public Task<ActionResult<QuickConnectResult>> InitiateQuickConnectLegacy() => InitiateQuickConnect(); - /// <summary> /// Attempts to retrieve authentication information. /// </summary> diff --git a/Jellyfin.Api/Controllers/ScheduledTasksController.cs b/Jellyfin.Api/Controllers/ScheduledTasksController.cs index 065466cbca..f122d0f5e5 100644 --- a/Jellyfin.Api/Controllers/ScheduledTasksController.cs +++ b/Jellyfin.Api/Controllers/ScheduledTasksController.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; -using Jellyfin.Api.Constants; using MediaBrowser.Common.Api; using MediaBrowser.Model.Tasks; using Microsoft.AspNetCore.Authorization; @@ -15,6 +14,7 @@ namespace Jellyfin.Api.Controllers; /// Scheduled Tasks Controller. /// </summary> [Authorize(Policy = Policies.RequiresElevation)] +[Tags("ScheduledTask")] public class ScheduledTasksController : BaseJellyfinApiController { private readonly ITaskManager _taskManager; diff --git a/Jellyfin.Api/Controllers/SearchController.cs b/Jellyfin.Api/Controllers/SearchController.cs index ecf2335ba0..b03cb88e75 100644 --- a/Jellyfin.Api/Controllers/SearchController.cs +++ b/Jellyfin.Api/Controllers/SearchController.cs @@ -3,6 +3,7 @@ using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.Globalization; using System.Linq; +using System.Threading.Tasks; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Enums; @@ -29,7 +30,7 @@ namespace Jellyfin.Api.Controllers; [Authorize] public class SearchController : BaseJellyfinApiController { - private readonly ISearchEngine _searchEngine; + private readonly ISearchManager _searchManager; private readonly ILibraryManager _libraryManager; private readonly IDtoService _dtoService; private readonly IImageProcessor _imageProcessor; @@ -37,17 +38,17 @@ public class SearchController : BaseJellyfinApiController /// <summary> /// Initializes a new instance of the <see cref="SearchController"/> class. /// </summary> - /// <param name="searchEngine">Instance of <see cref="ISearchEngine"/> interface.</param> + /// <param name="searchManager">Instance of <see cref="ISearchManager"/> interface.</param> /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param> /// <param name="dtoService">Instance of <see cref="IDtoService"/> interface.</param> /// <param name="imageProcessor">Instance of <see cref="IImageProcessor"/> interface.</param> public SearchController( - ISearchEngine searchEngine, + ISearchManager searchManager, ILibraryManager libraryManager, IDtoService dtoService, IImageProcessor imageProcessor) { - _searchEngine = searchEngine; + _searchManager = searchManager; _libraryManager = libraryManager; _dtoService = dtoService; _imageProcessor = imageProcessor; @@ -79,7 +80,7 @@ public class SearchController : BaseJellyfinApiController [HttpGet] [Description("Gets search hints based on a search term")] [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<SearchHintResult> GetSearchHints( + public async Task<ActionResult<SearchHintResult>> GetSearchHints( [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery] Guid? userId, @@ -100,7 +101,7 @@ public class SearchController : BaseJellyfinApiController [FromQuery] bool includeArtists = true) { userId = RequestHelpers.GetUserId(User, userId); - var result = _searchEngine.GetSearchHints(new SearchQuery + var result = await _searchManager.GetSearchHintsAsync(new SearchQuery { Limit = limit, SearchTerm = searchTerm, @@ -121,7 +122,7 @@ public class SearchController : BaseJellyfinApiController IsNews = isNews, IsSeries = isSeries, IsSports = isSports - }); + }).ConfigureAwait(false); return new SearchHintResult(result.Items.Select(GetSearchHintResult).ToArray(), result.TotalRecordCount); } diff --git a/Jellyfin.Api/Controllers/SessionController.cs b/Jellyfin.Api/Controllers/SessionController.cs index 9886d03dee..84c2d90fb1 100644 --- a/Jellyfin.Api/Controllers/SessionController.cs +++ b/Jellyfin.Api/Controllers/SessionController.cs @@ -306,11 +306,14 @@ public class SessionController : BaseJellyfinApiController [HttpPost("Sessions/{sessionId}/User/{userId}")] [Authorize] [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult AddUserToSession( + public async Task<ActionResult> AddUserToSession( [FromRoute, Required] string sessionId, [FromRoute, Required] Guid userId) { - _sessionManager.AddAdditionalUser(sessionId, userId); + _sessionManager.AddAdditionalUser( + await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false), + sessionId, + userId); return NoContent(); } @@ -324,11 +327,14 @@ public class SessionController : BaseJellyfinApiController [HttpDelete("Sessions/{sessionId}/User/{userId}")] [Authorize] [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult RemoveUserFromSession( + public async Task<ActionResult> RemoveUserFromSession( [FromRoute, Required] string sessionId, [FromRoute, Required] Guid userId) { - _sessionManager.RemoveAdditionalUser(sessionId, userId); + _sessionManager.RemoveAdditionalUser( + await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false), + sessionId, + userId); return NoContent(); } @@ -352,12 +358,13 @@ public class SessionController : BaseJellyfinApiController [FromQuery] bool supportsMediaControl = false, [FromQuery] bool supportsPersistentIdentifier = true) { + var currentSessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); if (string.IsNullOrWhiteSpace(id)) { - id = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + id = currentSessionId; } - _sessionManager.ReportCapabilities(id, new ClientCapabilities + _sessionManager.ReportCapabilities(currentSessionId, id, new ClientCapabilities { PlayableMediaTypes = playableMediaTypes, SupportedCommands = supportedCommands, @@ -381,12 +388,13 @@ public class SessionController : BaseJellyfinApiController [FromQuery] string? id, [FromBody, Required] ClientCapabilitiesDto capabilities) { + var currentSessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); if (string.IsNullOrWhiteSpace(id)) { - id = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + id = currentSessionId; } - _sessionManager.ReportCapabilities(id, capabilities.ToClientCapabilities()); + _sessionManager.ReportCapabilities(currentSessionId, id, capabilities.ToClientCapabilities()); return NoContent(); } @@ -405,9 +413,9 @@ public class SessionController : BaseJellyfinApiController [FromQuery] string? sessionId, [FromQuery, Required] string? itemId) { - string session = sessionId ?? await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var currentSessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - _sessionManager.ReportNowViewingItem(session, itemId); + _sessionManager.ReportNowViewingItem(currentSessionId, sessionId ?? currentSessionId, itemId); return NoContent(); } @@ -432,6 +440,7 @@ public class SessionController : BaseJellyfinApiController /// <returns>An <see cref="IEnumerable{NameIdPair}"/> with the auth providers.</returns> [HttpGet("Auth/Providers")] [Authorize(Policy = Policies.RequiresElevation)] + [Tags("Authentication")] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<IEnumerable<NameIdPair>> GetAuthProviders() { @@ -444,6 +453,7 @@ public class SessionController : BaseJellyfinApiController /// <response code="200">Password reset providers retrieved.</response> /// <returns>An <see cref="IEnumerable{NameIdPair}"/> with the password reset providers.</returns> [HttpGet("Auth/PasswordResetProviders")] + [Tags("Authentication")] [ProducesResponseType(StatusCodes.Status200OK)] [Authorize(Policy = Policies.RequiresElevation)] public ActionResult<IEnumerable<NameIdPair>> GetPasswordResetProviders() diff --git a/Jellyfin.Api/Controllers/StartupController.cs b/Jellyfin.Api/Controllers/StartupController.cs index 09f20558fe..47c8f21241 100644 --- a/Jellyfin.Api/Controllers/StartupController.cs +++ b/Jellyfin.Api/Controllers/StartupController.cs @@ -1,7 +1,6 @@ +using System; using System.ComponentModel.DataAnnotations; -using System.Linq; using System.Threading.Tasks; -using Jellyfin.Api.Constants; using Jellyfin.Api.Models.StartupDtos; using MediaBrowser.Common.Api; using MediaBrowser.Common.Net; @@ -54,6 +53,7 @@ public class StartupController : BaseJellyfinApiController /// <returns>An <see cref="OkResult"/> containing the initial startup wizard configuration.</returns> [HttpGet("Configuration")] [ProducesResponseType(StatusCodes.Status200OK)] + [Obsolete("Use configuration endpoints")] public ActionResult<StartupConfigurationDto> GetStartupConfiguration() { return new StartupConfigurationDto @@ -73,6 +73,7 @@ public class StartupController : BaseJellyfinApiController /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> [HttpPost("Configuration")] [ProducesResponseType(StatusCodes.Status204NoContent)] + [Obsolete("Use configuration endpoints")] public ActionResult UpdateInitialConfiguration([FromBody, Required] StartupConfigurationDto startupConfiguration) { _config.Configuration.ServerName = startupConfiguration.ServerName ?? string.Empty; @@ -91,6 +92,7 @@ public class StartupController : BaseJellyfinApiController /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> [HttpPost("RemoteAccess")] [ProducesResponseType(StatusCodes.Status204NoContent)] + [Obsolete("Use configuration endpoints")] public ActionResult SetRemoteAccess([FromBody, Required] StartupRemoteAccessDto startupRemoteAccessDto) { NetworkConfiguration settings = _config.GetNetworkConfiguration(); @@ -107,11 +109,12 @@ public class StartupController : BaseJellyfinApiController [HttpGet("User")] [HttpGet("FirstUser", Name = "GetFirstUser_2")] [ProducesResponseType(StatusCodes.Status200OK)] + [Obsolete("Use authentication endpoints")] public async Task<StartupUserDto> GetFirstUser() { // TODO: Remove this method when startup wizard no longer requires an existing user. await _userManager.InitializeAsync().ConfigureAwait(false); - var user = _userManager.Users.First(); + var user = _userManager.GetFirstUser() ?? throw new InvalidOperationException("No user exists after initialization."); return new StartupUserDto { Name = user.Username @@ -131,22 +134,34 @@ public class StartupController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task<ActionResult> UpdateStartupUser([FromBody] StartupUserDto startupUserDto) { - var user = _userManager.Users.First(); + var user = _userManager.GetFirstUser(); + if (user is null) + { + return NotFound(); + } + + if (!string.IsNullOrEmpty(user.Password)) + { + return Forbid(); + } + if (string.IsNullOrWhiteSpace(startupUserDto.Password)) { return BadRequest("Password must not be empty"); } - if (startupUserDto.Name is not null) - { - user.Username = startupUserDto.Name; - } - await _userManager.UpdateUserAsync(user).ConfigureAwait(false); +#pragma warning disable CA1309 // Use ordinal string comparison + if (startupUserDto.Name is not null && !startupUserDto.Name.Equals(user.Username, StringComparison.InvariantCultureIgnoreCase)) + { + await _userManager.RenameUser(user.Id, user.Username, startupUserDto.Name).ConfigureAwait(false); + } +#pragma warning restore CA1309 // Use ordinal string comparison + if (!string.IsNullOrEmpty(startupUserDto.Password)) { - await _userManager.ChangePassword(user, startupUserDto.Password).ConfigureAwait(false); + await _userManager.ChangePassword(user.Id, startupUserDto.Password).ConfigureAwait(false); } return NoContent(); diff --git a/Jellyfin.Api/Controllers/StudiosController.cs b/Jellyfin.Api/Controllers/StudiosController.cs index 52cb87e72c..5bac850859 100644 --- a/Jellyfin.Api/Controllers/StudiosController.cs +++ b/Jellyfin.Api/Controllers/StudiosController.cs @@ -22,6 +22,7 @@ namespace Jellyfin.Api.Controllers; /// Studios controller. /// </summary> [Authorize] +[Tags("Studio")] public class StudiosController : BaseJellyfinApiController { private readonly ILibraryManager _libraryManager; @@ -89,9 +90,14 @@ public class StudiosController : BaseJellyfinApiController { userId = RequestHelpers.GetUserId(User, userId); var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + // Asking for a type filter has always implied wanting that type's counts back. + if (includeItemTypes.Length != 0 && !dtoOptions.ContainsField(ItemFields.ItemCounts)) + { + dtoOptions.Fields = [.. dtoOptions.Fields, ItemFields.ItemCounts]; + } + User? user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); @@ -126,8 +132,7 @@ public class StudiosController : BaseJellyfinApiController } var result = _libraryManager.GetStudios(query); - var shouldIncludeItemTypes = includeItemTypes.Length != 0; - return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user); + return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, user); } /// <summary> @@ -142,7 +147,7 @@ public class StudiosController : BaseJellyfinApiController public ActionResult<BaseItemDto> GetStudio([FromRoute, Required] string name, [FromQuery] Guid? userId) { userId = RequestHelpers.GetUserId(User, userId); - var dtoOptions = new DtoOptions().AddClientFields(User); + var dtoOptions = new DtoOptions(); var item = _libraryManager.GetStudio(name); if (!userId.IsNullOrEmpty()) diff --git a/Jellyfin.Api/Controllers/SubtitleController.cs b/Jellyfin.Api/Controllers/SubtitleController.cs index e5df873f5b..c4851091c1 100644 --- a/Jellyfin.Api/Controllers/SubtitleController.cs +++ b/Jellyfin.Api/Controllers/SubtitleController.cs @@ -557,7 +557,7 @@ public class SubtitleController : BaseJellyfinApiController if (!string.IsNullOrEmpty(fallbackFontPath)) { var fontFile = _fileSystem.GetFiles(fallbackFontPath) - .First(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase)); + .FirstOrDefault(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase)); var fileSize = fontFile?.Length; if (fontFile is not null && fileSize is not null && fileSize > 0) diff --git a/Jellyfin.Api/Controllers/SuggestionsController.cs b/Jellyfin.Api/Controllers/SuggestionsController.cs index 52982c362d..9c5515dd92 100644 --- a/Jellyfin.Api/Controllers/SuggestionsController.cs +++ b/Jellyfin.Api/Controllers/SuggestionsController.cs @@ -23,6 +23,7 @@ namespace Jellyfin.Api.Controllers; /// </summary> [Route("")] [Authorize] +[Tags("Suggestion")] public class SuggestionsController : BaseJellyfinApiController { private readonly IDtoService _dtoService; @@ -77,7 +78,7 @@ public class SuggestionsController : BaseJellyfinApiController user = _userManager.GetUserById(requestUserId); } - var dtoOptions = new DtoOptions().AddClientFields(User); + var dtoOptions = new DtoOptions(); var result = _libraryManager.GetItemsResult(new InternalItemsQuery(user) { OrderBy = new[] { (ItemSortBy.Random, SortOrder.Descending) }, diff --git a/Jellyfin.Api/Controllers/TimeSyncController.cs b/Jellyfin.Api/Controllers/TimeSyncController.cs index d7304cf426..fe6e11f9e2 100644 --- a/Jellyfin.Api/Controllers/TimeSyncController.cs +++ b/Jellyfin.Api/Controllers/TimeSyncController.cs @@ -9,6 +9,7 @@ namespace Jellyfin.Api.Controllers; /// The time sync controller. /// </summary> [Route("")] +[Tags("System")] public class TimeSyncController : BaseJellyfinApiController { /// <summary> diff --git a/Jellyfin.Api/Controllers/TrailersController.cs b/Jellyfin.Api/Controllers/TrailersController.cs index 3e4bac89a5..6bddd85337 100644 --- a/Jellyfin.Api/Controllers/TrailersController.cs +++ b/Jellyfin.Api/Controllers/TrailersController.cs @@ -1,4 +1,5 @@ using System; +using System.Threading.Tasks; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Enums; @@ -15,6 +16,7 @@ namespace Jellyfin.Api.Controllers; /// The trailers controller. /// </summary> [Authorize] +[Tags("Trailer")] public class TrailersController : BaseJellyfinApiController { private readonly ItemsController _itemsController; @@ -113,12 +115,15 @@ public class TrailersController : BaseJellyfinApiController /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param> /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param> /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param> + /// <param name="audioLanguages">Optional. If specified, results will be filtered based on audio language. This allows multiple, comma delimited values.</param> + /// <param name="subtitleLanguages">Optional. If specified, results will be filtered based on subtitale language. This allows multiple, comma delimited values.</param> /// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param> /// <param name="enableImages">Optional, include image information in output.</param> /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the trailers.</returns> [HttpGet] [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetTrailers( + [Obsolete("Use GetItems with includeItemTypes=Trailer instead.")] + public async Task<ActionResult<QueryResult<BaseItemDto>>> GetTrailers( [FromQuery] Guid? userId, [FromQuery] string? maxOfficialRating, [FromQuery] bool? hasThemeSong, @@ -201,12 +206,14 @@ public class TrailersController : BaseJellyfinApiController [FromQuery] string? nameLessThan, [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] studioIds, [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] genreIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] audioLanguages, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] subtitleLanguages, [FromQuery] bool enableTotalRecordCount = true, [FromQuery] bool? enableImages = true) { var includeItemTypes = new[] { BaseItemKind.Trailer }; - return _itemsController + return await _itemsController .GetItems( userId, maxOfficialRating, @@ -292,7 +299,9 @@ public class TrailersController : BaseJellyfinApiController nameLessThan, studioIds, genreIds, + audioLanguages, + subtitleLanguages, enableTotalRecordCount, - enableImages); + enableImages).ConfigureAwait(false); } } diff --git a/Jellyfin.Api/Controllers/TrickplayController.cs b/Jellyfin.Api/Controllers/TrickplayController.cs index c9f8b36768..d7a10ce5f6 100644 --- a/Jellyfin.Api/Controllers/TrickplayController.cs +++ b/Jellyfin.Api/Controllers/TrickplayController.cs @@ -5,7 +5,6 @@ using System.Text; using System.Threading.Tasks; using Jellyfin.Api.Attributes; using Jellyfin.Api.Extensions; -using Jellyfin.Api.Helpers; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Trickplay; @@ -21,6 +20,7 @@ namespace Jellyfin.Api.Controllers; /// </summary> [Route("")] [Authorize] +[Tags("TrickPlay")] public class TrickplayController : BaseJellyfinApiController { private readonly ILibraryManager _libraryManager; diff --git a/Jellyfin.Api/Controllers/TvShowsController.cs b/Jellyfin.Api/Controllers/TvShowsController.cs index 0f08854d24..6b0f10e02a 100644 --- a/Jellyfin.Api/Controllers/TvShowsController.cs +++ b/Jellyfin.Api/Controllers/TvShowsController.cs @@ -2,7 +2,6 @@ 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; @@ -28,6 +27,7 @@ namespace Jellyfin.Api.Controllers; /// </summary> [Route("Shows")] [Authorize] +[Tags("Show")] public class TvShowsController : BaseJellyfinApiController { private readonly IUserManager _userManager; @@ -69,7 +69,6 @@ public class TvShowsController : BaseJellyfinApiController /// <param name="enableUserData">Optional. Include user data.</param> /// <param name="nextUpDateCutoff">Optional. Starting date of shows to show in Next Up section.</param> /// <param name="enableTotalRecordCount">Whether to enable the total records count. Defaults to true.</param> - /// <param name="disableFirstEpisode">Whether to disable sending the first episode in a series as next up.</param> /// <param name="enableResumable">Whether to include resumable episodes in next up results.</param> /// <param name="enableRewatching">Whether to include watched episodes in next up results.</param> /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the next up episodes.</returns> @@ -88,7 +87,6 @@ public class TvShowsController : BaseJellyfinApiController [FromQuery] bool? enableUserData, [FromQuery] DateTime? nextUpDateCutoff, [FromQuery] bool enableTotalRecordCount = true, - [FromQuery][ParameterObsolete] bool disableFirstEpisode = false, [FromQuery] bool enableResumable = true, [FromQuery] bool enableRewatching = false) { @@ -99,7 +97,6 @@ public class TvShowsController : BaseJellyfinApiController } var options = new DtoOptions { Fields = fields } - .AddClientFields(User) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); var result = _tvSeriesManager.GetNextUp( @@ -161,7 +158,6 @@ public class TvShowsController : BaseJellyfinApiController var parentIdGuid = parentId ?? Guid.Empty; var options = new DtoOptions { Fields = fields } - .AddClientFields(User) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); var itemsResult = _libraryManager.GetItemList(new InternalItemsQuery(user) @@ -231,13 +227,12 @@ public class TvShowsController : BaseJellyfinApiController List<BaseItem> episodes; var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); var shouldIncludeMissingEpisodes = (user is not null && user.DisplayMissingEpisodes) || User.GetIsApiKey(); if (seasonId.HasValue) // Season id was supplied. Get episodes by season id. { - var item = _libraryManager.GetItemById<BaseItem>(seasonId.Value); + var item = _libraryManager.GetItemById<BaseItem>(seasonId.Value, user); if (item is not Season seasonItem) { return NotFound("No season exists with Id " + seasonId); @@ -247,7 +242,7 @@ public class TvShowsController : BaseJellyfinApiController } else if (season.HasValue) // Season number was supplied. Get episodes by season number { - var series = _libraryManager.GetItemById<Series>(seriesId); + var series = _libraryManager.GetItemById<Series>(seriesId, user); if (series is null) { return NotFound("Series not found"); @@ -263,7 +258,7 @@ public class TvShowsController : BaseJellyfinApiController } else // No season number or season id was supplied. Returning all episodes. { - if (_libraryManager.GetItemById<BaseItem>(seriesId) is not Series series) + if (_libraryManager.GetItemById<BaseItem>(seriesId, user) is not Series series) { return NotFound("Series not found"); } @@ -282,8 +277,15 @@ public class TvShowsController : BaseJellyfinApiController if (startItemId.HasValue) { + // The start item may be an alternate version, which is not part of the episode listing; start from its primary episode instead. + var startId = startItemId.Value; + if (_libraryManager.GetItemById<Video>(startId)?.PrimaryVersionId is { } primaryVersionId) + { + startId = primaryVersionId; + } + episodes = episodes - .SkipWhile(i => !startItemId.Value.Equals(i.Id)) + .SkipWhile(i => !startId.Equals(i.Id)) .ToList(); } @@ -360,7 +362,6 @@ public class TvShowsController : BaseJellyfinApiController }); var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); var returnItems = _dtoService.GetBaseItemDtos(seasons, dtoOptions, user); diff --git a/Jellyfin.Api/Controllers/UniversalAudioController.cs b/Jellyfin.Api/Controllers/UniversalAudioController.cs index 8752cb3895..cdbd1ee7aa 100644 --- a/Jellyfin.Api/Controllers/UniversalAudioController.cs +++ b/Jellyfin.Api/Controllers/UniversalAudioController.cs @@ -29,6 +29,7 @@ namespace Jellyfin.Api.Controllers; /// The universal audio controller. /// </summary> [Route("")] +[Tags("Audio")] public class UniversalAudioController : BaseJellyfinApiController { private readonly ILibraryManager _libraryManager; @@ -83,7 +84,6 @@ public class UniversalAudioController : BaseJellyfinApiController /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> /// <param name="enableRemoteMedia">Optional. Whether to enable remote media.</param> /// <param name="enableAudioVbrEncoding">Optional. Whether to enable Audio Encoding.</param> - /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> /// <param name="enableRedirection">Whether to enable redirection. Defaults to true.</param> /// <response code="200">Audio stream returned.</response> /// <response code="302">Redirected to remote audio stream.</response> @@ -102,19 +102,18 @@ public class UniversalAudioController : BaseJellyfinApiController [FromQuery] string? mediaSourceId, [FromQuery] string? deviceId, [FromQuery] Guid? userId, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec, [FromQuery] int? maxAudioChannels, [FromQuery] int? transcodingAudioChannels, [FromQuery] int? maxStreamingBitrate, [FromQuery] int? audioBitRate, [FromQuery] long? startTimeTicks, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? transcodingContainer, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? transcodingContainer, [FromQuery] MediaStreamProtocol? transcodingProtocol, [FromQuery] int? maxAudioSampleRate, [FromQuery] int? maxAudioBitDepth, [FromQuery] bool? enableRemoteMedia, [FromQuery] bool enableAudioVbrEncoding = true, - [FromQuery] bool breakOnNonKeyFrames = false, [FromQuery] bool enableRedirection = true) { userId = RequestHelpers.GetUserId(User, userId); @@ -127,13 +126,14 @@ public class UniversalAudioController : BaseJellyfinApiController return NotFound(); } - var deviceProfile = GetDeviceProfile(container, transcodingContainer, audioCodec, transcodingProtocol, breakOnNonKeyFrames, transcodingAudioChannels, maxAudioSampleRate, maxAudioBitDepth, maxAudioChannels); + var deviceProfile = GetDeviceProfile(container, transcodingContainer, audioCodec, transcodingProtocol, transcodingAudioChannels, maxAudioSampleRate, maxAudioBitDepth, maxAudioChannels); _logger.LogInformation("GetPostedPlaybackInfo profile: {@Profile}", deviceProfile); var info = await _mediaInfoHelper.GetPlaybackInfo( item, user, + Request, mediaSourceId) .ConfigureAwait(false); @@ -164,7 +164,7 @@ public class UniversalAudioController : BaseJellyfinApiController Request.HttpContext.GetNormalizedRemoteIP()); } - _mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate); + _mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate, item.Id); foreach (var source in info.MediaSources) { @@ -208,7 +208,6 @@ public class UniversalAudioController : BaseJellyfinApiController EnableAutoStreamCopy = true, AllowAudioStreamCopy = true, AllowVideoStreamCopy = true, - BreakOnNonKeyFrames = breakOnNonKeyFrames, AudioSampleRate = maxAudioSampleRate, MaxAudioChannels = maxAudioChannels, MaxAudioBitDepth = maxAudioBitDepth, @@ -242,7 +241,6 @@ public class UniversalAudioController : BaseJellyfinApiController EnableAutoStreamCopy = true, AllowAudioStreamCopy = true, AllowVideoStreamCopy = true, - BreakOnNonKeyFrames = breakOnNonKeyFrames, AudioSampleRate = maxAudioSampleRate, MaxAudioChannels = maxAudioChannels, AudioBitRate = isStatic ? null : (audioBitRate ?? maxStreamingBitrate), @@ -263,7 +261,6 @@ public class UniversalAudioController : BaseJellyfinApiController string? transcodingContainer, string? audioCodec, MediaStreamProtocol? transcodingProtocol, - bool? breakOnNonKeyFrames, int? transcodingAudioChannels, int? maxAudioSampleRate, int? maxAudioBitDepth, @@ -298,7 +295,6 @@ public class UniversalAudioController : BaseJellyfinApiController Container = transcodingContainer ?? "mp3", AudioCodec = audioCodec ?? "mp3", Protocol = transcodingProtocol ?? MediaStreamProtocol.http, - BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, MaxAudioChannels = transcodingAudioChannels?.ToString(CultureInfo.InvariantCulture) } }; diff --git a/Jellyfin.Api/Controllers/UserController.cs b/Jellyfin.Api/Controllers/UserController.cs index d0ced277a0..657bda4d15 100644 --- a/Jellyfin.Api/Controllers/UserController.cs +++ b/Jellyfin.Api/Controllers/UserController.cs @@ -208,6 +208,7 @@ public class UserController : BaseJellyfinApiController /// <returns>A <see cref="Task"/> containing an <see cref="AuthenticationRequest"/> with information about the new session.</returns> [HttpPost("AuthenticateByName")] [ProducesResponseType(StatusCodes.Status200OK)] + [Tags("Authentication")] public async Task<ActionResult<AuthenticationResult>> AuthenticateUserByName([FromBody, Required] AuthenticateUserByName request) { var auth = await _authContext.GetAuthorizationInfo(Request).ConfigureAwait(false); @@ -243,6 +244,7 @@ public class UserController : BaseJellyfinApiController /// <returns>A <see cref="Task"/> containing an <see cref="AuthenticationRequest"/> with information about the new session.</returns> [HttpPost("AuthenticateWithQuickConnect")] [ProducesResponseType(StatusCodes.Status200OK)] + [Tags("Authentication")] public ActionResult<AuthenticationResult> AuthenticateWithQuickConnect([FromBody, Required] QuickConnectDto request) { try @@ -288,7 +290,7 @@ public class UserController : BaseJellyfinApiController if (request.ResetPassword) { - await _userManager.ResetPassword(user).ConfigureAwait(false); + await _userManager.ResetPassword(user.Id).ConfigureAwait(false); } else { @@ -306,7 +308,7 @@ public class UserController : BaseJellyfinApiController } } - await _userManager.ChangePassword(user, request.NewPw ?? string.Empty).ConfigureAwait(false); + await _userManager.ChangePassword(user.Id, request.NewPw ?? string.Empty).ConfigureAwait(false); var currentToken = User.GetToken(); @@ -337,29 +339,6 @@ public class UserController : BaseJellyfinApiController [FromBody, Required] UpdateUserPassword request) => UpdateUserPassword(userId, request); - /// <summary> - /// Updates a user's easy password. - /// </summary> - /// <param name="userId">The user id.</param> - /// <param name="request">The <see cref="UpdateUserEasyPassword"/> request.</param> - /// <response code="204">Password successfully reset.</response> - /// <response code="403">User is not allowed to update the password.</response> - /// <response code="404">User not found.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="ForbidResult"/> or a <see cref="NotFoundResult"/> on failure.</returns> - [HttpPost("{userId}/EasyPassword")] - [Obsolete("Use Quick Connect instead")] - [ApiExplorerSettings(IgnoreApi = true)] - [Authorize] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult UpdateUserEasyPassword( - [FromRoute, Required] Guid userId, - [FromBody, Required] UpdateUserEasyPassword request) - { - return Forbid(); - } - /// <summary> /// Updates a user. /// </summary> @@ -392,7 +371,7 @@ public class UserController : BaseJellyfinApiController if (!string.Equals(user.Username, updateUser.Name, StringComparison.Ordinal)) { - await _userManager.RenameUser(user, updateUser.Name).ConfigureAwait(false); + await _userManager.RenameUser(user.Id, user.Username, updateUser.Name).ConfigureAwait(false); } await _userManager.UpdateConfigurationAsync(requestUserId, updateUser.Configuration).ConfigureAwait(false); @@ -448,7 +427,7 @@ public class UserController : BaseJellyfinApiController // If removing admin access if (!newPolicy.IsAdministrator && user.HasPermission(PermissionKind.IsAdministrator)) { - if (_userManager.Users.Count(i => i.HasPermission(PermissionKind.IsAdministrator)) == 1) + if (_userManager.GetUsers().Count(i => i.HasPermission(PermissionKind.IsAdministrator)) == 1) { return StatusCode(StatusCodes.Status403Forbidden, "There must be at least one user in the system with administrative access."); } @@ -463,7 +442,7 @@ public class UserController : BaseJellyfinApiController // If disabling if (newPolicy.IsDisabled && !user.HasPermission(PermissionKind.IsDisabled)) { - if (_userManager.Users.Count(i => !i.HasPermission(PermissionKind.IsDisabled)) == 1) + if (_userManager.GetUsers().Count(i => !i.HasPermission(PermissionKind.IsDisabled)) == 1) { return StatusCode(StatusCodes.Status403Forbidden, "There must be at least one enabled user in the system."); } @@ -545,7 +524,7 @@ public class UserController : BaseJellyfinApiController // no need to authenticate password for new user if (request.Password is not null) { - await _userManager.ChangePassword(newUser, request.Password).ConfigureAwait(false); + await _userManager.ChangePassword(newUser.Id, request.Password).ConfigureAwait(false); } var result = _userManager.GetUserDto(newUser, HttpContext.GetNormalizedRemoteIP().ToString()); @@ -561,6 +540,7 @@ public class UserController : BaseJellyfinApiController /// <returns>A <see cref="Task"/> containing a <see cref="ForgotPasswordResult"/>.</returns> [HttpPost("ForgotPassword")] [ProducesResponseType(StatusCodes.Status200OK)] + [Tags("Authentication")] public async Task<ActionResult<ForgotPasswordResult>> ForgotPassword([FromBody, Required] ForgotPasswordDto forgotPasswordRequest) { var ip = HttpContext.GetNormalizedRemoteIP(); @@ -585,6 +565,7 @@ public class UserController : BaseJellyfinApiController /// <returns>A <see cref="Task"/> containing a <see cref="PinRedeemResult"/>.</returns> [HttpPost("ForgotPassword/Pin")] [ProducesResponseType(StatusCodes.Status200OK)] + [Tags("Authentication")] public async Task<ActionResult<PinRedeemResult>> ForgotPasswordPin([FromBody, Required] ForgotPasswordPinDto forgotPasswordPinRequest) { var result = await _userManager.RedeemPasswordResetPin(forgotPasswordPinRequest.Pin).ConfigureAwait(false); @@ -620,7 +601,7 @@ public class UserController : BaseJellyfinApiController private IEnumerable<UserDto> Get(bool? isHidden, bool? isDisabled, bool filterByDevice, bool filterByNetwork) { - var users = _userManager.Users; + var users = _userManager.GetUsers(); if (isDisabled.HasValue) { diff --git a/Jellyfin.Api/Controllers/UserLibraryController.cs b/Jellyfin.Api/Controllers/UserLibraryController.cs index 0e04beb14e..da03032249 100644 --- a/Jellyfin.Api/Controllers/UserLibraryController.cs +++ b/Jellyfin.Api/Controllers/UserLibraryController.cs @@ -13,6 +13,7 @@ using Jellyfin.Extensions; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Dto; @@ -30,14 +31,18 @@ namespace Jellyfin.Api.Controllers; /// </summary> [Route("")] [Authorize] +[Tags("Library")] public class UserLibraryController : BaseJellyfinApiController { + private static readonly TimeSpan RefreshOnDemandTimeout = TimeSpan.FromSeconds(3); + private readonly IUserManager _userManager; private readonly IUserDataManager _userDataRepository; private readonly ILibraryManager _libraryManager; private readonly IDtoService _dtoService; private readonly IUserViewManager _userViewManager; private readonly IFileSystem _fileSystem; + private readonly IProviderManager _providerManager; /// <summary> /// Initializes a new instance of the <see cref="UserLibraryController"/> class. @@ -48,13 +53,15 @@ public class UserLibraryController : BaseJellyfinApiController /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> /// <param name="userViewManager">Instance of the <see cref="IUserViewManager"/> interface.</param> /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param> public UserLibraryController( IUserManager userManager, IUserDataManager userDataRepository, ILibraryManager libraryManager, IDtoService dtoService, IUserViewManager userViewManager, - IFileSystem fileSystem) + IFileSystem fileSystem, + IProviderManager providerManager) { _userManager = userManager; _userDataRepository = userDataRepository; @@ -62,6 +69,7 @@ public class UserLibraryController : BaseJellyfinApiController _dtoService = dtoService; _userViewManager = userViewManager; _fileSystem = fileSystem; + _providerManager = providerManager; } /// <summary> @@ -92,9 +100,9 @@ public class UserLibraryController : BaseJellyfinApiController return NotFound(); } - await RefreshItemOnDemandIfNeeded(item).ConfigureAwait(false); + await RefreshOnDemandIfNeeded(item).ConfigureAwait(false); - var dtoOptions = new DtoOptions().AddClientFields(User); + var dtoOptions = new DtoOptions(); return _dtoService.GetBaseItemDto(item, dtoOptions, user); } @@ -133,7 +141,7 @@ public class UserLibraryController : BaseJellyfinApiController } var item = _libraryManager.GetUserRootFolder(); - var dtoOptions = new DtoOptions().AddClientFields(User); + var dtoOptions = new DtoOptions(); return _dtoService.GetBaseItemDto(item, dtoOptions, user); } @@ -180,7 +188,7 @@ public class UserLibraryController : BaseJellyfinApiController } var items = await _libraryManager.GetIntros(item, user).ConfigureAwait(false); - var dtoOptions = new DtoOptions().AddClientFields(User); + var dtoOptions = new DtoOptions(); var dtos = items.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user)).ToArray(); return new QueryResult<BaseItemDto>(dtos); @@ -211,6 +219,7 @@ public class UserLibraryController : BaseJellyfinApiController /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns> [HttpPost("UserFavoriteItems/{itemId}")] [ProducesResponseType(StatusCodes.Status200OK)] + [Tags("UserData")] public ActionResult<UserItemDataDto> MarkFavoriteItem( [FromQuery] Guid? userId, [FromRoute, Required] Guid itemId) @@ -258,6 +267,7 @@ public class UserLibraryController : BaseJellyfinApiController /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns> [HttpDelete("UserFavoriteItems/{itemId}")] [ProducesResponseType(StatusCodes.Status200OK)] + [Tags("UserData")] public ActionResult<UserItemDataDto> UnmarkFavoriteItem( [FromQuery] Guid? userId, [FromRoute, Required] Guid itemId) @@ -305,6 +315,7 @@ public class UserLibraryController : BaseJellyfinApiController /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns> [HttpDelete("UserItems/{itemId}/Rating")] [ProducesResponseType(StatusCodes.Status200OK)] + [Tags("UserData")] public ActionResult<UserItemDataDto?> DeleteUserItemRating( [FromQuery] Guid? userId, [FromRoute, Required] Guid itemId) @@ -353,6 +364,7 @@ public class UserLibraryController : BaseJellyfinApiController /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns> [HttpPost("UserItems/{itemId}/Rating")] [ProducesResponseType(StatusCodes.Status200OK)] + [Tags("UserData")] public ActionResult<UserItemDataDto?> UpdateUserItemRating( [FromQuery] Guid? userId, [FromRoute, Required] Guid itemId, @@ -422,15 +434,9 @@ public class UserLibraryController : BaseJellyfinApiController return NotFound(); } - var dtoOptions = new DtoOptions().AddClientFields(User); - if (item is IHasTrailers hasTrailers) - { - var trailers = hasTrailers.LocalTrailers; - return Ok(_dtoService.GetBaseItemDtos(trailers, dtoOptions, user, item).AsEnumerable()); - } + var dtoOptions = new DtoOptions(); - return Ok(item.GetExtras() - .Where(e => e.ExtraType == ExtraType.Trailer) + return Ok(item.GetExtras([ExtraType.Trailer], user) .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item))); } @@ -478,10 +484,10 @@ public class UserLibraryController : BaseJellyfinApiController return NotFound(); } - var dtoOptions = new DtoOptions().AddClientFields(User); + var dtoOptions = new DtoOptions(); return Ok(item - .GetExtras() + .GetExtras(user) .Where(i => i.ExtraType.HasValue && BaseItem.DisplayExtraTypes.Contains(i.ExtraType.Value)) .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item))); } @@ -549,7 +555,6 @@ public class UserLibraryController : BaseJellyfinApiController } var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); var list = _userViewManager.GetLatestItems( @@ -564,25 +569,35 @@ public class UserLibraryController : BaseJellyfinApiController }, dtoOptions); - var dtos = list.Select(i => + var resolvedItems = new BaseItem[list.Count]; + var childCounts = new int[list.Count]; + for (int i = 0; i < list.Count; i++) { - var item = i.Item2[0]; + var tuple = list[i]; + var item = tuple.Item2[0]; var childCount = 0; - if (i.Item1 is not null && (i.Item2.Count > 1 || i.Item1 is MusicAlbum)) + if (tuple.Item1 is not null && (tuple.Item2.Count > 1 || tuple.Item1 is MusicAlbum)) { - item = i.Item1; - childCount = i.Item2.Count; + item = tuple.Item1; + childCount = tuple.Item2.Count; } - var dto = _dtoService.GetBaseItemDto(item, dtoOptions, user); + resolvedItems[i] = item; + childCounts[i] = childCount; + } - dto.ChildCount = childCount; + // Fetch DTOs without visibility check since we've already done that in GetLatestItems and restore child counts afterwards + var dtos = _dtoService.GetBaseItemDtos(resolvedItems, dtoOptions, user, skipVisibilityCheck: true); + for (int i = 0; i < dtos.Count; i++) + { + if (childCounts[i] > 0) + { + dtos[i].ChildCount = childCounts[i]; + } + } - return dto; - }); - - return Ok(dtos); + return Ok(dtos.AsEnumerable()); } /// <summary> @@ -630,24 +645,36 @@ public class UserLibraryController : BaseJellyfinApiController limit, groupItems); - private async Task RefreshItemOnDemandIfNeeded(BaseItem item) + private async Task RefreshOnDemandIfNeeded(BaseItem item) { - if (item is Person) + if (item is not Person) { - var hasMetadata = !string.IsNullOrWhiteSpace(item.Overview) && item.HasImage(ImageType.Primary); - var performFullRefresh = !hasMetadata && (DateTime.UtcNow - item.DateLastRefreshed).TotalDays >= 3; + return; + } - if (!hasMetadata) - { - var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem)) - { - MetadataRefreshMode = MetadataRefreshMode.FullRefresh, - ImageRefreshMode = MetadataRefreshMode.FullRefresh, - ForceSave = performFullRefresh - }; + var hasMetadata = !string.IsNullOrWhiteSpace(item.Overview) && item.HasImage(ImageType.Primary); + if (hasMetadata || (DateTime.UtcNow - item.DateLastRefreshed).TotalDays < 3) + { + return; + } - await item.RefreshMetadata(options, CancellationToken.None).ConfigureAwait(false); - } + var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem)) + { + MetadataRefreshMode = MetadataRefreshMode.FullRefresh, + ImageRefreshMode = MetadataRefreshMode.FullRefresh, + ForceSave = true + }; + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(HttpContext.RequestAborted); + timeout.CancelAfter(RefreshOnDemandTimeout); + + try + { + await item.RefreshMetadata(options, timeout.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!HttpContext.RequestAborted.IsCancellationRequested) + { + _providerManager.QueueRefresh(item.Id, options, RefreshPriority.High); } } diff --git a/Jellyfin.Api/Controllers/UserViewsController.cs b/Jellyfin.Api/Controllers/UserViewsController.cs index 64b2dffb32..8b359c48af 100644 --- a/Jellyfin.Api/Controllers/UserViewsController.cs +++ b/Jellyfin.Api/Controllers/UserViewsController.cs @@ -26,6 +26,7 @@ namespace Jellyfin.Api.Controllers; /// </summary> [Route("")] [Authorize] +[Tags("UserView")] public class UserViewsController : BaseJellyfinApiController { private readonly IUserManager _userManager; @@ -86,8 +87,8 @@ public class UserViewsController : BaseJellyfinApiController var folders = _userViewManager.GetUserViews(query); - var dtoOptions = new DtoOptions().AddClientFields(User); - dtoOptions.Fields = [..dtoOptions.Fields, ItemFields.PrimaryImageAspectRatio, ItemFields.DisplayPreferencesId]; + var dtoOptions = new DtoOptions(); + dtoOptions.Fields = [.. dtoOptions.Fields, ItemFields.PrimaryImageAspectRatio, ItemFields.DisplayPreferencesId]; var dtos = Array.ConvertAll(folders, i => _dtoService.GetBaseItemDto(i, dtoOptions, user)); diff --git a/Jellyfin.Api/Controllers/VideoAttachmentsController.cs b/Jellyfin.Api/Controllers/VideoAttachmentsController.cs index b67c6fdb7b..2c8b452c35 100644 --- a/Jellyfin.Api/Controllers/VideoAttachmentsController.cs +++ b/Jellyfin.Api/Controllers/VideoAttachmentsController.cs @@ -19,6 +19,7 @@ namespace Jellyfin.Api.Controllers; /// Attachments controller. /// </summary> [Route("Videos")] +[Tags("Video")] public class VideoAttachmentsController : BaseJellyfinApiController { private readonly ILibraryManager _libraryManager; diff --git a/Jellyfin.Api/Controllers/VideosController.cs b/Jellyfin.Api/Controllers/VideosController.cs index 447f71bd96..29a92cdb90 100644 --- a/Jellyfin.Api/Controllers/VideosController.cs +++ b/Jellyfin.Api/Controllers/VideosController.cs @@ -35,6 +35,7 @@ namespace Jellyfin.Api.Controllers; /// <summary> /// The videos controller. /// </summary> +[Tags("Video")] public class VideosController : BaseJellyfinApiController { private readonly ILibraryManager _libraryManager; @@ -111,12 +112,11 @@ public class VideosController : BaseJellyfinApiController } var dtoOptions = new DtoOptions(); - dtoOptions = dtoOptions.AddClientFields(User); BaseItemDto[] items; if (item is Video video) { - items = video.GetAdditionalParts() + items = video.GetAdditionalParts(user) .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, video)) .ToArray(); } @@ -148,9 +148,9 @@ public class VideosController : BaseJellyfinApiController return NotFound(); } - if (item.LinkedAlternateVersions.Length == 0) + if (item.LinkedAlternateVersions.Length == 0 && item.PrimaryVersionId.HasValue) { - item = _libraryManager.GetItemById<Video>(Guid.Parse(item.PrimaryVersionId)); + item = _libraryManager.GetItemById<Video>(item.PrimaryVersionId.Value); } if (item is null) @@ -158,7 +158,7 @@ public class VideosController : BaseJellyfinApiController return NotFound(); } - foreach (var link in item.GetLinkedAlternateVersions()) + foreach (var link in _libraryManager.GetLinkedAlternateVersions(item)) { link.SetPrimaryVersionId(null); link.LinkedAlternateVersions = Array.Empty<LinkedChild>(); @@ -198,7 +198,7 @@ public class VideosController : BaseJellyfinApiController return BadRequest("Please supply at least two videos to merge."); } - var primaryVersion = items.FirstOrDefault(i => i.MediaSourceCount > 1 && string.IsNullOrEmpty(i.PrimaryVersionId)); + var primaryVersion = items.FirstOrDefault(i => i.MediaSourceCount > 1 && !i.PrimaryVersionId.HasValue); if (primaryVersion is null) { primaryVersion = items @@ -219,22 +219,25 @@ public class VideosController : BaseJellyfinApiController foreach (var item in items.Where(i => !i.Id.Equals(primaryVersion.Id))) { - item.SetPrimaryVersionId(primaryVersion.Id.ToString("N", CultureInfo.InvariantCulture)); + item.SetPrimaryVersionId(primaryVersion.Id); await item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); - if (!alternateVersionsOfPrimary.Any(i => string.Equals(i.Path, item.Path, StringComparison.OrdinalIgnoreCase))) + // Re-route any playlist/collection references from this item to the primary + await _libraryManager.RerouteLinkedChildReferencesAsync(item.Id, primaryVersion.Id).ConfigureAwait(false); + + if (!alternateVersionsOfPrimary.Any(i => i.ItemId.HasValue && i.ItemId.Value.Equals(item.Id))) { alternateVersionsOfPrimary.Add(new LinkedChild { - Path = item.Path, - ItemId = item.Id + ItemId = item.Id, + Type = LinkedChildType.LinkedAlternateVersion }); } foreach (var linkedItem in item.LinkedAlternateVersions) { - if (!alternateVersionsOfPrimary.Any(i => string.Equals(i.Path, linkedItem.Path, StringComparison.OrdinalIgnoreCase))) + if (linkedItem.ItemId.HasValue && !alternateVersionsOfPrimary.Any(i => i.ItemId.HasValue && i.ItemId.Value.Equals(linkedItem.ItemId.Value))) { alternateVersionsOfPrimary.Add(linkedItem); } @@ -271,7 +274,6 @@ public class VideosController : BaseJellyfinApiController /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> - /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> @@ -315,29 +317,28 @@ public class VideosController : BaseJellyfinApiController [ProducesVideoFile] public async Task<ActionResult> GetVideoStream( [FromRoute, Required] Guid itemId, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? container, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? container, [FromQuery] bool? @static, [FromQuery] string? @params, [FromQuery] string? tag, [FromQuery, ParameterObsolete] string? deviceProfileId, [FromQuery] string? playSessionId, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer, [FromQuery] int? segmentLength, [FromQuery] int? minSegments, [FromQuery] string? mediaSourceId, [FromQuery] string? deviceId, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec, [FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowAudioStreamCopy, - [FromQuery] bool? breakOnNonKeyFrames, [FromQuery] int? audioSampleRate, [FromQuery] int? maxAudioBitDepth, [FromQuery] int? audioBitRate, [FromQuery] int? audioChannels, [FromQuery] int? maxAudioChannels, [FromQuery] string? profile, - [FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level, + [FromQuery][RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level, [FromQuery] float? framerate, [FromQuery] float? maxFramerate, [FromQuery] bool? copyTimestamps, @@ -358,8 +359,8 @@ public class VideosController : BaseJellyfinApiController [FromQuery] int? cpuCoreLimit, [FromQuery] string? liveStreamId, [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec, [FromQuery] string? transcodeReasons, [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, @@ -387,7 +388,6 @@ public class VideosController : BaseJellyfinApiController EnableAutoStreamCopy = enableAutoStreamCopy ?? true, AllowAudioStreamCopy = allowAudioStreamCopy ?? true, AllowVideoStreamCopy = allowVideoStreamCopy ?? true, - BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, AudioSampleRate = audioSampleRate, MaxAudioChannels = maxAudioChannels, AudioBitRate = audioBitRate, @@ -512,7 +512,6 @@ public class VideosController : BaseJellyfinApiController /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> - /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> @@ -556,29 +555,28 @@ public class VideosController : BaseJellyfinApiController [ProducesVideoFile] public Task<ActionResult> GetVideoStreamByContainer( [FromRoute, Required] Guid itemId, - [FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string container, + [FromRoute, Required][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string container, [FromQuery] bool? @static, [FromQuery] string? @params, [FromQuery] string? tag, [FromQuery] string? deviceProfileId, [FromQuery] string? playSessionId, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer, [FromQuery] int? segmentLength, [FromQuery] int? minSegments, [FromQuery] string? mediaSourceId, [FromQuery] string? deviceId, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec, [FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowAudioStreamCopy, - [FromQuery] bool? breakOnNonKeyFrames, [FromQuery] int? audioSampleRate, [FromQuery] int? maxAudioBitDepth, [FromQuery] int? audioBitRate, [FromQuery] int? audioChannels, [FromQuery] int? maxAudioChannels, [FromQuery] string? profile, - [FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level, + [FromQuery][RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level, [FromQuery] float? framerate, [FromQuery] float? maxFramerate, [FromQuery] bool? copyTimestamps, @@ -599,8 +597,8 @@ public class VideosController : BaseJellyfinApiController [FromQuery] int? cpuCoreLimit, [FromQuery] string? liveStreamId, [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec, [FromQuery] string? transcodeReasons, [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, @@ -625,7 +623,6 @@ public class VideosController : BaseJellyfinApiController enableAutoStreamCopy, allowVideoStreamCopy, allowAudioStreamCopy, - breakOnNonKeyFrames, audioSampleRate, maxAudioBitDepth, audioBitRate, diff --git a/Jellyfin.Api/Controllers/YearsController.cs b/Jellyfin.Api/Controllers/YearsController.cs index 5495f60d88..aa6464ee7a 100644 --- a/Jellyfin.Api/Controllers/YearsController.cs +++ b/Jellyfin.Api/Controllers/YearsController.cs @@ -26,6 +26,7 @@ namespace Jellyfin.Api.Controllers; /// Years controller. /// </summary> [Authorize] +[Tags("Year")] public class YearsController : BaseJellyfinApiController { private readonly ILibraryManager _libraryManager; @@ -89,7 +90,6 @@ public class YearsController : BaseJellyfinApiController { userId = RequestHelpers.GetUserId(User, userId); var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); User? user = userId.IsNullOrEmpty() @@ -182,8 +182,7 @@ public class YearsController : BaseJellyfinApiController return NotFound(); } - var dtoOptions = new DtoOptions() - .AddClientFields(User); + var dtoOptions = new DtoOptions(); if (!userId.IsNullOrEmpty()) { diff --git a/Jellyfin.Api/Extensions/DtoExtensions.cs b/Jellyfin.Api/Extensions/DtoExtensions.cs index f919a4707d..36a8e197a2 100644 --- a/Jellyfin.Api/Extensions/DtoExtensions.cs +++ b/Jellyfin.Api/Extensions/DtoExtensions.cs @@ -1,10 +1,7 @@ -using System; using System.Collections.Generic; -using System.Security.Claims; -using Jellyfin.Extensions; +using Jellyfin.Data.Enums; using MediaBrowser.Controller.Dto; using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Querying; namespace Jellyfin.Api.Extensions; @@ -14,52 +11,34 @@ namespace Jellyfin.Api.Extensions; public static class DtoExtensions { /// <summary> - /// Add additional fields depending on client. + /// Gets the BaseItemKind values associated with the specified CollectionType. /// </summary> - /// <remarks> - /// Use in place of GetDtoOptions. - /// Legacy order: 2. - /// </remarks> - /// <param name="dtoOptions">DtoOptions object.</param> - /// <param name="user">Current claims principal.</param> - /// <returns>Modified DtoOptions object.</returns> - internal static DtoOptions AddClientFields( - this DtoOptions dtoOptions, ClaimsPrincipal user) + /// <param name="collectionType">The collection type to map to BaseItemKind values.</param> + /// <returns>An array of BaseItemKind values that correspond to the collection type.</returns> + public static BaseItemKind[] GetBaseItemKindsForCollectionType(CollectionType? collectionType) { - string? client = user.GetClient(); - - // No client in claim - if (string.IsNullOrEmpty(client)) + switch (collectionType) { - return dtoOptions; + case CollectionType.movies: + return [BaseItemKind.Movie]; + case CollectionType.tvshows: + return [BaseItemKind.Series]; + case CollectionType.music: + return [BaseItemKind.MusicAlbum]; + case CollectionType.musicvideos: + return [BaseItemKind.MusicVideo]; + case CollectionType.books: + return [BaseItemKind.Book, BaseItemKind.AudioBook]; + case CollectionType.boxsets: + return [BaseItemKind.BoxSet]; + case CollectionType.homevideos: + case CollectionType.photos: + return [BaseItemKind.Video, BaseItemKind.Photo]; + case CollectionType.folders: + return []; + default: + return [BaseItemKind.Video, BaseItemKind.Audio, BaseItemKind.Photo, BaseItemKind.Movie, BaseItemKind.Series]; } - - if (!dtoOptions.ContainsField(ItemFields.RecursiveItemCount)) - { - if (client.Contains("kodi", StringComparison.OrdinalIgnoreCase) || - client.Contains("wmc", StringComparison.OrdinalIgnoreCase) || - client.Contains("media center", StringComparison.OrdinalIgnoreCase) || - client.Contains("classic", StringComparison.OrdinalIgnoreCase)) - { - dtoOptions.Fields = [..dtoOptions.Fields, ItemFields.RecursiveItemCount]; - } - } - - if (!dtoOptions.ContainsField(ItemFields.ChildCount)) - { - if (client.Contains("kodi", StringComparison.OrdinalIgnoreCase) || - client.Contains("wmc", StringComparison.OrdinalIgnoreCase) || - client.Contains("media center", StringComparison.OrdinalIgnoreCase) || - client.Contains("classic", StringComparison.OrdinalIgnoreCase) || - client.Contains("roku", StringComparison.OrdinalIgnoreCase) || - client.Contains("samsung", StringComparison.OrdinalIgnoreCase) || - client.Contains("androidtv", StringComparison.OrdinalIgnoreCase)) - { - dtoOptions.Fields = [..dtoOptions.Fields, ItemFields.ChildCount]; - } - } - - return dtoOptions; } /// <summary> diff --git a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs index 16e51151d9..4995d37f4b 100644 --- a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs +++ b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs @@ -209,6 +209,25 @@ public class DynamicHlsHelper AddSubtitles(state, subtitleStreams, builder, _httpContextAccessor.HttpContext.User); } + // For DoVi profiles without a compatible base layer (P5 HEVC, P10/bl0 AV1), + // add a spec-compliant dvh1/dav1 variant before the hvc1 hack variant. + // SUPPLEMENTAL-CODECS cannot be used for these profiles (no compatible BL to supplement). + // The DoVi variant is listed first so spec-compliant clients (Apple TV, webOS 24+) + // select it over the fallback when both have identical BANDWIDTH. + // Only emit for clients that explicitly declared DOVI support to avoid breaking + // non-compliant players that don't recognize dvh1/dav1 CODECS strings. + if (state.VideoStream is not null + && state.VideoRequest is not null + && EncodingHelper.IsCopyCodec(state.OutputVideoCodec) + && state.VideoStream.VideoRangeType == VideoRangeType.DOVI + && state.VideoStream.DvProfile.HasValue + && state.VideoStream.DvLevel.HasValue + && state.GetRequestedRangeTypes(state.VideoStream.Codec) + .Contains(VideoRangeType.DOVI.ToString(), StringComparison.OrdinalIgnoreCase)) + { + AppendDoviPlaylist(builder, state, playlistUrl, totalBitrate, subtitleGroup); + } + var basicPlaylist = AppendPlaylist(builder, state, playlistUrl, totalBitrate, subtitleGroup); if (state.VideoStream is not null && state.VideoRequest is not null) @@ -355,6 +374,65 @@ public class DynamicHlsHelper return playlistBuilder; } + /// <summary> + /// Appends a Dolby Vision variant with dvh1/dav1 CODECS for profiles without a compatible + /// base layer (P5 HEVC, P10/bl0 AV1). This enables spec-compliant HLS clients to detect + /// DoVi from the manifest rather than relying on init segment inspection. + /// </summary> + /// <param name="builder">StringBuilder for the master playlist.</param> + /// <param name="state">StreamState of the current stream.</param> + /// <param name="url">Playlist URL for this variant.</param> + /// <param name="bitrate">Bitrate for the BANDWIDTH field.</param> + /// <param name="subtitleGroup">Subtitle group identifier, or null.</param> + private void AppendDoviPlaylist(StringBuilder builder, StreamState state, string url, int bitrate, string? subtitleGroup) + { + var dvProfile = state.VideoStream.DvProfile; + var dvLevel = state.VideoStream.DvLevel; + if (dvProfile is null || dvLevel is null) + { + return; + } + + var playlistBuilder = new StringBuilder(); + playlistBuilder.Append("#EXT-X-STREAM-INF:BANDWIDTH=") + .Append(bitrate.ToString(CultureInfo.InvariantCulture)) + .Append(",AVERAGE-BANDWIDTH=") + .Append(bitrate.ToString(CultureInfo.InvariantCulture)); + + playlistBuilder.Append(",VIDEO-RANGE=PQ"); + + var dvCodec = HlsCodecStringHelpers.GetDoviString(dvProfile.Value, dvLevel.Value, state.ActualOutputVideoCodec); + + string audioCodecs = string.Empty; + if (!string.IsNullOrEmpty(state.ActualOutputAudioCodec)) + { + audioCodecs = GetPlaylistAudioCodecs(state); + } + + playlistBuilder.Append(",CODECS=\"") + .Append(dvCodec); + if (!string.IsNullOrEmpty(audioCodecs)) + { + playlistBuilder.Append(',').Append(audioCodecs); + } + + playlistBuilder.Append('"'); + + AppendPlaylistResolutionField(playlistBuilder, state); + AppendPlaylistFramerateField(playlistBuilder, state); + + if (!string.IsNullOrWhiteSpace(subtitleGroup)) + { + playlistBuilder.Append(",SUBTITLES=\"") + .Append(subtitleGroup) + .Append('"'); + } + + playlistBuilder.AppendLine(); + playlistBuilder.AppendLine(url); + builder.Append(playlistBuilder); + } + /// <summary> /// Appends a VIDEO-RANGE field containing the range of the output video stream. /// </summary> @@ -380,6 +458,7 @@ public class DynamicHlsHelper { case VideoRangeType.HLG: case VideoRangeType.DOVIWithHLG: + case VideoRangeType.DOVIInvalid when string.Equals(state.VideoStream.ColorTransfer, "arib-std-b67", StringComparison.OrdinalIgnoreCase): builder.Append(",VIDEO-RANGE=HLG"); break; default: @@ -754,7 +833,9 @@ public class DynamicHlsHelper { if (string.Equals(state.ActualOutputAudioCodec, "aac", StringComparison.OrdinalIgnoreCase)) { - string? profile = state.GetRequestedProfiles("aac").FirstOrDefault(); + string? profile = EncodingHelper.IsCopyCodec(state.OutputAudioCodec) + ? state.AudioStream?.Profile : state.GetRequestedProfiles("aac").FirstOrDefault(); + return HlsCodecStringHelpers.GetAACString(profile); } @@ -788,6 +869,19 @@ public class DynamicHlsHelper return HlsCodecStringHelpers.GetOPUSString(); } + if (string.Equals(state.ActualOutputAudioCodec, "truehd", StringComparison.OrdinalIgnoreCase)) + { + return HlsCodecStringHelpers.GetTRUEHDString(); + } + + if (string.Equals(state.ActualOutputAudioCodec, "dts", StringComparison.OrdinalIgnoreCase)) + { + // lavc only support encoding DTS core profile + string? profile = EncodingHelper.IsCopyCodec(state.OutputAudioCodec) ? state.AudioStream?.Profile : "DTS"; + + return HlsCodecStringHelpers.GetDTSString(profile); + } + return string.Empty; } diff --git a/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs b/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs index 4034a80887..d123dbc82e 100644 --- a/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs +++ b/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs @@ -62,12 +62,12 @@ public static class FileStreamResponseHelpers if (response.Headers.TryGetValues(HeaderNames.AcceptRanges, out var acceptRangesHeaders)) { // Prefer upstream server's Accept-Ranges header if available - acceptRangesValue = string.Join(", ", acceptRangesHeaders); - upstreamSupportsRange |= acceptRangesValue.Contains("bytes", StringComparison.OrdinalIgnoreCase); + acceptRangesValue = string.Join(", ", acceptRangesHeaders); + upstreamSupportsRange |= acceptRangesValue.Contains("bytes", StringComparison.OrdinalIgnoreCase); } else if (upstreamSupportsRange) // If we got 206 but no Accept-Ranges header, assume bytes { - acceptRangesValue = "bytes"; + acceptRangesValue = "bytes"; } // Set Accept-Ranges header for the client based on upstream support @@ -76,13 +76,13 @@ public static class FileStreamResponseHelpers // Set Content-Range header if upstream provided it (implies partial content) if (response.Content.Headers.ContentRange is not null) { - httpContext.Response.Headers[HeaderNames.ContentRange] = response.Content.Headers.ContentRange.ToString(); + httpContext.Response.Headers[HeaderNames.ContentRange] = response.Content.Headers.ContentRange.ToString(); } // Set Content-Length header. For partial content, this is the length of the partial segment. if (response.Content.Headers.ContentLength.HasValue) { - httpContext.Response.ContentLength = response.Content.Headers.ContentLength.Value; + httpContext.Response.ContentLength = response.Content.Headers.ContentLength.Value; } // Set Content-Type header diff --git a/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs b/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs index 0efb7f45de..1ac2abcfbf 100644 --- a/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs +++ b/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs @@ -41,6 +41,11 @@ public static class HlsCodecStringHelpers /// </summary> public const string OPUS = "Opus"; + /// <summary> + /// Codec name for TRUEHD. + /// </summary> + public const string TRUEHD = "mlpa"; + /// <summary> /// Gets a MP3 codec string. /// </summary> @@ -59,7 +64,7 @@ public static class HlsCodecStringHelpers { StringBuilder result = new StringBuilder("mp4a", 9); - if (string.Equals(profile, "HE", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(profile, "HE-AAC", StringComparison.OrdinalIgnoreCase)) { result.Append(".40.5"); } @@ -117,6 +122,46 @@ public static class HlsCodecStringHelpers return OPUS; } + /// <summary> + /// Gets an TRUEHD codec string. + /// </summary> + /// <returns>TRUEHD codec string.</returns> + public static string GetTRUEHDString() + { + return TRUEHD; + } + + /// <summary> + /// Gets an DTS codec string. + /// </summary> + /// <param name="profile">DTS profile.</param> + /// <returns>DTS codec string.</returns> + public static string GetDTSString(string? profile) + { + if (string.Equals(profile, "DTS", StringComparison.OrdinalIgnoreCase) + || string.Equals(profile, "DTS-ES", StringComparison.OrdinalIgnoreCase) + || string.Equals(profile, "DTS 96/24", StringComparison.OrdinalIgnoreCase)) + { + return "dtsc"; + } + + if (string.Equals(profile, "DTS-HD HRA", StringComparison.OrdinalIgnoreCase) + || string.Equals(profile, "DTS-HD MA", StringComparison.OrdinalIgnoreCase) + || string.Equals(profile, "DTS-HD MA + DTS:X", StringComparison.OrdinalIgnoreCase) + || string.Equals(profile, "DTS-HD MA + DTS:X IMAX", StringComparison.OrdinalIgnoreCase)) + { + return "dtsh"; + } + + if (string.Equals(profile, "DTS Express", StringComparison.OrdinalIgnoreCase)) + { + return "dtse"; + } + + // Default to DTS core if profile is invalid + return "dtsc"; + } + /// <summary> /// Gets a H.264 codec string. /// </summary> @@ -301,4 +346,25 @@ public static class HlsCodecStringHelpers return result.ToString(); } + + /// <summary> + /// Gets a Dolby Vision codec string for profiles without a compatible base layer. + /// </summary> + /// <param name="dvProfile">Dolby Vision profile number.</param> + /// <param name="dvLevel">Dolby Vision level number.</param> + /// <param name="codec">Video codec name (e.g. "hevc", "av1") to determine the DoVi FourCC.</param> + /// <returns>Dolby Vision codec string.</returns> + public static string GetDoviString(int dvProfile, int dvLevel, string codec) + { + // HEVC DoVi uses dvh1, AV1 DoVi uses dav1 (out-of-band parameter sets, recommended by Apple HLS spec Rule 1.10) + var fourCc = string.Equals(codec, "av1", StringComparison.OrdinalIgnoreCase) ? "dav1" : "dvh1"; + StringBuilder result = new StringBuilder(fourCc, 12); + + result.Append('.') + .AppendFormat(CultureInfo.InvariantCulture, "{0:D2}", dvProfile) + .Append('.') + .AppendFormat(CultureInfo.InvariantCulture, "{0:D2}", dvLevel); + + return result.ToString(); + } } diff --git a/Jellyfin.Api/Helpers/HlsHelpers.cs b/Jellyfin.Api/Helpers/HlsHelpers.cs index cad8d650e9..15540338b3 100644 --- a/Jellyfin.Api/Helpers/HlsHelpers.cs +++ b/Jellyfin.Api/Helpers/HlsHelpers.cs @@ -45,15 +45,9 @@ public static class HlsHelpers using var reader = new StreamReader(fileStream); var count = 0; - while (!reader.EndOfStream) + string? line; + while ((line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false)) is not null) { - var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false); - if (line is null) - { - // Nothing currently in buffer. - break; - } - if (line.Contains("#EXTINF:", StringComparison.OrdinalIgnoreCase)) { count++; diff --git a/Jellyfin.Api/Helpers/MediaInfoHelper.cs b/Jellyfin.Api/Helpers/MediaInfoHelper.cs index 454d3f08e3..c27a18831c 100644 --- a/Jellyfin.Api/Helpers/MediaInfoHelper.cs +++ b/Jellyfin.Api/Helpers/MediaInfoHelper.cs @@ -14,6 +14,7 @@ using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; +using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Devices; using MediaBrowser.Controller.Entities; @@ -44,6 +45,7 @@ public class MediaInfoHelper private readonly ILogger<MediaInfoHelper> _logger; private readonly INetworkManager _networkManager; private readonly IDeviceManager _deviceManager; + private readonly IServerApplicationHost _appHost; /// <summary> /// Initializes a new instance of the <see cref="MediaInfoHelper"/> class. @@ -56,6 +58,7 @@ public class MediaInfoHelper /// <param name="logger">Instance of the <see cref="ILogger{MediaInfoHelper}"/> interface.</param> /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param> /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param> + /// <param name="appHost">Instance of the <see cref="IServerApplicationHost"/> interface.</param> public MediaInfoHelper( IUserManager userManager, ILibraryManager libraryManager, @@ -64,7 +67,8 @@ public class MediaInfoHelper IServerConfigurationManager serverConfigurationManager, ILogger<MediaInfoHelper> logger, INetworkManager networkManager, - IDeviceManager deviceManager) + IDeviceManager deviceManager, + IServerApplicationHost appHost) { _userManager = userManager; _libraryManager = libraryManager; @@ -74,6 +78,7 @@ public class MediaInfoHelper _logger = logger; _networkManager = networkManager; _deviceManager = deviceManager; + _appHost = appHost; } /// <summary> @@ -81,40 +86,20 @@ public class MediaInfoHelper /// </summary> /// <param name="item">The item.</param> /// <param name="user">The user.</param> + /// <param name="request">The current <see cref="HttpRequest"/>.</param> /// <param name="mediaSourceId">Media source id.</param> /// <param name="liveStreamId">Live stream id.</param> /// <returns>A <see cref="Task"/> containing the <see cref="PlaybackInfoResponse"/>.</returns> public async Task<PlaybackInfoResponse> GetPlaybackInfo( BaseItem item, User? user, + HttpRequest request, string? mediaSourceId = null, string? liveStreamId = null) { var result = new PlaybackInfoResponse(); - MediaSourceInfo[] mediaSources; - if (string.IsNullOrWhiteSpace(liveStreamId)) - { - // TODO (moved from MediaBrowser.Api) handle supportedLiveMediaTypes? - var mediaSourcesList = await _mediaSourceManager.GetPlaybackMediaSources(item, user, true, true, CancellationToken.None).ConfigureAwait(false); - - if (string.IsNullOrWhiteSpace(mediaSourceId)) - { - mediaSources = mediaSourcesList.ToArray(); - } - else - { - mediaSources = mediaSourcesList - .Where(i => string.Equals(i.Id, mediaSourceId, StringComparison.OrdinalIgnoreCase)) - .ToArray(); - } - } - else - { - var mediaSource = await _mediaSourceManager.GetLiveStream(liveStreamId, CancellationToken.None).ConfigureAwait(false); - - mediaSources = new[] { mediaSource }; - } + var mediaSources = await ResolvePlaybackMediaSources(item, user, mediaSourceId, liveStreamId).ConfigureAwait(false); if (mediaSources.Length == 0) { @@ -136,6 +121,11 @@ public class MediaInfoHelper mediaSourcesClone[i].DefaultAudioIndexSource = mediaSources[i].DefaultAudioIndexSource; } + foreach (var mediaSource in mediaSourcesClone) + { + RewritePublishedLiveStreamPath(mediaSource, request); + } + result.MediaSources = mediaSourcesClone; } @@ -145,6 +135,28 @@ public class MediaInfoHelper return result; } + private async Task<MediaSourceInfo[]> ResolvePlaybackMediaSources(BaseItem item, User? user, string? mediaSourceId, string? liveStreamId) + { + if (!string.IsNullOrWhiteSpace(liveStreamId)) + { + var mediaSource = await _mediaSourceManager.GetLiveStream(liveStreamId, CancellationToken.None).ConfigureAwait(false); + + return new[] { mediaSource }; + } + + // TODO (moved from MediaBrowser.Api) handle supportedLiveMediaTypes? + var mediaSourcesList = await _mediaSourceManager.GetPlaybackMediaSources(item, user, true, true, CancellationToken.None).ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(mediaSourceId)) + { + return mediaSourcesList.ToArray(); + } + + return mediaSourcesList + .Where(i => string.Equals(i.Id, mediaSourceId, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + } + /// <summary> /// SetDeviceSpecificData. /// </summary> @@ -351,11 +363,20 @@ public class MediaInfoHelper /// </summary> /// <param name="result">Playback info response.</param> /// <param name="maxBitrate">Max bitrate.</param> - public void SortMediaSources(PlaybackInfoResponse result, long? maxBitrate) + /// <param name="preferredItemId">The id of the queried item, whose own media source must stay the default.</param> + public void SortMediaSources(PlaybackInfoResponse result, long? maxBitrate, Guid preferredItemId = default) { var originalList = result.MediaSources.ToList(); - result.MediaSources = result.MediaSources.OrderBy(i => + // The queried item's source carries the user's resume state for that version, so it must stay the + // default the client plays. An unfavorable bitrate means transcoding it, not switching to a sibling version. + var preferredId = preferredItemId.IsEmpty() + ? null + : preferredItemId.ToString("N", CultureInfo.InvariantCulture); + + result.MediaSources = result.MediaSources + .OrderByDescending(i => preferredId is not null && string.Equals(i.Id, preferredId, StringComparison.OrdinalIgnoreCase)) + .ThenBy(i => { // Nothing beats direct playing a file if (i.SupportsDirectPlay && i.Protocol == MediaProtocol.File) @@ -406,6 +427,8 @@ public class MediaInfoHelper { var result = await _mediaSourceManager.OpenLiveStream(request, CancellationToken.None).ConfigureAwait(false); + RewritePublishedLiveStreamPath(result.MediaSource, httpContext.Request); + var profile = request.DeviceProfile; if (profile is null) { @@ -515,4 +538,90 @@ public class MediaInfoHelper return maxBitrate; } + + /// <summary> + /// Rewrites a Live TV media source's <see cref="MediaSourceInfo.Path"/> to the request-appropriate published + /// URL when it points at a Jellyfin-hosted live stream buffer, so response copies never leak server-local + /// addresses. Only opened live streams are eligible. The shared instance held by + /// <see cref="IMediaSourceManager"/> is never touched by this method. + /// </summary> + /// <param name="mediaSource">The media source clone to rewrite in place.</param> + /// <param name="request">The current <see cref="HttpRequest"/>.</param> + private void RewritePublishedLiveStreamPath(MediaSourceInfo mediaSource, HttpRequest request) + { + // Opened live streams always carry a LiveStreamId; this excludes pre-open and plugin/remote sources. + if (string.IsNullOrEmpty(mediaSource.LiveStreamId)) + { + return; + } + + var baseUrl = _serverConfigurationManager.GetNetworkConfiguration().BaseUrl; + var publishedPath = GetPublishedLiveStreamPath(_appHost.GetSmartApiUrl(request), mediaSource.Path, mediaSource.Protocol, baseUrl); + + if (publishedPath is not null) + { + mediaSource.Path = publishedPath; + return; + } + + if (mediaSource.Path is not null && mediaSource.Path.Contains("/LiveTv/LiveStreamFiles/", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogDebug("Not rewriting live stream path for media source {MediaSourceId}: the local path did not resolve under the request's smart API URL/BaseUrl", mediaSource.Id); + } + } + + /// <summary> + /// Resolves a Jellyfin-hosted Live TV buffer path to its request-appropriate published equivalent. + /// Returns null when the path isn't a Jellyfin-hosted <c>/LiveTv/LiveStreamFiles/</c> HTTP URL. + /// </summary> + /// <param name="smartApiUrl">The request-appropriate base URL, as returned by <see cref="IServerApplicationHost.GetSmartApiUrl(HttpRequest)"/>.</param> + /// <param name="localPath">The media source's local (LAN-access) path, as built from <see cref="IServerApplicationHost.GetApiUrlForLocalAccess"/>.</param> + /// <param name="protocol">The media source's protocol.</param> + /// <param name="baseUrl">The server's configured BaseUrl, if any.</param> + /// <returns>The published path, or null if the local path should be left unchanged.</returns> + internal static string? GetPublishedLiveStreamPath( + string smartApiUrl, + string? localPath, + MediaProtocol protocol, + string baseUrl) + { + if (protocol != MediaProtocol.Http + || !Uri.TryCreate(localPath, UriKind.Absolute, out var localUri)) + { + return null; + } + + var relativePath = localUri.PathAndQuery; + if (!string.IsNullOrEmpty(baseUrl)) + { + var basePrefix = baseUrl + "/"; + if (!relativePath.StartsWith(basePrefix, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + relativePath = relativePath[baseUrl.Length..]; + } + + if (!relativePath.StartsWith("/LiveTv/LiveStreamFiles/", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var prefix = smartApiUrl.TrimEnd('/'); + if (!string.IsNullOrEmpty(baseUrl)) + { + var includesBaseUrl = Uri.TryCreate(prefix, UriKind.Absolute, out var publishedUri) + && Uri.UnescapeDataString(publishedUri.AbsolutePath) + .TrimEnd('/') + .EndsWith(baseUrl, StringComparison.OrdinalIgnoreCase); + + if (!includesBaseUrl) + { + prefix += baseUrl; + } + } + + return prefix + relativePath; + } } diff --git a/Jellyfin.Api/Helpers/RequestHelpers.cs b/Jellyfin.Api/Helpers/RequestHelpers.cs index 5072f902da..4c5ed16015 100644 --- a/Jellyfin.Api/Helpers/RequestHelpers.cs +++ b/Jellyfin.Api/Helpers/RequestHelpers.cs @@ -5,7 +5,6 @@ using System.Security.Claims; using System.Threading.Tasks; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; -using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; @@ -31,15 +30,16 @@ public static class RequestHelpers /// </summary> /// <param name="sortBy">Sort By. Comma delimited string.</param> /// <param name="requestedSortOrder">Sort Order. Comma delimited string.</param> + /// <typeparam name="TSortBy">The type of the sort by field.</typeparam> /// <returns>Order By.</returns> - public static (ItemSortBy, SortOrder)[] GetOrderBy(IReadOnlyList<ItemSortBy> sortBy, IReadOnlyList<SortOrder> requestedSortOrder) + public static (TSortBy, SortOrder)[] GetOrderBy<TSortBy>(IReadOnlyList<TSortBy> sortBy, IReadOnlyList<SortOrder> requestedSortOrder) { if (sortBy.Count == 0) { - return Array.Empty<(ItemSortBy, SortOrder)>(); + return Array.Empty<(TSortBy, SortOrder)>(); } - var result = new (ItemSortBy, SortOrder)[sortBy.Count]; + var result = new (TSortBy, SortOrder)[sortBy.Count]; var i = 0; // Add elements which have a SortOrder specified for (; i < requestedSortOrder.Count; i++) @@ -156,7 +156,6 @@ public static class RequestHelpers QueryResult<(BaseItem Item, ItemCounts ItemCounts)> result, DtoOptions dtoOptions, IDtoService dtoService, - bool includeItemTypes, User? user) { var dtos = result.Items.Select(i => @@ -164,7 +163,7 @@ public static class RequestHelpers var (baseItem, counts) = i; var dto = dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user); - if (includeItemTypes) + if (counts is not null) { dto.ChildCount = counts.ItemCount; dto.ProgramCount = counts.ProgramCount; @@ -175,6 +174,7 @@ public static class RequestHelpers dto.AlbumCount = counts.AlbumCount; dto.SongCount = counts.SongCount; dto.ArtistCount = counts.ArtistCount; + dto.MusicVideoCount = counts.MusicVideoCount; } return dto; diff --git a/Jellyfin.Api/Helpers/StreamingHelpers.cs b/Jellyfin.Api/Helpers/StreamingHelpers.cs index e17468cfae..60c5bb2ef6 100644 --- a/Jellyfin.Api/Helpers/StreamingHelpers.cs +++ b/Jellyfin.Api/Helpers/StreamingHelpers.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; -using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Extensions; @@ -145,6 +144,15 @@ public static class StreamingHelpers mediaSource = liveStreamInfo.Item1; state.DirectStreamProvider = liveStreamInfo.Item2; + // The requested live stream is no longer open. This commonly happens when a client keeps + // polling the HLS playlist (e.g. live.m3u8) after the stream was disposed because its + // consumer count dropped to zero. GetLiveStreamWithDirectStreamProvider returns a null + // MediaSource in that case, so return 404 instead of dereferencing it below. + if (mediaSource is null) + { + throw new ResourceNotFoundException($"The live stream with id {streamingRequest.LiveStreamId} could not be found or is no longer available."); + } + // Cap the max bitrate when it is too high. This is usually due to ffmpeg is unable to probe the source liveTV streams' bitrate. if (mediaSource.FallbackMaxStreamingBitrate is not null && streamingRequest.VideoBitRate is not null) { @@ -190,17 +198,12 @@ public static class StreamingHelpers state.OutputAudioBitrate = encodingHelper.GetAudioBitrateParam(streamingRequest.AudioBitRate, streamingRequest.AudioCodec, state.AudioStream, state.OutputAudioChannels) ?? 0; } - if (outputAudioCodec.StartsWith("pcm_", StringComparison.Ordinal)) - { - containerInternal = ".pcm"; - } - if (state.VideoRequest is not null) { state.OutputVideoCodec = state.Request.VideoCodec; state.OutputVideoBitrate = encodingHelper.GetVideoBitrateParamValue(state.VideoRequest, state.VideoStream, state.OutputVideoCodec); - encodingHelper.TryStreamCopy(state); + encodingHelper.TryStreamCopy(state, encodingOptions); if (!EncodingHelper.IsCopyCodec(state.OutputVideoCodec) && state.OutputVideoBitrate.HasValue) { @@ -267,7 +270,7 @@ public static class StreamingHelpers Dictionary<string, string?> streamOptions = new Dictionary<string, string?>(); foreach (var param in queryString) { - if (char.IsLower(param.Key[0])) + if (param.Key.Length > 0 && char.IsLower(param.Key[0])) { // This was probably not parsed initially and should be a StreamOptions // or the generated URL should correctly serialize it @@ -486,7 +489,7 @@ public static class StreamingHelpers request.StartTimeTicks = long.Parse(val, CultureInfo.InvariantCulture); break; case 15: - if (videoRequest is not null && Regex.IsMatch(val, EncodingHelper.LevelValidationRegexStr)) + if (videoRequest is not null && EncodingHelper.LevelValidationRegex().IsMatch(val)) { videoRequest.Level = val; } diff --git a/Jellyfin.Api/Jellyfin.Api.csproj b/Jellyfin.Api/Jellyfin.Api.csproj index 25feaa2d75..3ccf7a746b 100644 --- a/Jellyfin.Api/Jellyfin.Api.csproj +++ b/Jellyfin.Api/Jellyfin.Api.csproj @@ -6,7 +6,7 @@ </PropertyGroup> <PropertyGroup> - <TargetFramework>net9.0</TargetFramework> + <TargetFramework>net10.0</TargetFramework> <GenerateDocumentationFile>true</GenerateDocumentationFile> </PropertyGroup> diff --git a/Jellyfin.Api/Middleware/LegacyEmbyRouteRewriteMiddleware.cs b/Jellyfin.Api/Middleware/LegacyEmbyRouteRewriteMiddleware.cs deleted file mode 100644 index 2cbb183263..0000000000 --- a/Jellyfin.Api/Middleware/LegacyEmbyRouteRewriteMiddleware.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Logging; - -namespace Jellyfin.Api.Middleware; - -/// <summary> -/// Removes /emby and /mediabrowser from requested route. -/// </summary> -public class LegacyEmbyRouteRewriteMiddleware -{ - private const string EmbyPath = "/emby"; - private const string MediabrowserPath = "/mediabrowser"; - - private readonly RequestDelegate _next; - private readonly ILogger<LegacyEmbyRouteRewriteMiddleware> _logger; - - /// <summary> - /// Initializes a new instance of the <see cref="LegacyEmbyRouteRewriteMiddleware"/> class. - /// </summary> - /// <param name="next">The next delegate in the pipeline.</param> - /// <param name="logger">The logger.</param> - public LegacyEmbyRouteRewriteMiddleware( - RequestDelegate next, - ILogger<LegacyEmbyRouteRewriteMiddleware> logger) - { - _next = next; - _logger = logger; - } - - /// <summary> - /// Executes the middleware action. - /// </summary> - /// <param name="httpContext">The current HTTP context.</param> - /// <returns>The async task.</returns> - public async Task Invoke(HttpContext httpContext) - { - var localPath = httpContext.Request.Path.ToString(); - if (localPath.StartsWith(EmbyPath, StringComparison.OrdinalIgnoreCase)) - { - httpContext.Request.Path = localPath[EmbyPath.Length..]; - _logger.LogDebug("Removing {EmbyPath} from route.", EmbyPath); - } - else if (localPath.StartsWith(MediabrowserPath, StringComparison.OrdinalIgnoreCase)) - { - httpContext.Request.Path = localPath[MediabrowserPath.Length..]; - _logger.LogDebug("Removing {MediabrowserPath} from route.", MediabrowserPath); - } - - await _next(httpContext).ConfigureAwait(false); - } -} diff --git a/Jellyfin.Api/Models/ConfigurationDtos/MediaEncoderPathDto.cs b/Jellyfin.Api/Models/ConfigurationDtos/MediaEncoderPathDto.cs deleted file mode 100644 index 5a48345eb6..0000000000 --- a/Jellyfin.Api/Models/ConfigurationDtos/MediaEncoderPathDto.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace Jellyfin.Api.Models.ConfigurationDtos; - -/// <summary> -/// Media Encoder Path Dto. -/// </summary> -public class MediaEncoderPathDto -{ - /// <summary> - /// Gets or sets media encoder path. - /// </summary> - public string Path { get; set; } = null!; - - /// <summary> - /// Gets or sets media encoder path type. - /// </summary> - public string PathType { get; set; } = null!; -} diff --git a/Jellyfin.Api/Models/LibraryDtos/LibraryTypeOptionsDto.cs b/Jellyfin.Api/Models/LibraryDtos/LibraryTypeOptionsDto.cs index f76c4a9678..98da6c8f44 100644 --- a/Jellyfin.Api/Models/LibraryDtos/LibraryTypeOptionsDto.cs +++ b/Jellyfin.Api/Models/LibraryDtos/LibraryTypeOptionsDto.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Entities; @@ -18,20 +17,25 @@ public class LibraryTypeOptionsDto /// <summary> /// Gets or sets the metadata fetchers. /// </summary> - public IReadOnlyList<LibraryOptionInfoDto> MetadataFetchers { get; set; } = Array.Empty<LibraryOptionInfoDto>(); + public IReadOnlyList<LibraryOptionInfoDto> MetadataFetchers { get; set; } = []; /// <summary> /// Gets or sets the image fetchers. /// </summary> - public IReadOnlyList<LibraryOptionInfoDto> ImageFetchers { get; set; } = Array.Empty<LibraryOptionInfoDto>(); + public IReadOnlyList<LibraryOptionInfoDto> ImageFetchers { get; set; } = []; + + /// <summary> + /// Gets or sets the similar item providers. + /// </summary> + public IReadOnlyList<LibraryOptionInfoDto> SimilarItemProviders { get; set; } = []; /// <summary> /// Gets or sets the supported image types. /// </summary> - public IReadOnlyList<ImageType> SupportedImageTypes { get; set; } = Array.Empty<ImageType>(); + public IReadOnlyList<ImageType> SupportedImageTypes { get; set; } = []; /// <summary> /// Gets or sets the default image options. /// </summary> - public IReadOnlyList<ImageOption> DefaultImageOptions { get; set; } = Array.Empty<ImageOption>(); + public IReadOnlyList<ImageOption> DefaultImageOptions { get; set; } = []; } diff --git a/Jellyfin.Api/Models/StartupDtos/StartupRemoteAccessDto.cs b/Jellyfin.Api/Models/StartupDtos/StartupRemoteAccessDto.cs index 9c29e372cf..2a1a312d54 100644 --- a/Jellyfin.Api/Models/StartupDtos/StartupRemoteAccessDto.cs +++ b/Jellyfin.Api/Models/StartupDtos/StartupRemoteAccessDto.cs @@ -1,4 +1,3 @@ -using System; using System.ComponentModel.DataAnnotations; namespace Jellyfin.Api.Models.StartupDtos; @@ -13,11 +12,4 @@ public class StartupRemoteAccessDto /// </summary> [Required] public bool EnableRemoteAccess { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether enable automatic port mapping. - /// </summary> - [Required] - [Obsolete("No longer supported")] - public bool EnableAutomaticPortMapping { get; set; } } diff --git a/Jellyfin.Api/Models/SystemInfoDtos/LibraryStorageDto.cs b/Jellyfin.Api/Models/SystemInfoDtos/LibraryStorageDto.cs index c138324d2e..6e4ba91133 100644 --- a/Jellyfin.Api/Models/SystemInfoDtos/LibraryStorageDto.cs +++ b/Jellyfin.Api/Models/SystemInfoDtos/LibraryStorageDto.cs @@ -10,7 +10,7 @@ namespace Jellyfin.Api.Models.SystemInfoDtos; /// </summary> public record LibraryStorageDto { - /// <summary> + /// <summary> /// Gets or sets the Library Id. /// </summary> public required Guid Id { get; set; } diff --git a/Jellyfin.Api/Models/UserDtos/UpdateUserEasyPassword.cs b/Jellyfin.Api/Models/UserDtos/UpdateUserEasyPassword.cs deleted file mode 100644 index f19d0b57a1..0000000000 --- a/Jellyfin.Api/Models/UserDtos/UpdateUserEasyPassword.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace Jellyfin.Api.Models.UserDtos; - -/// <summary> -/// The update user easy password request body. -/// </summary> -public class UpdateUserEasyPassword -{ - /// <summary> - /// Gets or sets the new sha1-hashed password. - /// </summary> - public string? NewPassword { get; set; } - - /// <summary> - /// Gets or sets the new password. - /// </summary> - public string? NewPw { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether to reset the password. - /// </summary> - public bool ResetPassword { get; set; } -} diff --git a/Jellyfin.Data/Enums/ActivityLogSortBy.cs b/Jellyfin.Data/Enums/ActivityLogSortBy.cs new file mode 100644 index 0000000000..a24185e365 --- /dev/null +++ b/Jellyfin.Data/Enums/ActivityLogSortBy.cs @@ -0,0 +1,49 @@ +namespace Jellyfin.Data.Enums; + +/// <summary> +/// Activity log sorting options. +/// </summary> +public enum ActivityLogSortBy +{ + /// <summary> + /// Sort by name. + /// </summary> + Name = 0, + + /// <summary> + /// Sort by overview. + /// </summary> + Overiew = 1, + + /// <summary> + /// Sort by short overview. + /// </summary> + ShortOverview = 2, + + /// <summary> + /// Sort by type. + /// </summary> + Type = 3, + + /* + /// <summary> + /// Sort by item name. + /// </summary> + Item = 4, + */ + + /// <summary> + /// Sort by date. + /// </summary> + DateCreated = 5, + + /// <summary> + /// Sort by username. + /// </summary> + Username = 6, + + /// <summary> + /// Sort by severity. + /// </summary> + LogSeverity = 7 +} diff --git a/Jellyfin.Data/Enums/PersonKind.cs b/Jellyfin.Data/Enums/PersonKind.cs index 29308789a0..54eac5ff3b 100644 --- a/Jellyfin.Data/Enums/PersonKind.cs +++ b/Jellyfin.Data/Enums/PersonKind.cs @@ -129,5 +129,10 @@ public enum PersonKind /// <summary> /// A person who renders a text from one language into another. /// </summary> - Translator + Translator, + + /// <summary> + /// A person who narrates a book or other work. + /// </summary> + Narrator } diff --git a/Jellyfin.Data/Enums/VideoRangeType.cs b/Jellyfin.Data/Enums/VideoRangeType.cs index ce232d73c3..e7cc340d21 100644 --- a/Jellyfin.Data/Enums/VideoRangeType.cs +++ b/Jellyfin.Data/Enums/VideoRangeType.cs @@ -61,8 +61,9 @@ public enum VideoRangeType DOVIWithELHDR10Plus, /// <summary> - /// 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. + /// Dolby Vision with invalid configuration, e.g. Profile 8 compat id 6 or inconsistent base-layer color metadata. + /// The base layer is classified as HDR only when its transfer characteristics signal PQ or HLG. + /// Otherwise, it is classified as SDR. /// </summary> DOVIInvalid, diff --git a/Jellyfin.Data/Jellyfin.Data.csproj b/Jellyfin.Data/Jellyfin.Data.csproj index 2bfebaabb7..c8983480c7 100644 --- a/Jellyfin.Data/Jellyfin.Data.csproj +++ b/Jellyfin.Data/Jellyfin.Data.csproj @@ -1,7 +1,7 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> - <TargetFramework>net9.0</TargetFramework> + <TargetFramework>net10.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> <PublishRepositoryUrl>true</PublishRepositoryUrl> @@ -18,7 +18,7 @@ <PropertyGroup> <Authors>Jellyfin Contributors</Authors> <PackageId>Jellyfin.Data</PackageId> - <VersionPrefix>10.11.7</VersionPrefix> + <VersionPrefix>12.0.0</VersionPrefix> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> </PropertyGroup> diff --git a/Jellyfin.Data/Queries/ActivityLogQuery.cs b/Jellyfin.Data/Queries/ActivityLogQuery.cs index f1af099d3c..6de6c4c217 100644 --- a/Jellyfin.Data/Queries/ActivityLogQuery.cs +++ b/Jellyfin.Data/Queries/ActivityLogQuery.cs @@ -1,20 +1,68 @@ using System; +using System.Collections.Generic; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; +using Microsoft.Extensions.Logging; -namespace Jellyfin.Data.Queries +namespace Jellyfin.Data.Queries; + +/// <summary> +/// A class representing a query to the activity logs. +/// </summary> +public class ActivityLogQuery : PaginatedQuery { /// <summary> - /// A class representing a query to the activity logs. + /// Gets or sets a value indicating whether to take entries with a user id. /// </summary> - public class ActivityLogQuery : PaginatedQuery - { - /// <summary> - /// Gets or sets a value indicating whether to take entries with a user id. - /// </summary> - public bool? HasUserId { get; set; } + public bool? HasUserId { get; set; } - /// <summary> - /// Gets or sets the minimum date to query for. - /// </summary> - public DateTime? MinDate { get; set; } - } + /// <summary> + /// Gets or sets the minimum date to query for. + /// </summary> + public DateTime? MinDate { get; set; } + + /// <summary> + /// Gets or sets the maximum date to query for. + /// </summary> + public DateTime? MaxDate { get; set; } + + /// <summary> + /// Gets or sets the name filter. + /// </summary> + public string? Name { get; set; } + + /// <summary> + /// Gets or sets the overview filter. + /// </summary> + public string? Overview { get; set; } + + /// <summary> + /// Gets or sets the short overview filter. + /// </summary> + public string? ShortOverview { get; set; } + + /// <summary> + /// Gets or sets the type filter. + /// </summary> + public string? Type { get; set; } + + /// <summary> + /// Gets or sets the item filter. + /// </summary> + public Guid? ItemId { get; set; } + + /// <summary> + /// Gets or sets the username filter. + /// </summary> + public string? Username { get; set; } + + /// <summary> + /// Gets or sets the log level filter. + /// </summary> + public LogLevel? Severity { get; set; } + + /// <summary> + /// Gets or sets the result ordering. + /// </summary> + public IReadOnlyCollection<(ActivityLogSortBy, SortOrder)>? OrderBy { get; set; } } diff --git a/Jellyfin.Data/UserEntityExtensions.cs b/Jellyfin.Data/UserEntityExtensions.cs index 0fc8d3cd25..c6468daa2e 100644 --- a/Jellyfin.Data/UserEntityExtensions.cs +++ b/Jellyfin.Data/UserEntityExtensions.cs @@ -162,6 +162,23 @@ public static class UserEntityExtensions return Array.IndexOf(GetPreferenceValues<Guid>(entity, PreferenceKind.GroupedFolders), id) != -1; } + /// <summary> + /// Checks whether any library, parental rating or tag rule keeps content from this user. + /// </summary> + /// <param name="entity">The user to check.</param> + /// <returns><c>True</c> if some content in the library is hidden from this user.</returns> + public static bool HasContentRestrictions(this User entity) + { + ArgumentNullException.ThrowIfNull(entity); + + return !entity.HasPermission(PermissionKind.EnableAllFolders) + || entity.GetPreference(PreferenceKind.BlockedMediaFolders).Length > 0 + || entity.MaxParentalRatingScore.HasValue + || entity.GetPreference(PreferenceKind.BlockedTags).Length > 0 + || entity.GetPreference(PreferenceKind.AllowedTags).Length > 0 + || entity.GetPreference(PreferenceKind.BlockUnratedItems).Length > 0; + } + /// <summary> /// Initializes the default permissions for a user. Should only be called on user creation. /// </summary> diff --git a/Jellyfin.Server.Implementations/Activity/ActivityManager.cs b/Jellyfin.Server.Implementations/Activity/ActivityManager.cs index 8d492f7cd7..f21e94a0fd 100644 --- a/Jellyfin.Server.Implementations/Activity/ActivityManager.cs +++ b/Jellyfin.Server.Implementations/Activity/ActivityManager.cs @@ -1,103 +1,213 @@ using System; +using System.Collections.Generic; using System.Linq; +using System.Linq.Expressions; using System.Threading.Tasks; +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.Enums; +using Jellyfin.Extensions; using MediaBrowser.Model.Activity; using MediaBrowser.Model.Querying; using Microsoft.EntityFrameworkCore; -namespace Jellyfin.Server.Implementations.Activity +namespace Jellyfin.Server.Implementations.Activity; + +/// <summary> +/// Manages the storage and retrieval of <see cref="ActivityLog"/> instances. +/// </summary> +public class ActivityManager : IActivityManager { + private readonly IDbContextFactory<JellyfinDbContext> _provider; + /// <summary> - /// Manages the storage and retrieval of <see cref="ActivityLog"/> instances. + /// Initializes a new instance of the <see cref="ActivityManager"/> class. /// </summary> - public class ActivityManager : IActivityManager + /// <param name="provider">The Jellyfin database provider.</param> + public ActivityManager(IDbContextFactory<JellyfinDbContext> provider) { - private readonly IDbContextFactory<JellyfinDbContext> _provider; + _provider = provider; + } - /// <summary> - /// Initializes a new instance of the <see cref="ActivityManager"/> class. - /// </summary> - /// <param name="provider">The Jellyfin database provider.</param> - public ActivityManager(IDbContextFactory<JellyfinDbContext> provider) + /// <inheritdoc/> + public event EventHandler<GenericEventArgs<ActivityLogEntry>>? EntryCreated; + + /// <inheritdoc/> + public async Task CreateAsync(ActivityLog entry) + { + var dbContext = await _provider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) { - _provider = provider; + dbContext.ActivityLogs.Add(entry); + await dbContext.SaveChangesAsync().ConfigureAwait(false); } - /// <inheritdoc/> - public event EventHandler<GenericEventArgs<ActivityLogEntry>>? EntryCreated; + EntryCreated?.Invoke(this, new GenericEventArgs<ActivityLogEntry>(ConvertToOldModel(entry))); + } - /// <inheritdoc/> - public async Task CreateAsync(ActivityLog entry) + /// <inheritdoc/> + public async Task<QueryResult<ActivityLogEntry>> GetPagedResultAsync(ActivityLogQuery query) + { + // TODO allow sorting and filtering by item id. Currently not possible because ActivityLog stores the item id as a string. + + var dbContext = await _provider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) { - var dbContext = await _provider.CreateDbContextAsync().ConfigureAwait(false); - await using (dbContext.ConfigureAwait(false)) + var entries = dbContext.ActivityLogs.LeftJoin( + dbContext.Users, + a => a.UserId, + u => u.Id, + (a, u) => new ExpandedActivityLog { ActivityLog = a, Username = u == null ? null : u.Username }); + + if (query.HasUserId is not null) { - dbContext.ActivityLogs.Add(entry); - await dbContext.SaveChangesAsync().ConfigureAwait(false); + entries = entries.Where(e => e.ActivityLog.UserId.Equals(default) != query.HasUserId.Value); } - EntryCreated?.Invoke(this, new GenericEventArgs<ActivityLogEntry>(ConvertToOldModel(entry))); - } - - /// <inheritdoc/> - public async Task<QueryResult<ActivityLogEntry>> GetPagedResultAsync(ActivityLogQuery query) - { - var dbContext = await _provider.CreateDbContextAsync().ConfigureAwait(false); - await using (dbContext.ConfigureAwait(false)) + if (query.MinDate is not null) { - var entries = dbContext.ActivityLogs - .OrderByDescending(entry => entry.DateCreated) - .Where(entry => query.MinDate == null || entry.DateCreated >= query.MinDate) - .Where(entry => !query.HasUserId.HasValue || entry.UserId.Equals(default) != query.HasUserId.Value); - - return new QueryResult<ActivityLogEntry>( - query.Skip, - await entries.CountAsync().ConfigureAwait(false), - await entries - .Skip(query.Skip ?? 0) - .Take(query.Limit ?? 100) - .Select(entity => new ActivityLogEntry(entity.Name, entity.Type, entity.UserId) - { - Id = entity.Id, - Overview = entity.Overview, - ShortOverview = entity.ShortOverview, - ItemId = entity.ItemId, - Date = entity.DateCreated, - Severity = entity.LogSeverity - }) - .ToListAsync() - .ConfigureAwait(false)); + entries = entries.Where(e => e.ActivityLog.DateCreated >= query.MinDate.Value); } - } - /// <inheritdoc /> - public async Task CleanAsync(DateTime startDate) - { - var dbContext = await _provider.CreateDbContextAsync().ConfigureAwait(false); - await using (dbContext.ConfigureAwait(false)) + if (query.MaxDate is not null) { - await dbContext.ActivityLogs - .Where(entry => entry.DateCreated <= startDate) - .ExecuteDeleteAsync() - .ConfigureAwait(false); + entries = entries.Where(e => e.ActivityLog.DateCreated <= query.MaxDate.Value); } - } - private static ActivityLogEntry ConvertToOldModel(ActivityLog entry) - { - return new ActivityLogEntry(entry.Name, entry.Type, entry.UserId) + if (!string.IsNullOrEmpty(query.Name)) { - Id = entry.Id, - Overview = entry.Overview, - ShortOverview = entry.ShortOverview, - ItemId = entry.ItemId, - Date = entry.DateCreated, - Severity = entry.LogSeverity - }; + entries = entries.Where(e => EF.Functions.Like(e.ActivityLog.Name, $"%{query.Name}%")); + } + + if (!string.IsNullOrEmpty(query.Overview)) + { + entries = entries.Where(e => EF.Functions.Like(e.ActivityLog.Overview, $"%{query.Overview}%")); + } + + if (!string.IsNullOrEmpty(query.ShortOverview)) + { + entries = entries.Where(e => EF.Functions.Like(e.ActivityLog.ShortOverview, $"%{query.ShortOverview}%")); + } + + if (!string.IsNullOrEmpty(query.Type)) + { + entries = entries.Where(e => EF.Functions.Like(e.ActivityLog.Type, $"%{query.Type}%")); + } + + if (!query.ItemId.IsNullOrEmpty()) + { + var itemId = query.ItemId.Value.ToString("N"); + entries = entries.Where(e => e.ActivityLog.ItemId == itemId); + } + + if (!string.IsNullOrEmpty(query.Username)) + { + entries = entries.Where(e => EF.Functions.Like(e.Username, $"%{query.Username}%")); + } + + if (query.Severity is not null) + { + entries = entries.Where(e => e.ActivityLog.LogSeverity == query.Severity); + } + + return new QueryResult<ActivityLogEntry>( + query.Skip, + await entries.CountAsync().ConfigureAwait(false), + await ApplyOrdering(entries, query.OrderBy) + .Skip(query.Skip ?? 0) + .Take(query.Limit ?? 100) + .Select(entity => new ActivityLogEntry(entity.ActivityLog.Name, entity.ActivityLog.Type, entity.ActivityLog.UserId) + { + Id = entity.ActivityLog.Id, + Overview = entity.ActivityLog.Overview, + ShortOverview = entity.ActivityLog.ShortOverview, + ItemId = entity.ActivityLog.ItemId, + Date = entity.ActivityLog.DateCreated, + Severity = entity.ActivityLog.LogSeverity + }) + .ToListAsync() + .ConfigureAwait(false)); } } + + /// <inheritdoc /> + public async Task CleanAsync(DateTime startDate) + { + var dbContext = await _provider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + await dbContext.ActivityLogs + .Where(entry => entry.DateCreated <= startDate) + .ExecuteDeleteAsync() + .ConfigureAwait(false); + } + } + + private static ActivityLogEntry ConvertToOldModel(ActivityLog entry) + { + return new ActivityLogEntry(entry.Name, entry.Type, entry.UserId) + { + Id = entry.Id, + Overview = entry.Overview, + ShortOverview = entry.ShortOverview, + ItemId = entry.ItemId, + Date = entry.DateCreated, + Severity = entry.LogSeverity + }; + } + + private IOrderedQueryable<ExpandedActivityLog> ApplyOrdering(IQueryable<ExpandedActivityLog> query, IReadOnlyCollection<(ActivityLogSortBy, SortOrder)>? sorting) + { + if (sorting is null || sorting.Count == 0) + { + return query.OrderByDescending(e => e.ActivityLog.DateCreated); + } + + IOrderedQueryable<ExpandedActivityLog> ordered = null!; + + foreach (var (sortBy, sortOrder) in sorting) + { + var orderBy = MapOrderBy(sortBy); + + if (ordered == null) + { + ordered = sortOrder == SortOrder.Ascending + ? query.OrderBy(orderBy) + : query.OrderByDescending(orderBy); + } + else + { + ordered = sortOrder == SortOrder.Ascending + ? ordered.ThenBy(orderBy) + : ordered.ThenByDescending(orderBy); + } + } + + return ordered; + } + + private Expression<Func<ExpandedActivityLog, object?>> MapOrderBy(ActivityLogSortBy sortBy) + { + return sortBy switch + { + ActivityLogSortBy.Name => e => e.ActivityLog.Name, + ActivityLogSortBy.Overiew => e => e.ActivityLog.Overview, + ActivityLogSortBy.ShortOverview => e => e.ActivityLog.ShortOverview, + ActivityLogSortBy.Type => e => e.ActivityLog.Type, + ActivityLogSortBy.DateCreated => e => e.ActivityLog.DateCreated, + ActivityLogSortBy.Username => e => e.Username, + ActivityLogSortBy.LogSeverity => e => e.ActivityLog.LogSeverity, + _ => throw new ArgumentOutOfRangeException(nameof(sortBy), sortBy, "Unhandled ActivityLogSortBy") + }; + } + + private class ExpandedActivityLog + { + public ActivityLog ActivityLog { get; set; } = null!; + + public string? Username { get; set; } + } } diff --git a/Jellyfin.Server.Implementations/Devices/DeviceManager.cs b/Jellyfin.Server.Implementations/Devices/DeviceManager.cs index 51a1186452..d0d52a23fb 100644 --- a/Jellyfin.Server.Implementations/Devices/DeviceManager.cs +++ b/Jellyfin.Server.Implementations/Devices/DeviceManager.cs @@ -158,7 +158,7 @@ namespace Jellyfin.Server.Implementations.Devices devices = devices.Skip(query.Skip.Value); } - if (query.Limit.HasValue) + if (query.Limit.HasValue && query.Limit.Value > 0) { devices = devices.Take(query.Limit.Value); } @@ -213,8 +213,10 @@ namespace Jellyfin.Server.Implementations.Devices var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); await using (dbContext.ConfigureAwait(false)) { - dbContext.Devices.Remove(device); - await dbContext.SaveChangesAsync().ConfigureAwait(false); + await dbContext.Devices + .Where(d => d.Id == device.Id) + .ExecuteDeleteAsync() + .ConfigureAwait(false); } } diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Library/LyricDownloadFailureLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Library/LyricDownloadFailureLogger.cs index 5f4864e953..cfd1cbe05b 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Library/LyricDownloadFailureLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Library/LyricDownloadFailureLogger.cs @@ -37,7 +37,7 @@ public class LyricDownloadFailureLogger : IEventConsumer<LyricDownloadFailureEve await _activityManager.CreateAsync(new ActivityLog( string.Format( CultureInfo.InvariantCulture, - _localizationManager.GetLocalizedString("LyricDownloadFailureFromForItem"), + _localizationManager.GetServerLocalizedString("LyricDownloadFailureFromForItem"), eventArgs.Provider, GetItemName(eventArgs.Item)), "LyricDownloadFailure", diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Library/SubtitleDownloadFailureLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Library/SubtitleDownloadFailureLogger.cs index 8fe380e4f4..24146210c6 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Library/SubtitleDownloadFailureLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Library/SubtitleDownloadFailureLogger.cs @@ -37,7 +37,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Library await _activityManager.CreateAsync(new ActivityLog( string.Format( CultureInfo.InvariantCulture, - _localizationManager.GetLocalizedString("SubtitleDownloadFailureFromForItem"), + _localizationManager.GetServerLocalizedString("SubtitleDownloadFailureFromForItem"), eventArgs.Provider, GetItemName(eventArgs.Item)), "SubtitleDownloadFailure", diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationFailedLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationFailedLogger.cs index 1a8931a6dc..df526977a2 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationFailedLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationFailedLogger.cs @@ -35,7 +35,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Security await _activityManager.CreateAsync(new ActivityLog( string.Format( CultureInfo.InvariantCulture, - _localizationManager.GetLocalizedString("FailedLoginAttemptWithUserName"), + _localizationManager.GetServerLocalizedString("FailedLoginAttemptWithUserName"), eventArgs.Username), "AuthenticationFailed", Guid.Empty) @@ -43,7 +43,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Security LogSeverity = LogLevel.Error, ShortOverview = string.Format( CultureInfo.InvariantCulture, - _localizationManager.GetLocalizedString("LabelIpAddressValue"), + _localizationManager.GetServerLocalizedString("LabelIpAddressValue"), eventArgs.RemoteEndPoint), }).ConfigureAwait(false); } diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationSucceededLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationSucceededLogger.cs index 584d559e44..fa9ce21170 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationSucceededLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationSucceededLogger.cs @@ -33,14 +33,14 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Security await _activityManager.CreateAsync(new ActivityLog( string.Format( CultureInfo.InvariantCulture, - _localizationManager.GetLocalizedString("AuthenticationSucceededWithUserName"), + _localizationManager.GetServerLocalizedString("AuthenticationSucceededWithUserName"), eventArgs.User.Name), "AuthenticationSucceeded", eventArgs.User.Id) { ShortOverview = string.Format( CultureInfo.InvariantCulture, - _localizationManager.GetLocalizedString("LabelIpAddressValue"), + _localizationManager.GetServerLocalizedString("LabelIpAddressValue"), eventArgs.SessionInfo?.RemoteEndPoint), }).ConfigureAwait(false); } diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStartLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStartLogger.cs index 73323acb37..8f71966b83 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStartLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStartLogger.cs @@ -61,7 +61,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Session await _activityManager.CreateAsync(new ActivityLog( string.Format( CultureInfo.InvariantCulture, - _localizationManager.GetLocalizedString("UserStartedPlayingItemWithValues"), + _localizationManager.GetServerLocalizedString("UserStartedPlayingItemWithValues"), user.Username, GetItemName(eventArgs.MediaInfo), eventArgs.DeviceName), diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStopLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStopLogger.cs index b75567539c..a88904c727 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStopLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStopLogger.cs @@ -69,15 +69,15 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Session await _activityManager.CreateAsync(new ActivityLog( string.Format( CultureInfo.InvariantCulture, - _localizationManager.GetLocalizedString("UserStoppedPlayingItemWithValues"), + _localizationManager.GetServerLocalizedString("UserStoppedPlayingItemWithValues"), user.Username, GetItemName(item), eventArgs.DeviceName), notificationType, user.Id) - { - ItemId = eventArgs.Item?.Id.ToString("N", CultureInfo.InvariantCulture), - }) + { + ItemId = eventArgs.Item?.Id.ToString("N", CultureInfo.InvariantCulture), + }) .ConfigureAwait(false); } diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Session/SessionEndedLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Session/SessionEndedLogger.cs index b90708a2f2..74dfeebba6 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Session/SessionEndedLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Session/SessionEndedLogger.cs @@ -38,7 +38,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Session await _activityManager.CreateAsync(new ActivityLog( string.Format( CultureInfo.InvariantCulture, - _localizationManager.GetLocalizedString("UserOfflineFromDevice"), + _localizationManager.GetServerLocalizedString("UserOfflineFromDevice"), eventArgs.Argument.UserName, eventArgs.Argument.DeviceName), "SessionEnded", @@ -46,7 +46,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Session { ShortOverview = string.Format( CultureInfo.InvariantCulture, - _localizationManager.GetLocalizedString("LabelIpAddressValue"), + _localizationManager.GetServerLocalizedString("LabelIpAddressValue"), eventArgs.Argument.RemoteEndPoint), }).ConfigureAwait(false); } diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Session/SessionStartedLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Session/SessionStartedLogger.cs index 139c2e2acb..4028522838 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Session/SessionStartedLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Session/SessionStartedLogger.cs @@ -38,7 +38,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Session await _activityManager.CreateAsync(new ActivityLog( string.Format( CultureInfo.InvariantCulture, - _localizationManager.GetLocalizedString("UserOnlineFromDevice"), + _localizationManager.GetServerLocalizedString("UserOnlineFromDevice"), eventArgs.Argument.UserName, eventArgs.Argument.DeviceName), "SessionStarted", @@ -46,7 +46,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Session { ShortOverview = string.Format( CultureInfo.InvariantCulture, - _localizationManager.GetLocalizedString("LabelIpAddressValue"), + _localizationManager.GetServerLocalizedString("LabelIpAddressValue"), eventArgs.Argument.RemoteEndPoint) }).ConfigureAwait(false); } diff --git a/Jellyfin.Server.Implementations/Events/Consumers/System/TaskCompletedLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/System/TaskCompletedLogger.cs index da82a3b30f..1e3dc7c92e 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/System/TaskCompletedLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/System/TaskCompletedLogger.cs @@ -47,7 +47,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.System var time = result.EndTimeUtc - result.StartTimeUtc; var runningTime = string.Format( CultureInfo.InvariantCulture, - _localizationManager.GetLocalizedString("LabelRunningTimeValue"), + _localizationManager.GetServerLocalizedString("LabelRunningTimeValue"), ToUserFriendlyString(time)); if (result.Status == TaskCompletionStatus.Failed) @@ -65,7 +65,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.System } await _activityManager.CreateAsync(new ActivityLog( - string.Format(CultureInfo.InvariantCulture, _localizationManager.GetLocalizedString("ScheduledTaskFailedWithName"), task.Name), + string.Format(CultureInfo.InvariantCulture, _localizationManager.GetServerLocalizedString("ScheduledTaskFailedWithName"), task.Name), NotificationType.TaskFailed.ToString(), Guid.Empty) { diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstallationFailedLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstallationFailedLogger.cs index 632f30c7ad..9fb007aca7 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstallationFailedLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstallationFailedLogger.cs @@ -35,14 +35,14 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Updates await _activityManager.CreateAsync(new ActivityLog( string.Format( CultureInfo.InvariantCulture, - _localizationManager.GetLocalizedString("NameInstallFailed"), + _localizationManager.GetServerLocalizedString("NameInstallFailed"), eventArgs.InstallationInfo.Name), NotificationType.InstallationFailed.ToString(), Guid.Empty) { ShortOverview = string.Format( CultureInfo.InvariantCulture, - _localizationManager.GetLocalizedString("VersionNumber"), + _localizationManager.GetServerLocalizedString("VersionNumber"), eventArgs.InstallationInfo.Version), Overview = eventArgs.Exception.Message }).ConfigureAwait(false); diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstalledLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstalledLogger.cs index 4b49b714cf..2aa738c153 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstalledLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstalledLogger.cs @@ -35,14 +35,14 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Updates await _activityManager.CreateAsync(new ActivityLog( string.Format( CultureInfo.InvariantCulture, - _localizationManager.GetLocalizedString("PluginInstalledWithName"), + _localizationManager.GetServerLocalizedString("PluginInstalledWithName"), eventArgs.Argument.Name), NotificationType.PluginInstalled.ToString(), Guid.Empty) { ShortOverview = string.Format( CultureInfo.InvariantCulture, - _localizationManager.GetLocalizedString("VersionNumber"), + _localizationManager.GetServerLocalizedString("VersionNumber"), eventArgs.Argument.Version) }).ConfigureAwait(false); } diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginUninstalledLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginUninstalledLogger.cs index 2d24de7fc6..f7e651173d 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginUninstalledLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginUninstalledLogger.cs @@ -35,7 +35,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Updates await _activityManager.CreateAsync(new ActivityLog( string.Format( CultureInfo.InvariantCulture, - _localizationManager.GetLocalizedString("PluginUninstalledWithName"), + _localizationManager.GetServerLocalizedString("PluginUninstalledWithName"), eventArgs.Argument.Name), NotificationType.PluginUninstalled.ToString(), Guid.Empty)) diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginUpdatedLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginUpdatedLogger.cs index e892d3dd9a..bca9662839 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginUpdatedLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginUpdatedLogger.cs @@ -35,14 +35,14 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Updates await _activityManager.CreateAsync(new ActivityLog( string.Format( CultureInfo.InvariantCulture, - _localizationManager.GetLocalizedString("PluginUpdatedWithName"), + _localizationManager.GetServerLocalizedString("PluginUpdatedWithName"), eventArgs.Argument.Name), NotificationType.PluginUpdateInstalled.ToString(), Guid.Empty) { ShortOverview = string.Format( CultureInfo.InvariantCulture, - _localizationManager.GetLocalizedString("VersionNumber"), + _localizationManager.GetServerLocalizedString("VersionNumber"), eventArgs.Argument.Version), Overview = eventArgs.Argument.Changelog }).ConfigureAwait(false); diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Users/UserCreatedLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Users/UserCreatedLogger.cs index 4f063f6a1b..cf5c81b981 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Users/UserCreatedLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Users/UserCreatedLogger.cs @@ -33,7 +33,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Users await _activityManager.CreateAsync(new ActivityLog( string.Format( CultureInfo.InvariantCulture, - _localizationManager.GetLocalizedString("UserCreatedWithName"), + _localizationManager.GetServerLocalizedString("UserCreatedWithName"), eventArgs.Argument.Username), "UserCreated", eventArgs.Argument.Id)) diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Users/UserDeletedLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Users/UserDeletedLogger.cs index ba4a072e84..720480c28f 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Users/UserDeletedLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Users/UserDeletedLogger.cs @@ -34,7 +34,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Users await _activityManager.CreateAsync(new ActivityLog( string.Format( CultureInfo.InvariantCulture, - _localizationManager.GetLocalizedString("UserDeletedWithName"), + _localizationManager.GetServerLocalizedString("UserDeletedWithName"), eventArgs.Argument.Username), "UserDeleted", Guid.Empty)) diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Users/UserLockedOutLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Users/UserLockedOutLogger.cs index bbc00567d1..efaf19397f 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Users/UserLockedOutLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Users/UserLockedOutLogger.cs @@ -35,7 +35,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Users await _activityManager.CreateAsync(new ActivityLog( string.Format( CultureInfo.InvariantCulture, - _localizationManager.GetLocalizedString("UserLockedOutWithName"), + _localizationManager.GetServerLocalizedString("UserLockedOutWithName"), eventArgs.Argument.Username), NotificationType.UserLockedOut.ToString(), eventArgs.Argument.Id) diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Users/UserPasswordChangedLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Users/UserPasswordChangedLogger.cs index 7219704ec6..cc9efa7061 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Users/UserPasswordChangedLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Users/UserPasswordChangedLogger.cs @@ -33,7 +33,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Users await _activityManager.CreateAsync(new ActivityLog( string.Format( CultureInfo.InvariantCulture, - _localizationManager.GetLocalizedString("UserPasswordChangedWithName"), + _localizationManager.GetServerLocalizedString("UserPasswordChangedWithName"), eventArgs.Argument.Username), "UserPasswordChanged", eventArgs.Argument.Id)) diff --git a/Jellyfin.Server.Implementations/Extensions/ExpressionExtensions.cs b/Jellyfin.Server.Implementations/Extensions/ExpressionExtensions.cs index d70ac672f2..0f166fc6e0 100644 --- a/Jellyfin.Server.Implementations/Extensions/ExpressionExtensions.cs +++ b/Jellyfin.Server.Implementations/Extensions/ExpressionExtensions.cs @@ -39,6 +39,19 @@ public static class ExpressionExtensions return predicates.Aggregate((aggregatePredicate, nextPredicate) => aggregatePredicate.Or(nextPredicate)); } + /// <summary> + /// Negates a predicate. + /// </summary> + /// <typeparam name="T">The predicate parameter type.</typeparam> + /// <param name="predicate">The predicate expression to negate.</param> + /// <returns>A new expression representing the negation of the input predicate.</returns> + public static Expression<Func<T, bool>> Not<T>(this Expression<Func<T, bool>> predicate) + { + ArgumentNullException.ThrowIfNull(predicate); + + return Expression.Lambda<Func<T, bool>>(Expression.Not(predicate.Body), predicate.Parameters); + } + /// <summary> /// Combines two predicates into a single predicate using a logical AND operation. /// </summary> diff --git a/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs b/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs index 5eaf319ab3..7c10a5dc77 100644 --- a/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs +++ b/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs @@ -12,6 +12,7 @@ using Jellyfin.Database.Implementations; using Jellyfin.Server.Implementations.StorageHelpers; using Jellyfin.Server.Implementations.SystemBackupService; using MediaBrowser.Controller; +using MediaBrowser.Controller.Library; using MediaBrowser.Controller.SystemBackupService; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -33,6 +34,7 @@ public class BackupService : IBackupService private readonly IServerApplicationPaths _applicationPaths; private readonly IJellyfinDatabaseProvider _jellyfinDatabaseProvider; private readonly IHostApplicationLifetime _hostApplicationLifetime; + private readonly ILibraryManager _libraryManager; private static readonly JsonSerializerOptions _serializerSettings = new JsonSerializerOptions(JsonSerializerDefaults.General) { AllowTrailingCommas = true, @@ -50,13 +52,15 @@ public class BackupService : IBackupService /// <param name="applicationPaths">The application paths.</param> /// <param name="jellyfinDatabaseProvider">The Jellyfin database Provider in use.</param> /// <param name="applicationLifetime">The SystemManager.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> public BackupService( ILogger<BackupService> logger, IDbContextFactory<JellyfinDbContext> dbProvider, IServerApplicationHost applicationHost, IServerApplicationPaths applicationPaths, IJellyfinDatabaseProvider jellyfinDatabaseProvider, - IHostApplicationLifetime applicationLifetime) + IHostApplicationLifetime applicationLifetime, + ILibraryManager libraryManager) { _logger = logger; _dbProvider = dbProvider; @@ -64,6 +68,7 @@ public class BackupService : IBackupService _applicationPaths = applicationPaths; _jellyfinDatabaseProvider = jellyfinDatabaseProvider; _hostApplicationLifetime = applicationLifetime; + _libraryManager = libraryManager; } /// <inheritdoc/> @@ -102,7 +107,7 @@ public class BackupService : IBackupService } BackupManifest? manifest; - var manifestStream = zipArchiveEntry.Open(); + var manifestStream = await zipArchiveEntry.OpenAsync().ConfigureAwait(false); await using (manifestStream.ConfigureAwait(false)) { manifest = await JsonSerializer.DeserializeAsync<BackupManifest>(manifestStream, _serializerSettings).ConfigureAwait(false); @@ -168,7 +173,7 @@ public class BackupService : IBackupService } HistoryRow[] historyEntries; - var historyArchive = historyEntry.Open(); + var historyArchive = await historyEntry.OpenAsync().ConfigureAwait(false); await using (historyArchive.ConfigureAwait(false)) { historyEntries = await JsonSerializer.DeserializeAsync<HistoryRow[]>(historyArchive).ConfigureAwait(false) ?? @@ -212,7 +217,7 @@ public class BackupService : IBackupService continue; } - var zipEntryStream = zipEntry.Open(); + var zipEntryStream = await zipEntry.OpenAsync().ConfigureAwait(false); await using (zipEntryStream.ConfigureAwait(false)) { _logger.LogInformation("Restore backup of {Table}", entityType.Type.Name); @@ -263,6 +268,14 @@ public class BackupService : IBackupService /// <inheritdoc/> public async Task<BackupManifestDto> CreateBackupAsync(BackupOptionsDto backupOptions) { + // Creating a backup runs a database optimization and reads the entire database under a transaction, both of + // which heavily contend with an active library scan and could capture an inconsistent database state. + if (_libraryManager.IsScanRunning) + { + _logger.LogWarning("Cannot create a backup while a library scan is running."); + throw new InvalidOperationException("Cannot create a backup while a library scan is running. Please try again once the scan has finished."); + } + var manifest = new BackupManifest() { DateCreated = DateTime.UtcNow, @@ -337,7 +350,7 @@ public class BackupService : IBackupService _logger.LogInformation("Begin backup of entity {Table}", entityType.SourceName); var zipEntry = zipArchive.CreateEntry(NormalizePathSeparator(Path.Combine("Database", $"{entityType.SourceName}.json"))); var entities = 0; - var zipEntryStream = zipEntry.Open(); + var zipEntryStream = await zipEntry.OpenAsync().ConfigureAwait(false); await using (zipEntryStream.ConfigureAwait(false)) { var jsonSerializer = new Utf8JsonWriter(zipEntryStream); @@ -346,18 +359,39 @@ public class BackupService : IBackupService jsonSerializer.WriteStartArray(); var set = entityType.ValueFactory().ConfigureAwait(false); - await foreach (var item in set.ConfigureAwait(false)) + var enumerator = set.GetAsyncEnumerator(); + await using (enumerator) { - entities++; - try + while (true) { - using var document = JsonSerializer.SerializeToDocument(item, _serializerSettings); - document.WriteTo(jsonSerializer); - } - catch (Exception ex) - { - _logger.LogError(ex, "Could not load entity {Entity}", item); - throw; + bool hasNext; + try + { + hasNext = await enumerator.MoveNextAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Could not read next entity of type {Table}, the underlying data appears to be corrupt. Skipping this row and continuing backup; the affected database row should be inspected and fixed manually", entityType.SourceName); + continue; + } + + if (!hasNext) + { + break; + } + + var item = enumerator.Current; + entities++; + try + { + using var document = JsonSerializer.SerializeToDocument(item, _serializerSettings); + document.WriteTo(jsonSerializer); + } + catch (Exception ex) + { + _logger.LogError(ex, "Could not load entity {Entity}", item); + throw; + } } } @@ -374,7 +408,7 @@ public class BackupService : IBackupService foreach (var item in Directory.EnumerateFiles(_applicationPaths.ConfigurationDirectoryPath, "*.xml", SearchOption.TopDirectoryOnly) .Union(Directory.EnumerateFiles(_applicationPaths.ConfigurationDirectoryPath, "*.json", SearchOption.TopDirectoryOnly))) { - zipArchive.CreateEntryFromFile(item, NormalizePathSeparator(Path.Combine("Config", Path.GetFileName(item)))); + await zipArchive.CreateEntryFromFileAsync(item, NormalizePathSeparator(Path.Combine("Config", Path.GetFileName(item)))).ConfigureAwait(false); } void CopyDirectory(string source, string target, string filter = "*") @@ -388,6 +422,7 @@ public class BackupService : IBackupService foreach (var item in Directory.EnumerateFiles(source, filter, SearchOption.AllDirectories)) { + // TODO: @bond make async zipArchive.CreateEntryFromFile(item, NormalizePathSeparator(Path.Combine(target, Path.GetRelativePath(source, item)))); } } @@ -422,7 +457,7 @@ public class BackupService : IBackupService } } - var manifestStream = zipArchive.CreateEntry(ManifestEntryName).Open(); + var manifestStream = await zipArchive.CreateEntry(ManifestEntryName).OpenAsync().ConfigureAwait(false); await using (manifestStream.ConfigureAwait(false)) { await JsonSerializer.SerializeAsync(manifestStream, manifest).ConfigureAwait(false); @@ -522,7 +557,7 @@ public class BackupService : IBackupService return null; } - var manifestStream = manifestEntry.Open(); + var manifestStream = await manifestEntry.OpenAsync().ConfigureAwait(false); await using (manifestStream.ConfigureAwait(false)) { return await JsonSerializer.DeserializeAsync<BackupManifest>(manifestStream, _serializerSettings).ConfigureAwait(false); diff --git a/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs b/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs new file mode 100644 index 0000000000..c2cb644c59 --- /dev/null +++ b/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs @@ -0,0 +1,506 @@ +#pragma warning disable RS0030 // Do not use banned APIs + +using System; +using System.Collections.Concurrent; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Text.Json; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Extensions; +using Jellyfin.Extensions.Json; +using MediaBrowser.Common; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.LiveTv; +using Microsoft.Extensions.Logging; +using BaseItemDto = MediaBrowser.Controller.Entities.BaseItem; +using BaseItemEntity = Jellyfin.Database.Implementations.Entities.BaseItemEntity; + +namespace Jellyfin.Server.Implementations.Item; + +/// <summary> +/// Handles mapping between BaseItemEntity (database) and BaseItemDto (domain) objects. +/// </summary> +public static class BaseItemMapper +{ + /// <summary> + /// This holds all the types in the running assemblies + /// so that we can de-serialize properly when we don't have strong types. + /// </summary> + private static readonly ConcurrentDictionary<string, Type?> _typeMap = new ConcurrentDictionary<string, Type?>(); + + /// <summary> + /// Maps a Entity to the DTO. + /// </summary> + /// <param name="entity">The entity.</param> + /// <param name="dto">The dto base instance.</param> + /// <param name="appHost">The Application server Host.</param> + /// <returns>The dto to map.</returns> + 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.OriginalLanguage = entity.OriginalLanguage; + 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 = string.IsNullOrWhiteSpace(entity.Genres) ? [] : entity.Genres.Split('|'); + dto.DateCreated = entity.DateCreated ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc); + dto.DateModified = entity.DateModified ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc); + dto.ChannelId = entity.ChannelId ?? Guid.Empty; + dto.DateLastRefreshed = entity.DateLastRefreshed ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc); + dto.DateLastSaved = entity.DateLastSaved ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc); + dto.OwnerId = entity.OwnerId ?? Guid.Empty; + dto.Width = entity.Width.GetValueOrDefault(); + dto.Height = entity.Height.GetValueOrDefault(); + dto.UserData = entity.UserData; + + 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.ProductionLocations = entity.ProductionLocations?.Split('|', StringSplitOptions.RemoveEmptyEntries) ?? []; + dto.Studios = entity.Studios?.Split('|') ?? []; + dto.Tags = string.IsNullOrWhiteSpace(entity.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; + + // The LinkedChildren table is the source of truth for version links + if (entity.LinkedChildEntities is not null) + { + video.LinkedAlternateVersions = entity.LinkedChildEntities + // LocalAlternateVersion links belong to Video.LocalAlternateVersions, not here + .Where(e => e.ChildType == Database.Implementations.Entities.LinkedChildType.LinkedAlternateVersion) + .OrderBy(e => e.SortOrder) + .Select(e => new LinkedChild + { + ItemId = e.ChildId, + Type = (MediaBrowser.Controller.Entities.LinkedChildType)e.ChildType + }) + .ToArray(); + } + } + + 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 => MapImageFromEntity(e, appHost)).ToArray(); + } + + 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 ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc); + if (entity.LinkedChildEntities is not null) + { + folder.LinkedChildren = entity.LinkedChildEntities + .OrderBy(e => e.SortOrder) + .Select(e => new LinkedChild + { + ItemId = e.ChildId, + Type = (MediaBrowser.Controller.Entities.LinkedChildType)e.ChildType + }) + .ToArray(); + } + } + + return dto; + } + + /// <summary> + /// Maps a DTO to a database entity. + /// </summary> + /// <param name="dto">The DTO.</param> + /// <param name="appHost">The application host for path resolution.</param> + /// <returns>The database entity.</returns> + public static BaseItemEntity Map(BaseItemDto dto, IServerApplicationHost appHost) + { + 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, appHost); + 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 = dto.Name.GetCleanValue(); + 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.OriginalLanguage = dto.OriginalLanguage; + 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.Distinct(StringComparer.OrdinalIgnoreCase)); + entity.DateCreated = dto.DateCreated == DateTime.MinValue ? null : dto.DateCreated; + entity.DateModified = dto.DateModified == DateTime.MinValue ? null : dto.DateModified; + entity.ChannelId = dto.ChannelId; + entity.DateLastRefreshed = dto.DateLastRefreshed == DateTime.MinValue ? null : dto.DateLastRefreshed; + entity.DateLastSaved = dto.DateLastSaved == DateTime.MinValue ? null : dto.DateLastSaved; + entity.OwnerId = dto.OwnerId == Guid.Empty ? null : dto.OwnerId; + 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.ProductionLocations = dto.ProductionLocations is not null ? string.Join('|', dto.ProductionLocations.Where(p => !string.IsNullOrWhiteSpace(p)).Distinct(StringComparer.OrdinalIgnoreCase)) : null; + entity.Studios = dto.Studios is not null ? string.Join('|', dto.Studios.Distinct(StringComparer.OrdinalIgnoreCase)) : null; + entity.Tags = dto.Tags is not null ? string.Join('|', dto.Tags.Distinct(StringComparer.OrdinalIgnoreCase)) : 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.Distinct(StringComparer.OrdinalIgnoreCase)) : null; + } + + if (dto is IHasAlbumArtist hasAlbumArtists) + { + entity.AlbumArtists = hasAlbumArtists.AlbumArtists is not null ? string.Join('|', hasAlbumArtists.AlbumArtists.Distinct(StringComparer.OrdinalIgnoreCase)) : null; + } + + if (dto is LiveTvProgram program) + { + entity.ShowId = program.ShowId; + } + + if (dto.ImageInfos is not null) + { + entity.Images = dto.ImageInfos.Select(f => MapImageToEntity(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() ?? []; + } + + 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 == DateTime.MinValue ? null : folder.DateLastMediaAdded; + entity.IsFolder = folder.IsFolder; + } + + return entity; + } + + /// <summary> + /// Maps a database image entity to a domain image info. + /// </summary> + /// <param name="e">The database image entity.</param> + /// <param name="appHost">The application host.</param> + /// <returns>The mapped image info.</returns> + public static ItemImageInfo MapImageFromEntity(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 ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc), + Height = e.Height, + Width = e.Width, + Type = (ImageType)e.ImageType + }; + } + + /// <summary> + /// Maps a domain image info to a database image entity. + /// </summary> + /// <param name="baseItemId">The parent item ID.</param> + /// <param name="e">The image info to map.</param> + /// <returns>The mapped database entity.</returns> + public static BaseItemImageInfo MapImageToEntity(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! + }; + } + + /// <summary> + /// Gets the type from a type name string. + /// </summary> + /// <param name="typeName">The type name.</param> + /// <returns>The resolved type, or null.</returns> + public static Type? GetType(string typeName) + { + ArgumentException.ThrowIfNullOrEmpty(typeName); + + return _typeMap.GetOrAdd(typeName, k => AppDomain.CurrentDomain.GetAssemblies() + .Select(a => a.GetType(k)) + .FirstOrDefault(t => t is not null)); + } + + /// <summary> + /// Checks whether a type requires JSON deserialization. + /// </summary> + /// <param name="type">The type to check.</param> + /// <returns>True if the type requires deserialization.</returns> + public static bool TypeRequiresDeserialization(Type type) + { + return type.GetCustomAttribute<RequiresSourceSerialisationAttribute>() == null; + } + + /// <summary> + /// Deserializes a BaseItemEntity and sets all properties. + /// </summary> + /// <param name="baseItemEntity">The DB entity.</param> + /// <param name="logger">Logger.</param> + /// <param name="appHost">The application server Host.</param> + /// <param name="skipDeserialization">If only mapping should be processed.</param> + /// <returns>A mapped BaseItem, or null if the item type is unknown.</returns> + public static BaseItemDto? DeserializeBaseItem(BaseItemEntity baseItemEntity, ILogger logger, IServerApplicationHost? appHost, bool skipDeserialization = false) + { + var type = GetType(baseItemEntity.Type); + if (type is null) + { + logger.LogWarning( + "Skipping item {ItemId} with unknown type '{ItemType}'. This may indicate a removed plugin or database corruption.", + baseItemEntity.Id, + baseItemEntity.Type); + return null; + } + + 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 deserialize unknown type."); + } + + return Map(baseItemEntity, dto, appHost); + } + + private static string? GetPathToSave(string path, IServerApplicationHost appHost) + { + if (path is null) + { + return null; + } + + return appHost.ReverseVirtualPath(path); + } +} diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs new file mode 100644 index 0000000000..cdc8744642 --- /dev/null +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs @@ -0,0 +1,426 @@ +#pragma warning disable RS0030 // Do not use banned APIs + +using System; +using System.Collections.Generic; +using System.Linq; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Querying; +using Microsoft.EntityFrameworkCore; +using BaseItemDto = MediaBrowser.Controller.Entities.BaseItem; + +namespace Jellyfin.Server.Implementations.Item; + +public sealed partial class BaseItemRepository +{ + /// <inheritdoc /> + public QueryResult<(BaseItemDto Item, ItemCounts? ItemCounts)> GetAllArtists(InternalItemsQuery filter) + { + return GetItemValues(filter, _getAllArtistsValueTypes, _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]); + } + + /// <inheritdoc /> + public QueryResult<(BaseItemDto Item, ItemCounts? ItemCounts)> GetArtists(InternalItemsQuery filter) + { + return GetItemValues(filter, _getArtistValueTypes, _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]); + } + + /// <inheritdoc /> + public QueryResult<(BaseItemDto Item, ItemCounts? ItemCounts)> GetAlbumArtists(InternalItemsQuery filter) + { + return GetItemValues(filter, _getAlbumArtistValueTypes, _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]); + } + + /// <inheritdoc /> + public QueryResult<(BaseItemDto Item, ItemCounts? ItemCounts)> GetStudios(InternalItemsQuery filter) + { + return GetItemValues(filter, _getStudiosValueTypes, _itemTypeLookup.BaseItemKindNames[BaseItemKind.Studio]); + } + + /// <inheritdoc /> + public QueryResult<(BaseItemDto Item, ItemCounts? ItemCounts)> GetGenres(InternalItemsQuery filter) + { + return GetItemValues(filter, _getGenreValueTypes, _itemTypeLookup.BaseItemKindNames[BaseItemKind.Genre]); + } + + /// <inheritdoc /> + public QueryResult<(BaseItemDto Item, ItemCounts? ItemCounts)> GetMusicGenres(InternalItemsQuery filter) + { + return GetItemValues(filter, _getGenreValueTypes, _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicGenre]); + } + + /// <inheritdoc /> + public IReadOnlyList<string> GetStudioNames() + { + return GetItemValueNames(_getStudiosValueTypes, [], []); + } + + /// <inheritdoc /> + public IReadOnlyList<string> GetAllArtistNames() + { + return GetItemValueNames(_getAllArtistsValueTypes, [], []); + } + + /// <inheritdoc /> + public IReadOnlyList<string> GetMusicGenreNames() + { + return GetItemValueNames( + _getGenreValueTypes, + _itemTypeLookup.MusicGenreTypes, + []); + } + + /// <inheritdoc /> + public IReadOnlyList<string> GetGenreNames() + { + return GetItemValueNames( + _getGenreValueTypes, + [], + _itemTypeLookup.MusicGenreTypes); + } + + /// <inheritdoc /> + public IReadOnlyList<string> GetMediaStreamLanguages(InternalItemsQuery filter, MediaStreamType mediaStreamType) + { + ArgumentNullException.ThrowIfNull(filter); + + using var context = _dbProvider.CreateDbContext(); + + return TranslateQuery( + context.BaseItems.Include(e => e.MediaStreams).Where(e => e.Id != EF.Constant(PlaceholderId)), + context, + new InternalItemsQuery(filter.User) + { + IncludeOwnedItems = filter.IncludeOwnedItems, + ExcludeItemTypes = filter.ExcludeItemTypes, + IncludeItemTypes = filter.IncludeItemTypes, + MediaTypes = filter.MediaTypes, + AncestorIds = filter.AncestorIds, + ItemIds = filter.ItemIds, + TopParentIds = filter.TopParentIds, + ParentId = filter.ParentId, + IsAiring = filter.IsAiring, + IsMovie = filter.IsMovie, + IsSports = filter.IsSports, + IsKids = filter.IsKids, + IsNews = filter.IsNews, + IsSeries = filter.IsSeries + }) + .SelectMany(e => e.MediaStreams!) + .Where(e => e.StreamType == (MediaStreamTypeEntity)mediaStreamType) + .Select(s => string.IsNullOrEmpty(s.Language) ? "und" : s.Language) // und = undetermined + .Distinct() + .ToArray(); + } + + private string[] GetItemValueNames(IReadOnlyList<ItemValueType> itemValueTypes, IReadOnlyList<string> withItemTypes, IReadOnlyList<string> excludeItemTypes) + { + using var context = _dbProvider.CreateDbContext(); + + var maps = context.ItemValuesMap.AsNoTracking(); + if (withItemTypes.Count > 0) + { + maps = maps.Where(e => withItemTypes.Contains(e.Item.Type)); + } + + if (excludeItemTypes.Count > 0) + { + maps = maps.Where(e => !excludeItemTypes.Contains(e.Item.Type)); + } + + return context.ItemValues + .AsNoTracking() + .WhereOneOrMany(itemValueTypes, e => e.Type) + .Where(e => maps.Any(m => m.ItemValueId == e.ItemValueId)) + .Select(e => new { e.CleanValue, e.Value }) + .GroupBy(e => e.CleanValue) + .Select(g => g.Min(v => v.Value)!) + .ToArray(); + } + + private QueryResult<(BaseItemDto Item, ItemCounts? ItemCounts)> GetItemValues(InternalItemsQuery filter, IReadOnlyList<ItemValueType> itemValueTypes, string returnType) + { + ArgumentNullException.ThrowIfNull(filter); + + using var context = _dbProvider.CreateDbContext(); + + var innerQueryFilter = TranslateQuery(context.BaseItems.Where(e => e.Id != EF.Constant(PlaceholderId)), context, new InternalItemsQuery(filter.User) + { + ExcludeItemTypes = filter.ExcludeItemTypes, + IncludeItemTypes = filter.IncludeItemTypes, + MediaTypes = filter.MediaTypes, + AncestorIds = filter.AncestorIds, + ItemIds = filter.ItemIds, + TopParentIds = filter.TopParentIds, + ParentId = filter.ParentId, + IsAiring = filter.IsAiring, + IsMovie = filter.IsMovie, + IsSports = filter.IsSports, + IsKids = filter.IsKids, + IsNews = filter.IsNews, + IsSeries = filter.IsSeries + }); + + var innerQuery = PrepareItemQuery(context, filter) + .Where(e => e.Type == returnType) + .Where(e => context.ItemValuesMap + .Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type) && ivm.ItemValue.CleanValue == e.CleanName) + .Join( + innerQueryFilter, + ivm => ivm.ItemId, + g => g.Id, + (ivm, g) => ivm.ItemId) + .Any()); + + var outerQueryFilter = new InternalItemsQuery(filter.User) + { + IsPlayed = filter.IsPlayed, + IsFavorite = filter.IsFavorite, + IsFavoriteOrLiked = filter.IsFavoriteOrLiked, + IsLiked = filter.IsLiked, + IsLocked = filter.IsLocked, + NameLessThan = filter.NameLessThan, + NameStartsWith = filter.NameStartsWith, + NameStartsWithOrGreater = filter.NameStartsWithOrGreater, + Tags = filter.Tags, + OfficialRatings = filter.OfficialRatings, + StudioIds = filter.StudioIds, + GenreIds = filter.GenreIds, + Genres = filter.Genres, + Years = filter.Years, + NameContains = filter.NameContains, + SearchTerm = filter.SearchTerm, + ExcludeItemIds = filter.ExcludeItemIds + }; + + // Collapse rows that share a PresentationUniqueKey (e.g. alternate versions) into one + // representative id per group, then materialize the representative ids once. + var masterQuery = TranslateQuery(innerQuery, context, outerQueryFilter); + var isMusicArtist = returnType == _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]; + List<Guid> representativeIds; + if (isMusicArtist) + { + // For MusicArtist, prefer the entity from a library the user can actually access. + // Materialize to prevent correlated per-group first-row queries which hurt performance. + var topParentIds = filter.TopParentIds; + representativeIds = masterQuery + .Select(e => new { e.Id, e.PresentationUniqueKey, e.TopParentId }) + .AsEnumerable() + .GroupBy(e => e.PresentationUniqueKey) + .Select(g => g + .OrderBy(e => topParentIds.Contains(e.TopParentId ?? Guid.Empty) ? 0 : 1) + .ThenBy(e => e.Id) + .First().Id) + .ToList(); + } + else + { + representativeIds = masterQuery + .GroupBy(e => e.PresentationUniqueKey) + .Select(g => g.Min(e => e.Id)) + .ToList(); + } + + var result = new QueryResult<(BaseItemDto, ItemCounts?)>(); + if (filter.EnableTotalRecordCount) + { + result.TotalRecordCount = representativeIds.Count; + } + + var query = ApplyNavigations( + context.BaseItems.AsNoTracking().AsSingleQuery().WhereOneOrMany(representativeIds, e => e.Id), + filter); + + query = ApplyOrder(query, filter, context); + + if (filter.StartIndex.HasValue && filter.StartIndex.Value > 0) + { + query = query.Skip(filter.StartIndex.Value); + } + + if (filter.Limit.HasValue) + { + query = query.Take(filter.Limit.Value); + } + + result.StartIndex = filter.StartIndex ?? 0; + var page = query.AsEnumerable().Where(e => e is not null).ToList(); + + if (filter.DtoOptions.ContainsField(ItemFields.ItemCounts)) + { + var pageCleanNames = page + .Where(e => !string.IsNullOrEmpty(e.CleanName)) + .Select(e => e.CleanName!) + .Distinct() + .ToList(); + + var countsByCleanName = BuildItemCountsByCleanName(context, filter, itemValueTypes, pageCleanNames); + result.Items = + [ + .. page + .Select(e => + { + var item = DeserializeBaseItem(e, filter.SkipDeserialization); + countsByCleanName.TryGetValue(e.CleanName ?? string.Empty, out var itemCount); + return (item, itemCount); + }) + .Where(x => x.item is not null) + .Select(x => (x.item!, x.itemCount)) + ]; + } + else + { + result.Items = + [ + .. page + .Select(e => DeserializeBaseItem(e, filter.SkipDeserialization)) + .Where(item => item != null) + .Select(item => (item!, (ItemCounts?)null)) + ]; + } + + return result; + } + + private Dictionary<string, ItemCounts> BuildItemCountsByCleanName( + JellyfinDbContext context, + InternalItemsQuery filter, + IReadOnlyList<ItemValueType> itemValueTypes, + IReadOnlyList<string> cleanNames) + { + var countsByCleanName = new Dictionary<string, ItemCounts>(); + if (cleanNames.Count == 0) + { + return countsByCleanName; + } + + // The counts describe everything the value is attached to, not only the types the list was + // filtered down to. + var scopeQuery = new InternalItemsQuery(filter.User) + { + ExcludeItemTypes = filter.ExcludeItemTypes, + MediaTypes = filter.MediaTypes, + AncestorIds = filter.AncestorIds, + ExcludeItemIds = filter.ExcludeItemIds, + ItemIds = filter.ItemIds, + TopParentIds = filter.TopParentIds, + ParentId = filter.ParentId, + IsPlayed = filter.IsPlayed + }; + + var scopedItems = TranslateQuery(context.BaseItems.AsNoTracking().Where(e => e.Id != EF.Constant(PlaceholderId)), context, scopeQuery); + var valueLinks = context.ItemValuesMap + .AsNoTracking() + .Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type)) + .WhereOneOrMany(cleanNames, ivm => ivm.ItemValue.CleanValue); + + var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series]; + var episodeTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode]; + + // Rewrite query to avoid SelectMany on navigation properties (which requires SQL APPLY, not supported on SQLite) + // Instead, start from ItemValueMaps and join with BaseItems. + var rawCounts = valueLinks + .Join( + scopedItems, + ivm => ivm.ItemId, + e => e.Id, + (ivm, e) => new { CleanName = ivm.ItemValue.CleanValue, e.Type, e.SeriesId, e.Id }) + .GroupBy(x => new { x.CleanName, x.Type, x.SeriesId }) + .Select(g => new { g.Key.CleanName, g.Key.Type, g.Key.SeriesId, Count = g.Select(x => x.Id).Distinct().Count() }) + .ToList(); + + // Only studios and genres pass down from a series to its episodes; an artist credit does not. + var inheritsToEpisodes = itemValueTypes.Contains(ItemValueType.Studios) || itemValueTypes.Contains(ItemValueType.Genre); + var episodeCounts = inheritsToEpisodes + ? BuildEpisodeCountsByCleanName( + scopedItems, + valueLinks, + rawCounts + .Where(x => x.Type == episodeTypeName) + .Select(x => (x.CleanName, x.SeriesId, x.Count)) + .ToList(), + seriesTypeName, + episodeTypeName) + : rawCounts + .Where(x => x.Type == episodeTypeName) + .GroupBy(x => x.CleanName) + .ToDictionary(g => g.Key, g => g.Sum(x => x.Count)); + + foreach (var group in rawCounts.GroupBy(x => x.CleanName)) + { + var counts = ItemCountBuilder.Build(_itemTypeLookup, group.Select(row => (row.Type, row.Count))); + + // Episodes are counted separately: the value is usually only written on the series. + ItemCountBuilder.SetEpisodeCount(counts, episodeCounts.GetValueOrDefault(group.Key)); + countsByCleanName[group.Key] = counts; + } + + // A value carried by nothing but the episodes below a tagged series has no row of its own. + foreach (var (cleanName, episodeCount) in episodeCounts) + { + if (!countsByCleanName.ContainsKey(cleanName)) + { + var counts = new ItemCounts(); + ItemCountBuilder.SetEpisodeCount(counts, episodeCount); + countsByCleanName[cleanName] = counts; + } + } + + return countsByCleanName; + } + + private static Dictionary<string, int> BuildEpisodeCountsByCleanName( + IQueryable<BaseItemEntity> scopedItems, + IQueryable<ItemValueMap> valueLinks, + IReadOnlyList<(string CleanName, Guid? SeriesId, int Count)> taggedEpisodes, + string seriesTypeName, + string episodeTypeName) + { + // Resolved in steps rather than as one union: each of these drives off an index, while the + // single-statement form leaves SQLite free to scan every episode in the library instead. + var taggedSeries = valueLinks + .Join( + scopedItems.Where(e => e.Type == seriesTypeName), + ivm => ivm.ItemId, + e => e.Id, + (ivm, e) => new { CleanName = ivm.ItemValue.CleanValue, SeriesId = e.Id }) + .ToList(); + + var seriesIds = taggedSeries.Select(x => x.SeriesId).Distinct().ToArray(); + var episodesPerSeries = seriesIds.Length == 0 + ? [] + : scopedItems + .Where(e => e.Type == episodeTypeName && e.SeriesId != null) + .WhereOneOrMany(seriesIds, e => e.SeriesId!.Value) + .GroupBy(e => e.SeriesId!.Value) + .Select(g => new { SeriesId = g.Key, Count = g.Count() }) + .ToDictionary(x => x.SeriesId, x => x.Count); + + var episodeCounts = new Dictionary<string, int>(); + var seriesByCleanName = new Dictionary<string, HashSet<Guid>>(); + foreach (var group in taggedSeries.GroupBy(x => x.CleanName)) + { + var series = group.Select(x => x.SeriesId).ToHashSet(); + seriesByCleanName[group.Key] = series; + episodeCounts[group.Key] = series.Sum(id => episodesPerSeries.GetValueOrDefault(id)); + } + + foreach (var (cleanName, seriesId, count) in taggedEpisodes) + { + if (seriesId is not null + && seriesByCleanName.TryGetValue(cleanName, out var series) + && series.Contains(seriesId.Value)) + { + continue; + } + + episodeCounts[cleanName] = episodeCounts.GetValueOrDefault(cleanName) + count; + } + + return episodeCounts; + } +} diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs new file mode 100644 index 0000000000..c0067d8392 --- /dev/null +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs @@ -0,0 +1,699 @@ +#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 + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; +using Jellyfin.Extensions; +using Jellyfin.Server.Implementations.Extensions; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Querying; +using Microsoft.EntityFrameworkCore; +using BaseItemEntity = Jellyfin.Database.Implementations.Entities.BaseItemEntity; + +namespace Jellyfin.Server.Implementations.Item; + +public sealed partial class BaseItemRepository +{ + /// <inheritdoc /> + public IQueryable<BaseItemEntity> PrepareItemQuery(JellyfinDbContext context, InternalItemsQuery filter) + { + IQueryable<BaseItemEntity> dbQuery = context.BaseItems.AsNoTracking(); + dbQuery = dbQuery.AsSingleQuery(); + + return dbQuery; + } + + private IQueryable<BaseItemEntity> ApplyQueryFilter(IQueryable<BaseItemEntity> dbQuery, JellyfinDbContext context, InternalItemsQuery filter) + { + dbQuery = TranslateQuery(dbQuery, context, filter); + dbQuery = ApplyGroupingFilter(context, dbQuery, filter); + dbQuery = ApplyAdjacencyFilter(context, dbQuery, filter); + dbQuery = ApplyQueryPaging(dbQuery, filter); + dbQuery = ApplyNavigations(dbQuery, filter); + return dbQuery; + } + + /// <summary> + /// Trims an ordered query down to the AdjacentTo item and its immediate neighbours. + /// </summary> + private IQueryable<BaseItemEntity> ApplyAdjacencyFilter(JellyfinDbContext context, IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter) + { + if (filter.AdjacentTo.IsNullOrEmpty()) + { + return dbQuery; + } + + // Adjacency is relative to the result set and the order the query asked for, so the ids have + // to be read back in that order. + var orderedIds = dbQuery.Select(e => e.Id).ToList(); + var index = orderedIds.IndexOf(filter.AdjacentTo.Value); + if (index < 0) + { + // The item isn't part of this result set, so it has no neighbours in it either. + return dbQuery.Take(0); + } + + var start = Math.Max(index - 1, 0); + var adjacentIds = orderedIds.GetRange(start, Math.Min(index + 2, orderedIds.Count) - start); + + var adjacentQuery = context.BaseItems.AsNoTracking().AsSingleQuery().Where(e => adjacentIds.Contains(e.Id)); + + return ApplyOrder(adjacentQuery, filter, context); + } + + private IQueryable<BaseItemEntity> ApplyQueryPaging(IQueryable<BaseItemEntity> 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<BaseItemEntity> ApplyGroupingFilter(JellyfinDbContext context, IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter) + { + // Collapse duplicates sharing a presentation key (e.g. alternate versions), preferring the + // primary version (PrimaryVersionId is null) so detail pages and actions target it instead + // of an arbitrary alternate. Keep the grouped ids as an IQueryable sub-select; materializing + // to a List would inline one bound parameter per id and hit SQLite's variable cap. + var enableGroupByPresentationUniqueKey = EnableGroupByPresentationUniqueKey(filter); + if (enableGroupByPresentationUniqueKey && filter.GroupBySeriesPresentationUniqueKey) + { + var groupedIds = dbQuery.GroupBy(e => new { e.PresentationUniqueKey, e.SeriesPresentationUniqueKey }) + .Select(g => g.Where(e => e.PrimaryVersionId == null).Min(e => (Guid?)e.Id) ?? g.Min(e => (Guid?)e.Id)); + dbQuery = context.BaseItems.AsNoTracking().Where(e => groupedIds.Contains(e.Id)); + } + else if (enableGroupByPresentationUniqueKey) + { + var groupedIds = dbQuery.GroupBy(e => e.PresentationUniqueKey) + .Select(g => g.Where(e => e.PrimaryVersionId == null).Min(e => (Guid?)e.Id) ?? g.Min(e => (Guid?)e.Id)); + dbQuery = context.BaseItems.AsNoTracking().Where(e => groupedIds.Contains(e.Id)); + } + else if (filter.GroupBySeriesPresentationUniqueKey) + { + var groupedIds = dbQuery.GroupBy(e => e.SeriesPresentationUniqueKey).Select(e => e.Min(x => x.Id)); + dbQuery = context.BaseItems.AsNoTracking().Where(e => groupedIds.Contains(e.Id)); + } + else + { + dbQuery = dbQuery.Distinct(); + } + + if (filter.CollapseBoxSetItems == true) + { + dbQuery = ApplyBoxSetCollapsing(context, dbQuery, filter.CollapseBoxSetItemTypes); + + // Name filters run after collapse so BoxSets match by their own name, not a child's. + dbQuery = ApplyNameFilters(dbQuery, filter); + } + + dbQuery = ApplyOrder(dbQuery, filter, context); + + return dbQuery; + } + + private IQueryable<BaseItemEntity> ApplyBoxSetCollapsing( + JellyfinDbContext context, + IQueryable<BaseItemEntity> dbQuery, + BaseItemKind[] collapsibleTypes) + { + var boxSetTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.BoxSet]; + + var currentIds = dbQuery.Select(e => e.Id); + + if (collapsibleTypes.Length == 0) + { + // Collapse all item types into box sets + return ApplyBoxSetCollapsingAll(context, currentIds, boxSetTypeName); + } + + // Only collapse specific item types, keep others untouched + var collapsibleTypeNames = collapsibleTypes.Select(t => _itemTypeLookup.BaseItemKindNames[t]).ToList(); + + // Categorize items in currentIds in a single pass to avoid multiple correlated EXISTS over BaseItems. + var categorized = context.BaseItems + .AsNoTracking() + .Where(bi => currentIds.Contains(bi.Id)) + .Select(bi => new + { + bi.Id, + IsCollapsible = collapsibleTypeNames.Contains(bi.Type), + IsBoxSet = bi.Type == boxSetTypeName + }); + + var collapsibleChildIds = categorized.Where(c => c.IsCollapsible).Select(c => c.Id); + + // Single JOIN: manual links to BoxSet parents, restricted to currentIds children. + var manualBoxSetLinks = context.LinkedChildren + .Where(lc => lc.ChildType == Database.Implementations.Entities.LinkedChildType.Manual + && currentIds.Contains(lc.ChildId)) + .Join( + context.BaseItems.Where(bs => bs.Type == boxSetTypeName), + lc => lc.ParentId, + bs => bs.Id, + (lc, bs) => new { lc.ChildId, lc.ParentId }); + + var childrenInBoxSet = manualBoxSetLinks.Select(x => x.ChildId).Distinct(); + + // Items whose type is NOT collapsible (always kept in results) + var nonCollapsibleIds = categorized.Where(c => !c.IsCollapsible).Select(c => c.Id); + + // Collapsible items that are not a BoxSet themselves and not a manual child of any BoxSet + var collapsibleNotInBoxSet = categorized + .Where(c => c.IsCollapsible && !c.IsBoxSet) + .Select(c => c.Id) + .Where(id => !childrenInBoxSet.Contains(id)); + + // BoxSet IDs containing at least one collapsible child item from currentIds + var boxSetIds = manualBoxSetLinks + .Where(x => collapsibleChildIds.Contains(x.ChildId)) + .Select(x => x.ParentId) + .Distinct(); + + var collapsedIds = nonCollapsibleIds.Union(collapsibleNotInBoxSet).Union(boxSetIds); + return context.BaseItems.AsNoTracking().Where(e => collapsedIds.Contains(e.Id)); + } + + private static IQueryable<BaseItemEntity> ApplyBoxSetCollapsingAll( + JellyfinDbContext context, + IQueryable<Guid> currentIds, + string boxSetTypeName) + { + // Single JOIN: manual links to BoxSet parents, restricted to currentIds children. + var manualBoxSetLinks = context.LinkedChildren + .Where(lc => lc.ChildType == Database.Implementations.Entities.LinkedChildType.Manual + && currentIds.Contains(lc.ChildId)) + .Join( + context.BaseItems.Where(bs => bs.Type == boxSetTypeName), + lc => lc.ParentId, + bs => bs.Id, + (lc, bs) => new { lc.ChildId, lc.ParentId }); + + var childrenInBoxSet = manualBoxSetLinks.Select(x => x.ChildId).Distinct(); + var boxSetIds = manualBoxSetLinks.Select(x => x.ParentId).Distinct(); + + // Items in currentIds that are not BoxSets themselves and not a manual child of any BoxSet + var notInBoxSet = context.BaseItems + .AsNoTracking() + .Where(e => currentIds.Contains(e.Id) && e.Type != boxSetTypeName) + .Select(e => e.Id) + .Where(id => !childrenInBoxSet.Contains(id)); + + var collapsedIds = notInBoxSet.Union(boxSetIds); + return context.BaseItems.AsNoTracking().Where(e => collapsedIds.Contains(e.Id)); + } + + private static IQueryable<BaseItemEntity> ApplyNameFilters(IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter) + { + if (!string.IsNullOrWhiteSpace(filter.NameStartsWith)) + { + var nameStartsWithLower = filter.NameStartsWith.ToLowerInvariant(); + dbQuery = dbQuery.Where(e => e.SortName!.ToLower().StartsWith(nameStartsWithLower)); + } + + if (!string.IsNullOrWhiteSpace(filter.NameStartsWithOrGreater)) + { + var startsOrGreaterLower = filter.NameStartsWithOrGreater.ToLowerInvariant(); + dbQuery = dbQuery.Where(e => e.SortName!.ToLower().CompareTo(startsOrGreaterLower) >= 0); + } + + if (!string.IsNullOrWhiteSpace(filter.NameLessThan)) + { + var lessThanLower = filter.NameLessThan.ToLowerInvariant(); + dbQuery = dbQuery.Where(e => e.SortName!.ToLower().CompareTo(lessThanLower) < 0); + } + + return dbQuery; + } + + /// <inheritdoc /> + public IQueryable<BaseItemEntity> ApplyNavigations(IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter) + { + if (filter.TrailerTypes.Length > 0 || filter.IncludeItemTypes.Contains(BaseItemKind.Trailer)) + { + dbQuery = dbQuery.Include(e => e.TrailerTypes); + } + + if (filter.DtoOptions.ContainsField(ItemFields.ProviderIds)) + { + dbQuery = dbQuery.Include(e => e.Provider); + } + + if (filter.DtoOptions.ContainsField(ItemFields.Settings)) + { + dbQuery = dbQuery.Include(e => e.LockedFields); + } + + if (filter.DtoOptions.EnableUserData) + { + dbQuery = dbQuery.Include(e => e.UserData); + } + + if (filter.DtoOptions.EnableImages) + { + dbQuery = dbQuery.Include(e => e.Images); + } + + // Include LinkedChildEntities for container types and videos that use them (BoxSet, Playlist, + // CollectionFolder for manual linking; every video type for alternate versions). + // When IncludeItemTypes is empty (any type may be returned), always include them to ensure + // LinkedChildren are loaded before items are saved back, preventing accidental deletion. + var linkedChildTypes = new[] + { + BaseItemKind.BoxSet, + BaseItemKind.Playlist, + BaseItemKind.CollectionFolder, + BaseItemKind.Video, + BaseItemKind.Movie, + BaseItemKind.Episode, + BaseItemKind.MusicVideo, + BaseItemKind.Trailer + }; + if (filter.IncludeItemTypes.Length == 0 || filter.IncludeItemTypes.Any(linkedChildTypes.Contains)) + { + dbQuery = dbQuery.Include(e => e.LinkedChildEntities); + } + + if (filter.IncludeExtras) + { + dbQuery = dbQuery.Include(e => e.Extras); + } + + return dbQuery; + } + + /// <inheritdoc /> + public IQueryable<BaseItemEntity> ApplyOrder(IQueryable<BaseItemEntity> query, InternalItemsQuery filter, JellyfinDbContext context) + { + var orderBy = filter.OrderBy.Where(e => e.OrderBy != ItemSortBy.Default).ToArray(); + var hasSearch = !string.IsNullOrEmpty(filter.SearchTerm); + + // SeriesDatePlayed requires special handling to avoid correlated subqueries. + // Instead of running a MAX() subquery per-row in ORDER BY, we pre-aggregate + // max played dates per series in one query and left-join it. + if (!hasSearch && orderBy.Any(o => o.OrderBy == ItemSortBy.SeriesDatePlayed)) + { + return ApplySeriesDatePlayedOrder(query, filter, context, orderBy); + } + + IOrderedQueryable<BaseItemEntity>? orderedQuery = null; + + if (hasSearch) + { + var relevanceExpression = OrderMapper.MapSearchRelevanceOrder(filter.SearchTerm!); + orderedQuery = query.OrderBy(relevanceExpression); + } + + // Folders carry no played flag of their own, so these two keys go through the same predicate + // the isPlayed filter uses rather than through the stored-column lookup in OrderMapper. + Expression<Func<BaseItemEntity, object?>> MapOrderByField(ItemSortBy sortBy) => sortBy switch + { + ItemSortBy.IsPlayed when filter.User is not null + => AsOrderKey(BuildIsPlayedFilter(context, filter.User)), + ItemSortBy.IsUnplayed when filter.User is not null + => AsOrderKey(BuildIsPlayedFilter(context, filter.User).Not()), + _ => OrderMapper.MapOrderByField(sortBy, filter, context) + }; + + if (orderBy.Length > 0) + { + var firstOrdering = orderBy[0]; + var expression = MapOrderByField(firstOrdering.OrderBy); + + if (orderedQuery is null) + { + orderedQuery = firstOrdering.SortOrder == SortOrder.Ascending + ? query.OrderBy(expression) + : query.OrderByDescending(expression); + } + else + { + orderedQuery = firstOrdering.SortOrder == SortOrder.Ascending + ? orderedQuery.ThenBy(expression) + : orderedQuery.ThenByDescending(expression); + } + + if (firstOrdering.OrderBy is ItemSortBy.Default or ItemSortBy.SortName) + { + orderedQuery = firstOrdering.SortOrder == SortOrder.Ascending + ? orderedQuery.ThenBy(e => e.Name) + : orderedQuery.ThenByDescending(e => e.Name); + } + + foreach (var item in orderBy.Skip(1)) + { + expression = MapOrderByField(item.OrderBy); + orderedQuery = item.SortOrder == SortOrder.Ascending + ? orderedQuery.ThenBy(expression) + : orderedQuery.ThenByDescending(expression); + } + } + + if (orderedQuery is null) + { + return query.OrderBy(e => e.SortName); + } + + // Add SortName as final tiebreaker + if (!hasSearch && (orderBy.Length == 0 || orderBy.All(o => o.OrderBy is not ItemSortBy.SortName and not ItemSortBy.Name))) + { + orderedQuery = orderedQuery.ThenBy(e => e.SortName); + } + + return orderedQuery; + } + + private IQueryable<BaseItemEntity> ApplySeriesDatePlayedOrder( + IQueryable<BaseItemEntity> query, + InternalItemsQuery filter, + JellyfinDbContext context, + (ItemSortBy OrderBy, SortOrder SortOrder)[] orderBy) + { + // Pre-aggregate max played date per series key in ONE query. + // This generates a single: SELECT SeriesPresentationUniqueKey, MAX(LastPlayedDate) ... GROUP BY + // instead of a correlated subquery per outer row. + IQueryable<UserData> userDataQuery = filter.User is not null + ? context.UserData.Where(ud => ud.UserId == filter.User.Id && ud.Played) + : context.UserData.Where(ud => ud.Played); + + var seriesMaxDates = userDataQuery + .Join( + context.BaseItems, + ud => ud.ItemId, + bi => bi.Id, + (ud, bi) => new { bi.SeriesPresentationUniqueKey, ud.LastPlayedDate }) + .Where(x => x.SeriesPresentationUniqueKey != null) + .GroupBy(x => x.SeriesPresentationUniqueKey) + .Select(g => new { SeriesKey = g.Key!, MaxDate = g.Max(x => x.LastPlayedDate) }); + + var joined = query.LeftJoin( + seriesMaxDates, + e => e.PresentationUniqueKey, + s => s.SeriesKey, + (e, s) => new { Item = e, MaxDate = s != null ? s.MaxDate : (DateTime?)null }); + + var seriesSort = orderBy.First(o => o.OrderBy == ItemSortBy.SeriesDatePlayed); + + return seriesSort.SortOrder == SortOrder.Ascending + ? joined.OrderBy(x => x.MaxDate).ThenBy(x => x.Item.SortName).Select(x => x.Item) + : joined.OrderByDescending(x => x.MaxDate).ThenBy(x => x.Item.SortName).Select(x => x.Item); + } + + /// <summary> + /// Builds a query for descendants of an ancestor with user access filtering applied. + /// Uses recursive CTE to traverse both hierarchical (AncestorIds) and linked (LinkedChildren) relationships. + /// </summary> + /// <inheritdoc /> + public IQueryable<BaseItemEntity> BuildAccessFilteredDescendantsQuery( + JellyfinDbContext context, + InternalItemsQuery filter, + Guid ancestorId) + { + // Use recursive CTE to get all descendants (hierarchical and linked) + var allDescendantIds = DescendantQueryHelper.GetAllDescendantIds(context, ancestorId); + + var baseQuery = context.BaseItems + .AsNoTracking() + .Where(b => allDescendantIds.Contains(b.Id)) + .Where(DescendantQueryHelper.IsCountableLeaf); + + return ApplyAccessFiltering(context, baseQuery, filter); + } + + /// <summary> + /// Checks whether the user restricts access to items by parental rating or tags. + /// </summary> + /// <param name="filter">The query filter.</param> + /// <returns><c>true</c> if the query carries parental restrictions.</returns> + private static bool RequiresParentalRestrictions(InternalItemsQuery filter) + => filter.IncludeInheritedTags.Length > 0 + || filter.ExcludeInheritedTags.Length > 0 + || filter.MaxParentalRating is not null + || filter.BlockUnratedItems.Length > 0; + + /// <summary> + /// Applies user access filtering to a query. + /// Includes TopParentIds, parental rating, and tag filtering. + /// </summary> + /// <inheritdoc /> + public IQueryable<BaseItemEntity> ApplyAccessFiltering( + JellyfinDbContext context, + IQueryable<BaseItemEntity> baseQuery, + InternalItemsQuery filter) + { + baseQuery = ApplyTopParentFiltering(context, baseQuery, filter); + + baseQuery = ApplyParentalRestrictions(context, baseQuery, filter); + + // Exclude alternate versions (have PrimaryVersionId set) and owned non-extra items. + // Extras (trailers, etc.) have OwnerId set but also have ExtraType set — keep those. + if (!filter.IncludeOwnedItems) + { + baseQuery = baseQuery.Where(e => e.PrimaryVersionId == null && (e.OwnerId == null || e.ExtraType != null)); + } + + return baseQuery; + } + + /// <summary> + /// Restricts a query to the libraries the user may open, exempting requested by-name items. + /// </summary> + /// <param name="context">The database context.</param> + /// <param name="baseQuery">The query to filter.</param> + /// <param name="filter">The query filter.</param> + /// <returns>The filtered query.</returns> + private IQueryable<BaseItemEntity> ApplyTopParentFiltering( + JellyfinDbContext context, + IQueryable<BaseItemEntity> baseQuery, + InternalItemsQuery filter) + { + var queryTopParentIds = filter.TopParentIds; + if (queryTopParentIds.Length == 0) + { + return baseQuery; + } + + var exemptedItemByNameTypes = GetExemptedItemByNameTypes(filter); + if (exemptedItemByNameTypes.Count == 0) + { + return baseQuery.WhereOneOrMany(queryTopParentIds, e => e.TopParentId!.Value); + } + + baseQuery = baseQuery.Where(e => exemptedItemByNameTypes.Contains(e.Type) || queryTopParentIds.Any(w => w == e.TopParentId!.Value)); + if (filter.UserHasContentRestrictions) + { + baseQuery = ApplyItemByNameAccessFiltering(baseQuery, context, filter, exemptedItemByNameTypes, queryTopParentIds); + } + + return baseQuery; + } + + /// <summary> + /// Returns the by-name types a query asks for, which carry no TopParentId to filter on. + /// </summary> + /// <param name="filter">The query filter.</param> + /// <returns>The type names exempt from library filtering.</returns> + private List<string> GetExemptedItemByNameTypes(InternalItemsQuery filter) + { + var includedItemByNameTypes = GetItemByNameTypesInQuery(filter); + if ((filter.IncludeItemsByName ?? false) && includedItemByNameTypes.Count > 0) + { + return includedItemByNameTypes; + } + + return _itemByNameKinds.Where(filter.IncludeItemTypes.Contains).Select(e => _itemTypeLookup.BaseItemKindNames[e]!).ToList(); + } + + /// <summary> + /// Keeps a by-name row only when at least one item behind its name is reachable for the user. + /// </summary> + /// <param name="baseQuery">The query to filter.</param> + /// <param name="context">The database context.</param> + /// <param name="filter">The query filter.</param> + /// <param name="itemByNameTypes">The exempted by-name type names.</param> + /// <param name="topParentIds">The libraries the user may open.</param> + /// <returns>The filtered query.</returns> + private IQueryable<BaseItemEntity> ApplyItemByNameAccessFiltering( + IQueryable<BaseItemEntity> baseQuery, + JellyfinDbContext context, + InternalItemsQuery filter, + IReadOnlyList<string> itemByNameTypes, + Guid[] topParentIds) + { + // IncludeOwnedItems: a credit on an alternate version of a reachable movie still counts. + var accessibleItems = ApplyAccessFiltering( + context, + context.BaseItems.AsNoTracking(), + new InternalItemsQuery(filter.User) { TopParentIds = topParentIds, IncludeOwnedItems = true }); + + var personType = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Person]; + if (itemByNameTypes.Contains(personType)) + { + baseQuery = baseQuery.Where(e => e.Type != personType + || context.Peoples.Any(p => p.Name == e.Name + && context.PeopleBaseItemMap.Any(m => m.PeopleId == p.Id && accessibleItems.Any(i => i.Id == m.ItemId)))); + } + + foreach (var (kind, valueTypes) in _itemByNameValueTypes) + { + var typeName = _itemTypeLookup.BaseItemKindNames[kind]; + if (!itemByNameTypes.Contains(typeName)) + { + continue; + } + + baseQuery = baseQuery.Where(e => e.Type != typeName + || context.ItemValues.Any(v => valueTypes.Contains(v.Type) && v.CleanValue == e.CleanName + && context.ItemValuesMap.Any(m => m.ItemValueId == v.ItemValueId && accessibleItems.Any(i => i.Id == m.ItemId)))); + } + + return baseQuery; + } + + /// <summary> + /// Applies the user's parental rating and tag restrictions to a query. + /// </summary> + /// <param name="context">The database context.</param> + /// <param name="baseQuery">The query to filter.</param> + /// <param name="filter">The query filter.</param> + /// <returns>The filtered query.</returns> + private IQueryable<BaseItemEntity> ApplyParentalRestrictions( + JellyfinDbContext context, + IQueryable<BaseItemEntity> baseQuery, + InternalItemsQuery filter) + { + // Apply parental rating filtering + if (filter.MaxParentalRating is not null) + { + baseQuery = baseQuery.Where(BuildMaxParentalRatingFilter(context, filter.MaxParentalRating)); + } + + // Apply block unrated items filtering + if (filter.BlockUnratedItems.Length > 0) + { + var unratedItemTypes = filter.BlockUnratedItems.Select(f => f.ToString()).ToArray(); + baseQuery = baseQuery.Where(e => + e.InheritedParentalRatingValue != null || !unratedItemTypes.Contains(e.UnratedType)); + } + + // Apply excluded tags filtering (blocked tags). + // Pre-build the blocked-item-id set as a sub-select; then four index-seek Contains checks + // instead of one EXISTS over a 4-way OR predicate that defeats index seeks. + if (filter.ExcludeInheritedTags.Length > 0) + { + var excludedTags = filter.ExcludeInheritedTags.Select(e => e.GetCleanValue()).ToArray(); + var blockedTagItemIds = context.ItemValuesMap + .Where(f => f.ItemValue.Type == ItemValueType.Tags && excludedTags.Contains(f.ItemValue.CleanValue)) + .Select(f => f.ItemId); + + baseQuery = baseQuery.Where(e => + !blockedTagItemIds.Contains(e.Id) + && !(e.SeriesId.HasValue && blockedTagItemIds.Contains(e.SeriesId.Value)) + && !e.Parents!.Any(p => blockedTagItemIds.Contains(p.ParentItemId)) + && !(e.TopParentId.HasValue && blockedTagItemIds.Contains(e.TopParentId.Value))); + } + + // Apply included tags filtering (allowed tags - item must have at least one). + if (filter.IncludeInheritedTags.Length > 0) + { + var includeTags = filter.IncludeInheritedTags.Select(e => e.GetCleanValue()).ToArray(); + var personTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Person]; + var allowedTagItemIds = context.ItemValuesMap + .Where(f => f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue)) + .Select(f => f.ItemId); + + baseQuery = baseQuery.Where(e => + allowedTagItemIds.Contains(e.Id) + || (e.SeriesId.HasValue && allowedTagItemIds.Contains(e.SeriesId.Value)) + || e.Parents!.Any(p => allowedTagItemIds.Contains(p.ParentItemId)) + || (e.TopParentId.HasValue && allowedTagItemIds.Contains(e.TopParentId.Value)) + + // People don't carry the tags of the media they appear in and would never match + || e.Type == personTypeName); + } + + return baseQuery; + } + + /// <summary> + /// Builds a filter expression for max parental rating that handles both rated items + /// and unrated BoxSets/Playlists (which check linked children's ratings). + /// </summary> + private static Expression<Func<BaseItemEntity, bool>> BuildMaxParentalRatingFilter( + JellyfinDbContext context, + ParentalRatingScore maxRating) + { + var maxScore = maxRating.Score; + var maxSubScore = maxRating.SubScore ?? 0; + var linkedChildren = context.LinkedChildren; + + return e => + // Item has a rating: check against limit + (e.InheritedParentalRatingValue != null + && (e.InheritedParentalRatingValue < maxScore + || (e.InheritedParentalRatingValue == maxScore && (e.InheritedParentalRatingSubValue ?? 0) <= maxSubScore))) + // Item has no rating + || (e.InheritedParentalRatingValue == null + && ( + // No linked children (not a BoxSet/Playlist): pass as unrated + !linkedChildren.Any(lc => lc.ParentId == e.Id) + // Has linked children: at least one child must be within limits + || linkedChildren.Any(lc => lc.ParentId == e.Id + && (lc.Child!.InheritedParentalRatingValue == null + || lc.Child.InheritedParentalRatingValue < maxScore + || (lc.Child.InheritedParentalRatingValue == maxScore + && (lc.Child.InheritedParentalRatingSubValue ?? 0) <= maxSubScore))))); + } + + /// <inheritdoc /> + public IQueryable<BaseItemEntity> GetAccessFilteredLeafItemsQuery(JellyfinDbContext context, User user, bool includeOwnedItems = false) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(user); + + var leafItems = context.BaseItems + .AsNoTracking() + .Where(DescendantQueryHelper.IsCountableLeaf); + + return ApplyAccessFiltering(context, leafItems, new InternalItemsQuery(user) { IncludeOwnedItems = includeOwnedItems }); + } + + private static Expression<Func<BaseItemEntity, object?>> AsOrderKey(Expression<Func<BaseItemEntity, bool>> predicate) + => Expression.Lambda<Func<BaseItemEntity, object?>>(Expression.Convert(predicate.Body, typeof(object)), predicate.Parameters); + + /// <inheritdoc /> + public Expression<Func<BaseItemEntity, bool>> BuildHasDescendantFilter(JellyfinDbContext context, IQueryable<BaseItemEntity> descendants) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(descendants); + + // Descendants are reachable through the ancestor chain and - for BoxSets and Playlists - as + // linked children, which can themselves be folders contributing their own descendants. + // Every step is a correlated index seek, so only the rows the outer query keeps are visited + // and a folder is left as soon as its first matching descendant is found. + return e => context.AncestorIds.Any(a => a.ParentItemId == e.Id && descendants.Any(d => d.Id == a.ItemId)) + || context.LinkedChildren.Any(lc => lc.ParentId == e.Id + && (descendants.Any(d => d.Id == lc.ChildId) + || context.AncestorIds.Any(a => a.ParentItemId == lc.ChildId && descendants.Any(d => d.Id == a.ItemId)))); + } +} diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs new file mode 100644 index 0000000000..1ed10cce2b --- /dev/null +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs @@ -0,0 +1,662 @@ +#pragma warning disable RS0030 // Do not use banned APIs + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Extensions; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Querying; +using Microsoft.EntityFrameworkCore; +using BaseItemDto = MediaBrowser.Controller.Entities.BaseItem; + +namespace Jellyfin.Server.Implementations.Item; + +public sealed partial class BaseItemRepository +{ + /// <inheritdoc /> + public IReadOnlyList<Guid> GetItemIdsList(InternalItemsQuery filter) + { + ArgumentNullException.ThrowIfNull(filter); + PrepareFilterQuery(filter); + + using var context = _dbProvider.CreateDbContext(); + return ApplyQueryFilter(context.BaseItems.AsNoTracking().Where(e => e.Id != EF.Constant(PlaceholderId)), context, filter).Select(e => e.Id).ToArray(); + } + + /// <inheritdoc /> + public QueryResult<BaseItemDto> GetItems(InternalItemsQuery filter) + { + ArgumentNullException.ThrowIfNull(filter); + if (!filter.EnableTotalRecordCount || (!filter.Limit.HasValue && (filter.StartIndex ?? 0) == 0)) + { + var returnList = GetItemList(filter); + return new QueryResult<BaseItemDto>( + filter.StartIndex, + returnList.Count, + returnList); + } + + PrepareFilterQuery(filter); + var result = new QueryResult<BaseItemDto>(); + + using var context = _dbProvider.CreateDbContext(); + + IQueryable<BaseItemEntity> dbQuery = PrepareItemQuery(context, filter); + + dbQuery = TranslateQuery(dbQuery, context, filter); + dbQuery = ApplyGroupingFilter(context, dbQuery, filter); + dbQuery = ApplyAdjacencyFilter(context, dbQuery, filter); + + if (filter.EnableTotalRecordCount) + { + result.TotalRecordCount = dbQuery.Count(); + } + + dbQuery = ApplyQueryPaging(dbQuery, filter); + dbQuery = ApplyNavigations(dbQuery, filter); + + result.Items = dbQuery.AsEnumerable().Where(e => e != null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).Where(dto => dto != null).ToArray()!; + result.StartIndex = filter.StartIndex ?? 0; + return result; + } + + /// <inheritdoc /> + public IReadOnlyList<BaseItemDto> GetItemList(InternalItemsQuery filter) + { + ArgumentNullException.ThrowIfNull(filter); + PrepareFilterQuery(filter); + + using var context = _dbProvider.CreateDbContext(); + IQueryable<BaseItemEntity> dbQuery = PrepareItemQuery(context, filter); + + dbQuery = TranslateQuery(dbQuery, context, filter); + + dbQuery = ApplyGroupingFilter(context, dbQuery, filter); + dbQuery = ApplyAdjacencyFilter(context, dbQuery, filter); + dbQuery = ApplyQueryPaging(dbQuery, filter); + + var hasRandomSort = filter.OrderBy.Any(e => e.OrderBy == ItemSortBy.Random); + if (hasRandomSort) + { + var orderedIds = dbQuery.AsNoTracking().Select(e => e.Id).ToList(); + if (orderedIds.Count == 0) + { + return Array.Empty<BaseItemDto>(); + } + + var itemsById = ApplyNavigations(context.BaseItems.AsNoTracking().WhereOneOrMany(orderedIds, e => e.Id), filter) + .AsSplitQuery() + .AsEnumerable() + .Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)) + .Where(dto => dto != null) + .ToDictionary(i => i!.Id); + + return orderedIds.Where(itemsById.ContainsKey).Select(id => itemsById[id]).ToArray()!; + } + + dbQuery = ApplyNavigations(dbQuery, filter); + + return dbQuery.AsEnumerable().Where(e => e != null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).Where(dto => dto != null).ToArray()!; + } + + /// <inheritdoc/> + public IReadOnlyList<BaseItemDto> GetLatestItemList(InternalItemsQuery filter, CollectionType collectionType) + { + ArgumentNullException.ThrowIfNull(filter); + PrepareFilterQuery(filter); + + // Early exit if collection type is not supported + if (collectionType is not CollectionType.movies and not CollectionType.tvshows and not CollectionType.music and not CollectionType.unknown) + { + return []; + } + + var limit = filter.Limit; + using var context = _dbProvider.CreateDbContext(); + + var baseQuery = PrepareItemQuery(context, filter); + baseQuery = TranslateQuery(baseQuery, context, filter); + + if (collectionType is CollectionType.tvshows) + { + return GetLatestTvShowItems(context, baseQuery, filter, limit); + } + + if (collectionType is CollectionType.movies) + { + return GetLatestMovieItems(context, baseQuery, filter, limit); + } + + if (collectionType is CollectionType.unknown) + { + var moviesQuery = baseQuery.Where(e => e.SeriesName == null); + var latestMovies = GetLatestMovieItems(context, moviesQuery, filter, limit); + var latestShows = GetLatestTvShowItems(context, baseQuery, filter, limit); + + return latestMovies.Concat(latestShows) + .OrderByDescending(dto => dto.DateCreated) + .ThenByDescending(dto => dto.Id) + .Take(limit ?? int.MaxValue) + .ToList(); + } + + var musicAlbumTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicAlbum]!; + IQueryable<BaseItemEntity> topAlbumsQuery; + + // When the query is scoped to whole libraries, read the newest albums directly by their own TopParentId. + if (filter.TopParentIds.Length > 0) + { + topAlbumsQuery = context.BaseItems.AsNoTracking() + .Where(album => album.Type == musicAlbumTypeName + && !album.IsVirtualItem + && album.TopParentId.HasValue) + .WhereOneOrMany(filter.TopParentIds, album => album.TopParentId!.Value); + } + else + { + // Fallback (e.g. AncestorIds-scoped callers): albums that are the parent of a matching track. + var albumIdsWithMatchingTrack = context.AncestorIds + .Join(baseQuery, ai => ai.ItemId, t => t.Id, (ai, _) => ai.ParentItemId); + topAlbumsQuery = context.BaseItems.AsNoTracking() + .Where(album => album.Type == musicAlbumTypeName) + .Where(album => albumIdsWithMatchingTrack.Contains(album.Id)); + } + + // The album is what gets returned, and neither branch above reads it through the + // user's filters, so its own parental restrictions have to be applied here: a + // matching track does not make an album the user may not see visible. + var orderedAlbums = ApplyParentalRestrictions(context, topAlbumsQuery, filter) + .OrderByDescending(album => album.DateCreated) + .ThenByDescending(album => album.Id); + + var albumIdsQuery = limit.HasValue + ? orderedAlbums.Take(limit.Value).Select(a => a.Id) + : orderedAlbums.Select(a => a.Id); + + return LoadLatestByIds(context, albumIdsQuery, filter); + } + + // Keeping idsQuery deferred lets EF emit `WHERE Id IN (<subquery>)`. + private IReadOnlyList<BaseItemDto> LoadLatestByIds( + JellyfinDbContext context, + IQueryable<Guid> idsQuery, + InternalItemsQuery filter) + { + var itemsQuery = ApplyNavigations( + context.BaseItems.AsNoTracking().Where(e => idsQuery.Contains(e.Id)), + filter); + + return itemsQuery + .OrderByDescending(e => e.DateCreated) + .ThenByDescending(e => e.Id) + .AsEnumerable() + .Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)) + .Where(dto => dto != null) + .ToArray()!; + } + + private IReadOnlyList<BaseItemDto> LoadLatestByIds( + JellyfinDbContext context, + List<Guid> ids, + InternalItemsQuery filter) + { + if (ids.Count == 0) + { + return []; + } + + var itemsQuery = ApplyNavigations( + context.BaseItems.AsNoTracking().WhereOneOrMany(ids, e => e.Id), + filter); + + return itemsQuery + .OrderByDescending(e => e.DateCreated) + .ThenByDescending(e => e.Id) + .AsEnumerable() + .Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)) + .Where(dto => dto != null) + .ToArray()!; + } + + /// <summary> + /// Gets the latest movies, deduplicated so each movie only appears once. + /// </summary> + /// <param name="context">The database context.</param> + /// <param name="baseQuery">The query to pull movies from, with filters already applied.</param> + /// <param name="filter">The original query filter, used when loading the final items.</param> + /// <param name="limit">How many items to return.</param> + /// <returns>The latest movies, newest first.</returns> + private IReadOnlyList<BaseItemDto> GetLatestMovieItems( + JellyfinDbContext context, + IQueryable<BaseItemEntity> baseQuery, + InternalItemsQuery filter, + int? limit) + { + // Pick, per PresentationUniqueKey, the newest item; return the newest `limit` of those. + // Build up until limit by streaming through results and deduplicating on the fly. + var orderedIds = baseQuery + .Where(e => e.PresentationUniqueKey != null) + .OrderByDescending(e => e.DateCreated) + .ThenByDescending(e => e.Id) + .Select(e => new { e.Id, e.PresentationUniqueKey }); + + // DistinctBy and Take are lazy, so enumeration stops as soon as limit distinct keys are read. + var firstIds = orderedIds + .AsEnumerable() + .DistinctBy(row => row.PresentationUniqueKey) + .Select(row => row.Id) + .Take(limit ?? int.MaxValue) + .ToList(); + + return LoadLatestByIds(context, firstIds, filter); + } + + /// <summary> + /// Gets the latest TV show items with smart Season/Series container selection. + /// </summary> + /// <remarks> + /// <para> + /// This method implements intelligent container selection for TV shows in the "Latest" section. + /// Instead of always showing individual episodes, it analyzes recent additions and may return + /// a Season or Series container when multiple related episodes were recently added. + /// </para> + /// <para> + /// The selection logic is: + /// <list type="bullet"> + /// <item>If recent episodes span multiple seasons → return the Series</item> + /// <item>If multiple recent episodes are from one season AND the series has multiple seasons → return the Season</item> + /// <item>If multiple recent episodes are from one season AND the series has only one season → return the Series</item> + /// <item>Otherwise → return the most recent Episode</item> + /// </list> + /// </para> + /// </remarks> + /// <param name="context">The database context.</param> + /// <param name="baseQuery">The base query with filters already applied.</param> + /// <param name="filter">The query filter options.</param> + /// <param name="limit">Maximum number of items to return.</param> + /// <returns>A list of BaseItemDto representing the latest TV content.</returns> + private IReadOnlyList<BaseItemDto> GetLatestTvShowItems(JellyfinDbContext context, IQueryable<BaseItemEntity> baseQuery, InternalItemsQuery filter, int? limit) + { + // Episodes added within this window are considered "recently added together" + const double RecentAdditionWindowHours = 24.0; + + // Step 1: Find the top N series with recently added content, ordered by most recent addition + var topSeriesWithDates = baseQuery + .Where(e => e.SeriesName != null) + .GroupBy(e => e.SeriesName) + .Select(g => new { SeriesName = g.Key!, MaxDate = g.Max(e => e.DateCreated) }) + .OrderByDescending(g => g.MaxDate); + + if (limit.HasValue) + { + topSeriesWithDates = topSeriesWithDates.Take(limit.Value).OrderByDescending(g => g.MaxDate); + } + + // Materialize series names and cutoff to avoid embedding the GroupBy+OrderBy + // expression tree as a subquery inside the episode query. + var topSeriesData = topSeriesWithDates + .Select(g => new { g.SeriesName, g.MaxDate }) + .ToList(); + var topSeriesNames = topSeriesData.Select(g => g.SeriesName).ToList(); + + // Compute a global date cutoff: the oldest series' max date minus the window. + // Episodes before this cutoff cannot be in any series' "recent additions" window, + // so we can safely exclude them to avoid loading ancient episodes. + var globalCutoff = topSeriesData.Count > 0 + ? topSeriesData.Min(g => g.MaxDate)?.AddHours(-RecentAdditionWindowHours) + : null; + + // Restrict to episodes of the top series, optionally bounded by the global cutoff. + var episodeQuery = baseQuery.Where(e => e.SeriesName != null && topSeriesNames.Contains(e.SeriesName)); + if (globalCutoff is not null) + { + episodeQuery = episodeQuery.Where(e => e.DateCreated >= globalCutoff); + } + + // Lightweight projection: only the columns needed for the in-memory analysis below. + var allEpisodes = episodeQuery + .OrderByDescending(e => e.DateCreated) + .ThenByDescending(e => e.Id) + .Select(e => new { e.Id, e.SeriesName, e.DateCreated, e.SeasonId, e.SeriesId }) + .AsEnumerable(); + + // Collect all season/series IDs we'll need to look up for count information + var allSeasonIds = new HashSet<Guid>(); + var allSeriesIds = new HashSet<Guid>(); + + // Analysis data for each series: recent episode count, season IDs, and the most recent episode ID + var analysisData = new List<( + int RecentEpisodeCount, + List<Guid> SeasonIds, + Guid? FirstRecentSeriesId, + DateTime MaxDate, + Guid MostRecentEpisodeId)>(); + + // Step 3: Analyze each series to identify recent additions within the time window + foreach (var group in allEpisodes.GroupBy(e => e.SeriesName)) + { + var episodes = group.ToList(); + var mostRecentDate = episodes[0].DateCreated ?? DateTime.MinValue; + var recentCutoff = mostRecentDate.AddHours(-RecentAdditionWindowHours); + + // Find episodes added within the recent window + var recentEpisodeCount = 0; + var seasonIdSet = new HashSet<Guid>(); + Guid? firstRecentSeriesId = null; + + foreach (var ep in episodes) + { + if (ep.DateCreated >= recentCutoff) + { + recentEpisodeCount++; + if (ep.SeasonId.HasValue) + { + seasonIdSet.Add(ep.SeasonId.Value); + } + + firstRecentSeriesId ??= ep.SeriesId; + } + } + + var seasonIds = seasonIdSet.ToList(); + analysisData.Add((recentEpisodeCount, seasonIds, firstRecentSeriesId, mostRecentDate, episodes[0].Id)); + + // Track all unique season/series IDs for batch lookups + foreach (var sid in seasonIds) + { + allSeasonIds.Add(sid); + } + + if (firstRecentSeriesId.HasValue) + { + allSeriesIds.Add(firstRecentSeriesId.Value); + } + } + + // Step 4: Batch fetch counts - episodes per season and seasons per series + // These counts help determine whether to show Season or Series as the container + var episodeType = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode]; + var seasonType = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Season]; + var seasonEpisodeCounts = allSeasonIds.Count > 0 + ? context.BaseItems + .AsNoTracking() + .Where(e => e.SeasonId.HasValue && allSeasonIds.Contains(e.SeasonId.Value) && e.Type == episodeType) + .GroupBy(e => e.SeasonId!.Value) + .Select(g => new { SeasonId = g.Key, Count = g.Count() }) + .ToDictionary(x => x.SeasonId, x => x.Count) + : []; + + var seriesSeasonCounts = allSeriesIds.Count > 0 + ? context.BaseItems + .AsNoTracking() + .Where(e => e.SeriesId.HasValue && allSeriesIds.Contains(e.SeriesId.Value) && e.Type == seasonType) + .GroupBy(e => e.SeriesId!.Value) + .Select(g => new { SeriesId = g.Key, Count = g.Count() }) + .ToDictionary(x => x.SeriesId, x => x.Count) + : []; + + // Step 5: Apply the container selection logic for each series. + // For each series, decide which entity best represents the recent additions: + // - 1 episode added → show the Episode itself + // - Multiple episodes in 1 season (multi-season series) → show the Season + // - Multiple episodes in 1 season (single-season series) → show the Series + // - Episodes across multiple seasons → show the Series + var entitiesToFetch = new HashSet<Guid>(); + var seriesResults = new List<(Guid? SeasonId, Guid? SeriesId, DateTime MaxDate, Guid MostRecentEpisodeId)>(analysisData.Count); + + foreach (var (recentEpisodeCount, seasonIds, firstRecentSeriesId, maxDate, mostRecentEpisodeId) in analysisData) + { + Guid? seasonId = null; + Guid? seriesId = null; + + if (seasonIds.Count == 1) + { + // All recent episodes are from a single season + var sid = seasonIds[0]; + var totalEpisodes = seasonEpisodeCounts.GetValueOrDefault(sid, 0); + var totalSeasonsInSeries = firstRecentSeriesId.HasValue + ? seriesSeasonCounts.GetValueOrDefault(firstRecentSeriesId.Value, 1) + : 1; + + // Check if multiple episodes were added, or if all episodes in the season were added + var hasMultipleOrAllEpisodes = recentEpisodeCount > 1 || recentEpisodeCount == totalEpisodes; + + if (totalSeasonsInSeries > 1 && hasMultipleOrAllEpisodes) + { + // Multi-season series with bulk additions: show the Season + seasonId = sid; + entitiesToFetch.Add(sid); + } + else if (hasMultipleOrAllEpisodes && firstRecentSeriesId.HasValue) + { + // Single-season series with bulk additions: show the Series + seriesId = firstRecentSeriesId; + entitiesToFetch.Add(firstRecentSeriesId.Value); + } + + // Otherwise: single episode, will fall through to show the Episode + } + else if (seasonIds.Count > 1 && firstRecentSeriesId.HasValue) + { + // Recent episodes span multiple seasons: show the Series + seriesId = firstRecentSeriesId; + entitiesToFetch.Add(seriesId!.Value); + } + + if (seasonId is null && seriesId is null) + { + entitiesToFetch.Add(mostRecentEpisodeId); + } + + seriesResults.Add((seasonId, seriesId, maxDate, mostRecentEpisodeId)); + } + + // Step 5b: A container is what gets returned, so it has to pass the user's access + // filters on its own - a matching episode does not make a Season or Series the user + // may not see visible. Containers that don't pass are replaced by their episode. + if (RequiresParentalRestrictions(filter) && entitiesToFetch.Count > 0) + { + var allowedContainerIds = ApplyParentalRestrictions( + context, + context.BaseItems.AsNoTracking().Where(e => entitiesToFetch.Contains(e.Id)), + filter) + .Select(e => e.Id) + .ToHashSet(); + + for (var i = 0; i < seriesResults.Count; i++) + { + var (seasonId, seriesId, maxDate, mostRecentEpisodeId) = seriesResults[i]; + if (seasonId.HasValue && !allowedContainerIds.Contains(seasonId.Value)) + { + seasonId = null; + } + + if (seriesId.HasValue && !allowedContainerIds.Contains(seriesId.Value)) + { + seriesId = null; + } + + if (seasonId is null && seriesId is null) + { + entitiesToFetch.Add(mostRecentEpisodeId); + } + + seriesResults[i] = (seasonId, seriesId, maxDate, mostRecentEpisodeId); + } + } + + // Step 6: Fetch the Season/Series entities we decided to return + var entities = entitiesToFetch.Count > 0 + ? ApplyNavigations( + context.BaseItems.AsNoTracking().Where(e => entitiesToFetch.Contains(e.Id)), + filter) + .AsSingleQuery() + .ToDictionary(e => e.Id) + : []; + + // Step 7: Build final results, preferring Season > Series > Episode. + // All needed entities are already fetched in step 6 with navigation properties. + var results = new List<(BaseItemEntity Entity, DateTime MaxDate)>(seriesResults.Count); + foreach (var (seasonId, seriesId, maxDate, mostRecentEpisodeId) in seriesResults) + { + if (seasonId.HasValue && entities.TryGetValue(seasonId.Value, out var seasonEntity)) + { + results.Add((seasonEntity, maxDate)); + continue; + } + + if (seriesId.HasValue && entities.TryGetValue(seriesId.Value, out var seriesEntity)) + { + results.Add((seriesEntity, maxDate)); + continue; + } + + if (entities.TryGetValue(mostRecentEpisodeId, out var episodeEntity)) + { + results.Add((episodeEntity, maxDate)); + } + } + + var finalResults = results + .OrderByDescending(r => r.MaxDate) + .ThenByDescending(r => r.Entity.Id); + + if (limit.HasValue) + { + finalResults = finalResults + .Take(limit.Value) + .OrderByDescending(r => r.MaxDate) + .ThenByDescending(r => r.Entity.Id); + } + + return finalResults + .Select(r => DeserializeBaseItem(r.Entity, filter.SkipDeserialization)) + .Where(dto => dto is not null) + .ToArray()!; + } + + /// <inheritdoc/> + public async Task<bool> ItemExistsAsync(Guid id) + { + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + return await dbContext.BaseItems.AnyAsync(f => f.Id == id).ConfigureAwait(false); + } + } + + /// <inheritdoc /> + public BaseItemDto? RetrieveItem(Guid id) + { + if (id.IsEmpty()) + { + throw new ArgumentException("Guid can't be empty", nameof(id)); + } + + using var context = _dbProvider.CreateDbContext(); + var dbQuery = PrepareItemQuery(context, new() + { + DtoOptions = new() + { + EnableImages = true + } + }); + dbQuery = dbQuery.Include(e => e.TrailerTypes) + .Include(e => e.Provider) + .Include(e => e.LockedFields) + .Include(e => e.UserData) + .Include(e => e.Images) + .Include(e => e.LinkedChildEntities) + .AsSingleQuery(); + + var item = dbQuery.FirstOrDefault(e => e.Id == id); + if (item is null) + { + return null; + } + + return DeserializeBaseItem(item); + } + + /// <inheritdoc /> + public bool GetIsPlayed(User user, Guid id, bool recursive) + { + using var dbContext = _dbProvider.CreateDbContext(); + + if (recursive) + { + var descendantIds = DescendantQueryHelper.GetAllDescendantIds(dbContext, id); + + return dbContext.BaseItems + .Where(e => descendantIds.Contains(e.Id) && !e.IsFolder && !e.IsVirtualItem) + .All(f => f.UserData!.Any(e => e.UserId == user.Id && e.Played)); + } + + return dbContext.BaseItems.Where(e => e.ParentId == id).All(f => f.UserData!.Any(e => e.UserId == user.Id && e.Played)); + } + + /// <inheritdoc /> + public QueryFiltersLegacy GetQueryFiltersLegacy(InternalItemsQuery filter) + { + ArgumentNullException.ThrowIfNull(filter); + PrepareFilterQuery(filter); + + using var context = _dbProvider.CreateDbContext(); + var baseQuery = PrepareItemQuery(context, filter); + baseQuery = TranslateQuery(baseQuery, context, filter); + + var matchingItemIds = baseQuery.Select(e => e.Id); + + var years = baseQuery + .Where(e => e.ProductionYear != null && e.ProductionYear > 0) + .Select(e => e.ProductionYear!.Value) + .Distinct() + .OrderBy(y => y) + .ToArray(); + + var officialRatings = baseQuery + .Where(e => e.OfficialRating != null && e.OfficialRating != string.Empty) + .Select(e => e.OfficialRating!) + .Distinct() + .OrderBy(r => r) + .ToArray(); + + var tags = context.ItemValuesMap + .Join( + context.ItemValues, + ivm => ivm.ItemValueId, + iv => iv.ItemValueId, + (ivm, iv) => new { ivm.ItemId, iv.Type, iv.CleanValue, iv.Value }) + .Where(iv => iv.Type == ItemValueType.Tags) + .Where(iv => matchingItemIds.Contains(iv.ItemId)) + .GroupBy(iv => iv.CleanValue) + .Select(g => g.Min(iv => iv.Value)) + .OrderBy(t => t) + .ToArray(); + + var genres = context.ItemValuesMap + .Join( + context.ItemValues, + ivm => ivm.ItemValueId, + iv => iv.ItemValueId, + (ivm, iv) => new { ivm.ItemId, iv.Type, iv.CleanValue, iv.Value }) + .Where(iv => iv.Type == ItemValueType.Genre) + .Where(iv => matchingItemIds.Contains(iv.ItemId)) + .GroupBy(iv => iv.CleanValue) + .Select(g => g.Min(iv => iv.Value)) + .OrderBy(g => g) + .ToArray(); + + return new QueryFiltersLegacy + { + Years = years, + OfficialRatings = officialRatings, + Tags = tags, + Genres = genres + }; + } +} diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs new file mode 100644 index 0000000000..a745c3309f --- /dev/null +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -0,0 +1,1296 @@ +#pragma warning disable RS0030 // Do not use banned APIs +#pragma warning disable CA1304 // Specify CultureInfo +#pragma warning disable CA1309 // Use ordinal string comparison +#pragma warning disable CA1311 // Specify a culture or use an invariant version +#pragma warning disable CA1307 // Specify StringComparison for clarity +#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Linq.Expressions; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.MatchCriteria; +using Jellyfin.Extensions; +using Jellyfin.Server.Implementations.Extensions; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Entities; +using Microsoft.EntityFrameworkCore; +using BaseItemEntity = Jellyfin.Database.Implementations.Entities.BaseItemEntity; + +namespace Jellyfin.Server.Implementations.Item; + +public sealed partial class BaseItemRepository +{ + private static readonly IReadOnlyList<char> SearchWildcardTerms = ['%', '_', '[', ']', '^']; + + private static readonly string ImdbProviderName = MetadataProvider.Imdb.ToString().ToLowerInvariant(); + private static readonly string TmdbProviderName = MetadataProvider.Tmdb.ToString().ToLowerInvariant(); + private static readonly string TvdbProviderName = MetadataProvider.Tvdb.ToString().ToLowerInvariant(); + + // A fresh expression per access: EF rejects a query tree that reuses one lambda parameter + // instance across several lambdas, and this filter is combined into a tree more than once. + private static Expression<Func<BaseItemEntity, bool>> IsFolderFilter => e => e.IsFolder; + + // Shared by the isPlayed filter and the IsPlayed/IsUnplayed ordering so the two cannot disagree. + private Expression<Func<BaseItemEntity, bool>> BuildIsPlayedFilter(JellyfinDbContext context, User user) + { + // Folders (Series, Seasons, BoxSets, albums, ...) carry no played state of their own and count + // as played once no descendant is left unplayed. + var unplayedLeafItems = GetAccessFilteredLeafItemsQuery(context, user) + .Where(BuildLeafIsPlayedFilter(context, user.Id).Not()); + + return IsFolderFilter.And(BuildHasDescendantFilter(context, unplayedLeafItems).Not()) + .Or(IsFolderFilter.Not().And(BuildLeafIsPlayedFilter(context, user.Id))); + } + + private static Expression<Func<BaseItemEntity, bool>> BuildLeafIsPlayedFilter(JellyfinDbContext context, Guid userId) + { + var playedItemIds = context.UserData + .Where(ud => ud.UserId == userId && ud.Played) + .Select(ud => ud.ItemId); + + // The primaries of every version group holding a played row, whichever version carries it. + var playedGroupIds = context.BaseItems + .Where(v => v.PrimaryVersionId != null + && context.UserData.Any(ud => ud.UserId == userId + && ud.Played + && (ud.ItemId == v.Id || ud.ItemId == v.PrimaryVersionId))) + .Select(v => v.PrimaryVersionId!.Value); + + return e => playedItemIds.Contains(e.Id) + || playedGroupIds.Contains(e.Id) + || (e.PrimaryVersionId != null && playedGroupIds.Contains(e.PrimaryVersionId.Value)); + } + + // "und" is the language filters' stand-in for a track that declares no language at all. + private static string NormalizeLanguage(string language) + => string.Equals(language, "und", StringComparison.OrdinalIgnoreCase) ? "und" : language; + + // The primary versions whose alternate version satisfies a dimension bound. Anchored on + // PrimaryVersionId so the filtered index carries it rather than a scan of every item. + private static IQueryable<Guid> VersionsMatchingDimension(JellyfinDbContext context, Expression<Func<BaseItemEntity, bool>> bound) + => context.BaseItems + .Where(v => v.PrimaryVersionId != null) + .Where(bound) + .Select(v => v.PrimaryVersionId!.Value); + + /// <inheritdoc /> + public IQueryable<BaseItemEntity> TranslateQuery( + IQueryable<BaseItemEntity> 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; + } + + // A 4K remux of an SD primary is a version of the same item, so the bucket a caller filters + // on is the best any of the item's versions offers, not just the primary file's. Three sets, + // because a bucket is as much about what the version group does not have as what it does, and + // because an unprobed primary can still be placed by a version that does carry dimensions. + // The filtered PrimaryVersionId index keeps all three to the few items that have versions. + var versionsSd = VersionsMatchingDimension(context, v => v.Width > 0 && v.Width < HDWidth); + var versionsHd = VersionsMatchingDimension(context, v => v.Width >= HDWidth); + var versions4K = VersionsMatchingDimension(context, v => v.Width >= UHDWidth || v.Height >= UHDHeight); + + // Only the SD test needs the Width > 0 guard against a row with no dimensions: such a row + // cannot reach the HD or 4K bound anyway, and EF lowers the HD bucket's negated "not itself + // 4K" guard to CASE WHEN ... THEN 0 ELSE 1, which already reads unknown as not 4K rather + // than propagating a null. Folders (Series, BoxSets) answer on their descendants, bucketed + // exactly as a top-level item is so that the two cannot disagree; the navigation properties + // (a.Item, lc.Child) give EXISTS + JOIN rather than nested IN (SELECT ...). + baseQuery = baseQuery.Where(e => + (!e.IsFolder + && ((includeSD + && ((e.Width > 0 && e.Width < HDWidth) || versionsSd.Contains(e.Id)) + && !versionsHd.Contains(e.Id) + && !versions4K.Contains(e.Id)) + || (includeHD + && (e.Width >= HDWidth || versionsHd.Contains(e.Id)) + && !(e.Width >= UHDWidth || e.Height >= UHDHeight) + && !versions4K.Contains(e.Id)) + || (include4K + && (e.Width >= UHDWidth || e.Height >= UHDHeight || versions4K.Contains(e.Id))))) + || (e.IsFolder + && (e.Children!.Any(a => + (includeSD + && ((a.Item.Width > 0 && a.Item.Width < HDWidth) || versionsSd.Contains(a.ItemId)) + && !versionsHd.Contains(a.ItemId) + && !versions4K.Contains(a.ItemId)) + || (includeHD + && (a.Item.Width >= HDWidth || versionsHd.Contains(a.ItemId)) + && !(a.Item.Width >= UHDWidth || a.Item.Height >= UHDHeight) + && !versions4K.Contains(a.ItemId)) + || (include4K + && (a.Item.Width >= UHDWidth || a.Item.Height >= UHDHeight || versions4K.Contains(a.ItemId)))) + || context.LinkedChildren.Any(lc => + lc.ParentId == e.Id + && ((includeSD + && ((lc.Child!.Width > 0 && lc.Child!.Width < HDWidth) || versionsSd.Contains(lc.ChildId)) + && !versionsHd.Contains(lc.ChildId) + && !versions4K.Contains(lc.ChildId)) + || (includeHD + && (lc.Child!.Width >= HDWidth || versionsHd.Contains(lc.ChildId)) + && !(lc.Child!.Width >= UHDWidth || lc.Child!.Height >= UHDHeight) + && !versions4K.Contains(lc.ChildId)) + || (include4K + && (lc.Child!.Width >= UHDWidth || lc.Child!.Height >= UHDHeight || versions4K.Contains(lc.ChildId)))))))); + } + + // Same reasoning as the resolution filter: a dimension bound is met if any version meets it. + if (minWidth.HasValue) + { + var versionsWideEnough = VersionsMatchingDimension(context, v => v.Width >= minWidth); + baseQuery = baseQuery.Where(e => e.Width >= minWidth || versionsWideEnough.Contains(e.Id)); + } + + if (filter.MinHeight.HasValue) + { + var minHeight = filter.MinHeight; + var versionsTallEnough = VersionsMatchingDimension(context, v => v.Height >= minHeight); + baseQuery = baseQuery.Where(e => e.Height >= minHeight || versionsTallEnough.Contains(e.Id)); + } + + // An upper bound inverts that: it is met only if no version breaches it, since the item's + // resolution is the best its version group offers. + if (maxWidth.HasValue) + { + var versionsTooWide = VersionsMatchingDimension(context, v => v.Width > maxWidth); + baseQuery = baseQuery.Where(e => e.Width <= maxWidth && !versionsTooWide.Contains(e.Id)); + } + + if (filter.MaxHeight.HasValue) + { + var maxHeight = filter.MaxHeight; + var versionsTooTall = VersionsMatchingDimension(context, v => v.Height > maxHeight); + baseQuery = baseQuery.Where(e => e.Height <= maxHeight && !versionsTooTall.Contains(e.Id)); + } + + 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.HasValue) + { + var shouldIncludeAllMovieTypes = filter.IsMovie.Value + && (filter.IncludeItemTypes.Length == 0 + || filter.IncludeItemTypes.Contains(BaseItemKind.Movie) + || filter.IncludeItemTypes.Contains(BaseItemKind.Trailer)); + + if (!shouldIncludeAllMovieTypes) + { + baseQuery = baseQuery.Where(e => e.IsMovie == filter.IsMovie.Value); + } + } + + 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 cleanedSearchTerm = filter.SearchTerm.GetCleanValue(); + var originalSearchTerm = filter.SearchTerm; + if (SearchWildcardTerms.Any(f => cleanedSearchTerm.Contains(f))) + { + cleanedSearchTerm = $"%{cleanedSearchTerm.Trim('%')}%"; + var likeSearchTerm = $"%{originalSearchTerm.Trim('%')}%"; + baseQuery = baseQuery.Where(e => EF.Functions.Like(e.CleanName!, cleanedSearchTerm) || (e.OriginalTitle != null && EF.Functions.Like(e.OriginalTitle, likeSearchTerm))); + } + else + { + var likeSearchTerm = $"%{originalSearchTerm}%"; + baseQuery = baseQuery.Where(e => e.CleanName!.Contains(cleanedSearchTerm) || (e.OriginalTitle != null && EF.Functions.Like(e.OriginalTitle, likeSearchTerm))); + } + } + + 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<string>(); + 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 + { + string[] types = includeTypes.Select(f => _itemTypeLookup.BaseItemKindNames.GetValueOrDefault(f)).Where(e => e != null).ToArray()!; + baseQuery = baseQuery.WhereOneOrMany(types, f => f.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)) + { + var pathToQuery = GetPathToSave(filter.Path); + baseQuery = baseQuery.Where(e => e.Path == pathToQuery); + } + + 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 => e.TrailerTypes!.Any(w => trailerTypes.Contains(w.Id))); + } + + 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) + { + var peopleEntityIds = context.BaseItems + .WhereOneOrMany(filter.PersonIds, b => b.Id) + .Join( + context.Peoples, + b => b.Name, + p => p.Name, + (b, p) => p.Id); + + var personTypes = filter.PersonTypes; + baseQuery = baseQuery + .Where(e => context.PeopleBaseItemMap + .Any(m => m.ItemId == e.Id && peopleEntityIds.Contains(m.PeopleId) && (personTypes.Length == 0 || personTypes.Contains(m.People.PersonType)))); + } + + if (!string.IsNullOrWhiteSpace(filter.Person)) + { + var personTypes = filter.PersonTypes; + baseQuery = baseQuery.Where(e => e.Peoples!.Any(f => f.People.Name == filter.Person && (personTypes.Length == 0 || personTypes.Contains(f.People.PersonType)))); + } + + 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)) + { + if (filter.UseRawName == true) + { + var nameLower = filter.Name.ToLowerInvariant(); + baseQuery = baseQuery.Where(e => e.Name!.ToLower() == nameLower); + } + else + { + var cleanName = filter.Name.GetCleanValue(); + baseQuery = baseQuery.Where(e => e.CleanName == cleanName); + } + } + + var nameContains = filter.NameContains; + if (!string.IsNullOrWhiteSpace(nameContains)) + { + if (SearchWildcardTerms.Any(f => nameContains.Contains(f))) + { + nameContains = $"%{nameContains.Trim('%')}%"; + baseQuery = baseQuery.Where(e => EF.Functions.Like(e.CleanName, nameContains) || EF.Functions.Like(e.OriginalTitle, nameContains)); + } + else + { + var likeNameContains = $"%{nameContains}%"; + baseQuery = baseQuery.Where(e => + e.CleanName!.Contains(nameContains) + || EF.Functions.Like(e.OriginalTitle, likeNameContains)); + } + } + + // When box set collapsing is active, defer name-range filters to after the collapse. + // Otherwise, items are filtered by their own name but then collapsed into a BoxSet + // whose name may fall in a different range (e.g. "21 Jump Street" is under "#" + // but its BoxSet "Jump Street Collection" should appear under "J"). + if (filter.CollapseBoxSetItems != true) + { + baseQuery = ApplyNameFilters(baseQuery, filter); + } + + if (filter.ImageTypes.Length > 0) + { + var imgTypes = filter.ImageTypes.Select(e => (ImageInfoImageType)e).ToArray(); + baseQuery = baseQuery.Where(e => e.Images!.Any(w => imgTypes.Contains(w.ImageType))); + } + + if (filter.IsLiked.HasValue) + { + var likedIds = context.UserData + .Where(ud => ud.UserId == filter.User!.Id && ud.Rating >= UserItemData.MinLikeValue) + .Select(ud => ud.ItemId); + + baseQuery = filter.IsLiked.Value + ? baseQuery.Where(e => likedIds.Contains(e.Id)) + : baseQuery.Where(e => !likedIds.Contains(e.Id)); + } + + if (filter.IsFavoriteOrLiked.HasValue || filter.IsFavorite.HasValue) + { + var favoriteIds = context.UserData + .Where(ud => ud.UserId == filter.User!.Id && ud.IsFavorite) + .Select(ud => ud.ItemId); + + if (filter.IsFavoriteOrLiked.HasValue) + { + baseQuery = filter.IsFavoriteOrLiked.Value + ? baseQuery.Where(e => favoriteIds.Contains(e.Id)) + : baseQuery.Where(e => !favoriteIds.Contains(e.Id)); + } + + if (filter.IsFavorite.HasValue) + { + baseQuery = filter.IsFavorite.Value + ? baseQuery.Where(e => favoriteIds.Contains(e.Id)) + : baseQuery.Where(e => !favoriteIds.Contains(e.Id)); + } + } + + if (filter.IsPlayed.HasValue) + { + var isPlayedFilter = BuildIsPlayedFilter(context, filter.User!); + + baseQuery = baseQuery.Where(filter.IsPlayed.Value ? isPlayedFilter : isPlayedFilter.Not()); + } + + if (filter.IsResumable.HasValue) + { + var userId = filter.User!.Id; + var isResumable = filter.IsResumable.Value; + + // In-progress user data rows; alternate versions track their own progress. + var inProgress = context.UserData + .Where(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0); + + // Series and Seasons are resumable when a descendant is in progress, or when they hold both + // played and unplayed descendants (partially watched). Alternate versions keep their own + // progress, so they count towards the in-progress check but not towards the played/unplayed one. + var leafItems = GetAccessFilteredLeafItemsQuery(context, filter.User!); + var inProgressLeafItems = GetAccessFilteredLeafItemsQuery(context, filter.User!, includeOwnedItems: true) + .Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0)); + + // Every other folder kind is a container rather than one continuous piece of media + var resumableFolderTypes = _resumableFolderKinds + .Select(kind => _itemTypeLookup.BaseItemKindNames.GetValueOrDefault(kind)) + .ToArray(); + var folderIsResumableFilter = IsFolderFilter.And(e => resumableFolderTypes.Contains(e.Type)) + .And(BuildHasDescendantFilter(context, inProgressLeafItems) + .Or(BuildHasDescendantFilter(context, leafItems.Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played))) + .And(BuildHasDescendantFilter(context, leafItems.Where(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played)))))); + + if (isResumable) + { + // Resume queries surface the version that was actually played, which may be an alternate. + // Match each version on its own progress rather than coalescing onto the primary. + var inProgressIds = inProgress.Select(ud => ud.ItemId); + + baseQuery = baseQuery.Where(folderIsResumableFilter + .Or(IsFolderFilter.Not().And(e => inProgressIds.Contains(e.Id)))); + + // When several versions of the same item are in progress, keep only the most recently played one, use id as tiebreaker. + // Only in-progress siblings can eliminate a candidate: a version without progress has a NULL max LastPlayedDate, + // which is never greater and never ties. Restricting the sibling scan to the in-progress set keeps this bounded by + // the user's Continue Watching count instead of forcing a full BaseItems scan (COALESCE keys are non-indexable) per row. + // Items in no version group at all have no sibling that could eliminate them, so short-circuit the scan for those. + baseQuery = baseQuery.Where(e => e.IsFolder + || (e.PrimaryVersionId == null && !context.BaseItems.Any(a => a.PrimaryVersionId == e.Id)) + || !context.BaseItems + .Where(s => s.Id != e.Id + && inProgressIds.Contains(s.Id) + && (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id)) + .Any(s => + inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate) + > inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate) + || (inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate) + == inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate) + && s.Id.CompareTo(e.Id) < 0))); + } + else + { + // Not-resumable queries operate on primaries only. + var resumableMovieIds = inProgress + .Join(context.BaseItems, ud => ud.ItemId, bi => bi.Id, (ud, bi) => bi.PrimaryVersionId ?? bi.Id); + + baseQuery = baseQuery.Where(IsFolderFilter.And(folderIsResumableFilter.Not()) + .Or(IsFolderFilter.Not().And(e => !resumableMovieIds.Contains(e.Id)))); + } + } + + if (filter.ArtistIds.Length > 0) + { + baseQuery = baseQuery.WhereReferencedItem(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ArtistIds); + } + + if (filter.AlbumArtistIds.Length > 0) + { + baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.AlbumArtist, filter.AlbumArtistIds); + } + + if (filter.ContributingArtistIds.Length > 0) + { + var contributingNames = context.BaseItems + .Where(b => filter.ContributingArtistIds.Contains(b.Id)) + .Select(b => b.CleanName); + + baseQuery = baseQuery.Where(e => + e.ItemValues!.Any(ivm => + ivm.ItemValue.Type == ItemValueType.Artist && + contributingNames.Contains(ivm.ItemValue.CleanValue)) + && + !e.ItemValues!.Any(ivm => + ivm.ItemValue.Type == ItemValueType.AlbumArtist && + contributingNames.Contains(ivm.ItemValue.CleanValue))); + } + + if (filter.AlbumIds.Length > 0) + { + baseQuery = baseQuery.Where(e => e.ParentId.HasValue && filter.AlbumIds.Contains(e.ParentId.Value)); + } + + if (filter.ExcludeArtistIds.Length > 0) + { + baseQuery = baseQuery.WhereReferencedItem(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ExcludeArtistIds, true); + } + + if (filter.GenreIds.Count > 0) + { + baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Genre, filter.GenreIds); + } + + if (filter.Genres.Count > 0) + { + var cleanGenres = filter.Genres.Select(e => e.GetCleanValue()).ToArray().OneOrManyExpressionBuilder<ItemValueMap, string>(f => f.ItemValue.CleanValue); + baseQuery = baseQuery + .Where(e => e.ItemValues!.AsQueryable().Where(f => f.ItemValue.Type == ItemValueType.Genre).Any(cleanGenres)); + } + + if (tags.Count > 0) + { + var cleanValues = tags.Select(e => e.GetCleanValue()).ToArray().OneOrManyExpressionBuilder<ItemValueMap, string>(f => f.ItemValue.CleanValue); + baseQuery = baseQuery + .Where(e => e.ItemValues!.AsQueryable().Where(f => f.ItemValue.Type == ItemValueType.Tags).Any(cleanValues)); + } + + if (excludeTags.Count > 0) + { + var cleanValues = excludeTags.Select(e => e.GetCleanValue()).ToArray().OneOrManyExpressionBuilder<ItemValueMap, string>(f => f.ItemValue.CleanValue); + baseQuery = baseQuery + .Where(e => !e.ItemValues!.AsQueryable().Where(f => f.ItemValue.Type == ItemValueType.Tags).Any(cleanValues)); + } + + if (filter.StudioIds.Length > 0) + { + baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Studios, filter.StudioIds); + } + + if (filter.OfficialRatings.Length > 0) + { + var ratings = filter.OfficialRatings; + baseQuery = baseQuery.WhereItemOrDescendantMatches(context, e => ratings.Contains(e.OfficialRating)); + } + + Expression<Func<BaseItemEntity, bool>>? minParentalRatingFilter = null; + if (filter.MinParentalRating != null) + { + var min = filter.MinParentalRating; + var minScore = min.Score; + var minSubScore = min.SubScore ?? 0; + + minParentalRatingFilter = e => + e.InheritedParentalRatingValue == null || + e.InheritedParentalRatingValue > minScore || + (e.InheritedParentalRatingValue == minScore && (e.InheritedParentalRatingSubValue ?? 0) >= minSubScore); + } + + Expression<Func<BaseItemEntity, bool>>? maxParentalRatingFilter = null; + if (filter.MaxParentalRating != null) + { + maxParentalRatingFilter = BuildMaxParentalRatingFilter(context, filter.MaxParentalRating); + } + + 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<Func<BaseItemEntity, bool>> 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) + { + Expression<Func<BaseItemEntity, bool>> hasRating = + e => e.OfficialRating != null && e.OfficialRating != string.Empty; + + baseQuery = filter.HasOfficialRating.Value + ? baseQuery.WhereItemOrDescendantMatches(context, hasRating) + : baseQuery.WhereNeitherItemNorDescendantMatches(context, hasRating); + } + + 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); + } + } + else if (filter.OwnerIds.Length == 0 && filter.ExtraTypes.Length == 0 && !filter.IncludeOwnedItems) + { + // Exclude owned non-extra items from general queries. + // Extras (trailers, etc.) have OwnerId set but also have ExtraType set - keep those. + // Alternate versions (PrimaryVersionId set) are normally excluded too, but resume queries + // keep them so the actually-played version can surface instead of collapsing onto the primary. + baseQuery = filter.IsResumable == true + ? baseQuery.Where(e => e.OwnerId == null || e.ExtraType != null) + : baseQuery.Where(e => e.PrimaryVersionId == null && (e.OwnerId == null || e.ExtraType != null)); + } + + if (filter.OwnerIds.Length > 0) + { + baseQuery = baseQuery.Where(e => e.OwnerId != null && filter.OwnerIds.Contains(e.OwnerId.Value)); + } + + if (filter.ExtraTypes.Length > 0) + { + // Convert ExtraType enum to BaseItemExtraType enum via int cast (same underlying values) + var extraTypeValues = filter.ExtraTypes.Select(e => (BaseItemExtraType?)(int)e).ToArray(); + baseQuery = baseQuery.Where(e => e.ExtraType != null && extraTypeValues.Contains(e.ExtraType)); + } + + if (!string.IsNullOrWhiteSpace(filter.HasNoAudioTrackWithLanguage)) + { + var lang = NormalizeLanguage(filter.HasNoAudioTrackWithLanguage); + var undetermined = string.Equals(lang, "und", StringComparison.Ordinal); + var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Audio, lang); + // A track only an alternate version carries still belongs to the item a caller sees, so the + // item's own streams alone do not decide this. Same for every stream filter below. + var versionsWithAudio = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria); + var foldersWithAudio = DescendantQueryHelper.GetFolderIdsMatching(context, criteria); + + baseQuery = baseQuery + .Where(e => + (!e.IsFolder + && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Audio + && (ms.Language == lang || (undetermined && string.IsNullOrEmpty(ms.Language)))) + && !versionsWithAudio.Contains(e.Id)) + || (e.IsFolder && !foldersWithAudio.Contains(e.Id))); + } + + if (!string.IsNullOrWhiteSpace(filter.HasNoInternalSubtitleTrackWithLanguage)) + { + var lang = NormalizeLanguage(filter.HasNoInternalSubtitleTrackWithLanguage); + var undetermined = string.Equals(lang, "und", StringComparison.Ordinal); + var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: false); + var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria); + var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria); + + baseQuery = baseQuery + .Where(e => + (!e.IsFolder + && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && !ms.IsExternal + && (ms.Language == lang || (undetermined && string.IsNullOrEmpty(ms.Language)))) + && !versionsWithSubtitles.Contains(e.Id)) + || (e.IsFolder && !foldersWithSubtitles.Contains(e.Id))); + } + + if (!string.IsNullOrWhiteSpace(filter.HasNoExternalSubtitleTrackWithLanguage)) + { + var lang = NormalizeLanguage(filter.HasNoExternalSubtitleTrackWithLanguage); + var undetermined = string.Equals(lang, "und", StringComparison.Ordinal); + var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: true); + var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria); + var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria); + + baseQuery = baseQuery + .Where(e => + (!e.IsFolder + && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && ms.IsExternal + && (ms.Language == lang || (undetermined && string.IsNullOrEmpty(ms.Language)))) + && !versionsWithSubtitles.Contains(e.Id)) + || (e.IsFolder && !foldersWithSubtitles.Contains(e.Id))); + } + + if (!string.IsNullOrWhiteSpace(filter.HasNoSubtitleTrackWithLanguage)) + { + var lang = NormalizeLanguage(filter.HasNoSubtitleTrackWithLanguage); + var undetermined = string.Equals(lang, "und", StringComparison.Ordinal); + var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang); + var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria); + var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria); + + baseQuery = baseQuery + .Where(e => + (!e.IsFolder + && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle + && (ms.Language == lang || (undetermined && string.IsNullOrEmpty(ms.Language)))) + && !versionsWithSubtitles.Contains(e.Id)) + || (e.IsFolder && !foldersWithSubtitles.Contains(e.Id))); + } + + if (filter.HasSubtitles.HasValue) + { + var hasSubtitles = filter.HasSubtitles.Value; + var criteria = new HasSubtitles(); + var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria); + var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria); + if (hasSubtitles) + { + baseQuery = baseQuery + .Where(e => + (!e.IsFolder && (e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle) + || versionsWithSubtitles.Contains(e.Id))) + || (e.IsFolder && foldersWithSubtitles.Contains(e.Id))); + } + else + { + baseQuery = baseQuery + .Where(e => + (!e.IsFolder && !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle) + && !versionsWithSubtitles.Contains(e.Id)) + || (e.IsFolder && !foldersWithSubtitles.Contains(e.Id))); + } + } + + if (filter.SubtitleLanguages.Count > 0) + { + var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, filter.SubtitleLanguages); + var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria); + var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria); + baseQuery = baseQuery + .Where(e => + (!e.IsFolder && (e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle + && (filter.SubtitleLanguages.Contains(f.Language) || (filter.SubtitleLanguages.Contains("und") && string.IsNullOrEmpty(f.Language)))) + || versionsWithSubtitles.Contains(e.Id))) + || (e.IsFolder && foldersWithSubtitles.Contains(e.Id))); + } + + if (filter.AudioLanguages.Count > 0) + { + var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Audio, filter.AudioLanguages); + var versionsWithAudio = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria); + var foldersWithAudio = DescendantQueryHelper.GetFolderIdsMatching(context, criteria); + baseQuery = baseQuery + .Where(e => + (!e.IsFolder && (e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Audio + && (filter.AudioLanguages.Contains(f.Language) || (filter.AudioLanguages.Contains("und") && string.IsNullOrEmpty(f.Language)))) + || versionsWithAudio.Contains(e.Id))) + || (e.IsFolder && foldersWithAudio.Contains(e.Id))); + } + + if (filter.HasChapterImages.HasValue) + { + var hasChapterImages = filter.HasChapterImages.Value; + var criteria = new HasChapterImages(); + var versionsWithChapterImages = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria); + var foldersWithChapterImages = DescendantQueryHelper.GetFolderIdsMatching(context, criteria); + if (hasChapterImages) + { + baseQuery = baseQuery + .Where(e => + (!e.IsFolder && (e.Chapters!.Any(f => f.ImagePath != null) + || versionsWithChapterImages.Contains(e.Id))) + || (e.IsFolder && foldersWithChapterImages.Contains(e.Id))); + } + else + { + baseQuery = baseQuery + .Where(e => + (!e.IsFolder && !e.Chapters!.Any(f => f.ImagePath != null) + && !versionsWithChapterImages.Contains(e.Id)) + || (e.IsFolder && !foldersWithChapterImages.Contains(e.Id))); + } + } + + if (filter.HasDeadParentId.HasValue && filter.HasDeadParentId.Value) + { + baseQuery = baseQuery + .Where(e => e.ParentId.HasValue && !context.BaseItems.Where(e => e.Id != EF.Constant(PlaceholderId)).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 > 0) + { + baseQuery = baseQuery.WhereOneOrMany(filter.Years, e => e.ProductionYear!.Value); + } + + 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.WhereOneOrMany(mediaTypes, e => e.MediaType); + } + + if (filter.ItemIds.Length > 0) + { + baseQuery = baseQuery.WhereOneOrMany(filter.ItemIds, e => e.Id); + } + + if (filter.ExcludeItemIds.Length > 0) + { + baseQuery = baseQuery + .Where(e => !filter.ExcludeItemIds.Contains(e.Id)); + } + + if (filter.ExcludeProviderIds is not null && filter.ExcludeProviderIds.Count > 0) + { + baseQuery = baseQuery.WhereExcludeProviderIds(filter.ExcludeProviderIds); + } + + if (filter.HasAnyProviderId is not null && filter.HasAnyProviderId.Count > 0) + { + baseQuery = baseQuery.WhereHasAnyProviderId(filter.HasAnyProviderId); + } + + if (filter.HasAnyProviderIds is not null && filter.HasAnyProviderIds.Count > 0) + { + baseQuery = baseQuery.WhereHasAnyProviderIds(filter.HasAnyProviderIds); + } + + if (filter.HasImdbId.HasValue) + { + baseQuery = filter.HasImdbId.Value + ? baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId.ToLower() == ImdbProviderName)) + : baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != ImdbProviderName)); + } + + if (filter.HasTmdbId.HasValue) + { + baseQuery = filter.HasTmdbId.Value + ? baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId.ToLower() == TmdbProviderName)) + : baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != TmdbProviderName)); + } + + if (filter.HasTvdbId.HasValue) + { + baseQuery = filter.HasTvdbId.Value + ? baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId.ToLower() == TvdbProviderName)) + : baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != TvdbProviderName)); + } + + baseQuery = ApplyTopParentFiltering(context, baseQuery, filter); + + if (filter.AncestorIds.Length > 0) + { + var ancestorFilter = filter.AncestorIds.OneOrManyExpressionBuilder<AncestorId, Guid>(f => f.ParentItemId); + baseQuery = baseQuery.Where(e => e.Parents!.AsQueryable().Any(ancestorFilter)); + } + + if (filter.DescendantOfId.HasValue) + { + var descendantIds = DescendantQueryHelper.GetAllDescendantIds(context, filter.DescendantOfId.Value); + baseQuery = baseQuery.Where(e => descendantIds.Contains(e.Id)); + } + + if (filter.LinkedChildAncestorIds.Length > 0) + { + // Keep folder-like items (BoxSets, Playlists) whose linked children descend from any of the requested ancestor ids. + var linkedChildAncestorIds = filter.LinkedChildAncestorIds; + baseQuery = baseQuery.Where(e => context.LinkedChildren.Any(lc => + lc.ParentId == e.Id + && lc.Child!.Parents!.Any(a => linkedChildAncestorIds.Contains(a.ParentItemId)))); + } + + if (!string.IsNullOrWhiteSpace(filter.AncestorWithPresentationUniqueKey)) + { + baseQuery = baseQuery + .Where(e => context.BaseItems.Where(e => e.Id != EF.Constant(PlaceholderId)).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); + } + + // Pre-build the blocked-item-id set as a sub-select + if (filter.ExcludeInheritedTags.Length > 0) + { + var excludedTags = filter.ExcludeInheritedTags.Select(e => e.GetCleanValue()).ToArray(); + var blockedTagItemIds = context.ItemValuesMap + .Where(f => f.ItemValue.Type == ItemValueType.Tags && excludedTags.Contains(f.ItemValue.CleanValue)) + .Select(f => f.ItemId); + + baseQuery = baseQuery.Where(e => + !blockedTagItemIds.Contains(e.Id) + && !(e.SeriesId.HasValue && blockedTagItemIds.Contains(e.SeriesId.Value)) + && !e.Parents!.Any(p => blockedTagItemIds.Contains(p.ParentItemId)) + && !(e.TopParentId.HasValue && blockedTagItemIds.Contains(e.TopParentId.Value))); + } + + if (filter.IncludeInheritedTags.Length > 0) + { + var includeTags = filter.IncludeInheritedTags.Select(e => e.GetCleanValue()).ToArray(); + var isPlaylistOnlyQuery = includeTypes.Length == 1 && includeTypes.FirstOrDefault() == BaseItemKind.Playlist; + var personTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Person]; + var allowedTagItemIds = context.ItemValuesMap + .Where(f => f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue)) + .Select(f => f.ItemId); + + baseQuery = baseQuery.Where(e => + allowedTagItemIds.Contains(e.Id) + || (e.SeriesId.HasValue && allowedTagItemIds.Contains(e.SeriesId.Value)) + || e.Parents!.Any(p => allowedTagItemIds.Contains(p.ParentItemId)) + || (e.TopParentId.HasValue && allowedTagItemIds.Contains(e.TopParentId.Value)) + + // People don't carry the tags of the media they appear in and would never match + || e.Type == personTypeName + + // A playlist should be accessible to its owner regardless of allowed tags + || (isPlaylistOnlyQuery && e.Data!.Contains($"OwnerUserId\":\"{filter.User!.Id:N}\""))); + } + + 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) + { + // Dvds and Blu-rays can either be stored in a folder structure or as an iso file + // => to find all matches we need to check both: VideoType and IsoType + // alternatively, we could provide specific IsoType filters + var videoTypeBs = filter.VideoTypes.Select(vt => $"\"VideoType\":\"{vt}\"").ToArray(); + var isoTypeBs = filter.VideoTypes.Select(vt => $"\"IsoType\":\"{vt}\"").ToArray(); + Expression<Func<BaseItemEntity, bool>> hasVideoType = e => videoTypeBs.Any(f => e.Data!.Contains(f)) || isoTypeBs.Any(f => e.Data!.Contains(f)); + baseQuery = baseQuery.WhereItemOrDescendantMatches(context, hasVideoType); + } + + if (filter.Is3D.HasValue) + { + Expression<Func<BaseItemEntity, bool>> is3D = e => e.Data!.Contains("Video3DFormat"); + + baseQuery = filter.Is3D.Value + ? baseQuery.WhereItemOrDescendantMatches(context, is3D) + : baseQuery.WhereNeitherItemNorDescendantMatches(context, is3D); + } + + if (filter.IsPlaceHolder.HasValue) + { + Expression<Func<BaseItemEntity, bool>> isPlaceHolder = e => e.Data!.Contains("IsPlaceHolder\":true"); + + baseQuery = filter.IsPlaceHolder.Value + ? baseQuery.WhereItemOrDescendantMatches(context, isPlaceHolder) + : baseQuery.WhereNeitherItemNorDescendantMatches(context, isPlaceHolder); + } + + // An extra is owned by the single version of an item it is named after, so an extra on any + // version counts for the item itself + IQueryable<Guid> WithPrimaryVersions(IQueryable<Guid> ownerIds) + => ownerIds.Concat(context.BaseItems + .Where(version => version.PrimaryVersionId != null && ownerIds.Contains(version.Id)) + .Select(version => version.PrimaryVersionId!.Value)); + + if (filter.HasSpecialFeature.HasValue) + { + var itemsWithExtras = WithPrimaryVersions(context.BaseItems + .Where(extra => extra.OwnerId != null + && extra.ExtraType != null + && extra.ExtraType != BaseItemExtraType.Unknown + && extra.ExtraType != BaseItemExtraType.Trailer + && extra.ExtraType != BaseItemExtraType.ThemeSong + && extra.ExtraType != BaseItemExtraType.ThemeVideo) + .Select(extra => extra.OwnerId!.Value)) + .Distinct(); + + Expression<Func<BaseItemEntity, bool>> hasExtras = e => itemsWithExtras.Contains(e.Id); + + baseQuery = filter.HasSpecialFeature.Value + ? baseQuery.WhereItemOrDescendantMatches(context, hasExtras) + : baseQuery.WhereNeitherItemNorDescendantMatches(context, hasExtras); + } + + if (filter.HasTrailer.HasValue) + { + var trailerOwnerIds = WithPrimaryVersions(context.BaseItems + .Where(extra => extra.ExtraType == BaseItemExtraType.Trailer && extra.OwnerId != null) + .Select(extra => extra.OwnerId!.Value)); + + Expression<Func<BaseItemEntity, bool>> hasTrailer = e => trailerOwnerIds.Contains(e.Id); + + baseQuery = filter.HasTrailer.Value + ? baseQuery.WhereItemOrDescendantMatches(context, hasTrailer) + : baseQuery.WhereNeitherItemNorDescendantMatches(context, hasTrailer); + } + + if (filter.HasThemeSong.HasValue) + { + var themeSongOwnerIds = WithPrimaryVersions(context.BaseItems + .Where(extra => extra.ExtraType == BaseItemExtraType.ThemeSong && extra.OwnerId != null) + .Select(extra => extra.OwnerId!.Value)); + + Expression<Func<BaseItemEntity, bool>> hasThemeSong = e => themeSongOwnerIds.Contains(e.Id); + + baseQuery = filter.HasThemeSong.Value + ? baseQuery.WhereItemOrDescendantMatches(context, hasThemeSong) + : baseQuery.WhereNeitherItemNorDescendantMatches(context, hasThemeSong); + } + + if (filter.HasThemeVideo.HasValue) + { + var themeVideoOwnerIds = WithPrimaryVersions(context.BaseItems + .Where(extra => extra.ExtraType == BaseItemExtraType.ThemeVideo && extra.OwnerId != null) + .Select(extra => extra.OwnerId!.Value)); + + Expression<Func<BaseItemEntity, bool>> hasThemeVideo = e => themeVideoOwnerIds.Contains(e.Id); + + baseQuery = filter.HasThemeVideo.Value + ? baseQuery.WhereItemOrDescendantMatches(context, hasThemeVideo) + : baseQuery.WhereNeitherItemNorDescendantMatches(context, hasThemeVideo); + } + + if (filter.AiredDuringSeason.HasValue) + { + var seasonNumber = filter.AiredDuringSeason.Value; + if (seasonNumber < 1) + { + baseQuery = baseQuery.Where(e => e.ParentIndexNumber == seasonNumber); + } + else + { + var seasonStr = seasonNumber.ToString(CultureInfo.InvariantCulture); + baseQuery = baseQuery.Where(e => + e.ParentIndexNumber == seasonNumber + || (e.Data != null && ( + e.Data.Contains("\"AirsAfterSeasonNumber\":" + seasonStr) + || e.Data.Contains("\"AirsBeforeSeasonNumber\":" + seasonStr)))); + } + } + + return baseQuery; + } +} diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs index bd4ff4e312..1d2aa21853 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs @@ -1,40 +1,15 @@ -#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 System.Threading.Tasks; 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; @@ -51,19 +26,14 @@ namespace Jellyfin.Server.Implementations.Item; /// <summary> /// Handles all storage logic for BaseItems. /// </summary> -public sealed class BaseItemRepository - : IItemRepository +public sealed partial class BaseItemRepository + : IItemRepository, IItemQueryHelpers { /// <summary> /// Gets the placeholder id for UserData detached items. /// </summary> public static readonly Guid PlaceholderId = Guid.Parse("00000000-0000-0000-0000-000000000001"); - /// <summary> - /// This holds all the types in the running assemblies - /// so that we can de-serialize properly when we don't have strong types. - /// </summary> - private static readonly ConcurrentDictionary<string, Type?> _typeMap = new ConcurrentDictionary<string, Type?>(); private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; private readonly IServerApplicationHost _appHost; private readonly IItemTypeLookup _itemTypeLookup; @@ -75,7 +45,31 @@ public sealed class BaseItemRepository private static readonly IReadOnlyList<ItemValueType> _getAlbumArtistValueTypes = [ItemValueType.AlbumArtist]; private static readonly IReadOnlyList<ItemValueType> _getStudiosValueTypes = [ItemValueType.Studios]; private static readonly IReadOnlyList<ItemValueType> _getGenreValueTypes = [ItemValueType.Genre]; - private static readonly IReadOnlyList<char> SearchWildcardTerms = ['%', '_', '[', ']', '^']; + + private static readonly BaseItemKind[] _itemByNameKinds = + [ + BaseItemKind.Person, + BaseItemKind.Genre, + BaseItemKind.MusicGenre, + BaseItemKind.MusicArtist, + BaseItemKind.Studio + ]; + + private static readonly (BaseItemKind Kind, IReadOnlyList<ItemValueType> ValueTypes)[] _itemByNameValueTypes = + [ + (BaseItemKind.Genre, _getGenreValueTypes), + (BaseItemKind.MusicGenre, _getGenreValueTypes), + (BaseItemKind.MusicArtist, _getAllArtistsValueTypes), + (BaseItemKind.Studio, _getStudiosValueTypes) + ]; + + // The only folder kinds whose children form a single viewing sequence, so playback progress on a + // child rolls up to them. Every other folder kind is a container that cannot be resumed. + private static readonly BaseItemKind[] _resumableFolderKinds = + [ + BaseItemKind.Series, + BaseItemKind.Season + ]; /// <summary> /// Initializes a new instance of the <see cref="BaseItemRepository"/> class. @@ -99,1325 +93,44 @@ public sealed class BaseItemRepository _logger = logger; } - /// <inheritdoc /> - public void DeleteItem(params IReadOnlyList<Guid> ids) - { - if (ids is null || ids.Count == 0 || ids.Any(f => f.Equals(PlaceholderId))) - { - throw new ArgumentException("Guid can't be empty or the placeholder id.", nameof(ids)); - } - - using var context = _dbProvider.CreateDbContext(); - using var transaction = context.Database.BeginTransaction(); - - var date = (DateTime?)DateTime.UtcNow; - - var relatedItems = ids.SelectMany(f => TraverseHirachyDown(f, context)).ToArray(); - - // Remove any UserData entries for the placeholder item that would conflict with the UserData - // being detached from the item being deleted. This is necessary because, during an update, - // UserData may be reattached to a new entry, but some entries can be left behind. - // Ensures there are no duplicate UserId/CustomDataKey combinations for the placeholder. - context.UserData - .Join( - context.UserData.WhereOneOrMany(relatedItems, e => e.ItemId), - placeholder => new { placeholder.UserId, placeholder.CustomDataKey }, - userData => new { userData.UserId, userData.CustomDataKey }, - (placeholder, userData) => placeholder) - .Where(e => e.ItemId == PlaceholderId) - .ExecuteDelete(); - - // Detach all user watch data - context.UserData.WhereOneOrMany(relatedItems, e => e.ItemId) - .ExecuteUpdate(e => e - .SetProperty(f => f.RetentionDate, date) - .SetProperty(f => f.ItemId, PlaceholderId)); - - context.AncestorIds.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); - context.AncestorIds.WhereOneOrMany(relatedItems, e => e.ParentItemId).ExecuteDelete(); - context.AttachmentStreamInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); - context.BaseItemImageInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); - context.BaseItemMetadataFields.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); - context.BaseItemProviders.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); - context.BaseItemTrailerTypes.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); - context.BaseItems.WhereOneOrMany(relatedItems, e => e.Id).ExecuteDelete(); - context.Chapters.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); - context.CustomItemDisplayPreferences.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); - context.ItemDisplayPreferences.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); - context.ItemValues.Where(e => e.BaseItemsMap!.Count == 0).ExecuteDelete(); - context.ItemValuesMap.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); - context.KeyframeData.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); - context.MediaSegments.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); - context.MediaStreamInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); - var query = context.PeopleBaseItemMap.WhereOneOrMany(relatedItems, e => e.ItemId).Select(f => f.PeopleId).Distinct().ToArray(); - context.PeopleBaseItemMap.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); - context.Peoples.WhereOneOrMany(query, e => e.Id).Where(e => e.BaseItems!.Count == 0).ExecuteDelete(); - context.TrickplayInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); - context.SaveChanges(); - transaction.Commit(); - } - - /// <inheritdoc /> - 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(); - } - - /// <inheritdoc /> - public IReadOnlyList<Guid> GetItemIdsList(InternalItemsQuery filter) - { - ArgumentNullException.ThrowIfNull(filter); - PrepareFilterQuery(filter); - - using var context = _dbProvider.CreateDbContext(); - return ApplyQueryFilter(context.BaseItems.AsNoTracking().Where(e => e.Id != EF.Constant(PlaceholderId)), context, filter).Select(e => e.Id).ToArray(); - } - - /// <inheritdoc /> - public QueryResult<(BaseItemDto Item, ItemCounts? ItemCounts)> GetAllArtists(InternalItemsQuery filter) - { - return GetItemValues(filter, _getAllArtistsValueTypes, _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]); - } - - /// <inheritdoc /> - public QueryResult<(BaseItemDto Item, ItemCounts? ItemCounts)> GetArtists(InternalItemsQuery filter) - { - return GetItemValues(filter, _getArtistValueTypes, _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]); - } - - /// <inheritdoc /> - public QueryResult<(BaseItemDto Item, ItemCounts? ItemCounts)> GetAlbumArtists(InternalItemsQuery filter) - { - return GetItemValues(filter, _getAlbumArtistValueTypes, _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]); - } - - /// <inheritdoc /> - public QueryResult<(BaseItemDto Item, ItemCounts? ItemCounts)> GetStudios(InternalItemsQuery filter) - { - return GetItemValues(filter, _getStudiosValueTypes, _itemTypeLookup.BaseItemKindNames[BaseItemKind.Studio]); - } - - /// <inheritdoc /> - public QueryResult<(BaseItemDto Item, ItemCounts? ItemCounts)> GetGenres(InternalItemsQuery filter) - { - return GetItemValues(filter, _getGenreValueTypes, _itemTypeLookup.BaseItemKindNames[BaseItemKind.Genre]); - } - - /// <inheritdoc /> - public QueryResult<(BaseItemDto Item, ItemCounts? ItemCounts)> GetMusicGenres(InternalItemsQuery filter) - { - return GetItemValues(filter, _getGenreValueTypes, _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicGenre]); - } - - /// <inheritdoc /> - public IReadOnlyList<string> GetStudioNames() - { - return GetItemValueNames(_getStudiosValueTypes, [], []); - } - - /// <inheritdoc /> - public IReadOnlyList<string> GetAllArtistNames() - { - return GetItemValueNames(_getAllArtistsValueTypes, [], []); - } - - /// <inheritdoc /> - public IReadOnlyList<string> GetMusicGenreNames() - { - return GetItemValueNames( - _getGenreValueTypes, - _itemTypeLookup.MusicGenreTypes, - []); - } - - /// <inheritdoc /> - public IReadOnlyList<string> GetGenreNames() - { - return GetItemValueNames( - _getGenreValueTypes, - [], - _itemTypeLookup.MusicGenreTypes); - } - - /// <inheritdoc /> - public QueryResult<BaseItemDto> GetItems(InternalItemsQuery filter) - { - ArgumentNullException.ThrowIfNull(filter); - if (!filter.EnableTotalRecordCount || (!filter.Limit.HasValue && (filter.StartIndex ?? 0) == 0)) - { - var returnList = GetItemList(filter); - return new QueryResult<BaseItemDto>( - filter.StartIndex, - returnList.Count, - returnList); - } - - PrepareFilterQuery(filter); - var result = new QueryResult<BaseItemDto>(); - - using var context = _dbProvider.CreateDbContext(); - - IQueryable<BaseItemEntity> dbQuery = PrepareItemQuery(context, filter); - - dbQuery = TranslateQuery(dbQuery, context, filter); - dbQuery = ApplyGroupingFilter(context, dbQuery, filter); - - if (filter.EnableTotalRecordCount) - { - result.TotalRecordCount = dbQuery.Count(); - } - - dbQuery = ApplyQueryPaging(dbQuery, filter); - dbQuery = ApplyNavigations(dbQuery, filter); - - result.Items = dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray(); - result.StartIndex = filter.StartIndex ?? 0; - return result; - } - - /// <inheritdoc /> - public IReadOnlyList<BaseItemDto> GetItemList(InternalItemsQuery filter) - { - ArgumentNullException.ThrowIfNull(filter); - PrepareFilterQuery(filter); - - using var context = _dbProvider.CreateDbContext(); - IQueryable<BaseItemEntity> dbQuery = PrepareItemQuery(context, filter); - - dbQuery = TranslateQuery(dbQuery, context, filter); - - dbQuery = ApplyGroupingFilter(context, dbQuery, filter); - dbQuery = ApplyQueryPaging(dbQuery, filter); - - var hasRandomSort = filter.OrderBy.Any(e => e.OrderBy == ItemSortBy.Random); - if (hasRandomSort) - { - var orderedIds = dbQuery.Select(e => e.Id).ToList(); - if (orderedIds.Count == 0) - { - return Array.Empty<BaseItemDto>(); - } - - var itemsById = ApplyNavigations(context.BaseItems.Where(e => orderedIds.Contains(e.Id)), filter) - .AsEnumerable() - .Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)) - .Where(dto => dto is not null) - .ToDictionary(i => i!.Id); - - return orderedIds.Where(itemsById.ContainsKey).Select(id => itemsById[id]).ToArray()!; - } - - dbQuery = ApplyNavigations(dbQuery, filter); - - return dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray(); - } - - /// <inheritdoc/> - public IReadOnlyList<BaseItem> 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<BaseItem>(); - } - - 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(context, mainquery, filter); - mainquery = ApplyQueryPaging(mainquery, filter); - - mainquery = ApplyNavigations(mainquery, filter); - - return mainquery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray(); - } - - /// <inheritdoc /> - public IReadOnlyList<string> 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().Where(e => e.ItemId != EF.Constant(PlaceholderId)), - 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<BaseItemEntity> ApplyGroupingFilter(JellyfinDbContext context, IQueryable<BaseItemEntity> 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) - { - var tempQuery = dbQuery.GroupBy(e => new { e.PresentationUniqueKey, e.SeriesPresentationUniqueKey }).Select(e => e.FirstOrDefault()).Select(e => e!.Id); - dbQuery = context.BaseItems.Where(e => tempQuery.Contains(e.Id)); - } - else if (enableGroupByPresentationUniqueKey) - { - var tempQuery = dbQuery.GroupBy(e => e.PresentationUniqueKey).Select(e => e.FirstOrDefault()).Select(e => e!.Id); - dbQuery = context.BaseItems.Where(e => tempQuery.Contains(e.Id)); - } - else if (filter.GroupBySeriesPresentationUniqueKey) - { - var tempQuery = dbQuery.GroupBy(e => e.SeriesPresentationUniqueKey).Select(e => e.FirstOrDefault()).Select(e => e!.Id); - dbQuery = context.BaseItems.Where(e => tempQuery.Contains(e.Id)); - } - else - { - dbQuery = dbQuery.Distinct(); - } - - dbQuery = ApplyOrder(dbQuery, filter, context); - - return dbQuery; - } - - private static IQueryable<BaseItemEntity> ApplyNavigations(IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter) - { - if (filter.TrailerTypes.Length > 0 || filter.IncludeItemTypes.Contains(BaseItemKind.Trailer)) - { - dbQuery = dbQuery.Include(e => e.TrailerTypes); - } - - if (filter.DtoOptions.ContainsField(ItemFields.ProviderIds)) - { - dbQuery = dbQuery.Include(e => e.Provider); - } - - if (filter.DtoOptions.ContainsField(ItemFields.Settings)) - { - dbQuery = dbQuery.Include(e => e.LockedFields); - } - - if (filter.DtoOptions.EnableUserData) - { - dbQuery = dbQuery.Include(e => e.UserData); - } - - if (filter.DtoOptions.EnableImages) - { - dbQuery = dbQuery.Include(e => e.Images); - } - - return dbQuery; - } - - private IQueryable<BaseItemEntity> ApplyQueryPaging(IQueryable<BaseItemEntity> 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<BaseItemEntity> ApplyQueryFilter(IQueryable<BaseItemEntity> dbQuery, JellyfinDbContext context, InternalItemsQuery filter) - { - dbQuery = TranslateQuery(dbQuery, context, filter); - dbQuery = ApplyGroupingFilter(context, dbQuery, filter); - dbQuery = ApplyQueryPaging(dbQuery, filter); - dbQuery = ApplyNavigations(dbQuery, filter); - return dbQuery; - } - - private IQueryable<BaseItemEntity> PrepareItemQuery(JellyfinDbContext context, InternalItemsQuery filter) - { - IQueryable<BaseItemEntity> dbQuery = context.BaseItems.AsNoTracking(); - dbQuery = dbQuery.AsSingleQuery(); - - return dbQuery; - } - - /// <inheritdoc/> - 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(); - } - - /// <inheritdoc /> - public ItemCounts GetItemCounts(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); - - var counts = dbQuery - .GroupBy(x => x.Type) - .Select(x => new { x.Key, Count = x.Count() }) - .ToArray(); - - var lookup = _itemTypeLookup.BaseItemKindNames; - var result = new ItemCounts(); - foreach (var count in counts) - { - if (string.Equals(count.Key, lookup[BaseItemKind.MusicAlbum], StringComparison.Ordinal)) - { - result.AlbumCount = count.Count; - } - else if (string.Equals(count.Key, lookup[BaseItemKind.MusicArtist], StringComparison.Ordinal)) - { - result.ArtistCount = count.Count; - } - else if (string.Equals(count.Key, lookup[BaseItemKind.Episode], StringComparison.Ordinal)) - { - result.EpisodeCount = count.Count; - } - else if (string.Equals(count.Key, lookup[BaseItemKind.Movie], StringComparison.Ordinal)) - { - result.MovieCount = count.Count; - } - else if (string.Equals(count.Key, lookup[BaseItemKind.MusicVideo], StringComparison.Ordinal)) - { - result.MusicVideoCount = count.Count; - } - else if (string.Equals(count.Key, lookup[BaseItemKind.LiveTvProgram], StringComparison.Ordinal)) - { - result.ProgramCount = count.Count; - } - else if (string.Equals(count.Key, lookup[BaseItemKind.Series], StringComparison.Ordinal)) - { - result.SeriesCount = count.Count; - } - else if (string.Equals(count.Key, lookup[BaseItemKind.Audio], StringComparison.Ordinal)) - { - result.SongCount = count.Count; - } - else if (string.Equals(count.Key, lookup[BaseItemKind.Trailer], StringComparison.Ordinal)) - { - result.TrailerCount = count.Count; - } - } - - return result; - } - -#pragma warning disable CA1307 // Specify StringComparison for clarity /// <summary> - /// Gets the type. + /// Maps a Entity to the DTO. Delegates to <see cref="BaseItemMapper"/>. /// </summary> - /// <param name="typeName">Name of the type.</param> - /// <returns>Type.</returns> - /// <exception cref="ArgumentNullException"><c>typeName</c> is null.</exception> - 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)); - } - - /// <inheritdoc /> - public void SaveImages(BaseItemDto item) - { - ArgumentNullException.ThrowIfNull(item); - - var images = item.ImageInfos.Select(e => Map(item.Id, e)); - using var context = _dbProvider.CreateDbContext(); - - if (!context.BaseItems.Any(bi => bi.Id == item.Id)) - { - _logger.LogWarning("Unable to save ImageInfo for non existing BaseItem"); - return; - } - - context.BaseItemImageInfos.Where(e => e.ItemId == item.Id).ExecuteDelete(); - context.BaseItemImageInfos.AddRange(images); - context.SaveChanges(); - } - - /// <inheritdoc /> - public void SaveItems(IReadOnlyList<BaseItemDto> items, CancellationToken cancellationToken) - { - UpdateOrInsertItems(items, cancellationToken); - } - - /// <inheritdoc cref="IItemRepository"/> - public void UpdateOrInsertItems(IReadOnlyList<BaseItemDto> items, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(items); - cancellationToken.ThrowIfCancellationRequested(); - - var tuples = new List<(BaseItemDto Item, List<Guid>? AncestorIds, BaseItemDto TopParent, IEnumerable<string> UserDataKey, List<string> InheritedTags)>(); - foreach (var item in items.GroupBy(e => e.Id).Select(e => e.Last()).Where(e => e.Id != PlaceholderId)) - { - 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)); - } - - using var context = _dbProvider.CreateDbContext(); - using var transaction = context.Database.BeginTransaction(); - - var ids = tuples.Select(f => f.Item.Id).ToArray(); - var existingItems = context.BaseItems.Where(e => ids.Contains(e.Id)).Select(f => f.Id).ToArray(); - - foreach (var item in tuples) - { - var entity = Map(item.Item); - // TODO: refactor this "inconsistency" - entity.TopParentId = item.TopParent?.Id; - - if (!existingItems.Any(e => e == entity.Id)) - { - context.BaseItems.Add(entity); - } - else - { - context.BaseItemProviders.Where(e => e.ItemId == entity.Id).ExecuteDelete(); - context.BaseItemImageInfos.Where(e => e.ItemId == entity.Id).ExecuteDelete(); - context.BaseItemMetadataFields.Where(e => e.ItemId == entity.Id).ExecuteDelete(); - - if (entity.Images is { Count: > 0 }) - { - context.BaseItemImageInfos.AddRange(entity.Images); - } - - if (entity.LockedFields is { Count: > 0 }) - { - context.BaseItemMetadataFields.AddRange(entity.LockedFields); - } - - context.BaseItems.Attach(entity).State = EntityState.Modified; - } - } - - context.SaveChanges(); - - var itemValueMaps = tuples - .Select(e => (e.Item, Values: GetItemValuesToSave(e.Item, e.InheritedTags))) - .ToArray(); - var allListedItemValues = itemValueMaps - .SelectMany(f => f.Values) - .Distinct() - .ToArray(); - var existingValues = context.ItemValues - .Select(e => new - { - item = e, - Key = e.Type + "+" + e.Value - }) - .Where(f => allListedItemValues.Select(e => $"{(int)e.MagicNumber}+{e.Value}").Contains(f.Key)) - .Select(e => e.item) - .ToArray(); - var missingItemValues = allListedItemValues.Except(existingValues.Select(f => (MagicNumber: f.Type, f.Value))).Select(f => new ItemValue() - { - CleanValue = GetCleanValue(f.Value), - ItemValueId = Guid.NewGuid(), - Type = f.MagicNumber, - Value = f.Value - }).ToArray(); - context.ItemValues.AddRange(missingItemValues); - context.SaveChanges(); - - var itemValuesStore = existingValues.Concat(missingItemValues).ToArray(); - var valueMap = itemValueMaps - .Select(f => (f.Item, Values: f.Values.Select(e => itemValuesStore.First(g => g.Value == e.Value && g.Type == e.MagicNumber)).DistinctBy(e => e.ItemValueId).ToArray())) - .ToArray(); - - var mappedValues = context.ItemValuesMap.Where(e => ids.Contains(e.ItemId)).ToList(); - - foreach (var item in valueMap) - { - var itemMappedValues = mappedValues.Where(e => e.ItemId == item.Item.Id).ToList(); - foreach (var itemValue in item.Values) - { - var existingItem = itemMappedValues.FirstOrDefault(f => f.ItemValueId == itemValue.ItemValueId); - if (existingItem is null) - { - context.ItemValuesMap.Add(new ItemValueMap() - { - Item = null!, - ItemId = item.Item.Id, - ItemValue = null!, - ItemValueId = itemValue.ItemValueId - }); - } - else - { - // map exists, remove from list so its been handled. - itemMappedValues.Remove(existingItem); - } - } - - // all still listed values are not in the new list so remove them. - context.ItemValuesMap.RemoveRange(itemMappedValues); - } - - context.SaveChanges(); - - foreach (var item in tuples) - { - if (item.Item.SupportsAncestors && item.AncestorIds != null) - { - var existingAncestorIds = context.AncestorIds.Where(e => e.ItemId == item.Item.Id).ToList(); - var validAncestorIds = context.BaseItems.Where(e => item.AncestorIds.Contains(e.Id)).Select(f => f.Id).ToArray(); - foreach (var ancestorId in validAncestorIds) - { - var existingAncestorId = existingAncestorIds.FirstOrDefault(e => e.ParentItemId == ancestorId); - if (existingAncestorId is null) - { - context.AncestorIds.Add(new AncestorId() - { - ParentItemId = ancestorId, - ItemId = item.Item.Id, - Item = null!, - ParentItem = null! - }); - } - else - { - existingAncestorIds.Remove(existingAncestorId); - } - } - - context.AncestorIds.RemoveRange(existingAncestorIds); - } - } - - context.SaveChanges(); - transaction.Commit(); - } - - /// <inheritdoc /> - public async Task ReattachUserDataAsync(BaseItemDto item, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(item); - cancellationToken.ThrowIfCancellationRequested(); - - var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); - - await using (dbContext.ConfigureAwait(false)) - { - var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); - await using (transaction.ConfigureAwait(false)) - { - var userKeys = item.GetUserDataKeys().ToArray(); - var retentionDate = (DateTime?)null; - - await dbContext.UserData - .Where(e => e.ItemId == PlaceholderId) - .Where(e => userKeys.Contains(e.CustomDataKey)) - .ExecuteUpdateAsync( - e => e - .SetProperty(f => f.ItemId, item.Id) - .SetProperty(f => f.RetentionDate, retentionDate), - cancellationToken).ConfigureAwait(false); - - // Rehydrate the cached userdata - item.UserData = await dbContext.UserData - .AsNoTracking() - .Where(e => e.ItemId == item.Id) - .ToArrayAsync(cancellationToken) - .ConfigureAwait(false); - - await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); - } - } - } - - /// <inheritdoc /> - public BaseItemDto? RetrieveItem(Guid id) - { - if (id.IsEmpty()) - { - throw new ArgumentException("Guid can't be empty", nameof(id)); - } - - using var context = _dbProvider.CreateDbContext(); - var dbQuery = PrepareItemQuery(context, new() - { - DtoOptions = new() - { - EnableImages = true - } - }); - dbQuery = dbQuery.Include(e => e.TrailerTypes) - .Include(e => e.Provider) - .Include(e => e.LockedFields) - .Include(e => e.UserData) - .Include(e => e.Images); - - var item = dbQuery.FirstOrDefault(e => e.Id == id); - if (item is null) - { - return null; - } - - return DeserializeBaseItem(item); - } - - /// <summary> - /// Maps a Entity to the DTO. - /// </summary> - /// <param name="entity">The entity.</param> - /// <param name="dto">The dto base instance.</param> - /// <param name="appHost">The Application server Host.</param> - /// <param name="logger">The applogger.</param> - /// <returns>The dto to map.</returns> + /// <param name="entity">The database entity.</param> + /// <param name="dto">The target DTO.</param> + /// <param name="appHost">The application host.</param> + /// <param name="logger">The logger.</param> + /// <returns>The mapped DTO.</returns> public static BaseItemDto Map(BaseItemEntity entity, BaseItemDto dto, IServerApplicationHost? appHost, ILogger logger) { - 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 = string.IsNullOrWhiteSpace(entity.Genres) ? [] : entity.Genres.Split('|'); - dto.DateCreated = entity.DateCreated ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc); - dto.DateModified = entity.DateModified ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc); - dto.ChannelId = entity.ChannelId ?? Guid.Empty; - dto.DateLastRefreshed = entity.DateLastRefreshed ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc); - dto.DateLastSaved = entity.DateLastSaved ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc); - 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(); - dto.UserData = entity.UserData; - - 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('|', StringSplitOptions.RemoveEmptyEntries) ?? []; - dto.Studios = entity.Studios?.Split('|') ?? []; - dto.Tags = string.IsNullOrWhiteSpace(entity.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<MediaType>(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 ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc); - } - - return dto; + return BaseItemMapper.Map(entity, dto, appHost); } /// <summary> - /// Maps a Entity to the DTO. + /// Maps a DTO to a database entity. Delegates to <see cref="BaseItemMapper"/>. /// </summary> - /// <param name="dto">The entity.</param> - /// <returns>The dto to map.</returns> + /// <param name="dto">The DTO to map.</param> + /// <returns>The mapped database entity.</returns> 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 == DateTime.MinValue ? null : dto.DateCreated; - entity.DateModified = dto.DateModified == DateTime.MinValue ? null : dto.DateModified; - entity.ChannelId = dto.ChannelId; - entity.DateLastRefreshed = dto.DateLastRefreshed == DateTime.MinValue ? null : dto.DateLastRefreshed; - entity.DateLastSaved = dto.DateLastSaved == DateTime.MinValue ? null : 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.Where(p => !string.IsNullOrWhiteSpace(p))) : 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 == DateTime.MinValue ? null : folder.DateLastMediaAdded; - entity.IsFolder = folder.IsFolder; - } - - return entity; - } - - private string[] GetItemValueNames(IReadOnlyList<ItemValueType> itemValueTypes, IReadOnlyList<string> withItemTypes, IReadOnlyList<string> 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<RequiresSourceSerialisationAttribute>() == null; - } - - private BaseItemDto DeserializeBaseItem(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.DeserializeBaseItem( - baseItemEntity, - _logger, - _appHost, - skipDeserialization || (_serverConfigurationManager.Configuration.SkipDeserializationForBasicTypes && (typeToSerialise == typeof(Channel) || typeToSerialise == typeof(UserRootFolder)))); + return BaseItemMapper.Map(dto, _appHost); } /// <summary> /// Deserializes a BaseItemEntity and sets all properties. /// </summary> - /// <param name="baseItemEntity">The DB entity.</param> - /// <param name="logger">Logger.</param> - /// <param name="appHost">The application server Host.</param> - /// <param name="skipDeserialization">If only mapping should be processed.</param> - /// <returns>A mapped BaseItem.</returns> - /// <exception cref="InvalidOperationException">Will be thrown if an invalid serialisation is requested.</exception> - public static BaseItemDto DeserializeBaseItem(BaseItemEntity baseItemEntity, ILogger logger, IServerApplicationHost? appHost, bool skipDeserialization = false) + /// <param name="baseItemEntity">The entity to deserialize.</param> + /// <param name="logger">The logger.</param> + /// <param name="appHost">The application host.</param> + /// <param name="skipDeserialization">Whether to skip JSON deserialization.</param> + /// <returns>The deserialized item, or null.</returns> + public static BaseItemDto? DeserializeBaseItem(BaseItemEntity baseItemEntity, ILogger logger, IServerApplicationHost? appHost, bool skipDeserialization = false) { - var type = GetType(baseItemEntity.Type) ?? throw new InvalidOperationException("Cannot deserialize 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 deserialize unknown type."); - } - - return Map(baseItemEntity, dto, appHost, logger); + return BaseItemMapper.DeserializeBaseItem(baseItemEntity, logger, appHost, skipDeserialization); } - private QueryResult<(BaseItemDto Item, ItemCounts? ItemCounts)> GetItemValues(InternalItemsQuery filter, IReadOnlyList<ItemValueType> itemValueTypes, string returnType) - { - ArgumentNullException.ThrowIfNull(filter); - - if (!filter.Limit.HasValue) - { - filter.EnableTotalRecordCount = false; - } - - using var context = _dbProvider.CreateDbContext(); - - var innerQueryFilter = TranslateQuery(context.BaseItems.Where(e => e.Id != EF.Constant(PlaceholderId)), context, new InternalItemsQuery(filter.User) - { - ExcludeItemTypes = filter.ExcludeItemTypes, - IncludeItemTypes = filter.IncludeItemTypes, - MediaTypes = filter.MediaTypes, - AncestorIds = filter.AncestorIds, - ItemIds = filter.ItemIds, - TopParentIds = filter.TopParentIds, - ParentId = filter.ParentId, - IsAiring = filter.IsAiring, - IsMovie = filter.IsMovie, - IsSports = filter.IsSports, - IsKids = filter.IsKids, - IsNews = filter.IsNews, - IsSeries = filter.IsSeries - }); - - var itemValuesQuery = context.ItemValues - .Where(f => itemValueTypes.Contains(f.Type)) - .SelectMany(f => f.BaseItemsMap!, (f, w) => new { f, w }) - .Join( - innerQueryFilter, - fw => fw.w.ItemId, - g => g.Id, - (fw, g) => fw.f.CleanValue); - - var innerQuery = PrepareItemQuery(context, filter) - .Where(e => e.Type == returnType) - .Where(e => itemValuesQuery.Contains(e.CleanName)); - - var outerQueryFilter = new InternalItemsQuery(filter.User) - { - IsPlayed = filter.IsPlayed, - IsFavorite = filter.IsFavorite, - IsFavoriteOrLiked = filter.IsFavoriteOrLiked, - IsLiked = filter.IsLiked, - IsLocked = filter.IsLocked, - NameLessThan = filter.NameLessThan, - NameStartsWith = filter.NameStartsWith, - NameStartsWithOrGreater = filter.NameStartsWithOrGreater, - Tags = filter.Tags, - OfficialRatings = filter.OfficialRatings, - StudioIds = filter.StudioIds, - GenreIds = filter.GenreIds, - Genres = filter.Genres, - Years = filter.Years, - NameContains = filter.NameContains, - SearchTerm = filter.SearchTerm, - ExcludeItemIds = filter.ExcludeItemIds - }; - - var masterQuery = TranslateQuery(innerQuery, context, outerQueryFilter) - .GroupBy(e => e.PresentationUniqueKey) - .Select(e => e.FirstOrDefault()) - .Select(e => e!.Id); - - var query = context.BaseItems - .Include(e => e.TrailerTypes) - .Include(e => e.Provider) - .Include(e => e.LockedFields) - .Include(e => e.Images) - .AsSingleQuery() - .Where(e => masterQuery.Contains(e.Id)); - - query = ApplyOrder(query, filter, context); - - var result = new QueryResult<(BaseItemDto, ItemCounts?)>(); - if (filter.EnableTotalRecordCount) - { - result.TotalRecordCount = query.Count(); - } - - 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); - } - } - - IQueryable<BaseItemEntity>? itemCountQuery = null; - - if (filter.IncludeItemTypes.Length > 0) - { - // if we are to include more then one type, sub query those items beforehand. - - var typeSubQuery = new InternalItemsQuery(filter.User) - { - ExcludeItemTypes = filter.ExcludeItemTypes, - IncludeItemTypes = filter.IncludeItemTypes, - MediaTypes = filter.MediaTypes, - AncestorIds = filter.AncestorIds, - ExcludeItemIds = filter.ExcludeItemIds, - ItemIds = filter.ItemIds, - TopParentIds = filter.TopParentIds, - ParentId = filter.ParentId, - IsPlayed = filter.IsPlayed - }; - - itemCountQuery = TranslateQuery(context.BaseItems.AsNoTracking().Where(e => e.Id != EF.Constant(PlaceholderId)), context, typeSubQuery) - .Where(e => e.ItemValues!.Any(f => itemValueTypes!.Contains(f.ItemValue.Type))); - - 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 = itemCountQuery!.Count(f => f.Type == seriesTypeName), - EpisodeCount = itemCountQuery!.Count(f => f.Type == episodeTypeName), - MovieCount = itemCountQuery!.Count(f => f.Type == movieTypeName), - AlbumCount = itemCountQuery!.Count(f => f.Type == musicAlbumTypeName), - ArtistCount = itemCountQuery!.Count(f => f.Type == musicArtistTypeName), - SongCount = itemCountQuery!.Count(f => f.Type == audioTypeName), - TrailerCount = itemCountQuery!.Count(f => f.Type == trailerTypeName), - } - }); - - result.StartIndex = filter.StartIndex ?? 0; - result.Items = - [ - .. resultQuery - .AsEnumerable() - .Where(e => e is not null) - .Select(e => - { - return (DeserializeBaseItem(e.item, filter.SkipDeserialization), e.itemCount); - }) - ]; - } - else - { - result.StartIndex = filter.StartIndex ?? 0; - result.Items = - [ - .. query - .AsEnumerable() - .Where(e => e is not null) - .Select<BaseItemEntity, (BaseItemDto, ItemCounts?)>(e => - { - return (DeserializeBaseItem(e, filter.SkipDeserialization), null); - }) - ]; - } - - return result; - } - - private static void PrepareFilterQuery(InternalItemsQuery query) + /// <inheritdoc /> + public void PrepareFilterQuery(InternalItemsQuery query) { if (query.Limit.HasValue && query.EnableGroupByMetadataKey) { @@ -1430,83 +143,6 @@ public sealed class BaseItemRepository } } - private string GetCleanValue(string value) - { - if (string.IsNullOrWhiteSpace(value)) - { - return value; - } - - return value.RemoveDiacritics().ToLowerInvariant(); - } - - private List<(ItemValueType MagicNumber, string Value)> GetItemValuesToSave(BaseItemDto item, List<string> inheritedTags) - { - var list = new List<(ItemValueType, string)>(); - - if (item is IHasArtist hasArtist) - { - list.AddRange(hasArtist.Artists.Select(i => ((ItemValueType)0, i))); - } - - if (item is IHasAlbumArtist hasAlbumArtist) - { - list.AddRange(hasAlbumArtist.AlbumArtists.Select(i => (ItemValueType.AlbumArtist, i))); - } - - list.AddRange(item.Genres.Select(i => (ItemValueType.Genre, i))); - list.AddRange(item.Studios.Select(i => (ItemValueType.Studios, i))); - list.AddRange(item.Tags.Select(i => (ItemValueType.Tags, i))); - - // keywords was 5 - - list.AddRange(inheritedTags.Select(i => (ItemValueType.InheritedTags, 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 ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc), - 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<string> GetItemByNameTypesInQuery(InternalItemsQuery query) { var list = new List<string>(); @@ -1556,6 +192,14 @@ public sealed class BaseItemRepository return false; } + // Resume queries surface the actually-played version (which may be an alternate sharing the + // primary's presentation key). The resumable filter already keeps one version per group, so + // presentation-key grouping must not collapse the surfaced version back onto the primary. + if (query.IsResumable == true) + { + return false; + } + if (query.GroupBySeriesPresentationUniqueKey) { return false; @@ -1584,1104 +228,40 @@ public sealed class BaseItemRepository || query.IncludeItemTypes.Contains(BaseItemKind.Season); } - private IQueryable<BaseItemEntity> ApplyOrder(IQueryable<BaseItemEntity> query, InternalItemsQuery filter, JellyfinDbContext context) + private static BaseItemImageInfo Map(Guid baseItemId, ItemImageInfo e) { - var orderBy = filter.OrderBy.Where(e => e.OrderBy != ItemSortBy.Default).ToArray(); - var hasSearch = !string.IsNullOrEmpty(filter.SearchTerm); - - if (hasSearch) - { - orderBy = [(ItemSortBy.SortName, SortOrder.Ascending), .. orderBy]; - } - else if (orderBy.Length == 0) - { - return query.OrderBy(e => e.SortName); - } - - IOrderedQueryable<BaseItemEntity>? orderedQuery = null; - - // When searching, prioritize by match quality: exact match > prefix match > contains - if (hasSearch) - { - orderedQuery = query.OrderBy(OrderMapper.MapSearchRelevanceOrder(filter.SearchTerm!)); - } - - var firstOrdering = orderBy.FirstOrDefault(); - if (firstOrdering != default) - { - var expression = OrderMapper.MapOrderByField(firstOrdering.OrderBy, filter, context); - if (orderedQuery is null) - { - // No search relevance ordering, start fresh - orderedQuery = firstOrdering.SortOrder == SortOrder.Ascending - ? query.OrderBy(expression) - : query.OrderByDescending(expression); - } - else - { - // Search relevance ordering already applied, chain with ThenBy - orderedQuery = firstOrdering.SortOrder == SortOrder.Ascending - ? orderedQuery.ThenBy(expression) - : orderedQuery.ThenByDescending(expression); - } - - if (firstOrdering.OrderBy is ItemSortBy.Default or ItemSortBy.SortName) - { - orderedQuery = firstOrdering.SortOrder is SortOrder.Ascending - ? orderedQuery.ThenBy(e => e.Name) - : orderedQuery.ThenByDescending(e => e.Name); - } - } - - foreach (var item in orderBy.Skip(1)) - { - var expression = OrderMapper.MapOrderByField(item.OrderBy, filter, context); - if (item.SortOrder == SortOrder.Ascending) - { - orderedQuery = orderedQuery!.ThenBy(expression); - } - else - { - orderedQuery = orderedQuery!.ThenByDescending(expression); - } - } - - return orderedQuery ?? query; + return BaseItemMapper.MapImageToEntity(baseItemId, e); } - private IQueryable<BaseItemEntity> TranslateQuery( - IQueryable<BaseItemEntity> baseQuery, - JellyfinDbContext context, - InternalItemsQuery filter) + private static ItemImageInfo Map(BaseItemImageInfo e, IServerApplicationHost? appHost) { - 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.HasValue) - { - var shouldIncludeAllMovieTypes = filter.IsMovie.Value - && (filter.IncludeItemTypes.Length == 0 - || filter.IncludeItemTypes.Contains(BaseItemKind.Movie) - || filter.IncludeItemTypes.Contains(BaseItemKind.Trailer)); - - if (!shouldIncludeAllMovieTypes) - { - baseQuery = baseQuery.Where(e => e.IsMovie == filter.IsMovie.Value); - } - } - - 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 cleanedSearchTerm = GetCleanValue(filter.SearchTerm); - var originalSearchTerm = filter.SearchTerm.ToLower(); - if (SearchWildcardTerms.Any(f => cleanedSearchTerm.Contains(f))) - { - cleanedSearchTerm = $"%{cleanedSearchTerm.Trim('%')}%"; - baseQuery = baseQuery.Where(e => EF.Functions.Like(e.CleanName!, cleanedSearchTerm) || (e.OriginalTitle != null && EF.Functions.Like(e.OriginalTitle.ToLower(), originalSearchTerm))); - } - else - { - baseQuery = baseQuery.Where(e => e.CleanName!.Contains(cleanedSearchTerm) || (e.OriginalTitle != null && e.OriginalTitle.ToLower().Contains(originalSearchTerm))); - } - } - - 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<string>(); - 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 - { - string[] types = includeTypes.Select(f => _itemTypeLookup.BaseItemKindNames.GetValueOrDefault(f)).Where(e => e != null).ToArray()!; - baseQuery = baseQuery.WhereOneOrMany(types, f => f.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)) - { - var pathToQuery = GetPathToSave(filter.Path); - baseQuery = baseQuery.Where(e => e.Path == pathToQuery); - } - - 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) - { - var peopleEntityIds = context.BaseItems - .WhereOneOrMany(filter.PersonIds, b => b.Id) - .Join( - context.Peoples, - b => b.Name, - p => p.Name, - (b, p) => p.Id); - - baseQuery = baseQuery - .Where(e => context.PeopleBaseItemMap - .Any(m => m.ItemId == e.Id && peopleEntityIds.Contains(m.PeopleId))); - } - - 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)) - { - if (filter.UseRawName == true) - { - baseQuery = baseQuery.Where(e => e.Name == filter.Name); - } - else - { - 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)) - { - if (SearchWildcardTerms.Any(f => nameContains.Contains(f))) - { - nameContains = $"%{nameContains.Trim('%')}%"; - baseQuery = baseQuery.Where(e => EF.Functions.Like(e.CleanName, nameContains) || EF.Functions.Like(e.OriginalTitle, nameContains)); - } - else - { - baseQuery = baseQuery.Where(e => - e.CleanName!.Contains(nameContains) - || e.OriginalTitle!.ToLower().Contains(nameContains!)); - } - } - - if (!string.IsNullOrWhiteSpace(filter.NameStartsWith)) - { - var startsWithLower = filter.NameStartsWith.ToLowerInvariant(); - baseQuery = baseQuery.Where(e => e.SortName!.StartsWith(startsWithLower)); - } - - if (!string.IsNullOrWhiteSpace(filter.NameStartsWithOrGreater)) - { - var startsOrGreaterLower = filter.NameStartsWithOrGreater.ToLowerInvariant(); - baseQuery = baseQuery.Where(e => e.SortName!.CompareTo(startsOrGreaterLower) >= 0); - } - - if (!string.IsNullOrWhiteSpace(filter.NameLessThan)) - { - var lessThanLower = filter.NameLessThan.ToLowerInvariant(); - baseQuery = baseQuery.Where(e => e.SortName!.CompareTo(lessThanLower ) < 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.Id != EF.Constant(PlaceholderId)) - .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.WhereReferencedItemMultipleTypes(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ArtistIds); - } - - if (filter.AlbumArtistIds.Length > 0) - { - baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.AlbumArtist, filter.AlbumArtistIds); - } - - if (filter.ContributingArtistIds.Length > 0) - { - var contributingNames = context.BaseItems - .Where(b => filter.ContributingArtistIds.Contains(b.Id)) - .Select(b => b.CleanName); - - baseQuery = baseQuery.Where(e => - e.ItemValues!.Any(ivm => - ivm.ItemValue.Type == ItemValueType.Artist && - contributingNames.Contains(ivm.ItemValue.CleanValue)) - && - !e.ItemValues!.Any(ivm => - ivm.ItemValue.Type == ItemValueType.AlbumArtist && - contributingNames.Contains(ivm.ItemValue.CleanValue))); - } - - if (filter.AlbumIds.Length > 0) - { - var subQuery = context.BaseItems.WhereOneOrMany(filter.AlbumIds, f => f.Id); - baseQuery = baseQuery.Where(e => subQuery.Any(f => f.Name == e.Album)); - } - - if (filter.ExcludeArtistIds.Length > 0) - { - baseQuery = baseQuery.WhereReferencedItemMultipleTypes(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ExcludeArtistIds, true); - } - - if (filter.GenreIds.Count > 0) - { - baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Genre, filter.GenreIds.ToArray()); - } - - if (filter.Genres.Count > 0) - { - var cleanGenres = filter.Genres.Select(e => GetCleanValue(e)).ToArray().OneOrManyExpressionBuilder<ItemValueMap, string>(f => f.ItemValue.CleanValue); - baseQuery = baseQuery - .Where(e => e.ItemValues!.AsQueryable().Where(f => f.ItemValue.Type == ItemValueType.Genre).Any(cleanGenres)); - } - - if (tags.Count > 0) - { - var cleanValues = tags.Select(e => GetCleanValue(e)).ToArray().OneOrManyExpressionBuilder<ItemValueMap, string>(f => f.ItemValue.CleanValue); - baseQuery = baseQuery - .Where(e => e.ItemValues!.AsQueryable().Where(f => f.ItemValue.Type == ItemValueType.Tags).Any(cleanValues)); - } - - if (excludeTags.Count > 0) - { - var cleanValues = excludeTags.Select(e => GetCleanValue(e)).ToArray().OneOrManyExpressionBuilder<ItemValueMap, string>(f => f.ItemValue.CleanValue); - baseQuery = baseQuery - .Where(e => !e.ItemValues!.AsQueryable().Where(f => f.ItemValue.Type == ItemValueType.Tags).Any(cleanValues)); - } - - if (filter.StudioIds.Length > 0) - { - baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Studios, filter.StudioIds.ToArray()); - } - - if (filter.OfficialRatings.Length > 0) - { - baseQuery = baseQuery - .Where(e => filter.OfficialRatings.Contains(e.OfficialRating)); - } - - Expression<Func<BaseItemEntity, bool>>? minParentalRatingFilter = null; - if (filter.MinParentalRating != null) - { - var min = filter.MinParentalRating; - var minScore = min.Score; - var minSubScore = min.SubScore ?? 0; - - minParentalRatingFilter = e => - e.InheritedParentalRatingValue == null || - e.InheritedParentalRatingValue > minScore || - (e.InheritedParentalRatingValue == minScore && (e.InheritedParentalRatingSubValue ?? 0) >= minSubScore); - } - - Expression<Func<BaseItemEntity, bool>>? maxParentalRatingFilter = null; - if (filter.MaxParentalRating != null) - { - var max = filter.MaxParentalRating; - var maxScore = max.Score; - var maxSubScore = max.SubScore ?? 0; - - maxParentalRatingFilter = e => - e.InheritedParentalRatingValue == null || - e.InheritedParentalRatingValue < maxScore || - (e.InheritedParentalRatingValue == maxScore && (e.InheritedParentalRatingSubValue ?? 0) <= maxSubScore); - } - - 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<Func<BaseItemEntity, bool>> 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.Where(e => e.Id != EF.Constant(PlaceholderId)).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 > 0) - { - baseQuery = baseQuery.WhereOneOrMany(filter.Years, e => e.ProductionYear!.Value); - } - - 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.WhereOneOrMany(mediaTypes, e => e.MediaType); - } - - if (filter.ItemIds.Length > 0) - { - baseQuery = baseQuery.WhereOneOrMany(filter.ItemIds, e => e.Id); - } - - if (filter.ExcludeItemIds.Length > 0) - { - baseQuery = baseQuery - .Where(e => !filter.ExcludeItemIds.Contains(e.Id)); - } - - if (filter.ExcludeProviderIds is not null && filter.ExcludeProviderIds.Count > 0) - { - var exclude = filter.ExcludeProviderIds.Select(e => $"{e.Key}:{e.Value}").ToArray(); - baseQuery = baseQuery.Where(e => e.Provider!.Select(f => f.ProviderId + ":" + f.ProviderValue)!.All(f => !exclude.Contains(f))); - } - - if (filter.HasAnyProviderId is not null && filter.HasAnyProviderId.Count > 0) - { - // Allow setting a null or empty value to get all items that have the specified provider set. - var includeAny = filter.HasAnyProviderId.Where(e => string.IsNullOrEmpty(e.Value)).Select(e => e.Key).ToArray(); - if (includeAny.Length > 0) - { - baseQuery = baseQuery.Where(e => e.Provider!.Any(f => includeAny.Contains(f.ProviderId))); - } - - var includeSelected = filter.HasAnyProviderId.Where(e => !string.IsNullOrEmpty(e.Value)).Select(e => $"{e.Key}:{e.Value}").ToArray(); - if (includeSelected.Length > 0) - { - baseQuery = baseQuery.Where(e => e.Provider!.Select(f => f.ProviderId + ":" + f.ProviderValue)!.Any(f => includeSelected.Contains(f))); - } - } - - if (filter.HasImdbId.HasValue) - { - baseQuery = filter.HasImdbId.Value - ? baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId.ToLower() == MetadataProvider.Imdb.ToString().ToLower())) - : baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != MetadataProvider.Imdb.ToString().ToLower())); - } - - if (filter.HasTmdbId.HasValue) - { - baseQuery = filter.HasTmdbId.Value - ? baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId.ToLower() == MetadataProvider.Tmdb.ToString().ToLower())) - : baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != MetadataProvider.Tmdb.ToString().ToLower())); - } - - if (filter.HasTvdbId.HasValue) - { - baseQuery = filter.HasTvdbId.Value - ? baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId.ToLower() == MetadataProvider.Tvdb.ToString().ToLower())) - : baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != MetadataProvider.Tvdb.ToString().ToLower())); - } - - var queryTopParentIds = filter.TopParentIds; - - 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.WhereOneOrMany(queryTopParentIds, e => e.TopParentId!.Value); - } - } - - if (filter.AncestorIds.Length > 0) - { - baseQuery = baseQuery.Where(e => e.Parents!.Any(f => filter.AncestorIds.Contains(f.ParentItemId))); - } - - if (!string.IsNullOrWhiteSpace(filter.AncestorWithPresentationUniqueKey)) - { - baseQuery = baseQuery - .Where(e => context.BaseItems.Where(e => e.Id != EF.Constant(PlaceholderId)).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) - { - var excludedTags = filter.ExcludeInheritedTags; - baseQuery = baseQuery.Where(e => - !e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && excludedTags.Contains(f.ItemValue.CleanValue)) - && (!e.SeriesId.HasValue || !context.ItemValuesMap.Any(f => f.ItemId == e.SeriesId.Value && f.ItemValue.Type == ItemValueType.Tags && excludedTags.Contains(f.ItemValue.CleanValue)))); - } - - if (filter.IncludeInheritedTags.Length > 0) - { - var includeTags = filter.IncludeInheritedTags; - var isPlaylistOnlyQuery = includeTypes.Length == 1 && includeTypes.FirstOrDefault() == BaseItemKind.Playlist; - baseQuery = baseQuery.Where(e => - e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue)) - - // For seasons and episodes, we also need to check the parent series' tags. - || (e.SeriesId.HasValue && context.ItemValuesMap.Any(f => f.ItemId == e.SeriesId.Value && f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue))) - - // A playlist should be accessible to its owner regardless of allowed tags - || (isPlaylistOnlyQuery && e.Data!.Contains($"OwnerUserId\":\"{filter.User!.Id:N}\""))); - } - - 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; + return BaseItemMapper.MapImageFromEntity(e, appHost); } - /// <inheritdoc/> - public async Task<bool> ItemExistsAsync(Guid id) + private string? GetPathToSave(string path) { - var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); - await using (dbContext.ConfigureAwait(false)) + if (path is null) { - return await dbContext.BaseItems.AnyAsync(f => f.Id == id).ConfigureAwait(false); + return null; } + + return _appHost.ReverseVirtualPath(path); } - /// <inheritdoc/> - public bool GetIsPlayed(User user, Guid id, bool recursive) + /// <inheritdoc /> + public BaseItemDto? DeserializeBaseItem(BaseItemEntity entity, bool skipDeserialization = false) { - using var dbContext = _dbProvider.CreateDbContext(); - - if (recursive) + ArgumentNullException.ThrowIfNull(entity, nameof(entity)); + if (_serverConfigurationManager?.Configuration is null) { - var folderList = TraverseHirachyDown(id, dbContext, item => (item.IsFolder || item.IsVirtualItem)); - - return dbContext.BaseItems - .Where(e => folderList.Contains(e.ParentId!.Value) && !e.IsFolder && !e.IsVirtualItem) - .All(f => f.UserData!.Any(e => e.UserId == user.Id && e.Played)); + throw new InvalidOperationException("Server Configuration manager or configuration is null"); } - return dbContext.BaseItems.Where(e => e.ParentId == id).All(f => f.UserData!.Any(e => e.UserId == user.Id && e.Played)); - } - - private static HashSet<Guid> TraverseHirachyDown(Guid parentId, JellyfinDbContext dbContext, Expression<Func<BaseItemEntity, bool>>? filter = null) - { - var folderStack = new HashSet<Guid>() - { - parentId - }; - var folderList = new HashSet<Guid>() - { - parentId - }; - - while (folderStack.Count != 0) - { - var items = folderStack.ToArray(); - folderStack.Clear(); - var query = dbContext.BaseItems - .WhereOneOrMany(items, e => e.ParentId!.Value); - - if (filter != null) - { - query = query.Where(filter); - } - - foreach (var item in query.Select(e => e.Id).ToArray()) - { - if (folderList.Add(item)) - { - folderStack.Add(item); - } - } - } - - return folderList; - } - - /// <inheritdoc/> - public IReadOnlyDictionary<string, MusicArtist[]> FindArtists(IReadOnlyList<string> artistNames) - { - using var dbContext = _dbProvider.CreateDbContext(); - - var artists = dbContext.BaseItems.Where(e => e.Type == _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]!) - .Where(e => artistNames.Contains(e.Name)) - .ToArray(); - - var lookup = artists - .GroupBy(e => e.Name!) - .ToDictionary( - g => g.Key, - g => g.Select(f => DeserializeBaseItem(f)).Where(dto => dto is not null).Cast<MusicArtist>().ToArray()); - - var result = new Dictionary<string, MusicArtist[]>(artistNames.Count); - foreach (var name in artistNames) - { - if (lookup.TryGetValue(name, out var artistArray)) - { - result[name] = artistArray; - } - } - - return result; + var typeToSerialise = BaseItemMapper.GetType(entity.Type); + return BaseItemMapper.DeserializeBaseItem( + entity, + _logger, + _appHost, + skipDeserialization || (_serverConfigurationManager.Configuration.SkipDeserializationForBasicTypes && (typeToSerialise == typeof(Channel) || typeToSerialise == typeof(UserRootFolder)))); } } diff --git a/Jellyfin.Server.Implementations/Item/ChapterRepository.cs b/Jellyfin.Server.Implementations/Item/ChapterRepository.cs index 98700f3224..f7d76517e1 100644 --- a/Jellyfin.Server.Implementations/Item/ChapterRepository.cs +++ b/Jellyfin.Server.Implementations/Item/ChapterRepository.cs @@ -55,6 +55,7 @@ public class ChapterRepository : IChapterRepository { using var context = _dbProvider.CreateDbContext(); return context.Chapters.AsNoTracking().Where(e => e.ItemId.Equals(baseItemId)) + .OrderBy(e => e.StartPositionTicks) .Select(e => new { chapter = e, @@ -69,18 +70,16 @@ public class ChapterRepository : IChapterRepository public void SaveChapters(Guid itemId, IReadOnlyList<ChapterInfo> chapters) { using var context = _dbProvider.CreateDbContext(); - using (var transaction = context.Database.BeginTransaction()) + using var transaction = context.Database.BeginTransaction(); + context.Chapters.Where(e => e.ItemId.Equals(itemId)).ExecuteDelete(); + for (var i = 0; i < chapters.Count; i++) { - 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(); + var chapter = chapters[i]; + context.Chapters.Add(Map(chapter, i, itemId)); } + + context.SaveChanges(); + transaction.Commit(); } /// <inheritdoc /> diff --git a/Jellyfin.Server.Implementations/Item/FolderAwareFilterExtensions.cs b/Jellyfin.Server.Implementations/Item/FolderAwareFilterExtensions.cs new file mode 100644 index 0000000000..888dacd16b --- /dev/null +++ b/Jellyfin.Server.Implementations/Item/FolderAwareFilterExtensions.cs @@ -0,0 +1,83 @@ +#pragma warning disable RS0030 // Do not use banned APIs + +using System; +using System.Linq; +using System.Linq.Expressions; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; + +namespace Jellyfin.Server.Implementations.Item; + +/// <summary> +/// Extension methods for applying folder-aware filters that check items and their descendants. +/// </summary> +internal static class FolderAwareFilterExtensions +{ + /// <summary> + /// Filters items where either the item matches the condition (for non-folders) + /// or any descendant matches (for folders). Uses reverse traversal through AncestorIds. + /// </summary> + /// <param name="query">The query to filter.</param> + /// <param name="context">The database context.</param> + /// <param name="condition">The condition to check on BaseItemEntity.</param> + /// <returns>Filtered query.</returns> + public static IQueryable<BaseItemEntity> WhereItemOrDescendantMatches( + this IQueryable<BaseItemEntity> query, + JellyfinDbContext context, + Expression<Func<BaseItemEntity, bool>> condition) + { + // Get IDs of items that directly match the condition + var directMatchIds = context.BaseItems.Where(condition).Select(b => b.Id); + + // Get parent IDs where a descendant (via AncestorIds) matches + var ancestorMatchIds = context.AncestorIds + .Where(a => directMatchIds.Contains(a.ItemId)) + .Select(a => a.ParentItemId); + + // Get parent IDs where a linked child matches + var linkedMatchIds = context.LinkedChildren + .Where(lc => directMatchIds.Contains(lc.ChildId)) + .Select(lc => lc.ParentId); + + var allMatchingIds = directMatchIds + .Concat(ancestorMatchIds) + .Concat(linkedMatchIds) + .Distinct(); + + return query.Where(e => allMatchingIds.Contains(e.Id)); + } + + /// <summary> + /// Filters items where neither the item matches the condition (for non-folders) + /// nor any descendant matches (for folders). Uses reverse traversal for infinite depth. + /// </summary> + /// <param name="query">The query to filter.</param> + /// <param name="context">The database context.</param> + /// <param name="condition">The condition that should NOT match.</param> + /// <returns>Filtered query.</returns> + public static IQueryable<BaseItemEntity> WhereNeitherItemNorDescendantMatches( + this IQueryable<BaseItemEntity> query, + JellyfinDbContext context, + Expression<Func<BaseItemEntity, bool>> condition) + { + // Get IDs of items that directly match the condition + var directMatchIds = context.BaseItems.Where(condition).Select(b => b.Id); + + // Get parent IDs where a descendant (via AncestorIds) matches + var ancestorMatchIds = context.AncestorIds + .Where(a => directMatchIds.Contains(a.ItemId)) + .Select(a => a.ParentItemId); + + // Get parent IDs where a linked child matches + var linkedMatchIds = context.LinkedChildren + .Where(lc => directMatchIds.Contains(lc.ChildId)) + .Select(lc => lc.ParentId); + + var allMatchingIds = directMatchIds + .Concat(ancestorMatchIds) + .Concat(linkedMatchIds) + .Distinct(); + + return query.Where(e => !allMatchingIds.Contains(e.Id)); + } +} diff --git a/Jellyfin.Server.Implementations/Item/ItemCountBuilder.cs b/Jellyfin.Server.Implementations/Item/ItemCountBuilder.cs new file mode 100644 index 0000000000..0f8a1b9dfe --- /dev/null +++ b/Jellyfin.Server.Implementations/Item/ItemCountBuilder.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Model.Dto; + +namespace Jellyfin.Server.Implementations.Item; + +/// <summary> +/// Turns per-type counts into an <see cref="ItemCounts"/>. +/// </summary> +internal static class ItemCountBuilder +{ + /// <summary> + /// Builds the counts of one by-name item. + /// </summary> + /// <param name="itemTypeLookup">The item type lookup.</param> + /// <param name="counts">The counted items, by type name. A type may repeat.</param> + /// <returns>The counts.</returns> + public static ItemCounts Build(IItemTypeLookup itemTypeLookup, IEnumerable<(string Type, int Count)> counts) + { + ArgumentNullException.ThrowIfNull(itemTypeLookup); + ArgumentNullException.ThrowIfNull(counts); + + var lookup = itemTypeLookup.BaseItemKindNames; + var result = new ItemCounts(); + + foreach (var (type, count) in counts) + { + // Accumulated rather than assigned: a caller may group by something finer than the + // type and hand the same type over more than once. + if (string.Equals(type, lookup[BaseItemKind.MusicAlbum], StringComparison.Ordinal)) + { + result.AlbumCount += count; + } + else if (string.Equals(type, lookup[BaseItemKind.MusicArtist], StringComparison.Ordinal)) + { + result.ArtistCount += count; + } + else if (string.Equals(type, lookup[BaseItemKind.Episode], StringComparison.Ordinal)) + { + result.EpisodeCount += count; + } + else if (string.Equals(type, lookup[BaseItemKind.Movie], StringComparison.Ordinal)) + { + result.MovieCount += count; + } + else if (string.Equals(type, lookup[BaseItemKind.MusicVideo], StringComparison.Ordinal)) + { + result.MusicVideoCount += count; + } + else if (string.Equals(type, lookup[BaseItemKind.LiveTvProgram], StringComparison.Ordinal)) + { + result.ProgramCount += count; + } + else if (string.Equals(type, lookup[BaseItemKind.Series], StringComparison.Ordinal)) + { + result.SeriesCount += count; + } + else if (string.Equals(type, lookup[BaseItemKind.Audio], StringComparison.Ordinal)) + { + result.SongCount += count; + } + else if (string.Equals(type, lookup[BaseItemKind.Trailer], StringComparison.Ordinal)) + { + result.TrailerCount += count; + } + else if (string.Equals(type, lookup[BaseItemKind.BoxSet], StringComparison.Ordinal)) + { + result.BoxSetCount += count; + } + else if (string.Equals(type, lookup[BaseItemKind.Book], StringComparison.Ordinal)) + { + result.BookCount += count; + } + } + + result.ItemCount = result.TotalItemCount(); + + return result; + } + + /// <summary> + /// Replaces the episode count, which both by-name paths decide separately from the other + /// types because a genre or studio is usually written on the series rather than its episodes. + /// </summary> + /// <param name="counts">The counts to update.</param> + /// <param name="episodeCount">The episode count.</param> + public static void SetEpisodeCount(ItemCounts counts, int episodeCount) + { + ArgumentNullException.ThrowIfNull(counts); + + counts.EpisodeCount = episodeCount; + counts.ItemCount = counts.TotalItemCount(); + } +} diff --git a/Jellyfin.Server.Implementations/Item/ItemCountService.cs b/Jellyfin.Server.Implementations/Item/ItemCountService.cs new file mode 100644 index 0000000000..57705cdf11 --- /dev/null +++ b/Jellyfin.Server.Implementations/Item/ItemCountService.cs @@ -0,0 +1,742 @@ +#pragma warning disable RS0030 // Do not use banned APIs + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; +using Jellyfin.Extensions; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Model.Dto; +using Microsoft.EntityFrameworkCore; + +namespace Jellyfin.Server.Implementations.Item; + +/// <summary> +/// Provides item counting and played-status query operations. +/// </summary> +public class ItemCountService : IItemCountService +{ + private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; + private readonly IItemTypeLookup _itemTypeLookup; + private readonly IItemQueryHelpers _queryHelpers; + + /// <summary> + /// Initializes a new instance of the <see cref="ItemCountService"/> class. + /// </summary> + /// <param name="dbProvider">The database context factory.</param> + /// <param name="itemTypeLookup">The item type lookup.</param> + /// <param name="queryHelpers">The shared query helpers.</param> + public ItemCountService( + IDbContextFactory<JellyfinDbContext> dbProvider, + IItemTypeLookup itemTypeLookup, + IItemQueryHelpers queryHelpers) + { + _dbProvider = dbProvider; + _itemTypeLookup = itemTypeLookup; + _queryHelpers = queryHelpers; + } + + /// <inheritdoc/> + public int GetCount(InternalItemsQuery filter) + { + ArgumentNullException.ThrowIfNull(filter); + _queryHelpers.PrepareFilterQuery(filter); + + using var context = _dbProvider.CreateDbContext(); + var dbQuery = _queryHelpers.TranslateQuery(context.BaseItems.AsNoTracking(), context, filter); + + return dbQuery.Count(); + } + + /// <inheritdoc /> + public ItemCounts GetItemCounts(InternalItemsQuery filter) + { + ArgumentNullException.ThrowIfNull(filter); + _queryHelpers.PrepareFilterQuery(filter); + + using var context = _dbProvider.CreateDbContext(); + var dbQuery = _queryHelpers.TranslateQuery(context.BaseItems.AsNoTracking(), context, filter); + + var counts = dbQuery + .GroupBy(x => x.Type) + .Select(x => new { x.Key, Count = x.Count() }) + .ToArray(); + + var lookup = _itemTypeLookup.BaseItemKindNames; + var result = new ItemCounts + { + ItemCount = counts.Sum(c => c.Count) + }; + foreach (var count in counts) + { + if (string.Equals(count.Key, lookup[BaseItemKind.MusicAlbum], StringComparison.Ordinal)) + { + result.AlbumCount = count.Count; + } + else if (string.Equals(count.Key, lookup[BaseItemKind.MusicArtist], StringComparison.Ordinal)) + { + result.ArtistCount = count.Count; + } + else if (string.Equals(count.Key, lookup[BaseItemKind.Episode], StringComparison.Ordinal)) + { + result.EpisodeCount = count.Count; + } + else if (string.Equals(count.Key, lookup[BaseItemKind.Movie], StringComparison.Ordinal)) + { + result.MovieCount = count.Count; + } + else if (string.Equals(count.Key, lookup[BaseItemKind.MusicVideo], StringComparison.Ordinal)) + { + result.MusicVideoCount = count.Count; + } + else if (string.Equals(count.Key, lookup[BaseItemKind.LiveTvProgram], StringComparison.Ordinal)) + { + result.ProgramCount = count.Count; + } + else if (string.Equals(count.Key, lookup[BaseItemKind.Series], StringComparison.Ordinal)) + { + result.SeriesCount = count.Count; + } + else if (string.Equals(count.Key, lookup[BaseItemKind.Audio], StringComparison.Ordinal)) + { + result.SongCount = count.Count; + } + else if (string.Equals(count.Key, lookup[BaseItemKind.Trailer], StringComparison.Ordinal)) + { + result.TrailerCount = count.Count; + } + else if (string.Equals(count.Key, lookup[BaseItemKind.BoxSet], StringComparison.Ordinal)) + { + result.BoxSetCount = count.Count; + } + else if (string.Equals(count.Key, lookup[BaseItemKind.Book], StringComparison.Ordinal)) + { + result.BookCount = count.Count; + } + } + + return result; + } + + /// <inheritdoc /> + public ItemCounts GetItemCountsForNameItem(BaseItemKind kind, Guid id, BaseItemKind[] relatedItemKinds, InternalItemsQuery accessFilter) + { + return GetItemCountsForNameItems(kind, [id], relatedItemKinds, accessFilter)[id]; + } + + private static ItemValueType[] GetItemValueTypes(BaseItemKind kind) + => kind switch + { + BaseItemKind.MusicArtist => [ItemValueType.Artist, ItemValueType.AlbumArtist], + BaseItemKind.Genre or BaseItemKind.MusicGenre => [ItemValueType.Genre], + BaseItemKind.Studio => [ItemValueType.Studios], + _ => [] + }; + + /// <inheritdoc /> + public Dictionary<Guid, ItemCounts> GetItemCountsForNameItems(BaseItemKind kind, IReadOnlyList<Guid> ids, BaseItemKind[] relatedItemKinds, InternalItemsQuery accessFilter) + { + ArgumentNullException.ThrowIfNull(ids); + ArgumentNullException.ThrowIfNull(relatedItemKinds); + ArgumentNullException.ThrowIfNull(accessFilter); + + var result = new Dictionary<Guid, ItemCounts>(); + if (ids.Count == 0) + { + return result; + } + + using var context = _dbProvider.CreateDbContext(); + + var idsArray = ids as Guid[] ?? ids.ToArray(); + var nameItems = context.BaseItems.AsNoTracking() + .WhereOneOrMany(idsArray, e => e.Id) + .Select(e => new NameItem(e.Id, e.Name, e.CleanName)) + .ToArray(); + + foreach (var id in ids) + { + result[id] = new ItemCounts(); + } + + if (nameItems.Length == 0) + { + return result; + } + + var typeNames = relatedItemKinds.Select(k => _itemTypeLookup.BaseItemKindNames[k]).ToArray(); + var related = _queryHelpers.ApplyAccessFiltering( + context, + context.BaseItems.AsNoTracking().Where(e => typeNames.Contains(e.Type)), + accessFilter); + + var valueTypes = GetItemValueTypes(kind); + if (valueTypes.Length > 0) + { + CountByItemValue(context, related, kind, relatedItemKinds, valueTypes, nameItems, result); + } + else if (kind == BaseItemKind.Person) + { + CountByPersonName(context, related, nameItems, result); + } + else if (kind == BaseItemKind.Year) + { + CountByProductionYear(related, nameItems, result); + } + + return result; + } + + private void CountByItemValue( + JellyfinDbContext context, + IQueryable<BaseItemEntity> related, + BaseItemKind kind, + BaseItemKind[] relatedItemKinds, + ItemValueType[] valueTypes, + NameItem[] nameItems, + Dictionary<Guid, ItemCounts> result) + { + var cleanNames = nameItems + .Select(n => n.CleanName) + .OfType<string>() + .Distinct(StringComparer.Ordinal) + .ToArray(); + + if (cleanNames.Length == 0) + { + return; + } + + var grouped = context.ItemValuesMap.AsNoTracking() + .Where(ivm => valueTypes.Contains(ivm.ItemValue.Type)) + .WhereOneOrMany(cleanNames, ivm => ivm.ItemValue.CleanValue) + .Join(related, ivm => ivm.ItemId, e => e.Id, (ivm, e) => new { ivm.ItemValue.CleanValue, e.Type, e.Id }) + .GroupBy(x => new { x.CleanValue, x.Type }) + .Select(g => new { g.Key.CleanValue, g.Key.Type, Count = g.Select(x => x.Id).Distinct().Count() }) + .ToArray(); + + var byCleanName = grouped + .GroupBy(g => g.CleanValue, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.Select(x => (x.Type, x.Count)).ToArray(), StringComparer.Ordinal); + + var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series]; + var episodeRollUp = RollsUpEpisodes(kind, relatedItemKinds) + && Array.Exists(grouped, g => string.Equals(g.Type, seriesTypeName, StringComparison.Ordinal)) + ? CountEpisodesOfTaggedSeriesByCleanName(context, related, valueTypes, cleanNames) + : null; + + foreach (var nameItem in nameItems) + { + if (nameItem.CleanName is null || !byCleanName.TryGetValue(nameItem.CleanName, out var counts)) + { + continue; + } + + var itemCounts = ItemCountBuilder.Build(_itemTypeLookup, counts); + + if (episodeRollUp is not null) + { + var rollUp = episodeRollUp.GetValueOrDefault(nameItem.CleanName); + + // Episodes of a tagged series count towards it even when untagged themselves, and + // a tagged episode of a tagged series must not be counted a second time. + var directEpisodeCount = itemCounts.EpisodeCount - rollUp.TaggedEpisodesOfTaggedSeries; + ItemCountBuilder.SetEpisodeCount(itemCounts, rollUp.EpisodesOfTaggedSeries + directEpisodeCount); + } + + result[nameItem.Id] = itemCounts; + } + } + + private void CountByPersonName( + JellyfinDbContext context, + IQueryable<BaseItemEntity> related, + NameItem[] nameItems, + Dictionary<Guid, ItemCounts> result) + { + var names = nameItems + .Select(n => n.Name) + .OfType<string>() + .Distinct(StringComparer.Ordinal) + .ToArray(); + + if (names.Length == 0) + { + return; + } + + var grouped = context.PeopleBaseItemMap.AsNoTracking() + .WhereOneOrMany(names, m => m.People.Name) + .Join(related, m => m.ItemId, e => e.Id, (m, e) => new { m.People.Name, e.Type, e.Id }) + .GroupBy(x => new { x.Name, x.Type }) + // A person can be credited on one item more than once, in different roles. + .Select(g => new { g.Key.Name, g.Key.Type, Count = g.Select(x => x.Id).Distinct().Count() }) + .ToArray(); + + ApplyGroupedCounts(nameItems, n => n.Name, grouped.Select(g => (g.Name, g.Type, g.Count)), result); + } + + private void CountByProductionYear( + IQueryable<BaseItemEntity> related, + NameItem[] nameItems, + Dictionary<Guid, ItemCounts> result) + { + var years = new List<int>(); + foreach (var nameItem in nameItems) + { + if (int.TryParse(nameItem.Name, NumberStyles.Integer, CultureInfo.InvariantCulture, out var year) + && !years.Contains(year)) + { + years.Add(year); + } + } + + if (years.Count == 0) + { + return; + } + + // No join, so no row can be reached twice and a plain count is the distinct count. + var grouped = related + .Where(e => e.ProductionYear != null) + .WhereOneOrMany(years, e => e.ProductionYear!.Value) + .GroupBy(e => new { Year = e.ProductionYear!.Value, e.Type }) + .Select(g => new { g.Key.Year, g.Key.Type, Count = g.Count() }) + .ToArray(); + + var byYear = grouped + .GroupBy(g => g.Year) + .ToDictionary(g => g.Key, g => g.Select(x => (x.Type, x.Count)).ToArray()); + + foreach (var nameItem in nameItems) + { + if (int.TryParse(nameItem.Name, NumberStyles.Integer, CultureInfo.InvariantCulture, out var year) + && byYear.TryGetValue(year, out var counts)) + { + result[nameItem.Id] = ItemCountBuilder.Build(_itemTypeLookup, counts); + } + } + } + + private void ApplyGroupedCounts( + NameItem[] nameItems, + Func<NameItem, string?> keySelector, + IEnumerable<(string Key, string Type, int Count)> grouped, + Dictionary<Guid, ItemCounts> result) + { + var byKey = grouped + .GroupBy(g => g.Key, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.Select(x => (x.Type, x.Count)).ToArray(), StringComparer.Ordinal); + + foreach (var nameItem in nameItems) + { + var key = keySelector(nameItem); + if (key is not null && byKey.TryGetValue(key, out var counts)) + { + result[nameItem.Id] = ItemCountBuilder.Build(_itemTypeLookup, counts); + } + } + } + + private static bool RollsUpEpisodes(BaseItemKind kind, BaseItemKind[] relatedItemKinds) + => kind is BaseItemKind.Studio or BaseItemKind.Genre or BaseItemKind.MusicGenre + && relatedItemKinds.Contains(BaseItemKind.Episode) + && relatedItemKinds.Contains(BaseItemKind.Series); + + private Dictionary<string, (int EpisodesOfTaggedSeries, int TaggedEpisodesOfTaggedSeries)> CountEpisodesOfTaggedSeriesByCleanName( + JellyfinDbContext context, + IQueryable<BaseItemEntity> related, + ItemValueType[] valueTypes, + string[] cleanNames) + { + var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series]; + var episodeTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode]; + + var taggedValues = context.ItemValuesMap.AsNoTracking() + .Where(ivm => valueTypes.Contains(ivm.ItemValue.Type)) + .WhereOneOrMany(cleanNames, ivm => ivm.ItemValue.CleanValue); + + // The series carrying each clean name. Distinct, because one item can be mapped to the + // same clean name once per value type. + var taggedSeries = taggedValues + .Join( + related.Where(e => e.Type == seriesTypeName), + ivm => ivm.ItemId, + e => e.Id, + (ivm, e) => new { ivm.ItemValue.CleanValue, SeriesId = e.Id }) + .Distinct(); + + var episodes = related.Where(e => e.Type == episodeTypeName && e.SeriesId != null); + + var episodesOfTaggedSeries = taggedSeries + .Join(episodes, s => s.SeriesId, e => e.SeriesId!.Value, (s, e) => new { s.CleanValue, e.Id }) + .GroupBy(x => x.CleanValue) + .Select(g => new { CleanValue = g.Key, Count = g.Select(x => x.Id).Distinct().Count() }) + .ToArray(); + + // Episodes that carry the clean name themselves *and* belong to a series carrying it. The + // roll-up already counts those, so they have to come off the directly tagged ones. + var taggedEpisodesOfTaggedSeries = taggedValues + .Join(episodes, ivm => ivm.ItemId, e => e.Id, (ivm, e) => new { ivm.ItemValue.CleanValue, e.Id, e.SeriesId }) + .Join( + taggedSeries, + e => new { e.CleanValue, SeriesId = e.SeriesId!.Value }, + s => new { s.CleanValue, s.SeriesId }, + (e, s) => new { e.CleanValue, e.Id }) + .GroupBy(x => x.CleanValue) + .Select(g => new { CleanValue = g.Key, Count = g.Select(x => x.Id).Distinct().Count() }) + .ToArray(); + + var taggedLookup = taggedEpisodesOfTaggedSeries + .ToDictionary(x => x.CleanValue, x => x.Count, StringComparer.Ordinal); + + // Every clean name in taggedLookup came from an episode of a tagged series, so it always + // has a row in episodesOfTaggedSeries too - no second merge pass is needed. + var result = new Dictionary<string, (int EpisodesOfTaggedSeries, int TaggedEpisodesOfTaggedSeries)>(StringComparer.Ordinal); + foreach (var entry in episodesOfTaggedSeries) + { + result[entry.CleanValue] = (entry.Count, taggedLookup.GetValueOrDefault(entry.CleanValue)); + } + + return result; + } + + /// <inheritdoc/> + public int GetPlayedCount(InternalItemsQuery filter, Guid ancestorId) + { + ArgumentNullException.ThrowIfNull(filter); + ArgumentNullException.ThrowIfNull(filter.User); + using var dbContext = _dbProvider.CreateDbContext(); + + var baseQuery = BuildGroupedDescendantsQuery(dbContext, filter, ancestorId); + return baseQuery.Count(b => b.UserData!.Any(u => u.UserId == filter.User.Id && u.Played)); + } + + /// <inheritdoc/> + public int GetTotalCount(InternalItemsQuery filter, Guid ancestorId) + { + ArgumentNullException.ThrowIfNull(filter); + using var dbContext = _dbProvider.CreateDbContext(); + + var baseQuery = BuildGroupedDescendantsQuery(dbContext, filter, ancestorId); + return baseQuery.Count(); + } + + /// <inheritdoc/> + public (int Played, int Total) GetPlayedAndTotalCount(InternalItemsQuery filter, Guid ancestorId) + { + ArgumentNullException.ThrowIfNull(filter); + ArgumentNullException.ThrowIfNull(filter.User); + using var dbContext = _dbProvider.CreateDbContext(); + + var baseQuery = BuildGroupedDescendantsQuery(dbContext, filter, ancestorId); + return GetPlayedAndTotalCountFromQuery(baseQuery, filter.User.Id); + } + + private IQueryable<BaseItemEntity> BuildGroupedDescendantsQuery(JellyfinDbContext dbContext, InternalItemsQuery filter, Guid ancestorId) + { + var ancestorIds = GetPresentationKeyGroups(dbContext, [ancestorId])[ancestorId]; + var descendantIds = DescendantQueryHelper.GetAllDescendantIdsBatch(dbContext, ancestorIds).ToArray(); + + var baseQuery = dbContext.BaseItems + .AsNoTracking() + .WhereOneOrMany(descendantIds, b => b.Id) + .Where(DescendantQueryHelper.IsCountableLeaf); + + return _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, filter); + } + + /// <inheritdoc/> + public (int Played, int Total) GetPlayedAndTotalCountFromLinkedChildren(InternalItemsQuery filter, Guid parentId) + { + ArgumentNullException.ThrowIfNull(filter); + ArgumentNullException.ThrowIfNull(filter.User); + using var dbContext = _dbProvider.CreateDbContext(); + + var allDescendantIds = DescendantQueryHelper.GetAllDescendantIdsBatch(dbContext, [parentId]).ToArray(); + var baseQuery = dbContext.BaseItems + .WhereOneOrMany(allDescendantIds, b => b.Id) + .Where(DescendantQueryHelper.IsCountableLeaf); + baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, filter); + + return GetPlayedAndTotalCountFromQuery(baseQuery, filter.User.Id); + } + + /// <inheritdoc/> + public Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, User? user) + { + ArgumentNullException.ThrowIfNull(parentIds); + + if (parentIds.Count == 0) + { + return new Dictionary<Guid, int>(); + } + + using var dbContext = _dbProvider.CreateDbContext(); + + var parentIdsArray = parentIds.ToArray(); + + var includeVirtual = user is null || user.DisplayMissingEpisodes; + + var hierarchicalCounts = dbContext.BaseItems + .Where(b => b.ParentId.HasValue && !b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem)) + .WhereOneOrMany(parentIdsArray, b => b.ParentId!.Value) + .GroupBy(b => b.ParentId!.Value) + .Select(g => new { ParentId = g.Key, Count = g.Count() }) + .ToDictionary(x => x.ParentId, x => x.Count); + + // An episode is a child of its season even when it is not stored under one: with a flat + // structure ParentId points at the series, so counting by ParentId alone leaves the season + // empty and counts its episodes towards the series instead. + var seasonCounts = dbContext.BaseItems + .Where(b => b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem)) + .WhereOneOrMany(parentIdsArray, b => b.SeasonId!.Value) + .GroupBy(b => b.SeasonId!.Value) + .Select(g => new { SeasonId = g.Key, Count = g.Count() }) + .ToDictionary(x => x.SeasonId, x => x.Count); + + var linkedCounts = dbContext.LinkedChildren + .WhereOneOrMany(parentIdsArray, lc => lc.ParentId) + .GroupBy(lc => lc.ParentId) + .Select(g => new { ParentId = g.Key, Count = g.Count() }) + .ToDictionary(x => x.ParentId, x => x.Count); + + var mergedChildCounts = GetMergedChildCounts(dbContext, parentIdsArray, includeVirtual); + + var result = new Dictionary<Guid, int>(); + foreach (var parentId in parentIds) + { + if (mergedChildCounts.TryGetValue(parentId, out var mergedCount)) + { + result[parentId] = mergedCount; + continue; + } + + var hierarchicalCount = hierarchicalCounts.GetValueOrDefault(parentId, 0) + + seasonCounts.GetValueOrDefault(parentId, 0); + var linkedCount = linkedCounts.GetValueOrDefault(parentId, 0); + + result[parentId] = linkedCount > 0 ? linkedCount : hierarchicalCount; + } + + return result; + } + + private static Dictionary<Guid, int> GetMergedChildCounts(JellyfinDbContext dbContext, IReadOnlyList<Guid> parentIds, bool includeVirtual) + { + var mergedGroups = GetPresentationKeyGroups(dbContext, parentIds) + .Where(group => group.Value.Count > 1) + .ToArray(); + + if (mergedGroups.Length == 0) + { + return []; + } + + // Only merged folders. + var memberIds = mergedGroups.SelectMany(group => group.Value).Distinct().ToArray(); + var children = dbContext.BaseItems + .AsNoTracking() + .Where(b => b.ParentId.HasValue && !b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem)) + .WhereOneOrMany(memberIds, b => b.ParentId!.Value) + .Select(b => new { ParentId = b.ParentId!.Value, b.Id, b.PresentationUniqueKey }) + .ToArray() + .Concat(dbContext.BaseItems + .AsNoTracking() + .Where(b => b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem)) + .WhereOneOrMany(memberIds, b => b.SeasonId!.Value) + .Select(b => new { ParentId = b.SeasonId!.Value, b.Id, b.PresentationUniqueKey }) + .ToArray()) + .GroupBy(b => b.ParentId) + .ToDictionary( + g => g.Key, + g => g.Select(b => string.IsNullOrEmpty(b.PresentationUniqueKey) + ? b.Id.ToString("N", CultureInfo.InvariantCulture) + : b.PresentationUniqueKey).ToArray()); + + var result = new Dictionary<Guid, int>(); + foreach (var (parentId, members) in mergedGroups) + { + var childKeys = new HashSet<string>(StringComparer.Ordinal); + foreach (var member in members) + { + if (children.TryGetValue(member, out var keys)) + { + childKeys.UnionWith(keys); + } + } + + result[parentId] = childKeys.Count; + } + + return result; + } + + /// <inheritdoc/> + public Dictionary<Guid, (int Played, int Total)> GetPlayedAndTotalCountBatch(IReadOnlyList<Guid> folderIds, User user) + { + ArgumentNullException.ThrowIfNull(folderIds); + ArgumentNullException.ThrowIfNull(user); + + if (folderIds.Count == 0) + { + return new Dictionary<Guid, (int Played, int Total)>(); + } + + using var dbContext = _dbProvider.CreateDbContext(); + var filter = new InternalItemsQuery(user); + var userId = user.Id; + + // Merged series and seasons are stored as one row per folder-item sharing a presentation key. + var groups = GetPresentationKeyGroups(dbContext, folderIds); + var folderIdsArray = groups.Values.SelectMany(members => members).Distinct().ToArray(); + + var leafItems = dbContext.BaseItems + .Where(DescendantQueryHelper.IsCountableLeaf); + leafItems = _queryHelpers.ApplyAccessFiltering(dbContext, leafItems, filter); + + var playedLeafItems = leafItems + .Select(b => new { b.Id, Played = b.UserData!.Any(ud => ud.UserId == userId && ud.Played) }); + + var ancestorLeaves = dbContext.AncestorIds + .WhereOneOrMany(folderIdsArray, a => a.ParentItemId) + .Join( + playedLeafItems, + a => a.ItemId, + b => b.Id, + (a, b) => new { FolderId = a.ParentItemId, b.Id, b.Played }); + + var linkedLeaves = dbContext.LinkedChildren + .WhereOneOrMany(folderIdsArray, lc => lc.ParentId) + .Join( + playedLeafItems, + lc => lc.ChildId, + b => b.Id, + (lc, b) => new { FolderId = lc.ParentId, b.Id, b.Played }); + + var linkedFolderLeaves = dbContext.LinkedChildren + .WhereOneOrMany(folderIdsArray, lc => lc.ParentId) + .Join( + dbContext.BaseItems.Where(b => b.IsFolder), + lc => lc.ChildId, + b => b.Id, + (lc, b) => new { lc.ParentId, FolderChildId = b.Id }) + .Join( + dbContext.AncestorIds, + x => x.FolderChildId, + a => a.ParentItemId, + (x, a) => new { x.ParentId, DescendantId = a.ItemId }) + .Join( + playedLeafItems, + x => x.DescendantId, + b => b.Id, + (x, b) => new { FolderId = x.ParentId, b.Id, b.Played }); + + var countsByFolder = ancestorLeaves + .Union(linkedLeaves) + .Union(linkedFolderLeaves) + .GroupBy(x => x.FolderId) + .Select(g => new + { + FolderId = g.Key, + Total = g.Select(x => x.Id).Distinct().Count(), + Played = g.Where(x => x.Played).Select(x => x.Id).Distinct().Count() + }) + .ToDictionary(x => x.FolderId, x => (x.Played, x.Total)); + + var results = new Dictionary<Guid, (int Played, int Total)>(); + foreach (var (folderId, members) in groups) + { + var played = 0; + var total = 0; + + // Members of a group are distinct folders, so their leaves cannot overlap. + foreach (var member in members) + { + if (countsByFolder.TryGetValue(member, out var counts)) + { + played += counts.Played; + total += counts.Total; + } + } + + if (total > 0 || played > 0) + { + results[folderId] = (played, total); + } + } + + return results; + } + + private static Dictionary<Guid, List<Guid>> GetPresentationKeyGroups(JellyfinDbContext dbContext, IReadOnlyList<Guid> folderIds) + { + var requested = dbContext.BaseItems + .AsNoTracking() + .WhereOneOrMany(folderIds, e => e.Id) + .Select(e => new { e.Id, e.PresentationUniqueKey }) + .ToArray(); + + var keys = requested + .Select(e => e.PresentationUniqueKey) + .Where(key => !string.IsNullOrEmpty(key)) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + // Every item that is not merged carries a key derived from its own id, so in the common case + // each group resolves back to the single folder that was asked for. + var membersByKey = keys.Length == 0 + ? [] + : dbContext.BaseItems + .AsNoTracking() + .Where(e => e.IsFolder) + .WhereOneOrMany(keys, e => e.PresentationUniqueKey!) + .Select(e => new { e.Id, Key = e.PresentationUniqueKey! }) + .ToArray() + .GroupBy(e => e.Key, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.Select(e => e.Id).ToList(), StringComparer.Ordinal); + + var keyById = requested.ToDictionary(e => e.Id, e => e.PresentationUniqueKey); + var groups = new Dictionary<Guid, List<Guid>>(); + foreach (var folderId in folderIds) + { + groups[folderId] = keyById.TryGetValue(folderId, out var key) + && !string.IsNullOrEmpty(key) + && membersByKey.TryGetValue(key, out var members) + && members.Count > 0 + ? members + : [folderId]; + } + + return groups; + } + + private static (int Played, int Total) GetPlayedAndTotalCountFromQuery(IQueryable<BaseItemEntity> query, Guid userId) + { + var result = query + .Select(b => b.UserData!.Any(u => u.UserId == userId && u.Played)) + .GroupBy(_ => 1) + .OrderBy(g => g.Key) + .Select(g => new + { + Total = g.Count(), + Played = g.Count(isPlayed => isPlayed) + }) + .FirstOrDefault(); + + return result is null ? (0, 0) : (result.Played, result.Total); + } + + /// <summary> + /// A by-name item, reduced to the three columns the counting keys off. + /// </summary> + /// <param name="Id">The id of the by-name item.</param> + /// <param name="Name">The name of the by-name item.</param> + /// <param name="CleanName">The cleaned name of the by-name item.</param> + private sealed record NameItem(Guid Id, string? Name, string? CleanName); +} diff --git a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs new file mode 100644 index 0000000000..c8672e189b --- /dev/null +++ b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs @@ -0,0 +1,718 @@ +#pragma warning disable RS0030 // Do not use banned APIs + +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 Jellyfin.Extensions; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Controller.Playlists; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using BaseItemDto = MediaBrowser.Controller.Entities.BaseItem; +using DbLinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType; +using LinkedChildType = MediaBrowser.Controller.Entities.LinkedChildType; + +namespace Jellyfin.Server.Implementations.Item; + +/// <summary> +/// Handles item persistence operations (save, delete, update). +/// </summary> +public class ItemPersistenceService : IItemPersistenceService +{ + private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; + private readonly IServerApplicationHost _appHost; + private readonly ILogger<ItemPersistenceService> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="ItemPersistenceService"/> class. + /// </summary> + /// <param name="dbProvider">The database context factory.</param> + /// <param name="appHost">The application host.</param> + /// <param name="logger">The logger.</param> + public ItemPersistenceService( + IDbContextFactory<JellyfinDbContext> dbProvider, + IServerApplicationHost appHost, + ILogger<ItemPersistenceService> logger) + { + _dbProvider = dbProvider; + _appHost = appHost; + _logger = logger; + } + + /// <inheritdoc /> + public void DeleteItem(params IReadOnlyList<Guid> ids) + { + if (ids is null || ids.Count == 0 || ids.Any(f => f.Equals(BaseItemRepository.PlaceholderId))) + { + throw new ArgumentException("Guid can't be empty or the placeholder id.", nameof(ids)); + } + + using var context = _dbProvider.CreateDbContext(); + using var transaction = context.Database.BeginTransaction(); + + var date = (DateTime?)DateTime.UtcNow; + + var descendantIds = DescendantQueryHelper.GetOwnedDescendantIdsBatch(context, ids); + foreach (var id in ids) + { + descendantIds.Add(id); + } + + // Use WhereOneOrMany instead of a raw HashSet.Contains so large id sets are bound as a + // single parameter (json_each) rather than one SQL variable per id, which would otherwise + // overflow SQLite's variable limit when deleting many items at once (e.g. migrations). + var ownerIds = descendantIds.ToArray(); + var extraIds = context.BaseItems + .Where(e => e.OwnerId.HasValue) + .WhereOneOrMany(ownerIds, e => e.OwnerId!.Value) + .Select(e => e.Id) + .ToArray(); + + foreach (var extraId in extraIds) + { + descendantIds.Add(extraId); + } + + var relatedItems = descendantIds.ToArray(); + + // When batch-deleting, multiple items may have UserData for the same (UserId, CustomDataKey). + // Moving all of them to PlaceholderId would violate the UNIQUE constraint. + // Deduplicate by loading keys client-side, keeping the best row per group. + var batchUserData = context.UserData.WhereOneOrMany(relatedItems, e => e.ItemId); + + var allRows = batchUserData + .Select(ud => new { ud.ItemId, ud.UserId, ud.CustomDataKey, ud.LastPlayedDate, ud.PlayCount }) + .ToList(); + + var duplicateRows = allRows + .GroupBy(ud => new { ud.UserId, ud.CustomDataKey }) + .Where(g => g.Count() > 1) + .SelectMany(g => g + .OrderByDescending(ud => ud.LastPlayedDate) + .ThenByDescending(ud => ud.PlayCount) + .Skip(1)) + .ToList(); + + foreach (var dup in duplicateRows) + { + context.UserData + .Where(ud => ud.ItemId == dup.ItemId && ud.UserId == dup.UserId && ud.CustomDataKey == dup.CustomDataKey) + .ExecuteDelete(); + } + + // Delete existing placeholder rows that would conflict with the incoming ones + context.UserData + .Join( + batchUserData, + placeholder => new { placeholder.UserId, placeholder.CustomDataKey }, + userData => new { userData.UserId, userData.CustomDataKey }, + (placeholder, userData) => placeholder) + .Where(e => e.ItemId == BaseItemRepository.PlaceholderId) + .ExecuteDelete(); + + batchUserData + .ExecuteUpdate(e => e + .SetProperty(f => f.RetentionDate, date) + .SetProperty(f => f.ItemId, BaseItemRepository.PlaceholderId)); + + context.AncestorIds.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); + context.AncestorIds.WhereOneOrMany(relatedItems, e => e.ParentItemId).ExecuteDelete(); + context.AttachmentStreamInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); + context.BaseItemImageInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); + context.BaseItemMetadataFields.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); + context.BaseItemProviders.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); + context.BaseItemTrailerTypes.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); + context.Chapters.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); + context.CustomItemDisplayPreferences.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); + context.ItemDisplayPreferences.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); + context.ItemValues.Where(e => e.BaseItemsMap!.Count == 0).ExecuteDelete(); + context.ItemValuesMap.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); + context.LinkedChildren.WhereOneOrMany(relatedItems, e => e.ParentId).ExecuteDelete(); + context.LinkedChildren.WhereOneOrMany(relatedItems, e => e.ChildId).ExecuteDelete(); + context.BaseItems.WhereOneOrMany(relatedItems, e => e.Id).ExecuteDelete(); + context.KeyframeData.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); + context.MediaSegments.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); + context.MediaStreamInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); + var query = context.PeopleBaseItemMap.WhereOneOrMany(relatedItems, e => e.ItemId).Select(f => f.PeopleId).Distinct().ToArray(); + context.PeopleBaseItemMap.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); + context.Peoples.WhereOneOrMany(query, e => e.Id).Where(e => e.BaseItems!.Count == 0).ExecuteDelete(); + context.TrickplayInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); + context.SaveChanges(); + transaction.Commit(); + } + + /// <inheritdoc /> + public void UpdateInheritedValues() + { + using var context = _dbProvider.CreateDbContext(); + using var transaction = context.Database.BeginTransaction(); + + context.ItemValuesMap.Where(e => e.ItemValue.Type == ItemValueType.InheritedTags).ExecuteDelete(); + context.SaveChanges(); + + transaction.Commit(); + } + + /// <inheritdoc /> + public void SaveItems(IReadOnlyList<BaseItemDto> items, CancellationToken cancellationToken) + { + UpdateOrInsertItems(items, cancellationToken); + } + + /// <inheritdoc /> + public async Task SaveImagesAsync(BaseItem item, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(item); + + var images = item.ImageInfos.Select(e => BaseItemMapper.MapImageToEntity(item.Id, e)).ToArray(); + + var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (context.ConfigureAwait(false)) + { + await context.BaseItemImageInfos + .Where(e => e.ItemId == item.Id) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + + await context.BaseItemImageInfos + .AddRangeAsync(images, cancellationToken) + .ConfigureAwait(false); + + try + { + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + catch (DbUpdateException) + { + // Checking that the item exists before writing leaves a gap a scan can delete it + // through, turning the insert into a foreign key violation that fails the whole + // refresh instead of the no-op intended here. Let the insert be the check: it is the + // only point at which the answer cannot go stale. Nothing is orphaned by the delete + // above, because deleting the item cascades to its images anyway. + if (await context.BaseItems + .AnyAsync(bi => bi.Id == item.Id, cancellationToken) + .ConfigureAwait(false)) + { + throw; + } + + _logger.LogWarning("Unable to save ImageInfo for non existing BaseItem {ItemId}", item.Id); + } + } + } + + /// <inheritdoc /> + public async Task ReattachUserDataAsync(BaseItemDto item, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(item); + cancellationToken.ThrowIfCancellationRequested(); + + var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + + await using (dbContext.ConfigureAwait(false)) + { + var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); + await using (transaction.ConfigureAwait(false)) + { + var userKeys = item.GetUserDataKeys().ToArray(); + var retentionDate = (DateTime?)null; + + await dbContext.UserData + .Where(e => e.ItemId == BaseItemRepository.PlaceholderId) + .Where(e => userKeys.Contains(e.CustomDataKey)) + .ExecuteUpdateAsync( + e => e + .SetProperty(f => f.ItemId, item.Id) + .SetProperty(f => f.RetentionDate, retentionDate), + cancellationToken).ConfigureAwait(false); + + item.UserData = await dbContext.UserData + .AsNoTracking() + .Where(e => e.ItemId == item.Id) + .ToArrayAsync(cancellationToken) + .ConfigureAwait(false); + + await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + } + } + } + + private void UpdateOrInsertItems(IReadOnlyList<BaseItemDto> items, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(items); + cancellationToken.ThrowIfCancellationRequested(); + + var tuples = new List<(BaseItemDto Item, List<Guid>? AncestorIds, BaseItemDto TopParent, IEnumerable<string> UserDataKey, List<string> InheritedTags)>(); + foreach (var item in items.GroupBy(e => e.Id).Select(e => e.Last()).Where(e => e.Id != BaseItemRepository.PlaceholderId)) + { + 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)); + } + + using var context = _dbProvider.CreateDbContext(); + using var transaction = context.Database.BeginTransaction(); + + var ids = tuples.Select(f => f.Item.Id).ToArray(); + var existingItems = context.BaseItems.Where(e => ids.Contains(e.Id)).Select(f => f.Id).ToHashSet(); + + foreach (var item in tuples) + { + var entity = BaseItemMapper.Map(item.Item, _appHost); + entity.TopParentId = item.TopParent?.Id; + + if (!existingItems.Contains(entity.Id)) + { + context.BaseItems.Add(entity); + } + else + { + if (entity.Images is { Count: > 0 }) + { + context.BaseItemImageInfos.AddRange(entity.Images); + } + + if (entity.LockedFields is { Count: > 0 }) + { + context.BaseItemMetadataFields.AddRange(entity.LockedFields); + } + + context.BaseItems.Attach(entity).State = EntityState.Modified; + } + } + + var itemValueMaps = tuples + .Select(e => (e.Item, Values: GetItemValuesToSave(e.Item, e.InheritedTags))) + .ToArray(); + var allListedItemValues = itemValueMaps + .SelectMany(f => f.Values) + .Distinct() + .ToArray(); + + var types = allListedItemValues.Select(e => e.MagicNumber).Distinct().ToArray(); + var values = allListedItemValues.Select(e => e.Value).Distinct().ToArray(); + var allListedItemValuesSet = allListedItemValues.ToHashSet(); + + var existingValues = context.ItemValues + .Where(e => types.Contains(e.Type) && values.Contains(e.Value)) + .AsEnumerable() + .Where(e => allListedItemValuesSet.Contains((e.Type, e.Value))) + .ToArray(); + var missingItemValues = allListedItemValues.Except(existingValues.Select(f => (MagicNumber: f.Type, f.Value))).Select(f => new ItemValue() + { + CleanValue = f.Value.GetCleanValue(), + ItemValueId = Guid.NewGuid(), + Type = f.MagicNumber, + Value = f.Value + }).ToArray(); + context.ItemValues.AddRange(missingItemValues); + + var itemValuesStore = existingValues + .Concat(missingItemValues) + .ToDictionary(e => (e.Type, e.Value)); + var valueMap = itemValueMaps + .Select(f => (f.Item, Values: f.Values.Select(e => itemValuesStore[(e.MagicNumber, e.Value)]).DistinctBy(e => e.ItemValueId).ToArray())) + .ToArray(); + + var mappedValues = context.ItemValuesMap.Where(e => ids.Contains(e.ItemId)).ToList(); + + foreach (var item in valueMap) + { + var itemMappedValues = mappedValues.Where(e => e.ItemId == item.Item.Id).ToList(); + foreach (var itemValue in item.Values) + { + var existingItem = itemMappedValues.FirstOrDefault(f => f.ItemValueId == itemValue.ItemValueId); + if (existingItem is null) + { + context.ItemValuesMap.Add(new ItemValueMap() + { + Item = null!, + ItemId = item.Item.Id, + ItemValue = null!, + ItemValueId = itemValue.ItemValueId + }); + } + else + { + itemMappedValues.Remove(existingItem); + } + } + + context.ItemValuesMap.RemoveRange(itemMappedValues); + } + + var itemsWithAncestors = tuples + .Where(t => t.Item.SupportsAncestors && t.AncestorIds != null) + .Select(t => t.Item.Id) + .ToList(); + + var allExistingAncestorIds = itemsWithAncestors.Count > 0 + ? context.AncestorIds + .Where(e => itemsWithAncestors.Contains(e.ItemId)) + .ToList() + .GroupBy(e => e.ItemId) + .ToDictionary(g => g.Key, g => g.ToList()) + : new Dictionary<Guid, List<AncestorId>>(); + + var allRequestedAncestorIds = tuples + .Where(t => t.Item.SupportsAncestors && t.AncestorIds != null) + .SelectMany(t => t.AncestorIds!) + .Distinct() + .ToList(); + + var validAncestorIdsSet = allRequestedAncestorIds.Count > 0 + ? context.BaseItems + .Where(e => allRequestedAncestorIds.Contains(e.Id)) + .Select(f => f.Id) + .ToHashSet() + : new HashSet<Guid>(); + + foreach (var item in tuples) + { + if (item.Item.SupportsAncestors && item.AncestorIds != null) + { + var existingAncestorIds = allExistingAncestorIds.GetValueOrDefault(item.Item.Id) ?? new List<AncestorId>(); + var validAncestorIds = item.AncestorIds.Where(id => validAncestorIdsSet.Contains(id)).ToArray(); + foreach (var ancestorId in validAncestorIds) + { + var existingAncestorId = existingAncestorIds.FirstOrDefault(e => e.ParentItemId == ancestorId); + if (existingAncestorId is null) + { + context.AncestorIds.Add(new AncestorId() + { + ParentItemId = ancestorId, + ItemId = item.Item.Id, + Item = null!, + ParentItem = null! + }); + } + else + { + existingAncestorIds.Remove(existingAncestorId); + } + } + + context.AncestorIds.RemoveRange(existingAncestorIds); + } + } + + // Owned rows of updated items are rewritten wholesale; cleared in one statement per table. + if (existingItems.Count > 0) + { + var updatedIds = existingItems.ToArray(); + context.BaseItemProviders.WhereOneOrMany(updatedIds, e => e.ItemId).ExecuteDelete(); + context.BaseItemImageInfos.WhereOneOrMany(updatedIds, e => e.ItemId).ExecuteDelete(); + context.BaseItemMetadataFields.WhereOneOrMany(updatedIds, e => e.ItemId).ExecuteDelete(); + } + + context.SaveChanges(); + + var folderIds = tuples + .Where(t => t.Item is Folder) + .Select(t => t.Item.Id) + .ToList(); + + var videoIds = tuples + .Where(t => t.Item is Video) + .Select(t => t.Item.Id) + .ToList(); + + var allLinkedChildrenByParent = new Dictionary<Guid, List<LinkedChildEntity>>(); + if (folderIds.Count > 0 || videoIds.Count > 0) + { + var allParentIds = folderIds.Concat(videoIds).Distinct().ToList(); + var allLinkedChildren = context.LinkedChildren + .Where(e => allParentIds.Contains(e.ParentId)) + .ToList(); + + allLinkedChildrenByParent = allLinkedChildren + .GroupBy(e => e.ParentId) + .ToDictionary(g => g.Key, g => g.ToList()); + } + + foreach (var item in tuples) + { + // A container that was never hydrated cannot be used to rewrite its links: its empty + // array means "unknown", so clearing the stored rows would silently empty the item. + if (item.Item is Folder { LinkedChildrenLoaded: false }) + { + continue; + } + + if (item.Item is Folder or Video + && allLinkedChildrenByParent.TryGetValue(item.Item.Id, out var existingLinks) + && existingLinks.Count > 0) + { + // A video only owns its alternate version links; any other link on that parent is + // written by the folder branch below and must survive. + var staleLinks = item.Item is Folder + ? existingLinks + : existingLinks + .Where(e => e.ChildType is DbLinkedChildType.LocalAlternateVersion or DbLinkedChildType.LinkedAlternateVersion) + .ToList(); + + if (staleLinks.Count > 0) + { + context.LinkedChildren.RemoveRange(staleLinks); + } + } + } + + context.SaveChanges(); + + // A LinkedChild's ItemId is only a cache. + var cachedChildIds = tuples + .Select(t => t.Item) + .OfType<Folder>() + .Where(f => f.LinkedChildrenLoaded) + .SelectMany(f => f.LinkedChildren) + .Where(lc => lc.ItemId.HasValue && !lc.ItemId.Value.IsEmpty()) + .Select(lc => lc.ItemId!.Value) + .Distinct() + .ToList(); + + var knownChildIds = cachedChildIds.Count > 0 + ? context.BaseItems + .WhereOneOrMany(cachedChildIds, e => e.Id) + .Select(e => e.Id) + .ToHashSet() + : []; + + foreach (var item in tuples) + { + if (item.Item is Folder { LinkedChildrenLoaded: true } folder && folder.LinkedChildren.Length > 0) + { +#pragma warning disable CS0618 // Type or member is obsolete - legacy path resolution for old data + var pathsToResolve = folder.LinkedChildren + .Where(lc => !string.IsNullOrEmpty(lc.Path) + && (!lc.ItemId.HasValue || lc.ItemId.Value.IsEmpty() || !knownChildIds.Contains(lc.ItemId.Value))) + .Select(lc => lc.Path) + .Distinct() + .ToList(); + + var pathToIdMap = pathsToResolve.Count > 0 + ? context.BaseItems + .Where(e => e.Path != null && pathsToResolve.Contains(e.Path)) + .Select(e => new { e.Path, e.Id }) + .GroupBy(e => e.Path!) + .ToDictionary(g => g.Key, g => g.First().Id) + : []; + + var resolvedChildren = new List<(LinkedChild Child, Guid ChildId)>(); + foreach (var linkedChild in folder.LinkedChildren) + { + var childItemId = linkedChild.ItemId; + if (!childItemId.HasValue || childItemId.Value.IsEmpty() || !knownChildIds.Contains(childItemId.Value)) + { + if (!string.IsNullOrEmpty(linkedChild.Path) && pathToIdMap.TryGetValue(linkedChild.Path, out var resolvedId)) + { + childItemId = resolvedId; + } + else if (Guid.TryParse(linkedChild.LibraryItemId, out var libraryItemId) && !libraryItemId.IsEmpty()) + { + childItemId = libraryItemId; + } + } +#pragma warning restore CS0618 + + if (childItemId.HasValue && !childItemId.Value.IsEmpty()) + { + resolvedChildren.Add((linkedChild, childItemId.Value)); + } + } + + // Playlists may legitimately contain the same item multiple times (e.g. a song repeated + // in an .m3u file). Every other container type keeps a single entry per child. + var isPlaylist = folder is Playlist; + if (!isPlaylist) + { + resolvedChildren = resolvedChildren + .GroupBy(c => c.ChildId) + .Select(g => g.Last()) + .ToList(); + } + + var childIdsToCheck = resolvedChildren.Select(c => c.ChildId).Distinct().ToList(); + var existingChildIds = childIdsToCheck.Count > 0 + ? context.BaseItems + .WhereOneOrMany(childIdsToCheck, e => e.Id) + .Select(e => e.Id) + .ToHashSet() + : []; + + var sortOrder = 0; + foreach (var (linkedChild, childId) in resolvedChildren) + { + if (!existingChildIds.Contains(childId)) + { +#pragma warning disable CS0618 // Type or member is obsolete - legacy path is logged for diagnostics + _logger.LogWarning( + "Skipping LinkedChild for parent {ParentName} ({ParentId}): child item {ChildId} (path {ChildPath}) does not exist in database", + item.Item.Name, + item.Item.Id, + childId, + linkedChild.Path ?? "unknown"); +#pragma warning restore CS0618 + continue; + } + + context.LinkedChildren.Add(new LinkedChildEntity() + { + ParentId = item.Item.Id, + ChildId = childId, + ChildType = (DbLinkedChildType)linkedChild.Type, + SortOrder = sortOrder + }); + + sortOrder++; + } + } + + if (item.Item is Video video) + { + var newLinkedChildren = new List<(Guid ChildId, LinkedChildType Type)>(); + + if (video.LocalAlternateVersions.Length > 0) + { + var pathsToResolve = video.LocalAlternateVersions.Where(p => !string.IsNullOrEmpty(p)).ToList(); + if (pathsToResolve.Count > 0) + { + var pathToIdMap = context.BaseItems + .Where(e => e.Path != null && pathsToResolve.Contains(e.Path)) + .Select(e => new { e.Path, e.Id }) + .GroupBy(e => e.Path!) + .ToDictionary(g => g.Key, g => g.First().Id); + + foreach (var path in pathsToResolve) + { + if (pathToIdMap.TryGetValue(path, out var childId)) + { + newLinkedChildren.Add((childId, LinkedChildType.LocalAlternateVersion)); + } + } + } + } + + if (video.LinkedAlternateVersions.Length > 0) + { + foreach (var linkedChild in video.LinkedAlternateVersions) + { + if (linkedChild.ItemId.HasValue && !linkedChild.ItemId.Value.IsEmpty()) + { + newLinkedChildren.Add((linkedChild.ItemId.Value, LinkedChildType.LinkedAlternateVersion)); + } + } + } + + // Deduplicate; local (file-based) relationships take priority over linked (user-merged) + // ones, matching the LinkedChildren migration. + newLinkedChildren = newLinkedChildren + .GroupBy(c => c.ChildId) + .Select(g => g.OrderBy(c => c.Type == LinkedChildType.LocalAlternateVersion ? 0 : 1).First()) + .ToList(); + + var childIdsToCheck = newLinkedChildren.Select(c => c.ChildId).ToList(); + var existingChildIds = childIdsToCheck.Count > 0 + ? context.BaseItems + .Where(e => childIdsToCheck.Contains(e.Id)) + .Select(e => e.Id) + .ToHashSet() + : []; + + var sortOrder = 0; + foreach (var (childId, childType) in newLinkedChildren) + { + if (!existingChildIds.Contains(childId)) + { + _logger.LogWarning( + "Skipping alternate version for video {VideoName} ({VideoId}): child item {ChildId} does not exist in database", + video.Name, + video.Id, + childId); + continue; + } + + context.LinkedChildren.Add(new LinkedChildEntity + { + ParentId = video.Id, + ChildId = childId, + ChildType = (DbLinkedChildType)childType, + SortOrder = sortOrder + }); + + sortOrder++; + } + + // A previously-linked LocalAlternateVersion that is no longer present becomes orphaned; + var previousLinkedChildren = allLinkedChildrenByParent.GetValueOrDefault(video.Id); + if (previousLinkedChildren is { Count: > 0 }) + { + var newChildIds = newLinkedChildren.Select(c => c.ChildId).ToHashSet(); + var orphanedLocalVersionIds = previousLinkedChildren + .Where(e => e.ChildType == DbLinkedChildType.LocalAlternateVersion && !newChildIds.Contains(e.ChildId)) + .Select(e => e.ChildId) + .ToList(); + + if (orphanedLocalVersionIds.Count > 0) + { + var orphanedItems = context.BaseItems + .Where(e => orphanedLocalVersionIds.Contains(e.Id) && e.OwnerId == video.Id) + .ToList(); + + if (orphanedItems.Count > 0) + { + _logger.LogInformation( + "Deleting {Count} orphaned LocalAlternateVersion items for video {VideoName} ({VideoId})", + orphanedItems.Count, + video.Name, + video.Id); + context.BaseItems.RemoveRange(orphanedItems); + } + } + } + } + } + + context.SaveChanges(); + transaction.Commit(); + } + + private static List<(ItemValueType MagicNumber, string Value)> GetItemValuesToSave(BaseItemDto item, List<string> inheritedTags) + { + var list = new List<(ItemValueType, string)>(); + + if (item is IHasArtist hasArtist) + { + list.AddRange(hasArtist.Artists.Select(i => ((ItemValueType)0, i))); + } + + if (item is IHasAlbumArtist hasAlbumArtist) + { + list.AddRange(hasAlbumArtist.AlbumArtists.Select(i => (ItemValueType.AlbumArtist, i))); + } + + list.AddRange(item.Genres.Select(i => (ItemValueType.Genre, i))); + list.AddRange(item.Studios.Select(i => (ItemValueType.Studios, i))); + list.AddRange(item.Tags.Select(i => (ItemValueType.Tags, i))); + + list.AddRange(inheritedTags.Select(i => (ItemValueType.InheritedTags, i))); + + list.RemoveAll(i => string.IsNullOrWhiteSpace(i.Item2)); + + return list; + } +} diff --git a/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs b/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs new file mode 100644 index 0000000000..de112d7aa4 --- /dev/null +++ b/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs @@ -0,0 +1,202 @@ +#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 + +using System; +using System.Collections.Generic; +using System.Linq; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations; +using Jellyfin.Extensions; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Persistence; +using Microsoft.EntityFrameworkCore; +using DbLinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType; +using LinkedChildType = MediaBrowser.Controller.Entities.LinkedChildType; + +namespace Jellyfin.Server.Implementations.Item; + +/// <summary> +/// Provides linked children query and manipulation operations. +/// </summary> +public class LinkedChildrenService : ILinkedChildrenService +{ + private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; + private readonly IItemTypeLookup _itemTypeLookup; + private readonly IItemQueryHelpers _queryHelpers; + + /// <summary> + /// Initializes a new instance of the <see cref="LinkedChildrenService"/> class. + /// </summary> + /// <param name="dbProvider">The database context factory.</param> + /// <param name="itemTypeLookup">The item type lookup.</param> + /// <param name="queryHelpers">The shared query helpers.</param> + public LinkedChildrenService( + IDbContextFactory<JellyfinDbContext> dbProvider, + IItemTypeLookup itemTypeLookup, + IItemQueryHelpers queryHelpers) + { + _dbProvider = dbProvider; + _itemTypeLookup = itemTypeLookup; + _queryHelpers = queryHelpers; + } + + /// <inheritdoc/> + public IReadOnlyList<Guid> GetLinkedChildrenIds(Guid parentId, int? childType = null) + { + using var dbContext = _dbProvider.CreateDbContext(); + + var query = dbContext.LinkedChildren + .Where(lc => lc.ParentId.Equals(parentId)); + + if (childType.HasValue) + { + query = query.Where(lc => (int)lc.ChildType == childType.Value); + } + + return query + .OrderBy(lc => lc.SortOrder) + .Select(lc => lc.ChildId) + .ToArray(); + } + + /// <inheritdoc/> + public IReadOnlySet<Guid> GetItemIdsWithAlternateVersions(IReadOnlyList<Guid> itemIds) + { + if (itemIds.Count == 0) + { + return new HashSet<Guid>(); + } + + using var dbContext = _dbProvider.CreateDbContext(); + + return dbContext.LinkedChildren + .Where(lc => lc.ChildType == DbLinkedChildType.LocalAlternateVersion + || lc.ChildType == DbLinkedChildType.LinkedAlternateVersion) + .WhereOneOrMany(itemIds, lc => lc.ParentId) + .Select(lc => lc.ParentId) + .Distinct() + .ToHashSet(); + } + + /// <inheritdoc/> + public IReadOnlyDictionary<string, MusicArtist[]> FindArtists(IReadOnlyList<string> artistNames) + { + using var dbContext = _dbProvider.CreateDbContext(); + + var cleanNames = artistNames.Select(n => (Original: n, Clean: n.GetCleanValue())).ToArray(); + var cleanValues = cleanNames.Select(x => x.Clean).ToArray(); + + var artists = dbContext.BaseItems + .AsNoTracking() + .Where(e => e.Type == _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]!) + .Where(e => cleanValues.Contains(e.CleanName)) + .ToArray(); + + var lookup = artists + .GroupBy(e => e.CleanName!) + .ToDictionary( + g => g.Key, + g => g.Select(f => _queryHelpers.DeserializeBaseItem(f)).Where(dto => dto is not null).Cast<MusicArtist>().ToArray()); + + var result = new Dictionary<string, MusicArtist[]>(cleanNames.Length); + foreach (var (original, clean) in cleanNames) + { + if (lookup.TryGetValue(clean, out var artistArray)) + { + result[original] = artistArray; + } + } + + return result; + } + + /// <inheritdoc/> + public IReadOnlyList<Guid> GetManualLinkedParentIds(Guid childId, BaseItemKind? parentType = null) + { + using var context = _dbProvider.CreateDbContext(); + + var query = context.LinkedChildren + .Where(lc => lc.ChildId == childId && lc.ChildType == DbLinkedChildType.Manual); + + if (parentType.HasValue) + { + var parentTypeName = _itemTypeLookup.BaseItemKindNames[parentType.Value]; + query = query.Join( + context.BaseItems + .Where(item => item.Type == parentTypeName), + lc => lc.ParentId, + item => item.Id, + (lc, _) => lc); + } + + return query.Select(lc => lc.ParentId).Distinct().ToList(); + } + + /// <inheritdoc/> + public IReadOnlyList<Guid> RerouteLinkedChildren(Guid fromChildId, Guid toChildId) + { + using var context = _dbProvider.CreateDbContext(); + + var affectedParentIds = context.LinkedChildren + .Where(lc => lc.ChildId == fromChildId && lc.ChildType == DbLinkedChildType.Manual) + .Select(lc => lc.ParentId) + .Distinct() + .ToList(); + + if (affectedParentIds.Count == 0) + { + return affectedParentIds; + } + + var parentsWithTarget = context.LinkedChildren + .Where(lc => lc.ChildId == toChildId && lc.ChildType == DbLinkedChildType.Manual) + .Select(lc => lc.ParentId) + .ToHashSet(); + + context.LinkedChildren + .Where(lc => lc.ChildId == fromChildId + && lc.ChildType == DbLinkedChildType.Manual + && !parentsWithTarget.Contains(lc.ParentId)) + .ExecuteUpdate(s => s.SetProperty(e => e.ChildId, toChildId)); + + context.LinkedChildren + .Where(lc => lc.ChildId == fromChildId + && lc.ChildType == DbLinkedChildType.Manual + && parentsWithTarget.Contains(lc.ParentId)) + .ExecuteDelete(); + + return affectedParentIds; + } + + /// <inheritdoc/> + public void UpsertLinkedChild(Guid parentId, Guid childId, LinkedChildType childType) + { + using var context = _dbProvider.CreateDbContext(); + + var dbChildType = (DbLinkedChildType)childType; + var existingLink = context.LinkedChildren + .FirstOrDefault(lc => lc.ParentId == parentId && lc.ChildId == childId); + + if (existingLink is null) + { + var nextSortOrder = (context.LinkedChildren + .Where(lc => lc.ParentId == parentId) + .Max(lc => (int?)lc.SortOrder) ?? -1) + 1; + + context.LinkedChildren.Add(new Jellyfin.Database.Implementations.Entities.LinkedChildEntity + { + ParentId = parentId, + ChildId = childId, + ChildType = dbChildType, + SortOrder = nextSortOrder + }); + } + else + { + existingLink.ChildType = dbChildType; + } + + context.SaveChanges(); + } +} diff --git a/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs b/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs index 7eb13b7408..a25629132b 100644 --- a/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs +++ b/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs @@ -55,6 +55,17 @@ public class MediaStreamRepository : IMediaStreamRepository return TranslateQuery(context.MediaStreamInfos.AsNoTracking(), filter).AsEnumerable().Select(Map).ToArray(); } + /// <inheritdoc /> + public IReadOnlyList<string> GetMediaStreamLanguages(MediaStreamType mediaStreamType) + { + using var context = _dbProvider.CreateDbContext(); + return context.MediaStreamInfos + .Where(e => e.StreamType == (MediaStreamTypeEntity)mediaStreamType) + .Select(s => string.IsNullOrEmpty(s.Language) ? "und" : s.Language) // und = undetermined + .Distinct() + .ToArray(); + } + private string? GetPathToSave(string? path) { if (path is null) @@ -123,6 +134,7 @@ public class MediaStreamRepository : IMediaStreamRepository dto.IsDefault = entity.IsDefault; dto.IsForced = entity.IsForced; dto.IsExternal = entity.IsExternal; + dto.IsOriginal = entity.IsOriginal; dto.Height = entity.Height; dto.Width = entity.Width; dto.AverageFrameRate = entity.AverageFrameRate; @@ -158,6 +170,16 @@ public class MediaStreamRepository : IMediaStreamRepository dto.LocalizedDefault = _localization.GetLocalizedString("Default"); dto.LocalizedExternal = _localization.GetLocalizedString("External"); + if (!string.IsNullOrEmpty(dto.Language)) + { + dto.LocalizedLanguage = _localization.GetLanguageDisplayName(dto.Language); + } + + if (dto.Type is MediaStreamType.Audio) + { + dto.LocalizedOriginal = _localization.GetLocalizedString("Original"); + } + if (dto.Type is MediaStreamType.Subtitle) { dto.LocalizedUndefined = _localization.GetLocalizedString("Undefined"); @@ -192,6 +214,7 @@ public class MediaStreamRepository : IMediaStreamRepository IsDefault = dto.IsDefault, IsForced = dto.IsForced, IsExternal = dto.IsExternal, + IsOriginal = dto.IsOriginal, Height = dto.Height, Width = dto.Width, AverageFrameRate = dto.AverageFrameRate, diff --git a/Jellyfin.Server.Implementations/Item/NextUpService.cs b/Jellyfin.Server.Implementations/Item/NextUpService.cs new file mode 100644 index 0000000000..f478daef23 --- /dev/null +++ b/Jellyfin.Server.Implementations/Item/NextUpService.cs @@ -0,0 +1,354 @@ +#pragma warning disable RS0030 // Do not use banned APIs + +using System; +using System.Collections.Generic; +using System.Linq; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Persistence; +using Microsoft.EntityFrameworkCore; +using BaseItemDto = MediaBrowser.Controller.Entities.BaseItem; + +namespace Jellyfin.Server.Implementations.Item; + +/// <summary> +/// Provides next-up episode query operations. +/// </summary> +public class NextUpService : INextUpService +{ + private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; + private readonly IItemTypeLookup _itemTypeLookup; + private readonly IItemQueryHelpers _queryHelpers; + + /// <summary> + /// Initializes a new instance of the <see cref="NextUpService"/> class. + /// </summary> + /// <param name="dbProvider">The database context factory.</param> + /// <param name="itemTypeLookup">The item type lookup.</param> + /// <param name="queryHelpers">The shared query helpers.</param> + public NextUpService( + IDbContextFactory<JellyfinDbContext> dbProvider, + IItemTypeLookup itemTypeLookup, + IItemQueryHelpers queryHelpers) + { + _dbProvider = dbProvider; + _itemTypeLookup = itemTypeLookup; + _queryHelpers = queryHelpers; + } + + /// <inheritdoc /> + public IReadOnlyList<string> 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().Where(e => e.ItemId != EF.Constant(BaseItemRepository.PlaceholderId)), + i => new { UserId = filter.User.Id, ItemId = i.Id }, + u => new { u.UserId, 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(); + } + + /// <inheritdoc /> + public IReadOnlyDictionary<string, NextUpEpisodeBatchResult> GetNextUpEpisodesBatch( + InternalItemsQuery filter, + IReadOnlyList<string> seriesKeys, + bool includeSpecials, + bool includeWatchedForRewatching) + { + ArgumentNullException.ThrowIfNull(filter); + ArgumentNullException.ThrowIfNull(filter.User); + + if (seriesKeys.Count == 0) + { + return new Dictionary<string, NextUpEpisodeBatchResult>(); + } + + _queryHelpers.PrepareFilterQuery(filter); + using var context = _dbProvider.CreateDbContext(); + + var userId = filter.User.Id; + var episodeTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode]; + + var lastWatchedBase = context.BaseItems + .AsNoTracking() + .Where(e => e.Type == episodeTypeName) + .Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey)) + .Where(e => e.ParentIndexNumber != 0) + .Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played)); + lastWatchedBase = _queryHelpers.ApplyAccessFiltering(context, lastWatchedBase, filter); + + // Use lightweight projection + client-side dedup to avoid the correlated scalar subquery + // per group that EF generates for GroupBy+OrderByDescending+FirstOrDefault. + var allPlayedLite = lastWatchedBase + .Select(e => new + { + e.Id, + e.SeriesPresentationUniqueKey, + e.ParentIndexNumber, + e.IndexNumber + }) + .ToList(); + + var lastWatchedInfo = allPlayedLite + .OrderByDescending(e => e.ParentIndexNumber) + .ThenByDescending(e => e.IndexNumber) + .DistinctBy(e => e.SeriesPresentationUniqueKey) + .ToDictionary(e => e.SeriesPresentationUniqueKey!, e => e.Id); + + Dictionary<string, Guid> lastWatchedByDateInfo = new(); + if (includeWatchedForRewatching) + { + var lastWatchedByDateBase = context.BaseItems + .AsNoTracking() + .Where(e => e.Type == episodeTypeName) + .Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey)) + .Where(e => e.ParentIndexNumber != 0); + lastWatchedByDateBase = _queryHelpers.ApplyAccessFiltering(context, lastWatchedByDateBase, filter); + + // Use an explicit Join (INNER JOIN) instead of SelectMany on a collection navigation. + // SelectMany on UserData with a correlated Where would translate to APPLY, + // which SQLite does not support. + var playedWithDates = lastWatchedByDateBase + .Join( + context.UserData + .AsNoTracking() + .Where(ud => ud.ItemId != EF.Constant(BaseItemRepository.PlaceholderId)) + .Where(ud => ud.Played), + e => new { UserId = userId, ItemId = e.Id }, + ud => new { ud.UserId, ud.ItemId }, + (e, ud) => new { EpisodeId = e.Id, e.SeriesPresentationUniqueKey, ud.LastPlayedDate }) + .ToList(); + + lastWatchedByDateInfo = playedWithDates + .OrderByDescending(x => x.LastPlayedDate) + .DistinctBy(x => x.SeriesPresentationUniqueKey) + .ToDictionary(x => x.SeriesPresentationUniqueKey!, x => x.EpisodeId); + } + + var allLastWatchedIds = lastWatchedInfo.Values + .Concat(lastWatchedByDateInfo.Values) + .Where(id => id != Guid.Empty) + .Distinct() + .ToList(); + var lwQuery = context.BaseItems.AsNoTracking().Where(e => allLastWatchedIds.Contains(e.Id)); + lwQuery = _queryHelpers.ApplyNavigations(lwQuery, filter); + var lastWatchedEpisodes = lwQuery.ToDictionary(e => e.Id); + + Dictionary<string, List<BaseItemEntity>> specialsBySeriesKey = new(); + if (includeSpecials) + { + var specialsQuery = context.BaseItems + .AsNoTracking() + .Where(e => e.Type == episodeTypeName) + .Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey)) + .Where(e => e.ParentIndexNumber == 0) + .Where(e => !e.IsVirtualItem); + specialsQuery = _queryHelpers.ApplyAccessFiltering(context, specialsQuery, filter); + specialsQuery = _queryHelpers.ApplyNavigations(specialsQuery, filter).AsSingleQuery(); + + foreach (var special in specialsQuery) + { + var key = special.SeriesPresentationUniqueKey!; + if (!specialsBySeriesKey.TryGetValue(key, out var list)) + { + list = new List<BaseItemEntity>(); + specialsBySeriesKey[key] = list; + } + + list.Add(special); + } + } + + var positionLookup = new Dictionary<string, (int Season, int Episode)>(); + foreach (var kvp in lastWatchedInfo) + { + if (kvp.Value != Guid.Empty + && lastWatchedEpisodes.TryGetValue(kvp.Value, out var lw) + && lw.ParentIndexNumber.HasValue + && lw.IndexNumber.HasValue) + { + positionLookup[kvp.Key] = (lw.ParentIndexNumber.Value, lw.IndexNumber.Value); + } + } + + var allUnplayedBase = context.BaseItems + .AsNoTracking() + .Where(e => e.Type == episodeTypeName) + .Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey)) + .Where(e => e.ParentIndexNumber != 0) + .Where(e => !e.IsVirtualItem) + .Where(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played)); + allUnplayedBase = _queryHelpers.ApplyAccessFiltering(context, allUnplayedBase, filter); + var allUnplayedCandidates = allUnplayedBase + .Select(e => new + { + e.Id, + e.SeriesPresentationUniqueKey, + e.ParentIndexNumber, + EpisodeNumber = e.IndexNumber + }) + .ToList(); + + var nextEpisodeIds = new HashSet<Guid>(); + var seriesNextIdMap = new Dictionary<string, Guid>(); + + foreach (var seriesKey in seriesKeys) + { + var candidates = allUnplayedCandidates + .Where(c => c.SeriesPresentationUniqueKey == seriesKey); + + if (positionLookup.TryGetValue(seriesKey, out var pos)) + { + candidates = candidates.Where(c => + c.ParentIndexNumber > pos.Season + || (c.ParentIndexNumber == pos.Season && c.EpisodeNumber > pos.Episode)); + } + + var nextCandidate = candidates + .OrderBy(c => c.ParentIndexNumber) + .ThenBy(c => c.EpisodeNumber) + .FirstOrDefault(); + + if (nextCandidate is not null && nextCandidate.Id != Guid.Empty) + { + nextEpisodeIds.Add(nextCandidate.Id); + seriesNextIdMap[seriesKey] = nextCandidate.Id; + } + } + + var seriesNextPlayedIdMap = new Dictionary<string, Guid>(); + if (includeWatchedForRewatching) + { + var allPlayedBase = context.BaseItems + .AsNoTracking() + .Where(e => e.Type == episodeTypeName) + .Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey)) + .Where(e => e.ParentIndexNumber != 0) + .Where(e => !e.IsVirtualItem) + .Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played)); + allPlayedBase = _queryHelpers.ApplyAccessFiltering(context, allPlayedBase, filter); + var allPlayedCandidates = allPlayedBase + .Select(e => new + { + e.Id, + e.SeriesPresentationUniqueKey, + e.ParentIndexNumber, + EpisodeNumber = e.IndexNumber + }) + .ToList(); + + foreach (var seriesKey in seriesKeys) + { + if (!lastWatchedByDateInfo.TryGetValue(seriesKey, out var lastByDateId)) + { + continue; + } + + var lastByDateEntity = lastWatchedEpisodes.GetValueOrDefault(lastByDateId); + if (lastByDateEntity is null) + { + continue; + } + + var playedCandidates = allPlayedCandidates + .Where(c => c.SeriesPresentationUniqueKey == seriesKey); + + if (lastByDateEntity.ParentIndexNumber.HasValue && lastByDateEntity.IndexNumber.HasValue) + { + var lastSeason = lastByDateEntity.ParentIndexNumber.Value; + var lastEp = lastByDateEntity.IndexNumber.Value; + playedCandidates = playedCandidates.Where(c => + c.ParentIndexNumber > lastSeason + || (c.ParentIndexNumber == lastSeason && c.EpisodeNumber > lastEp)); + } + + var nextPlayedCandidate = playedCandidates + .OrderBy(c => c.ParentIndexNumber) + .ThenBy(c => c.EpisodeNumber) + .FirstOrDefault(); + + if (nextPlayedCandidate is not null && nextPlayedCandidate.Id != Guid.Empty) + { + nextEpisodeIds.Add(nextPlayedCandidate.Id); + seriesNextPlayedIdMap[seriesKey] = nextPlayedCandidate.Id; + } + } + } + + var nextEpisodes = new Dictionary<Guid, BaseItemEntity>(); + if (nextEpisodeIds.Count > 0) + { + var nextQuery = context.BaseItems.AsNoTracking().Where(e => nextEpisodeIds.Contains(e.Id)); + nextQuery = _queryHelpers.ApplyNavigations(nextQuery, filter).AsSingleQuery(); + nextEpisodes = nextQuery.ToDictionary(e => e.Id); + } + + var result = new Dictionary<string, NextUpEpisodeBatchResult>(); + foreach (var seriesKey in seriesKeys) + { + var batchResult = new NextUpEpisodeBatchResult(); + + if (lastWatchedInfo.TryGetValue(seriesKey, out var lwId) && lwId != Guid.Empty) + { + if (lastWatchedEpisodes.TryGetValue(lwId, out var entity)) + { + batchResult.LastWatched = _queryHelpers.DeserializeBaseItem(entity, filter.SkipDeserialization); + } + } + + if (seriesNextIdMap.TryGetValue(seriesKey, out var nextId) && nextEpisodes.TryGetValue(nextId, out var nextEntity)) + { + batchResult.NextUp = _queryHelpers.DeserializeBaseItem(nextEntity, filter.SkipDeserialization); + } + + if (includeSpecials && specialsBySeriesKey.TryGetValue(seriesKey, out var specials)) + { + batchResult.Specials = specials.Select(s => _queryHelpers.DeserializeBaseItem(s, filter.SkipDeserialization)!).ToList(); + } + else + { + batchResult.Specials = Array.Empty<BaseItemDto>(); + } + + if (includeWatchedForRewatching) + { + if (lastWatchedByDateInfo.TryGetValue(seriesKey, out var lastByDateId) && + lastWatchedEpisodes.TryGetValue(lastByDateId, out var lastByDateEntity)) + { + batchResult.LastWatchedForRewatching = _queryHelpers.DeserializeBaseItem(lastByDateEntity, filter.SkipDeserialization); + } + + if (seriesNextPlayedIdMap.TryGetValue(seriesKey, out var nextPlayedId) && + nextEpisodes.TryGetValue(nextPlayedId, out var nextPlayedEntity)) + { + batchResult.NextPlayedForRewatching = _queryHelpers.DeserializeBaseItem(nextPlayedEntity, filter.SkipDeserialization); + } + } + + result[seriesKey] = batchResult; + } + + return result; + } +} diff --git a/Jellyfin.Server.Implementations/Item/OrderMapper.cs b/Jellyfin.Server.Implementations/Item/OrderMapper.cs index 1ae7cc6c4a..00b10e44a9 100644 --- a/Jellyfin.Server.Implementations/Item/OrderMapper.cs +++ b/Jellyfin.Server.Implementations/Item/OrderMapper.cs @@ -1,4 +1,7 @@ #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 using System; using System.Linq; @@ -26,26 +29,44 @@ public static class OrderMapper /// <returns>Func to be executed later for sorting query.</returns> public static Expression<Func<BaseItemEntity, object?>> MapOrderByField(ItemSortBy sortBy, InternalItemsQuery query, JellyfinDbContext jellyfinDbContext) { + if (sortBy == ItemSortBy.DatePlayed) + { + // An item's played date is the newest of its own progress and that of its alternate versions, + // which track progress under their own ids. Matching both in one predicate ORs them together, + // which no index can serve: the user's whole UserData table gets scanned per sorted row. + // Two indexed lookups combined by MAX cost a seek each instead. + var userData = query.User is null + ? jellyfinDbContext.UserData + : jellyfinDbContext.UserData.Where(w => w.UserId == query.User.Id); + + return e => userData + .Where(w => w.ItemId == e.Id) + .Select(w => w.LastPlayedDate) + .Concat(userData + .Where(w => w.Item!.PrimaryVersionId == e.Id) + .Select(w => w.LastPlayedDate)) + .Max(); + } + return (sortBy, query.User) switch { - (ItemSortBy.AirTime, _) => e => e.SortName, // TODO + (ItemSortBy.AirTime, _) => e => e.SortName, (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.PlayCount, _) => e => e.UserData!.Where(f => f.UserId.Equals(query.User!.Id)).OrderBy(f => f.CustomDataKey).FirstOrDefault()!.PlayCount, + (ItemSortBy.IsFavoriteOrLiked, _) => e => e.UserData!.Where(f => f.UserId.Equals(query.User!.Id)).OrderBy(f => f.CustomDataKey).Select(f => (bool?)f.IsFavorite).FirstOrDefault() ?? false, (ItemSortBy.IsFolder, _) => e => e.IsFolder, - (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.IsPlayed, _) => e => e.UserData!.Where(f => f.UserId.Equals(query.User!.Id)).OrderBy(f => f.CustomDataKey).FirstOrDefault()!.Played, + (ItemSortBy.IsUnplayed, _) => e => !e.UserData!.Where(f => f.UserId.Equals(query.User!.Id)).OrderBy(f => f.CustomDataKey).FirstOrDefault()!.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.Artist, _) => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.Artist).OrderBy(f => f.ItemValue.CleanValue).Select(f => f.ItemValue.CleanValue).FirstOrDefault(), + (ItemSortBy.AlbumArtist, _) => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.AlbumArtist).OrderBy(f => f.ItemValue.CleanValue).Select(f => f.ItemValue.CleanValue).FirstOrDefault(), + (ItemSortBy.Studio, _) => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.Studios).OrderBy(f => f.ItemValue.CleanValue).Select(f => f.ItemValue.CleanValue).FirstOrDefault(), (ItemSortBy.OfficialRating, _) => e => e.InheritedParentalRatingValue, (ItemSortBy.SeriesSortName, _) => e => e.SeriesName, (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.PremiereDate, _) => e => e.PremiereDate ?? (e.ProductionYear.HasValue ? DateTime.MinValue.AddYears(e.ProductionYear.Value - 1) : null), (ItemSortBy.StartDate, _) => e => e.StartDate, (ItemSortBy.Name, _) => e => e.CleanName, (ItemSortBy.CommunityRating, _) => e => e.CommunityRating, @@ -54,18 +75,16 @@ public static class OrderMapper (ItemSortBy.VideoBitRate, _) => e => e.TotalBitrate, (ItemSortBy.ParentIndexNumber, _) => e => e.ParentIndexNumber, (ItemSortBy.IndexNumber, _) => e => e.IndexNumber, + // SeriesDatePlayed is normally handled via pre-aggregated join in ApplySeriesDatePlayedOrder. + // This correlated subquery fallback is only reached when combined with search. (ItemSortBy.SeriesDatePlayed, not null) => e => - jellyfinDbContext.BaseItems - .Where(w => w.SeriesPresentationUniqueKey == e.PresentationUniqueKey) - .Join(jellyfinDbContext.UserData.Where(w => w.UserId == query.User.Id && w.Played), f => f.Id, f => f.ItemId, (item, userData) => userData.LastPlayedDate) - .Max(f => f), - (ItemSortBy.SeriesDatePlayed, null) => e => jellyfinDbContext.BaseItems.Where(w => w.SeriesPresentationUniqueKey == e.PresentationUniqueKey) - .Join(jellyfinDbContext.UserData.Where(w => w.Played), f => f.Id, f => f.ItemId, (item, userData) => userData.LastPlayedDate) - .Max(f => f), - // ItemSortBy.SeriesDatePlayed => e => jellyfinDbContext.UserData - // .Where(u => u.Item!.SeriesPresentationUniqueKey == e.PresentationUniqueKey && u.Played) - // .Max(f => f.LastPlayedDate), - // ItemSortBy.AiredEpisodeOrder => "AiredEpisodeOrder", + jellyfinDbContext.UserData + .Where(w => w.UserId == query.User.Id && w.Played && w.Item!.SeriesPresentationUniqueKey == e.PresentationUniqueKey) + .Max(f => f.LastPlayedDate), + (ItemSortBy.SeriesDatePlayed, null) => e => + jellyfinDbContext.UserData + .Where(w => w.Played && w.Item!.SeriesPresentationUniqueKey == e.PresentationUniqueKey) + .Max(f => f.LastPlayedDate), _ => e => e.SortName }; } @@ -73,6 +92,7 @@ public static class OrderMapper /// <summary> /// Creates an expression to order search results by match quality. /// Prioritizes: exact match (0) > prefix match with word boundary (1) > prefix match (2) > contains (3). + /// Considers both CleanName and OriginalTitle for matching. /// </summary> /// <param name="searchTerm">The search term to match against.</param> /// <returns>An expression that returns an integer representing match quality (lower is better).</returns> @@ -80,10 +100,15 @@ public static class OrderMapper { var cleanSearchTerm = GetCleanValue(searchTerm); var searchPrefix = cleanSearchTerm + " "; + var originalSearchLower = searchTerm.ToLowerInvariant(); + var originalSearchPrefix = originalSearchLower + " "; return e => - e.CleanName == cleanSearchTerm ? 0 : - e.CleanName!.StartsWith(searchPrefix) ? 1 : - e.CleanName!.StartsWith(cleanSearchTerm) ? 2 : 3; + // Exact match on CleanName or OriginalTitle + (e.CleanName == cleanSearchTerm || (e.OriginalTitle != null && e.OriginalTitle.ToLower() == originalSearchLower)) ? 0 : + // Prefix match with word boundary + (e.CleanName!.StartsWith(searchPrefix) || (e.OriginalTitle != null && e.OriginalTitle.ToLower().StartsWith(originalSearchPrefix))) ? 1 : + // Prefix match + (e.CleanName!.StartsWith(cleanSearchTerm) || (e.OriginalTitle != null && e.OriginalTitle.ToLower().StartsWith(originalSearchLower))) ? 2 : 3; } private static string GetCleanValue(string value) diff --git a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs index 355ed64797..da2ad033ec 100644 --- a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs +++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs @@ -1,14 +1,13 @@ 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.Database.Implementations.Entities.Libraries; using Jellyfin.Extensions; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Persistence; +using MediaBrowser.Model.Querying; using Microsoft.EntityFrameworkCore; namespace Jellyfin.Server.Implementations.Item; @@ -22,30 +21,55 @@ namespace Jellyfin.Server.Implementations.Item; /// </summary> /// <param name="dbProvider">Efcore Factory.</param> /// <param name="itemTypeLookup">Items lookup service.</param> +/// <param name="queryHelpers">Shared item query helpers.</param> /// <remarks> /// Initializes a new instance of the <see cref="PeopleRepository"/> class. /// </remarks> -public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, IItemTypeLookup itemTypeLookup) : IPeopleRepository +public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, IItemTypeLookup itemTypeLookup, IItemQueryHelpers queryHelpers) : IPeopleRepository { private readonly IDbContextFactory<JellyfinDbContext> _dbProvider = dbProvider; /// <inheritdoc/> - public IReadOnlyList<PersonInfo> GetPeople(InternalPeopleQuery filter) + public QueryResult<PersonInfo> GetPeople(InternalPeopleQuery filter) { using var context = _dbProvider.CreateDbContext(); var dbQuery = TranslateQuery(context.Peoples.AsNoTracking(), context, filter); + int? distinctNameCount = null; // Include PeopleBaseItemMap if (!filter.ItemId.IsEmpty()) { dbQuery = dbQuery.Include(p => p.BaseItems!.Where(m => m.ItemId == filter.ItemId)) - .OrderBy(e => e.BaseItems!.First(e => e.ItemId == filter.ItemId).ListOrder) + .OrderBy(e => e.BaseItems!.Where(m => m.ItemId == filter.ItemId).Min(m => m.ListOrder)) .ThenBy(e => e.PersonType) .ThenBy(e => e.Name); } else { - dbQuery = dbQuery.OrderBy(e => e.Name); + // The Peoples table has one row per (Name, PersonType), so the same person can + // appear multiple times (e.g. as Actor and GuestStar). Collapse to one row per + // name so /Persons doesn't return the same BaseItem id repeatedly, keeping the + // lowest id per lowercased name so case-only duplicates collapse together. + var candidates = dbQuery; + dbQuery = candidates + .Where(p => !candidates.Any(other => other.Name.ToLower() == p.Name.ToLower() && other.Id < p.Id)) + .OrderBy(e => e.Name.ToLower()); + + if (filter.EnableTotalRecordCount) + { + distinctNameCount = candidates.Select(e => e.Name.ToLower()).Distinct().Count(); + } + } + + var count = 0; + if (filter.EnableTotalRecordCount) + { + count = distinctNameCount ?? dbQuery.Count(); + } + + if (filter.StartIndex.HasValue && filter.StartIndex > 0) + { + dbQuery = dbQuery.Skip(filter.StartIndex.Value); } if (filter.Limit > 0) @@ -53,16 +77,29 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I dbQuery = dbQuery.Take(filter.Limit); } - return dbQuery.AsEnumerable().Select(Map).ToArray(); + return new QueryResult<PersonInfo> + { + StartIndex = filter.StartIndex ?? 0, + TotalRecordCount = count, + Items = dbQuery.AsEnumerable().SelectMany(MapCredits).ToArray(), + }; } /// <inheritdoc/> public IReadOnlyList<string> GetPeopleNames(InternalPeopleQuery filter) { using var context = _dbProvider.CreateDbContext(); - var dbQuery = TranslateQuery(context.Peoples.AsNoTracking(), context, filter).Select(e => e.Name).Distinct(); - // dbQuery = dbQuery.OrderBy(e => e.ListOrder); + IQueryable<string> dbQuery = TranslateQuery(context.Peoples.AsNoTracking(), context, filter) + .Select(e => e.Name) + .Distinct() + .OrderBy(e => e); + + if (filter.StartIndex.HasValue && filter.StartIndex > 0) + { + dbQuery = dbQuery.Skip(filter.StartIndex.Value); + } + if (filter.Limit > 0) { dbQuery = dbQuery.Take(filter.Limit); @@ -74,49 +111,73 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I /// <inheritdoc /> public void UpdatePeople(Guid itemId, IReadOnlyList<PersonInfo> people) { - foreach (var item in people.Where(e => e.Role is null)) + foreach (var person in people) { - item.Role = string.Empty; + person.Name = person.Name.Trim(); + person.Role = person.Role?.Trim() ?? string.Empty; } - // multiple metadata providers can provide the _same_ person - people = people.DistinctBy(e => e.Name + "-" + e.Type).ToArray(); - var personKeys = people.Select(e => e.Name + "-" + e.Type).ToArray(); + // Project the values every comparison below needs once, so neither the case folding nor the + // enum formatting is repeated per candidate. + var credits = people.Select(e => (Person: e, LoweredName: e.Name.ToLowerInvariant(), PersonType: e.Type.ToString(), LoweredRole: e.Role.ToLowerInvariant())); + + // multiple metadata providers can provide the _same_ credit; dedupe case-insensitively. + // The role is part of the key because one person can hold several credits of the same type + // on an item, e.g. a Writer credited for both the Novel and the Screenplay. + var distinctCredits = credits.DistinctBy(e => (e.LoweredName, e.PersonType, e.LoweredRole)).ToArray(); + + var distinctPersons = distinctCredits.DistinctBy(e => (e.LoweredName, e.PersonType)).ToArray(); + var personKeys = distinctPersons.Select(e => e.LoweredName + "-" + e.PersonType).ToArray(); using var context = _dbProvider.CreateDbContext(); using var transaction = context.Database.BeginTransaction(); var existingPersons = context.Peoples.Select(e => new - { - item = e, - SelectionKey = e.Name + "-" + e.PersonType - }) + { + item = e, + SelectionKey = e.Name.ToLower() + "-" + e.PersonType + }) .Where(p => personKeys.Contains(p.SelectionKey)) .Select(f => f.item) .ToArray(); - var toAdd = people - .Where(e => e.Type is not PersonKind.Artist && e.Type is not PersonKind.AlbumArtist) - .Where(e => !existingPersons.Any(f => f.Name == e.Name && f.PersonType == e.Type.ToString())) - .Select(Map); + var existingPersonKeys = existingPersons.Select(e => (e.Name.ToLowerInvariant(), e.PersonType ?? string.Empty)).ToHashSet(); + + var toAdd = distinctPersons + .Where(e => !existingPersonKeys.Contains((e.LoweredName, e.PersonType))) + .Select(e => Map(e.Person)) + .ToArray(); context.Peoples.AddRange(toAdd); context.SaveChanges(); - var personsEntities = toAdd.Concat(existingPersons).ToArray(); + // The Peoples table can hold case-only duplicates, so keep the first match per key just as + // the previous First() lookup did. + var personsEntities = new Dictionary<(string LoweredName, string PersonType), People>(); + foreach (var entity in toAdd.Concat(existingPersons)) + { + personsEntities.TryAdd((entity.Name.ToLowerInvariant(), entity.PersonType ?? string.Empty), entity); + } var existingMaps = context.PeopleBaseItemMap.Include(e => e.People).Where(e => e.ItemId == itemId).ToList(); + var existingMapsByCredit = new Dictionary<(string LoweredName, string PersonType, string LoweredRole), PeopleBaseItemMap>(); + foreach (var map in existingMaps) + { + existingMapsByCredit.TryAdd((map.People.Name.ToLowerInvariant(), map.People.PersonType ?? string.Empty, map.Role?.ToLowerInvariant() ?? string.Empty), map); + } var listOrder = 0; - foreach (var person in people) + foreach (var credit in distinctCredits) { - if (person.Type == PersonKind.Artist || person.Type == PersonKind.AlbumArtist) + var entityPerson = personsEntities[(credit.LoweredName, credit.PersonType)]; + if (existingMapsByCredit.TryGetValue((credit.LoweredName, credit.PersonType, credit.LoweredRole), out var existingMap)) { - continue; + // Update the order for existing mappings + existingMap.ListOrder = listOrder; + existingMap.SortOrder = credit.Person.SortOrder; + // person mapping already exists so remove from list + existingMaps.Remove(existingMap); } - - var entityPerson = personsEntities.First(e => e.Name == person.Name && e.PersonType == person.Type.ToString()); - var existingMap = existingMaps.FirstOrDefault(e => e.People.Name == person.Name && e.People.PersonType == person.Type.ToString() && e.Role == person.Role); - if (existingMap is null) + else { context.PeopleBaseItemMap.Add(new PeopleBaseItemMap() { @@ -125,31 +186,148 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I People = null!, PeopleId = entityPerson.Id, ListOrder = listOrder, - SortOrder = person.SortOrder, - Role = person.Role + SortOrder = credit.Person.SortOrder, + Role = credit.Person.Role }); } - else - { - // Update the order for existing mappings - existingMap.ListOrder = listOrder; - existingMap.SortOrder = person.SortOrder; - // person mapping already exists so remove from list - existingMaps.Remove(existingMap); - } listOrder++; } + var droppedCredits = existingMaps.Select(e => e.PeopleId).Distinct().ToArray(); context.PeopleBaseItemMap.RemoveRange(existingMaps); + context.SaveChanges(); + + // Nothing else ever deletes a credit row, so one left without a single mapping outlives the + // credit it stood for: it keeps a person of that name off the dead-person sweep, which only + // sees items no credit names, and keeps the name in every by-name list. That is how a credit + // a provider dropped, or one a broken provider result invented, becomes impossible to clean up. + DeleteCreditsWithoutMapping(context, droppedCredits); + context.SaveChanges(); transaction.Commit(); } - private PersonInfo Map(People people) + /// <inheritdoc/> + public int DeleteOrphanedCredits() + { + using var context = _dbProvider.CreateDbContext(); + + return DeleteCreditsWithoutMapping(context, null); + } + + // A null candidate list sweeps every credit, anything else only the ones just unmapped. + private int DeleteCreditsWithoutMapping(JellyfinDbContext context, IReadOnlyList<Guid>? candidates) + { + if (candidates is not null && candidates.Count == 0) + { + return 0; + } + + var credits = candidates is null + ? context.Peoples.AsQueryable() + : context.Peoples.WhereOneOrMany(candidates, e => e.Id); + + return credits.Where(e => !context.PeopleBaseItemMap.Any(f => f.PeopleId == e.Id)).ExecuteDelete(); + } + + /// <inheritdoc/> + public IReadOnlyDictionary<Guid, IReadOnlyList<string>> GetPeopleNamesByItems(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes) + { + using var context = _dbProvider.CreateDbContext(); + var query = context.PeopleBaseItemMap + .AsNoTracking() + .Where(m => itemIds.Contains(m.ItemId)); + + if (personTypes.Count > 0) + { + query = query.Where(m => personTypes.Contains(m.People.PersonType)); + } + + var rows = query + .OrderBy(m => m.ListOrder) + .Select(m => new { m.ItemId, m.People.Name }) + .ToList(); + + var result = new Dictionary<Guid, IReadOnlyList<string>>(); + foreach (var group in rows.GroupBy(r => r.ItemId)) + { + var names = group + .Select(r => r.Name) + .Where(name => !string.IsNullOrEmpty(name)) + .Distinct() + .ToArray(); + + if (names.Length > 0) + { + result[group.Key] = names; + } + } + + return result; + } + + /// <inheritdoc/> + public IReadOnlyDictionary<Guid, IReadOnlyList<PersonInfo>> GetPeopleByItems(IReadOnlyList<Guid> itemIds) + { + using var context = _dbProvider.CreateDbContext(); + var rows = context.PeopleBaseItemMap + .AsNoTracking() + .Where(m => itemIds.Contains(m.ItemId)) + .OrderBy(m => m.ListOrder) + .Select(m => new + { + m.ItemId, + m.Role, + m.SortOrder, + m.People.Id, + m.People.Name, + m.People.PersonType + }) + .ToList(); + + var result = new Dictionary<Guid, IReadOnlyList<PersonInfo>>(); + foreach (var group in rows.GroupBy(r => r.ItemId)) + { + var people = new List<PersonInfo>(); + foreach (var row in group) + { + var personInfo = new PersonInfo + { + ItemId = row.ItemId, + Id = row.Id, + Name = row.Name, + Role = row.Role, + SortOrder = row.SortOrder + }; + if (Enum.TryParse<PersonKind>(row.PersonType, out var kind)) + { + personInfo.Type = kind; + } + + people.Add(personInfo); + } + + result[group.Key] = people; + } + + return result; + } + + private IEnumerable<PersonInfo> MapCredits(People people) + { + var mappings = people.BaseItems; + if (mappings is null || mappings.Count == 0) + { + return [Map(people, null)]; + } + + return mappings.OrderBy(m => m.ListOrder).Select(m => Map(people, m)); + } + + private PersonInfo Map(People people, PeopleBaseItemMap? mapping) { - var mapping = people.BaseItems?.FirstOrDefault(); var personInfo = new PersonInfo() { Id = people.Id, @@ -182,23 +360,48 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I if (filter.User is not null && filter.IsFavorite.HasValue) { var personType = itemTypeLookup.BaseItemKindNames[BaseItemKind.Person]; - var oldQuery = query; + var userId = filter.User.Id; + var isFavorite = filter.IsFavorite.Value; + var favoriteItemIds = context.UserData + .Where(u => u.UserId.Equals(userId) && u.IsFavorite == isFavorite) + .Select(u => u.ItemId); - query = context.UserData - .Where(u => u.Item!.Type == personType && u.IsFavorite == filter.IsFavorite && u.UserId.Equals(filter.User.Id)) - .Join(oldQuery, e => e.Item!.Name, e => e.Name, (item, person) => person) - .Distinct() - .AsNoTracking(); + var favoriteNames = context.BaseItems + .Where(b => b.Type == personType && favoriteItemIds.Contains(b.Id)) + .Select(b => b.Name); + + query = query.Where(e => favoriteNames.Contains(e.Name)); + } + + if (filter.AccessFilter is not null) + { + // Keep only people credited on at least one item the user can see. + var accessibleItems = queryHelpers.ApplyAccessFiltering(context, context.BaseItems.AsNoTracking(), filter.AccessFilter); + query = query.Where(e => context.PeopleBaseItemMap + .Any(m => m.PeopleId == e.Id && accessibleItems.Any(i => i.Id == m.ItemId))); } if (!filter.ItemId.IsEmpty()) { - query = query.Where(e => e.BaseItems!.Any(w => w.ItemId.Equals(filter.ItemId))); + var itemId = filter.ItemId; + query = query.Where(e => context.PeopleBaseItemMap + .Where(m => m.ItemId.Equals(itemId)) + .Select(m => m.PeopleId) + .Contains(e.Id)); + } + + if (filter.ParentId != null) + { + query = query.Where(e => e.BaseItems!.Any(w => context.AncestorIds.Any(i => i.ParentItemId == filter.ParentId && i.ItemId == w.ItemId))); } if (!filter.AppearsInItemId.IsEmpty()) { - query = query.Where(e => e.BaseItems!.Any(w => w.ItemId.Equals(filter.AppearsInItemId))); + var appearsInItemId = filter.AppearsInItemId; + query = query.Where(e => context.PeopleBaseItemMap + .Where(m => m.ItemId.Equals(appearsInItemId)) + .Select(m => m.PeopleId) + .Contains(e.Id)); } var queryPersonTypes = filter.PersonTypes.Where(IsValidPersonType).ToList(); @@ -211,12 +414,12 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I if (queryExcludePersonTypes.Count > 0) { - query = query.Where(e => !queryPersonTypes.Contains(e.PersonType)); + query = query.Where(e => !queryExcludePersonTypes.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); + query = query.Where(e => e.BaseItems!.Any(w => w.ItemId == filter.ItemId && w.ListOrder <= filter.MaxListOrder.Value)); } if (!string.IsNullOrWhiteSpace(filter.NameContains)) @@ -225,6 +428,21 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I query = query.Where(e => e.Name.ToUpper().Contains(nameContainsUpper)); } + if (!string.IsNullOrWhiteSpace(filter.NameStartsWith)) + { + query = query.Where(e => e.Name.StartsWith(filter.NameStartsWith.ToLowerInvariant())); + } + + if (!string.IsNullOrWhiteSpace(filter.NameLessThan)) + { + query = query.Where(e => e.Name.CompareTo(filter.NameLessThan.ToLowerInvariant()) < 0); + } + + if (!string.IsNullOrWhiteSpace(filter.NameStartsWithOrGreater)) + { + query = query.Where(e => e.Name.CompareTo(filter.NameStartsWithOrGreater.ToLowerInvariant()) >= 0); + } + return query; } diff --git a/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj b/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj index 88c1e18333..a9229cae25 100644 --- a/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj +++ b/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj @@ -1,7 +1,7 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> - <TargetFramework>net9.0</TargetFramework> + <TargetFramework>net10.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> </PropertyGroup> @@ -27,7 +27,6 @@ <ItemGroup> <PackageReference Include="AsyncKeyedLock" /> - <PackageReference Include="System.Linq.Async" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" /> </ItemGroup> diff --git a/Jellyfin.Server.Implementations/MediaSegments/MediaSegmentManager.cs b/Jellyfin.Server.Implementations/MediaSegments/MediaSegmentManager.cs index d00c87463c..be98f93dab 100644 --- a/Jellyfin.Server.Implementations/MediaSegments/MediaSegmentManager.cs +++ b/Jellyfin.Server.Implementations/MediaSegments/MediaSegmentManager.cs @@ -54,7 +54,7 @@ public class MediaSegmentManager : IMediaSegmentManager public async Task RunSegmentPluginProviders(BaseItem baseItem, LibraryOptions libraryOptions, bool forceOverwrite, CancellationToken cancellationToken) { var providers = _segmentProviders - .Where(e => !libraryOptions.DisabledMediaSegmentProviders.Contains(GetProviderId(e.Name))) + .Where(e => !libraryOptions.DisabledMediaSegmentProviders.Contains(e.Name, StringComparer.OrdinalIgnoreCase)) .OrderBy(i => { var index = libraryOptions.MediaSegmentProviderOrder.IndexOf(i.Name); @@ -81,6 +81,8 @@ public class MediaSegmentManager : IMediaSegmentManager foreach (var provider in providers) { + cancellationToken.ThrowIfCancellationRequested(); + if (!await provider.Supports(baseItem).ConfigureAwait(false)) { _logger.LogDebug("Media Segment provider {ProviderName} does not support item with path {MediaPath}", provider.Name, baseItem.Path); @@ -146,6 +148,15 @@ public class MediaSegmentManager : IMediaSegmentManager await CreateSegmentAsync(segment, providerId).ConfigureAwait(false); } } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) when (cancellationToken.IsCancellationRequested) + { + _logger.LogDebug(ex, "Provider {ProviderName} aborted segment extraction for {MediaPath} due to shutdown", provider.Name, baseItem.Path); + break; + } catch (Exception ex) { _logger.LogError(ex, "Provider {ProviderName} failed to extract segments from {MediaPath}", provider.Name, baseItem.Path); @@ -182,6 +193,18 @@ public class MediaSegmentManager : IMediaSegmentManager /// <inheritdoc /> public async Task DeleteSegmentsAsync(Guid itemId, CancellationToken cancellationToken) { + foreach (var provider in _segmentProviders) + { + try + { + await provider.CleanupExtractedData(itemId, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Provider {ProviderName} failed to clean up extracted data for item {ItemId}", provider.Name, itemId); + } + } + var db = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); await using (db.ConfigureAwait(false)) { @@ -212,7 +235,7 @@ public class MediaSegmentManager : IMediaSegmentManager if (filterByProvider) { var providerIds = _segmentProviders - .Where(e => !libraryOptions.DisabledMediaSegmentProviders.Contains(GetProviderId(e.Name))) + .Where(e => !libraryOptions.DisabledMediaSegmentProviders.Contains(e.Name, StringComparer.OrdinalIgnoreCase)) .Select(f => GetProviderId(f.Name)) .ToArray(); if (providerIds.Length == 0) diff --git a/Jellyfin.Server.Implementations/Security/AuthorizationContext.cs b/Jellyfin.Server.Implementations/Security/AuthorizationContext.cs index e3fe517c49..8657cb7dbb 100644 --- a/Jellyfin.Server.Implementations/Security/AuthorizationContext.cs +++ b/Jellyfin.Server.Implementations/Security/AuthorizationContext.cs @@ -302,7 +302,7 @@ namespace Jellyfin.Server.Implementations.Security } else if (!escaped && token == '=') { - key = authorizationHeader[start.. i].Trim().ToString(); + key = authorizationHeader[start..i].Trim().ToString(); start = i + 1; } } diff --git a/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs b/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs index ce628a04d0..0989ce84ba 100644 --- a/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs +++ b/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs @@ -28,22 +28,44 @@ public static class StorageHelper } /// <summary> - /// Gets the free space of a specific directory. + /// Gets the free space of the parent filesystem of a specific directory. /// </summary> /// <param name="path">Path to a folder.</param> - /// <returns>The number of bytes available space.</returns> + /// <returns>Various details about the parent filesystem containing the directory.</returns> public static FolderStorageInfo GetFreeSpaceOf(string path) { try { - var driveInfo = new DriveInfo(path); + // Fully resolve the given path to an actual filesystem target, in case it's a symlink or similar. + var resolvedPath = ResolvePath(path); + // We iterate all filesystems reported by GetDrives() here, and attempt to find the best + // match that contains, as deep as possible, the given path. + // This is required because simply calling `DriveInfo` on a path returns that path as + // the Name and RootDevice, which is not at all how this should work. + var allDrives = DriveInfo.GetDrives(); + DriveInfo? bestMatch = null; + foreach (DriveInfo d in allDrives) + { + if (resolvedPath.StartsWith(d.RootDirectory.FullName, StringComparison.InvariantCultureIgnoreCase) && + (bestMatch is null || d.RootDirectory.FullName.Length > bestMatch.RootDirectory.FullName.Length)) + { + bestMatch = d; + } + } + + if (bestMatch is null) + { + throw new InvalidOperationException($"The path `{path}` has no matching parent device. Space check invalid."); + } + return new FolderStorageInfo() { Path = path, - FreeSpace = driveInfo.AvailableFreeSpace, - UsedSpace = driveInfo.TotalSize - driveInfo.AvailableFreeSpace, - StorageType = driveInfo.DriveType.ToString(), - DeviceId = driveInfo.Name, + ResolvedPath = resolvedPath, + FreeSpace = bestMatch.AvailableFreeSpace, + UsedSpace = bestMatch.TotalSize - bestMatch.AvailableFreeSpace, + StorageType = bestMatch.DriveType.ToString(), + DeviceId = bestMatch.Name, }; } catch @@ -51,6 +73,7 @@ public static class StorageHelper return new FolderStorageInfo() { Path = path, + ResolvedPath = path, FreeSpace = -1, UsedSpace = -1, StorageType = null, @@ -59,6 +82,27 @@ public static class StorageHelper } } + /// <summary> + /// Walk a path and fully resolve any symlinks within it. + /// </summary> + private static string ResolvePath(string path) + { + var root = Path.GetPathRoot(path) ?? Path.DirectorySeparatorChar.ToString(); + var parts = path.Substring(root.Length).Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries); + var current = root; + foreach (var part in parts) + { + current = Path.Combine(current, part); + var resolved = new DirectoryInfo(current).ResolveLinkTarget(returnFinalTarget: true); + if (resolved is not null) + { + current = resolved.FullName; + } + } + + return current; + } + /// <summary> /// Gets the underlying drive data from a given path and checks if the available storage capacity matches the threshold. /// </summary> diff --git a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs index 4505a377ce..58b9f7f822 100644 --- a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs +++ b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs @@ -4,6 +4,7 @@ using System.Globalization; using System.IO; using System.Linq; using System.Text; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using AsyncKeyedLock; @@ -28,7 +29,7 @@ namespace Jellyfin.Server.Implementations.Trickplay; /// <summary> /// ITrickplayManager implementation. /// </summary> -public class TrickplayManager : ITrickplayManager +public partial class TrickplayManager : ITrickplayManager { private readonly ILogger<TrickplayManager> _logger; private readonly IMediaEncoder _mediaEncoder; @@ -135,6 +136,147 @@ public class TrickplayManager : ITrickplayManager } } + private async Task DiscoverExistingTrickplayAsync(Video video, bool saveWithMedia, CancellationToken cancellationToken) + { + var options = _config.Configuration.TrickplayOptions; + var existing = await GetTrickplayResolutions(video.Id).ConfigureAwait(false); + + // Remove DB rows whose on-disk folder no longer exists in either possible location. + // Checking both locations avoids dropping rows mid-`SaveTrickplayWithMedia` migration. + var orphanedWidths = new List<int>(); + foreach (var (width, info) in existing) + { + cancellationToken.ThrowIfCancellationRequested(); + var localDir = GetTrickplayDirectory(video, info.TileWidth, info.TileHeight, info.Width, false); + var mediaDir = GetTrickplayDirectory(video, info.TileWidth, info.TileHeight, info.Width, true); + if (!HasTrickplayTiles(localDir) && !HasTrickplayTiles(mediaDir)) + { + orphanedWidths.Add(width); + } + } + + if (orphanedWidths.Count > 0) + { + var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + await dbContext.TrickplayInfos + .Where(i => i.ItemId.Equals(video.Id) && orphanedWidths.Contains(i.Width)) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + } + + foreach (var width in orphanedWidths) + { + _logger.LogInformation("Removed orphaned trickplay DB entry width={Width} for {Path}", width, video.Path); + existing.Remove(width); + } + } + + var trickplayDirectory = _pathManager.GetTrickplayDirectory(video, saveWithMedia); + if (!Directory.Exists(trickplayDirectory)) + { + return; + } + + foreach (var subdir in new DirectoryInfo(trickplayDirectory).EnumerateDirectories()) + { + cancellationToken.ThrowIfCancellationRequested(); + + var match = TrickplaySubdirRegex().Match(subdir.Name); + if (!match.Success) + { + continue; + } + + var width = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture); + var tileWidth = int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture); + var tileHeight = int.Parse(match.Groups[3].Value, CultureInfo.InvariantCulture); + + if (existing.ContainsKey(width)) + { + continue; + } + + var tiles = subdir.GetFiles("*.jpg") + .OrderBy(t => t.Name, StringComparer.Ordinal) + .ToArray(); + if (tiles.Length == 0) + { + continue; + } + + // The encoder pads the last tile to a full TileWidth*TileHeight grid, so the real + // thumbnail count cannot be read from tile dimensions. Instead, bound the count from + // the tile count and per-tile capacity, then pick an interval consistent with the + // video runtime - snapping to the server's configured interval when it fits. + var thumbsPerTile = tileWidth * tileHeight; + var maxThumbs = tiles.Length * thumbsPerTile; + var minThumbs = tiles.Length > 1 ? ((tiles.Length - 1) * thumbsPerTile) + 1 : 1; + + int interval; + int thumbnailCount; + if (video.RunTimeTicks is long ticks) + { + var runtimeMs = ticks / TimeSpan.TicksPerMillisecond; + var minInterval = Math.Max(1000L, (long)Math.Ceiling(runtimeMs / (double)maxThumbs)); + var maxInterval = Math.Max(minInterval, (long)Math.Floor(runtimeMs / (double)minThumbs)); + + if (options.Interval >= minInterval && options.Interval <= maxInterval) + { + interval = options.Interval; + } + else + { + var midpoint = (minInterval + maxInterval) / 2.0; + var snapped = (long)Math.Round(midpoint / 1000d) * 1000L; + interval = (int)Math.Clamp(snapped, minInterval, maxInterval); + } + + thumbnailCount = Math.Clamp( + (int)Math.Round(runtimeMs / (double)interval), + minThumbs, + maxThumbs); + } + else + { + interval = Math.Max(1000, options.Interval); + thumbnailCount = maxThumbs; + } + + var firstSize = _imageEncoder.GetImageSize(tiles[0].FullName); + var thumbPxH = Math.Max(1, (int)Math.Ceiling((double)firstSize.Height / tileHeight)); + + var info = new TrickplayInfo + { + ItemId = video.Id, + Width = width, + Interval = interval, + TileWidth = tileWidth, + TileHeight = tileHeight, + ThumbnailCount = thumbnailCount, + Height = thumbPxH, + Bandwidth = 0, + }; + + foreach (var tile in tiles) + { + var bitrate = (int)Math.Ceiling((decimal)tile.Length * 8 / tileWidth / tileHeight / (interval / 1000m)); + info.Bandwidth = Math.Max(info.Bandwidth, bitrate); + } + + await SaveTrickplayInfo(info).ConfigureAwait(false); + _logger.LogInformation( + "Discovered existing trickplay {Width} - {TileWidth}x{TileHeight} ({ThumbnailCount} thumbnails, {Interval}ms interval) for {Path}", + width, + tileWidth, + tileHeight, + thumbnailCount, + interval, + video.Path); + } + } + /// <inheritdoc /> public async Task RefreshTrickplayDataAsync(Video video, bool replace, LibraryOptions libraryOptions, CancellationToken cancellationToken) { @@ -144,11 +286,27 @@ public class TrickplayManager : ITrickplayManager return; } + var saveWithMedia = libraryOptions.SaveTrickplayWithMedia; + + // Catalog any existing trickplay folders on disk before any prune/generate. This picks up + // user-placed files even when their (width, tile dims) don't match the server's configured values. + if (!replace) + { + await DiscoverExistingTrickplayAsync(video, saveWithMedia, cancellationToken).ConfigureAwait(false); + } + var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); await using (dbContext.ConfigureAwait(false)) { - var saveWithMedia = libraryOptions.SaveTrickplayWithMedia; var trickplayDirectory = _pathManager.GetTrickplayDirectory(video, saveWithMedia); + + // When extraction is disabled and files live next to media, treat them as user-managed: + // discovery above already catalogued whatever is on disk, leave it alone. + if (!libraryOptions.EnableTrickplayImageExtraction && !replace && saveWithMedia) + { + return; + } + if (!libraryOptions.EnableTrickplayImageExtraction || replace) { // Prune existing data @@ -198,13 +356,13 @@ public class TrickplayManager : ITrickplayManager // Cleanup old trickplay files if (Directory.Exists(trickplayDirectory)) { - var existingFolders = Directory.GetDirectories(trickplayDirectory).ToList(); + var existingFolders = Directory.GetDirectories(trickplayDirectory); var trickplayInfos = await dbContext.TrickplayInfos .AsNoTracking() .Where(i => i.ItemId.Equals(video.Id)) .ToListAsync(cancellationToken) .ConfigureAwait(false); - var expectedFolders = trickplayInfos.Select(i => GetTrickplayDirectory(video, i.TileWidth, i.TileHeight, i.Width, saveWithMedia)).ToList(); + var expectedFolders = trickplayInfos.Select(i => GetTrickplayDirectory(video, i.TileWidth, i.TileHeight, i.Width, saveWithMedia)); var foldersToRemove = existingFolders.Except(expectedFolders); foreach (var folder in foldersToRemove) { @@ -399,64 +557,72 @@ public class TrickplayManager : ITrickplayManager var workDir = Path.Combine(_appPaths.TempDirectory, "trickplay_" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(workDir); - var trickplayInfo = new TrickplayInfo + try { - Width = width, - Interval = options.Interval, - TileWidth = options.TileWidth, - TileHeight = options.TileHeight, - ThumbnailCount = images.Count, - // Set during image generation - Height = 0, - Bandwidth = 0 - }; - - /* - * Generate trickplay tiles from sets of thumbnails - */ - var imageOptions = new ImageCollageOptions - { - Width = trickplayInfo.TileWidth, - Height = trickplayInfo.TileHeight - }; - - var thumbnailsPerTile = trickplayInfo.TileWidth * trickplayInfo.TileHeight; - var requiredTiles = (int)Math.Ceiling((double)images.Count / thumbnailsPerTile); - - for (int i = 0; i < requiredTiles; i++) - { - // Set output/input paths - var tilePath = Path.Combine(workDir, $"{i}.jpg"); - - imageOptions.OutputPath = tilePath; - imageOptions.InputPaths = images.Skip(i * thumbnailsPerTile).Take(Math.Min(thumbnailsPerTile, images.Count - (i * thumbnailsPerTile))).ToList(); - - // Generate image and use returned height for tiles info - var height = _imageEncoder.CreateTrickplayTile(imageOptions, options.JpegQuality, trickplayInfo.Width, trickplayInfo.Height != 0 ? trickplayInfo.Height : null); - if (trickplayInfo.Height == 0) + var trickplayInfo = new TrickplayInfo { - trickplayInfo.Height = height; + Width = width, + Interval = options.Interval, + TileWidth = options.TileWidth, + TileHeight = options.TileHeight, + ThumbnailCount = images.Count, + // Set during image generation + Height = 0, + Bandwidth = 0 + }; + + /* + * Generate trickplay tiles from sets of thumbnails + */ + var imageOptions = new ImageCollageOptions + { + Width = trickplayInfo.TileWidth, + Height = trickplayInfo.TileHeight + }; + + var thumbnailsPerTile = trickplayInfo.TileWidth * trickplayInfo.TileHeight; + var requiredTiles = (int)Math.Ceiling((double)images.Count / thumbnailsPerTile); + + for (int i = 0; i < requiredTiles; i++) + { + // Set output/input paths + var tilePath = Path.Combine(workDir, $"{i}.jpg"); + + imageOptions.OutputPath = tilePath; + imageOptions.InputPaths = images.Skip(i * thumbnailsPerTile).Take(Math.Min(thumbnailsPerTile, images.Count - (i * thumbnailsPerTile))).ToList(); + + // Generate image and use returned height for tiles info + var height = _imageEncoder.CreateTrickplayTile(imageOptions, options.JpegQuality, trickplayInfo.Width, trickplayInfo.Height != 0 ? trickplayInfo.Height : null); + if (trickplayInfo.Height == 0) + { + trickplayInfo.Height = height; + } + + // Update bitrate + var bitrate = (int)Math.Ceiling(new FileInfo(tilePath).Length * 8m / trickplayInfo.TileWidth / trickplayInfo.TileHeight / (trickplayInfo.Interval / 1000m)); + trickplayInfo.Bandwidth = Math.Max(trickplayInfo.Bandwidth, bitrate); } - // Update bitrate - var bitrate = (int)Math.Ceiling(new FileInfo(tilePath).Length * 8m / trickplayInfo.TileWidth / trickplayInfo.TileHeight / (trickplayInfo.Interval / 1000m)); - trickplayInfo.Bandwidth = Math.Max(trickplayInfo.Bandwidth, bitrate); + /* + * Move trickplay tiles to output directory + */ + Directory.CreateDirectory(Directory.GetParent(outputDir)!.FullName); + + // Replace existing tiles if they already exist + if (Directory.Exists(outputDir)) + { + Directory.Delete(outputDir, true); + } + + _fileSystem.MoveDirectory(workDir, outputDir); + + return trickplayInfo; } - - /* - * Move trickplay tiles to output directory - */ - Directory.CreateDirectory(Directory.GetParent(outputDir)!.FullName); - - // Replace existing tiles if they already exist - if (Directory.Exists(outputDir)) + catch { - Directory.Delete(outputDir, true); + Directory.Delete(workDir, true); + throw; } - - _fileSystem.MoveDirectory(workDir, outputDir); - - return trickplayInfo; } private bool CanGenerateTrickplay(Video video, int interval) @@ -680,6 +846,19 @@ public class TrickplayManager : ITrickplayManager return Path.Combine(path, subdirectory); } + [GeneratedRegex(@"^(\d+) - (\d+)x(\d+)$")] + private static partial Regex TrickplaySubdirRegex(); + + private static bool HasTrickplayTiles(string directory) + { + if (!Directory.Exists(directory)) + { + return false; + } + + return new DirectoryInfo(directory).EnumerateFiles("*.jpg").Any(); + } + private async Task<bool> HasTrickplayResolutionAsync(Guid itemId, int width) { var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); diff --git a/Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs b/Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs index 35c43b176d..446849b6f3 100644 --- a/Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs +++ b/Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs @@ -59,7 +59,7 @@ namespace Jellyfin.Server.Implementations.Users } // As long as jellyfin supports password-less users, we need this little block here to accommodate - if (!HasPassword(resolvedUser) && string.IsNullOrEmpty(password)) + if (string.IsNullOrEmpty(resolvedUser.Password) && string.IsNullOrEmpty(password)) { return Task.FromResult(new ProviderAuthenticationResult { @@ -93,10 +93,6 @@ namespace Jellyfin.Server.Implementations.Users }); } - /// <inheritdoc /> - public bool HasPassword(User user) - => !string.IsNullOrEmpty(user?.Password); - /// <inheritdoc /> public Task ChangePassword(User user, string newPassword) { diff --git a/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs b/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs index 49a9fda943..7371545914 100644 --- a/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs +++ b/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs @@ -74,7 +74,7 @@ namespace Jellyfin.Server.Implementations.Users var resetUser = userManager.GetUserByName(spr.UserName) ?? throw new ResourceNotFoundException($"User with a username of {spr.UserName} not found"); - await userManager.ChangePassword(resetUser, pin).ConfigureAwait(false); + await userManager.ChangePassword(resetUser.Id, pin).ConfigureAwait(false); usersReset.Add(resetUser.Username); File.Delete(resetFile); } diff --git a/Jellyfin.Server.Implementations/Users/InvalidAuthProvider.cs b/Jellyfin.Server.Implementations/Users/InvalidAuthProvider.cs index caf9d5bd9a..56b8a7fc4c 100644 --- a/Jellyfin.Server.Implementations/Users/InvalidAuthProvider.cs +++ b/Jellyfin.Server.Implementations/Users/InvalidAuthProvider.cs @@ -21,12 +21,6 @@ namespace Jellyfin.Server.Implementations.Users throw new AuthenticationException("User Account cannot login with this provider. The Normal provider for this user cannot be found"); } - /// <inheritdoc /> - public bool HasPassword(User user) - { - return true; - } - /// <inheritdoc /> public Task ChangePassword(User user, string newPassword) { diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs index 2add65d321..fea6084267 100644 --- a/Jellyfin.Server.Implementations/Users/UserManager.cs +++ b/Jellyfin.Server.Implementations/Users/UserManager.cs @@ -1,12 +1,13 @@ -#pragma warning disable CA1307 +#pragma warning disable RS0030 // Do not use banned APIs using System; -using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Text.RegularExpressions; +using System.Threading; using System.Threading.Tasks; +using AsyncKeyedLock; using Jellyfin.Data; using Jellyfin.Data.Enums; using Jellyfin.Data.Events; @@ -35,7 +36,7 @@ namespace Jellyfin.Server.Implementations.Users /// <summary> /// Manages the creation and retrieval of <see cref="User"/> instances. /// </summary> - public partial class UserManager : IUserManager + public partial class UserManager : IUserManager, IDisposable { private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; private readonly IEventManager _eventManager; @@ -50,7 +51,7 @@ namespace Jellyfin.Server.Implementations.Users private readonly DefaultPasswordResetProvider _defaultPasswordResetProvider; private readonly IServerConfigurationManager _serverConfigurationManager; - private readonly IDictionary<Guid, User> _users; + private readonly LockHelper _userLock = new(); /// <summary> /// Initializes a new instance of the <see cref="UserManager"/> class. @@ -89,29 +90,28 @@ namespace Jellyfin.Server.Implementations.Users _invalidAuthProvider = _authenticationProviders.OfType<InvalidAuthProvider>().First(); _defaultAuthenticationProvider = _authenticationProviders.OfType<DefaultAuthenticationProvider>().First(); _defaultPasswordResetProvider = _passwordResetProviders.OfType<DefaultPasswordResetProvider>().First(); - - _users = new ConcurrentDictionary<Guid, User>(); - using var dbContext = _dbProvider.CreateDbContext(); - foreach (var user in dbContext.Users - .AsSplitQuery() - .Include(user => user.Permissions) - .Include(user => user.Preferences) - .Include(user => user.AccessSchedules) - .Include(user => user.ProfileImage) - .AsEnumerable()) - { - _users.Add(user.Id, user); - } } /// <inheritdoc/> public event EventHandler<GenericEventArgs<User>>? OnUserUpdated; /// <inheritdoc/> - public IEnumerable<User> Users => _users.Values; + public IEnumerable<User> GetUsers() + { + using var dbContext = _dbProvider.CreateDbContext(); + return UserQuery(dbContext) + .ToArray(); + } /// <inheritdoc/> - public IEnumerable<Guid> UsersIds => _users.Keys; + public IEnumerable<Guid> GetUsersIds() + { + using var dbContext = _dbProvider.CreateDbContext(); + return dbContext.Users + .AsNoTracking() + .Select(user => user.Id) + .ToArray(); + } // 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 @@ -127,8 +127,27 @@ namespace Jellyfin.Server.Implementations.Users throw new ArgumentException("Guid can't be empty", nameof(id)); } - _users.TryGetValue(id, out var user); - return user; + using var dbContext = _dbProvider.CreateDbContext(); + return UserQuery(dbContext) + .FirstOrDefault(user => user.Id == id); + } + + private static IQueryable<User> UserQuery(JellyfinDbContext dbContext) + { + return dbContext.Users + .AsSingleQuery() + .Include(user => user.Permissions) + .Include(user => user.Preferences) + .Include(user => user.AccessSchedules) + .Include(user => user.ProfileImage) + .AsNoTracking(); + } + + /// <inheritdoc/> + public User? GetFirstUser() + { + using var dbContext = _dbProvider.CreateDbContext(); + return UserQuery(dbContext).FirstOrDefault(); } /// <inheritdoc/> @@ -139,42 +158,50 @@ namespace Jellyfin.Server.Implementations.Users throw new ArgumentException("Invalid username", nameof(name)); } - return _users.Values.FirstOrDefault(u => string.Equals(u.Username, name, StringComparison.OrdinalIgnoreCase)); + using var dbContext = _dbProvider.CreateDbContext(); +#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons + return UserQuery(dbContext) + .FirstOrDefault(u => u.NormalizedUsername == name.ToUpperInvariant()); +#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons } /// <inheritdoc/> - public async Task RenameUser(User user, string newName) + public async Task RenameUser(Guid userId, string oldName, string newName) { - ArgumentNullException.ThrowIfNull(user); - ThrowIfInvalidUsername(newName); - if (user.Username.Equals(newName, StringComparison.Ordinal)) + if (oldName.Equals(newName, StringComparison.Ordinal)) { throw new ArgumentException("The new and old names must be different."); } - var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); - await using (dbContext.ConfigureAwait(false)) + User user = null!; // user is never actually null where its used afterwards so we can just ignore. + using (await _userLock.LockAsync(userId).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.ToUpper() == newName.ToUpper() && !u.Id.Equals(user.Id)) - .ConfigureAwait(false)) + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) { - throw new ArgumentException(string.Format( - CultureInfo.InvariantCulture, - "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 disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons + if (await dbContext.Users + .AnyAsync(u => u.NormalizedUsername == newName.ToUpperInvariant() && u.Id != userId) + .ConfigureAwait(false)) + { + throw new ArgumentException(string.Format( + CultureInfo.InvariantCulture, + "A user with the name '{0}' already exists.", + newName)); + } #pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons - user.Username = newName; - await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false); + user = await UserQuery(dbContext) + .AsTracking() + .FirstOrDefaultAsync(u => u.Id == userId) + .ConfigureAwait(false) + ?? throw new ResourceNotFoundException(nameof(userId)); + user.Username = newName; + user.NormalizedUsername = newName.ToUpperInvariant(); + await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false); + } } var eventArgs = new UserUpdatedEventArgs(user); @@ -185,10 +212,105 @@ namespace Jellyfin.Server.Implementations.Users /// <inheritdoc/> public async Task UpdateUserAsync(User user) { - var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); - await using (dbContext.ConfigureAwait(false)) + using (await _userLock.LockAsync(user.Id).ConfigureAwait(false)) { - await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false); + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + // TODO: this is a bit of a hack. Because the user entity can be created in another context, it is maybe tracked elsewhere and navigation properties do not easily move between context. Solution is to use proper DTOs instead. + var dbUser = await UserQuery(dbContext) + .AsTracking() + .FirstOrDefaultAsync(u => u.Id == user.Id) + .ConfigureAwait(false) + ?? throw new ResourceNotFoundException(nameof(user.Id)); + + dbContext.Entry(dbUser).CurrentValues.SetValues(user); + SyncPermissions(dbUser, user.Permissions); + SyncPreferences(dbUser, user.Preferences); + + dbUser.AccessSchedules.Clear(); + foreach (var accessSchedule in user.AccessSchedules) + { + dbUser.AccessSchedules.Add(new AccessSchedule(accessSchedule.DayOfWeek, accessSchedule.StartHour, accessSchedule.EndHour, dbUser.Id)); + } + + if (user.ProfileImage is null) + { + if (dbUser.ProfileImage is not null) + { + dbContext.Remove(dbUser.ProfileImage); + dbUser.ProfileImage = null; + } + } + else if (dbUser.ProfileImage is null) + { + dbUser.ProfileImage = new Jellyfin.Database.Implementations.Entities.ImageInfo(user.ProfileImage.Path) + { + LastModified = user.ProfileImage.LastModified + }; + } + else + { + dbUser.ProfileImage.Path = user.ProfileImage.Path; + dbUser.ProfileImage.LastModified = user.ProfileImage.LastModified; + } + + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } + } + } + + private static void SyncPermissions(User dbUser, ICollection<Permission> source) + { + var incoming = new Dictionary<PermissionKind, bool>(); + foreach (var permission in source) + { + incoming[permission.Kind] = permission.Value; + } + + foreach (var existing in dbUser.Permissions) + { + if (incoming.Remove(existing.Kind, out var value)) + { + // EF only marks the row modified if the value actually differs, so an update that + // touches nothing but the user row - a session activity stamp - writes no children. + existing.Value = value; + } + else + { + dbUser.Permissions.Remove(existing); + } + } + + foreach (var (kind, value) in incoming) + { + dbUser.Permissions.Add(new Permission(kind, value)); + } + } + + private static void SyncPreferences(User dbUser, ICollection<Preference> source) + { + var incoming = new Dictionary<PreferenceKind, string>(); + foreach (var preference in source) + { + incoming[preference.Kind] = preference.Value; + } + + foreach (var existing in dbUser.Preferences) + { + if (incoming.Remove(existing.Kind, out var value)) + { + existing.Value = value; + } + else + { + dbUser.Preferences.Remove(existing); + } + } + + foreach (var (kind, value) in incoming) + { + dbUser.Preferences.Add(new Preference(kind, value)); } } @@ -218,23 +340,26 @@ namespace Jellyfin.Server.Implementations.Users { ThrowIfInvalidUsername(name); - if (Users.Any(u => u.Username.Equals(name, StringComparison.OrdinalIgnoreCase))) - { - throw new ArgumentException(string.Format( - CultureInfo.InvariantCulture, - "A user with the name '{0}' already exists.", - name)); - } - User newUser; 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 + if (await dbContext.Users + .AnyAsync(u => u.NormalizedUsername == name.ToUpperInvariant()) + .ConfigureAwait(false)) + { + throw new ArgumentException(string.Format( + CultureInfo.InvariantCulture, + "A user with the name '{0}' already exists.", + name)); + } +#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons + newUser = await CreateUserInternalAsync(name, dbContext).ConfigureAwait(false); dbContext.Users.Add(newUser); await dbContext.SaveChangesAsync().ConfigureAwait(false); - _users.Add(newUser.Id, newUser); } await _eventManager.PublishAsync(new UserCreatedEventArgs(newUser)).ConfigureAwait(false); @@ -245,76 +370,93 @@ namespace Jellyfin.Server.Implementations.Users /// <inheritdoc/> public async Task DeleteUserAsync(Guid userId) { - if (!_users.TryGetValue(userId, out var user)) + User? user; + using (await _userLock.LockAsync(userId).ConfigureAwait(false)) { - throw new ResourceNotFoundException(nameof(userId)); - } + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + user = await dbContext.Users + .Include(u => u.Permissions) + .FirstOrDefaultAsync(u => u.Id.Equals(userId)) + .ConfigureAwait(false); + if (user is null) + { + throw new ResourceNotFoundException(nameof(userId)); + } - if (_users.Count == 1) - { - throw new InvalidOperationException(string.Format( - CultureInfo.InvariantCulture, - "The user '{0}' cannot be deleted because there must be at least one user in the system.", - user.Username)); - } + var userCount = await dbContext.Users.CountAsync().ConfigureAwait(false); + if (userCount == 1) + { + throw new InvalidOperationException(string.Format( + CultureInfo.InvariantCulture, + "The user '{0}' cannot be deleted because there must be at least one user in the system.", + user.Username)); + } - if (user.HasPermission(PermissionKind.IsAdministrator) - && Users.Count(i => i.HasPermission(PermissionKind.IsAdministrator)) == 1) - { - throw new ArgumentException( - string.Format( - CultureInfo.InvariantCulture, - "The user '{0}' cannot be deleted because there must be at least one admin user in the system.", - user.Username), - nameof(userId)); - } + if (user.HasPermission(PermissionKind.IsAdministrator) + && await dbContext.Users + .CountAsync(i => i.Permissions.Any(p => p.Kind == PermissionKind.IsAdministrator && p.Value)) + .ConfigureAwait(false) == 1) + { + throw new ArgumentException( + string.Format( + CultureInfo.InvariantCulture, + "The user '{0}' cannot be deleted because there must be at least one admin user in the system.", + user.Username), + nameof(userId)); + } - var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); - await using (dbContext.ConfigureAwait(false)) - { - dbContext.Users.Attach(user); - dbContext.Users.Remove(user); - await dbContext.SaveChangesAsync().ConfigureAwait(false); + dbContext.Users.Remove(user); + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } } - _users.Remove(userId); - await _eventManager.PublishAsync(new UserDeletedEventArgs(user)).ConfigureAwait(false); } /// <inheritdoc/> - public Task ResetPassword(User user) + public Task ResetPassword(Guid userId) { - return ChangePassword(user, string.Empty); + return ChangePassword(userId, string.Empty); } /// <inheritdoc/> - public async Task ChangePassword(User user, string newPassword) + public async Task ChangePassword(Guid userId, string newPassword) { - ArgumentNullException.ThrowIfNull(user); - if (user.HasPermission(PermissionKind.IsAdministrator) && string.IsNullOrWhiteSpace(newPassword)) + User dbUser = null!; + using (await _userLock.LockAsync(userId).ConfigureAwait(false)) { - throw new ArgumentException("Admin user passwords must not be empty", nameof(newPassword)); + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + dbUser = await UserQuery(dbContext) + .AsTracking() + .FirstOrDefaultAsync(u => u.Id == userId) + .ConfigureAwait(false) + ?? throw new ResourceNotFoundException(nameof(userId)); + if (dbUser.HasPermission(PermissionKind.IsAdministrator) && string.IsNullOrWhiteSpace(newPassword)) + { + throw new ArgumentException("Admin user passwords must not be empty", nameof(newPassword)); + } + + await GetAuthenticationProvider(dbUser).ChangePassword(dbUser, newPassword).ConfigureAwait(false); + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } } - await GetAuthenticationProvider(user).ChangePassword(user, newPassword).ConfigureAwait(false); - await UpdateUserAsync(user).ConfigureAwait(false); - - await _eventManager.PublishAsync(new UserPasswordChangedEventArgs(user)).ConfigureAwait(false); + await _eventManager.PublishAsync(new UserPasswordChangedEventArgs(dbUser)).ConfigureAwait(false); } /// <inheritdoc/> public UserDto GetUserDto(User user, string? remoteEndPoint = null) { - var hasPassword = GetAuthenticationProvider(user).HasPassword(user); var castReceiverApplications = _serverConfigurationManager.Configuration.CastReceiverApplications; return new UserDto { Name = user.Username, Id = user.Id, ServerId = _appHost.SystemId, - HasPassword = hasPassword, - HasConfiguredPassword = hasPassword, EnableAutoLogin = user.EnableAutoLogin, LastLoginDate = user.LastLoginDate, LastActivityDate = user.LastActivityDate, @@ -403,102 +545,163 @@ namespace Jellyfin.Server.Implementations.Users throw new ArgumentNullException(nameof(username)); } - var user = Users.FirstOrDefault(i => string.Equals(username, i.Username, StringComparison.OrdinalIgnoreCase)); - var authResult = await AuthenticateLocalUser(username, password, user) - .ConfigureAwait(false); - var authenticationProvider = authResult.AuthenticationProvider; - var success = authResult.Success; - - if (user is null) + bool success; + var user = GetUserByName(username); + using (await _userLock.LockAsync(user?.Id ?? Guid.Empty).ConfigureAwait(false)) { - string updatedUsername = authResult.Username; + using var dbContext = _dbProvider.CreateDbContext(); - if (success - && authenticationProvider is not null - && authenticationProvider is not DefaultAuthenticationProvider) + // Reload the user now that we hold the lock so the RowVersion is current. + // GetUserByName uses AsNoTracking and the snapshot may be stale if another + // write (e.g. a concurrent login) incremented RowVersion after our initial load. + if (user is not null) { - // Trust the username returned by the authentication provider - username = updatedUsername; + user = await UserQuery(dbContext).FirstOrDefaultAsync(e => e.Id == user.Id).ConfigureAwait(false) ?? user; + } - // Search the database for the user again - // the authentication provider might have created it - user = Users.FirstOrDefault(i => string.Equals(username, i.Username, StringComparison.OrdinalIgnoreCase)); + var authResult = await AuthenticateLocalUser(username, password, user) + .ConfigureAwait(false); + var authenticationProvider = authResult.AuthenticationProvider; + success = authResult.Success; - if (authenticationProvider is IHasNewUserPolicy hasNewUserPolicy && user is not null) + if (success && user is not null) + { + // refresh the user if the auth provider might have updated it in the auth method. + // this is a hack, this needs removal once the LDAP plugin uses the correct interface to get the user we hand in here and update that one instead. + user = await UserQuery(dbContext).FirstOrDefaultAsync(e => e.Id == user.Id).ConfigureAwait(false); + } + + if (user is null) + { + string updatedUsername = authResult.Username; + + if (success + && authenticationProvider is not null + && authenticationProvider is not DefaultAuthenticationProvider) { - await UpdatePolicyAsync(user.Id, hasNewUserPolicy.GetNewUserPolicy()).ConfigureAwait(false); + // Trust the username returned by the authentication provider + username = updatedUsername; + + // Search the database for the user again + // the authentication provider might have created it +#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons + user = await UserQuery(dbContext) + .FirstOrDefaultAsync(e => e.NormalizedUsername == username.ToUpperInvariant()).ConfigureAwait(false); + + if (authenticationProvider is IHasNewUserPolicy hasNewUserPolicy && user is not null) + { + await UpdatePolicyAsync(user.Id, hasNewUserPolicy.GetNewUserPolicy()).ConfigureAwait(false); + user = await UserQuery(dbContext) + .FirstOrDefaultAsync(e => e.NormalizedUsername == username.ToUpperInvariant()).ConfigureAwait(false); +#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons + } } } - } - if (success && user is not null && authenticationProvider is not null) - { - var providerId = authenticationProvider.GetType().FullName; - - if (providerId is not null && !string.Equals(providerId, user.AuthenticationProviderId, StringComparison.OrdinalIgnoreCase)) + if (success && user is not null && authenticationProvider is not null) { - user.AuthenticationProviderId = providerId; - await UpdateUserAsync(user).ConfigureAwait(false); - } - } + var providerId = authenticationProvider.GetType().FullName; - if (user is null) - { - _logger.LogInformation( - "Authentication request for {UserName} has been denied (IP: {IP}).", - username, - remoteEndPoint); - throw new AuthenticationException("Invalid username or password entered."); - } - - if (user.HasPermission(PermissionKind.IsDisabled)) - { - _logger.LogInformation( - "Authentication request for {UserName} has been denied because this account is currently disabled (IP: {IP}).", - username, - remoteEndPoint); - throw new SecurityException( - $"The {user.Username} account is currently disabled. Please consult with your administrator."); - } - - if (!user.HasPermission(PermissionKind.EnableRemoteAccess) && - !_networkManager.IsInLocalNetwork(remoteEndPoint)) - { - _logger.LogInformation( - "Authentication request for {UserName} forbidden: remote access disabled and user not in local network (IP: {IP}).", - username, - remoteEndPoint); - throw new SecurityException("Forbidden."); - } - - if (!user.IsParentalScheduleAllowed()) - { - _logger.LogInformation( - "Authentication request for {UserName} is not allowed at this time due parental restrictions (IP: {IP}).", - username, - remoteEndPoint); - throw new SecurityException("User is not allowed access at this time."); - } - - // Update LastActivityDate and LastLoginDate, then save - if (success) - { - if (isUserSession) - { - user.LastActivityDate = user.LastLoginDate = DateTime.UtcNow; + if (providerId is not null && !string.Equals(providerId, user.AuthenticationProviderId, StringComparison.OrdinalIgnoreCase)) + { + await dbContext.Users + .Where(e => e.Id == user.Id) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.AuthenticationProviderId, providerId)) + .ConfigureAwait(false); + } } - user.InvalidLoginAttemptCount = 0; - await UpdateUserAsync(user).ConfigureAwait(false); - _logger.LogInformation("Authentication request for {UserName} has succeeded.", user.Username); - } - else - { - await IncrementInvalidLoginAttemptCount(user).ConfigureAwait(false); - _logger.LogInformation( - "Authentication request for {UserName} has been denied (IP: {IP}).", - user.Username, - remoteEndPoint); + if (user is null) + { + _logger.LogInformation( + "Authentication request for {UserName} has been denied (IP: {IP}).", + username, + remoteEndPoint); + throw new AuthenticationException("Invalid username or password entered."); + } + + if (user.HasPermission(PermissionKind.IsDisabled)) + { + _logger.LogInformation( + "Authentication request for {UserName} has been denied because this account is currently disabled (IP: {IP}).", + username, + remoteEndPoint); + throw new SecurityException( + $"The {user.Username} account is currently disabled. Please consult with your administrator."); + } + + if (!user.HasPermission(PermissionKind.EnableRemoteAccess) && + !_networkManager.IsInLocalNetwork(remoteEndPoint)) + { + _logger.LogInformation( + "Authentication request for {UserName} forbidden: remote access disabled and user not in local network (IP: {IP}).", + username, + remoteEndPoint); + throw new SecurityException("Forbidden."); + } + + if (!user.IsParentalScheduleAllowed()) + { + _logger.LogInformation( + "Authentication request for {UserName} is not allowed at this time due parental restrictions (IP: {IP}).", + username, + remoteEndPoint); + throw new SecurityException("User is not allowed access at this time."); + } + + // Update LastActivityDate and LastLoginDate, then save + if (success) + { + if (isUserSession) + { + var date = DateTime.UtcNow; + await dbContext.Users + .Where(e => e.Id == user.Id) + .ExecuteUpdateAsync(e => e + .SetProperty(f => f.LastActivityDate, date) + .SetProperty(f => f.LastLoginDate, date)) + .ConfigureAwait(false); + + // ExecuteUpdateAsync bypasses the change tracker, so keep the + // returned entity in sync. Otherwise SessionManager.LogSessionActivity + // saves this (stale) entity in full and reverts LastLoginDate. + user.LastActivityDate = date; + user.LastLoginDate = date; + } + + await dbContext.Users + .Where(e => e.Id == user.Id) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.InvalidLoginAttemptCount, 0)) + .ConfigureAwait(false); + _logger.LogInformation("Authentication request for {UserName} has succeeded.", user.Username); + } + else + { + user.InvalidLoginAttemptCount++; + int? maxInvalidLogins = user.LoginAttemptsBeforeLockout; + if (maxInvalidLogins.HasValue && user.InvalidLoginAttemptCount >= maxInvalidLogins) + { + user.SetPermission(PermissionKind.IsDisabled, true); + dbContext.Update(user); + await dbContext.SaveChangesAsync() + .ConfigureAwait(false); + await _eventManager.PublishAsync(new UserLockedOutEventArgs(user)).ConfigureAwait(false); + _logger.LogWarning( + "Disabling user {Username} due to {Attempts} unsuccessful login attempts.", + user.Username, + user.InvalidLoginAttemptCount); + } + + await dbContext.Users + .Where(e => e.Id == user.Id) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.InvalidLoginAttemptCount, f => f.InvalidLoginAttemptCount + 1)) + .ConfigureAwait(false); + + _logger.LogInformation( + "Authentication request for {UserName} has been denied (IP: {IP}).", + user.Username, + remoteEndPoint); + } } return success ? user : null; @@ -542,22 +745,22 @@ namespace Jellyfin.Server.Implementations.Users public async Task InitializeAsync() { // TODO: Refactor the startup wizard so that it doesn't require a user to already exist. - if (_users.Any()) - { - return; - } - - var defaultName = Environment.UserName; - if (string.IsNullOrWhiteSpace(defaultName) || !ValidUsernameRegex().IsMatch(defaultName)) - { - defaultName = "MyJellyfinUser"; - } - - _logger.LogWarning("No users, creating one with username {UserName}", defaultName); - var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); await using (dbContext.ConfigureAwait(false)) { + if (await dbContext.Users.AnyAsync().ConfigureAwait(false)) + { + return; + } + + var defaultName = Environment.UserName; + if (string.IsNullOrWhiteSpace(defaultName) || !ValidUsernameRegex().IsMatch(defaultName)) + { + defaultName = "MyJellyfinUser"; + } + + _logger.LogWarning("No users, creating one with username {UserName}", defaultName); + var newUser = await CreateUserInternalAsync(defaultName, dbContext).ConfigureAwait(false); newUser.SetPermission(PermissionKind.IsAdministrator, true); newUser.SetPermission(PermissionKind.EnableContentDeletion, true); @@ -565,7 +768,6 @@ namespace Jellyfin.Server.Implementations.Users dbContext.Users.Add(newUser); await dbContext.SaveChangesAsync().ConfigureAwait(false); - _users.Add(newUser.Id, newUser); } } @@ -602,122 +804,120 @@ namespace Jellyfin.Server.Implementations.Users /// <inheritdoc/> public async Task UpdateConfigurationAsync(Guid userId, UserConfiguration config) { - var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); - await using (dbContext.ConfigureAwait(false)) + using (await _userLock.LockAsync(userId).ConfigureAwait(false)) { - var user = dbContext.Users - .Include(u => u.Permissions) - .Include(u => u.Preferences) - .Include(u => u.AccessSchedules) - .Include(u => u.ProfileImage) - .FirstOrDefault(u => u.Id.Equals(userId)) - ?? throw new ArgumentException("No user exists with given Id!"); - - user.SubtitleMode = config.SubtitleMode; - user.HidePlayedInLatest = config.HidePlayedInLatest; - user.EnableLocalPassword = config.EnableLocalPassword; - user.PlayDefaultAudioTrack = config.PlayDefaultAudioTrack; - user.DisplayCollectionsView = config.DisplayCollectionsView; - user.DisplayMissingEpisodes = config.DisplayMissingEpisodes; - user.AudioLanguagePreference = config.AudioLanguagePreference; - user.RememberAudioSelections = config.RememberAudioSelections; - user.EnableNextEpisodeAutoPlay = config.EnableNextEpisodeAutoPlay; - user.RememberSubtitleSelections = config.RememberSubtitleSelections; - user.SubtitleLanguagePreference = config.SubtitleLanguagePreference; - - // Only set cast receiver id if it is passed in and it exists in the server config. - if (!string.IsNullOrEmpty(config.CastReceiverId) - && _serverConfigurationManager.Configuration.CastReceiverApplications.Any(c => string.Equals(c.Id, config.CastReceiverId, StringComparison.Ordinal))) + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) { - user.CastReceiverId = config.CastReceiverId; + var user = UserQuery(dbContext) + .AsTracking() + .FirstOrDefault(u => u.Id.Equals(userId)) + ?? throw new ArgumentException("No user exists with given Id!"); + + user.SubtitleMode = config.SubtitleMode; + user.HidePlayedInLatest = config.HidePlayedInLatest; + user.EnableLocalPassword = config.EnableLocalPassword; + user.PlayDefaultAudioTrack = config.PlayDefaultAudioTrack; + user.DisplayCollectionsView = config.DisplayCollectionsView; + user.DisplayMissingEpisodes = config.DisplayMissingEpisodes; + user.AudioLanguagePreference = config.AudioLanguagePreference; + user.RememberAudioSelections = config.RememberAudioSelections; + user.EnableNextEpisodeAutoPlay = config.EnableNextEpisodeAutoPlay; + user.RememberSubtitleSelections = config.RememberSubtitleSelections; + user.SubtitleLanguagePreference = config.SubtitleLanguagePreference; + + // Only set cast receiver id if it is passed in and it exists in the server config. + if (!string.IsNullOrEmpty(config.CastReceiverId) + && _serverConfigurationManager.Configuration.CastReceiverApplications.Any(c => string.Equals(c.Id, config.CastReceiverId, StringComparison.Ordinal))) + { + user.CastReceiverId = config.CastReceiverId; + } + + user.SetPreference(PreferenceKind.OrderedViews, config.OrderedViews); + user.SetPreference(PreferenceKind.GroupedFolders, config.GroupedFolders); + user.SetPreference(PreferenceKind.MyMediaExcludes, config.MyMediaExcludes); + user.SetPreference(PreferenceKind.LatestItemExcludes, config.LatestItemsExcludes); + + dbContext.Update(user); + await dbContext.SaveChangesAsync().ConfigureAwait(false); } - - user.SetPreference(PreferenceKind.OrderedViews, config.OrderedViews); - user.SetPreference(PreferenceKind.GroupedFolders, config.GroupedFolders); - user.SetPreference(PreferenceKind.MyMediaExcludes, config.MyMediaExcludes); - user.SetPreference(PreferenceKind.LatestItemExcludes, config.LatestItemsExcludes); - - dbContext.Update(user); - _users[user.Id] = user; - await dbContext.SaveChangesAsync().ConfigureAwait(false); } } /// <inheritdoc/> public async Task UpdatePolicyAsync(Guid userId, UserPolicy policy) { - var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); - await using (dbContext.ConfigureAwait(false)) + using (await _userLock.LockAsync(userId).ConfigureAwait(false)) { - var user = dbContext.Users - .Include(u => u.Permissions) - .Include(u => u.Preferences) - .Include(u => u.AccessSchedules) - .Include(u => u.ProfileImage) - .FirstOrDefault(u => u.Id.Equals(userId)) - ?? throw new ArgumentException("No user exists with given Id!"); - - // The default number of login attempts is 3, but for some god forsaken reason it's sent to the server as "0" - int? maxLoginAttempts = policy.LoginAttemptsBeforeLockout switch + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) { - -1 => null, - 0 => 3, - _ => policy.LoginAttemptsBeforeLockout - }; + var user = UserQuery(dbContext) + .AsTracking() + .FirstOrDefault(u => u.Id.Equals(userId)) + ?? throw new ArgumentException("No user exists with given Id!"); - user.MaxParentalRatingScore = policy.MaxParentalRating; - user.MaxParentalRatingSubScore = policy.MaxParentalSubRating; - user.EnableUserPreferenceAccess = policy.EnableUserPreferenceAccess; - user.RemoteClientBitrateLimit = policy.RemoteClientBitrateLimit; - user.AuthenticationProviderId = policy.AuthenticationProviderId; - user.PasswordResetProviderId = policy.PasswordResetProviderId; - user.InvalidLoginAttemptCount = policy.InvalidLoginAttemptCount; - user.LoginAttemptsBeforeLockout = maxLoginAttempts; - user.MaxActiveSessions = policy.MaxActiveSessions; - user.SyncPlayAccess = policy.SyncPlayAccess; - user.SetPermission(PermissionKind.IsAdministrator, policy.IsAdministrator); - user.SetPermission(PermissionKind.IsHidden, policy.IsHidden); - user.SetPermission(PermissionKind.IsDisabled, policy.IsDisabled); - user.SetPermission(PermissionKind.EnableSharedDeviceControl, policy.EnableSharedDeviceControl); - user.SetPermission(PermissionKind.EnableRemoteAccess, policy.EnableRemoteAccess); - user.SetPermission(PermissionKind.EnableLiveTvManagement, policy.EnableLiveTvManagement); - user.SetPermission(PermissionKind.EnableLiveTvAccess, policy.EnableLiveTvAccess); - user.SetPermission(PermissionKind.EnableMediaPlayback, policy.EnableMediaPlayback); - user.SetPermission(PermissionKind.EnableAudioPlaybackTranscoding, policy.EnableAudioPlaybackTranscoding); - user.SetPermission(PermissionKind.EnableVideoPlaybackTranscoding, policy.EnableVideoPlaybackTranscoding); - user.SetPermission(PermissionKind.EnableContentDeletion, policy.EnableContentDeletion); - user.SetPermission(PermissionKind.EnableContentDownloading, policy.EnableContentDownloading); - user.SetPermission(PermissionKind.EnableSyncTranscoding, policy.EnableSyncTranscoding); - user.SetPermission(PermissionKind.EnableMediaConversion, policy.EnableMediaConversion); - user.SetPermission(PermissionKind.EnableAllChannels, policy.EnableAllChannels); - user.SetPermission(PermissionKind.EnableAllDevices, policy.EnableAllDevices); - user.SetPermission(PermissionKind.EnableAllFolders, policy.EnableAllFolders); - user.SetPermission(PermissionKind.EnableRemoteControlOfOtherUsers, policy.EnableRemoteControlOfOtherUsers); - user.SetPermission(PermissionKind.EnablePlaybackRemuxing, policy.EnablePlaybackRemuxing); - user.SetPermission(PermissionKind.EnableCollectionManagement, policy.EnableCollectionManagement); - user.SetPermission(PermissionKind.EnableSubtitleManagement, policy.EnableSubtitleManagement); - user.SetPermission(PermissionKind.EnableLyricManagement, policy.EnableLyricManagement); - user.SetPermission(PermissionKind.ForceRemoteSourceTranscoding, policy.ForceRemoteSourceTranscoding); - user.SetPermission(PermissionKind.EnablePublicSharing, policy.EnablePublicSharing); + // The default number of login attempts is 3, but for some god forsaken reason it's sent to the server as "0" + int? maxLoginAttempts = policy.LoginAttemptsBeforeLockout switch + { + -1 => null, + 0 => 3, + _ => policy.LoginAttemptsBeforeLockout + }; - user.AccessSchedules.Clear(); - foreach (var policyAccessSchedule in policy.AccessSchedules) - { - user.AccessSchedules.Add(policyAccessSchedule); + user.MaxParentalRatingScore = policy.MaxParentalRating; + user.MaxParentalRatingSubScore = policy.MaxParentalSubRating; + user.EnableUserPreferenceAccess = policy.EnableUserPreferenceAccess; + user.RemoteClientBitrateLimit = policy.RemoteClientBitrateLimit; + user.AuthenticationProviderId = policy.AuthenticationProviderId; + user.PasswordResetProviderId = policy.PasswordResetProviderId; + user.InvalidLoginAttemptCount = policy.InvalidLoginAttemptCount; + user.LoginAttemptsBeforeLockout = maxLoginAttempts; + user.MaxActiveSessions = policy.MaxActiveSessions; + user.SyncPlayAccess = policy.SyncPlayAccess; + user.SetPermission(PermissionKind.IsAdministrator, policy.IsAdministrator); + user.SetPermission(PermissionKind.IsHidden, policy.IsHidden); + user.SetPermission(PermissionKind.IsDisabled, policy.IsDisabled); + user.SetPermission(PermissionKind.EnableSharedDeviceControl, policy.EnableSharedDeviceControl); + user.SetPermission(PermissionKind.EnableRemoteAccess, policy.EnableRemoteAccess); + user.SetPermission(PermissionKind.EnableLiveTvManagement, policy.EnableLiveTvManagement); + user.SetPermission(PermissionKind.EnableLiveTvAccess, policy.EnableLiveTvAccess); + user.SetPermission(PermissionKind.EnableMediaPlayback, policy.EnableMediaPlayback); + user.SetPermission(PermissionKind.EnableAudioPlaybackTranscoding, policy.EnableAudioPlaybackTranscoding); + user.SetPermission(PermissionKind.EnableVideoPlaybackTranscoding, policy.EnableVideoPlaybackTranscoding); + user.SetPermission(PermissionKind.EnableContentDeletion, policy.EnableContentDeletion); + user.SetPermission(PermissionKind.EnableContentDownloading, policy.EnableContentDownloading); + user.SetPermission(PermissionKind.EnableSyncTranscoding, policy.EnableSyncTranscoding); + user.SetPermission(PermissionKind.EnableMediaConversion, policy.EnableMediaConversion); + user.SetPermission(PermissionKind.EnableAllChannels, policy.EnableAllChannels); + user.SetPermission(PermissionKind.EnableAllDevices, policy.EnableAllDevices); + user.SetPermission(PermissionKind.EnableAllFolders, policy.EnableAllFolders); + user.SetPermission(PermissionKind.EnableRemoteControlOfOtherUsers, policy.EnableRemoteControlOfOtherUsers); + user.SetPermission(PermissionKind.EnablePlaybackRemuxing, policy.EnablePlaybackRemuxing); + user.SetPermission(PermissionKind.EnableCollectionManagement, policy.EnableCollectionManagement); + user.SetPermission(PermissionKind.EnableSubtitleManagement, policy.EnableSubtitleManagement); + user.SetPermission(PermissionKind.EnableLyricManagement, policy.EnableLyricManagement); + user.SetPermission(PermissionKind.ForceRemoteSourceTranscoding, policy.ForceRemoteSourceTranscoding); + user.SetPermission(PermissionKind.EnablePublicSharing, policy.EnablePublicSharing); + + user.AccessSchedules.Clear(); + foreach (var policyAccessSchedule in policy.AccessSchedules) + { + user.AccessSchedules.Add(policyAccessSchedule); + } + + // TODO: fix this at some point + user.SetPreference(PreferenceKind.BlockUnratedItems, policy.BlockUnratedItems ?? Array.Empty<UnratedItem>()); + user.SetPreference(PreferenceKind.BlockedTags, policy.BlockedTags); + user.SetPreference(PreferenceKind.AllowedTags, policy.AllowedTags); + user.SetPreference(PreferenceKind.EnabledChannels, policy.EnabledChannels); + user.SetPreference(PreferenceKind.EnabledDevices, policy.EnabledDevices); + user.SetPreference(PreferenceKind.EnabledFolders, policy.EnabledFolders); + user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, policy.EnableContentDeletionFromFolders); + + dbContext.Update(user); + await dbContext.SaveChangesAsync().ConfigureAwait(false); } - - // TODO: fix this at some point - user.SetPreference(PreferenceKind.BlockUnratedItems, policy.BlockUnratedItems ?? Array.Empty<UnratedItem>()); - user.SetPreference(PreferenceKind.BlockedTags, policy.BlockedTags); - user.SetPreference(PreferenceKind.AllowedTags, policy.AllowedTags); - user.SetPreference(PreferenceKind.EnabledChannels, policy.EnabledChannels); - user.SetPreference(PreferenceKind.EnabledDevices, policy.EnabledDevices); - user.SetPreference(PreferenceKind.EnabledFolders, policy.EnabledFolders); - user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, policy.EnableContentDeletionFromFolders); - - dbContext.Update(user); - _users[user.Id] = user; - await dbContext.SaveChangesAsync().ConfigureAwait(false); } } @@ -729,20 +929,34 @@ namespace Jellyfin.Server.Implementations.Users return; } - var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); - await using (dbContext.ConfigureAwait(false)) + using (await _userLock.LockAsync(user.Id).ConfigureAwait(false)) { - dbContext.Remove(user.ProfileImage); - await dbContext.SaveChangesAsync().ConfigureAwait(false); - } + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + // Remove the tracked profile image loaded from the database instead of the + // detached instance on the passed in user. That instance can carry a stale, + // never-persisted (temporary) key, which makes EF Core throw when it is marked + // for deletion, leaving the profile image impossible to clear or replace. + var dbUser = await UserQuery(dbContext) + .AsTracking() + .FirstOrDefaultAsync(u => u.Id == user.Id) + .ConfigureAwait(false); + if (dbUser?.ProfileImage is not null) + { + dbContext.Remove(dbUser.ProfileImage); + dbUser.ProfileImage = null; + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } + } - user.ProfileImage = null; - _users[user.Id] = user; + user.ProfileImage = null; + } } internal static void ThrowIfInvalidUsername(string name) { - if (!string.IsNullOrWhiteSpace(name) && ValidUsernameRegex().IsMatch(name)) + if (!string.IsNullOrWhiteSpace(name) && ValidUsernameRegex().IsMatch(name) && !string.Equals(name, ".", StringComparison.Ordinal) && !string.Equals(name, "..", StringComparison.Ordinal)) { return; } @@ -869,29 +1083,95 @@ namespace Jellyfin.Server.Implementations.Users } } - private async Task IncrementInvalidLoginAttemptCount(User user) - { - user.InvalidLoginAttemptCount++; - int? maxInvalidLogins = user.LoginAttemptsBeforeLockout; - if (maxInvalidLogins.HasValue && user.InvalidLoginAttemptCount >= maxInvalidLogins) - { - user.SetPermission(PermissionKind.IsDisabled, true); - await _eventManager.PublishAsync(new UserLockedOutEventArgs(user)).ConfigureAwait(false); - _logger.LogWarning( - "Disabling user {Username} due to {Attempts} unsuccessful login attempts.", - user.Username, - user.InvalidLoginAttemptCount); - } - - await UpdateUserAsync(user).ConfigureAwait(false); - } - private async Task UpdateUserInternalAsync(JellyfinDbContext dbContext, User user) { dbContext.Users.Attach(user); dbContext.Entry(user).State = EntityState.Modified; - _users[user.Id] = user; await dbContext.SaveChangesAsync().ConfigureAwait(false); } + + /// <inheritdoc/> + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// <summary> + /// Disposes all members of this class. + /// </summary> + /// <param name="disposing">Defines if the class has been cleaned up by a dispose or finalizer.</param> + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + _userLock.Dispose(); + } + } + + internal sealed class LockHelper : IDisposable + { + private readonly AsyncKeyedLocker<Guid> _userLock = new(); + + private bool _disposed; + + public static AsyncLocal<int> IsNestedLock { get; set; } = new(); + + public bool ShouldLock() + { + return IsNestedLock.Value == 0; + } + + public ValueTask<IDisposable> LockAsync(Guid key) + { + ThrowIfDisposed(); + var isNested = LockHelper.IsNestedLock.Value != 0; + LockHelper.IsNestedLock.Value = LockHelper.IsNestedLock.Value + 1; + if (isNested) + { + return new ValueTask<IDisposable>(new LockHandle { Parent = null }); + } + + return AcquireLockAsync(key); + } + + private async ValueTask<IDisposable> AcquireLockAsync(Guid key) + { + var lockHandle = await _userLock.LockAsync(key, true).ConfigureAwait(false); + return new LockHandle { Parent = lockHandle }; + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _userLock.Dispose(); + } + + private void ThrowIfDisposed() + { + ObjectDisposedException.ThrowIf(_disposed, this); + } + + private sealed class LockHandle : IDisposable + { + public required IDisposable? Parent { get; init; } + + public void Dispose() + { + Parent?.Dispose(); + LockHelper.IsNestedLock.Value = LockHelper.IsNestedLock.Value - 1; + + if (LockHelper.IsNestedLock.Value < 0) + { + throw new InvalidOperationException("Mismatched locking detected. Threads internal NestedLock is less then 0 which should not be possible."); + } + } + } + } } } diff --git a/Jellyfin.Server/Configuration/StartupMode.cs b/Jellyfin.Server/Configuration/StartupMode.cs new file mode 100644 index 0000000000..e1d18f1dd6 --- /dev/null +++ b/Jellyfin.Server/Configuration/StartupMode.cs @@ -0,0 +1,24 @@ +using MediaBrowser.Model.Configuration; + +namespace Jellyfin.Server.Configuration; + +/// <summary> +/// Defines types for usage with the <see cref="StartupOptions.StartupMode"/>. +/// </summary> +public enum StartupMode +{ + /// <summary> + /// Default startup mode, runs the jellyfin server in normal operation. + /// </summary> + MediaServer = 0, + + /// <summary> + /// Attempts to Migrate the system only then shuts down. + /// </summary> + MigrateSystem = 1, + + /// <summary> + /// Runs the Database seed function regardless of <see cref="BaseApplicationConfiguration.IsStartupWizardCompleted"/> state. + /// </summary> + SeedSystem = 2 +} diff --git a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs index a56baba33b..6db0edb237 100644 --- a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs @@ -3,7 +3,7 @@ using Jellyfin.Api.Middleware; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Configuration; using Microsoft.AspNetCore.Builder; -using Microsoft.OpenApi.Models; +using Microsoft.OpenApi; namespace Jellyfin.Server.Extensions { @@ -48,6 +48,7 @@ namespace Jellyfin.Server.Extensions c.SwaggerEndpoint($"/{baseUrl}api-docs/openapi.json", "Jellyfin API"); c.InjectStylesheet($"/{baseUrl}api-docs/swagger/custom.css"); c.RoutePrefix = "api-docs/swagger"; + c.UseRequestInterceptor("""(req) => { req.headers['Authorization'] = `MediaBrowser Token=\"${req.headers['Authorization']}\"`; return req; }"""); }) .UseReDoc(c => { @@ -117,18 +118,5 @@ namespace Jellyfin.Server.Extensions { return appBuilder.UseMiddleware<RobotsRedirectionMiddleware>(); } - - /// <summary> - /// Adds /emby and /mediabrowser route trimming to the application pipeline. - /// </summary> - /// <remarks> - /// This must be injected before any path related middleware. - /// </remarks> - /// <param name="appBuilder">The application builder.</param> - /// <returns>The updated application builder.</returns> - public static IApplicationBuilder UsePathTrim(this IApplicationBuilder appBuilder) - { - return appBuilder.UseMiddleware<LegacyEmbyRouteRewriteMiddleware>(); - } } } diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs index 04dd19eda6..a498901481 100644 --- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs @@ -1,11 +1,11 @@ using System; using System.Collections.Generic; using System.IO; -using System.Linq; using System.Net; using System.Net.Sockets; using System.Reflection; using System.Security.Claims; +using System.Text.Json.Nodes; using Emby.Server.Implementations; using Jellyfin.Api.Auth; using Jellyfin.Api.Auth.AnonymousLanAccessPolicy; @@ -26,7 +26,6 @@ using Jellyfin.Server.Filters; using MediaBrowser.Common.Api; using MediaBrowser.Common.Net; using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Session; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; @@ -34,9 +33,7 @@ using Microsoft.AspNetCore.Cors.Infrastructure; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.OpenApi.Any; -using Microsoft.OpenApi.Interfaces; -using Microsoft.OpenApi.Models; +using Microsoft.OpenApi; using Swashbuckle.AspNetCore.Swagger; using Swashbuckle.AspNetCore.SwaggerGen; using AuthenticationSchemes = Jellyfin.Api.Constants.AuthenticationSchemes; @@ -174,7 +171,7 @@ namespace Jellyfin.Server.Extensions if (config.KnownProxies.Length == 0) { options.ForwardedHeaders = ForwardedHeaders.None; - options.KnownNetworks.Clear(); + options.KnownIPNetworks.Clear(); options.KnownProxies.Clear(); } else @@ -184,7 +181,7 @@ namespace Jellyfin.Server.Extensions } // Only set forward limit if we have some known proxies or some known networks. - if (options.KnownProxies.Count != 0 || options.KnownNetworks.Count != 0) + if (options.KnownProxies.Count != 0 || options.KnownIPNetworks.Count != 0) { options.ForwardLimit = null; } @@ -208,7 +205,7 @@ namespace Jellyfin.Server.Extensions { { "x-jellyfin-version", - new OpenApiString(version) + new JsonNodeExtension(JsonValue.Create(version)) } } }); @@ -255,12 +252,14 @@ namespace Jellyfin.Server.Extensions c.AddSwaggerTypeMappings(); c.SchemaFilter<IgnoreEnumSchemaFilter>(); + c.SchemaFilter<FlagsEnumSchemaFilter>(); c.OperationFilter<RetryOnTemporarilyUnavailableFilter>(); c.OperationFilter<SecurityRequirementsOperationFilter>(); c.OperationFilter<FileResponseFilter>(); c.OperationFilter<FileRequestFilter>(); c.OperationFilter<ParameterObsoleteFilter>(); c.DocumentFilter<AdditionalModelFilter>(); + c.DocumentFilter<SecuritySchemeReferenceFixupFilter>(); }) .Replace(ServiceDescriptor.Transient<ISwaggerProvider, CachingOpenApiProvider>()); } @@ -289,10 +288,7 @@ namespace Jellyfin.Server.Extensions } else if (NetworkUtils.TryParseToSubnet(allowedProxies[i], out var subnet)) { - if (subnet is not null) - { - AddIPAddress(config, options, subnet.Prefix, subnet.PrefixLength); - } + AddIPAddress(config, options, subnet.Address, subnet.Subnet.PrefixLength); } else if (NetworkUtils.TryParseHost(allowedProxies[i], out var addresses, config.EnableIPv4, config.EnableIPv6)) { @@ -316,13 +312,13 @@ namespace Jellyfin.Server.Extensions return; } - if (prefixLength == NetworkConstants.MinimumIPv4PrefixSize) + if ((addr.AddressFamily == AddressFamily.InterNetwork && prefixLength == NetworkConstants.MinimumIPv4PrefixSize) || (addr.AddressFamily == AddressFamily.InterNetworkV6 && prefixLength == NetworkConstants.MinimumIPv6PrefixSize)) { options.KnownProxies.Add(addr); } else { - options.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(addr, prefixLength)); + options.KnownIPNetworks.Add(new System.Net.IPNetwork(addr, prefixLength)); } } @@ -335,63 +331,28 @@ namespace Jellyfin.Server.Extensions options.MapType<Dictionary<ImageType, string>>(() => new OpenApiSchema { - Type = "object", + Type = JsonSchemaType.Object, AdditionalProperties = new OpenApiSchema { - Type = "string" + Type = JsonSchemaType.String } }); - /* - * Support BlurHash dictionary - */ - options.MapType<Dictionary<ImageType, Dictionary<string, string>>>(() => - new OpenApiSchema - { - Type = "object", - Properties = typeof(ImageType).GetEnumNames().ToDictionary( - name => name, - _ => new OpenApiSchema - { - Type = "object", - AdditionalProperties = new OpenApiSchema - { - Type = "string" - } - }) - }); - // Support dictionary with nullable string value. options.MapType<Dictionary<string, string?>>(() => new OpenApiSchema { - Type = "object", + Type = JsonSchemaType.Object, AdditionalProperties = new OpenApiSchema { - Type = "string", - Nullable = true - } - }); - - // Manually describe Flags enum. - options.MapType<TranscodeReason>(() => - new OpenApiSchema - { - Type = "array", - Items = new OpenApiSchema - { - Reference = new OpenApiReference - { - Id = nameof(TranscodeReason), - Type = ReferenceType.Schema, - } + Type = JsonSchemaType.String | JsonSchemaType.Null } }); // Swashbuckle doesn't use JsonOptions to describe responses, so we need to manually describe it. options.MapType<Version>(() => new OpenApiSchema { - Type = "string" + Type = JsonSchemaType.String }); } } diff --git a/Jellyfin.Server/Filters/AdditionalModelFilter.cs b/Jellyfin.Server/Filters/AdditionalModelFilter.cs index 58d37db5a5..efa2f4cca5 100644 --- a/Jellyfin.Server/Filters/AdditionalModelFilter.cs +++ b/Jellyfin.Server/Filters/AdditionalModelFilter.cs @@ -3,18 +3,17 @@ using System.Collections.Generic; using System.ComponentModel; using System.Linq; using System.Reflection; +using System.Text.Json.Nodes; using Jellyfin.Extensions; using Jellyfin.Server.Migrations; using MediaBrowser.Common.Plugins; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Net.WebSocketMessages; -using MediaBrowser.Controller.Net.WebSocketMessages.Outbound; using MediaBrowser.Model.ApiClient; using MediaBrowser.Model.Session; using MediaBrowser.Model.SyncPlay; -using Microsoft.OpenApi.Any; -using Microsoft.OpenApi.Models; +using Microsoft.OpenApi; using Swashbuckle.AspNetCore.SwaggerGen; namespace Jellyfin.Server.Filters @@ -25,7 +24,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), typeof(MediaBrowser.Model.Branding.BrandingOptions) }; + private static readonly Type[] _ignoredConfigurations = [typeof(MigrationOptions), typeof(MediaBrowser.Model.Branding.BrandingOptions)]; private readonly IServerConfigurationManager _serverConfigurationManager; /// <summary> @@ -48,8 +47,8 @@ namespace Jellyfin.Server.Filters && t != typeof(WebSocketMessageInfo)) .ToList(); - var inboundWebSocketSchemas = new List<OpenApiSchema>(); - var inboundWebSocketDiscriminators = new Dictionary<string, string>(); + var inboundWebSocketSchemas = new List<IOpenApiSchema>(); + var inboundWebSocketDiscriminators = new Dictionary<string, OpenApiSchemaReference>(); foreach (var type in webSocketTypes.Where(t => typeof(IInboundWebSocketMessage).IsAssignableFrom(t))) { var messageType = (SessionMessageType?)type.GetProperty(nameof(WebSocketMessage.MessageType))?.GetCustomAttribute<DefaultValueAttribute>()?.Value; @@ -60,18 +59,16 @@ namespace Jellyfin.Server.Filters var schema = context.SchemaGenerator.GenerateSchema(type, context.SchemaRepository); inboundWebSocketSchemas.Add(schema); - inboundWebSocketDiscriminators[messageType.ToString()!] = schema.Reference.ReferenceV3; + if (schema is OpenApiSchemaReference schemaRef) + { + inboundWebSocketDiscriminators[messageType.ToString()!] = schemaRef; + } } var inboundWebSocketMessageSchema = new OpenApiSchema { - Type = "object", + Type = JsonSchemaType.Object, Description = "Represents the list of possible inbound websocket types", - Reference = new OpenApiReference - { - Id = nameof(InboundWebSocketMessage), - Type = ReferenceType.Schema - }, OneOf = inboundWebSocketSchemas, Discriminator = new OpenApiDiscriminator { @@ -82,8 +79,8 @@ namespace Jellyfin.Server.Filters context.SchemaRepository.AddDefinition(nameof(InboundWebSocketMessage), inboundWebSocketMessageSchema); - var outboundWebSocketSchemas = new List<OpenApiSchema>(); - var outboundWebSocketDiscriminators = new Dictionary<string, string>(); + var outboundWebSocketSchemas = new List<IOpenApiSchema>(); + var outboundWebSocketDiscriminators = new Dictionary<string, OpenApiSchemaReference>(); foreach (var type in webSocketTypes.Where(t => typeof(IOutboundWebSocketMessage).IsAssignableFrom(t))) { var messageType = (SessionMessageType?)type.GetProperty(nameof(WebSocketMessage.MessageType))?.GetCustomAttribute<DefaultValueAttribute>()?.Value; @@ -94,58 +91,55 @@ namespace Jellyfin.Server.Filters var schema = context.SchemaGenerator.GenerateSchema(type, context.SchemaRepository); outboundWebSocketSchemas.Add(schema); - outboundWebSocketDiscriminators.Add(messageType.ToString()!, schema.Reference.ReferenceV3); + if (schema is OpenApiSchemaReference schemaRef) + { + outboundWebSocketDiscriminators.Add(messageType.ToString()!, schemaRef); + } } // Add custom "SyncPlayGroupUpdateMessage" schema because Swashbuckle cannot generate it for us var syncPlayGroupUpdateMessageSchema = new OpenApiSchema { - Type = "object", + Type = JsonSchemaType.Object, Description = "Untyped sync play command.", - Properties = new Dictionary<string, OpenApiSchema> + Properties = new Dictionary<string, IOpenApiSchema> { { "Data", new OpenApiSchema { - AllOf = - [ - new OpenApiSchema { Reference = new OpenApiReference { Type = ReferenceType.Schema, Id = nameof(GroupUpdate<object>) } } - ], + AllOf = new List<IOpenApiSchema> + { + new OpenApiSchemaReference(nameof(GroupUpdate<object>), null, null) + }, Description = "Group update data", - Nullable = false, } }, - { "MessageId", new OpenApiSchema { Type = "string", Format = "uuid", Description = "Gets or sets the message id." } }, + { "MessageId", new OpenApiSchema { Type = JsonSchemaType.String, Format = "uuid", Description = "Gets or sets the message id." } }, { "MessageType", new OpenApiSchema { - Enum = Enum.GetValues<SessionMessageType>().Select(type => new OpenApiString(type.ToString())).ToList<IOpenApiAny>(), - AllOf = - [ - new OpenApiSchema { Reference = new OpenApiReference { Type = ReferenceType.Schema, Id = nameof(SessionMessageType) } } - ], + Enum = Enum.GetValues<SessionMessageType>().Select(type => (JsonNode)JsonValue.Create(type.ToString())!).ToList(), + AllOf = new List<IOpenApiSchema> + { + new OpenApiSchemaReference(nameof(SessionMessageType), null, null) + }, Description = "The different kinds of messages that are used in the WebSocket api.", - Default = new OpenApiString(nameof(SessionMessageType.SyncPlayGroupUpdate)), + Default = JsonValue.Create(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 syncPlayRef = new OpenApiSchemaReference("SyncPlayGroupUpdateMessage", null, null); + outboundWebSocketSchemas.Add(syncPlayRef); + outboundWebSocketDiscriminators[nameof(SessionMessageType.SyncPlayGroupUpdate)] = syncPlayRef; var outboundWebSocketMessageSchema = new OpenApiSchema { - Type = "object", + Type = JsonSchemaType.Object, Description = "Represents the list of possible outbound websocket types", - Reference = new OpenApiReference - { - Id = nameof(OutboundWebSocketMessage), - Type = ReferenceType.Schema - }, OneOf = outboundWebSocketSchemas, Discriminator = new OpenApiDiscriminator { @@ -159,17 +153,12 @@ namespace Jellyfin.Server.Filters nameof(WebSocketMessage), new OpenApiSchema { - Type = "object", + Type = JsonSchemaType.Object, Description = "Represents the possible websocket types", - Reference = new OpenApiReference + OneOf = new List<IOpenApiSchema> { - Id = nameof(WebSocketMessage), - Type = ReferenceType.Schema - }, - OneOf = new[] - { - inboundWebSocketMessageSchema, - outboundWebSocketMessageSchema + new OpenApiSchemaReference(nameof(InboundWebSocketMessage), null, null), + new OpenApiSchemaReference(nameof(OutboundWebSocketMessage), null, null) } }); @@ -180,8 +169,8 @@ namespace Jellyfin.Server.Filters && t.BaseType.GetGenericTypeDefinition() == typeof(GroupUpdate<>)) .ToList(); - var groupUpdateSchemas = new List<OpenApiSchema>(); - var groupUpdateDiscriminators = new Dictionary<string, string>(); + var groupUpdateSchemas = new List<IOpenApiSchema>(); + var groupUpdateDiscriminators = new Dictionary<string, OpenApiSchemaReference>(); foreach (var type in groupUpdateTypes) { var groupUpdateType = (GroupUpdateType?)type.GetProperty(nameof(GroupUpdate<object>.Type))?.GetCustomAttribute<DefaultValueAttribute>()?.Value; @@ -192,18 +181,16 @@ namespace Jellyfin.Server.Filters var schema = context.SchemaGenerator.GenerateSchema(type, context.SchemaRepository); groupUpdateSchemas.Add(schema); - groupUpdateDiscriminators[groupUpdateType.ToString()!] = schema.Reference.ReferenceV3; + if (schema is OpenApiSchemaReference schemaRef) + { + groupUpdateDiscriminators[groupUpdateType.ToString()!] = schemaRef; + } } var groupUpdateSchema = new OpenApiSchema { - Type = "object", + Type = JsonSchemaType.Object, Description = "Represents the list of possible group update types", - Reference = new OpenApiReference - { - Id = nameof(GroupUpdate<object>), - Type = ReferenceType.Schema - }, OneOf = groupUpdateSchemas, Discriminator = new OpenApiDiscriminator { @@ -225,15 +212,6 @@ namespace Jellyfin.Server.Filters context.SchemaGenerator.GenerateSchema(configuration.ConfigurationType, context.SchemaRepository); } - - context.SchemaRepository.AddDefinition(nameof(TranscodeReason), new OpenApiSchema - { - Type = "string", - Enum = Enum.GetNames<TranscodeReason>() - .Select(e => new OpenApiString(e)) - .Cast<IOpenApiAny>() - .ToArray() - }); } } } diff --git a/Jellyfin.Server/Filters/CachingOpenApiProvider.cs b/Jellyfin.Server/Filters/CachingOpenApiProvider.cs index 4169f2fb31..c9fd031ef9 100644 --- a/Jellyfin.Server/Filters/CachingOpenApiProvider.cs +++ b/Jellyfin.Server/Filters/CachingOpenApiProvider.cs @@ -1,9 +1,10 @@ using System; -using System.Threading; +using AsyncKeyedLock; using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Microsoft.OpenApi.Models; +using Microsoft.OpenApi; using Swashbuckle.AspNetCore.Swagger; using Swashbuckle.AspNetCore.SwaggerGen; @@ -17,12 +18,13 @@ internal sealed class CachingOpenApiProvider : ISwaggerProvider private const string CacheKey = "openapi.json"; private static readonly MemoryCacheEntryOptions _cacheOptions = new() { SlidingExpiration = TimeSpan.FromMinutes(5) }; - private static readonly SemaphoreSlim _lock = new(1, 1); + private static readonly AsyncNonKeyedLocker _lock = new(1); private static readonly TimeSpan _lockTimeout = TimeSpan.FromSeconds(1); private readonly IMemoryCache _memoryCache; private readonly SwaggerGenerator _swaggerGenerator; private readonly SwaggerGeneratorOptions _swaggerGeneratorOptions; + private readonly ILogger<CachingOpenApiProvider> _logger; /// <summary> /// Initializes a new instance of the <see cref="CachingOpenApiProvider"/> class. @@ -31,49 +33,51 @@ internal sealed class CachingOpenApiProvider : ISwaggerProvider /// <param name="apiDescriptionsProvider">The api descriptions provider.</param> /// <param name="schemaGenerator">The schema generator.</param> /// <param name="memoryCache">The memory cache.</param> + /// <param name="logger">The logger.</param> public CachingOpenApiProvider( IOptions<SwaggerGeneratorOptions> optionsAccessor, IApiDescriptionGroupCollectionProvider apiDescriptionsProvider, ISchemaGenerator schemaGenerator, - IMemoryCache memoryCache) + IMemoryCache memoryCache, + ILogger<CachingOpenApiProvider> logger) { _swaggerGeneratorOptions = optionsAccessor.Value; _swaggerGenerator = new SwaggerGenerator(_swaggerGeneratorOptions, apiDescriptionsProvider, schemaGenerator); _memoryCache = memoryCache; + _logger = logger; } /// <inheritdoc /> - public OpenApiDocument GetSwagger(string documentName, string? host = null, string? basePath = null) + public OpenApiDocument GetSwagger(string documentName, string host, string basePath) { if (_memoryCache.TryGetValue(CacheKey, out OpenApiDocument? openApiDocument) && openApiDocument is not null) { return AdjustDocument(openApiDocument, host, basePath); } - var acquired = _lock.Wait(_lockTimeout); - try + using var acquired = _lock.LockOrNull(_lockTimeout); + if (_memoryCache.TryGetValue(CacheKey, out openApiDocument) && openApiDocument is not null) { - if (_memoryCache.TryGetValue(CacheKey, out openApiDocument) && openApiDocument is not null) - { - return AdjustDocument(openApiDocument, host, basePath); - } - - if (!acquired) - { - throw new InvalidOperationException("OpenApi document is generating"); - } - - openApiDocument = _swaggerGenerator.GetSwagger(documentName); - _memoryCache.Set(CacheKey, openApiDocument, _cacheOptions); return AdjustDocument(openApiDocument, host, basePath); } - finally + + if (acquired is null) { - if (acquired) - { - _lock.Release(); - } + throw new InvalidOperationException("OpenApi document is generating"); } + + try + { + openApiDocument = _swaggerGenerator.GetSwagger(documentName); + } + catch (Exception ex) + { + _logger.LogError(ex, "OpenAPI generation error"); + throw; + } + + _memoryCache.Set(CacheKey, openApiDocument, _cacheOptions); + return AdjustDocument(openApiDocument, host, basePath); } private OpenApiDocument AdjustDocument(OpenApiDocument document, string? host, string? basePath) diff --git a/Jellyfin.Server/Filters/FileRequestFilter.cs b/Jellyfin.Server/Filters/FileRequestFilter.cs index 86dbf7657e..3d5b1fdf1f 100644 --- a/Jellyfin.Server/Filters/FileRequestFilter.cs +++ b/Jellyfin.Server/Filters/FileRequestFilter.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; using Jellyfin.Api.Attributes; -using Microsoft.OpenApi.Models; +using Microsoft.OpenApi; using Swashbuckle.AspNetCore.SwaggerGen; namespace Jellyfin.Server.Filters @@ -28,10 +28,11 @@ namespace Jellyfin.Server.Filters { Schema = new OpenApiSchema { - Type = "string", + Type = JsonSchemaType.String, Format = "binary" } }; + body.Content ??= new System.Collections.Generic.Dictionary<string, OpenApiMediaType>(); foreach (var contentType in contentTypes) { body.Content.Add(contentType, mediaType); diff --git a/Jellyfin.Server/Filters/FileResponseFilter.cs b/Jellyfin.Server/Filters/FileResponseFilter.cs index cd0acadf32..64aea62519 100644 --- a/Jellyfin.Server/Filters/FileResponseFilter.cs +++ b/Jellyfin.Server/Filters/FileResponseFilter.cs @@ -1,7 +1,7 @@ using System; using System.Linq; using Jellyfin.Api.Attributes; -using Microsoft.OpenApi.Models; +using Microsoft.OpenApi; using Swashbuckle.AspNetCore.SwaggerGen; namespace Jellyfin.Server.Filters @@ -14,7 +14,7 @@ namespace Jellyfin.Server.Filters { Schema = new OpenApiSchema { - Type = "string", + Type = JsonSchemaType.String, Format = "binary" } }; @@ -22,6 +22,11 @@ namespace Jellyfin.Server.Filters /// <inheritdoc /> public void Apply(OpenApiOperation operation, OperationFilterContext context) { + if (operation.Responses is null) + { + return; + } + foreach (var attribute in context.ApiDescription.ActionDescriptor.EndpointMetadata) { if (attribute is ProducesFileAttribute producesFileAttribute) @@ -31,7 +36,7 @@ namespace Jellyfin.Server.Filters .FirstOrDefault(o => o.Key.Equals(SuccessCode, StringComparison.Ordinal)); // Operation doesn't have a response. - if (response.Value is null) + if (response.Value?.Content is null) { continue; } diff --git a/Jellyfin.Server/Filters/FlagsEnumSchemaFilter.cs b/Jellyfin.Server/Filters/FlagsEnumSchemaFilter.cs new file mode 100644 index 0000000000..0c1f4197ce --- /dev/null +++ b/Jellyfin.Server/Filters/FlagsEnumSchemaFilter.cs @@ -0,0 +1,58 @@ +using System; +using Microsoft.OpenApi; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Jellyfin.Server.Filters; + +/// <summary> +/// Schema filter to ensure flags enums are represented correctly in OpenAPI. +/// </summary> +/// <remarks> +/// For flags enums: +/// - The enum schema definition is set to type "string" (not integer). +/// - Properties using flags enums are transformed to arrays referencing the enum schema. +/// </remarks> +public class FlagsEnumSchemaFilter : ISchemaFilter +{ + /// <inheritdoc /> + public void Apply(IOpenApiSchema schema, SchemaFilterContext context) + { + var type = context.Type.IsEnum ? context.Type : Nullable.GetUnderlyingType(context.Type); + if (type is null || !type.IsEnum) + { + return; + } + + // Check if enum has [Flags] attribute + if (!type.IsDefined(typeof(FlagsAttribute), false)) + { + return; + } + + if (schema is not OpenApiSchema concreteSchema) + { + return; + } + + if (context.MemberInfo is null) + { + // Processing the enum definition itself - ensure it's type "string" not "integer" + concreteSchema.Type = JsonSchemaType.String; + concreteSchema.Format = null; + } + else + { + // Processing a property that uses the flags enum - transform to array + // Generate the enum schema to ensure it exists in the repository + var enumSchema = context.SchemaGenerator.GenerateSchema(type, context.SchemaRepository); + + // Flags enums should be represented as arrays referencing the enum schema + // since multiple values can be combined + concreteSchema.Type = JsonSchemaType.Array; + concreteSchema.Format = null; + concreteSchema.Enum = null; + concreteSchema.AllOf = null; + concreteSchema.Items = enumSchema; + } + } +} diff --git a/Jellyfin.Server/Filters/IgnoreEnumSchemaFilter.cs b/Jellyfin.Server/Filters/IgnoreEnumSchemaFilter.cs index eb9ad03c21..3dcf29d9c2 100644 --- a/Jellyfin.Server/Filters/IgnoreEnumSchemaFilter.cs +++ b/Jellyfin.Server/Filters/IgnoreEnumSchemaFilter.cs @@ -2,9 +2,9 @@ using System; using System.Collections.Generic; using System.Linq; using System.Reflection; +using System.Text.Json.Nodes; using Jellyfin.Data.Attributes; -using Microsoft.OpenApi.Any; -using Microsoft.OpenApi.Models; +using Microsoft.OpenApi; using Swashbuckle.AspNetCore.SwaggerGen; namespace Jellyfin.Server.Filters; @@ -15,7 +15,7 @@ namespace Jellyfin.Server.Filters; public class IgnoreEnumSchemaFilter : ISchemaFilter { /// <inheritdoc /> - public void Apply(OpenApiSchema schema, SchemaFilterContext context) + public void Apply(IOpenApiSchema schema, SchemaFilterContext context) { if (context.Type.IsEnum || (Nullable.GetUnderlyingType(context.Type)?.IsEnum ?? false)) { @@ -25,18 +25,23 @@ public class IgnoreEnumSchemaFilter : ISchemaFilter return; } - var enumOpenApiStrings = new List<IOpenApiAny>(); + if (schema is not OpenApiSchema concreteSchema) + { + return; + } + + var enumOpenApiNodes = new List<JsonNode>(); foreach (var enumName in Enum.GetNames(type)) { var member = type.GetMember(enumName)[0]; if (!member.GetCustomAttributes<OpenApiIgnoreEnumAttribute>().Any()) { - enumOpenApiStrings.Add(new OpenApiString(enumName)); + enumOpenApiNodes.Add(JsonValue.Create(enumName)!); } } - schema.Enum = enumOpenApiStrings; + concreteSchema.Enum = enumOpenApiNodes; } } } diff --git a/Jellyfin.Server/Filters/ParameterObsoleteFilter.cs b/Jellyfin.Server/Filters/ParameterObsoleteFilter.cs index 98a8dc0f18..90bca884b1 100644 --- a/Jellyfin.Server/Filters/ParameterObsoleteFilter.cs +++ b/Jellyfin.Server/Filters/ParameterObsoleteFilter.cs @@ -1,7 +1,7 @@ using System; using System.Linq; using Jellyfin.Api.Attributes; -using Microsoft.OpenApi.Models; +using Microsoft.OpenApi; using Swashbuckle.AspNetCore.SwaggerGen; namespace Jellyfin.Server.Filters @@ -21,11 +21,17 @@ namespace Jellyfin.Server.Filters .OfType<ParameterObsoleteAttribute>() .Any()) { + if (operation.Parameters is null) + { + continue; + } + foreach (var parameter in operation.Parameters) { - if (parameter.Name.Equals(parameterDescription.Name, StringComparison.Ordinal)) + if (parameter is OpenApiParameter concreteParam + && string.Equals(concreteParam.Name, parameterDescription.Name, StringComparison.Ordinal)) { - parameter.Deprecated = true; + concreteParam.Deprecated = true; break; } } diff --git a/Jellyfin.Server/Filters/RetryOnTemporarilyUnavailableFilter.cs b/Jellyfin.Server/Filters/RetryOnTemporarilyUnavailableFilter.cs index 8b7268513a..435f55496a 100644 --- a/Jellyfin.Server/Filters/RetryOnTemporarilyUnavailableFilter.cs +++ b/Jellyfin.Server/Filters/RetryOnTemporarilyUnavailableFilter.cs @@ -1,5 +1,5 @@ using System.Collections.Generic; -using Microsoft.OpenApi.Models; +using Microsoft.OpenApi; using Swashbuckle.AspNetCore.SwaggerGen; namespace Jellyfin.Server.Filters; @@ -8,12 +8,12 @@ internal class RetryOnTemporarilyUnavailableFilter : IOperationFilter { public void Apply(OpenApiOperation operation, OperationFilterContext context) { - operation.Responses.TryAdd( + operation.Responses?.TryAdd( "503", new OpenApiResponse { Description = "The server is currently starting or is temporarily not available.", - Headers = new Dictionary<string, OpenApiHeader> + Headers = new Dictionary<string, IOpenApiHeader> { { "Retry-After", new OpenApiHeader @@ -23,7 +23,7 @@ internal class RetryOnTemporarilyUnavailableFilter : IOperationFilter Description = "A hint for when to retry the operation in full seconds.", Schema = new OpenApiSchema { - Type = "integer", + Type = JsonSchemaType.Integer, Format = "int32" } } @@ -36,7 +36,7 @@ internal class RetryOnTemporarilyUnavailableFilter : IOperationFilter Description = "A short plain-text reason why the server is not available.", Schema = new OpenApiSchema { - Type = "string", + Type = JsonSchemaType.String, Format = "text" } } diff --git a/Jellyfin.Server/Filters/SecurityRequirementsOperationFilter.cs b/Jellyfin.Server/Filters/SecurityRequirementsOperationFilter.cs index 8f57572696..5b048be913 100644 --- a/Jellyfin.Server/Filters/SecurityRequirementsOperationFilter.cs +++ b/Jellyfin.Server/Filters/SecurityRequirementsOperationFilter.cs @@ -5,7 +5,7 @@ using Jellyfin.Api.Auth.DefaultAuthorizationPolicy; using Jellyfin.Api.Constants; using Jellyfin.Extensions; using Microsoft.AspNetCore.Authorization; -using Microsoft.OpenApi.Models; +using Microsoft.OpenApi; using Swashbuckle.AspNetCore.SwaggerGen; namespace Jellyfin.Server.Filters; @@ -66,17 +66,10 @@ public class SecurityRequirementsOperationFilter : IOperationFilter return; } - operation.Responses.TryAdd("401", new OpenApiResponse { Description = "Unauthorized" }); - operation.Responses.TryAdd("403", new OpenApiResponse { Description = "Forbidden" }); + operation.Responses?.TryAdd("401", new OpenApiResponse { Description = "Unauthorized" }); + operation.Responses?.TryAdd("403", new OpenApiResponse { Description = "Forbidden" }); - var scheme = new OpenApiSecurityScheme - { - Reference = new OpenApiReference - { - Type = ReferenceType.SecurityScheme, - Id = AuthenticationSchemes.CustomAuthentication - }, - }; + var scheme = new OpenApiSecuritySchemeReference(AuthenticationSchemes.CustomAuthentication, null, null); // Add DefaultAuthorization scope to any endpoint that has a policy with a requirement that is a subset of DefaultAuthorization. if (!requiredScopes.Contains(DefaultAuthPolicy.AsSpan(), StringComparison.Ordinal)) diff --git a/Jellyfin.Server/Filters/SecuritySchemeReferenceFixupFilter.cs b/Jellyfin.Server/Filters/SecuritySchemeReferenceFixupFilter.cs new file mode 100644 index 0000000000..e4eb5be2b9 --- /dev/null +++ b/Jellyfin.Server/Filters/SecuritySchemeReferenceFixupFilter.cs @@ -0,0 +1,56 @@ +using Microsoft.OpenApi; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Jellyfin.Server.Filters; + +/// <summary> +/// Document filter that fixes security scheme references after document generation. +/// </summary> +/// <remarks> +/// In Microsoft.OpenApi v2, <see cref="OpenApiSecuritySchemeReference"/> requires a resolved +/// <c>Target</c> to serialize correctly. References created without a host document (as in +/// operation filters) serialize as empty objects. This filter re-creates all security scheme +/// references with the document context so they resolve properly during serialization. +/// </remarks> +internal class SecuritySchemeReferenceFixupFilter : IDocumentFilter +{ + /// <inheritdoc /> + public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context) + { + swaggerDoc.RegisterComponents(); + + if (swaggerDoc.Paths is null) + { + return; + } + + foreach (var pathItem in swaggerDoc.Paths.Values) + { + if (pathItem.Operations is null) + { + continue; + } + + foreach (var operation in pathItem.Operations.Values) + { + if (operation.Security is null) + { + continue; + } + + for (int i = 0; i < operation.Security.Count; i++) + { + var oldReq = operation.Security[i]; + var newReq = new OpenApiSecurityRequirement(); + foreach (var kvp in oldReq) + { + var fixedRef = new OpenApiSecuritySchemeReference(kvp.Key.Reference.Id!, swaggerDoc); + newReq[fixedRef] = kvp.Value; + } + + operation.Security[i] = newReq; + } + } + } + } +} diff --git a/Jellyfin.Server/GlobalSuppressions.cs b/Jellyfin.Server/GlobalSuppressions.cs new file mode 100644 index 0000000000..676747e29f --- /dev/null +++ b/Jellyfin.Server/GlobalSuppressions.cs @@ -0,0 +1,8 @@ +// This file is used by Code Analysis to maintain SuppressMessage +// attributes that are applied to this project. +// Project-level suppressions either have no target or are given +// a specific target and scoped to a namespace, type, member, etc. + +using System.Diagnostics.CodeAnalysis; + +[assembly: SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1649:File name should match first type name", Justification = "Migration files should follow the EFCore standard in regards to naming.", Scope = "namespaceanddescendants", Target = "~N:Jellyfin.Server.Migrations.Routines")] diff --git a/Jellyfin.Server/Jellyfin.Server.csproj b/Jellyfin.Server/Jellyfin.Server.csproj index 4d20655b0e..00fdc1d894 100644 --- a/Jellyfin.Server/Jellyfin.Server.csproj +++ b/Jellyfin.Server/Jellyfin.Server.csproj @@ -8,7 +8,7 @@ <PropertyGroup> <AssemblyName>jellyfin</AssemblyName> <OutputType>Exe</OutputType> - <TargetFramework>net9.0</TargetFramework> + <TargetFramework>net10.0</TargetFramework> <ServerGarbageCollection>false</ServerGarbageCollection> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> @@ -44,9 +44,6 @@ <ItemGroup> <PackageReference Include="CommandLineParser" /> - <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" /> - <PackageReference Include="Microsoft.Extensions.Configuration.Json" /> - <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" /> <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" /> <PackageReference Include="Morestachio" /> <PackageReference Include="prometheus-net" /> diff --git a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs index 188d3c4a9a..a5b6bc4604 100644 --- a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs +++ b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs @@ -90,7 +90,7 @@ internal class JellyfinMigrationService private HashSet<MigrationStage> Migrations { get; set; } - public async Task CheckFirstTimeRunOrMigration(IApplicationPaths appPaths) + public async Task CheckFirstTimeRunOrMigration(IApplicationPaths appPaths, StartupOptions startupOptions) { var logger = _startupLogger.With(_loggerFactory.CreateLogger<JellyfinMigrationService>()).BeginGroup($"Migration Startup"); logger.LogInformation("Initialise Migration service."); @@ -98,9 +98,9 @@ internal class JellyfinMigrationService var serverConfig = File.Exists(appPaths.SystemConfigurationFilePath) ? (ServerConfiguration)xmlSerializer.DeserializeFromFile(typeof(ServerConfiguration), appPaths.SystemConfigurationFilePath)! : new ServerConfiguration(); - if (!serverConfig.IsStartupWizardCompleted) + if (!serverConfig.IsStartupWizardCompleted || startupOptions.StartupMode is Configuration.StartupMode.SeedSystem) { - logger.LogInformation("System initialisation detected. Seed data."); + logger.LogInformation("System initialization detected. Seed data. Startup mode is: {StartupMode}", startupOptions.StartupMode ?? Configuration.StartupMode.MediaServer); var flatApplyMigrations = Migrations.SelectMany(e => e.Where(f => !f.Metadata.RunMigrationOnSetup)).ToArray(); var dbContext = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false); @@ -183,7 +183,13 @@ internal class JellyfinMigrationService } } - public async Task MigrateStepAsync(JellyfinMigrationStageTypes stage, IServiceProvider? serviceProvider) + /// <summary> + /// Runs all pending migrations of the requested stage. + /// </summary> + /// <param name="stage">The stage to migrate.</param> + /// <param name="serviceProvider">The service provider handed to the migrations.</param> + /// <returns>A value indicating whether at least one migration has been applied.</returns> + public async Task<bool> MigrateStepAsync(JellyfinMigrationStageTypes stage, IServiceProvider serviceProvider) { var logger = _startupLogger.With(_loggerFactory.CreateLogger<JellyfinMigrationService>()).BeginGroup($"Migrate stage {stage}."); ICollection<CodeMigration> migrationStage = (Migrations.FirstOrDefault(e => e.Stage == stage) as ICollection<CodeMigration>) ?? []; @@ -193,26 +199,50 @@ internal class JellyfinMigrationService { var historyRepository = dbContext.GetService<IHistoryRepository>(); var migrationsAssembly = dbContext.GetService<IMigrationsAssembly>(); - var appliedMigrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false); - var pendingCodeMigrations = migrationStage - .Where(e => appliedMigrations.All(f => f.MigrationId != e.BuildCodeMigrationId())) - .Select(e => (Key: e.BuildCodeMigrationId(), Migration: new InternalCodeMigration(e, serviceProvider, dbContext))) - .ToArray(); + var completedMigrations = 0; + string? lastMigrationKey = null; - (string Key, InternalDatabaseMigration Migration)[] pendingDatabaseMigrations = []; - if (stage is JellyfinMigrationStageTypes.CoreInitialisation) + while (true) { - pendingDatabaseMigrations = migrationsAssembly.Migrations.Where(f => appliedMigrations.All(e => e.MigrationId != f.Key)) - .Select(e => (Key: e.Key, Migration: new InternalDatabaseMigration(e, dbContext))) - .ToArray(); - } + // A single migration can change which migrations still apply: IMigrator.MigrateAsync treats its argument as the + // state to end up in, so it reverts everything applied after it, and a reverted migration can take code migrations + // with it (AddNormalizedUsername.Down drops the UpdateNormalizedUsername history row). Anything computed before + // that point is stale, so only ever run the next migration and then work out the pending set again. + var appliedMigrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false); + var pendingCodeMigrations = migrationStage + .Where(e => appliedMigrations.All(f => f.MigrationId != e.BuildCodeMigrationId())) + .Select(e => (Key: e.BuildCodeMigrationId(), Migration: new InternalCodeMigration(e, serviceProvider, dbContext))) + .ToArray(); - (string Key, IInternalMigration Migration)[] pendingMigrations = [.. pendingCodeMigrations, .. pendingDatabaseMigrations]; - logger.LogInformation("There are {Pending} migrations for stage {Stage}.", pendingCodeMigrations.Length, stage); - var migrations = pendingMigrations.OrderBy(e => e.Key).ToArray(); + (string Key, InternalDatabaseMigration Migration)[] pendingDatabaseMigrations = []; + if (stage is JellyfinMigrationStageTypes.CoreInitialisation) + { + pendingDatabaseMigrations = migrationsAssembly.Migrations.Where(f => appliedMigrations.All(e => e.MigrationId != f.Key)) + .Select(e => (Key: e.Key, Migration: new InternalDatabaseMigration(e, dbContext))) + .ToArray(); + } - foreach (var item in migrations) - { + (string Key, IInternalMigration Migration)[] pendingMigrations = [.. pendingCodeMigrations, .. pendingDatabaseMigrations]; + if (pendingMigrations.Length == 0) + { + break; + } + + if (completedMigrations == 0) + { + logger.LogInformation("There are {Pending} migrations for stage {Stage}.", pendingMigrations.Length, stage); + } + + var item = pendingMigrations.OrderBy(e => e.Key, StringComparer.Ordinal).First(); + if (string.Equals(item.Key, lastMigrationKey, StringComparison.Ordinal)) + { + throw new InvalidOperationException($"Migration {item.Key} ran but did not record itself as applied and would repeat indefinitely."); + } + + lastMigrationKey = item.Key; + + // Surface generic "Running migration X of Y" progress in the always-visible startup UI header. + SetupServer.ReportActivity(StartupActivity.Migration(completedMigrations + 1, completedMigrations + pendingMigrations.Length)); var migrationLogger = logger.With(_loggerFactory.CreateLogger(item.Migration.GetType().Name)).BeginGroup($"{item.Key}"); try { @@ -270,7 +300,11 @@ internal class JellyfinMigrationService throw; } + + completedMigrations++; } + + return completedMigrations > 0; } } @@ -419,10 +453,10 @@ internal class JellyfinMigrationService private class InternalCodeMigration : IInternalMigration { private readonly CodeMigration _codeMigration; - private readonly IServiceProvider? _serviceProvider; + private readonly IServiceProvider _serviceProvider; private JellyfinDbContext _dbContext; - public InternalCodeMigration(CodeMigration codeMigration, IServiceProvider? serviceProvider, JellyfinDbContext dbContext) + public InternalCodeMigration(CodeMigration codeMigration, IServiceProvider serviceProvider, JellyfinDbContext dbContext) { _codeMigration = codeMigration; _serviceProvider = serviceProvider; diff --git a/Jellyfin.Server/Migrations/MigrationOptions.cs b/Jellyfin.Server/Migrations/MigrationOptions.cs index c9710f1fd1..cd1b74a613 100644 --- a/Jellyfin.Server/Migrations/MigrationOptions.cs +++ b/Jellyfin.Server/Migrations/MigrationOptions.cs @@ -16,7 +16,7 @@ namespace Jellyfin.Server.Migrations Applied = new List<(Guid Id, string Name)>(); } -// .Net xml serializer can't handle interfaces + // .Net xml serializer can't handle interfaces #pragma warning disable CA1002 // Do not expose generic lists /// <summary> /// Gets the list of applied migration routine names. diff --git a/Jellyfin.Server/Migrations/Routines/DisableTranscodingThrottling.cs b/Jellyfin.Server/Migrations/Routines/20250420050000_DisableTranscodingThrottling.cs similarity index 100% rename from Jellyfin.Server/Migrations/Routines/DisableTranscodingThrottling.cs rename to Jellyfin.Server/Migrations/Routines/20250420050000_DisableTranscodingThrottling.cs diff --git a/Jellyfin.Server/Migrations/Routines/CreateUserLoggingConfigFile.cs b/Jellyfin.Server/Migrations/Routines/20250420060000_CreateUserLoggingConfigFile.cs similarity index 100% rename from Jellyfin.Server/Migrations/Routines/CreateUserLoggingConfigFile.cs rename to Jellyfin.Server/Migrations/Routines/20250420060000_CreateUserLoggingConfigFile.cs diff --git a/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs b/Jellyfin.Server/Migrations/Routines/20250420070000_MigrateActivityLogDb.cs similarity index 100% rename from Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs rename to Jellyfin.Server/Migrations/Routines/20250420070000_MigrateActivityLogDb.cs diff --git a/Jellyfin.Server/Migrations/Routines/RemoveDuplicateExtras.cs b/Jellyfin.Server/Migrations/Routines/20250420080000_RemoveDuplicateExtras.cs similarity index 100% rename from Jellyfin.Server/Migrations/Routines/RemoveDuplicateExtras.cs rename to Jellyfin.Server/Migrations/Routines/20250420080000_RemoveDuplicateExtras.cs diff --git a/Jellyfin.Server/Migrations/Routines/AddDefaultPluginRepository.cs b/Jellyfin.Server/Migrations/Routines/20250420090000_AddDefaultPluginRepository.cs similarity index 100% rename from Jellyfin.Server/Migrations/Routines/AddDefaultPluginRepository.cs rename to Jellyfin.Server/Migrations/Routines/20250420090000_AddDefaultPluginRepository.cs diff --git a/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs b/Jellyfin.Server/Migrations/Routines/20250420100000_MigrateUserDb.cs similarity index 100% rename from Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs rename to Jellyfin.Server/Migrations/Routines/20250420100000_MigrateUserDb.cs diff --git a/Jellyfin.Server/Migrations/Routines/ReaddDefaultPluginRepository.cs b/Jellyfin.Server/Migrations/Routines/20250420110000_ReaddDefaultPluginRepository.cs similarity index 100% rename from Jellyfin.Server/Migrations/Routines/ReaddDefaultPluginRepository.cs rename to Jellyfin.Server/Migrations/Routines/20250420110000_ReaddDefaultPluginRepository.cs diff --git a/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs b/Jellyfin.Server/Migrations/Routines/20250420120000_MigrateDisplayPreferencesDb.cs similarity index 100% rename from Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs rename to Jellyfin.Server/Migrations/Routines/20250420120000_MigrateDisplayPreferencesDb.cs diff --git a/Jellyfin.Server/Migrations/Routines/RemoveDownloadImagesInAdvance.cs b/Jellyfin.Server/Migrations/Routines/20250420130000_RemoveDownloadImagesInAdvance.cs similarity index 100% rename from Jellyfin.Server/Migrations/Routines/RemoveDownloadImagesInAdvance.cs rename to Jellyfin.Server/Migrations/Routines/20250420130000_RemoveDownloadImagesInAdvance.cs diff --git a/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs b/Jellyfin.Server/Migrations/Routines/20250420140000_MigrateAuthenticationDb.cs similarity index 95% rename from Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs rename to Jellyfin.Server/Migrations/Routines/20250420140000_MigrateAuthenticationDb.cs index 0de775e03a..cbdc6efb44 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs +++ b/Jellyfin.Server/Migrations/Routines/20250420140000_MigrateAuthenticationDb.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using Emby.Server.Implementations.Data; using Jellyfin.Database.Implementations; @@ -79,9 +80,9 @@ namespace Jellyfin.Server.Migrations.Routines foreach (var row in authenticatedDevices) { var dateCreatedStr = row.GetString(9); - _ = DateTime.TryParse(dateCreatedStr, out var dateCreated); + _ = DateTime.TryParse(dateCreatedStr, CultureInfo.InvariantCulture, out var dateCreated); var dateLastActivityStr = row.GetString(10); - _ = DateTime.TryParse(dateLastActivityStr, out var dateLastActivity); + _ = DateTime.TryParse(dateLastActivityStr, CultureInfo.InvariantCulture, out var dateLastActivity); if (row.IsDBNull(6)) { diff --git a/Jellyfin.Server/Migrations/Routines/FixPlaylistOwner.cs b/Jellyfin.Server/Migrations/Routines/20250420150000_FixPlaylistOwner.cs similarity index 100% rename from Jellyfin.Server/Migrations/Routines/FixPlaylistOwner.cs rename to Jellyfin.Server/Migrations/Routines/20250420150000_FixPlaylistOwner.cs diff --git a/Jellyfin.Server/Migrations/Routines/AddDefaultCastReceivers.cs b/Jellyfin.Server/Migrations/Routines/20250420160000_AddDefaultCastReceivers.cs similarity index 100% rename from Jellyfin.Server/Migrations/Routines/AddDefaultCastReceivers.cs rename to Jellyfin.Server/Migrations/Routines/20250420160000_AddDefaultCastReceivers.cs diff --git a/Jellyfin.Server/Migrations/Routines/UpdateDefaultPluginRepository.cs b/Jellyfin.Server/Migrations/Routines/20250420170000_UpdateDefaultPluginRepository.cs similarity index 100% rename from Jellyfin.Server/Migrations/Routines/UpdateDefaultPluginRepository.cs rename to Jellyfin.Server/Migrations/Routines/20250420170000_UpdateDefaultPluginRepository.cs diff --git a/Jellyfin.Server/Migrations/Routines/FixAudioData.cs b/Jellyfin.Server/Migrations/Routines/20250420180000_FixAudioData.cs similarity index 82% rename from Jellyfin.Server/Migrations/Routines/FixAudioData.cs rename to Jellyfin.Server/Migrations/Routines/20250420180000_FixAudioData.cs index 05ded06ba8..d102e24b91 100644 --- a/Jellyfin.Server/Migrations/Routines/FixAudioData.cs +++ b/Jellyfin.Server/Migrations/Routines/20250420180000_FixAudioData.cs @@ -1,10 +1,6 @@ -using System; -using System.Globalization; -using System.IO; using System.Linq; using System.Threading; using Jellyfin.Data.Enums; -using MediaBrowser.Controller; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Persistence; @@ -23,16 +19,19 @@ namespace Jellyfin.Server.Migrations.Routines #pragma warning restore CS0618 // Type or member is obsolete { private readonly ILogger<FixAudioData> _logger; - private readonly IServerApplicationPaths _applicationPaths; private readonly IItemRepository _itemRepository; + private readonly IItemCountService _countService; + private readonly IItemPersistenceService _persistenceService; public FixAudioData( - IServerApplicationPaths applicationPaths, ILoggerFactory loggerFactory, - IItemRepository itemRepository) + IItemRepository itemRepository, + IItemCountService countService, + IItemPersistenceService persistenceService) { - _applicationPaths = applicationPaths; _itemRepository = itemRepository; + _countService = countService; + _persistenceService = persistenceService; _logger = loggerFactory.CreateLogger<FixAudioData>(); } @@ -41,7 +40,7 @@ namespace Jellyfin.Server.Migrations.Routines { _logger.LogInformation("Backfilling audio lyrics data to database."); var startIndex = 0; - var records = _itemRepository.GetCount(new InternalItemsQuery + var records = _countService.GetCount(new InternalItemsQuery { IncludeItemTypes = [BaseItemKind.Audio], }); @@ -68,7 +67,7 @@ namespace Jellyfin.Server.Migrations.Routines } } - _itemRepository.SaveItems(results, CancellationToken.None); + _persistenceService.SaveItems(results, CancellationToken.None); startIndex += results.Count; _logger.LogInformation("Backfilled data for {UpdatedRecords} of {TotalRecords} audio records", startIndex, records); } diff --git a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDbCompatibilityCheck.cs b/Jellyfin.Server/Migrations/Routines/20250420193000_MigrateLibraryDbCompatibilityCheck.cs similarity index 100% rename from Jellyfin.Server/Migrations/Routines/MigrateLibraryDbCompatibilityCheck.cs rename to Jellyfin.Server/Migrations/Routines/20250420193000_MigrateLibraryDbCompatibilityCheck.cs diff --git a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs b/Jellyfin.Server/Migrations/Routines/20250420200000_MigrateLibraryDb.cs similarity index 97% rename from Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs rename to Jellyfin.Server/Migrations/Routines/20250420200000_MigrateLibraryDb.cs index d48ab704f6..3e4205547a 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs +++ b/Jellyfin.Server/Migrations/Routines/20250420200000_MigrateLibraryDb.cs @@ -515,7 +515,7 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine PlayCount = dto.GetInt32(4), IsFavorite = dto.GetBoolean(5), PlaybackPositionTicks = dto.GetInt64(6), - LastPlayedDate = dto.IsDBNull(7) ? null : dto.GetDateTime(7), + LastPlayedDate = dto.IsDBNull(7) ? null : ReadDateTimeFromColumn(dto, 7), AudioStreamIndex = dto.IsDBNull(8) ? null : dto.GetInt32(8), SubtitleStreamIndex = dto.IsDBNull(9) ? null : dto.GetInt32(9), Likes = null, @@ -524,6 +524,28 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine }; } + private static DateTime? ReadDateTimeFromColumn(SqliteDataReader reader, int index) + { + // Try reading as a formatted date string first (handles ISO-8601 dates). + if (reader.TryReadDateTime(index, out var dateTimeResult)) + { + return dateTimeResult; + } + + // Some databases have Unix epoch timestamps stored as integers. + // SqliteDataReader.GetDateTime interprets integers as Julian dates, which crashes + // for Unix epoch values. Handle them explicitly. + var rawValue = reader.GetValue(index); + if (rawValue is long unixTimestamp + && unixTimestamp > 0 + && unixTimestamp <= DateTimeOffset.MaxValue.ToUnixTimeSeconds()) + { + return DateTimeOffset.FromUnixTimeSeconds(unixTimestamp).UtcDateTime; + } + + return null; + } + private AncestorId GetAncestorId(SqliteDataReader reader) { return new AncestorId() @@ -1084,9 +1106,9 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine entity.OriginalTitle = originalTitle; } - if (reader.TryGetString(index++, out var primaryVersionId)) + if (reader.TryGetString(index++, out var primaryVersionId) && Guid.TryParse(primaryVersionId, out var primaryVersionGuid)) { - entity.PrimaryVersionId = primaryVersionId; + entity.PrimaryVersionId = primaryVersionGuid; } if (reader.TryReadDateTime(index++, out var dateLastMediaAdded)) @@ -1188,10 +1210,8 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine entity.ProductionLocations = productionLocations; } - if (reader.TryGetString(index++, out var extraIds)) - { - entity.ExtraIds = extraIds; - } + // Skip ExtraIds column (removed - extras are now tracked via OwnerId relationship) + index++; if (reader.TryGetInt32(index++, out var totalBitrate)) { @@ -1228,9 +1248,9 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine entity.ShowId = showId; } - if (reader.TryGetString(index++, out var ownerId)) + if (reader.TryGetString(index++, out var ownerId) && Guid.TryParse(ownerId, out var ownerIdGuid)) { - entity.OwnerId = ownerId; + entity.OwnerId = ownerIdGuid; } if (reader.TryGetString(index++, out var mediaType)) @@ -1259,8 +1279,11 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine } var baseItem = BaseItemRepository.DeserializeBaseItem(entity, _logger, null, false); - var dataKeys = baseItem.GetUserDataKeys(); - userDataKeys.AddRange(dataKeys); + if (baseItem is not null) + { + var dataKeys = baseItem.GetUserDataKeys(); + userDataKeys.AddRange(dataKeys); + } return (entity, userDataKeys.ToArray()); } diff --git a/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs b/Jellyfin.Server/Migrations/Routines/20250420210000_MoveExtractedFiles.cs similarity index 98% rename from Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs rename to Jellyfin.Server/Migrations/Routines/20250420210000_MoveExtractedFiles.cs index fbf9c16377..cfc1628782 100644 --- a/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs +++ b/Jellyfin.Server/Migrations/Routines/20250420210000_MoveExtractedFiles.cs @@ -144,6 +144,11 @@ public class MoveExtractedFiles : IAsyncMigrationRoutine } var newSubtitleCachePath = _pathManager.GetSubtitlePath(itemIdString, mediaStreamIndex, extension); + if (newSubtitleCachePath is null) + { + continue; + } + if (File.Exists(newSubtitleCachePath)) { File.Delete(oldSubtitleCachePath); @@ -182,6 +187,11 @@ public class MoveExtractedFiles : IAsyncMigrationRoutine } var newAttachmentPath = _pathManager.GetAttachmentPath(itemIdString, attachment.Filename ?? attachmentIndex.ToString(CultureInfo.InvariantCulture)); + if (newAttachmentPath is null) + { + continue; + } + if (File.Exists(newAttachmentPath)) { File.Delete(oldAttachmentPath); diff --git a/Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs b/Jellyfin.Server/Migrations/Routines/20250420230000_MoveTrickplayFiles.cs similarity index 98% rename from Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs rename to Jellyfin.Server/Migrations/Routines/20250420230000_MoveTrickplayFiles.cs index 0f55465e86..79a8f9577c 100644 --- a/Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs +++ b/Jellyfin.Server/Migrations/Routines/20250420230000_MoveTrickplayFiles.cs @@ -57,7 +57,8 @@ public class MoveTrickplayFiles : IMigrationRoutine MediaTypes = [MediaType.Video], SourceTypes = [SourceType.Library], IsVirtualItem = false, - IsFolder = false + IsFolder = false, + IncludeOwnedItems = true }; do diff --git a/Jellyfin.Server/Migrations/Routines/RefreshInternalDateModified.cs b/Jellyfin.Server/Migrations/Routines/20250420230000_RefreshInternalDateModified.cs similarity index 100% rename from Jellyfin.Server/Migrations/Routines/RefreshInternalDateModified.cs rename to Jellyfin.Server/Migrations/Routines/20250420230000_RefreshInternalDateModified.cs diff --git a/Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs b/Jellyfin.Server/Migrations/Routines/20250421000000_MigrateKeyframeData.cs similarity index 100% rename from Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs rename to Jellyfin.Server/Migrations/Routines/20250421000000_MigrateKeyframeData.cs diff --git a/Jellyfin.Server/Migrations/Routines/MigrateLibraryUserData.cs b/Jellyfin.Server/Migrations/Routines/20250618010000_MigrateLibraryUserData.cs similarity index 98% rename from Jellyfin.Server/Migrations/Routines/MigrateLibraryUserData.cs rename to Jellyfin.Server/Migrations/Routines/20250618010000_MigrateLibraryUserData.cs index 8a0a1741f1..291de23b2e 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateLibraryUserData.cs +++ b/Jellyfin.Server/Migrations/Routines/20250618010000_MigrateLibraryUserData.cs @@ -29,7 +29,7 @@ internal class MigrateLibraryUserData : IAsyncMigrationRoutine private readonly IDbContextFactory<JellyfinDbContext> _provider; public MigrateLibraryUserData( - IStartupLogger<MigrateLibraryDb> startupLogger, + IStartupLogger<MigrateLibraryUserData> startupLogger, IDbContextFactory<JellyfinDbContext> provider, IServerApplicationPaths paths) { diff --git a/Jellyfin.Server/Migrations/Routines/FixDates.cs b/Jellyfin.Server/Migrations/Routines/20250620180000_FixDates.cs similarity index 100% rename from Jellyfin.Server/Migrations/Routines/FixDates.cs rename to Jellyfin.Server/Migrations/Routines/20250620180000_FixDates.cs diff --git a/Jellyfin.Server/Migrations/Routines/ReseedFolderFlag.cs b/Jellyfin.Server/Migrations/Routines/20250730215000_ReseedFolderFlag.cs similarity index 97% rename from Jellyfin.Server/Migrations/Routines/ReseedFolderFlag.cs rename to Jellyfin.Server/Migrations/Routines/20250730215000_ReseedFolderFlag.cs index 502763ac09..c8ee44a670 100644 --- a/Jellyfin.Server/Migrations/Routines/ReseedFolderFlag.cs +++ b/Jellyfin.Server/Migrations/Routines/20250730215000_ReseedFolderFlag.cs @@ -24,7 +24,7 @@ internal class ReseedFolderFlag : IAsyncMigrationRoutine private readonly IDbContextFactory<JellyfinDbContext> _provider; public ReseedFolderFlag( - IStartupLogger<MigrateLibraryDb> startupLogger, + IStartupLogger<ReseedFolderFlag> startupLogger, IDbContextFactory<JellyfinDbContext> provider, IServerApplicationPaths paths) { diff --git a/Jellyfin.Server/Migrations/Routines/CleanMusicArtist.cs b/Jellyfin.Server/Migrations/Routines/20251009200000_CleanMusicArtist.cs similarity index 100% rename from Jellyfin.Server/Migrations/Routines/CleanMusicArtist.cs rename to Jellyfin.Server/Migrations/Routines/20251009200000_CleanMusicArtist.cs diff --git a/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs b/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs new file mode 100644 index 0000000000..8f239007d8 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs @@ -0,0 +1,746 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Extensions; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using LinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// Migrates LinkedChildren data from JSON Data column to the LinkedChildren table. +/// </summary> +[JellyfinMigration("2026-01-13T12:00:00", nameof(MigrateLinkedChildren))] +[JellyfinMigrationBackup(JellyfinDb = true)] +internal class MigrateLinkedChildren : IDatabaseMigrationRoutine +{ + private const int ParseProgressLogStep = 25_000; + private const int FileCheckProgressLogStep = 10_000; + private const int ResolveProgressLogStep = 10_000; + private const int DeleteProgressLogStep = 25; + + private readonly ILogger<MigrateLinkedChildren> _logger; + private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; + private readonly ILibraryManager _libraryManager; + private readonly IServerApplicationHost _appHost; + private readonly IServerApplicationPaths _appPaths; + + public MigrateLinkedChildren( + ILoggerFactory loggerFactory, + IDbContextFactory<JellyfinDbContext> dbProvider, + ILibraryManager libraryManager, + IServerApplicationHost appHost, + IServerApplicationPaths appPaths) + { + _logger = loggerFactory.CreateLogger<MigrateLinkedChildren>(); + _dbProvider = dbProvider; + _libraryManager = libraryManager; + _appHost = appHost; + _appPaths = appPaths; + } + + /// <inheritdoc/> + public void Perform() + { + using var context = _dbProvider.CreateDbContext(); + + var containerTypes = new[] + { + "MediaBrowser.Controller.Entities.Movies.BoxSet", + "MediaBrowser.Controller.Playlists.Playlist", + "MediaBrowser.Controller.Entities.CollectionFolder" + }; + + var videoTypes = new[] + { + "MediaBrowser.Controller.Entities.Video", + "MediaBrowser.Controller.Entities.Movies.Movie", + "MediaBrowser.Controller.Entities.TV.Episode" + }; + + var itemsWithData = context.BaseItems + .Where(b => b.Data != null && (containerTypes.Contains(b.Type) || videoTypes.Contains(b.Type))) + .Select(b => new { b.Id, b.Data, b.Type, b.Path, b.IsFolder }) + .ToList(); + + _logger.LogInformation("Found {Count} potential items with LinkedChildren data to process.", itemsWithData.Count); + + var pathToIdMap = context.BaseItems + .Where(b => b.Path != null) + .Select(b => new { b.Id, b.Path }) + .GroupBy(b => b.Path!) + .ToDictionary(g => g.Key, g => g.First().Id); + + // Needed to tell a stale cached ItemId apart from one that still points at a real item. + var allItemIds = context.BaseItems.Select(b => b.Id).ToHashSet(); + + var playlistParentIds = itemsWithData + .Where(b => b.Type == "MediaBrowser.Controller.Playlists.Playlist") + .Select(b => b.Id) + .ToHashSet(); + + var droppedChildren = 0; + var linkedChildrenToAdd = new List<LinkedChildEntity>(); + var processedCount = 0; + var totalItems = itemsWithData.Count; + + foreach (var item in itemsWithData) + { + if (string.IsNullOrEmpty(item.Data)) + { + continue; + } + + if (processedCount > 0 && processedCount % ParseProgressLogStep == 0) + { + _logger.LogInformation("Processing LinkedChildren: {Processed}/{Total} items", processedCount, totalItems); + } + + try + { + using var doc = JsonDocument.Parse(item.Data); + + var isVideo = videoTypes.Contains(item.Type); + + // Handle Video alternate versions + if (isVideo) + { + ProcessVideoAlternateVersions(doc.RootElement, item.Id, pathToIdMap, allItemIds, linkedChildrenToAdd); + } + + // Handle LinkedChildren (for containers and other items) + if (!doc.RootElement.TryGetProperty("LinkedChildren", out var linkedChildrenElement) || linkedChildrenElement.ValueKind != JsonValueKind.Array) + { + processedCount++; + continue; + } + + // Legacy entries may hold a path relative to the container that holds them, so the + // container's own location has to be a real path, not a virtual one. + var itemPath = item.Path is null ? null : _appHost.ExpandVirtualPath(item.Path); + var containingFolderPath = item.IsFolder ? itemPath : Path.GetDirectoryName(itemPath); + var sortOrder = 0; + foreach (var childElement in linkedChildrenElement.EnumerateArray()) + { + var childId = ResolveChildId(childElement, containingFolderPath, pathToIdMap, allItemIds); + if (!childId.HasValue) + { + droppedChildren++; + _logger.LogWarning( + "Dropping unresolvable LinkedChild of {ParentId}: ItemId {ItemId}, path {ChildPath}", + item.Id, + GetStringProperty(childElement, "ItemId") ?? "none", + GetStringProperty(childElement, "Path") ?? "none"); + continue; + } + + var childType = LinkedChildType.Manual; + if (childElement.TryGetProperty("Type", out var typeProp)) + { + if (typeProp.ValueKind == JsonValueKind.Number) + { + childType = (LinkedChildType)typeProp.GetInt32(); + } + else if (typeProp.ValueKind == JsonValueKind.String) + { + var typeStr = typeProp.GetString(); + if (Enum.TryParse<LinkedChildType>(typeStr, out var parsedType)) + { + childType = parsedType; + } + } + } + + linkedChildrenToAdd.Add(new LinkedChildEntity + { + ParentId = item.Id, + ChildId = childId.Value, + ChildType = childType, + SortOrder = sortOrder + }); + + sortOrder++; + } + + processedCount++; + } + catch (JsonException ex) + { + _logger.LogWarning(ex, "Failed to parse JSON for item {ItemId}", item.Id); + } + } + + if (linkedChildrenToAdd.Count > 0) + { + _logger.LogInformation("Inserting {Count} LinkedChildren records.", linkedChildrenToAdd.Count); + + var existingKeys = context.LinkedChildren + .Select(lc => new { lc.ParentId, lc.ChildId }) + .ToHashSet(); + + // A playlist may list the same child more than once, so it cannot be keyed by + // (ParentId, ChildId): skip a playlist wholesale if it already has rows instead, which + // keeps the routine re-runnable without collapsing repeated entries. + var populatedParentIds = context.LinkedChildren + .Select(lc => lc.ParentId) + .Distinct() + .ToHashSet(); + + var toInsert = linkedChildrenToAdd + .Where(lc => playlistParentIds.Contains(lc.ParentId) + ? !populatedParentIds.Contains(lc.ParentId) + : !existingKeys.Contains(new { lc.ParentId, lc.ChildId })) + .ToList(); + + if (toInsert.Count > 0) + { + // Every container type other than a playlist keeps a single entry per child. + // Priority: LocalAlternateVersion > LinkedAlternateVersion > Other + toInsert = + [ + .. toInsert.Where(lc => playlistParentIds.Contains(lc.ParentId)), + .. toInsert + .Where(lc => !playlistParentIds.Contains(lc.ParentId)) + .OrderBy(lc => lc.ChildType switch + { + LinkedChildType.LocalAlternateVersion => 0, + LinkedChildType.LinkedAlternateVersion => 1, + _ => 2 + }) + .DistinctBy(lc => new { lc.ParentId, lc.ChildId }) + ]; + + var childIds = toInsert.Select(lc => lc.ChildId).Distinct().ToList(); + var existingChildIds = context.BaseItems + .WhereOneOrMany(childIds, b => b.Id) + .Select(b => b.Id) + .ToHashSet(); + + toInsert = toInsert.Where(lc => existingChildIds.Contains(lc.ChildId)).ToList(); + + // Drop linked (user-merged) entries that point at items the parent owns (local + // file-based alternates or extras). These stem from legacy data that merged an + // owned item onto its own primary and would wrongly mark server-merged groups + // as user-merged (splittable). + var linkedChildIds = toInsert + .Where(lc => lc.ChildType == LinkedChildType.LinkedAlternateVersion) + .Select(lc => lc.ChildId) + .Distinct() + .ToList(); + + if (linkedChildIds.Count > 0) + { + var ownerIdByChildId = context.BaseItems + .WhereOneOrMany(linkedChildIds, b => b.Id) + .Where(b => b.OwnerId.HasValue) + .Select(b => new { b.Id, b.OwnerId }) + .ToDictionary(b => b.Id, b => b.OwnerId!.Value); + + var removedCount = toInsert.RemoveAll(lc => + lc.ChildType == LinkedChildType.LinkedAlternateVersion + && ownerIdByChildId.TryGetValue(lc.ChildId, out var ownerId) + && ownerId.Equals(lc.ParentId)); + + if (removedCount > 0) + { + _logger.LogInformation("Skipped {Count} LinkedAlternateVersion records pointing at items owned by their parent.", removedCount); + } + } + + context.LinkedChildren.AddRange(toInsert); + context.SaveChanges(); + + _logger.LogInformation("Successfully inserted {Count} LinkedChildren records.", toInsert.Count); + } + else + { + _logger.LogInformation("All LinkedChildren records already exist, nothing to insert."); + } + } + else + { + _logger.LogInformation("No LinkedChildren data found to migrate."); + } + + _logger.LogInformation( + "LinkedChildren migration completed. Processed {Count} items, dropped {DroppedCount} unresolvable children.", + processedCount, + droppedChildren); + + CleanupWrongTypeAlternateVersions(context); + CleanupOrphanedAlternateVersionBaseItems(context); + CleanupItemsFromDeletedLibraries(context); + CleanupStaleFileEntries(context); + CleanupOrphanedLinkedChildren(context); + } + + private void CleanupWrongTypeAlternateVersions(JellyfinDbContext context) + { + _logger.LogInformation("Cleaning up alternate version items with wrong type..."); + + // Find all LocalAlternateVersion relationships where the child is a generic Video + // but the parent is a more specific type (like Movie). + // Since IDs are computed from type + path, just updating the Type column would break ID lookups. + // Instead, delete them and let the runtime recreate them with the correct type during the next library scan. + var wrongTypeChildIds = context.LinkedChildren + .Where(lc => lc.ChildType == LinkedChildType.LocalAlternateVersion) + .Join( + context.BaseItems, + lc => lc.ParentId, + parent => parent.Id, + (lc, parent) => new { lc.ChildId, ParentType = parent.Type }) + .Join( + context.BaseItems, + x => x.ChildId, + child => child.Id, + (x, child) => new { x.ChildId, x.ParentType, ChildType = child.Type }) + .Where(x => x.ChildType != x.ParentType) + .Select(x => x.ChildId) + .Distinct() + .ToList(); + + if (wrongTypeChildIds.Count == 0) + { + _logger.LogInformation("No wrong-type alternate version items found."); + return; + } + + _logger.LogInformation("Found {Count} wrong-type alternate version items to remove.", wrongTypeChildIds.Count); + + var deleted = ResolveAndDeleteItems(wrongTypeChildIds, "wrong-type alternate version items"); + + _logger.LogInformation("Removed {Count} wrong-type alternate version items. They will be recreated with the correct type on next library scan.", deleted); + } + + private void CleanupOrphanedAlternateVersionBaseItems(JellyfinDbContext context) + { + _logger.LogInformation("Starting cleanup of orphaned alternate version BaseItems..."); + + // Find BaseItems that have OwnerId set (they belonged to another item) and are not extras, + // but no LinkedChild entry references them — meaning they're orphaned alternate versions. + // This happens when a version file is renamed: the old BaseItem remains in the DB + // with a stale OwnerId but nothing links to it anymore. + var orphanedVersionIds = context.BaseItems + .Where(b => b.OwnerId.HasValue && b.ExtraType == null) + .Where(b => !context.LinkedChildren.Any(lc => lc.ChildId.Equals(b.Id))) + .Select(b => b.Id) + .ToList(); + + if (orphanedVersionIds.Count == 0) + { + _logger.LogInformation("No orphaned alternate version BaseItems found."); + return; + } + + _logger.LogInformation("Found {Count} orphaned alternate version BaseItems to remove.", orphanedVersionIds.Count); + + var deleted = ResolveAndDeleteItems(orphanedVersionIds, "orphaned alternate version BaseItems"); + + _logger.LogInformation("Removed {Count} orphaned alternate version BaseItems.", deleted); + } + + private void CleanupItemsFromDeletedLibraries(JellyfinDbContext context) + { + _logger.LogInformation("Starting cleanup of items from deleted libraries..."); + + // Find BaseItems whose TopParentId points to a library (collection folder) that no longer exists. + // This happens when a library is removed but the scan didn't fully clean up all items under it. + var orphanedIds = context.BaseItems + .Where(b => b.TopParentId.HasValue) + .Where(b => !context.BaseItems.Any(lib => lib.Id.Equals(b.TopParentId!.Value))) + .Select(b => b.Id) + .ToList(); + + if (orphanedIds.Count == 0) + { + _logger.LogInformation("No items from deleted libraries found."); + return; + } + + _logger.LogInformation("Found {Count} items from deleted libraries to remove.", orphanedIds.Count); + + var deleted = ResolveAndDeleteItems(orphanedIds, "items from deleted libraries"); + + _logger.LogInformation("Removed {Count} items from deleted libraries.", deleted); + } + + private void CleanupStaleFileEntries(JellyfinDbContext context) + { + _logger.LogInformation("Starting cleanup of items with missing files..."); + + // Get all library media locations and partition into accessible vs inaccessible. + // This mirrors the scanner's safeguard: if a library root is inaccessible + // (e.g. NAS offline), we skip items under it to avoid false deletions. + var virtualFolders = _libraryManager.GetVirtualFolders(); + var accessiblePaths = new List<string>(); + var inaccessiblePaths = new List<string>(); + + foreach (var folder in virtualFolders) + { + foreach (var location in folder.Locations) + { + if (Directory.Exists(location) && Directory.EnumerateFileSystemEntries(location).Any()) + { + accessiblePaths.Add(location); + } + else + { + inaccessiblePaths.Add(location); + _logger.LogWarning( + "Library location {Path} is inaccessible or empty, skipping file existence checks for items under this path.", + location); + } + } + } + + var allLibraryPaths = accessiblePaths.Concat(inaccessiblePaths).ToList(); + + // Get all non-folder, non-virtual items with paths from the DB + var itemsWithPaths = context.BaseItems + .Where(b => b.Path != null && b.Path != string.Empty) + .Where(b => !b.IsFolder && !b.IsVirtualItem) + .Select(b => new { b.Id, b.Path }) + .ToList(); + + var internalMetadataPath = _appPaths.InternalMetadataPath; + + // An item outside every library location is normally left over from a removed media path, but + // it looks exactly the same as one whose storage failed to mount (a wrong bind mount on the + // first container start, for example). Only act on it while every location is readable. + var canRemoveUnrootedItems = inaccessiblePaths.Count == 0; + var skippedUnrootedItems = 0; + + var staleIds = new List<Guid>(); + var checkedCount = 0; + _logger.LogInformation("Checking {Total} items for missing files.", itemsWithPaths.Count); + + foreach (var item in itemsWithPaths) + { + // A miss on offline storage can block for the mount timeout, so report while scanning. + if (checkedCount > 0 && checkedCount % FileCheckProgressLogStep == 0) + { + _logger.LogInformation( + "Checking for missing files: {Checked}/{Total} items, {Stale} stale so far.", + checkedCount, + itemsWithPaths.Count, + staleIds.Count); + } + + checkedCount++; + + // Expand virtual path placeholders (%AppDataPath%, %MetadataPath%) to real paths + var path = _appHost.ExpandVirtualPath(item.Path!); + + // Skip items stored under internal metadata (images, subtitles, trickplay, etc.) + if (path.StartsWith(internalMetadataPath, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (accessiblePaths.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase))) + { + // Item is under an accessible library location — check if it still exists + // Directory check covers BDMV/DVD items whose Path points to a folder + if (!File.Exists(path) && !Directory.Exists(path)) + { + _logger.LogDebug("Removing item {ItemId}: file {Path} no longer exists.", item.Id, path); + staleIds.Add(item.Id); + } + } + else if (!allLibraryPaths.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase))) + { + // Item is not under ANY library location (accessible or not) — + // it's orphaned from all libraries (e.g. media path was removed from config) + if (canRemoveUnrootedItems) + { + _logger.LogDebug("Removing item {ItemId}: path {Path} is outside every library location.", item.Id, path); + staleIds.Add(item.Id); + } + else + { + skippedUnrootedItems++; + } + } + + // Otherwise: item is under an inaccessible location — skip (storage may be offline) + } + + if (skippedUnrootedItems > 0) + { + _logger.LogWarning( + "Keeping {Count} items that are outside every library location because {LocationCount} library location(s) are currently unavailable.", + skippedUnrootedItems, + inaccessiblePaths.Count); + } + + if (staleIds.Count == 0) + { + _logger.LogInformation("No stale items found."); + return; + } + + _logger.LogInformation("Found {Count} stale items to remove.", staleIds.Count); + + var deleted = ResolveAndDeleteItems(staleIds, "items with missing files"); + + _logger.LogInformation("Removed {Count} stale items.", deleted); + } + + private int ResolveAndDeleteItems(IReadOnlyCollection<Guid> ids, string description) + { + if (ids.Count == 0) + { + return 0; + } + + return DeleteItems(ResolveItems(ids, description), description); + } + + private List<BaseItem> ResolveItems(IReadOnlyCollection<Guid> ids, string description) + { + // Each lookup is a separate repository read; cached ones are fast, so this only reports + // once a set is large enough for the reads to add up to a noticeable stretch. + var items = new List<BaseItem>(ids.Count); + var processed = 0; + foreach (var id in ids) + { + if (processed > 0 && processed % ResolveProgressLogStep == 0) + { + _logger.LogInformation("Loading {Description}: {Processed}/{Total} items", description, processed, ids.Count); + } + + processed++; + + var item = _libraryManager.GetItemById(id); + if (item is not null) + { + items.Add(item); + } + } + + return items; + } + + private int DeleteItems(IReadOnlyCollection<BaseItem> items, string description) + { + if (items.Count == 0) + { + return 0; + } + + var options = new DeleteOptions { DeleteFileLocation = false, DeleteFromExternalProvider = false }; + var deleted = 0; + var processed = 0; + foreach (var item in items) + { + if (processed > 0 && processed % DeleteProgressLogStep == 0) + { + _logger.LogInformation("Removing {Description}: {Processed}/{Total} items", description, processed, items.Count); + } + + processed++; + + try + { + _libraryManager.DeleteItem(item, options); + deleted++; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Skipping item {ItemId} ({ItemName}): delete failed.", item.Id, item.Name ?? "Unknown"); + } + } + + return deleted; + } + + private void CleanupOrphanedLinkedChildren(JellyfinDbContext context) + { + _logger.LogInformation("Starting cleanup of orphaned LinkedChildren records..."); + + // Find all LinkedChildren where the ChildId doesn't exist in BaseItems + var orphanedLinkedChildren = context.LinkedChildren + .Where(lc => !context.BaseItems.Any(b => b.Id.Equals(lc.ChildId))) + .ToList(); + + if (orphanedLinkedChildren.Count == 0) + { + _logger.LogInformation("No orphaned LinkedChildren found."); + return; + } + + _logger.LogInformation("Found {Count} orphaned LinkedChildren records to remove.", orphanedLinkedChildren.Count); + + var orphanedByParent = context.LinkedChildren + .Where(lc => !context.BaseItems.Any(b => b.Id.Equals(lc.ParentId))) + .ToList(); + + if (orphanedByParent.Count > 0) + { + _logger.LogInformation("Found {Count} LinkedChildren with non-existent parent.", orphanedByParent.Count); + orphanedLinkedChildren.AddRange(orphanedByParent); + } + + // Remove all orphaned records. Both queries can return the same row, and a playlist may hold + // several rows for one child, so the position is what identifies an entry here. + var distinctOrphaned = orphanedLinkedChildren.DistinctBy(lc => new { lc.ParentId, lc.SortOrder }).ToList(); + context.LinkedChildren.RemoveRange(distinctOrphaned); + context.SaveChanges(); + + _logger.LogInformation("Successfully removed {Count} orphaned LinkedChildren records.", distinctOrphaned.Count); + } + + /// <summary> + /// Resolves the item a legacy LinkedChild entry points at. + /// </summary> + private static Guid? ResolveChildId( + JsonElement childElement, + string? containingFolderPath, + Dictionary<string, Guid> pathToIdMap, + HashSet<Guid> allItemIds) + { + // Pre-12 data only cached ItemId and re-resolved it from the path whenever the cached value + // went stale (BaseItem.GetLinkedChild in 10.x). An id that no longer exists must therefore + // fall through to the path, or the entry is lost even though its file is still in the library. + if (TryGetGuidProperty(childElement, "ItemId", out var itemId) && allItemIds.Contains(itemId)) + { + return itemId; + } + + var path = GetStringProperty(childElement, "Path"); + if (!string.IsNullOrEmpty(path)) + { + if (pathToIdMap.TryGetValue(path, out var idByPath)) + { + return idByPath; + } + + // 10.x resolved entries relative to the container that holds them. + if (!Path.IsPathRooted(path) && !string.IsNullOrEmpty(containingFolderPath)) + { + string? absolutePath = null; + try + { + absolutePath = Path.GetFullPath(Path.Combine(containingFolderPath, path)); + } + catch (ArgumentException) + { + // Malformed path, nothing to resolve. + } + + if (absolutePath is not null && pathToIdMap.TryGetValue(absolutePath, out var idByAbsolutePath)) + { + return idByAbsolutePath; + } + } + } + + if (TryGetGuidProperty(childElement, "LibraryItemId", out var libraryItemId) && allItemIds.Contains(libraryItemId)) + { + return libraryItemId; + } + + return null; + } + + private static string? GetStringProperty(JsonElement element, string propertyName) + => element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String + ? property.GetString() + : null; + + private static bool TryGetGuidProperty(JsonElement element, string propertyName, out Guid value) + { + value = Guid.Empty; + var raw = GetStringProperty(element, propertyName); + + return !string.IsNullOrEmpty(raw) && Guid.TryParse(raw, out value) && !value.IsEmpty(); + } + + private void ProcessVideoAlternateVersions( + JsonElement root, + Guid parentId, + Dictionary<string, Guid> pathToIdMap, + HashSet<Guid> allItemIds, + List<LinkedChildEntity> linkedChildrenToAdd) + { + int sortOrder = 0; + + if (root.TryGetProperty("LocalAlternateVersions", out var localAlternateVersionsElement) + && localAlternateVersionsElement.ValueKind == JsonValueKind.Array) + { + foreach (var pathElement in localAlternateVersionsElement.EnumerateArray()) + { + if (pathElement.ValueKind != JsonValueKind.String) + { + continue; + } + + var path = pathElement.GetString(); + if (string.IsNullOrEmpty(path)) + { + continue; + } + + // Try to resolve the path to an ItemId + if (pathToIdMap.TryGetValue(path, out var childId)) + { + linkedChildrenToAdd.Add(new LinkedChildEntity + { + ParentId = parentId, + ChildId = childId, + ChildType = LinkedChildType.LocalAlternateVersion, + SortOrder = sortOrder++ + }); + + _logger.LogDebug( + "Migrating LocalAlternateVersion: Parent={ParentId}, Child={ChildId}, Path={Path}", + parentId, + childId, + path); + } + else + { + _logger.LogWarning( + "Could not resolve LocalAlternateVersion path to ItemId: {Path} for parent {ParentId}", + path, + parentId); + } + } + } + + if (root.TryGetProperty("LinkedAlternateVersions", out var linkedAlternateVersionsElement) + && linkedAlternateVersionsElement.ValueKind == JsonValueKind.Array) + { + foreach (var linkedChildElement in linkedAlternateVersionsElement.EnumerateArray()) + { + var childId = ResolveChildId(linkedChildElement, null, pathToIdMap, allItemIds); + if (!childId.HasValue) + { + _logger.LogWarning("Could not resolve LinkedAlternateVersion child ID for parent {ParentId}", parentId); + continue; + } + + linkedChildrenToAdd.Add(new LinkedChildEntity + { + ParentId = parentId, + ChildId = childId.Value, + ChildType = LinkedChildType.LinkedAlternateVersion, + SortOrder = sortOrder++ + }); + + _logger.LogDebug( + "Migrating LinkedAlternateVersion: Parent={ParentId}, Child={ChildId}", + parentId, + childId.Value); + } + } + } +} diff --git a/Jellyfin.Server/Migrations/Routines/20260113230000_CleanupOrphanedExtras.cs b/Jellyfin.Server/Migrations/Routines/20260113230000_CleanupOrphanedExtras.cs new file mode 100644 index 0000000000..e8eeb2da20 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260113230000_CleanupOrphanedExtras.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using Jellyfin.Server.Implementations.Item; +using Jellyfin.Server.Migrations.Stages; +using Jellyfin.Server.ServerSetupApp; +using MediaBrowser.Controller.Channels; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.MediaSegments; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Model.IO; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// Removes orphaned extras (items with OwnerId pointing to non-existent items). +/// Must run before EF migrations that add FK constraints on OwnerId. +/// </summary> +[JellyfinMigration("2026-01-13T23:00:00", nameof(CleanupOrphanedExtras), Stage = JellyfinMigrationStageTypes.AppInitialisation)] +[JellyfinMigrationBackup(JellyfinDb = true)] +public class CleanupOrphanedExtras : IAsyncMigrationRoutine +{ + private readonly IStartupLogger<CleanupOrphanedExtras> _logger; + private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory; + private readonly ILibraryManager _libraryManager; + + /// <summary> + /// Initializes a new instance of the <see cref="CleanupOrphanedExtras"/> class. + /// </summary> + /// <param name="logger">The startup logger.</param> + /// <param name="dbContextFactory">The database context factory.</param> + /// <param name="libraryManager">The library manager.</param> + public CleanupOrphanedExtras( + IStartupLogger<CleanupOrphanedExtras> logger, + IDbContextFactory<JellyfinDbContext> dbContextFactory, + ILibraryManager libraryManager) + { + _logger = logger; + _dbContextFactory = dbContextFactory; + _libraryManager = libraryManager; + } + + /// <inheritdoc/> + public async Task PerformAsync(CancellationToken cancellationToken) + { + var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (context.ConfigureAwait(false)) + { + var placeholderOwner = Guid.Parse("00000000-0000-0000-0000-000000000001"); +#pragma warning disable RS0030 // Do not use banned APIs + var orphanedItemIds = await context.BaseItems + .Where(b => b.OwnerId.HasValue && b.OwnerId == placeholderOwner) + .Select(b => new + { + b.Id, + b.Path, + b.Type + }) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); +#pragma warning restore RS0030 // Do not use banned APIs + + if (orphanedItemIds.Count == 0) + { + _logger.LogInformation("No orphaned extras found, skipping migration."); + return; + } + + _logger.LogInformation("Found {Count} orphaned extras to remove", orphanedItemIds.Count); + + // Resolve items for metadata path cleanup, then delete in batches so we never issue one + // massive delete transaction and progress stays visible on large libraries. + _logger.LogInformation("Deleting {Count} orphaned extras...", orphanedItemIds.Count); + const int deleteBatchSize = 500; + var deletedSoFar = 0; + for (var offset = 0; offset < orphanedItemIds.Count; offset += deleteBatchSize) + { + cancellationToken.ThrowIfCancellationRequested(); + + var batch = orphanedItemIds.GetRange(offset, Math.Min(deleteBatchSize, orphanedItemIds.Count - offset)); + var itemsToDelete = batch + .Select(itemId => BaseItemMapper.DeserializeBaseItem( + new Database.Implementations.Entities.BaseItemEntity() + { + Id = itemId.Id, + Path = itemId.Path, + Type = itemId.Type + }, + _logger, + null, + true)!) + .ToList(); + + _libraryManager.DeleteItemsUnsafeFast(itemsToDelete); + + deletedSoFar += batch.Count; + _logger.LogInformation("Deleting orphaned extras: {Deleted}/{Total}", deletedSoFar, orphanedItemIds.Count); + } + + _logger.LogInformation("Successfully removed {Count} orphaned extras", orphanedItemIds.Count); + } + } +} diff --git a/Jellyfin.Server/Migrations/Routines/20260115120000_FixIncorrectOwnerIdRelationships.cs b/Jellyfin.Server/Migrations/Routines/20260115120000_FixIncorrectOwnerIdRelationships.cs new file mode 100644 index 0000000000..e34182fd5d --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260115120000_FixIncorrectOwnerIdRelationships.cs @@ -0,0 +1,360 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using Jellyfin.Server.ServerSetupApp; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// Fixes incorrect OwnerId relationships where video/movie items are children of other video/movie items. +/// These are alternate versions (4K vs 1080p) that were incorrectly linked as parent-child relationships +/// by the auto-merge logic. Only legitimate extras (trailers, behind-the-scenes) should have OwnerId set. +/// Also removes duplicate database entries for the same file path. +/// </summary> +[JellyfinMigration("2026-01-15T12:00:00", nameof(FixIncorrectOwnerIdRelationships))] +[JellyfinMigrationBackup(JellyfinDb = true)] +public class FixIncorrectOwnerIdRelationships : IAsyncMigrationRoutine +{ + private readonly IStartupLogger<FixIncorrectOwnerIdRelationships> _logger; + private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory; + private readonly ILibraryManager _libraryManager; + private readonly IItemPersistenceService _persistenceService; + + /// <summary> + /// Initializes a new instance of the <see cref="FixIncorrectOwnerIdRelationships"/> class. + /// </summary> + /// <param name="logger">The startup logger.</param> + /// <param name="dbContextFactory">The database context factory.</param> + /// <param name="libraryManager">The library manager.</param> + /// <param name="persistenceService">The item persistence service.</param> + public FixIncorrectOwnerIdRelationships( + IStartupLogger<FixIncorrectOwnerIdRelationships> logger, + IDbContextFactory<JellyfinDbContext> dbContextFactory, + ILibraryManager libraryManager, + IItemPersistenceService persistenceService) + { + _logger = logger; + _dbContextFactory = dbContextFactory; + _libraryManager = libraryManager; + _persistenceService = persistenceService; + } + + /// <inheritdoc/> + public async Task PerformAsync(CancellationToken cancellationToken) + { + var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (context.ConfigureAwait(false)) + { + // Step 1: Find and remove duplicate database entries (same Path, different IDs) + await RemoveDuplicateItemsAsync(context, cancellationToken).ConfigureAwait(false); + + // Step 2: Clear incorrect OwnerId for video/movie items that are children of other video/movie items + await ClearIncorrectOwnerIdsAsync(context, cancellationToken).ConfigureAwait(false); + + // Step 3: Reassign orphaned extras to correct parents + await ReassignOrphanedExtrasAsync(context, cancellationToken).ConfigureAwait(false); + + // Step 4: Populate PrimaryVersionId for alternate version children + await PopulatePrimaryVersionIdAsync(context, cancellationToken).ConfigureAwait(false); + } + } + + private async Task RemoveDuplicateItemsAsync(JellyfinDbContext context, CancellationToken cancellationToken) + { + // Find all paths that have duplicate entries + var duplicatePaths = await context.BaseItems + .Where(b => b.Path != null) + .GroupBy(b => b.Path) + .Where(g => g.Count() > 1) + .Select(g => g.Key) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + if (duplicatePaths.Count == 0) + { + _logger.LogInformation("No duplicate items found, skipping duplicate removal."); + return; + } + + _logger.LogInformation("Found {Count} paths with duplicate database entries", duplicatePaths.Count); + + // Collect all duplicate IDs to delete in one batch + var allIdsToDelete = new List<Guid>(); + const int progressLogStep = 500; + var processedPaths = 0; + foreach (var path in duplicatePaths) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (processedPaths > 0 && processedPaths % progressLogStep == 0) + { + _logger.LogInformation("Resolving duplicates: {Processed}/{Total} paths", processedPaths, duplicatePaths.Count); + } + + processedPaths++; + + // Get all items with this path + var itemsWithPath = await context.BaseItems + .Where(b => b.Path == path) + .Select(b => new + { + b.Id, + b.Type, + b.DateCreated, + HasOwnedExtras = context.BaseItems.Any(c => c.OwnerId.HasValue && c.OwnerId.Value.Equals(b.Id)), + HasDirectChildren = context.BaseItems.Any(c => c.ParentId.HasValue && c.ParentId.Value.Equals(b.Id)) + }) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + if (itemsWithPath.Count <= 1) + { + continue; + } + + // Keep the item that has direct children, then owned extras, then prefer non-Folder types, then newest + var itemToKeep = itemsWithPath + .OrderByDescending(i => i.HasDirectChildren) + .ThenByDescending(i => i.HasOwnedExtras) + .ThenByDescending(i => i.Type != "MediaBrowser.Controller.Entities.Folder") + .ThenByDescending(i => i.DateCreated) + .First(); + if (itemToKeep is null) + { + continue; + } + + allIdsToDelete.AddRange(itemsWithPath.Where(i => !i.Id.Equals(itemToKeep.Id)).Select(i => i.Id)); + } + + if (allIdsToDelete.Count > 0) + { + _logger.LogInformation("Deleting {Count} duplicate database entries...", allIdsToDelete.Count); + + // Delete in batches so progress is visible (item resolution and deletion can take a + // long time on large libraries) and so we never issue one massive delete transaction. + const int deleteBatchSize = 500; + var deletedSoFar = 0; + for (var offset = 0; offset < allIdsToDelete.Count; offset += deleteBatchSize) + { + cancellationToken.ThrowIfCancellationRequested(); + + var batchIds = allIdsToDelete.GetRange(offset, Math.Min(deleteBatchSize, allIdsToDelete.Count - offset)); + + // Resolve items for metadata path cleanup, then delete this batch + var itemsToDelete = batchIds + .Select(id => _libraryManager.GetItemById(id)) + .Where(item => item is not null) + .ToList(); + if (itemsToDelete.Count > 0) + { + _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!); + } + + // Fall back to direct DB deletion for any items that couldn't be resolved via LibraryManager + var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet(); + var unresolvedIds = batchIds.Where(id => !deletedIds.Contains(id)).ToList(); + if (unresolvedIds.Count > 0) + { + _persistenceService.DeleteItem(unresolvedIds); + } + + deletedSoFar += batchIds.Count; + _logger.LogInformation("Deleting duplicates: {Deleted}/{Total} items", deletedSoFar, allIdsToDelete.Count); + } + } + + _logger.LogInformation("Successfully removed {Count} duplicate database entries", allIdsToDelete.Count); + } + + private async Task ClearIncorrectOwnerIdsAsync(JellyfinDbContext context, CancellationToken cancellationToken) + { + // Find video/movie items with incorrect OwnerId (ExtraType is NULL or 0, pointing to another video/movie) + var incorrectChildrenWithParent = await context.BaseItems + .Where(b => b.OwnerId.HasValue + && (b.ExtraType == null || b.ExtraType == 0) + && (b.Type == "MediaBrowser.Controller.Entities.Video" || b.Type == "MediaBrowser.Controller.Entities.Movies.Movie")) + .Where(b => context.BaseItems.Any(parent => + parent.Id.Equals(b.OwnerId!.Value) + && (parent.Type == "MediaBrowser.Controller.Entities.Video" || parent.Type == "MediaBrowser.Controller.Entities.Movies.Movie"))) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + // Also find orphaned items (parent doesn't exist) + var orphanedChildren = await context.BaseItems + .Where(b => b.OwnerId.HasValue + && (b.ExtraType == null || b.ExtraType == 0) + && (b.Type == "MediaBrowser.Controller.Entities.Video" || b.Type == "MediaBrowser.Controller.Entities.Movies.Movie")) + .Where(b => !context.BaseItems.Any(parent => parent.Id.Equals(b.OwnerId!.Value))) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + var totalIncorrect = incorrectChildrenWithParent.Count + orphanedChildren.Count; + if (totalIncorrect == 0) + { + _logger.LogInformation("No items with incorrect OwnerId found, skipping OwnerId cleanup."); + return; + } + + _logger.LogInformation( + "Found {Count} video/movie items with incorrect OwnerId relationships ({WithParent} with parent, {Orphaned} orphaned)", + totalIncorrect, + incorrectChildrenWithParent.Count, + orphanedChildren.Count); + + // Clear OwnerId for all incorrect items + var allIncorrectItems = incorrectChildrenWithParent.Concat(orphanedChildren).ToList(); + foreach (var item in allIncorrectItems) + { + item.OwnerId = null; + } + + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + _logger.LogInformation("Successfully cleared OwnerId for {Count} items", totalIncorrect); + } + + private async Task ReassignOrphanedExtrasAsync(JellyfinDbContext context, CancellationToken cancellationToken) + { + // Find extras whose parent was deleted during duplicate removal + var orphanedExtras = await context.BaseItems + .Where(b => b.ExtraType != null && b.ExtraType != 0 && b.OwnerId.HasValue) + .Where(b => !context.BaseItems.Any(parent => parent.Id.Equals(b.OwnerId!.Value))) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + if (orphanedExtras.Count == 0) + { + _logger.LogInformation("No orphaned extras found, skipping reassignment."); + return; + } + + _logger.LogInformation("Found {Count} orphaned extras to reassign", orphanedExtras.Count); + const int extraProgressLogStep = 500; + + // Build a lookup of directory -> first video/movie item for parent resolution + var extraDirectories = orphanedExtras + .Where(e => !string.IsNullOrEmpty(e.Path)) + .Select(e => System.IO.Path.GetDirectoryName(e.Path)) + .Where(d => !string.IsNullOrEmpty(d)) + .Distinct() + .ToList(); + + // Load all potential parent video/movies with paths in one query + var videoTypes = new[] + { + "MediaBrowser.Controller.Entities.Video", + "MediaBrowser.Controller.Entities.Movies.Movie" + }; + var potentialParents = await context.BaseItems + .Where(b => b.Path != null && videoTypes.Contains(b.Type)) + .Select(b => new { b.Id, b.Path }) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + // Build directory -> parent ID mapping + var dirToParent = new Dictionary<string, Guid>(); + foreach (var dir in extraDirectories) + { + var parent = potentialParents + .Where(p => p.Path!.StartsWith(dir!, StringComparison.OrdinalIgnoreCase)) + .OrderBy(p => p.Id) + .FirstOrDefault(); + if (parent is not null) + { + dirToParent[dir!] = parent.Id; + } + } + + var reassignedCount = 0; + var processedExtras = 0; + foreach (var extra in orphanedExtras) + { + if (processedExtras > 0 && processedExtras % extraProgressLogStep == 0) + { + _logger.LogInformation("Reassigning orphaned extras: {Processed}/{Total}", processedExtras, orphanedExtras.Count); + } + + processedExtras++; + + if (string.IsNullOrEmpty(extra.Path)) + { + continue; + } + + var extraDirectory = System.IO.Path.GetDirectoryName(extra.Path); + if (!string.IsNullOrEmpty(extraDirectory) && dirToParent.TryGetValue(extraDirectory, out var parentId)) + { + extra.OwnerId = parentId; + reassignedCount++; + } + else + { + extra.OwnerId = null; + } + } + + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + _logger.LogInformation("Successfully reassigned {Count} orphaned extras", reassignedCount); + } + + private async Task PopulatePrimaryVersionIdAsync(JellyfinDbContext context, CancellationToken cancellationToken) + { + // Find all alternate version relationships where child's PrimaryVersionId is not set + // ChildType 2 = LocalAlternateVersion, ChildType 3 = LinkedAlternateVersion + var alternateVersionLinks = await context.LinkedChildren + .Where(lc => (lc.ChildType == Jellyfin.Database.Implementations.Entities.LinkedChildType.LocalAlternateVersion + || lc.ChildType == Jellyfin.Database.Implementations.Entities.LinkedChildType.LinkedAlternateVersion)) + .Join( + context.BaseItems, + lc => lc.ChildId, + item => item.Id, + (lc, item) => new { lc.ParentId, lc.ChildId, item.PrimaryVersionId }) + .Where(x => !x.PrimaryVersionId.HasValue || !x.PrimaryVersionId.Value.Equals(x.ParentId)) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + if (alternateVersionLinks.Count == 0) + { + _logger.LogInformation("No alternate version items need PrimaryVersionId population, skipping."); + return; + } + + _logger.LogInformation("Found {Count} alternate version items that need PrimaryVersionId populated", alternateVersionLinks.Count); + + // Batch-load all child items in a single query + var childIds = alternateVersionLinks.Select(l => l.ChildId).Distinct().ToList(); + var childItems = await context.BaseItems + .WhereOneOrMany(childIds, b => b.Id) + .ToDictionaryAsync(b => b.Id, cancellationToken) + .ConfigureAwait(false); + + var updatedCount = 0; + const int linkProgressLogStep = 1000; + var processedLinks = 0; + foreach (var link in alternateVersionLinks) + { + if (processedLinks > 0 && processedLinks % linkProgressLogStep == 0) + { + _logger.LogInformation("Populating PrimaryVersionId: {Processed}/{Total} links", processedLinks, alternateVersionLinks.Count); + } + + processedLinks++; + + if (childItems.TryGetValue(link.ChildId, out var childItem)) + { + childItem.PrimaryVersionId = link.ParentId; + updatedCount++; + } + } + + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + _logger.LogInformation("Successfully populated PrimaryVersionId for {Count} alternate version items", updatedCount); + } +} diff --git a/Jellyfin.Server/Migrations/Routines/FixLibrarySubtitleDownloadLanguages.cs b/Jellyfin.Server/Migrations/Routines/20260206200000_FixLibrarySubtitleDownloadLanguages.cs similarity index 96% rename from Jellyfin.Server/Migrations/Routines/FixLibrarySubtitleDownloadLanguages.cs rename to Jellyfin.Server/Migrations/Routines/20260206200000_FixLibrarySubtitleDownloadLanguages.cs index e82123e5ac..2b1f549940 100644 --- a/Jellyfin.Server/Migrations/Routines/FixLibrarySubtitleDownloadLanguages.cs +++ b/Jellyfin.Server/Migrations/Routines/20260206200000_FixLibrarySubtitleDownloadLanguages.cs @@ -7,7 +7,6 @@ using Jellyfin.Server.ServerSetupApp; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Globalization; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace Jellyfin.Server.Migrations.Routines; @@ -50,7 +49,7 @@ internal class FixLibrarySubtitleDownloadLanguages : IAsyncMigrationRoutine foreach (var virtualFolder in virtualFolders) { var options = virtualFolder.LibraryOptions; - if (options.SubtitleDownloadLanguages is null || options.SubtitleDownloadLanguages.Length == 0) + if (options?.SubtitleDownloadLanguages is null || options.SubtitleDownloadLanguages.Length == 0) { continue; } diff --git a/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs b/Jellyfin.Server/Migrations/Routines/20260302090000_MigrateRatingLevels.cs similarity index 97% rename from Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs rename to Jellyfin.Server/Migrations/Routines/20260302090000_MigrateRatingLevels.cs index 2a6db01cf3..ed92c34aa3 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs +++ b/Jellyfin.Server/Migrations/Routines/20260302090000_MigrateRatingLevels.cs @@ -1,4 +1,3 @@ -using System; using System.Linq; using Jellyfin.Database.Implementations; using Jellyfin.Server.ServerSetupApp; @@ -12,7 +11,7 @@ namespace Jellyfin.Server.Migrations.Routines; /// Migrate rating levels. /// </summary> #pragma warning disable CS0618 // Type or member is obsolete -[JellyfinMigration("2025-04-20T22:00:00", nameof(MigrateRatingLevels))] +[JellyfinMigration("2026-03-02T09:00:00", nameof(MigrateRatingLevels))] [JellyfinMigrationBackup(JellyfinDb = true)] #pragma warning restore CS0618 // Type or member is obsolete internal class MigrateRatingLevels : IDatabaseMigrationRoutine diff --git a/Jellyfin.Server/Migrations/Routines/20260508120000_MergeDuplicateMusicArtists.cs b/Jellyfin.Server/Migrations/Routines/20260508120000_MergeDuplicateMusicArtists.cs new file mode 100644 index 0000000000..bff6ebbfb0 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260508120000_MergeDuplicateMusicArtists.cs @@ -0,0 +1,216 @@ +#pragma warning disable RS0030 // Do not use banned APIs + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using Jellyfin.Server.ServerSetupApp; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// Merges MusicArtist records that differ only by Name casing. Prior to the case-insensitive +/// dedup lookup added alongside this migration, the artist validator would create a second +/// MusicArtist whenever a track tagged the artist with a different casing than the +/// resolver-created one (e.g. "Thirty Seconds To Mars" vs. "Thirty Seconds to Mars"). +/// </summary> +[JellyfinMigration("2026-05-08T12:00:00", nameof(MergeDuplicateMusicArtists))] +[JellyfinMigrationBackup(JellyfinDb = true)] +public class MergeDuplicateMusicArtists : IAsyncMigrationRoutine +{ + private const string MusicArtistType = "MediaBrowser.Controller.Entities.Audio.MusicArtist"; + + private readonly IStartupLogger<MergeDuplicateMusicArtists> _logger; + private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory; + private readonly ILibraryManager _libraryManager; + private readonly IItemPersistenceService _persistenceService; + + /// <summary> + /// Initializes a new instance of the <see cref="MergeDuplicateMusicArtists"/> class. + /// </summary> + /// <param name="logger">The startup logger.</param> + /// <param name="dbContextFactory">The database context factory.</param> + /// <param name="libraryManager">The library manager.</param> + /// <param name="persistenceService">The item persistence service.</param> + public MergeDuplicateMusicArtists( + IStartupLogger<MergeDuplicateMusicArtists> logger, + IDbContextFactory<JellyfinDbContext> dbContextFactory, + ILibraryManager libraryManager, + IItemPersistenceService persistenceService) + { + _logger = logger; + _dbContextFactory = dbContextFactory; + _libraryManager = libraryManager; + _persistenceService = persistenceService; + } + + /// <inheritdoc/> + public async Task PerformAsync(CancellationToken cancellationToken) + { + var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (context.ConfigureAwait(false)) + { + var artists = await context.BaseItems + .Where(b => b.Type == MusicArtistType && b.Name != null) + .Select(b => new { b.Id, b.Name, b.DateCreated }) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + var groups = artists + .GroupBy(a => a.Name!.ToLowerInvariant()) + .Where(g => g.Count() > 1) + .ToList(); + + if (groups.Count == 0) + { + _logger.LogInformation("No case-only duplicate MusicArtist records found."); + return; + } + + _logger.LogInformation("Found {Count} groups of case-only duplicate MusicArtist records.", groups.Count); + + var idsToDelete = new List<Guid>(); + foreach (var group in groups) + { + cancellationToken.ThrowIfCancellationRequested(); + + var groupIds = group.Select(g => g.Id).ToArray(); + + // Pick the keeper: the artist with the most child references is the "real" one + // (the resolver-created artist with a filesystem path); the duplicates are usually + // empty stubs created by the validator's case-sensitive miss. + var stats = await context.BaseItems + .Where(b => groupIds.Contains(b.Id)) + .Select(b => new + { + b.Id, + b.Name, + b.DateCreated, + ChildCount = context.BaseItems.Count(c => c.ParentId == b.Id), + AncestorCount = context.AncestorIds.Count(a => a.ParentItemId == b.Id), + LinkedCount = context.LinkedChildren.Count(l => l.ParentId == b.Id || l.ChildId == b.Id), + }) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + var keeper = stats + .OrderByDescending(s => s.ChildCount) + .ThenByDescending(s => s.AncestorCount) + .ThenByDescending(s => s.LinkedCount) + .ThenBy(s => s.DateCreated) + .First(); + + foreach (var dup in stats.Where(s => s.Id != keeper.Id)) + { + var keeperId = keeper.Id; + var dupId = dup.Id; + + await context.BaseItems + .Where(b => b.ParentId == dupId) + .ExecuteUpdateAsync(s => s.SetProperty(b => b.ParentId, keeperId), cancellationToken) + .ConfigureAwait(false); + + await context.BaseItems + .Where(b => b.OwnerId == dupId) + .ExecuteUpdateAsync(s => s.SetProperty(b => b.OwnerId, keeperId), cancellationToken) + .ConfigureAwait(false); + + // AncestorIds PK is (ItemId, ParentItemId); drop rows that would collide before redirecting. + await context.AncestorIds + .Where(a => a.ParentItemId == dupId + && context.AncestorIds.Any(k => k.ParentItemId == keeperId && k.ItemId == a.ItemId)) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + await context.AncestorIds + .Where(a => a.ParentItemId == dupId) + .ExecuteUpdateAsync(s => s.SetProperty(a => a.ParentItemId, keeperId), cancellationToken) + .ConfigureAwait(false); + + // LinkedChildren PK is (ParentId, ChildId); drop colliding rows in both directions. + await context.LinkedChildren + .Where(l => l.ParentId == dupId + && context.LinkedChildren.Any(k => k.ParentId == keeperId && k.ChildId == l.ChildId)) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + await context.LinkedChildren + .Where(l => l.ParentId == dupId) + .ExecuteUpdateAsync(s => s.SetProperty(l => l.ParentId, keeperId), cancellationToken) + .ConfigureAwait(false); + await context.LinkedChildren + .Where(l => l.ChildId == dupId + && context.LinkedChildren.Any(k => k.ChildId == keeperId && k.ParentId == l.ParentId)) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + await context.LinkedChildren + .Where(l => l.ChildId == dupId) + .ExecuteUpdateAsync(s => s.SetProperty(l => l.ChildId, keeperId), cancellationToken) + .ConfigureAwait(false); + + // UserData has UNIQUE(UserId, CustomDataKey); keep the dup's row only when the + // keeper has no equivalent row, otherwise the keeper's value wins. + await context.UserData + .Where(u => u.ItemId == dupId + && context.UserData.Any(k => k.ItemId == keeperId && k.UserId == u.UserId && k.CustomDataKey == u.CustomDataKey)) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + await context.UserData + .Where(u => u.ItemId == dupId) + .ExecuteUpdateAsync(s => s.SetProperty(u => u.ItemId, keeperId), cancellationToken) + .ConfigureAwait(false); + + idsToDelete.Add(dupId); + } + + _logger.LogDebug( + "Merged duplicates for '{Name}' into {KeeperId} ({Removed} removed).", + keeper.Name, + keeper.Id, + stats.Count - 1); + } + + if (idsToDelete.Count == 0) + { + return; + } + + // Resolve via LibraryManager so DeleteItemsUnsafeFast can also remove the + // %MetadataPath%/artists/<Name> directories that the duplicate stubs left behind. + // Fall back to the persistence service for any items the LibraryManager can't resolve. + // Delete in batches so we never issue one massive delete transaction and progress stays visible. + _logger.LogInformation("Deleting {Count} duplicate MusicArtist records...", idsToDelete.Count); + const int deleteBatchSize = 500; + var deletedSoFar = 0; + for (var offset = 0; offset < idsToDelete.Count; offset += deleteBatchSize) + { + cancellationToken.ThrowIfCancellationRequested(); + + var batchIds = idsToDelete.GetRange(offset, Math.Min(deleteBatchSize, idsToDelete.Count - offset)); + + var itemsToDelete = batchIds + .Select(id => _libraryManager.GetItemById(id)) + .Where(item => item is not null) + .ToList(); + if (itemsToDelete.Count > 0) + { + _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!); + } + + var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet(); + var unresolvedIds = batchIds.Where(id => !deletedIds.Contains(id)).ToList(); + if (unresolvedIds.Count > 0) + { + _persistenceService.DeleteItem(unresolvedIds); + } + + deletedSoFar += batchIds.Count; + _logger.LogInformation("Deleting duplicate MusicArtist records: {Deleted}/{Total}", deletedSoFar, idsToDelete.Count); + } + } + } +} diff --git a/Jellyfin.Server/Migrations/Routines/20260508130000_MergeDuplicatePeople.cs b/Jellyfin.Server/Migrations/Routines/20260508130000_MergeDuplicatePeople.cs new file mode 100644 index 0000000000..f28c804d26 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260508130000_MergeDuplicatePeople.cs @@ -0,0 +1,312 @@ +#pragma warning disable RS0030 // Do not use banned APIs + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using Jellyfin.Server.ServerSetupApp; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// Merges case-only duplicate people. Two passes: +/// 1) Person BaseItems whose Name differs only by casing — Person.GetPath hashes the name +/// verbatim, so two casings produce two distinct Person rows in BaseItems. +/// 2) Peoples lookup rows whose Name differs only by casing within the same PersonType — +/// UpdatePeople used to insert a second Peoples row when a metadata provider returned +/// a different casing than the row already in the table. +/// Both bugs cause the /Persons endpoint to list the same person twice. +/// </summary> +[JellyfinMigration("2026-05-08T13:00:00", nameof(MergeDuplicatePeople))] +[JellyfinMigrationBackup(JellyfinDb = true)] +public class MergeDuplicatePeople : IAsyncMigrationRoutine +{ + private const string PersonType = "MediaBrowser.Controller.Entities.Person"; + + private readonly IStartupLogger<MergeDuplicatePeople> _logger; + private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory; + private readonly ILibraryManager _libraryManager; + private readonly IItemPersistenceService _persistenceService; + + /// <summary> + /// Initializes a new instance of the <see cref="MergeDuplicatePeople"/> class. + /// </summary> + /// <param name="logger">The startup logger.</param> + /// <param name="dbContextFactory">The database context factory.</param> + /// <param name="libraryManager">The library manager.</param> + /// <param name="persistenceService">The item persistence service.</param> + public MergeDuplicatePeople( + IStartupLogger<MergeDuplicatePeople> logger, + IDbContextFactory<JellyfinDbContext> dbContextFactory, + ILibraryManager libraryManager, + IItemPersistenceService persistenceService) + { + _logger = logger; + _dbContextFactory = dbContextFactory; + _libraryManager = libraryManager; + _persistenceService = persistenceService; + } + + /// <inheritdoc/> + public async Task PerformAsync(CancellationToken cancellationToken) + { + var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (context.ConfigureAwait(false)) + { + await MergePersonBaseItemsAsync(context, cancellationToken).ConfigureAwait(false); + await MergePeoplesRowsAsync(context, cancellationToken).ConfigureAwait(false); + } + } + + private async Task MergePersonBaseItemsAsync(JellyfinDbContext context, CancellationToken cancellationToken) + { + var persons = await context.BaseItems + .Where(b => b.Type == PersonType && b.Name != null) + .Select(b => new { b.Id, b.Name, b.DateCreated }) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + var groups = persons + .GroupBy(p => p.Name!.ToLowerInvariant()) + .Where(g => g.Count() > 1) + .ToList(); + + if (groups.Count == 0) + { + _logger.LogInformation("No case-only duplicate Person BaseItems found."); + return; + } + + _logger.LogInformation("Found {Count} groups of case-only duplicate Person BaseItems.", groups.Count); + + var idsToDelete = new List<Guid>(); + foreach (var group in groups) + { + cancellationToken.ThrowIfCancellationRequested(); + + var groupIds = group.Select(g => g.Id).ToArray(); + + // Pick the keeper: the Person with the most UserData rows (favorites, image + // refresh state) is the one users have actually interacted with. + var stats = await context.BaseItems + .Where(b => groupIds.Contains(b.Id)) + .Select(b => new + { + b.Id, + b.Name, + b.DateCreated, + UserDataCount = context.UserData.Count(u => u.ItemId == b.Id), + LinkedCount = context.LinkedChildren.Count(l => l.ParentId == b.Id || l.ChildId == b.Id), + }) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + var keeper = stats + .OrderByDescending(s => s.UserDataCount) + .ThenByDescending(s => s.LinkedCount) + .ThenBy(s => s.DateCreated) + .First(); + + foreach (var dup in stats.Where(s => s.Id != keeper.Id)) + { + var keeperId = keeper.Id; + var dupId = dup.Id; + + await context.BaseItems + .Where(b => b.ParentId == dupId) + .ExecuteUpdateAsync(s => s.SetProperty(b => b.ParentId, keeperId), cancellationToken) + .ConfigureAwait(false); + + await context.BaseItems + .Where(b => b.OwnerId == dupId) + .ExecuteUpdateAsync(s => s.SetProperty(b => b.OwnerId, keeperId), cancellationToken) + .ConfigureAwait(false); + + await context.AncestorIds + .Where(a => a.ParentItemId == dupId + && context.AncestorIds.Any(k => k.ParentItemId == keeperId && k.ItemId == a.ItemId)) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + await context.AncestorIds + .Where(a => a.ParentItemId == dupId) + .ExecuteUpdateAsync(s => s.SetProperty(a => a.ParentItemId, keeperId), cancellationToken) + .ConfigureAwait(false); + + await context.LinkedChildren + .Where(l => l.ParentId == dupId + && context.LinkedChildren.Any(k => k.ParentId == keeperId && k.ChildId == l.ChildId)) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + await context.LinkedChildren + .Where(l => l.ParentId == dupId) + .ExecuteUpdateAsync(s => s.SetProperty(l => l.ParentId, keeperId), cancellationToken) + .ConfigureAwait(false); + await context.LinkedChildren + .Where(l => l.ChildId == dupId + && context.LinkedChildren.Any(k => k.ChildId == keeperId && k.ParentId == l.ParentId)) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + await context.LinkedChildren + .Where(l => l.ChildId == dupId) + .ExecuteUpdateAsync(s => s.SetProperty(l => l.ChildId, keeperId), cancellationToken) + .ConfigureAwait(false); + + await context.UserData + .Where(u => u.ItemId == dupId + && context.UserData.Any(k => k.ItemId == keeperId && k.UserId == u.UserId && k.CustomDataKey == u.CustomDataKey)) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + await context.UserData + .Where(u => u.ItemId == dupId) + .ExecuteUpdateAsync(s => s.SetProperty(u => u.ItemId, keeperId), cancellationToken) + .ConfigureAwait(false); + + idsToDelete.Add(dupId); + } + + _logger.LogDebug( + "Merged Person BaseItems for '{Name}' into {KeeperId} ({Removed} removed).", + keeper.Name, + keeper.Id, + stats.Count - 1); + } + + if (idsToDelete.Count == 0) + { + return; + } + + // Resolve via LibraryManager so DeleteItemsUnsafeFast can also remove the + // %MetadataPath%/People/<Letter>/<Name> directories the duplicate stubs left behind. + // Delete in batches so we never issue one massive delete transaction and progress stays visible. + _logger.LogInformation("Deleting {Count} duplicate Person BaseItems...", idsToDelete.Count); + const int deleteBatchSize = 500; + var deletedSoFar = 0; + for (var offset = 0; offset < idsToDelete.Count; offset += deleteBatchSize) + { + cancellationToken.ThrowIfCancellationRequested(); + + var batchIds = idsToDelete.GetRange(offset, Math.Min(deleteBatchSize, idsToDelete.Count - offset)); + + var itemsToDelete = batchIds + .Select(id => _libraryManager.GetItemById(id)) + .Where(item => item is not null) + .ToList(); + if (itemsToDelete.Count > 0) + { + _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!); + } + + var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet(); + var unresolvedIds = batchIds.Where(id => !deletedIds.Contains(id)).ToList(); + if (unresolvedIds.Count > 0) + { + _persistenceService.DeleteItem(unresolvedIds); + } + + deletedSoFar += batchIds.Count; + _logger.LogInformation("Deleting duplicate Person BaseItems: {Deleted}/{Total}", deletedSoFar, idsToDelete.Count); + } + } + + private async Task MergePeoplesRowsAsync(JellyfinDbContext context, CancellationToken cancellationToken) + { + var people = await context.Peoples + .Select(p => new { p.Id, p.Name, p.PersonType }) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + var groups = people + .GroupBy(p => (Name: p.Name.ToLowerInvariant(), p.PersonType)) + .Where(g => g.Count() > 1) + .ToList(); + + if (groups.Count == 0) + { + _logger.LogInformation("No case-only duplicate Peoples rows found."); + return; + } + + _logger.LogInformation("Found {Count} groups of case-only duplicate Peoples rows.", groups.Count); + + var idsToDelete = new List<Guid>(); + foreach (var group in groups) + { + cancellationToken.ThrowIfCancellationRequested(); + + var groupIds = group.Select(g => g.Id).ToArray(); + + // Pick the keeper: the row referenced by the most BaseItems is the one most + // tracks/movies already point at; the duplicates are usually orphan stubs left + // by a casing-mismatched insert. + var stats = await context.Peoples + .Where(p => groupIds.Contains(p.Id)) + .Select(p => new + { + p.Id, + p.Name, + MapCount = context.PeopleBaseItemMap.Count(m => m.PeopleId == p.Id), + }) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + var keeper = stats + .OrderByDescending(s => s.MapCount) + .ThenBy(s => s.Id) + .First(); + + foreach (var dup in stats.Where(s => s.Id != keeper.Id)) + { + var keeperId = keeper.Id; + var dupId = dup.Id; + + // PeopleBaseItemMap PK is (ItemId, PeopleId, Role); drop dup rows that would + // collide on (ItemId, Role) before redirecting PeopleId. Role is nullable, so + // match nulls explicitly. + await context.PeopleBaseItemMap + .Where(m => m.PeopleId == dupId + && context.PeopleBaseItemMap.Any(k => k.PeopleId == keeperId + && k.ItemId == m.ItemId + && (k.Role == m.Role || (k.Role == null && m.Role == null)))) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + await context.PeopleBaseItemMap + .Where(m => m.PeopleId == dupId) + .ExecuteUpdateAsync(s => s.SetProperty(m => m.PeopleId, keeperId), cancellationToken) + .ConfigureAwait(false); + + idsToDelete.Add(dupId); + } + + _logger.LogDebug( + "Merged Peoples rows for '{Name}' into {KeeperId} ({Removed} removed).", + keeper.Name, + keeper.Id, + stats.Count - 1); + } + + if (idsToDelete.Count == 0) + { + return; + } + + var idx = 0; + foreach (var item in idsToDelete.Chunk(200)) + { + idx++; // humans count at one + _logger.LogInformation("Remove batch {BatchNo}/{MaxBatches} duplicate Peoples.", idx, idsToDelete.Count / 200); + await context.Peoples + .Where(p => item.Contains(p.Id)) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + } + + _logger.LogInformation("Removed {Count} duplicate Peoples rows.", idsToDelete.Count); + } +} diff --git a/Jellyfin.Server/Migrations/Routines/20260522092304_UpdateNormalizedUsername.cs b/Jellyfin.Server/Migrations/Routines/20260522092304_UpdateNormalizedUsername.cs new file mode 100644 index 0000000000..8100d4759e --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260522092304_UpdateNormalizedUsername.cs @@ -0,0 +1,44 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using MediaBrowser.Controller.Configuration; +using Microsoft.EntityFrameworkCore; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// Part 2 Migration for NormalisedUsername. +/// </summary> +[JellyfinMigration("2026-05-22T09:23:04", nameof(UpdateNormalizedUsername), Stage = Stages.JellyfinMigrationStageTypes.CoreInitialisation)] +#pragma warning disable SA1649 // File name should match first type name +public class UpdateNormalizedUsername : IAsyncMigrationRoutine +#pragma warning restore SA1649 // File name should match first type name +{ + private readonly IDbContextFactory<JellyfinDbContext> _contextFactory; + + /// <summary> + /// Initializes a new instance of the <see cref="UpdateNormalizedUsername"/> class. + /// </summary> + /// <param name="contextFactory">Db Context factory.</param> + public UpdateNormalizedUsername(IDbContextFactory<JellyfinDbContext> contextFactory) + { + _contextFactory = contextFactory; + } + + /// <inheritdoc/> + public async Task PerformAsync(CancellationToken cancellationToken) + { + var dbContext = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + var users = await dbContext.Users.ToListAsync(cancellationToken).ConfigureAwait(false); + foreach (var user in users) + { + user.NormalizedUsername = user.Username.ToUpperInvariant(); + } + + await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/Jellyfin.Server/Migrations/Routines/20260525010000_CleanupOrphanedExternalData.cs b/Jellyfin.Server/Migrations/Routines/20260525010000_CleanupOrphanedExternalData.cs new file mode 100644 index 0000000000..d8dfe181ca --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260525010000_CleanupOrphanedExternalData.cs @@ -0,0 +1,182 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using Jellyfin.Server.ServerSetupApp; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// Removes on-disk external item data (attachments, subtitles, trickplay tiles, chapter images) for items that +/// no longer exist in the <c>BaseItems</c> table. The database side is cleaned up synchronously by +/// <c>IItemPersistenceService.DeleteItem</c>, so the leftover orphans live on the filesystem. +/// </summary> +[JellyfinMigration("2026-05-25T01:00:00", nameof(CleanupOrphanedExternalData))] +[JellyfinMigrationBackup(JellyfinDb = true)] +public class CleanupOrphanedExternalData : IAsyncMigrationRoutine +{ + private const int ProgressLogStep = 500; + + private readonly IStartupLogger<CleanupOrphanedExternalData> _logger; + private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory; + private readonly IApplicationPaths _appPaths; + private readonly IServerApplicationPaths _serverPaths; + + /// <summary> + /// Initializes a new instance of the <see cref="CleanupOrphanedExternalData"/> class. + /// </summary> + /// <param name="logger">The startup logger.</param> + /// <param name="dbContextFactory">The database context factory.</param> + /// <param name="appPaths">The application paths.</param> + /// <param name="serverPaths">The server application paths.</param> + public CleanupOrphanedExternalData( + IStartupLogger<CleanupOrphanedExternalData> logger, + IDbContextFactory<JellyfinDbContext> dbContextFactory, + IApplicationPaths appPaths, + IServerApplicationPaths serverPaths) + { + _logger = logger; + _dbContextFactory = dbContextFactory; + _appPaths = appPaths; + _serverPaths = serverPaths; + } + + /// <inheritdoc/> + public async Task PerformAsync(CancellationToken cancellationToken) + { + var knownIds = await LoadKnownItemIdsAsync(cancellationToken).ConfigureAwait(false); + + CleanupGuidIndexedRoot( + "attachment", + Path.Combine(_appPaths.DataPath, "attachments"), + knownIds, + deleteSubPath: null, + cancellationToken); + + CleanupGuidIndexedRoot( + "subtitle", + Path.Combine(_appPaths.DataPath, "subtitles"), + knownIds, + deleteSubPath: null, + cancellationToken); + + CleanupGuidIndexedRoot( + "trickplay", + _appPaths.TrickplayPath, + knownIds, + deleteSubPath: null, + cancellationToken); + + CleanupGuidIndexedRoot( + "chapter image", + Path.Combine(_serverPaths.InternalMetadataPath, "library"), + knownIds, + deleteSubPath: "chapters", + cancellationToken); + } + + private async Task<HashSet<Guid>> LoadKnownItemIdsAsync(CancellationToken cancellationToken) + { + var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (context.ConfigureAwait(false)) + { + var ids = await context.BaseItems + .AsNoTracking() + .Select(b => b.Id) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + return [.. ids]; + } + } + + private void CleanupGuidIndexedRoot( + string label, + string root, + HashSet<Guid> knownIds, + string? deleteSubPath, + CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(root) || !Directory.Exists(root)) + { + _logger.LogInformation("Skipping {Label} cleanup; root {Root} does not exist", label, root); + return; + } + + _logger.LogInformation("Scanning for orphaned {Label} data under {Root}", label, root); + + var scanned = 0; + var removed = 0; + foreach (var prefixDir in Directory.EnumerateDirectories(root)) + { + cancellationToken.ThrowIfCancellationRequested(); + + var prefixName = Path.GetFileName(prefixDir); + if (prefixName.Length != 2) + { + continue; + } + + foreach (var guidDir in Directory.EnumerateDirectories(prefixDir)) + { + cancellationToken.ThrowIfCancellationRequested(); + + scanned++; + if (scanned % ProgressLogStep == 0) + { + _logger.LogInformation("Scanning {Label}: {Scanned} directories examined, {Removed} orphans removed so far", label, scanned, removed); + } + + var leafName = Path.GetFileName(guidDir); + if (!Guid.TryParse(leafName, CultureInfo.InvariantCulture, out var id)) + { + continue; + } + + if (knownIds.Contains(id)) + { + continue; + } + + var target = deleteSubPath is null ? guidDir : Path.Combine(guidDir, deleteSubPath); + if (deleteSubPath is not null && !Directory.Exists(target)) + { + continue; + } + + if (TryDelete(target)) + { + removed++; + } + } + } + + _logger.LogInformation("Finished {Label} cleanup: scanned {Scanned} directories, removed {Removed} orphans", label, scanned, removed); + } + + private bool TryDelete(string dir) + { + try + { + Directory.Delete(dir, recursive: true); + return true; + } + catch (IOException ex) + { + _logger.LogWarning(ex, "Failed to delete orphaned directory {Dir}", dir); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning(ex, "Permission denied deleting orphaned directory {Dir}", dir); + } + + return false; + } +} diff --git a/Jellyfin.Server/Migrations/Routines/20260531160000_DisableLegacyAuthorization.cs b/Jellyfin.Server/Migrations/Routines/20260531160000_DisableLegacyAuthorization.cs new file mode 100644 index 0000000000..4b8ced90ac --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260531160000_DisableLegacyAuthorization.cs @@ -0,0 +1,32 @@ +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Configuration; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// Migration to disable legacy authorization in the system config. +/// </summary> +[JellyfinMigration("2026-05-31T16:00:00", nameof(DisableLegacyAuthorization), Stage = Stages.JellyfinMigrationStageTypes.CoreInitialisation)] +public class DisableLegacyAuthorization : IAsyncMigrationRoutine +{ + private readonly IServerConfigurationManager _serverConfigurationManager; + + /// <summary> + /// Initializes a new instance of the <see cref="DisableLegacyAuthorization"/> class. + /// </summary> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + public DisableLegacyAuthorization(IServerConfigurationManager serverConfigurationManager) + { + _serverConfigurationManager = serverConfigurationManager; + } + + /// <inheritdoc /> + public Task PerformAsync(CancellationToken cancellationToken) + { + _serverConfigurationManager.Configuration.EnableLegacyAuthorization = false; + _serverConfigurationManager.SaveConfiguration(); + + return Task.CompletedTask; + } +} diff --git a/Jellyfin.Server/Migrations/Routines/20260610120000_RefreshCleanNamesAndValues.cs b/Jellyfin.Server/Migrations/Routines/20260610120000_RefreshCleanNamesAndValues.cs new file mode 100644 index 0000000000..7ade727d9b --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260610120000_RefreshCleanNamesAndValues.cs @@ -0,0 +1,173 @@ +using System; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using Jellyfin.Extensions; +using Jellyfin.Server.ServerSetupApp; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// Migration to refresh CleanName values for all library items and CleanValue values for all item values. +/// </summary> +[JellyfinMigration("2026-06-10T12:00:00", nameof(RefreshCleanNamesAndValues))] +[JellyfinMigrationBackup(JellyfinDb = true)] +public class RefreshCleanNamesAndValues : IAsyncMigrationRoutine +{ + private readonly IStartupLogger<RefreshCleanNamesAndValues> _logger; + private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; + + /// <summary> + /// Initializes a new instance of the <see cref="RefreshCleanNamesAndValues"/> class. + /// </summary> + /// <param name="logger">The logger.</param> + /// <param name="dbProvider">Instance of the <see cref="IDbContextFactory{JellyfinDbContext}"/> interface.</param> + public RefreshCleanNamesAndValues( + IStartupLogger<RefreshCleanNamesAndValues> logger, + IDbContextFactory<JellyfinDbContext> dbProvider) + { + _logger = logger; + _dbProvider = dbProvider; + } + + /// <inheritdoc /> + public async Task PerformAsync(CancellationToken cancellationToken) + { + await RefreshCleanNamesAsync(cancellationToken).ConfigureAwait(false); + await RefreshCleanValuesAsync(cancellationToken).ConfigureAwait(false); + } + + private async Task RefreshCleanNamesAsync(CancellationToken cancellationToken) + { + const int Limit = 10000; + int itemCount = 0; + + var sw = Stopwatch.StartNew(); + + using var context = _dbProvider.CreateDbContext(); + var records = context.BaseItems.Count(b => !string.IsNullOrEmpty(b.Name)); + _logger.LogInformation("Refreshing CleanName for {Count} library items", records); + + var processedInPartition = 0; + + await foreach (var item in context.BaseItems + .Where(b => !string.IsNullOrEmpty(b.Name)) + .OrderBy(e => e.Id) + .WithPartitionProgress((partition) => _logger.LogInformation("Processed: {Offset}/{Total} - Updated: {UpdatedCount} - Time: {Elapsed}", partition * Limit, records, itemCount, sw.Elapsed)) + .PartitionEagerAsync(Limit, cancellationToken) + .WithCancellation(cancellationToken) + .ConfigureAwait(false)) + { + try + { + var newCleanName = string.IsNullOrWhiteSpace(item.Name) ? string.Empty : item.Name.GetCleanValue(); + if (!string.Equals(newCleanName, item.CleanName, StringComparison.Ordinal)) + { + _logger.LogDebug( + "Updating CleanName for item {Id}: '{OldValue}' -> '{NewValue}'", + item.Id, + item.CleanName, + newCleanName); + item.CleanName = newCleanName; + itemCount++; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to update CleanName for item {Id} ({Name})", item.Id, item.Name); + } + + processedInPartition++; + + if (processedInPartition >= Limit) + { + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + // Clear tracked entities to avoid memory growth across partitions + context.ChangeTracker.Clear(); + processedInPartition = 0; + } + } + + // Save any remaining changes after the loop + if (processedInPartition > 0) + { + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + context.ChangeTracker.Clear(); + } + + _logger.LogInformation( + "Refreshed CleanName for {UpdatedCount} out of {TotalCount} items in {Time}", + itemCount, + records, + sw.Elapsed); + } + + private async Task RefreshCleanValuesAsync(CancellationToken cancellationToken) + { + const int Limit = 10000; + int itemCount = 0; + + var sw = Stopwatch.StartNew(); + + using var context = _dbProvider.CreateDbContext(); + var records = context.ItemValues.Count(b => !string.IsNullOrEmpty(b.Value)); + _logger.LogInformation("Refreshing CleanValue for {Count} item values", records); + + var processedInPartition = 0; + + await foreach (var item in context.ItemValues + .Where(b => !string.IsNullOrEmpty(b.Value)) + .OrderBy(e => e.ItemValueId) + .WithPartitionProgress((partition) => _logger.LogInformation("Processed: {Offset}/{Total} - Updated: {UpdatedCount} - Time: {Elapsed}", partition * Limit, records, itemCount, sw.Elapsed)) + .PartitionEagerAsync(Limit, cancellationToken) + .WithCancellation(cancellationToken) + .ConfigureAwait(false)) + { + try + { + var newCleanValue = string.IsNullOrWhiteSpace(item.Value) ? string.Empty : item.Value.GetCleanValue(); + if (!string.Equals(newCleanValue, item.CleanValue, StringComparison.Ordinal)) + { + _logger.LogDebug( + "Updating CleanValue for item value {Id}: '{OldValue}' -> '{NewValue}'", + item.ItemValueId, + item.CleanValue, + newCleanValue); + item.CleanValue = newCleanValue; + itemCount++; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to update CleanValue for item value {Id} ({Value})", item.ItemValueId, item.Value); + } + + processedInPartition++; + + if (processedInPartition >= Limit) + { + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + // Clear tracked entities to avoid memory growth across partitions + context.ChangeTracker.Clear(); + processedInPartition = 0; + } + } + + // Save any remaining changes after the loop + if (processedInPartition > 0) + { + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + context.ChangeTracker.Clear(); + } + + _logger.LogInformation( + "Refreshed CleanValue for {UpdatedCount} out of {TotalCount} item values in {Time}", + itemCount, + records, + sw.Elapsed); + } +} diff --git a/Jellyfin.Server/Migrations/Routines/20260722120000_RefreshForcedSortNames.cs b/Jellyfin.Server/Migrations/Routines/20260722120000_RefreshForcedSortNames.cs new file mode 100644 index 0000000000..e9eefc20dc --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260722120000_RefreshForcedSortNames.cs @@ -0,0 +1,113 @@ +using System; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using Jellyfin.Extensions; +using Jellyfin.Server.ServerSetupApp; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// Migration to recompute the SortName of all items that have a forced sort name. +/// </summary> +[JellyfinMigration("2026-07-22T12:00:00", nameof(RefreshForcedSortNames))] +[JellyfinMigrationBackup(JellyfinDb = true)] +public class RefreshForcedSortNames : IAsyncMigrationRoutine +{ + private readonly IStartupLogger<RefreshForcedSortNames> _logger; + private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; + private readonly IServerConfigurationManager _configurationManager; + + /// <summary> + /// Initializes a new instance of the <see cref="RefreshForcedSortNames"/> class. + /// </summary> + /// <param name="logger">The logger.</param> + /// <param name="dbProvider">Instance of the <see cref="IDbContextFactory{JellyfinDbContext}"/> interface.</param> + /// <param name="configurationManager">The server configuration manager providing the sort rules.</param> + public RefreshForcedSortNames( + IStartupLogger<RefreshForcedSortNames> logger, + IDbContextFactory<JellyfinDbContext> dbProvider, + IServerConfigurationManager configurationManager) + { + _logger = logger; + _dbProvider = dbProvider; + _configurationManager = configurationManager; + } + + /// <inheritdoc /> + public async Task PerformAsync(CancellationToken cancellationToken) + { + const int Limit = 10000; + int itemCount = 0; + + var configuration = _configurationManager.Configuration; + // Only the Person type disables alphanumeric sorting; everything else uses the cleaning rules. + var personType = typeof(Person).ToString(); + + var sw = Stopwatch.StartNew(); + + using var context = _dbProvider.CreateDbContext(); + var records = context.BaseItems.Count(b => !string.IsNullOrEmpty(b.ForcedSortName)); + _logger.LogInformation("Refreshing SortName for {Count} library items with a forced sort name", records); + + var processedInPartition = 0; + + await foreach (var item in context.BaseItems + .Where(b => !string.IsNullOrEmpty(b.ForcedSortName)) + .OrderBy(e => e.Id) + .WithPartitionProgress((partition) => _logger.LogInformation("Processed: {Offset}/{Total} - Updated: {UpdatedCount} - Time: {Elapsed}", partition * Limit, records, itemCount, sw.Elapsed)) + .PartitionEagerAsync(Limit, cancellationToken) + .WithCancellation(cancellationToken) + .ConfigureAwait(false)) + { + try + { + var enableAlphaNumericSorting = !string.Equals(item.Type, personType, StringComparison.Ordinal); + var newSortName = BaseItem.GetSortName(item.ForcedSortName!, enableAlphaNumericSorting, configuration); + if (!string.Equals(newSortName, item.SortName, StringComparison.Ordinal)) + { + _logger.LogDebug( + "Updating SortName for item {Id}: '{OldValue}' -> '{NewValue}'", + item.Id, + item.SortName, + newSortName); + item.SortName = newSortName; + itemCount++; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to update SortName for item {Id} ({Name})", item.Id, item.Name); + } + + processedInPartition++; + + if (processedInPartition >= Limit) + { + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + // Clear tracked entities to avoid memory growth across partitions + context.ChangeTracker.Clear(); + processedInPartition = 0; + } + } + + // Save any remaining changes after the loop + if (processedInPartition > 0) + { + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + context.ChangeTracker.Clear(); + } + + _logger.LogInformation( + "Refreshed SortName for {UpdatedCount} out of {TotalCount} items in {Time}", + itemCount, + records, + sw.Elapsed); + } +} diff --git a/Jellyfin.Server/Migrations/Routines/20260729120000_RestorePlaylistChildrenFromMetadata.cs b/Jellyfin.Server/Migrations/Routines/20260729120000_RestorePlaylistChildrenFromMetadata.cs new file mode 100644 index 0000000000..16ac6cb5e5 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260729120000_RestorePlaylistChildrenFromMetadata.cs @@ -0,0 +1,191 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Xml; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using MediaBrowser.Controller; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// Restores playlist entries from playlist.xml for playlists that lost all of their children. +/// </summary> +[JellyfinMigration("2026-07-29T12:00:00", nameof(RestorePlaylistChildrenFromMetadata))] +internal class RestorePlaylistChildrenFromMetadata : IDatabaseMigrationRoutine +{ + private const string PlaylistTypeName = "MediaBrowser.Controller.Playlists.Playlist"; + private const string PlaylistFileName = "playlist.xml"; + + private readonly ILogger<RestorePlaylistChildrenFromMetadata> _logger; + private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; + private readonly IServerApplicationHost _appHost; + + public RestorePlaylistChildrenFromMetadata( + ILoggerFactory loggerFactory, + IDbContextFactory<JellyfinDbContext> dbProvider, + IServerApplicationHost appHost) + { + _logger = loggerFactory.CreateLogger<RestorePlaylistChildrenFromMetadata>(); + _dbProvider = dbProvider; + _appHost = appHost; + } + + /// <inheritdoc/> + public void Perform() + { + using var context = _dbProvider.CreateDbContext(); + + var playlists = context.BaseItems + .Where(b => b.Type == PlaylistTypeName && b.Path != null) + .Select(b => new { b.Id, b.Name, b.Path }) + .ToList(); + + if (playlists.Count == 0) + { + return; + } + + var childCountByPlaylist = context.LinkedChildren + .Where(lc => context.BaseItems.Any(b => b.Id.Equals(lc.ParentId) && b.Type == PlaylistTypeName)) + .GroupBy(lc => lc.ParentId) + .Select(g => new { ParentId = g.Key, Count = g.Count() }) + .ToDictionary(g => g.ParentId, g => g.Count); + + var pathToIdMap = context.BaseItems + .Where(b => b.Path != null) + .Select(b => new { b.Id, b.Path }) + .GroupBy(b => b.Path!) + .ToDictionary(g => g.Key, g => g.First().Id); + + var restoredPlaylists = 0; + var restoredEntries = 0; + + foreach (var playlist in playlists) + { + // Only directory-based (Jellyfin-managed) playlists keep their entries in playlist.xml. + // A playlist that is itself a file (.m3u and friends) is re-read by the library scan. + var playlistPath = _appHost.ExpandVirtualPath(playlist.Path!); + var metadataPath = Path.Combine(playlistPath, PlaylistFileName); + if (!Directory.Exists(playlistPath) || !File.Exists(metadataPath)) + { + continue; + } + + var storedPaths = ReadEntryPaths(metadataPath, playlist.Id); + if (storedPaths.Count == 0) + { + continue; + } + + var childCount = childCountByPlaylist.GetValueOrDefault(playlist.Id); + if (childCount > 0) + { + // Merging into a playlist that still has entries would resurrect anything the user + // removed while the metadata file was not rewritten, and there is no way to tell the + // two apart. Report the mismatch instead so it can be checked by hand. + if (storedPaths.Count > childCount) + { + _logger.LogWarning( + "Playlist {PlaylistName} ({PlaylistId}) holds {ChildCount} entries but {MetadataPath} lists {StoredCount}. Not restoring automatically.", + playlist.Name, + playlist.Id, + childCount, + metadataPath, + storedPaths.Count); + } + + continue; + } + + var sortOrder = 0; + foreach (var storedPath in storedPaths) + { + if (!pathToIdMap.TryGetValue(storedPath, out var childId)) + { + _logger.LogWarning( + "Cannot restore entry {EntryPath} of playlist {PlaylistName}: no library item has that path.", + storedPath, + playlist.Name); + continue; + } + + context.LinkedChildren.Add(new LinkedChildEntity + { + ParentId = playlist.Id, + ChildId = childId, + ChildType = LinkedChildType.Manual, + SortOrder = sortOrder + }); + + sortOrder++; + } + + if (sortOrder > 0) + { + restoredPlaylists++; + restoredEntries += sortOrder; + _logger.LogInformation( + "Restored {Count} entries of empty playlist {PlaylistName} ({PlaylistId}) from {MetadataPath}.", + sortOrder, + playlist.Name, + playlist.Id, + metadataPath); + } + } + + if (restoredEntries > 0) + { + context.SaveChanges(); + _logger.LogInformation("Restored {EntryCount} entries across {PlaylistCount} playlists.", restoredEntries, restoredPlaylists); + } + } + + private List<string> ReadEntryPaths(string metadataPath, Guid playlistId) + { + var paths = new List<string>(); + var settings = new XmlReaderSettings + { + IgnoreComments = true, + IgnoreWhitespace = true, + IgnoreProcessingInstructions = true, + DtdProcessing = DtdProcessing.Prohibit + }; + + try + { + using var reader = XmlReader.Create(metadataPath, settings); + var inEntry = false; + while (reader.Read()) + { + if (reader.NodeType != XmlNodeType.Element) + { + continue; + } + + if (string.Equals(reader.Name, "PlaylistItem", StringComparison.Ordinal)) + { + inEntry = true; + } + else if (inEntry && string.Equals(reader.Name, "Path", StringComparison.Ordinal)) + { + inEntry = false; + var value = reader.ReadElementContentAsString(); + if (!string.IsNullOrWhiteSpace(value)) + { + paths.Add(value.Trim()); + } + } + } + } + catch (Exception ex) when (ex is XmlException or IOException or UnauthorizedAccessException) + { + _logger.LogWarning(ex, "Could not read playlist metadata {MetadataPath} of playlist {PlaylistId}.", metadataPath, playlistId); + } + + return paths; + } +} diff --git a/Jellyfin.Server/Migrations/Routines/20260821120000_RecomputeSeriesPresentationKey.cs b/Jellyfin.Server/Migrations/Routines/20260821120000_RecomputeSeriesPresentationKey.cs new file mode 100644 index 0000000000..0e50ec2f47 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260821120000_RecomputeSeriesPresentationKey.cs @@ -0,0 +1,151 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations; +using Jellyfin.Server.ServerSetupApp; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// Recomputes the presentation unique key of every series and season so merged series are scoped to their own library. +/// </summary> +[JellyfinMigration("2026-08-21T12:00:00", nameof(RecomputeSeriesPresentationKey))] +[JellyfinMigrationBackup(JellyfinDb = true)] +internal class RecomputeSeriesPresentationKey : IAsyncMigrationRoutine +{ + private readonly IStartupLogger<RecomputeSeriesPresentationKey> _logger; + private readonly ILibraryManager _libraryManager; + private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; + + /// <summary> + /// Initializes a new instance of the <see cref="RecomputeSeriesPresentationKey"/> class. + /// </summary> + /// <param name="logger">The startup logger.</param> + /// <param name="libraryManager">The library manager.</param> + /// <param name="dbProvider">The database context factory.</param> + public RecomputeSeriesPresentationKey( + IStartupLogger<RecomputeSeriesPresentationKey> logger, + ILibraryManager libraryManager, + IDbContextFactory<JellyfinDbContext> dbProvider) + { + _logger = logger; + _libraryManager = libraryManager; + _dbProvider = dbProvider; + } + + /// <inheritdoc /> + public async Task PerformAsync(CancellationToken cancellationToken) + { + var series = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.Series] + }).OfType<Series>().ToArray(); + + _logger.LogInformation("Recomputing presentation unique key for {Count} series", series.Length); + + const int ProgressInterval = 250; + var sw = Stopwatch.StartNew(); + var newSeriesKeys = new Dictionary<Guid, string>(); + var processed = 0; + var updated = 0; + + var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + foreach (var item in series) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (++processed % ProgressInterval == 0) + { + _logger.LogInformation("Processed {Processed}/{Total} series - Updated: {Updated} - Time: {Elapsed}", processed, series.Length, updated, sw.Elapsed); + } + + var newKey = item.CreatePresentationUniqueKey(); + newSeriesKeys[item.Id] = newKey; + + if (string.Equals(item.PresentationUniqueKey, newKey, StringComparison.Ordinal)) + { + continue; + } + + // Write only the changed column instead of re-persisting the whole item. + var id = item.Id; + await dbContext.BaseItems + .Where(e => e.Id.Equals(id)) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.PresentationUniqueKey, newKey), cancellationToken) + .ConfigureAwait(false); + + // Seasons and episodes are matched to their series by SeriesPresentationUniqueKey, so + // re-point them here instead of waiting for the next scan. Scoped by SeriesId rather than + // by the old key: that key can be shared by every library holding the series, so matching + // on it would drag the other libraries' children along. + await dbContext.BaseItems + .Where(e => e.SeriesId.HasValue && e.SeriesId.Value.Equals(id)) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.SeriesPresentationUniqueKey, newKey), cancellationToken) + .ConfigureAwait(false); + + updated++; + } + + var updatedSeasons = await RecomputeSeasonsAsync(dbContext, newSeriesKeys, cancellationToken).ConfigureAwait(false); + + _logger.LogInformation( + "Recomputed presentation unique key for {Updated} of {Count} series and {UpdatedSeasons} seasons in {Elapsed}", + updated, + series.Length, + updatedSeasons, + sw.Elapsed); + } + } + + private async Task<int> RecomputeSeasonsAsync(JellyfinDbContext dbContext, Dictionary<Guid, string> newSeriesKeys, CancellationToken cancellationToken) + { + // A season's own key embeds its series' key, so it goes stale with it. + var seasons = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.Season] + }).OfType<Season>().ToArray(); + + var updated = 0; + + foreach (var season in seasons) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Without an index number the season keeps the base key, which carries no series key at all. + if (!season.IndexNumber.HasValue + || !newSeriesKeys.TryGetValue(season.SeriesId, out var seriesKey)) + { + continue; + } + + // Mirrors Season.CreatePresentationUniqueKey. + var newKey = seriesKey + "-" + season.IndexNumber.Value.ToString("000", CultureInfo.InvariantCulture); + if (string.Equals(season.PresentationUniqueKey, newKey, StringComparison.Ordinal)) + { + continue; + } + + var id = season.Id; + await dbContext.BaseItems + .Where(e => e.Id.Equals(id)) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.PresentationUniqueKey, newKey), cancellationToken) + .ConfigureAwait(false); + + updated++; + } + + return updated; + } +} diff --git a/Jellyfin.Server/Migrations/Routines/20260825200000_ConsolidateLocalizedUserViews.cs b/Jellyfin.Server/Migrations/Routines/20260825200000_ConsolidateLocalizedUserViews.cs new file mode 100644 index 0000000000..3fc2387e09 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260825200000_ConsolidateLocalizedUserViews.cs @@ -0,0 +1,334 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; +using Jellyfin.Server.ServerSetupApp; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.IO; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// Moves the views whose id used to be derived from their localized name onto their name independent id. +/// </summary> +[JellyfinMigration("2026-08-25T20:00:00", nameof(ConsolidateLocalizedUserViews))] +[JellyfinMigrationBackup(JellyfinDb = true)] +internal class ConsolidateLocalizedUserViews : IAsyncMigrationRoutine +{ + private readonly IStartupLogger<ConsolidateLocalizedUserViews> _logger; + private readonly ILibraryManager _libraryManager; + private readonly IServerConfigurationManager _configurationManager; + private readonly IFileSystem _fileSystem; + private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; + + /// <summary> + /// Initializes a new instance of the <see cref="ConsolidateLocalizedUserViews"/> class. + /// </summary> + /// <param name="logger">The startup logger.</param> + /// <param name="libraryManager">The library manager.</param> + /// <param name="configurationManager">The server configuration manager.</param> + /// <param name="fileSystem">The file system.</param> + /// <param name="dbProvider">The database context factory.</param> + public ConsolidateLocalizedUserViews( + IStartupLogger<ConsolidateLocalizedUserViews> logger, + ILibraryManager libraryManager, + IServerConfigurationManager configurationManager, + IFileSystem fileSystem, + IDbContextFactory<JellyfinDbContext> dbProvider) + { + _logger = logger; + _libraryManager = libraryManager; + _configurationManager = configurationManager; + _fileSystem = fileSystem; + _dbProvider = dbProvider; + } + + /// <inheritdoc /> + public async Task PerformAsync(CancellationToken cancellationToken) + { + // The Live TV view is the one that hurts: every channel and program is parented to it, so a + // translation update or a change of UI culture used to leave them behind under a view nothing + // looks up any more. + var views = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.UserView] + }).OfType<UserView>().Where(view => view.ViewType.HasValue).ToArray(); + + if (views.Length == 0) + { + return; + } + + var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + foreach (var group in views.GroupBy(view => view.ViewType!.Value)) + { + cancellationToken.ThrowIfCancellationRequested(); + + var viewType = group.Key; + var folderName = _fileSystem.GetValidFilename(viewType.ToString()); + var path = Path.Combine(_configurationManager.ApplicationPaths.InternalMetadataPath, "views", folderName); + + // Only the views created for a view type as a whole are named after it. The per user and + // per parent ones get a folder of their own, and carry no children to lose. Match on the + // folder rather than the whole path so a metadata directory that has since moved still + // lines up. + var candidates = group + .Where(view => !string.IsNullOrEmpty(view.Path) + && string.Equals(Path.GetFileName(view.Path.TrimEnd(Path.DirectorySeparatorChar)), folderName, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + if (candidates.Length == 0) + { + continue; + } + + // Mirrors LibraryManager.GetNamedView(name, viewType, sortName). + var canonicalId = _libraryManager.GetNewItemId(path + "_namedview_" + viewType.ToString(), typeof(UserView)); + + var stale = candidates.Where(view => !view.Id.Equals(canonicalId)).ToArray(); + if (stale.Length == 0) + { + continue; + } + + await ConsolidateAsync(dbContext, viewType, path, canonicalId, candidates, stale, cancellationToken).ConfigureAwait(false); + } + } + } + + private async Task ConsolidateAsync( + JellyfinDbContext dbContext, + CollectionType viewType, + string path, + Guid canonicalId, + IReadOnlyList<UserView> candidates, + IReadOnlyList<UserView> stale, + CancellationToken cancellationToken) + { + var staleIds = stale.Select(view => view.Id).ToArray(); + Guid? newParentId = canonicalId; + var sourceId = Guid.Empty; + + if (!candidates.Any(view => view.Id.Equals(canonicalId))) + { + // Whichever of the old views the items ended up under is the one worth keeping, so give the + // canonical id a copy of it. + var source = await PickSourceAsync(dbContext, stale, staleIds, cancellationToken).ConfigureAwait(false); + sourceId = source.Id; + + _libraryManager.CreateItem( + new UserView + { + Path = path, + Id = canonicalId, + DateCreated = source.DateCreated, + DateModified = source.DateModified, + Name = source.Name, + ViewType = viewType, + ForcedSortName = source.ForcedSortName + }, + null); + } + + var reparented = await dbContext.BaseItems + .Where(e => e.ParentId.HasValue) + .WhereOneOrMany(staleIds, e => e.ParentId!.Value) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.ParentId, newParentId), cancellationToken) + .ConfigureAwait(false); + + await dbContext.BaseItems + .Where(e => e.TopParentId.HasValue) + .WhereOneOrMany(staleIds, e => e.TopParentId!.Value) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.TopParentId, newParentId), cancellationToken) + .ConfigureAwait(false); + + await MoveAncestorsAsync(dbContext, canonicalId, staleIds, cancellationToken).ConfigureAwait(false); + await MoveUserSettingsAsync(dbContext, canonicalId, sourceId, staleIds, cancellationToken).ConfigureAwait(false); + + // Nothing points at them any more, and BaseItems cascades on ParentId, so this has to come last. + await dbContext.BaseItems + .WhereOneOrMany(staleIds, e => e.Id) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + + _logger.LogInformation( + "Moved {Reparented} items and dropped {Stale} stale {ViewType} views in favour of {CanonicalId}", + reparented, + staleIds.Length, + viewType, + canonicalId); + } + + private async Task<UserView> PickSourceAsync( + JellyfinDbContext dbContext, + IReadOnlyList<UserView> stale, + IReadOnlyList<Guid> staleIds, + CancellationToken cancellationToken) + { + var childCounts = await dbContext.BaseItems + .Where(e => e.ParentId.HasValue) + .WhereOneOrMany(staleIds, e => e.ParentId!.Value) + .GroupBy(e => e.ParentId!.Value) + .Select(g => new { ParentId = g.Key, Count = g.Count() }) + .ToDictionaryAsync(e => e.ParentId, e => e.Count, cancellationToken) + .ConfigureAwait(false); + + return stale + .OrderByDescending(view => childCounts.GetValueOrDefault(view.Id)) + .ThenBy(view => view.DateCreated) + .First(); + } + + private static async Task MoveUserSettingsAsync( + JellyfinDbContext dbContext, + Guid canonicalId, + Guid sourceId, + IReadOnlyList<Guid> staleIds, + CancellationToken cancellationToken) + { + // Everything below is keyed by the view's id, and a view holding no children still holds the + // ordering it was given and whether it was hidden. Only the view that was promoted can hand + // those over - the rest would collide on the one row per user, item and client - so the others + // are dropped instead. + var dropped = staleIds.Where(id => !id.Equals(sourceId)).ToArray(); + + if (!sourceId.Equals(Guid.Empty)) + { + var moved = new[] { sourceId }; + + await dbContext.DisplayPreferences + .WhereOneOrMany(moved, e => e.ItemId) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.ItemId, canonicalId), cancellationToken) + .ConfigureAwait(false); + + await dbContext.ItemDisplayPreferences + .WhereOneOrMany(moved, e => e.ItemId) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.ItemId, canonicalId), cancellationToken) + .ConfigureAwait(false); + + await dbContext.CustomItemDisplayPreferences + .WhereOneOrMany(moved, e => e.ItemId) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.ItemId, canonicalId), cancellationToken) + .ConfigureAwait(false); + } + + if (dropped.Length > 0) + { + await dbContext.DisplayPreferences.WhereOneOrMany(dropped, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); + await dbContext.ItemDisplayPreferences.WhereOneOrMany(dropped, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); + await dbContext.CustomItemDisplayPreferences.WhereOneOrMany(dropped, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); + } + + var stale = staleIds.ToHashSet(); + var preferences = await dbContext.Preferences + .Where(e => e.Kind == PreferenceKind.OrderedViews || e.Kind == PreferenceKind.MyMediaExcludes) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + var changed = false; + + foreach (var preference in preferences) + { + var values = preference.Value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var rewritten = new List<string>(values.Length); + var seen = new HashSet<Guid>(); + var touched = false; + + foreach (var value in values) + { + // Clients write these in both the dashed and the plain form, so compare them parsed. + if (!Guid.TryParse(value, out var parsed)) + { + rewritten.Add(value); + continue; + } + + var isStale = stale.Contains(parsed); + if (isStale) + { + parsed = canonicalId; + touched = true; + } + + // The same view can be listed twice once both of its ids point at the same place. + if (!seen.Add(parsed)) + { + continue; + } + + rewritten.Add(isStale + ? parsed.ToString(value.Contains('-', StringComparison.Ordinal) ? "D" : "N", CultureInfo.InvariantCulture) + : value); + } + + if (!touched) + { + continue; + } + + preference.Value = string.Join(',', rewritten); + changed = true; + } + + if (changed) + { + await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + } + + private static async Task MoveAncestorsAsync( + JellyfinDbContext dbContext, + Guid canonicalId, + IReadOnlyList<Guid> staleIds, + CancellationToken cancellationToken) + { + var items = await dbContext.AncestorIds + .WhereOneOrMany(staleIds, e => e.ParentItemId) + .Select(e => e.ItemId) + .Distinct() + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + await dbContext.AncestorIds + .WhereOneOrMany(staleIds, e => e.ParentItemId) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + + if (items.Count == 0) + { + return; + } + + // The pair is the primary key, so anything already recorded against the canonical view stays put. + var existing = await dbContext.AncestorIds + .Where(e => e.ParentItemId.Equals(canonicalId)) + .Select(e => e.ItemId) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + foreach (var itemId in items.Except(existing)) + { + dbContext.AncestorIds.Add(new AncestorId + { + ItemId = itemId, + ParentItemId = canonicalId, + Item = null!, + ParentItem = null! + }); + } + + await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } +} diff --git a/Jellyfin.Server/Migrations/Routines/20260831100000_EnableLocalSimilarityProviders.cs b/Jellyfin.Server/Migrations/Routines/20260831100000_EnableLocalSimilarityProviders.cs new file mode 100644 index 0000000000..e665725ced --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260831100000_EnableLocalSimilarityProviders.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Server.Migrations.Stages; +using Jellyfin.Server.ServerSetupApp; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Entities; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// Enables the local similarity providers on libraries that predate the similar items settings. +/// </summary> +[JellyfinMigration("2026-08-31T10:00:00", nameof(EnableLocalSimilarityProviders), Stage = JellyfinMigrationStageTypes.AppInitialisation)] +internal class EnableLocalSimilarityProviders : IAsyncMigrationRoutine +{ + private readonly ILibraryManager _libraryManager; + private readonly IProviderManager _providerManager; + private readonly ILogger _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="EnableLocalSimilarityProviders"/> class. + /// </summary> + /// <param name="libraryManager">The library manager.</param> + /// <param name="providerManager">The provider manager.</param> + /// <param name="startupLogger">The startup logger for Startup UI integration.</param> + /// <param name="logger">The logger.</param> + public EnableLocalSimilarityProviders( + ILibraryManager libraryManager, + IProviderManager providerManager, + IStartupLogger<EnableLocalSimilarityProviders> startupLogger, + ILogger<EnableLocalSimilarityProviders> logger) + { + _libraryManager = libraryManager; + _providerManager = providerManager; + _logger = startupLogger.With(logger); + } + + /// <inheritdoc /> + public Task PerformAsync(CancellationToken cancellationToken) + { + // Libraries created before similar items became configurable have an empty provider list, + // which the library editor renders as "everything unchecked" instead of falling back to the + // defaults it uses for new libraries. Seed the local providers so they stay enabled. + var localProvidersByType = GetLocalProvidersByItemType(); + if (localProvidersByType.Count == 0) + { + return Task.CompletedTask; + } + + foreach (var virtualFolder in _libraryManager.GetVirtualFolders(false)) + { + cancellationToken.ThrowIfCancellationRequested(); + + EnableLocalProviders(virtualFolder, localProvidersByType); + } + + return Task.CompletedTask; + } + + private void EnableLocalProviders(VirtualFolderInfo virtualFolder, Dictionary<string, string[]> localProvidersByType) + { + var options = virtualFolder.LibraryOptions; + if (options?.TypeOptions is null || options.TypeOptions.Length == 0) + { + return; + } + + // Some virtual folders don't have a proper item id. + if (!Guid.TryParse(virtualFolder.ItemId, out var folderId)) + { + return; + } + + var collectionFolder = _libraryManager.GetItemById<CollectionFolder>(folderId); + if (collectionFolder is null) + { + _logger.LogWarning("Could not find collection folder for virtual folder '{LibraryName}' with id '{FolderId}'. Skipping.", virtualFolder.Name, folderId); + return; + } + + var changed = false; + foreach (var typeOptions in options.TypeOptions) + { + changed |= EnableLocalProviders(typeOptions, localProvidersByType, virtualFolder.Name); + } + + if (changed) + { + collectionFolder.UpdateLibraryOptions(options); + } + } + + private bool EnableLocalProviders(TypeOptions typeOptions, Dictionary<string, string[]> localProvidersByType, string libraryName) + { + if (typeOptions.Type is null || !localProvidersByType.TryGetValue(typeOptions.Type, out var localProviders)) + { + return false; + } + + var enabled = typeOptions.SimilarItemProviders ?? []; + var missing = localProviders.Where(name => !enabled.Contains(name, StringComparer.OrdinalIgnoreCase)).ToArray(); + if (missing.Length == 0) + { + return false; + } + + // Local providers rank ahead of remote ones, and the enabled list doubles as the + // priority order when no explicit order was saved. + typeOptions.SimilarItemProviders = [.. missing, .. enabled]; + if (typeOptions.SimilarItemProviderOrder is { Length: > 0 } order) + { + typeOptions.SimilarItemProviderOrder = [.. missing, .. order]; + } + + _logger.LogInformation("Enabled local similarity providers {Providers} for '{ItemType}' in library '{LibraryName}'.", missing, typeOptions.Type, libraryName); + return true; + } + + private Dictionary<string, string[]> GetLocalProvidersByItemType() + { + var result = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase); + + foreach (var summary in _providerManager.GetAllMetadataPlugins()) + { + var names = summary.Plugins + .Where(p => p.Type == MetadataPluginType.LocalSimilarityProvider) + .Select(p => p.Name) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (names.Length > 0) + { + result[summary.ItemType] = names; + } + } + + return result; + } +} diff --git a/Jellyfin.Server/Migrations/Routines/RemoveDuplicatePlaylistChildren.cs b/Jellyfin.Server/Migrations/Routines/RemoveDuplicatePlaylistChildren.cs deleted file mode 100644 index 23f212424b..0000000000 --- a/Jellyfin.Server/Migrations/Routines/RemoveDuplicatePlaylistChildren.cs +++ /dev/null @@ -1,58 +0,0 @@ -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; - -/// <summary> -/// Remove duplicate playlist entries. -/// </summary> -#pragma warning disable CS0618 // Type or member is obsolete -[JellyfinMigration("2025-04-20T19:00:00", nameof(RemoveDuplicatePlaylistChildren), "96C156A2-7A13-4B3B-A8B8-FB80C94D20C0")] -internal class RemoveDuplicatePlaylistChildren : IMigrationRoutine -#pragma warning restore CS0618 // Type or member is obsolete -{ - private readonly ILibraryManager _libraryManager; - private readonly IPlaylistManager _playlistManager; - - public RemoveDuplicatePlaylistChildren( - ILibraryManager libraryManager, - IPlaylistManager playlistManager) - { - _libraryManager = libraryManager; - _playlistManager = playlistManager; - } - - /// <inheritdoc/> - public void Perform() - { - var playlists = _libraryManager.GetItemList(new InternalItemsQuery - { - IncludeItemTypes = [BaseItemKind.Playlist] - }) - .Cast<Playlist>() - .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/Migrations/Stages/CodeMigration.cs b/Jellyfin.Server/Migrations/Stages/CodeMigration.cs index 971b47608f..71706811b8 100644 --- a/Jellyfin.Server/Migrations/Stages/CodeMigration.cs +++ b/Jellyfin.Server/Migrations/Stages/CodeMigration.cs @@ -4,8 +4,6 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Server.ServerSetupApp; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Logging; namespace Jellyfin.Server.Migrations.Stages; @@ -22,66 +20,45 @@ internal class CodeMigration(Type migrationType, JellyfinMigrationAttribute meta return Metadata.Order.ToString("yyyyMMddHHmmsss", CultureInfo.InvariantCulture) + "_" + Metadata.Name!; } - private IServiceCollection MigrationServices(IServiceProvider serviceProvider, IStartupLogger logger) + public async Task Perform(IServiceProvider serviceProvider, IStartupLogger logger, CancellationToken cancellationToken) { - var childServiceCollection = new ServiceCollection() - .AddSingleton(serviceProvider) - .AddSingleton(logger) - .AddSingleton(typeof(IStartupLogger<>), typeof(NestedStartupLogger<>)) - .AddSingleton<StartupLogTopic>(logger.Topic!); - - foreach (ServiceDescriptor service in serviceProvider.GetRequiredService<IServiceCollection>()) - { - if (service.Lifetime == ServiceLifetime.Singleton && !service.ServiceType.IsGenericTypeDefinition) - { - childServiceCollection.AddSingleton(service.ServiceType, _ => serviceProvider.GetService(service.ServiceType)!); - continue; - } - - childServiceCollection.Add(service); - } - - return childServiceCollection; - } - - public async Task Perform(IServiceProvider? serviceProvider, IStartupLogger logger, CancellationToken cancellationToken) - { -#pragma warning disable CS0618 // Type or member is obsolete - if (typeof(IMigrationRoutine).IsAssignableFrom(MigrationType)) - { - if (serviceProvider is null) - { - ((IMigrationRoutine)Activator.CreateInstance(MigrationType)!).Perform(); - } - else - { - using var migrationServices = MigrationServices(serviceProvider, logger).BuildServiceProvider(); - ((IMigrationRoutine)ActivatorUtilities.CreateInstance(migrationServices, MigrationType)).Perform(); -#pragma warning restore CS0618 // Type or member is obsolete - } - } - else if (typeof(IAsyncMigrationRoutine).IsAssignableFrom(MigrationType)) - { - if (serviceProvider is null) - { - await ((IAsyncMigrationRoutine)Activator.CreateInstance(MigrationType)!).PerformAsync(cancellationToken).ConfigureAwait(false); - } - else - { - using var migrationServices = MigrationServices(serviceProvider, logger).BuildServiceProvider(); - await ((IAsyncMigrationRoutine)ActivatorUtilities.CreateInstance(migrationServices, MigrationType)).PerformAsync(cancellationToken).ConfigureAwait(false); - } - } - else + if (!IsMigrationRoutine(MigrationType)) { throw new InvalidOperationException($"The type {MigrationType} does not implement either IMigrationRoutine or IAsyncMigrationRoutine and is not a valid migration type"); } - } - private class NestedStartupLogger<TCategory> : StartupLogger<TCategory> - { - public NestedStartupLogger(ILogger logger, StartupLogTopic topic) : base(logger, topic) + // The routine runs against a scope of the applications own container. Copying the application service + // descriptors into a child container instead would make that child container the owner of every singleton it + // forwards, so disposing it after the migration would also dispose the applications own instance of services + // like the ProviderManager and leave the server broken until the next restart. + var scope = serviceProvider.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) { + // Nests everything the routine logs through an injected IStartupLogger under the migrations own topic. + using (StartupLogger.BeginAmbientTopic(logger.Topic)) + { + await RunAsync(ActivatorUtilities.CreateInstance(scope.ServiceProvider, MigrationType), cancellationToken).ConfigureAwait(false); + } } } + + // The obsolete IMigrationRoutine is still implemented by every routine that predates the async interface, so + // the members that have to touch it are grouped here behind a single suppression. +#pragma warning disable CS0618 // Type or member is obsolete + private static bool IsMigrationRoutine(Type migrationType) + { + return typeof(IMigrationRoutine).IsAssignableFrom(migrationType) || typeof(IAsyncMigrationRoutine).IsAssignableFrom(migrationType); + } + + private static async Task RunAsync(object routine, CancellationToken cancellationToken) + { + if (routine is IMigrationRoutine migrationRoutine) + { + migrationRoutine.Perform(); + return; + } + + await ((IAsyncMigrationRoutine)routine).PerformAsync(cancellationToken).ConfigureAwait(false); + } +#pragma warning restore CS0618 // Type or member is obsolete } diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs index e1856b60d4..8390c91313 100644 --- a/Jellyfin.Server/Program.cs +++ b/Jellyfin.Server/Program.cs @@ -62,6 +62,7 @@ namespace Jellyfin.Server private static ILogger _logger = NullLogger.Instance; private static bool _restartOnShutdown; private static IStartupLogger<JellyfinMigrationService>? _migrationLogger; + private static bool _optimizeDatabaseAfterMigration; private static string? _restoreFromBackup; /// <summary> @@ -134,11 +135,13 @@ namespace Jellyfin.Server } } + SetupServer.ReportActivity(StartupActivity.CheckingStorage); StorageHelper.TestCommonPathsForStorageCapacity(appPaths, StartupLogger.Logger.With(_loggerFactory.CreateLogger<Startup>()).BeginGroup($"Storage Check")); StartupHelpers.PerformStaticInitialization(); - await ApplyStartupMigrationAsync(appPaths, startupConfig).ConfigureAwait(false); + SetupServer.ReportActivity(StartupActivity.Initializing); + await ApplyStartupMigrationAsync(appPaths, startupConfig, options).ConfigureAwait(false); do { @@ -162,7 +165,6 @@ namespace Jellyfin.Server _loggerFactory, options, startupConfig); - _appHost = appHost; var configurationCompleted = false; try { @@ -180,9 +182,7 @@ namespace Jellyfin.Server }) .ConfigureAppConfiguration(config => config.ConfigureAppConfiguration(options, appPaths, startupConfig)) .UseSerilog() - .ConfigureServices(e => e - .RegisterStartupLogger() - .AddSingleton<IServiceCollection>(e)) + .ConfigureServices(e => e.RegisterStartupLogger()) .Build(); /* @@ -197,6 +197,7 @@ namespace Jellyfin.Server if (!string.IsNullOrWhiteSpace(_restoreFromBackup)) { + SetupServer.ReportActivity(StartupActivity.RestoringBackup); await appHost.ServiceProvider.GetService<IBackupService>()!.RestoreBackupAsync(_restoreFromBackup).ConfigureAwait(false); _restoreFromBackup = null; _restartOnShutdown = true; @@ -204,24 +205,34 @@ namespace Jellyfin.Server } var jellyfinMigrationService = ActivatorUtilities.CreateInstance<JellyfinMigrationService>(appHost.ServiceProvider); + SetupServer.ReportActivity(StartupActivity.PreparingMigrations); await jellyfinMigrationService.PrepareSystemForMigration(_logger).ConfigureAwait(false); - await jellyfinMigrationService.MigrateStepAsync(JellyfinMigrationStageTypes.CoreInitialisation, appHost.ServiceProvider).ConfigureAwait(false); + // "Preparing migrations" carries through the DB read; per-migration progress is reported + // as "Running migration X of Y" from inside the step once the pending set is known. + _optimizeDatabaseAfterMigration |= await jellyfinMigrationService.MigrateStepAsync(JellyfinMigrationStageTypes.CoreInitialisation, appHost.ServiceProvider).ConfigureAwait(false); + SetupServer.ReportActivity(StartupActivity.InitializingServices); await appHost.InitializeServices(startupConfig).ConfigureAwait(false); + _appHost = appHost; - await jellyfinMigrationService.MigrateStepAsync(JellyfinMigrationStageTypes.AppInitialisation, appHost.ServiceProvider).ConfigureAwait(false); + _optimizeDatabaseAfterMigration |= await jellyfinMigrationService.MigrateStepAsync(JellyfinMigrationStageTypes.AppInitialisation, appHost.ServiceProvider).ConfigureAwait(false); await jellyfinMigrationService.CleanupSystemAfterMigration(_logger).ConfigureAwait(false); + await OptimizeDatabaseAfterMigrationAsync(appHost.ServiceProvider).ConfigureAwait(false); try { configurationCompleted = true; await _setupServer!.StopAsync().ConfigureAwait(false); - await _jellyfinHost.StartAsync().ConfigureAwait(false); - if (!OperatingSystem.IsWindows() && startupConfig.UseUnixSocket()) + if (options.StartupMode is null or Configuration.StartupMode.MediaServer) { - var socketPath = StartupHelpers.GetUnixSocketPath(startupConfig, appPaths); + await _jellyfinHost.StartAsync().ConfigureAwait(false); - StartupHelpers.SetUnixSocketPermissions(startupConfig, socketPath, _logger); + if (!OperatingSystem.IsWindows() && startupConfig.UseUnixSocket()) + { + var socketPath = StartupHelpers.GetUnixSocketPath(startupConfig, appPaths); + + StartupHelpers.SetUnixSocketPermissions(startupConfig, socketPath, _logger); + } } } catch (Exception) @@ -230,11 +241,14 @@ namespace Jellyfin.Server throw; } - await appHost.RunStartupTasksAsync().ConfigureAwait(false); + if (options.StartupMode is null or Configuration.StartupMode.MediaServer) + { + await appHost.RunStartupTasksAsync().ConfigureAwait(false); + _logger.LogInformation("Startup complete {Time:g}", Stopwatch.GetElapsedTime(_startTimestamp)); - _logger.LogInformation("Startup complete {Time:g}", Stopwatch.GetElapsedTime(_startTimestamp)); + await _jellyfinHost.WaitForShutdownAsync().ConfigureAwait(false); + } - await _jellyfinHost.WaitForShutdownAsync().ConfigureAwait(false); _restartOnShutdown = appHost.ShouldRestart; _restoreFromBackup = appHost.RestoreBackupPath; } @@ -245,7 +259,11 @@ namespace Jellyfin.Server if (_setupServer!.IsAlive && !configurationCompleted) { _setupServer!.SoftStop(); - await Task.Delay(TimeSpan.FromMinutes(10)).ConfigureAwait(false); + if (options.StartupMode is null or Configuration.StartupMode.MediaServer) + { + await Task.Delay(TimeSpan.FromMinutes(10)).ConfigureAwait(false); + } + await _setupServer!.StopAsync().ConfigureAwait(false); } } @@ -254,16 +272,16 @@ namespace Jellyfin.Server // Don't throw additional exception if startup failed. if (appHost.ServiceProvider is not null) { - _logger.LogInformation("Running query planner optimizations in the database... This might take a while"); + _logger.LogInformation("Optimizing the database... This might take a while"); + // Deliberately untimed: a truncated optimization leaves the statistics incomplete. var databaseProvider = appHost.ServiceProvider.GetRequiredService<IJellyfinDatabaseProvider>(); - using var shutdownSource = new CancellationTokenSource(); - shutdownSource.CancelAfter((int)TimeSpan.FromSeconds(60).TotalMicroseconds); - await databaseProvider.RunShutdownTask(shutdownSource.Token).ConfigureAwait(false); + await databaseProvider.RunShutdownTask(CancellationToken.None).ConfigureAwait(false); } _appHost = null; _jellyfinHost?.Dispose(); + _jellyfinHost = null; } } @@ -275,8 +293,9 @@ namespace Jellyfin.Server /// </remarks> /// <param name="appPaths">Application Paths.</param> /// <param name="startupConfig">Startup Config.</param> + /// <param name="startupOptions">The applications startup options.</param> /// <returns>A task.</returns> - public static async Task ApplyStartupMigrationAsync(ServerApplicationPaths appPaths, IConfiguration startupConfig) + public static async Task ApplyStartupMigrationAsync(ServerApplicationPaths appPaths, IConfiguration startupConfig, StartupOptions startupOptions) { _migrationLogger = StartupLogger.Logger.BeginGroup<JellyfinMigrationService>($"Migration Service"); var startupConfigurationManager = new ServerConfigurationManager(appPaths, _loggerFactory, new MyXmlSerializer()); @@ -286,20 +305,19 @@ namespace Jellyfin.Server .AddJellyfinDbContext(startupConfigurationManager, startupConfig) .AddSingleton<IApplicationPaths>(appPaths) .AddSingleton<ServerApplicationPaths>(appPaths) - // Required by NpgsqlDataSource factory in AddJellyfinDbContext when + // Required by the NpgsqlDataSource factory in AddJellyfinDbContext when // DatabaseType=Jellyfin-PostgreSQL — the factory resolves this from DI // to read CustomProviderOptions and pool settings. .AddSingleton<IServerConfigurationManager>(startupConfigurationManager) .RegisterStartupLogger(); - migrationStartupServiceProvider.AddSingleton(migrationStartupServiceProvider); var startupService = migrationStartupServiceProvider.BuildServiceProvider(); PrepareDatabaseProvider(startupService); var jellyfinMigrationService = ActivatorUtilities.CreateInstance<JellyfinMigrationService>(startupService); - await jellyfinMigrationService.CheckFirstTimeRunOrMigration(appPaths).ConfigureAwait(false); - await jellyfinMigrationService.MigrateStepAsync(Migrations.Stages.JellyfinMigrationStageTypes.PreInitialisation, startupService).ConfigureAwait(false); + await jellyfinMigrationService.CheckFirstTimeRunOrMigration(appPaths, startupOptions).ConfigureAwait(false); + _optimizeDatabaseAfterMigration |= await jellyfinMigrationService.MigrateStepAsync(Migrations.Stages.JellyfinMigrationStageTypes.PreInitialisation, startupService).ConfigureAwait(false); } /// <summary> @@ -314,7 +332,32 @@ namespace Jellyfin.Server public static async Task ApplyCoreMigrationsAsync(IServiceProvider serviceProvider, Migrations.Stages.JellyfinMigrationStageTypes jellyfinMigrationStage) { var jellyfinMigrationService = ActivatorUtilities.CreateInstance<JellyfinMigrationService>(serviceProvider, _migrationLogger!); - await jellyfinMigrationService.MigrateStepAsync(jellyfinMigrationStage, serviceProvider).ConfigureAwait(false); + _optimizeDatabaseAfterMigration |= await jellyfinMigrationService.MigrateStepAsync(jellyfinMigrationStage, serviceProvider).ConfigureAwait(false); + } + + private static async Task OptimizeDatabaseAfterMigrationAsync(IServiceProvider serviceProvider) + { + if (!_optimizeDatabaseAfterMigration) + { + return; + } + + // Reset first: a restart runs no migrations and must not optimize again. + _optimizeDatabaseAfterMigration = false; + SetupServer.ReportActivity(StartupActivity.OptimizingDatabase); + _logger.LogInformation("Migrations have been applied, optimizing the database... This might take a while"); + + try + { + // Deliberately untimed: incomplete statistics are worse than a slow start. + var databaseProvider = serviceProvider.GetRequiredService<IJellyfinDatabaseProvider>(); + await databaseProvider.RunScheduledOptimisation(CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + // A missed optimization only costs performance, so never fail startup over this. + _logger.LogError(ex, "Error while optimizing the database after migration"); + } } /// <summary> diff --git a/Jellyfin.Server/Resources/Configuration/logging.json b/Jellyfin.Server/Resources/Configuration/logging.json index f64a85219d..ac5d9f60bc 100644 --- a/Jellyfin.Server/Resources/Configuration/logging.json +++ b/Jellyfin.Server/Resources/Configuration/logging.json @@ -11,7 +11,7 @@ { "Name": "Console", "Args": { - "outputTemplate": "[{Timestamp:HH:mm:ss}] [{Level:u3}] [{ThreadId}] {SourceContext}: {Message:lj}{NewLine}{Exception}" + "outputTemplate": "[{Timestamp:HH:mm:ss.fff}] [{Level:u3}] [{ThreadId}] {SourceContext}: {Message:lj}{NewLine}{Exception}" } }, { diff --git a/Jellyfin.Server/ServerSetupApp/SetupServer.cs b/Jellyfin.Server/ServerSetupApp/SetupServer.cs index 00d9fcc025..598de5aa5f 100644 --- a/Jellyfin.Server/ServerSetupApp/SetupServer.cs +++ b/Jellyfin.Server/ServerSetupApp/SetupServer.cs @@ -14,7 +14,6 @@ using Jellyfin.Server.Extensions; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Net; using MediaBrowser.Controller; -using MediaBrowser.Model.IO; using MediaBrowser.Model.System; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -25,9 +24,6 @@ using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; -using Morestachio; -using Morestachio.Framework.IO.SingleStream; -using Morestachio.Rendering; using Serilog; using ILogger = Microsoft.Extensions.Logging.ILogger; @@ -44,7 +40,8 @@ public sealed class SetupServer : IDisposable private readonly ILoggerFactory _loggerFactory; private readonly IConfiguration _startupConfiguration; private readonly ServerConfigurationManager _configurationManager; - private IRenderer? _startupUiRenderer; + private static volatile string _currentActivity = StartupActivity.Starting; + private StartupUiRenderer? _startupUiRenderer; private IHost? _startupServer; private bool _disposed; private bool _isUnhealthy; @@ -76,6 +73,12 @@ public sealed class SetupServer : IDisposable internal static ConcurrentQueue<StartupLogTopic>? LogQueue { get; set; } = new(); + /// <summary> + /// Gets a generic, non-identifying summary of what startup is currently doing. This is shown in the + /// always-visible header of the startup UI to unauthenticated clients, so it never contains server specific details. + /// </summary> + internal static string CurrentActivity => _currentActivity; + /// <summary> /// Gets a value indicating whether Startup server is currently running. /// </summary> @@ -87,61 +90,14 @@ public sealed class SetupServer : IDisposable /// <returns>A Task.</returns> public async Task RunAsync() { - var fileTemplate = await File.ReadAllTextAsync(Path.Combine(AppContext.BaseDirectory, "ServerSetupApp", "index.mstemplate.html")).ConfigureAwait(false); - _startupUiRenderer = (await ParserOptionsBuilder.New() - .WithTemplate(fileTemplate) - .WithFormatter( - (StartupLogTopic logEntry, IEnumerable<StartupLogTopic> children) => - { - if (children.Any()) - { - var maxLevel = logEntry.LogLevel; - var stack = new Stack<StartupLogTopic>(children); - - while (maxLevel != LogLevel.Error && stack.Count > 0 && (logEntry = stack.Pop()) is not null) // error is the highest inherted error level. - { - maxLevel = maxLevel < logEntry.LogLevel ? logEntry.LogLevel : maxLevel; - foreach (var child in logEntry.Children) - { - stack.Push(child); - } - } - - return maxLevel; - } - - return logEntry.LogLevel; - }, - "FormatLogLevel") - .WithFormatter( - (LogLevel logLevel) => - { - switch (logLevel) - { - case LogLevel.Trace: - case LogLevel.Debug: - case LogLevel.None: - return "success"; - case LogLevel.Information: - return "info"; - case LogLevel.Warning: - return "warn"; - case LogLevel.Error: - return "danger"; - case LogLevel.Critical: - return "danger-strong"; - } - - return string.Empty; - }, - "ToString") - .BuildAndParseAsync() - .ConfigureAwait(false)) - .CreateCompiledRenderer(); + ReportActivity(StartupActivity.Starting); + _startupUiRenderer = await StartupUiRenderer.CreateAsync( + Path.Combine(AppContext.BaseDirectory, "ServerSetupApp", "index.mstemplate.html")).ConfigureAwait(false); ThrowIfDisposed(); var retryAfterValue = TimeSpan.FromSeconds(5); var config = _configurationManager.GetNetworkConfiguration()!; + _startupServer?.Dispose(); _startupServer = Host.CreateDefaultBuilder(["hostBuilder:reloadConfigOnChange=false"]) .UseConsoleLifetime() .UseSerilog() @@ -162,7 +118,7 @@ public sealed class SetupServer : IDisposable { var knownBindInterfaces = NetworkManager.GetInterfacesCore(_loggerFactory.CreateLogger<SetupServer>(), 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); + var bindInterfaces = NetworkManager.GetAllBindInterfaces(_loggerFactory.CreateLogger<NetworkManager>(), false, _configurationManager, knownBindInterfaces, config.EnableIPv4, config.EnableIPv6); Extensions.WebHostBuilderExtensions.SetupJellyfinWebServer( bindInterfaces, config.InternalHttpPort, @@ -236,6 +192,7 @@ public sealed class SetupServer : IDisposable }); }); + var version = typeof(Emby.Server.Implementations.ApplicationHost).Assembly.GetName().Version!; app.Run(async (context) => { context.Response.StatusCode = (int)HttpStatusCode.ServiceUnavailable; @@ -248,12 +205,14 @@ public sealed class SetupServer : IDisposable new Dictionary<string, object>() { { "isInReportingMode", _isUnhealthy }, + { "currentActivity", CurrentActivity }, { "retryValue", retryAfterValue }, + { "version", version }, { "logs", startupLogEntries }, { "networkManagerReady", networkManager is not null }, { "localNetworkRequest", networkManager is not null && context.Connection.RemoteIpAddress is not null && networkManager.IsInLocalNetwork(context.Connection.RemoteIpAddress) } }, - new ByteCounterStream(context.Response.BodyWriter.AsStream(), IODefaults.FileStreamBufferSize, true, _startupUiRenderer.ParserOptions)) + context.Response.BodyWriter.AsStream()) .ConfigureAwait(false); }); }); @@ -299,6 +258,16 @@ public sealed class SetupServer : IDisposable ObjectDisposedException.ThrowIf(_disposed, this); } + /// <summary> + /// Reports the current startup activity shown to all clients in the startup UI header. + /// Only pass generic, non-identifying text from <see cref="StartupActivity"/>. + /// </summary> + /// <param name="activity">A generic description such as <see cref="StartupActivity.PreparingMigrations"/>.</param> + internal static void ReportActivity(string activity) + { + _currentActivity = activity; + } + internal void SoftStop() { _isUnhealthy = true; diff --git a/Jellyfin.Server/ServerSetupApp/StartupActivity.cs b/Jellyfin.Server/ServerSetupApp/StartupActivity.cs new file mode 100644 index 0000000000..abfc5bc8ba --- /dev/null +++ b/Jellyfin.Server/ServerSetupApp/StartupActivity.cs @@ -0,0 +1,44 @@ +using System.Globalization; + +namespace Jellyfin.Server.ServerSetupApp; + +/// <summary> +/// A curated vocabulary of generic, non-identifying descriptions of what the server is doing during startup. +/// These are shown in the always-visible header of the startup UI to <b>unauthenticated</b> clients, so every +/// value must stay generic and must never contain server specific details (paths, names, plugin or migration ids, counts of items, etc.). +/// </summary> +public static class StartupActivity +{ + /// <summary>The default state before any work has been reported.</summary> + public const string Starting = "Starting up"; + + /// <summary>Validating that the configured storage locations are usable.</summary> + public const string CheckingStorage = "Checking storage"; + + /// <summary>Bringing up the migration subsystem and running early startup checks.</summary> + public const string Initializing = "Initializing server"; + + /// <summary>Preparing the system for migrations (e.g. taking safety backups).</summary> + public const string PreparingMigrations = "Preparing migrations"; + + /// <summary>Restoring from a backup.</summary> + public const string RestoringBackup = "Restoring backup"; + + /// <summary>Bringing up core services and plugins.</summary> + public const string InitializingServices = "Initializing services"; + + /// <summary>Refreshing the database statistics after migrations have run.</summary> + public const string OptimizingDatabase = "Optimizing database"; + + /// <summary>Running the final startup tasks.</summary> + public const string FinishingStartup = "Finishing startup"; + + /// <summary> + /// Builds a generic "Running migration X of Y" description. Only the numeric position and total are exposed. + /// </summary> + /// <param name="current">The 1-based index of the migration currently running.</param> + /// <param name="total">The total number of migrations in this batch.</param> + /// <returns>A generic progress description.</returns> + public static string Migration(int current, int total) + => string.Format(CultureInfo.InvariantCulture, "Running migration {0} of {1}", current, total); +} diff --git a/Jellyfin.Server/ServerSetupApp/StartupLogger.cs b/Jellyfin.Server/ServerSetupApp/StartupLogger.cs index 0121854ce3..b72b0c0eab 100644 --- a/Jellyfin.Server/ServerSetupApp/StartupLogger.cs +++ b/Jellyfin.Server/ServerSetupApp/StartupLogger.cs @@ -1,5 +1,6 @@ using System; using System.Globalization; +using System.Threading; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -8,6 +9,8 @@ namespace Jellyfin.Server.ServerSetupApp; /// <inheritdoc/> public class StartupLogger : IStartupLogger { + private static readonly AsyncLocal<StartupLogTopic?> _ambientTopic = new(); + private readonly StartupLogTopic? _topic; /// <summary> @@ -17,6 +20,7 @@ public class StartupLogger : IStartupLogger public StartupLogger(ILogger logger) { BaseLogger = logger; + _topic = _ambientTopic.Value; } /// <summary> @@ -39,6 +43,18 @@ public class StartupLogger : IStartupLogger /// </summary> protected ILogger BaseLogger { get; set; } + /// <summary> + /// Makes <paramref name="topic"/> the topic that loggers created on this execution context attach to. + /// </summary> + /// <param name="topic">The topic to nest newly created loggers under.</param> + /// <returns>A scope that restores the previously ambient topic when disposed.</returns> + internal static IDisposable BeginAmbientTopic(StartupLogTopic? topic) + { + var scope = new AmbientTopicScope(_ambientTopic.Value); + _ambientTopic.Value = topic; + return scope; + } + /// <inheritdoc/> public IStartupLogger BeginGroup(FormattableString logEntry) { @@ -121,4 +137,19 @@ public class StartupLogger : IStartupLogger Topic.Children.Add(startupEntry); } } + + private sealed class AmbientTopicScope : IDisposable + { + private readonly StartupLogTopic? _previous; + + public AmbientTopicScope(StartupLogTopic? previous) + { + _previous = previous; + } + + public void Dispose() + { + _ambientTopic.Value = _previous; + } + } } diff --git a/Jellyfin.Server/ServerSetupApp/StartupUiRenderer.cs b/Jellyfin.Server/ServerSetupApp/StartupUiRenderer.cs new file mode 100644 index 0000000000..db07b9d8c1 --- /dev/null +++ b/Jellyfin.Server/ServerSetupApp/StartupUiRenderer.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.Logging; +using Morestachio; +using Morestachio.Framework.IO.SingleStream; +using Morestachio.Rendering; + +namespace Jellyfin.Server.ServerSetupApp; + +/// <summary> +/// Compiles and renders the startup UI Morestachio template. +/// Shared by the live <see cref="SetupServer"/> and the standalone startup UI preview tool so both +/// exercise the exact same template and formatters. +/// </summary> +public sealed class StartupUiRenderer +{ + private readonly IRenderer _renderer; + + private StartupUiRenderer(IRenderer renderer) + { + _renderer = renderer; + } + + /// <summary> + /// Compiles the startup UI template located at <paramref name="templatePath"/>. + /// </summary> + /// <param name="templatePath">The full path to the <c>index.mstemplate.html</c> template.</param> + /// <returns>A ready to use <see cref="StartupUiRenderer"/>.</returns> + public static async Task<StartupUiRenderer> CreateAsync(string templatePath) + { + var fileTemplate = await File.ReadAllTextAsync(templatePath).ConfigureAwait(false); + var renderer = (await ParserOptionsBuilder.New() + .WithTemplate(fileTemplate) + .WithFormatter( + (Version version, int arg) => + { + // version type does not for some stupid reason implement IFormattable which morestachio relies on for ToString support therefor we need to do it manually. + return version.ToString(arg); + }, + "ToString") + .WithFormatter( + (StartupLogTopic logEntry, IEnumerable<StartupLogTopic> children) => + { + if (children.Any()) + { + var maxLevel = logEntry.LogLevel; + var stack = new Stack<StartupLogTopic>(children); + + while (maxLevel != LogLevel.Error && stack.Count > 0 && (logEntry = stack.Pop()) is not null) // error is the highest inherted error level. + { + maxLevel = maxLevel < logEntry.LogLevel ? logEntry.LogLevel : maxLevel; + foreach (var child in logEntry.Children) + { + stack.Push(child); + } + } + + return maxLevel; + } + + return logEntry.LogLevel; + }, + "FormatLogLevel") + .WithFormatter( + (LogLevel logLevel) => + { + switch (logLevel) + { + case LogLevel.Trace: + case LogLevel.Debug: + case LogLevel.None: + return "success"; + case LogLevel.Information: + return "info"; + case LogLevel.Warning: + return "warn"; + case LogLevel.Error: + return "danger"; + case LogLevel.Critical: + return "danger-strong"; + } + + return string.Empty; + }, + "ToString") + .BuildAndParseAsync() + .ConfigureAwait(false)) + .CreateCompiledRenderer(); + + return new StartupUiRenderer(renderer); + } + + /// <summary> + /// Renders the template with the provided model into the target stream. + /// </summary> + /// <param name="model">The values made available to the template.</param> + /// <param name="output">The stream the rendered HTML is written to.</param> + /// <returns>A Task.</returns> + public Task RenderAsync(IDictionary<string, object> model, Stream output) + { + return _renderer.RenderAsync( + model, + new ByteCounterStream(output, IODefaults.FileStreamBufferSize, true, _renderer.ParserOptions)); + } +} diff --git a/Jellyfin.Server/ServerSetupApp/index.mstemplate.html b/Jellyfin.Server/ServerSetupApp/index.mstemplate.html index 9ec6efa2b9..9c12762c31 100644 --- a/Jellyfin.Server/ServerSetupApp/index.mstemplate.html +++ b/Jellyfin.Server/ServerSetupApp/index.mstemplate.html @@ -1,189 +1,469 @@ <!DOCTYPE html> -<html> +<html lang="en"> <head> <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <meta name="color-scheme" content="dark" /> + <meta name="theme-color" content="#202020" /> <title> {{#IF isInReportingMode}} ❌ {{/IF}} - Jellyfin Startup + Jellyfin -
-
+
+ {{^IF isInReportingMode}} +
+ +

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

+
+ {{#ELSE}} +
+

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

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

Startup log

+ Download logs +
+
+
    + {{#FOREACH log IN logs}} + {{#IMPORT 'LogEntry' #WITH log}} + {{/FOREACH}} +
+
- {{#ELSE}} - {{#IF networkManagerReady}} -

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

- {{#ELSE}} -

Initializing network settings. Please wait.

- {{/ELSE}} - {{/IF}} - {{/ELSE}} {{/IF}}
+ -{{^IF isInReportingMode}} - -{{/IF}} - diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs index 5032b2aec1..1802440dc4 100644 --- a/Jellyfin.Server/Startup.cs +++ b/Jellyfin.Server/Startup.cs @@ -1,4 +1,5 @@ using System; +using System.Globalization; using System.IO; using System.Net; using System.Net.Http; @@ -6,6 +7,7 @@ using System.Net.Http.Headers; using System.Net.Mime; using System.Text; using Emby.Server.Implementations.EntryPoints; +using Emby.Server.Implementations.Localization; using Jellyfin.Api.Middleware; using Jellyfin.Database.Implementations; using Jellyfin.LiveTv.Extensions; @@ -22,6 +24,7 @@ using MediaBrowser.Controller.Extensions; using MediaBrowser.XbmcMetadata; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Localization; using Microsoft.AspNetCore.StaticFiles; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -127,6 +130,25 @@ namespace Jellyfin.Server services.AddHlsPlaylistGenerator(); services.AddLiveTvServices(); + var serverUICulture = _serverConfigurationManager.Configuration.UICulture; + if (string.IsNullOrEmpty(serverUICulture)) + { + serverUICulture = "en-US"; + } + + CultureInfo.DefaultThreadCurrentUICulture = new CultureInfo(serverUICulture); + + services.Configure(options => + { + var supportedUICultures = LocalizationManager.GetSupportedUICultures(); + options.SupportedCultures = supportedUICultures; + options.SupportedUICultures = supportedUICultures; + options.DefaultRequestCulture = new RequestCulture(serverUICulture); + options.ApplyCurrentCultureToResponseHeaders = true; + options.FallBackToParentCultures = true; + options.FallBackToParentUICultures = true; + }); + services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); @@ -168,14 +190,13 @@ namespace Jellyfin.Server mainApp.UseCors(); + mainApp.UseRequestLocalization(); + if (config.RequireHttps && _serverApplicationHost.ListenWithHttps) { mainApp.UseHttpsRedirection(); } - // This must be injected before any path related middleware. - mainApp.UsePathTrim(); - if (appConfig.HostWebClient()) { var extensionProvider = new FileExtensionContentTypeProvider(); diff --git a/Jellyfin.Server/StartupOptions.cs b/Jellyfin.Server/StartupOptions.cs index 4890ccbb2e..4716bc1746 100644 --- a/Jellyfin.Server/StartupOptions.cs +++ b/Jellyfin.Server/StartupOptions.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using CommandLine; using Emby.Server.Implementations; +using Jellyfin.Server.Configuration; using static MediaBrowser.Controller.Extensions.ConfigurationExtensions; namespace Jellyfin.Server @@ -79,6 +80,13 @@ namespace Jellyfin.Server [Option("restore-archive", Required = false, HelpText = "Path to a Jellyfin backup archive to restore from")] public string? RestoreArchive { get; set; } + /// + /// Gets or sets the mode of operation the server should perform when started. + /// Defaults to: . + /// + [Option("mode", Required = false, HelpText = "Mode which selects what action the jellyfin server should perform when started.")] + public StartupMode? StartupMode { get; set; } + /// /// Gets the command line options as a dictionary that can be used in the .NET configuration system. /// diff --git a/Jellyfin.sln b/Jellyfin.sln index 5048aeccba..c8ef244319 100644 --- a/Jellyfin.sln +++ b/Jellyfin.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.30503.244 +# Visual Studio Version 18 +VisualStudioVersion = 18.0.11222.15 d18.0 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Server", "Jellyfin.Server\Jellyfin.Server.csproj", "{07E39F42-A2C6-4B32-AF8C-725F957A73FF}" EndProject @@ -30,6 +30,7 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{41093F42-C7CC-4D07-956B-6182CBEDE2EC}" ProjectSection(SolutionItems) = preProject .editorconfig = .editorconfig + Directory.Packages.props = Directory.Packages.props SharedVersion.cs = SharedVersion.cs EndProjectSection EndProject @@ -106,6 +107,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tools", "tools", "{3C85DA50 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.DbMigrator", "tools\Jellyfin.DbMigrator\Jellyfin.DbMigrator.csproj", "{6F7187CB-E1CB-4583-98CF-0FB87F21E844}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Drawing.Skia.Tests", "tests\Jellyfin.Drawing.Skia.Tests\Jellyfin.Drawing.Skia.Tests.csproj", "{E24A279C-9A37-419A-8F9C-853C11FBE753}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -284,6 +287,10 @@ Global {6F7187CB-E1CB-4583-98CF-0FB87F21E844}.Debug|Any CPU.Build.0 = Debug|Any CPU {6F7187CB-E1CB-4583-98CF-0FB87F21E844}.Release|Any CPU.ActiveCfg = Release|Any CPU {6F7187CB-E1CB-4583-98CF-0FB87F21E844}.Release|Any CPU.Build.0 = Release|Any CPU + {E24A279C-9A37-419A-8F9C-853C11FBE753}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E24A279C-9A37-419A-8F9C-853C11FBE753}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E24A279C-9A37-419A-8F9C-853C11FBE753}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E24A279C-9A37-419A-8F9C-853C11FBE753}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -319,6 +326,7 @@ Global {8C9F9221-8415-496C-B1F5-E7756F03FA59} = {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD} {11643D0F-6761-4EF7-AB71-6F9F8DE00714} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C} {6F7187CB-E1CB-4583-98CF-0FB87F21E844} = {3C85DA50-31AC-40D3-BCF4-F1B14C420996} + {E24A279C-9A37-419A-8F9C-853C11FBE753} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3448830C-EBDC-426C-85CD-7BBB9651A7FE} diff --git a/MediaBrowser.Common/MediaBrowser.Common.csproj b/MediaBrowser.Common/MediaBrowser.Common.csproj index 86c9079a8c..de07c7f2cb 100644 --- a/MediaBrowser.Common/MediaBrowser.Common.csproj +++ b/MediaBrowser.Common/MediaBrowser.Common.csproj @@ -1,4 +1,4 @@ - + @@ -8,7 +8,7 @@ Jellyfin Contributors Jellyfin.Common - 10.11.7 + 12.0.0 https://github.com/jellyfin/jellyfin GPL-3.0-only @@ -18,17 +18,12 @@ - - - - - - net9.0 + net10.0 false true true diff --git a/MediaBrowser.Common/Net/NetworkConstants.cs b/MediaBrowser.Common/Net/NetworkConstants.cs index ccef5d2719..cec996a1aa 100644 --- a/MediaBrowser.Common/Net/NetworkConstants.cs +++ b/MediaBrowser.Common/Net/NetworkConstants.cs @@ -1,5 +1,4 @@ using System.Net; -using IPNetwork = Microsoft.AspNetCore.HttpOverrides.IPNetwork; namespace MediaBrowser.Common.Net; diff --git a/MediaBrowser.Common/Net/NetworkUtils.cs b/MediaBrowser.Common/Net/NetworkUtils.cs index 24ed47a81b..25a1022d4e 100644 --- a/MediaBrowser.Common/Net/NetworkUtils.cs +++ b/MediaBrowser.Common/Net/NetworkUtils.cs @@ -6,7 +6,8 @@ using System.Net; using System.Net.Sockets; using System.Text.RegularExpressions; using Jellyfin.Extensions; -using IPNetwork = Microsoft.AspNetCore.HttpOverrides.IPNetwork; +using MediaBrowser.Model.Net; +using Microsoft.Extensions.Logging; namespace MediaBrowser.Common.Net; @@ -166,8 +167,9 @@ public static partial class NetworkUtils /// Input string array to be parsed. /// Collection of . /// Boolean signaling if negated or not negated values should be parsed. + /// Optional logger used to warn about entries that fail to parse. /// True if parsing was successful. - public static bool TryParseToSubnets(string[] values, [NotNullWhen(true)] out IReadOnlyList? result, bool negated = false) + public static bool TryParseToSubnets(string[] values, [NotNullWhen(true)] out IReadOnlyList? result, bool negated = false, ILogger? logger = null) { if (values is null || values.Length == 0) { @@ -175,28 +177,68 @@ public static partial class NetworkUtils return false; } - var tmpResult = new List(); + List? tmpResult = null; for (int a = 0; a < values.Length; a++) { + // Skip entries whose '!' polarity doesn't match this pass + var trimmed = values[a].AsSpan().Trim(); + if (trimmed.StartsWith('!') != negated) + { + continue; + } + if (TryParseToSubnet(values[a], out var innerResult, negated)) { - tmpResult.Add(innerResult); + (tmpResult ??= []).Add(innerResult); + } + else + { + LogInvalidSubnet(logger, values[a]); } } result = tmpResult; - return tmpResult.Count > 0; + return result is not null; + } + + private static void LogInvalidSubnet(ILogger? logger, string value) + { + if (logger is null) + { + return; + } + + var trimmed = value.AsSpan().Trim(); + if (trimmed.StartsWith('!')) + { + trimmed = trimmed[1..]; + } + + var slash = trimmed.IndexOf('/'); + if (slash != -1 + && trimmed.Contains(':') + && trimmed.IndexOf("::", StringComparison.Ordinal) == -1) + { + logger.LogWarning( + "Invalid IPv6 subnet '{Subnet}': IPv6 prefix-only notation is not supported. Use the full notation including '::' (e.g. '{Example}::/{Prefix}').", + value, + trimmed[..slash].ToString(), + trimmed[(slash + 1)..].ToString()); + return; + } + + logger.LogWarning("Invalid subnet '{Subnet}' will be ignored.", value); } /// - /// Try parsing a string into an , respecting exclusions. - /// Inputs without a subnet mask will be represented as with a single IP. + /// Try parsing a string into an , respecting exclusions. + /// Inputs without a subnet mask will be represented as with a single IP. /// /// Input string to be parsed. - /// An . + /// An . /// Boolean signaling if negated or not negated values should be parsed. /// True if parsing was successful. - public static bool TryParseToSubnet(ReadOnlySpan value, [NotNullWhen(true)] out IPNetwork? result, bool negated = false) + public static bool TryParseToSubnet(ReadOnlySpan value, [NotNullWhen(true)] out IPData? 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(); @@ -210,14 +252,16 @@ public static partial class NetworkUtils if (isAddressNegated != negated) { - result = null; + result = default; return false; } - if (value.Contains('/')) + var index = value.IndexOf('/'); + if (index != -1) { - if (IPNetwork.TryParse(value, out result)) + if (IPAddress.TryParse(value[..index], out var address) && IPNetwork.TryParse(value, out var subnet)) { + result = new IPData(address, subnet); return true; } } @@ -225,17 +269,17 @@ public static partial class NetworkUtils { if (address.AddressFamily == AddressFamily.InterNetwork) { - result = address.Equals(IPAddress.Any) ? NetworkConstants.IPv4Any : new IPNetwork(address, NetworkConstants.MinimumIPv4PrefixSize); + result = address.Equals(IPAddress.Any) ? new IPData(IPAddress.Any, NetworkConstants.IPv4Any) : new IPData(address, new IPNetwork(address, NetworkConstants.MinimumIPv4PrefixSize)); return true; } else if (address.AddressFamily == AddressFamily.InterNetworkV6) { - result = address.Equals(IPAddress.IPv6Any) ? NetworkConstants.IPv6Any : new IPNetwork(address, NetworkConstants.MinimumIPv6PrefixSize); + result = address.Equals(IPAddress.IPv6Any) ? new IPData(IPAddress.IPv6Any, NetworkConstants.IPv6Any) : new IPData(address, new IPNetwork(address, NetworkConstants.MinimumIPv6PrefixSize)); return true; } } - result = null; + result = default; return false; } @@ -330,7 +374,7 @@ public static partial class NetworkUtils /// The broadcast address. public static IPAddress GetBroadcastAddress(IPNetwork network) { - var addressBytes = network.Prefix.GetAddressBytes(); + var addressBytes = network.BaseAddress.GetAddressBytes(); uint ipAddress = BitConverter.ToUInt32(addressBytes, 0); uint ipMaskV4 = BitConverter.ToUInt32(CidrToMask(network.PrefixLength, AddressFamily.InterNetwork).GetAddressBytes(), 0); uint broadCastIPAddress = ipAddress | ~ipMaskV4; @@ -347,7 +391,6 @@ public static partial class NetworkUtils public static bool SubnetContainsAddress(IPNetwork network, IPAddress address) { ArgumentNullException.ThrowIfNull(address); - ArgumentNullException.ThrowIfNull(network); if (address.IsIPv4MappedToIPv6) { diff --git a/MediaBrowser.Common/Plugins/LocalPlugin.cs b/MediaBrowser.Common/Plugins/LocalPlugin.cs index 96af423cc3..221dda7d5f 100644 --- a/MediaBrowser.Common/Plugins/LocalPlugin.cs +++ b/MediaBrowser.Common/Plugins/LocalPlugin.cs @@ -72,6 +72,14 @@ namespace MediaBrowser.Common.Plugins /// public bool IsEnabledAndSupported => _supported && Manifest.Status >= PluginStatus.Active; + /// + /// Gets or sets a value indicating whether a restart is required for the plugin's state to take effect. + /// + /// + /// Memory only. holds the state that is persisted to disk. + /// + public bool RestartRequired { get; set; } + /// /// Gets a value indicating whether the plugin has a manifest. /// @@ -108,8 +116,8 @@ namespace MediaBrowser.Common.Plugins public PluginInfo GetPluginInfo() { var inst = Instance?.GetPluginInfo() ?? new PluginInfo(Manifest.Name, Version, Manifest.Description, Manifest.Id, true); - inst.Status = Manifest.Status; - inst.HasImage = !string.IsNullOrEmpty(Manifest.ImagePath); + inst.Status = RestartRequired ? PluginStatus.Restart : Manifest.Status; + inst.HasImage = !string.IsNullOrEmpty(Manifest.ImagePath) || !string.IsNullOrEmpty(Manifest.ImageResourceName); return inst; } diff --git a/MediaBrowser.Common/Plugins/PluginManifest.cs b/MediaBrowser.Common/Plugins/PluginManifest.cs index e0847ccea4..e749e85899 100644 --- a/MediaBrowser.Common/Plugins/PluginManifest.cs +++ b/MediaBrowser.Common/Plugins/PluginManifest.cs @@ -107,6 +107,15 @@ namespace MediaBrowser.Common.Plugins [JsonPropertyName("imagePath")] public string? ImagePath { get; set; } + /// + /// Gets or sets the name of an embedded resource in the plugin's assembly + /// that should be served as the plugin image. + /// Used by bundled/integrated plugins whose images are shipped inside the assembly + /// rather than on disk. Ignored when is set. + /// + [JsonIgnore] + public string? ImageResourceName { get; set; } + /// /// Gets or sets the collection of assemblies that should be loaded. /// Paths are considered relative to the plugin folder. diff --git a/MediaBrowser.Common/Providers/SubtitleConfigurationFactory.cs b/MediaBrowser.Common/Providers/SubtitleConfigurationFactory.cs deleted file mode 100644 index 0445397ad8..0000000000 --- a/MediaBrowser.Common/Providers/SubtitleConfigurationFactory.cs +++ /dev/null @@ -1,21 +0,0 @@ -#pragma warning disable CS1591 - -using System.Collections.Generic; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Model.Providers; - -namespace MediaBrowser.Common.Providers -{ - public class SubtitleConfigurationFactory : IConfigurationFactory - { - /// - public IEnumerable GetConfigurations() - { - yield return new ConfigurationStore() - { - Key = "subtitles", - ConfigurationType = typeof(SubtitleOptions) - }; - } - } -} diff --git a/MediaBrowser.Controller/Authentication/IAuthenticationProvider.cs b/MediaBrowser.Controller/Authentication/IAuthenticationProvider.cs index 976a667acd..c993ceea8e 100644 --- a/MediaBrowser.Controller/Authentication/IAuthenticationProvider.cs +++ b/MediaBrowser.Controller/Authentication/IAuthenticationProvider.cs @@ -14,8 +14,6 @@ namespace MediaBrowser.Controller.Authentication Task Authenticate(string username, string password); - bool HasPassword(User user); - Task ChangePassword(User user, string newPassword); } diff --git a/MediaBrowser.Controller/Channels/ChannelItemResult.cs b/MediaBrowser.Controller/Channels/ChannelItemResult.cs index ca7721991d..9557c91964 100644 --- a/MediaBrowser.Controller/Channels/ChannelItemResult.cs +++ b/MediaBrowser.Controller/Channels/ChannelItemResult.cs @@ -1,19 +1,29 @@ -#pragma warning disable CS1591 - using System; using System.Collections.Generic; namespace MediaBrowser.Controller.Channels { + /// + /// The result of a channel item query. + /// public class ChannelItemResult { + /// + /// Initializes a new instance of the class. + /// public ChannelItemResult() { Items = Array.Empty(); } + /// + /// Gets or sets the items. + /// public IReadOnlyList Items { get; set; } + /// + /// Gets or sets the total record count. + /// public int? TotalRecordCount { get; set; } } } diff --git a/MediaBrowser.Controller/Channels/ChannelItemType.cs b/MediaBrowser.Controller/Channels/ChannelItemType.cs index 3ce920e236..2608cb4c88 100644 --- a/MediaBrowser.Controller/Channels/ChannelItemType.cs +++ b/MediaBrowser.Controller/Channels/ChannelItemType.cs @@ -1,11 +1,18 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Controller.Channels { + /// + /// The type of a channel item. + /// public enum ChannelItemType { + /// + /// The item is a media item. + /// Media = 0, + /// + /// The item is a folder. + /// Folder = 1 } } diff --git a/MediaBrowser.Controller/Channels/ChannelLatestMediaSearch.cs b/MediaBrowser.Controller/Channels/ChannelLatestMediaSearch.cs index ebbe13763b..c6530814b9 100644 --- a/MediaBrowser.Controller/Channels/ChannelLatestMediaSearch.cs +++ b/MediaBrowser.Controller/Channels/ChannelLatestMediaSearch.cs @@ -1,11 +1,15 @@ #nullable disable -#pragma warning disable CS1591 - namespace MediaBrowser.Controller.Channels { + /// + /// The request for a latest media search in a channel. + /// public class ChannelLatestMediaSearch { + /// + /// Gets or sets the user id. + /// public string UserId { get; set; } } } diff --git a/MediaBrowser.Controller/Channels/ChannelParentalRating.cs b/MediaBrowser.Controller/Channels/ChannelParentalRating.cs index f77d81c166..a5a1ba5bf6 100644 --- a/MediaBrowser.Controller/Channels/ChannelParentalRating.cs +++ b/MediaBrowser.Controller/Channels/ChannelParentalRating.cs @@ -1,17 +1,33 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Controller.Channels { + /// + /// The parental rating of a channel. + /// public enum ChannelParentalRating { + /// + /// Suitable for a general audience. + /// GeneralAudience = 0, + /// + /// Parental guidance suggested (US PG). + /// UsPG = 1, + /// + /// Parents strongly cautioned (US PG-13). + /// UsPG13 = 2, + /// + /// Restricted (US R). + /// UsR = 3, + /// + /// Suitable for adults only. + /// Adult = 4 } } diff --git a/MediaBrowser.Controller/Channels/ChannelSearchInfo.cs b/MediaBrowser.Controller/Channels/ChannelSearchInfo.cs index 990b025bcb..d172b98b25 100644 --- a/MediaBrowser.Controller/Channels/ChannelSearchInfo.cs +++ b/MediaBrowser.Controller/Channels/ChannelSearchInfo.cs @@ -1,13 +1,20 @@ #nullable disable -#pragma warning disable CS1591 - namespace MediaBrowser.Controller.Channels { + /// + /// The request for a search in a channel. + /// public class ChannelSearchInfo { + /// + /// Gets or sets the search term. + /// public string SearchTerm { get; set; } + /// + /// Gets or sets the user id. + /// public string UserId { get; set; } } } diff --git a/MediaBrowser.Controller/Channels/IHasCacheKey.cs b/MediaBrowser.Controller/Channels/IHasCacheKey.cs index 7d5207c34a..4cdda38bd9 100644 --- a/MediaBrowser.Controller/Channels/IHasCacheKey.cs +++ b/MediaBrowser.Controller/Channels/IHasCacheKey.cs @@ -1,14 +1,15 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Controller.Channels { + /// + /// Interface for channels that provide a cache key. + /// public interface IHasCacheKey { /// /// Gets the cache key. /// /// The user identifier. - /// System.String. + /// The cache key. string? GetCacheKey(string? userId); } } diff --git a/MediaBrowser.Controller/Channels/ISupportsDelete.cs b/MediaBrowser.Controller/Channels/ISupportsDelete.cs index 0110bfa7a3..194654ca9e 100644 --- a/MediaBrowser.Controller/Channels/ISupportsDelete.cs +++ b/MediaBrowser.Controller/Channels/ISupportsDelete.cs @@ -1,15 +1,27 @@ -#pragma warning disable CS1591 - using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Entities; namespace MediaBrowser.Controller.Channels { + /// + /// Interface for channels that support deleting items. + /// public interface ISupportsDelete { + /// + /// Gets a value indicating whether the item can be deleted. + /// + /// The item. + /// true if the item can be deleted, false otherwise. bool CanDelete(BaseItem item); + /// + /// Deletes the item with the provided id. + /// + /// The item id. + /// The cancellation token. + /// A task representing the deletion of the item. Task DeleteItem(string id, CancellationToken cancellationToken); } } diff --git a/MediaBrowser.Controller/Channels/ISupportsLatestMedia.cs b/MediaBrowser.Controller/Channels/ISupportsLatestMedia.cs index 1935ec0f5f..82ca45d3ad 100644 --- a/MediaBrowser.Controller/Channels/ISupportsLatestMedia.cs +++ b/MediaBrowser.Controller/Channels/ISupportsLatestMedia.cs @@ -1,11 +1,12 @@ -#pragma warning disable CS1591 - using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; namespace MediaBrowser.Controller.Channels { + /// + /// Interface for channels that support retrieving the latest media. + /// public interface ISupportsLatestMedia { /// diff --git a/MediaBrowser.Controller/Chapters/IChapterManager.cs b/MediaBrowser.Controller/Chapters/IChapterManager.cs index 25656fd625..edc20205aa 100644 --- a/MediaBrowser.Controller/Chapters/IChapterManager.cs +++ b/MediaBrowser.Controller/Chapters/IChapterManager.cs @@ -13,12 +13,19 @@ namespace MediaBrowser.Controller.Chapters; /// public interface IChapterManager { + /// + /// Gets a value indicating whether the specified item type is supported for chapter operations. + /// + /// The item to check. + /// true if the item type supports chapters; otherwise, false. + bool Supports(BaseItem item); + /// /// Saves the chapters. /// - /// The video. + /// The item. /// The set of chapters. - void SaveChapters(Video video, IReadOnlyList chapters); + void SaveChapters(BaseItem item, IReadOnlyList chapters); /// /// Gets a single chapter of a BaseItem on a specific index. diff --git a/MediaBrowser.Controller/ClientEvent/ClientEventLogger.cs b/MediaBrowser.Controller/ClientEvent/ClientEventLogger.cs index 14dc64dabd..36f0d2195c 100644 --- a/MediaBrowser.Controller/ClientEvent/ClientEventLogger.cs +++ b/MediaBrowser.Controller/ClientEvent/ClientEventLogger.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.Threading.Tasks; +using Jellyfin.Extensions; namespace MediaBrowser.Controller.ClientEvent { @@ -21,8 +22,15 @@ namespace MediaBrowser.Controller.ClientEvent /// public async Task WriteDocumentAsync(string clientName, string clientVersion, Stream fileContents) { - var fileName = $"upload_{clientName}_{clientVersion}_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}.log"; + var safeClientName = PathHelper.GetSafeLeafFileName(clientName) ?? "unknown-client"; + var safeClientVersion = PathHelper.GetSafeLeafFileName(clientVersion) ?? "unknown-version"; + var fileName = $"upload_{safeClientName}_{safeClientVersion}_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}.log"; var logFilePath = Path.Combine(_applicationPaths.LogDirectoryPath, fileName); + if (!PathHelper.IsContainedIn(_applicationPaths.LogDirectoryPath, logFilePath)) + { + throw new ArgumentException("Path resolved to filename not in log directory"); + } + var fileStream = new FileStream(logFilePath, FileMode.CreateNew, FileAccess.Write, FileShare.None); await using (fileStream.ConfigureAwait(false)) { diff --git a/MediaBrowser.Controller/Collections/ICollectionManager.cs b/MediaBrowser.Controller/Collections/ICollectionManager.cs index 206b5ac426..8d5d54ffd9 100644 --- a/MediaBrowser.Controller/Collections/ICollectionManager.cs +++ b/MediaBrowser.Controller/Collections/ICollectionManager.cs @@ -57,6 +57,14 @@ namespace MediaBrowser.Controller.Collections /// IEnumerable{BaseItem}. IEnumerable CollapseItemsWithinBoxSets(IEnumerable items, User user); + /// + /// Gets the collections accessible to the supplied user that contain the provided item. + /// + /// The user. + /// The item identifier. + /// The collections containing the item. + IEnumerable GetCollectionsContainingItem(User user, Guid itemId); + /// /// Gets the folder where collections are stored. /// diff --git a/MediaBrowser.Controller/Drawing/ImageHelper.cs b/MediaBrowser.Controller/Drawing/ImageHelper.cs index 9ef92bc981..6f26b7d912 100644 --- a/MediaBrowser.Controller/Drawing/ImageHelper.cs +++ b/MediaBrowser.Controller/Drawing/ImageHelper.cs @@ -11,7 +11,9 @@ namespace MediaBrowser.Controller.Drawing // Determine the output size based on incoming parameters var newSize = DrawingUtils.Resize(originalImageSize, options.Width ?? 0, options.Height ?? 0, options.MaxWidth ?? 0, options.MaxHeight ?? 0); newSize = DrawingUtils.ResizeFill(newSize, options.FillWidth, options.FillHeight); - return newSize; + + // Never encode larger than the source. + return DrawingUtils.ScaleDownToFit(newSize, originalImageSize); } } } diff --git a/MediaBrowser.Controller/Dto/DtoOptions.cs b/MediaBrowser.Controller/Dto/DtoOptions.cs index a71cdbd62c..052626355f 100644 --- a/MediaBrowser.Controller/Dto/DtoOptions.cs +++ b/MediaBrowser.Controller/Dto/DtoOptions.cs @@ -1,5 +1,3 @@ -#pragma warning disable CS1591 - using System; using System.Collections.Generic; using System.Linq; @@ -8,13 +6,16 @@ using MediaBrowser.Model.Querying; namespace MediaBrowser.Controller.Dto { + /// + /// Options that control which fields and images are populated when building a . + /// public class DtoOptions { - private static readonly ItemFields[] DefaultExcludedFields = new[] - { + private static readonly ItemFields[] DefaultExcludedFields = + [ ItemFields.SeasonUserData, ItemFields.RefreshState - }; + ]; private static readonly ImageType[] AllImageTypes = Enum.GetValues(); @@ -22,11 +23,18 @@ namespace MediaBrowser.Controller.Dto .Except(DefaultExcludedFields) .ToArray(); + /// + /// Initializes a new instance of the class with all fields enabled. + /// public DtoOptions() : this(true) { } + /// + /// Initializes a new instance of the class. + /// + /// Whether to populate all available fields. public DtoOptions(bool allFields) { ImageTypeLimit = int.MaxValue; @@ -38,23 +46,54 @@ namespace MediaBrowser.Controller.Dto ImageTypes = AllImageTypes; } + /// + /// Gets or sets the fields to populate on the DTO. + /// public IReadOnlyList Fields { get; set; } + /// + /// Gets or sets the image types to populate on the DTO. + /// public IReadOnlyList ImageTypes { get; set; } + /// + /// Gets or sets the maximum number of images to return per image type. + /// public int ImageTypeLimit { get; set; } + /// + /// Gets or sets a value indicating whether image information is populated. + /// public bool EnableImages { get; set; } + /// + /// Gets or sets a value indicating whether program recording information is populated. + /// public bool AddProgramRecordingInfo { get; set; } + /// + /// Gets or sets a value indicating whether user data is populated. + /// public bool EnableUserData { get; set; } + /// + /// Gets or sets a value indicating whether the currently airing program is populated. + /// public bool AddCurrentProgram { get; set; } + /// + /// Gets a value indicating whether the specified field is populated. + /// + /// The field to check. + /// true if the field is populated; otherwise, false. public bool ContainsField(ItemFields field) => Fields.Contains(field); + /// + /// Gets the number of images to return for the specified image type. + /// + /// The image type. + /// The image limit for the type, or 0 if the type is not enabled. public int GetImageLimit(ImageType type) { if (EnableImages && ImageTypes.Contains(type)) diff --git a/MediaBrowser.Controller/Dto/IDtoService.cs b/MediaBrowser.Controller/Dto/IDtoService.cs index f1d507fcbd..f735abb09f 100644 --- a/MediaBrowser.Controller/Dto/IDtoService.cs +++ b/MediaBrowser.Controller/Dto/IDtoService.cs @@ -36,8 +36,14 @@ namespace MediaBrowser.Controller.Dto /// The options. /// The user. /// The owner. + /// Skip redundant visibility check if items are already filtered. /// The of . - IReadOnlyList GetBaseItemDtos(IReadOnlyList items, DtoOptions options, User? user = null, BaseItem? owner = null); + IReadOnlyList GetBaseItemDtos( + IReadOnlyList items, + DtoOptions options, + User? user = null, + BaseItem? owner = null, + bool skipVisibilityCheck = false); /// /// Gets the item by name dto. diff --git a/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs b/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs index 58841e5b78..1e2d94d2a4 100644 --- a/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs +++ b/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs @@ -154,11 +154,6 @@ namespace MediaBrowser.Controller.Entities.Audio return "Artist-" + (Name ?? string.Empty).RemoveDiacritics(); } - protected override bool GetBlockUnratedValue(User user) - { - return user.GetPreferenceValues(PreferenceKind.BlockUnratedItems).Contains(UnratedItem.Music); - } - public override UnratedItem GetBlockUnratedType() { return UnratedItem.Music; @@ -178,10 +173,7 @@ namespace MediaBrowser.Controller.Entities.Audio public static string GetPath(string name, bool normalizeName) { - // Trim the period at the end because windows will have a hard time with that - var validName = normalizeName ? - FileSystem.GetValidFilename(name).Trim().TrimEnd('.') : - name; + var validName = normalizeName ? GetItemByNameFolderName(name) : name; return System.IO.Path.Combine(ConfigurationManager.ApplicationPaths.ArtistsPath, validName); } diff --git a/MediaBrowser.Controller/Entities/Audio/MusicGenre.cs b/MediaBrowser.Controller/Entities/Audio/MusicGenre.cs index 65669e6804..23b3341dbc 100644 --- a/MediaBrowser.Controller/Entities/Audio/MusicGenre.cs +++ b/MediaBrowser.Controller/Entities/Audio/MusicGenre.cs @@ -80,10 +80,7 @@ namespace MediaBrowser.Controller.Entities.Audio public static string GetPath(string name, bool normalizeName) { - // Trim the period at the end because windows will have a hard time with that - var validName = normalizeName ? - FileSystem.GetValidFilename(name).Trim().TrimEnd('.') : - name; + var validName = normalizeName ? GetItemByNameFolderName(name) : name; return System.IO.Path.Combine(ConfigurationManager.ApplicationPaths.MusicGenrePath, validName); } diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index 252a5d8b47..d030c8f420 100644 --- a/MediaBrowser.Controller/Entities/BaseItem.cs +++ b/MediaBrowser.Controller/Entities/BaseItem.cs @@ -22,13 +22,12 @@ 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.IO; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaSegments; using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; @@ -49,6 +48,10 @@ namespace MediaBrowser.Controller.Entities public const string ThemeSongFileName = "theme"; + // Well below the 255 byte limit of the common Linux filesystems and the 255 character limit + // of Windows, so the files inside the folder still fit within MAX_PATH. + private const int MaxItemByNameFolderNameBytes = 128; + /// /// The supported image extensions. /// @@ -89,12 +92,16 @@ namespace MediaBrowser.Controller.Entities Model.Entities.ExtraType.Short }; + private protected static readonly char[] VersionDelimiters = ['-', '_', '.']; + private string _sortName; private string _forcedSortName; private string _name; + private string _originalLanguage; + public const char SlugChar = '-'; protected BaseItem() @@ -107,7 +114,6 @@ namespace MediaBrowser.Controller.Entities ImageInfos = Array.Empty(); ProductionLocations = Array.Empty(); RemoteTrailers = Array.Empty(); - ExtraIds = Array.Empty(); UserData = []; } @@ -218,6 +224,13 @@ namespace MediaBrowser.Controller.Entities [JsonIgnore] public string OriginalTitle { get; set; } + [JsonIgnore] + public string OriginalLanguage + { + get => _originalLanguage; + set => _originalLanguage = LocalizationManager?.FindLanguageInfo(value)?.TwoLetterISOLanguageName ?? value; + } + /// /// Gets or sets the id. /// @@ -398,8 +411,6 @@ namespace MediaBrowser.Controller.Entities public int Height { get; set; } - public Guid[] ExtraIds { get; set; } - /// /// Gets the primary image path. /// @@ -492,6 +503,8 @@ namespace MediaBrowser.Controller.Entities public static IItemRepository ItemRepository { get; set; } + public static IItemCountService ItemCountService { get; set; } + public static IChapterManager ChapterManager { get; set; } public static IFileSystem FileSystem { get; set; } @@ -532,8 +545,8 @@ namespace MediaBrowser.Controller.Entities { if (!string.IsNullOrEmpty(ForcedSortName)) { - // Need the ToLower because that's what CreateSortName does - _sortName = ModifySortChunks(ForcedSortName).ToLowerInvariant(); + // Run the forced sort name through the same cleaning as auto-generated sort names. + _sortName = GetSortName(ForcedSortName, EnableAlphaNumericSorting, ConfigurationManager.Configuration); } else { @@ -762,6 +775,17 @@ namespace MediaBrowser.Controller.Entities [JsonIgnore] protected virtual bool SupportsOwnedItems => !ParentId.IsEmpty() && IsFileProtocol; + /// + /// Gets a value indicating whether this item searches the folder it lives in for its own extras. + /// + [JsonIgnore] + protected virtual bool SearchesContainingFolderForExtras => + IsFileProtocol + && SupportsOwnedItems + && !IsInMixedFolder + && this is not (ICollectionFolder or UserRootFolder or AggregateFolder) + && GetType() != typeof(Folder); + [JsonIgnore] public virtual bool SupportsPeople => false; @@ -918,19 +942,68 @@ namespace MediaBrowser.Controller.Entities /// System.String. protected virtual string CreateSortName() { - if (Name is null) + return GetSortName(Name, EnableAlphaNumericSorting, ConfigurationManager.Configuration); + } + + /// + /// Turns an item-by-name entity's name into a folder name every supported filesystem accepts. + /// + /// The entity's name. + /// The folder name. + public static string GetItemByNameFolderName(string name) + { + // Trim the period at the end because windows will have a hard time with that + var validName = FileSystem.GetValidFilename(name).Trim().TrimEnd('.'); + + // Most Linux filesystems cap a path component at 255 bytes, so a name past that cannot be + // turned into a folder at all - and an entity with no folder can never be created, which + // leaves the credit behind it stuck: not refreshable, not deletable, retried on every scan. + // Only broken provider data gets this long, but it still has to resolve to something, so + // keep a readable prefix and let a hash of the whole name tell two of them apart. + if (Encoding.UTF8.GetByteCount(validName) <= MaxItemByNameFolderNameBytes) + { + return validName; + } + + var suffix = "-" + validName.GetMD5().ToString("N", CultureInfo.InvariantCulture); + var budget = MaxItemByNameFolderNameBytes - suffix.Length; + var length = Math.Min(validName.Length, budget); + while (length > 0 && Encoding.UTF8.GetByteCount(validName.AsSpan(0, length)) > budget) + { + length--; + } + + // Never cut a surrogate pair in half, the lone half is not a valid file name character. + if (length > 0 && char.IsHighSurrogate(validName[length - 1])) + { + length--; + } + + return string.Concat(validName.AsSpan(0, length).TrimEnd().TrimEnd('.'), suffix); + } + + /// + /// Cleans a raw name into its sortable form by applying the configured sort rules. + /// + /// The raw name to clean. + /// Whether alphanumeric sorting rules should be applied. + /// The server configuration providing the sort rules. + /// The cleaned, sortable name, or null if is null. + public static string GetSortName(string name, bool enableAlphaNumericSorting, ServerConfiguration configuration) + { + if (name is null) { return null; // some items may not have name filled in properly } - if (!EnableAlphaNumericSorting) + if (!enableAlphaNumericSorting) { - return Name.TrimStart(); + return name.TrimStart(); } - var sortable = Name.Trim().ToLowerInvariant(); + var sortable = name.Trim().ToLowerInvariant(); - foreach (var search in ConfigurationManager.Configuration.SortRemoveWords) + foreach (var search in configuration.SortRemoveWords) { // Remove from beginning if a space follows if (sortable.StartsWith(search + " ", StringComparison.Ordinal)) @@ -948,12 +1021,12 @@ namespace MediaBrowser.Controller.Entities } } - foreach (var removeChar in ConfigurationManager.Configuration.SortRemoveCharacters) + foreach (var removeChar in configuration.SortRemoveCharacters) { sortable = sortable.Replace(removeChar, string.Empty, StringComparison.Ordinal); } - foreach (var replaceChar in ConfigurationManager.Configuration.SortReplaceCharacters) + foreach (var replaceChar in configuration.SortReplaceCharacters) { sortable = sortable.Replace(replaceChar, " ", StringComparison.Ordinal); } @@ -1093,8 +1166,9 @@ namespace MediaBrowser.Controller.Entities } } - var list = GetAllItemsForMediaSources(); - var result = list.Select(i => GetVersionInfo(enablePathSubstitution, i.Item, i.MediaSourceType)).ToList(); + var list = GetAllItemsForMediaSources().ToList(); + var commonPrefix = GetCommonNamePrefix(list); + var result = list.Select(i => GetVersionInfo(enablePathSubstitution, i.Item, i.MediaSourceType, commonPrefix)).ToList(); if (IsActiveRecording()) { @@ -1104,17 +1178,15 @@ namespace MediaBrowser.Controller.Entities } } - return result.OrderBy(i => - { - if (i.VideoType == VideoType.VideoFile) - { - return 0; - } + // The source belonging to the item being queried sorts first so it is the default the client plays. + var selfId = Id.ToString("N", CultureInfo.InvariantCulture); - return 1; - }).ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0) - .ThenByDescending(i => i, new MediaSourceWidthComparator()) - .ToArray(); + return result + .OrderByDescending(i => string.Equals(i.Id, selfId, StringComparison.OrdinalIgnoreCase)) + .ThenBy(i => i.VideoType == VideoType.VideoFile ? 0 : 1) + .ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0) + .ThenByDescending(i => i, new MediaSourceWidthComparator()) + .ToArray(); } protected virtual IEnumerable<(BaseItem Item, MediaSourceType MediaSourceType)> GetAllItemsForMediaSources() @@ -1122,20 +1194,12 @@ namespace MediaBrowser.Controller.Entities return Enumerable.Empty<(BaseItem, MediaSourceType)>(); } - private MediaSourceInfo GetVersionInfo(bool enablePathSubstitution, BaseItem item, MediaSourceType type) + private MediaSourceInfo GetVersionInfo(bool enablePathSubstitution, BaseItem item, MediaSourceType type, string commonPrefix = null) { ArgumentNullException.ThrowIfNull(item); var protocol = item.PathProtocol; - - // Resolve the item path so everywhere we use the media source it will always point to - // the correct path even if symlinks are in use. Calling ResolveLinkTarget on a non-link - // path will return null, so it's safe to check for all paths. var itemPath = item.Path; - if (protocol is MediaProtocol.File && FileSystemHelper.ResolveLinkTarget(itemPath, returnFinalTarget: true) is { Exists: true } linkInfo) - { - itemPath = linkInfo.FullName; - } var info = new MediaSourceInfo { @@ -1143,7 +1207,7 @@ namespace MediaBrowser.Controller.Entities Protocol = protocol ?? MediaProtocol.File, MediaStreams = MediaSourceManager.GetMediaStreams(item.Id), MediaAttachments = MediaSourceManager.GetMediaAttachments(item.Id), - Name = GetMediaSourceName(item), + Name = GetMediaSourceName(item, commonPrefix), Path = enablePathSubstitution ? GetMappedPath(item, itemPath, protocol) : itemPath, RunTimeTicks = item.RunTimeTicks, Container = item.Container, @@ -1222,7 +1286,7 @@ namespace MediaBrowser.Controller.Entities return info; } - internal string GetMediaSourceName(BaseItem item) + internal string GetMediaSourceName(BaseItem item, string commonPrefix = null) { var terms = new List(); @@ -1230,12 +1294,31 @@ namespace MediaBrowser.Controller.Entities if (item.IsFileProtocol && !string.IsNullOrEmpty(path)) { var displayName = System.IO.Path.GetFileNameWithoutExtension(path); - if (HasLocalAlternateVersions) + + // Prefer the suffix that differs from the other versions: strip the prefix shared by + // all sibling files. This works regardless of folder layout, so it also labels episode + // versions that share a season folder (e.g. "Greyscale" instead of the full + // "Show - S01E02 - Title - Greyscale"). The prefix is already retreated to a delimiter + // boundary (see GetCommonVersionPrefix). + if (!string.IsNullOrEmpty(commonPrefix) + && displayName.Length > commonPrefix.Length + && displayName.StartsWith(commonPrefix, StringComparison.OrdinalIgnoreCase)) + { + var name = displayName.AsSpan(commonPrefix.Length).TrimStart([' ', .. VersionDelimiters]); + if (!name.IsWhiteSpace()) + { + terms.Add(name.ToString()); + } + } + + // Fall back to the containing folder name (the common layout for movie versions, and + // the path taken when no common prefix could be derived). + if (terms.Count == 0 && HasLocalAlternateVersions) { var containingFolderName = System.IO.Path.GetFileName(ContainingFolderPath); if (displayName.Length > containingFolderName.Length && displayName.StartsWith(containingFolderName, StringComparison.OrdinalIgnoreCase)) { - var name = displayName.AsSpan(containingFolderName.Length).TrimStart([' ', '-']); + var name = displayName.AsSpan(containingFolderName.Length).TrimStart([' ', .. VersionDelimiters]); if (!name.IsWhiteSpace()) { terms.Add(name.ToString()); @@ -1292,6 +1375,98 @@ namespace MediaBrowser.Controller.Entities return string.Join('/', terms); } + /// + /// Derives the prefix shared by the supplied media source items' file names, used to strip the + /// common part and surface a short version label per source. Returns null when there are fewer + /// than two file-based sources, since there is nothing to differentiate. + /// + /// The media source items. + /// The shared prefix, or null when no useful prefix exists. + private static string GetCommonNamePrefix(IReadOnlyList<(BaseItem Item, MediaSourceType MediaSourceType)> items) + { + var fileNames = new List(); + foreach (var (item, _) in items) + { + if (item.IsFileProtocol && !string.IsNullOrEmpty(item.Path)) + { + fileNames.Add(System.IO.Path.GetFileNameWithoutExtension(item.Path)); + } + } + + if (fileNames.Count < 2) + { + return null; + } + + var prefix = GetCommonVersionPrefix(fileNames); + return string.IsNullOrEmpty(prefix) ? null : prefix; + } + + /// + /// Computes the case-insensitive longest common prefix of the supplied version file names, + /// retreated to the last delimiter boundary. Retreating keeps the differing suffix intact: + /// it avoids slicing through a word every version shares (e.g. "Grey" in "Greyscale" and + /// "Greyish") while still trimming the common part when every version is suffixed (e.g. + /// "- Greyscale" / "- Colorized"). It prefers a structural delimiter ('-', '_', '.') so a + /// token shared by the descriptors but separated only by spaces (e.g. a common "2160p ") is + /// kept in the label, falling back to a space only when no structural delimiter is shared. The + /// separators mirror the version delimiters recognised by the naming layer (Emby.Naming + /// VideoFlagDelimiters). + /// + /// The version file names without extension; must contain at least one entry. + /// The shared prefix retreated to a separator boundary, or an empty string when none is shared. + internal static string GetCommonVersionPrefix(IReadOnlyList fileNames) + { + var prefix = fileNames[0]; + for (var i = 1; i < fileNames.Count && prefix.Length > 0; i++) + { + var name = fileNames[i]; + var length = Math.Min(prefix.Length, name.Length); + var common = 0; + while (common < length && char.ToUpperInvariant(prefix[common]) == char.ToUpperInvariant(name[common])) + { + common++; + } + + prefix = prefix[..common]; + } + + // If the common prefix is itself a whole file name then one version is unlabelled (the + // base name); the boundary already sits at the end of that name, so don't retreat into it. + var prefixIsWholeName = false; + for (var i = 0; i < fileNames.Count; i++) + { + if (fileNames[i].Length == prefix.Length) + { + prefixIsWholeName = true; + break; + } + } + + if (!prefixIsWholeName) + { + // Retreat to the last structural delimiter ('-', '_', '.'). + var cut = prefix.Length; + while (cut > 0 && Array.IndexOf(VersionDelimiters, prefix[cut - 1]) < 0) + { + cut--; + } + + if (cut == 0) + { + cut = prefix.Length; + while (cut > 0 && prefix[cut - 1] != ' ') + { + cut--; + } + } + + prefix = prefix[..cut]; + } + + return prefix; + } + public Task RefreshMetadata(CancellationToken cancellationToken) { return RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(FileSystem)), cancellationToken); @@ -1341,14 +1516,15 @@ namespace MediaBrowser.Controller.Entities return false; } - if (GetParents().Any(i => !i.IsVisible(user, true))) + var parents = GetParents().ToList(); + if (parents.Any(i => !i.IsVisible(user, true))) { return false; } if (checkFolders) { - var topParent = GetParents().LastOrDefault() ?? this; + var topParent = parents.Count > 0 ? parents[^1] : this; if (string.IsNullOrEmpty(topParent.Path)) { @@ -1359,8 +1535,27 @@ namespace MediaBrowser.Controller.Entities if (itemCollectionFolders.Count > 0) { - var userCollectionFolders = LibraryManager.GetUserRootFolder().GetChildren(user, true).Select(i => i.Id).ToList(); - if (!itemCollectionFolders.Any(userCollectionFolders.Contains)) + var blockedMediaFolders = user.GetPreferenceValues(PreferenceKind.BlockedMediaFolders); + IEnumerable userCollectionFolderIds; + if (blockedMediaFolders.Length > 0) + { + // User has blocked folders - get all library folders and exclude blocked ones + userCollectionFolderIds = LibraryManager.GetUserRootFolder().Children + .Select(i => i.Id) + .Where(id => !blockedMediaFolders.Contains(id)); + } + else if (user.HasPermission(PermissionKind.EnableAllFolders)) + { + // User can access all folders - no need to filter + return true; + } + else + { + // User has specific enabled folders + userCollectionFolderIds = user.GetPreferenceValues(PreferenceKind.EnabledFolders); + } + + if (!itemCollectionFolders.Any(userCollectionFolderIds.Contains)) { return false; } @@ -1385,7 +1580,14 @@ namespace MediaBrowser.Controller.Entities /// true if any items have changed, else false. protected virtual async Task RefreshedOwnedItems(MetadataRefreshOptions options, IReadOnlyList fileSystemChildren, CancellationToken cancellationToken) { - if (!IsFileProtocol || !SupportsOwnedItems || IsInMixedFolder || this is ICollectionFolder or UserRootFolder or AggregateFolder || this.GetType() == typeof(Folder)) + if (!SearchesContainingFolderForExtras) + { + return false; + } + + if (GetParent() is Folder container + && container.SearchesContainingFolderForExtras + && string.Equals(container.Path, ContainingFolderPath, StringComparison.OrdinalIgnoreCase)) { return false; } @@ -1400,32 +1602,63 @@ namespace MediaBrowser.Controller.Entities private async Task RefreshExtras(BaseItem item, MetadataRefreshOptions options, IReadOnlyList fileSystemChildren, CancellationToken cancellationToken) { + // An extra is owned by the version it is named after, so all of them are maintained together. + var currentExtras = LibraryManager.GetItemList(new InternalItemsQuery() + { + OwnerIds = item.GetOwnedVersionIds() + }).Where(e => e.ExtraType.HasValue).ToList(); + + var currentExtraIds = currentExtras.Select(e => e.Id).ToArray(); + + // Snapshot the persisted names before resolving, as FindExtras corrects the name on the + // items it hands back and may well hand back these very instances. + var currentExtraNames = new Dictionary(); + foreach (var extra in currentExtras) + { + currentExtraNames[extra.Id] = extra.Name; + } + var extras = LibraryManager.FindExtras(item, fileSystemChildren, options.DirectoryService).ToArray(); var newExtraIds = Array.ConvertAll(extras, x => x.Id); - var extrasChanged = !item.ExtraIds.SequenceEqual(newExtraIds); - if (!extrasChanged && !options.ReplaceAllMetadata && options.MetadataRefreshMode != MetadataRefreshMode.FullRefresh) + var renamedExtraIds = extras + .Where(e => currentExtraNames.TryGetValue(e.Id, out var oldName) && !string.Equals(oldName, e.Name, StringComparison.Ordinal)) + .Select(e => e.Id) + .ToHashSet(); + + var extrasChanged = !currentExtraIds.OrderBy(x => x).SequenceEqual(newExtraIds.OrderBy(x => x)); + + if (!extrasChanged && renamedExtraIds.Count == 0 && !options.ReplaceAllMetadata && options.MetadataRefreshMode != MetadataRefreshMode.FullRefresh) { + // The owner's dates may only have become known after its extras were created, so keep + // them in sync even when there is nothing to refresh. + foreach (var extra in currentExtras) + { + if (extra.ExtraType is not null && InheritDatesFromOwner(item, extra)) + { + await extra.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false); + } + } + return false; } - var ownerId = item.Id; - var tasks = extras.Select(i => { + var ownerId = item.GetOwnerIdForExtra(i); var subOptions = new MetadataRefreshOptions(options); - if (!i.OwnerId.Equals(ownerId) || !i.ParentId.IsEmpty()) + if (!i.OwnerId.Equals(ownerId) || !i.ParentId.IsEmpty() || renamedExtraIds.Contains(i.Id)) { - i.OwnerId = ownerId; - i.ParentId = Guid.Empty; subOptions.ForceSave = true; } + i.OwnerId = ownerId; + i.ParentId = Guid.Empty; + return RefreshMetadataForOwnedItem(i, true, subOptions, cancellationToken); }); - // Cleanup removed extras - var removedExtraIds = item.ExtraIds.Where(e => !newExtraIds.Contains(e)).ToArray(); + var removedExtraIds = currentExtraIds.Where(e => !newExtraIds.Contains(e)).ToArray(); if (removedExtraIds.Length > 0) { var removedExtras = LibraryManager.GetItemList(new InternalItemsQuery() @@ -1434,17 +1667,20 @@ namespace MediaBrowser.Controller.Entities }); foreach (var removedExtra in removedExtras) { - LibraryManager.DeleteItem(removedExtra, new DeleteOptions() + // Only delete items that are actual extras (have ExtraType set) + // Items with OwnerId but no ExtraType might be alternate versions, not extras + if (removedExtra.ExtraType.HasValue) { - DeleteFileLocation = false - }); + LibraryManager.DeleteItem(removedExtra, new DeleteOptions() + { + DeleteFileLocation = false + }); + } } } await Task.WhenAll(tasks).ConfigureAwait(false); - item.ExtraIds = newExtraIds; - return true; } @@ -1535,7 +1771,7 @@ namespace MediaBrowser.Controller.Entities } /// - /// Gets the preferred metadata language. + /// Gets the preferred metadata country code. /// /// System.String. public string GetPreferredMetadataCountryCode() @@ -1569,6 +1805,15 @@ namespace MediaBrowser.Controller.Entities return lang; } + /// + /// Gets the original language of the item, inheriting from parent items if necessary. + /// + /// System.String. + public virtual string GetInheritedOriginalLanguage() + { + return OriginalLanguage; + } + public virtual bool IsSaveLocalMetadataEnabled() { if (SourceType == SourceType.Channel) @@ -1608,11 +1853,10 @@ namespace MediaBrowser.Controller.Entities if (string.IsNullOrEmpty(rating)) { - Logger.LogDebug("{0} has no parental rating set.", Name); return !GetBlockUnratedValue(user); } - var ratingScore = LocalizationManager.GetRatingScore(rating); + var ratingScore = LocalizationManager.GetRatingScore(rating, GetPreferredMetadataCountryCode()); // Could not determine rating level if (ratingScore is null) @@ -1654,7 +1898,7 @@ namespace MediaBrowser.Controller.Entities return null; } - return LocalizationManager.GetRatingScore(rating); + return LocalizationManager.GetRatingScore(rating, GetPreferredMetadataCountryCode()); } public List GetInheritedTags() @@ -1675,10 +1919,28 @@ namespace MediaBrowser.Controller.Entities return list.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); } - private bool IsVisibleViaTags(User user, bool skipAllowedTagsCheck) + protected bool IsVisibleViaTags(User user, bool skipAllowedTagsCheck) { - var allTags = GetInheritedTags(); - if (user.GetPreference(PreferenceKind.BlockedTags).Any(i => allTags.Contains(i, StringComparison.OrdinalIgnoreCase))) + var blockedTags = user.GetPreference(PreferenceKind.BlockedTags); + var allowedTags = user.GetPreference(PreferenceKind.AllowedTags); + + if (blockedTags.Length == 0 && allowedTags.Length == 0) + { + return true; + } + + // Normalize tags using the same logic as database queries + var normalizedBlockedTags = blockedTags + .Where(t => !string.IsNullOrWhiteSpace(t)) + .Select(t => t.GetCleanValue()) + .ToHashSet(StringComparer.Ordinal); + + var normalizedItemTags = GetInheritedTags() + .Select(t => t.GetCleanValue()) + .ToHashSet(StringComparer.Ordinal); + + // Check blocked tags - item is hidden if it has any blocked tag + if (normalizedBlockedTags.Overlaps(normalizedItemTags)) { return false; } @@ -1689,10 +1951,18 @@ namespace MediaBrowser.Controller.Entities return true; } - var allowedTagsPreference = user.GetPreference(PreferenceKind.AllowedTags); - if (!skipAllowedTagsCheck && allowedTagsPreference.Length != 0 && !allowedTagsPreference.Any(i => allTags.Contains(i, StringComparison.OrdinalIgnoreCase))) + // Check allowed tags - item must have at least one allowed tag + if (!skipAllowedTagsCheck && allowedTags.Length > 0) { - return false; + var normalizedAllowedTags = allowedTags + .Where(t => !string.IsNullOrWhiteSpace(t)) + .Select(t => t.GetCleanValue()) + .ToHashSet(StringComparer.Ordinal); + + if (!normalizedAllowedTags.Overlaps(normalizedItemTags)) + { + return false; + } } return true; @@ -1805,10 +2075,23 @@ namespace MediaBrowser.Controller.Entities return item; } +#pragma warning disable CS0618 // Type or member is obsolete - fallback for legacy LinkedChild data private BaseItem FindLinkedChild(LinkedChild info) { - var path = info.Path; + // First try to find by ItemId (new preferred method) + if (info.ItemId.HasValue && !info.ItemId.Value.Equals(Guid.Empty)) + { + var item = LibraryManager.GetItemById(info.ItemId.Value); + if (item is not null) + { + return item; + } + Logger.LogWarning("Unable to find linked item by ItemId {0}", info.ItemId); + } + + // Fall back to Path (legacy method) + var path = info.Path; if (!string.IsNullOrEmpty(path)) { path = FileSystem.MakeAbsolutePath(ContainingFolderPath, path); @@ -1823,13 +2106,14 @@ namespace MediaBrowser.Controller.Entities return itemByPath; } + // Fall back to LibraryItemId (legacy method) if (!string.IsNullOrEmpty(info.LibraryItemId)) { var item = LibraryManager.GetItemById(info.LibraryItemId); if (item is null) { - Logger.LogWarning("Unable to find linked item at path {0}", info.Path); + Logger.LogWarning("Unable to find linked item by LibraryItemId {0}", info.LibraryItemId); } return item; @@ -1837,6 +2121,7 @@ namespace MediaBrowser.Controller.Entities return null; } +#pragma warning restore CS0618 /// /// Adds a studio to the item. @@ -1936,12 +2221,23 @@ namespace MediaBrowser.Controller.Entities // I think it is okay to do this here. // if this is only called when a user is manually forcing something to un-played // then it probably is what we want to do... + ResetPlayedState(data); + + UserDataManager.SaveUserData(user, this, data, UserDataSaveReason.TogglePlayed, CancellationToken.None); + } + + /// + /// Clears the played state on the supplied user data. + /// + /// The user data to reset. + protected static void ResetPlayedState(UserItemData data) + { + ArgumentNullException.ThrowIfNull(data); + data.PlayCount = 0; data.PlaybackPositionTicks = 0; data.LastPlayedDate = null; data.Played = false; - - UserDataManager.SaveUserData(user, this, data, UserDataSaveReason.TogglePlayed, CancellationToken.None); } /// @@ -2136,17 +2432,6 @@ namespace MediaBrowser.Controller.Entities }; } - // Music albums usually don't have dedicated backdrops, so return one from the artist instead - if (GetType() == typeof(MusicAlbum) && imageType == ImageType.Backdrop) - { - var artist = FindParent(); - - if (artist is not null) - { - return artist.GetImages(imageType).ElementAtOrDefault(imageIndex); - } - } - return GetImages(imageType) .ElementAtOrDefault(imageIndex); } @@ -2428,7 +2713,13 @@ namespace MediaBrowser.Controller.Entities return path; } - public virtual void FillUserDataDtoValues(UserItemDataDto dto, UserItemData userData, BaseItemDto itemDto, User user, DtoOptions fields) + public virtual void FillUserDataDtoValues( + UserItemDataDto dto, + UserItemData userData, + BaseItemDto itemDto, + User user, + DtoOptions fields, + (int Played, int Total)? precomputedCounts = null) { if (RunTimeTicks.HasValue) { @@ -2446,6 +2737,32 @@ namespace MediaBrowser.Controller.Entities } } + /// + /// Applies the owner's premiere date and production year to an owned item, returning whether anything changed. + /// + /// The owner. + /// The owned item. + /// true if the owned item was changed, else false. + internal static bool InheritDatesFromOwner(BaseItem owner, BaseItem ownedItem) + { + // Extras have no release date of their own, so the owner's is authoritative. + var changed = false; + + if (owner.ProductionYear is not null && ownedItem.ProductionYear != owner.ProductionYear) + { + ownedItem.ProductionYear = owner.ProductionYear; + changed = true; + } + + if (owner.PremiereDate is not null && ownedItem.PremiereDate != owner.PremiereDate) + { + ownedItem.PremiereDate = owner.PremiereDate; + changed = true; + } + + return changed; + } + protected async Task RefreshMetadataForOwnedItem(BaseItem ownedItem, bool copyTitleMetadata, MetadataRefreshOptions options, CancellationToken cancellationToken) { var newOptions = new MetadataRefreshOptions(options) @@ -2505,6 +2822,11 @@ namespace MediaBrowser.Controller.Entities ownedItem.CustomRating = item.CustomRating; newOptions.ForceSave = true; } + + if (InheritDatesFromOwner(item, ownedItem)) + { + newOptions.ForceSave = true; + } } await ownedItem.RefreshMetadata(newOptions, cancellationToken).ConfigureAwait(false); @@ -2628,7 +2950,7 @@ namespace MediaBrowser.Controller.Entities .Select(i => i.OfficialRating) .Where(i => !string.IsNullOrEmpty(i)) .Distinct(StringComparer.OrdinalIgnoreCase) - .Select(rating => (rating, LocalizationManager.GetRatingScore(rating))) + .Select(rating => (rating, LocalizationManager.GetRatingScore(rating, GetPreferredMetadataCountryCode()))) .OrderBy(i => i.Item2 is null ? 1001 : i.Item2.Score) .ThenBy(i => i.Item2 is null ? 1001 : i.Item2.SubScore) .Select(i => i.rating); @@ -2648,7 +2970,7 @@ namespace MediaBrowser.Controller.Entities public IReadOnlyList GetThemeSongs(User user, IEnumerable<(ItemSortBy SortBy, SortOrder SortOrder)> orderBy) { - return LibraryManager.Sort(GetExtras().Where(e => e.ExtraType == Model.Entities.ExtraType.ThemeSong), user, orderBy).ToArray(); + return LibraryManager.Sort(GetExtras(user).Where(e => e.ExtraType == Model.Entities.ExtraType.ThemeSong), user, orderBy).ToArray(); } public IReadOnlyList GetThemeVideos(User user = null) @@ -2658,33 +2980,65 @@ namespace MediaBrowser.Controller.Entities public IReadOnlyList GetThemeVideos(User user, IEnumerable<(ItemSortBy SortBy, SortOrder SortOrder)> orderBy) { - return LibraryManager.Sort(GetExtras().Where(e => e.ExtraType == Model.Entities.ExtraType.ThemeVideo), user, orderBy).ToArray(); + return LibraryManager.Sort(GetExtras(user).Where(e => e.ExtraType == Model.Entities.ExtraType.ThemeVideo), user, orderBy).ToArray(); + } + + /// + /// Gets the ids of the items whose owned extras belong to this item. + /// + /// An array containing the owner ids. + protected virtual Guid[] GetExtraOwnerIds() + { + return [Id]; + } + + /// + /// Gets the ids of this item and the versions of it whose extras it maintains. + /// + /// An array containing the version ids. + protected virtual Guid[] GetOwnedVersionIds() + { + return [Id]; + } + + /// + /// Gets the id of the version an extra belongs to. + /// + /// The extra. + /// The id of the owning version. + protected virtual Guid GetOwnerIdForExtra(BaseItem extra) + { + return Id; } /// /// Get all extras associated with this item, sorted by . /// + /// The user to apply parental restrictions for, or null to skip restriction checks. /// An enumerable containing the items. - public IEnumerable GetExtras() + public IEnumerable GetExtras(User user = null) { - return ExtraIds - .Select(LibraryManager.GetItemById) - .Where(i => i is not null) - .OrderBy(i => i.SortName); + return LibraryManager.GetItemList(new InternalItemsQuery(user) + { + OwnerIds = GetExtraOwnerIds(), + OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending)] + }); } /// /// Get all extras with specific types that are associated with this item. /// /// The types of extras to retrieve. + /// The user to apply parental restrictions for, or null to skip restriction checks. /// An enumerable containing the extras. - public IEnumerable GetExtras(IReadOnlyCollection extraTypes) + public IEnumerable GetExtras(IReadOnlyCollection extraTypes, User user = null) { - return ExtraIds - .Select(LibraryManager.GetItemById) - .Where(i => i is not null) - .Where(i => i.ExtraType.HasValue && extraTypes.Contains(i.ExtraType.Value)) - .OrderBy(i => i.SortName); + return LibraryManager.GetItemList(new InternalItemsQuery(user) + { + OwnerIds = GetExtraOwnerIds(), + ExtraTypes = extraTypes.ToArray(), + OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending)] + }); } public virtual long GetRunTimeTicksForPlayState() diff --git a/MediaBrowser.Controller/Entities/Book.cs b/MediaBrowser.Controller/Entities/Book.cs index 5187669373..8559681bdc 100644 --- a/MediaBrowser.Controller/Entities/Book.cs +++ b/MediaBrowser.Controller/Entities/Book.cs @@ -13,11 +13,6 @@ namespace MediaBrowser.Controller.Entities [Common.RequiresSourceSerialisation] public class Book : BaseItem, IHasLookupInfo, IHasSeries { - public Book() - { - this.RunTimeTicks = TimeSpan.TicksPerSecond; - } - [JsonIgnore] public override MediaType MediaType => MediaType.Book; diff --git a/MediaBrowser.Controller/Entities/CollectionFolder.cs b/MediaBrowser.Controller/Entities/CollectionFolder.cs index ca79e62454..ffdc8421da 100644 --- a/MediaBrowser.Controller/Entities/CollectionFolder.cs +++ b/MediaBrowser.Controller/Entities/CollectionFolder.cs @@ -44,6 +44,11 @@ namespace MediaBrowser.Controller.Entities PhysicalFolderIds = Array.Empty(); } + /// + /// Event raised when library options are updated for any collection folder. + /// + public static event EventHandler LibraryOptionsUpdated; + /// /// Gets the display preferences id. /// @@ -74,14 +79,27 @@ namespace MediaBrowser.Controller.Entities public CollectionType? CollectionType { get; set; } /// - /// Gets the item's children. + /// Gets or sets the item's children. /// /// /// Our children are actually just references to the ones in the physical root... + /// Setting to null propagates invalidation to physical folders since the getter + /// always delegates to and never reads the backing field. /// /// The actual children. [JsonIgnore] - public override IEnumerable Children => GetActualChildren(); + public override IEnumerable Children + { + get => GetActualChildren(); + set + { + // The getter delegates to physical folders, so invalidate their caches. + foreach (var folder in GetPhysicalFolders(true)) + { + folder.Children = null; + } + } + } [JsonIgnore] public override bool SupportsPeople => false; @@ -168,6 +186,8 @@ namespace MediaBrowser.Controller.Entities } XmlSerializer.SerializeToFile(clone, GetLibraryOptionsPath(path)); + + LibraryOptionsUpdated?.Invoke(null, new LibraryOptionsUpdatedEventArgs(path, options)); } public static void OnCollectionFolderChange() diff --git a/MediaBrowser.Controller/Entities/Extensions.cs b/MediaBrowser.Controller/Entities/Extensions.cs index c56603a3eb..380041af84 100644 --- a/MediaBrowser.Controller/Entities/Extensions.cs +++ b/MediaBrowser.Controller/Entities/Extensions.cs @@ -34,7 +34,7 @@ namespace MediaBrowser.Controller.Entities } else { - item.RemoteTrailers = [..item.RemoteTrailers, mediaUrl]; + item.RemoteTrailers = [.. item.RemoteTrailers, mediaUrl]; } } } diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs index 2ecb6cbdff..d8203ea6f2 100644 --- a/MediaBrowser.Controller/Entities/Folder.cs +++ b/MediaBrowser.Controller/Entities/Folder.cs @@ -43,11 +43,7 @@ namespace MediaBrowser.Controller.Entities public class Folder : BaseItem { private IEnumerable _children; - - public Folder() - { - LinkedChildren = Array.Empty(); - } + private LinkedChild[] _linkedChildren = []; public static IUserViewManager UserViewManager { get; set; } @@ -59,7 +55,31 @@ namespace MediaBrowser.Controller.Entities /// true if this instance is root; otherwise, false. public bool IsRoot { get; set; } - public LinkedChild[] LinkedChildren { get; set; } + /// + /// Gets or sets the linked children. + /// + [JsonIgnore] + public LinkedChild[] LinkedChildren + { + get => _linkedChildren; + set + { + _linkedChildren = value; + + // Assigning the collection means the caller knows the complete set of links. + LinkedChildrenLoaded = true; + } + } + + /// + /// Gets a value indicating whether holds the stored set of links. + /// + /// + /// An unloaded instance carries an empty array that means "unknown", not "no children" — + /// persisting it would delete every link the item has. + /// + [JsonIgnore] + public bool LinkedChildrenLoaded { get; private set; } [JsonIgnore] public DateTime? DateLastMediaAdded { get; set; } @@ -380,6 +400,7 @@ namespace MediaBrowser.Controller.Entities cancellationToken.ThrowIfCancellationRequested(); var validChildren = new List(); + var accessibleChildren = new List(); var validChildrenNeedGeneration = false; if (IsFileProtocol) @@ -416,6 +437,17 @@ namespace MediaBrowser.Controller.Entities // Create a list for our validated children var newItems = new List(); + var actuallyRemoved = new List(); + + // Build a reverse path→item lookup for detecting type changes + var currentChildrenByPath = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var kvp in currentChildren) + { + if (!string.IsNullOrEmpty(kvp.Value.Path)) + { + currentChildrenByPath.TryAdd(kvp.Value.Path, kvp.Value); + } + } cancellationToken.ThrowIfCancellationRequested(); @@ -423,12 +455,19 @@ namespace MediaBrowser.Controller.Entities { if (!IsLibraryFolderAccessible(directoryService, child, allowRemoveRoot)) { + // Preserve inaccessible items so they aren't treated as removed. + if (currentChildren.TryGetValue(child.Id, out var childrenToKeep)) + { + validChildren.Add(childrenToKeep); + } + continue; } if (currentChildren.TryGetValue(child.Id, out BaseItem currentChild)) { validChildren.Add(currentChild); + accessibleChildren.Add(currentChild); if (currentChild.UpdateFromResolvedItem(child) > ItemUpdateType.None) { @@ -443,18 +482,47 @@ namespace MediaBrowser.Controller.Entities continue; } + // Check if an existing item occupies the same path with different type/ID + if (!string.IsNullOrEmpty(child.Path) + && currentChildrenByPath.TryGetValue(child.Path, out var staleItem) + && !staleItem.Id.Equals(child.Id)) + { + Logger.LogInformation( + "Item type changed at {Path}: {OldType} -> {NewType}, removing stale entry", + child.Path, + staleItem.GetType().Name, + child.GetType().Name); + + currentChildren.Remove(staleItem.Id); + currentChildrenByPath.Remove(child.Path); + staleItem.SetParent(null); + LibraryManager.DeleteItem(staleItem, new DeleteOptions { DeleteFileLocation = false }, this, false); + actuallyRemoved.Add(staleItem); + } + // Brand new item - needs to be added child.SetParent(this); newItems.Add(child); validChildren.Add(child); + accessibleChildren.Add(child); } // That's all the new and changed ones - now see if any have been removed and need cleanup var itemsRemoved = currentChildren.Values.Except(validChildren).ToList(); - var shouldRemove = !IsRoot || allowRemoveRoot; - var actuallyRemoved = new List(); + // If it's an AggregateFolder, don't remove - if (shouldRemove && itemsRemoved.Count > 0) + // Collect replaced primaries for deferred deletion (after CreateItems) + var replacedPrimaries = new List<(Video OldPrimary, Video NewPrimary)>(); + + // Build a set of paths that are alternate versions of valid children + // These items should not be deleted - they're managed by their primary video + var alternateVersionPaths = validChildren + .OfType