Merge pull request 'Re-apply HA fork patches on upstream v12.0' (#3) from benvin/rebase-v12 into main
ci/woodpecker/push/ci Pipeline was successful

Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
2026-09-12 12:48:35 +10:00
1122 changed files with 94882 additions and 16602 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
"isRoot": true, "isRoot": true,
"tools": { "tools": {
"dotnet-ef": { "dotnet-ef": {
"version": "9.0.11", "version": "10.0.11",
"commands": [ "commands": [
"dotnet-ef" "dotnet-ef"
] ]
+20 -6
View File
@@ -1,17 +1,31 @@
{ {
"name": "Development Jellyfin Server", "name": "Development Jellyfin Server",
"image": "mcr.microsoft.com/devcontainers/dotnet:9.0-bookworm", "image": "mcr.microsoft.com/devcontainers/dotnet:10.0-noble",
"service": "app", "service": "app",
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
// restores nuget packages, installs the dotnet workloads and installs the dev https certificate // 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\"", "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 // The previous way of installing extensions via the vs command dont work on selfhosted devcontainers
"postAttachCommand": "cat .vscode/extensions.json | jq -r .recommendations[] | xargs -n 1 code --install-extension", "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": { "features": {
"ghcr.io/devcontainers/features/dotnet:2": { "ghcr.io/devcontainers/features/dotnet:2": {
"version": "none", "version": "none",
"dotnetRuntimeVersions": "9.0", "dotnetRuntimeVersions": "10.0",
"aspNetCoreRuntimeVersions": "9.0" "aspNetCoreRuntimeVersions": "10.0"
}, },
"ghcr.io/devcontainers-extra/features/apt-packages:1": { "ghcr.io/devcontainers-extra/features/apt-packages:1": {
"preserve_apt_list": false, "preserve_apt_list": false,
@@ -19,7 +33,7 @@
"libfontconfig1" "libfontconfig1"
] ]
}, },
"ghcr.io/devcontainers/features/docker-in-docker:2": { "ghcr.io/devcontainers/features/docker-in-docker:3": {
"dockerDashComposeVersion": "v2" "dockerDashComposeVersion": "v2"
}, },
"ghcr.io/devcontainers/features/github-cli:1": {}, "ghcr.io/devcontainers/features/github-cli:1": {},
+1 -1
View File
@@ -15,7 +15,7 @@ sudo apt-get install software-properties-common -y
sudo add-apt-repository universe -y sudo add-apt-repository universe -y
sudo mkdir -p /etc/apt/keyrings 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_OS="$( awk -F'=' '/^ID=/{ print $NF }' /etc/os-release )"
export VERSION_CODENAME="$( awk -F'=' '/^VERSION_CODENAME=/{ print $NF }' /etc/os-release )" export VERSION_CODENAME="$( awk -F'=' '/^VERSION_CODENAME=/{ print $NF }' /etc/os-release )"
export DPKG_ARCHITECTURE="$( dpkg --print-architecture )" export DPKG_ARCHITECTURE="$( dpkg --print-architecture )"
+7
View File
@@ -379,6 +379,9 @@ dotnet_diagnostic.CA1720.severity = suggestion
# disable warning CA1724: Type names should not match namespaces # disable warning CA1724: Type names should not match namespaces
dotnet_diagnostic.CA1724.severity = suggestion dotnet_diagnostic.CA1724.severity = suggestion
# disable warning CA1873: Avoid potentially expensive logging
dotnet_diagnostic.CA1873.severity = suggestion
# disable warning CA1805: Do not initialize unnecessarily # disable warning CA1805: Do not initialize unnecessarily
dotnet_diagnostic.CA1805.severity = suggestion dotnet_diagnostic.CA1805.severity = suggestion
@@ -400,6 +403,10 @@ dotnet_diagnostic.CA1861.severity = suggestion
# disable warning CA2000: Dispose objects before losing scope # disable warning CA2000: Dispose objects before losing scope
dotnet_diagnostic.CA2000.severity = suggestion 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 # disable warning CA2253: Named placeholders should not be numeric values
dotnet_diagnostic.CA2253.severity = suggestion dotnet_diagnostic.CA2253.severity = suggestion
+11 -4
View File
@@ -1,4 +1,11 @@
# Joshua must review all changes to deployment and build.sh # Joshua must review all changes to bump_version and any files it touches
.ci/* @joshuaboniface bump_version @joshuaboniface
deployment/* @joshuaboniface .github/ISSUE_TEMPLATE @joshuaboniface
build.sh @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
+12 -5
View File
@@ -87,7 +87,12 @@ body:
label: Jellyfin Server version label: Jellyfin Server version
description: What version of Jellyfin are you using? description: What version of Jellyfin are you using?
options: options:
- 10.10.0+ - 10.11.11
- 10.11.10
- 10.11.9
- 10.11.8
- 10.11.7
- 10.11.6
- Master - Master
- Unstable - Unstable
- Older* - Older*
@@ -136,13 +141,14 @@ body:
- **FFmpeg Version**: [e.g. 5.1.2-Jellyfin] - **FFmpeg Version**: [e.g. 5.1.2-Jellyfin]
- **Playback**: [Direct Play, Remux, Direct Stream, Transcode] - **Playback**: [Direct Play, Remux, Direct Stream, Transcode]
- **Hardware Acceleration**: [e.g. none, VAAPI, NVENC, etc.] - **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.] - **GPU Model**: [e.g. none, UHD630, GTX1050, etc.]
- **Installed Plugins**: [e.g. none, Fanart, Anime, etc.] - **Installed Plugins**: [e.g. none, Fanart, Anime, etc.]
- **Reverse Proxy**: [e.g. none, nginx, apache, etc.] - **Reverse Proxy**: [e.g. none, nginx, apache, etc.]
- **Base URL**: [e.g. none, yes: /example] - **Base URL**: [e.g. none, yes: /example]
- **Networking**: [e.g. Host, Bridge/NAT] - **Networking**: [e.g. Host, Bridge/NAT]
- **Jellyfin Data Storage**: [e.g. local SATA SSD, local HDD] - **Jellyfin Data Storage & Filesystem**: [e.g. local SATA SSD - ext4, local HDD - NTFS]
- **Media Storage**: [e.g. Local HDD, SMB Share] - **Media Storage & Filesystem**: [e.g. Local HDD - ext4, SMB Share]
- **External Integrations**: [e.g. Jellystat, Jellyseerr] - **External Integrations**: [e.g. Jellystat, Jellyseerr]
value: | value: |
- OS: - OS:
@@ -153,13 +159,14 @@ body:
- FFmpeg Version: - FFmpeg Version:
- Playback Method: - Playback Method:
- Hardware Acceleration: - Hardware Acceleration:
- CPU Model:
- GPU Model: - GPU Model:
- Plugins: - Plugins:
- Reverse Proxy: - Reverse Proxy:
- Base URL: - Base URL:
- Networking: - Networking:
- Jellyfin Data Storage: - Jellyfin Data Storage & Filesystem:
- Media Storage: - Media Storage & Filesystem:
- External Integrations: - External Integrations:
render: markdown render: markdown
validations: validations:
+5 -1
View File
@@ -1,11 +1,15 @@
<!-- <!--
Ensure your title is short, descriptive, and in the imperative mood (Fix X, Change Y, instead of Fixed X, Changed Y). Ensure your title is short, descriptive, and in the imperative mood (Fix X, Change Y, instead of Fixed X, Changed Y).
For a good inspiration of what to write in commit messages and PRs please review https://chris.beams.io/posts/git-commit/ and our documentation. For a good inspiration of what to write in commit messages and PRs please review https://chris.beams.io/posts/git-commit/ and our https://jellyfin.org/docs/general/contributing/issues/ page.
--> -->
**Changes** **Changes**
<!-- Describe your changes here in 1-5 sentences. --> <!-- Describe your changes here in 1-5 sentences. -->
**Code assistance**
<!-- If code assistance was used, describe how it contributed
e.g., code generated by LLM, explanation of code base, debugging guidance. -->
**Issues** **Issues**
<!-- Tag any issues that this PR solves here. <!-- Tag any issues that this PR solves here.
ex. Fixes # --> ex. Fixes # -->
+5
View File
@@ -19,6 +19,7 @@ local.properties
.classpath .classpath
.settings/ .settings/
.loadpath .loadpath
*.lscache
# External tool builders # External tool builders
.externalToolBuilders/ .externalToolBuilders/
@@ -277,3 +278,7 @@ apiclient/generated
# Omnisharp crash logs # Omnisharp crash logs
mono_crash.*.json mono_crash.*.json
# Devcontainer temp files
.devcontainer/devcontainer-lock.json
dotnet/
+3 -3
View File
@@ -6,7 +6,7 @@
"type": "coreclr", "type": "coreclr",
"request": "launch", "request": "launch",
"preLaunchTask": "build", "preLaunchTask": "build",
"program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net9.0/jellyfin.dll", "program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net10.0/jellyfin.dll",
"args": [], "args": [],
"cwd": "${workspaceFolder}/Jellyfin.Server", "cwd": "${workspaceFolder}/Jellyfin.Server",
"console": "internalConsole", "console": "internalConsole",
@@ -22,7 +22,7 @@
"type": "coreclr", "type": "coreclr",
"request": "launch", "request": "launch",
"preLaunchTask": "build", "preLaunchTask": "build",
"program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net9.0/jellyfin.dll", "program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net10.0/jellyfin.dll",
"args": ["--nowebclient"], "args": ["--nowebclient"],
"cwd": "${workspaceFolder}/Jellyfin.Server", "cwd": "${workspaceFolder}/Jellyfin.Server",
"console": "internalConsole", "console": "internalConsole",
@@ -34,7 +34,7 @@
"type": "coreclr", "type": "coreclr",
"request": "launch", "request": "launch",
"preLaunchTask": "build", "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"], "args": ["--nowebclient", "--ffmpeg", "/usr/lib/jellyfin-ffmpeg/ffmpeg"],
"cwd": "${workspaceFolder}/Jellyfin.Server", "cwd": "${workspaceFolder}/Jellyfin.Server",
"console": "internalConsole", "console": "internalConsole",
+21 -5
View File
@@ -3,15 +3,29 @@ when:
- event: push - event: push
steps: steps:
# Restore, build and test the full solution. global.json pins the .NET 9 # Restore, build and test in one step so the NuGet cache stays on the step's
# SDK (9.0.0, rollForward latestMinor), so build on the 9.0 SDK image. # own ephemeral storage instead of the 10Gi workspace volume.
# global.json pins the .NET 10 SDK (10.0.0, rollForward latestMinor).
- name: build-test - 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: commands:
- dotnet --info - dotnet --info
- dotnet restore Jellyfin.sln - dotnet restore Jellyfin.sln
- dotnet build Jellyfin.sln -c Release --no-restore - 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: backend_options:
kubernetes: kubernetes:
serviceAccountName: jellyfin-ha-src serviceAccountName: jellyfin-ha-src
@@ -19,6 +33,8 @@ steps:
requests: requests:
memory: 2Gi memory: 2Gi
cpu: 2 cpu: 2
ephemeral-storage: 10Gi
limits: limits:
memory: 6Gi memory: 8Gi
cpu: 4 cpu: 4
ephemeral-storage: 20Gi
+35 -15
View File
@@ -1,5 +1,6 @@
# Jellyfin Contributors # Jellyfin Contributors
- [0x25CBFC4F](https://github.com/0x25CBFC4F)
- [1337joe](https://github.com/1337joe) - [1337joe](https://github.com/1337joe)
- [97carmine](https://github.com/97carmine) - [97carmine](https://github.com/97carmine)
- [Abbe98](https://github.com/Abbe98) - [Abbe98](https://github.com/Abbe98)
@@ -14,7 +15,7 @@
- [bilde2910](https://github.com/bilde2910) - [bilde2910](https://github.com/bilde2910)
- [bfayers](https://github.com/bfayers) - [bfayers](https://github.com/bfayers)
- [BnMcG](https://github.com/BnMcG) - [BnMcG](https://github.com/BnMcG)
- [Bond-009](https://github.com/Bond-009) - [Bond_009](https://github.com/Bond-009)
- [brianjmurrell](https://github.com/brianjmurrell) - [brianjmurrell](https://github.com/brianjmurrell)
- [bugfixin](https://github.com/bugfixin) - [bugfixin](https://github.com/bugfixin)
- [chaosinnovator](https://github.com/chaosinnovator) - [chaosinnovator](https://github.com/chaosinnovator)
@@ -31,6 +32,7 @@
- [DaveChild](https://github.com/DaveChild) - [DaveChild](https://github.com/DaveChild)
- [DavidFair](https://github.com/DavidFair) - [DavidFair](https://github.com/DavidFair)
- [Delgan](https://github.com/Delgan) - [Delgan](https://github.com/Delgan)
- [DerMaddis](https://github.com/dermaddis)
- [Derpipose](https://github.com/Derpipose) - [Derpipose](https://github.com/Derpipose)
- [dcrdev](https://github.com/dcrdev) - [dcrdev](https://github.com/dcrdev)
- [dhartung](https://github.com/dhartung) - [dhartung](https://github.com/dhartung)
@@ -54,6 +56,7 @@
- [geilername](https://github.com/geilername) - [geilername](https://github.com/geilername)
- [GermanCoding](https://github.com/GermanCoding) - [GermanCoding](https://github.com/GermanCoding)
- [gnattu](https://github.com/gnattu) - [gnattu](https://github.com/gnattu)
- [gnuyent](https://github.com/gnuyent)
- [GodTamIt](https://github.com/GodTamIt) - [GodTamIt](https://github.com/GodTamIt)
- [grafixeyehero](https://github.com/grafixeyehero) - [grafixeyehero](https://github.com/grafixeyehero)
- [h1nk](https://github.com/h1nk) - [h1nk](https://github.com/h1nk)
@@ -61,6 +64,7 @@
- [HelloWorld017](https://github.com/HelloWorld017) - [HelloWorld017](https://github.com/HelloWorld017)
- [ikomhoog](https://github.com/ikomhoog) - [ikomhoog](https://github.com/ikomhoog)
- [iwalton3](https://github.com/iwalton3) - [iwalton3](https://github.com/iwalton3)
- [Jakob Kukla](https://github.com/jakobkukla)
- [jftuga](https://github.com/jftuga) - [jftuga](https://github.com/jftuga)
- [jkhsjdhjs](https://github.com/jkhsjdhjs) - [jkhsjdhjs](https://github.com/jkhsjdhjs)
- [jmshrv](https://github.com/jmshrv) - [jmshrv](https://github.com/jmshrv)
@@ -69,8 +73,10 @@
- [JustAMan](https://github.com/JustAMan) - [JustAMan](https://github.com/JustAMan)
- [justinfenn](https://github.com/justinfenn) - [justinfenn](https://github.com/justinfenn)
- [JPVenson](https://github.com/JPVenson) - [JPVenson](https://github.com/JPVenson)
- [JPUC1143](https://github.com/Jpuc1143/)
- [KerryRJ](https://github.com/KerryRJ) - [KerryRJ](https://github.com/KerryRJ)
- [Larvitar](https://github.com/Larvitar) - [Larvitar](https://github.com/Larvitar)
- [lbenini](https://github.com/lbenini)
- [LeoVerto](https://github.com/LeoVerto) - [LeoVerto](https://github.com/LeoVerto)
- [Liggy](https://github.com/Liggy) - [Liggy](https://github.com/Liggy)
- [lmaonator](https://github.com/lmaonator) - [lmaonator](https://github.com/lmaonator)
@@ -83,15 +89,20 @@
- [marius-luca-87](https://github.com/marius-luca-87) - [marius-luca-87](https://github.com/marius-luca-87)
- [mark-monteiro](https://github.com/mark-monteiro) - [mark-monteiro](https://github.com/mark-monteiro)
- [MarkCiliaVincenti](https://github.com/MarkCiliaVincenti) - [MarkCiliaVincenti](https://github.com/MarkCiliaVincenti)
- [Martin Reuter](https://github.com/reuterma24)
- [Matt Teahan](https://github.com/matt-teahan)
- [Matt07211](https://github.com/Matt07211) - [Matt07211](https://github.com/Matt07211)
- [Matthew Jones](https://github.com/matthew-jones-uk)
- [Maxr1998](https://github.com/Maxr1998) - [Maxr1998](https://github.com/Maxr1998)
- [mcarlton00](https://github.com/mcarlton00) - [mcarlton00](https://github.com/mcarlton00)
- [Michael McElroy](https://github.com/mcmcelro)
- [mitchfizz05](https://github.com/mitchfizz05) - [mitchfizz05](https://github.com/mitchfizz05)
- [mohd-akram](https://github.com/mohd-akram) - [mohd-akram](https://github.com/mohd-akram)
- [MrTimscampi](https://github.com/MrTimscampi) - [MrTimscampi](https://github.com/MrTimscampi)
- [n8225](https://github.com/n8225) - [n8225](https://github.com/n8225)
- [Nalsai](https://github.com/Nalsai) - [Nalsai](https://github.com/Nalsai)
- [Narfinger](https://github.com/Narfinger) - [Narfinger](https://github.com/Narfinger)
- [Nathan McCrina](https://github.com/nfmccrina)
- [NathanPickard](https://github.com/NathanPickard) - [NathanPickard](https://github.com/NathanPickard)
- [neilsb](https://github.com/neilsb) - [neilsb](https://github.com/neilsb)
- [nevado](https://github.com/nevado) - [nevado](https://github.com/nevado)
@@ -102,16 +113,20 @@
- [OancaAndrei](https://github.com/OancaAndrei) - [OancaAndrei](https://github.com/OancaAndrei)
- [obradovichv](https://github.com/obradovichv) - [obradovichv](https://github.com/obradovichv)
- [oddstr13](https://github.com/oddstr13) - [oddstr13](https://github.com/oddstr13)
- [olsh](https://github.com/olsh)
- [orryverducci](https://github.com/orryverducci) - [orryverducci](https://github.com/orryverducci)
- [PCEWLKR](https://github.com/PCEWLKR)
- [petermcneil](https://github.com/petermcneil) - [petermcneil](https://github.com/petermcneil)
- [Phlogi](https://github.com/Phlogi) - [Phlogi](https://github.com/Phlogi)
- [pjeanjean](https://github.com/pjeanjean) - [pjeanjean](https://github.com/pjeanjean)
- [ploughpuff](https://github.com/ploughpuff) - [ploughpuff](https://github.com/ploughpuff)
- [poytiis](https://github.com/poytiis)
- [pR0Ps](https://github.com/pR0Ps) - [pR0Ps](https://github.com/pR0Ps)
- [PrplHaz4](https://github.com/PrplHaz4) - [PrplHaz4](https://github.com/PrplHaz4)
- [RazeLighter777](https://github.com/RazeLighter777) - [RazeLighter777](https://github.com/RazeLighter777)
- [redSpoutnik](https://github.com/redSpoutnik) - [redSpoutnik](https://github.com/redSpoutnik)
- [ringmatter](https://github.com/ringmatter) - [ringmatter](https://github.com/ringmatter)
- [Robert Lützner](https://github.com/rluetzner)
- [ryan-hartzell](https://github.com/ryan-hartzell) - [ryan-hartzell](https://github.com/ryan-hartzell)
- [s0urcelab](https://github.com/s0urcelab) - [s0urcelab](https://github.com/s0urcelab)
- [sachk](https://github.com/sachk) - [sachk](https://github.com/sachk)
@@ -127,6 +142,8 @@
- [sl1288](https://github.com/sl1288) - [sl1288](https://github.com/sl1288)
- [Smith00101010](https://github.com/Smith00101010) - [Smith00101010](https://github.com/Smith00101010)
- [sorinyo2004](https://github.com/sorinyo2004) - [sorinyo2004](https://github.com/sorinyo2004)
- [Soumyadip Auddy](https://github.com/SoumyadipAuddy)
- [st7105](https://github.com/st7105)
- [sparky8251](https://github.com/sparky8251) - [sparky8251](https://github.com/sparky8251)
- [spookbits](https://github.com/spookbits) - [spookbits](https://github.com/spookbits)
- [ssenart](https://github.com/ssenart) - [ssenart](https://github.com/ssenart)
@@ -149,12 +166,14 @@
- [twinkybot](https://github.com/twinkybot) - [twinkybot](https://github.com/twinkybot)
- [Ullmie02](https://github.com/Ullmie02) - [Ullmie02](https://github.com/Ullmie02)
- [Unhelpful](https://github.com/Unhelpful) - [Unhelpful](https://github.com/Unhelpful)
- [Utku Özdemir](https://github.com/utkuozdemir)
- [viaregio](https://github.com/viaregio) - [viaregio](https://github.com/viaregio)
- [vitorsemeano](https://github.com/vitorsemeano) - [vitorsemeano](https://github.com/vitorsemeano)
- [voodoos](https://github.com/voodoos) - [voodoos](https://github.com/voodoos)
- [whooo](https://github.com/whooo) - [whooo](https://github.com/whooo)
- [WiiPlayer2](https://github.com/WiiPlayer2) - [WiiPlayer2](https://github.com/WiiPlayer2)
- [WillWill56](https://github.com/WillWill56) - [WillWill56](https://github.com/WillWill56)
- [WizardOfYendor1](https://github.com/WizardOfYendor1)
- [wtayl0r](https://github.com/wtayl0r) - [wtayl0r](https://github.com/wtayl0r)
- [Wuerfelbecher](https://github.com/Wuerfelbecher) - [Wuerfelbecher](https://github.com/Wuerfelbecher)
- [Wunax](https://github.com/Wunax) - [Wunax](https://github.com/Wunax)
@@ -164,6 +183,7 @@
- [XVicarious](https://github.com/XVicarious) - [XVicarious](https://github.com/XVicarious)
- [YouKnowBlom](https://github.com/YouKnowBlom) - [YouKnowBlom](https://github.com/YouKnowBlom)
- [ZachPhelan](https://github.com/ZachPhelan) - [ZachPhelan](https://github.com/ZachPhelan)
- [ZeusCraft10](https://github.com/ZeusCraft10)
- [KristupasSavickas](https://github.com/KristupasSavickas) - [KristupasSavickas](https://github.com/KristupasSavickas)
- [Pusta](https://github.com/pusta) - [Pusta](https://github.com/pusta)
- [nielsvanvelzen](https://github.com/nielsvanvelzen) - [nielsvanvelzen](https://github.com/nielsvanvelzen)
@@ -206,8 +226,21 @@
- [theshoeshiner](https://github.com/theshoeshiner) - [theshoeshiner](https://github.com/theshoeshiner)
- [TokerX](https://github.com/TokerX) - [TokerX](https://github.com/TokerX)
- [GeneMarks](https://github.com/GeneMarks) - [GeneMarks](https://github.com/GeneMarks)
- [Kirill Nikiforov](https://github.com/allmazz)
- [bjorntp](https://github.com/bjorntp)
- [martenumberto](https://github.com/martenumberto) - [martenumberto](https://github.com/martenumberto)
- [ZeusCraft10](https://github.com/ZeusCraft10)
- [MarcoCoreDuo](https://github.com/MarcoCoreDuo) - [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 # Emby Contributors
@@ -218,7 +251,7 @@
- [Mark2xv](https://github.com/Mark2xv) - [Mark2xv](https://github.com/Mark2xv)
- [ScottRapsey](https://github.com/ScottRapsey) - [ScottRapsey](https://github.com/ScottRapsey)
- [skynet600](https://github.com/skynet600) - [skynet600](https://github.com/skynet600)
- [Cheesegeezer](https://githum.com/Cheesegeezer) - [Cheesegeezer](https://github.com/Cheesegeezer)
- [Radeon](https://github.com/radeonorama) - [Radeon](https://github.com/radeonorama)
- [gcw07](https://github.com/gcw07) - [gcw07](https://github.com/gcw07)
- [SivaramAdhiappan](https://github.com/shivaram1190) - [SivaramAdhiappan](https://github.com/shivaram1190)
@@ -271,16 +304,3 @@
- [tikuf](https://github.com/tikuf/) - [tikuf](https://github.com/tikuf/)
- [Tim Hobbs](https://github.com/timhobbs) - [Tim Hobbs](https://github.com/timhobbs)
- [SvenVandenbrande](https://github.com/SvenVandenbrande) - [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)
+58 -65
View File
@@ -4,101 +4,94 @@
</PropertyGroup> </PropertyGroup>
<!-- Run "dotnet list package (dash,dash)outdated" to see the latest versions of each package.--> <!-- Run "dotnet list package (dash,dash)outdated" to see the latest versions of each package.-->
<ItemGroup Label="Package Dependencies"> <ItemGroup Label="Package Dependencies">
<PackageVersion Include="AsyncKeyedLock" Version="7.1.8" /> <PackageVersion Include="AsyncKeyedLock" Version="8.0.2" />
<PackageVersion Include="AWSSDK.S3" Version="3.7.410.2" />
<PackageVersion Include="AutoFixture.AutoMoq" Version="4.18.1" /> <PackageVersion Include="AutoFixture.AutoMoq" Version="4.18.1" />
<PackageVersion Include="AutoFixture.Xunit2" Version="4.18.1" /> <PackageVersion Include="AutoFixture.Xunit3" Version="4.19.0" />
<PackageVersion Include="AutoFixture" Version="4.18.1" /> <PackageVersion Include="AutoFixture" Version="4.18.1" />
<PackageVersion Include="AWSSDK.S3" Version="4.0.103.1" />
<PackageVersion Include="BDInfo" Version="0.8.0" /> <PackageVersion Include="BDInfo" Version="0.8.0" />
<PackageVersion Include="BitFaster.Caching" Version="2.5.4" /> <PackageVersion Include="BitFaster.Caching" Version="2.6.1" />
<PackageVersion Include="BlurHashSharp.SkiaSharp" Version="1.4.0-pre.1" /> <PackageVersion Include="BlurHashSharp.SkiaSharp" Version="1.4.0-pre.1" />
<PackageVersion Include="BlurHashSharp" Version="1.4.0-pre.1" /> <PackageVersion Include="BlurHashSharp" Version="1.4.0-pre.1" />
<PackageVersion Include="CommandLineParser" Version="2.9.1" /> <PackageVersion Include="CommandLineParser" Version="2.9.1" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" /> <PackageVersion Include="coverlet.collector" Version="10.0.1" />
<PackageVersion Include="Diacritics" Version="4.0.17" /> <PackageVersion Include="Diacritics" Version="4.1.8" />
<PackageVersion Include="DiscUtils.Udf" Version="0.16.13" /> <PackageVersion Include="DiscUtils.Udf" Version="0.16.13" />
<PackageVersion Include="DotNet.Glob" Version="3.1.3" /> <PackageVersion Include="DotNet.Glob" Version="3.1.3" />
<PackageVersion Include="FsCheck.Xunit" Version="3.3.2" /> <PackageVersion Include="FsCheck.Xunit.v3" Version="3.3.4" />
<PackageVersion Include="HarfBuzzSharp.NativeAssets.Linux" Version="8.3.1.1" /> <PackageVersion Include="HarfBuzzSharp.NativeAssets.Linux" Version="8.3.1.5" />
<PackageVersion Include="ICU4N.Transliterator" Version="60.1.0-alpha.356" /> <PackageVersion Include="ICU4N.Transliterator" Version="60.1.0-alpha.356" />
<PackageVersion Include="IDisposableAnalyzers" Version="4.0.8" /> <PackageVersion Include="IDisposableAnalyzers" Version="4.0.8" />
<PackageVersion Include="Ignore" Version="0.2.1" /> <PackageVersion Include="Ignore" Version="0.2.1" />
<PackageVersion Include="Jellyfin.XmlTv" Version="10.8.0" /> <PackageVersion Include="Jellyfin.XmlTv" Version="10.12.0-pre1" />
<PackageVersion Include="libse" Version="4.0.12" /> <PackageVersion Include="libse" Version="4.0.12" />
<PackageVersion Include="LrcParser" Version="2025.623.0" /> <PackageVersion Include="LrcParser" Version="2025.623.0" />
<PackageVersion Include="MetaBrainz.MusicBrainz" Version="6.1.0" /> <PackageVersion Include="MetaBrainz.MusicBrainz" Version="8.0.1" />
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="9.0.11" /> <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.11" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.11" /> <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.11" />
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="4.14.0" /> <PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="5.6.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="4.14.0" /> <PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="5.9.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" /> <PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.9.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" /> <PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.9.0" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.11" /> <PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.11" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.11" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.11" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.11" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.11" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.11" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.11" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.11" /> <PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="9.0.11" /> <PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.11" /> <PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.11" /> <PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.11" /> <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="9.0.11" /> <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.11" /> <PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.11" /> <PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.11" /> <PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="9.0.11" /> <PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.11" /> <PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="9.0.11" /> <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.11" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.11" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.11" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageVersion Include="MimeTypes" Version="2.5.2" /> <PackageVersion Include="MimeTypes" Version="2.5.2" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" /> <PackageVersion Include="Morestachio" Version="5.0.1.670" />
<PackageVersion Include="Morestachio" Version="5.0.1.631" />
<PackageVersion Include="Moq" Version="4.18.4" /> <PackageVersion Include="Moq" Version="4.18.4" />
<PackageVersion Include="NEbml" Version="1.1.0.5" /> <PackageVersion Include="NEbml" Version="1.1.0.5" />
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" /> <PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
<PackageVersion Include="Npgsql" Version="9.0.4" /> <PackageVersion Include="Npgsql" Version="10.0.3" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
<PackageVersion Include="PDFtoImage" Version="5.2.1" />
<PackageVersion Include="PlaylistsNET" Version="1.4.1" /> <PackageVersion Include="PlaylistsNET" Version="1.4.1" />
<PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" /> <PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" />
<PackageVersion Include="prometheus-net.DotNetRuntime" Version="4.4.1" /> <PackageVersion Include="prometheus-net.DotNetRuntime" Version="4.4.1" />
<PackageVersion Include="prometheus-net" Version="8.2.1" /> <PackageVersion Include="prometheus-net" Version="8.2.1" />
<PackageVersion Include="Polly" Version="8.6.5" /> <PackageVersion Include="Polly" Version="8.7.0" />
<PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" /> <PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0" /> <PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0" />
<PackageVersion Include="Serilog.Expressions" Version="5.0.0" /> <PackageVersion Include="Serilog.Expressions" Version="5.0.0" />
<PackageVersion Include="Serilog.Settings.Configuration" Version="9.0.0" /> <PackageVersion Include="Serilog.Settings.Configuration" Version="10.0.1" />
<PackageVersion Include="Serilog.Sinks.Async" Version="2.1.0" /> <PackageVersion Include="Serilog.Sinks.Async" Version="2.1.0" />
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" /> <PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" /> <PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageVersion Include="Serilog.Sinks.Graylog" Version="3.1.1" /> <PackageVersion Include="Serilog.Sinks.Graylog" Version="3.1.1" />
<PackageVersion Include="SerilogAnalyzer" Version="0.15.0" /> <PackageVersion Include="SerilogAnalyzer" Version="0.15.0" />
<PackageVersion Include="SharpFuzz" Version="2.2.0" /> <PackageVersion Include="SharpCompress" Version="0.50.4" />
<!-- Pinned to 3.116.1 because https://github.com/jellyfin/jellyfin/pull/14255 --> <PackageVersion Include="SharpFuzz" Version="2.3.0" />
<PackageVersion Include="SkiaSharp" Version="3.116.1" /> <PackageVersion Include="SkiaSharp" Version="3.119.4" />
<PackageVersion Include="SkiaSharp.HarfBuzz" Version="3.116.1" /> <PackageVersion Include="SkiaSharp.HarfBuzz" Version="3.119.4" />
<PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="3.116.1" /> <PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="3.119.4" />
<PackageVersion Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" /> <PackageVersion Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" />
<PackageVersion Include="StackExchange.Redis" Version="2.13.17" />
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" /> <PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
<PackageVersion Include="Svg.Skia" Version="3.2.1" /> <PackageVersion Include="Svg.Skia" Version="3.7.0" />
<PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="6.5.0" /> <PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="10.2.3" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="6.2.3" /> <PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.3" />
<PackageVersion Include="System.Globalization" Version="4.3.0" /> <PackageVersion Include="System.Text.Json" Version="10.0.11" />
<PackageVersion Include="System.Linq.Async" Version="6.0.3" />
<PackageVersion Include="System.Text.Encoding.CodePages" Version="9.0.11" />
<PackageVersion Include="System.Text.Json" Version="9.0.11" />
<PackageVersion Include="System.Threading.Tasks.Dataflow" Version="9.0.11" />
<PackageVersion Include="TagLibSharp" Version="2.3.0" /> <PackageVersion Include="TagLibSharp" Version="2.3.0" />
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.4.0" /> <PackageVersion Include="Testcontainers.PostgreSql" Version="4.15.0" />
<PackageVersion Include="z440.atl.core" Version="7.9.0" /> <PackageVersion Include="Testcontainers.Redis" Version="4.15.0" />
<PackageVersion Include="TMDbLib" Version="2.3.0" /> <PackageVersion Include="z440.atl.core" Version="7.16.0" />
<PackageVersion Include="UTF.Unknown" Version="2.6.0" /> <PackageVersion Include="TMDbLib" Version="3.0.0" />
<PackageVersion Include="StackExchange.Redis" Version="2.8.16" /> <PackageVersion Include="UTF.Unknown" Version="2.7.0" />
<PackageVersion Include="Xunit.Priority" Version="1.1.6" /> <PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" /> <PackageVersion Include="xunit.v3" Version="3.2.2" />
<PackageVersion Include="Xunit.SkippableFact" Version="1.5.23" /> <PackageVersion Include="Xunit.v3.Priority" Version="1.1.18" />
<PackageVersion Include="xunit" Version="2.9.3" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+3 -3
View File
@@ -44,9 +44,9 @@ RUN dotnet publish Jellyfin.Server/Jellyfin.Server.csproj \
# ── Web client stage ────────────────────────────────────────────────────────── # ── Web client stage ──────────────────────────────────────────────────────────
# Install jellyfin-web via the official Jellyfin apt repo. # 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. # 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 FROM --platform=linux/amd64 debian:bookworm-slim AS webclient
RUN apt-get update \ 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" \ && 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 \ > /etc/apt/sources.list.d/jellyfin.list \
&& apt-get update \ && 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/* \ && rm -rf /var/lib/apt/lists/* \
&& echo "Web client files:" && ls /usr/share/jellyfin/web/ | head -10 && echo "Web client files:" && ls /usr/share/jellyfin/web/ | head -10
+3 -3
View File
@@ -4,9 +4,9 @@
# ── Web client stage ────────────────────────────────────────────────────────── # ── Web client stage ──────────────────────────────────────────────────────────
# Install jellyfin-web via the official Jellyfin apt repo. # 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. # 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 FROM --platform=linux/amd64 debian:bookworm-slim AS webclient
RUN apt-get update \ 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" \ && 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 \ > /etc/apt/sources.list.d/jellyfin.list \
&& apt-get update \ && 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/* && rm -rf /var/lib/apt/lists/*
# ── Runtime stage ───────────────────────────────────────────────────────────── # ── Runtime stage ─────────────────────────────────────────────────────────────
@@ -1,3 +1,5 @@
#pragma warning disable CA1815
namespace Emby.Naming.AudioBook namespace Emby.Naming.AudioBook
{ {
/// <summary> /// <summary>
+94
View File
@@ -0,0 +1,94 @@
using System;
using System.Text.RegularExpressions;
namespace Emby.Naming.Book
{
/// <summary>
/// Helper class to retrieve basic metadata from a book filename.
/// </summary>
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(@"^(?<seriesName>.+?)((\s\((?<seriesYear>[0-9]{4})\))?)\s#(?<index>[0-9]+)(?:\.0)?((\s\(of\s(?<count>[0-9]+)\))?)((\s\((?<year>[0-9]{4})\))?)$"),
new Regex(@"^(?<name>.+?)\s\((?<seriesName>.+?),\s#(?<index>[0-9]+)\)(?:\.0)?((\s\((?<year>[0-9]{4})\))?)$"),
new Regex(@"^(?<index>[0-9]+)(?:\.0)?\s\-\s(?<name>.+?)((\s\((?<year>[0-9]{4})\))?)$"),
new Regex(@"(?<name>.*)\((?<year>[0-9]{4})\)"),
// last resort matches the whole string as the name
new Regex(@"(?<name>.*)")
];
[GeneratedRegex(@"^(?<name>.+?)(\sv(?<volume>[0-9]+))?(\sc(?<chapter>[0-9]+))?$")]
private static partial Regex ComicRegex();
/// <summary>
/// Parse a filename name to retrieve the book name, series name, index, and year.
/// </summary>
/// <param name="name">Book filename to parse for information.</param>
/// <returns>Returns <see cref="BookFileNameParserResult"/> object.</returns>
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;
}
}
}
@@ -0,0 +1,45 @@
namespace Emby.Naming.Book
{
/// <summary>
/// Data object used to pass metadata parsed from a book filename.
/// </summary>
public class BookFileNameParserResult
{
/// <summary>
/// Initializes a new instance of the <see cref="BookFileNameParserResult"/> class.
/// </summary>
public BookFileNameParserResult()
{
Name = null;
Index = null;
ParentIndex = null;
Year = null;
SeriesName = null;
}
/// <summary>
/// Gets or sets the name of the book.
/// </summary>
public string? Name { get; set; }
/// <summary>
/// Gets or sets the book index.
/// </summary>
public int? Index { get; set; }
/// <summary>
/// Gets or sets the parent index number.
/// </summary>
public int? ParentIndex { get; set; }
/// <summary>
/// Gets or sets the publication year.
/// </summary>
public int? Year { get; set; }
/// <summary>
/// Gets or sets the series name.
/// </summary>
public string? SeriesName { get; set; }
}
}
+15 -4
View File
@@ -57,7 +57,6 @@ namespace Emby.Naming.Common
".nrg", ".nrg",
".nsv", ".nsv",
".nuv", ".nuv",
".ogg",
".ogm", ".ogm",
".ogv", ".ogv",
".pva", ".pva",
@@ -152,8 +151,8 @@ namespace Emby.Naming.Common
CleanStrings = CleanStrings =
[ [
@"^\s*(?<cleaned>.+?)[ _\,\.\(\)\[\]\-](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*(?<cleaned>.+?)[ _\,\.\(\)\[\]\-](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)(?=[ _\,\.\(\)\[\]\-]|$)",
@"^(?<cleaned>.+?)(\[.*\])", @"^\s*(?<cleaned>.+?)((\s*\[[^\]]+\]\s*)+)(\.[^\s]+)?$",
@"^\s*(?<cleaned>.+?)\WE[0-9]+(-|~)E?[0-9]+(\W|$)", @"^\s*(?<cleaned>.+?)\WE[0-9]+(-|~)E?[0-9]+(\W|$)",
@"^\s*\[[^\]]+\](?!\.\w+$)\s*(?<cleaned>.+)", @"^\s*\[[^\]]+\](?!\.\w+$)\s*(?<cleaned>.+)",
@"^\s*(?<cleaned>.+?)\s+-\s+[0-9]+\s*$", @"^\s*(?<cleaned>.+?)\s+-\s+[0-9]+\s*$",
@@ -225,6 +224,7 @@ namespace Emby.Naming.Common
".afc", ".afc",
".amf", ".amf",
".aif", ".aif",
".aifc",
".aiff", ".aiff",
".alac", ".alac",
".amr", ".amr",
@@ -361,7 +361,10 @@ namespace Emby.Naming.Common
// Not a Kodi rule as well, but the expression below also causes false positives, // Not a Kodi rule as well, but the expression below also causes false positives,
// so we make sure this one gets tested first. // so we make sure this one gets tested first.
// "Foo Bar 889" // "Foo Bar 889"
new EpisodeExpression(@".*[\\\/](?![Ee]pisode)(?<seriesname>[\w\s]+?)\s(?<epnumber>[0-9]{1,4})(-(?<endingepnumber>[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]+)(?<seriesname>[\w\s]+?)\s(?<epnumber>[0-9]{1,4})(-(?<endingepnumber>[0-9]{2,4}))*[^\\\/x]*$")
{ {
IsNamed = true IsNamed = true
}, },
@@ -378,6 +381,14 @@ namespace Emby.Naming.Common
IsNamed = true 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(@".*[\\\/](?<seriesname>[^\\\/]+?)[\s_]+-[\s_]+(?<epnumber>[0-9]+)[\s_]*(?:\[.*?\]|\(.*?\))*[\s_]*(?:\.\w+)?$")
{
IsNamed = true
},
// /server/anything_102.mp4 // /server/anything_102.mp4
// /server/james.corden.2017.04.20.anne.hathaway.720p.hdtv.x264-crooks.mkv // /server/james.corden.2017.04.20.anne.hathaway.720p.hdtv.x264-crooks.mkv
// /server/anything_1996.11.14.mp4 // /server/anything_1996.11.14.mp4
+2 -2
View File
@@ -6,7 +6,7 @@
</PropertyGroup> </PropertyGroup>
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateDocumentationFile>true</GenerateDocumentationFile> <GenerateDocumentationFile>true</GenerateDocumentationFile>
<PublishRepositoryUrl>true</PublishRepositoryUrl> <PublishRepositoryUrl>true</PublishRepositoryUrl>
@@ -36,7 +36,7 @@
<PropertyGroup> <PropertyGroup>
<Authors>Jellyfin Contributors</Authors> <Authors>Jellyfin Contributors</Authors>
<PackageId>Jellyfin.Naming</PackageId> <PackageId>Jellyfin.Naming</PackageId>
<VersionPrefix>10.11.7</VersionPrefix> <VersionPrefix>12.0.0</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup> </PropertyGroup>
@@ -44,7 +44,14 @@ namespace Emby.Naming.ExternalFiles
} }
var extension = Path.GetExtension(path.AsSpan()); 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.Audio && _namingOptions.AudioFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
&& !(_type == DlnaProfileType.Lyric && _namingOptions.LyricFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))) && !(_type == DlnaProfileType.Lyric && _namingOptions.LyricFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase)))
{ {
@@ -70,7 +77,7 @@ namespace Emby.Naming.ExternalFiles
if (lastSeparator == -1) if (lastSeparator == -1)
{ {
break; break;
} }
string currentSlice = languageString[lastSeparator..]; string currentSlice = languageString[lastSeparator..];
+5 -3
View File
@@ -125,7 +125,7 @@ namespace Emby.Naming.TV
result.Success = true; 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.Year = date.Year;
result.Month = date.Month; result.Month = date.Month;
@@ -158,7 +158,9 @@ namespace Emby.Naming.TV
if (nextIndex >= name.Length if (nextIndex >= name.Length
|| !"0123456789iIpP".Contains(name[nextIndex], StringComparison.Ordinal)) || !"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; result.EndingEpisodeNumber = num;
} }
@@ -226,7 +228,7 @@ namespace Emby.Naming.TV
info.SeriesName = result.SeriesName; info.SeriesName = result.SeriesName;
} }
if (!info.EndingEpisodeNumber.HasValue && info.EpisodeNumber.HasValue) if (!info.EndingEpisodeNumber.HasValue && result.EndingEpisodeNumber >= info.EpisodeNumber)
{ {
info.EndingEpisodeNumber = result.EndingEpisodeNumber; info.EndingEpisodeNumber = result.EndingEpisodeNumber;
} }
+21 -2
View File
@@ -10,17 +10,25 @@ namespace Emby.Naming.TV
/// </summary> /// </summary>
public static partial class SeasonPathParser 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); private static readonly Regex CleanNameRegex = new(@"[ ._\-\[\]]", RegexOptions.Compiled);
[GeneratedRegex(@"^\s*((?<seasonnumber>(?>\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*(?<rightpart>.*)$", RegexOptions.IgnoreCase)] [GeneratedRegex(@"^\s*((?<seasonnumber>(?>\d+))(?:st|nd|rd|th|\.)*(?!\s*[Ee]\d+))\s*(?:" + SeasonKeywordPattern + @")\s*(?<rightpart>.*)$", RegexOptions.IgnoreCase)]
private static partial Regex ProcessPre(); 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*(?<seasonnumber>\d+?)(?=\d{3,4}p|[^\d]|$)(?!\s*[Ee]\d)(?<rightpart>.*)$", RegexOptions.IgnoreCase)] [GeneratedRegex(@"^\s*(?:" + SeasonKeywordPattern + @")\s*(?<seasonnumber>\d+?)(?=\d{3,4}p|[^\d]|$)(?!\s*[Ee]\d)(?<rightpart>.*)$", RegexOptions.IgnoreCase)]
private static partial Regex ProcessPost(); private static partial Regex ProcessPost();
[GeneratedRegex(@"[sS](\d{1,4})(?!\d|[eE]\d)(?=\.|_|-|\[|\]|\s|$)", RegexOptions.None)] [GeneratedRegex(@"[sS](\d{1,4})(?!\d|[eE]\d)(?=\.|_|-|\[|\]|\s|$)", RegexOptions.None)]
private static partial Regex SeasonPrefix(); private static partial Regex SeasonPrefix();
[GeneratedRegex(SeasonKeywordPattern, RegexOptions.IgnoreCase)]
private static partial Regex SeasonKeyword();
/// <summary> /// <summary>
/// Attempts to parse season number from path. /// Attempts to parse season number from path.
/// </summary> /// </summary>
@@ -91,14 +99,25 @@ namespace Emby.Naming.TV
return (val, true); return (val, true);
} }
bool isMixedLibrary = !supportNumericSeasonFolders && !supportSpecialAliases;
var preMatch = ProcessPre().Match(filename); var preMatch = ProcessPre().Match(filename);
if (preMatch.Success) if (preMatch.Success)
{ {
if (isMixedLibrary && !SeasonKeyword().IsMatch(fileName))
{
return (null, false);
}
return CheckMatch(preMatch); return CheckMatch(preMatch);
} }
else else
{ {
var postMatch = ProcessPost().Match(filename); var postMatch = ProcessPost().Match(filename);
if (postMatch.Success && isMixedLibrary && !SeasonKeyword().IsMatch(fileName))
{
return (null, false);
}
return CheckMatch(postMatch); return CheckMatch(postMatch);
} }
} }
+6
View File
@@ -25,5 +25,11 @@ namespace Emby.Naming.TV
/// </summary> /// </summary>
/// <value>The name of the series.</value> /// <value>The name of the series.</value>
public string? Name { get; set; } public string? Name { get; set; }
/// <summary>
/// Gets or sets the year of the series.
/// </summary>
/// <value>The year of the series.</value>
public int? Year { get; set; }
} }
} }
+22
View File
@@ -17,6 +17,13 @@ namespace Emby.Naming.TV
[GeneratedRegex(@"((?<a>[^\._]{2,})[\._]*)|([\._](?<b>[^\._]{2,}))")] [GeneratedRegex(@"((?<a>[^\._]{2,})[\._]*)|([\._](?<b>[^\._]{2,}))")]
private static partial Regex SeriesNameRegex(); private static partial Regex SeriesNameRegex();
/// <summary>
/// Regex that matches titles with year in parentheses. Captures the title (which may be
/// numeric) before the year, i.e. turns "1923 (2022)" into "1923".
/// </summary>
[GeneratedRegex(@"(?<title>.+?)\s*\((?<year>[0-9]{4})\)")]
private static partial Regex TitleWithYearRegex();
/// <summary> /// <summary>
/// Resolve information about series from path. /// Resolve information about series from path.
/// </summary> /// </summary>
@@ -27,6 +34,21 @@ namespace Emby.Naming.TV
{ {
string seriesName = Path.GetFileName(path); 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); SeriesPathParserResult result = SeriesPathParser.Parse(options, path);
if (result.Success) if (result.Success)
{ {
+1 -1
View File
@@ -18,7 +18,7 @@ public static class TvParserHelpers
/// <param name="status">The status string.</param> /// <param name="status">The status string.</param>
/// <param name="enumValue">The <see cref="SeriesStatus"/>.</param> /// <param name="enumValue">The <see cref="SeriesStatus"/>.</param>
/// <returns>Returns true if parsing was successful.</returns> /// <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)) if (Enum.TryParse(status, true, out SeriesStatus seriesStatus))
{ {
+2
View File
@@ -1,3 +1,5 @@
#pragma warning disable CA1815
namespace Emby.Naming.Video namespace Emby.Naming.Video
{ {
/// <summary> /// <summary>
+1 -1
View File
@@ -44,7 +44,7 @@ namespace Emby.Naming.Video
var match = expression.Match(name); var match = expression.Match(name);
if (match.Success && match.Groups.TryGetValue("cleaned", out var cleaned)) if (match.Success && match.Groups.TryGetValue("cleaned", out var cleaned))
{ {
newName = cleaned.Value; newName = cleaned.Value.Trim();
return true; return true;
} }
+9 -4
View File
@@ -52,13 +52,18 @@ namespace Emby.Naming.Video
while (path.Length > 0) while (path.Length > 0)
{ {
var index = path.IndexOfAny(delimiters); var index = path.IndexOfAny(delimiters);
ReadOnlySpan<char> currentSlice;
if (index == -1) 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) if (!foundPrefix)
{ {
+4 -4
View File
@@ -17,8 +17,8 @@ namespace Emby.Naming.Video
{ {
Name = name; Name = name;
Files = Array.Empty<VideoFileInfo>(); Files = [];
AlternateVersions = Array.Empty<VideoFileInfo>(); AlternateVersions = [];
} }
/// <summary> /// <summary>
@@ -40,10 +40,10 @@ namespace Emby.Naming.Video
public IReadOnlyList<VideoFileInfo> Files { get; set; } public IReadOnlyList<VideoFileInfo> Files { get; set; }
/// <summary> /// <summary>
/// Gets or sets the alternate versions. /// Gets or sets the alternate versions. Each alternate may itself span multiple files.
/// </summary> /// </summary>
/// <value>The alternate versions.</value> /// <value>The alternate versions.</value>
public IReadOnlyList<VideoFileInfo> AlternateVersions { get; set; } public IReadOnlyList<VideoInfo> AlternateVersions { get; set; }
/// <summary> /// <summary>
/// Gets or sets the extra type. /// Gets or sets the extra type.
+142 -41
View File
@@ -1,10 +1,12 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Emby.Naming.Common; using Emby.Naming.Common;
using Jellyfin.Extensions; using Emby.Naming.TV;
using Jellyfin.Data.Enums;
using MediaBrowser.Model.IO; using MediaBrowser.Model.IO;
namespace Emby.Naming.Video namespace Emby.Naming.Video
@@ -12,8 +14,23 @@ namespace Emby.Naming.Video
/// <summary> /// <summary>
/// Resolves alternative versions and extras from list of video files. /// Resolves alternative versions and extras from list of video files.
/// </summary> /// </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)] [GeneratedRegex("[0-9]{2}[0-9]+[ip]", RegexOptions.IgnoreCase)]
private static partial Regex ResolutionRegex(); private static partial Regex ResolutionRegex();
@@ -24,12 +41,12 @@ namespace Emby.Naming.Video
/// Resolves alternative versions and extras from list of video files. /// Resolves alternative versions and extras from list of video files.
/// </summary> /// </summary>
/// <param name="videoInfos">List of related video files.</param> /// <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="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="parseName">Whether to parse the name or use the filename.</param>
/// <param name="libraryRoot">Top-level folder for the containing library.</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> /// <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 // Filter out all extras, otherwise they could cause stacks to not be resolved
// See the unit test TestStackedWithTrailer // See the unit test TestStackedWithTrailer
@@ -37,7 +54,7 @@ namespace Emby.Naming.Video
.Where(i => i.ExtraType is null) .Where(i => i.ExtraType is null)
.Select(i => new FileSystemMetadata { FullName = i.Path, IsDirectory = i.IsDirectory }); .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 remainingFiles = new List<VideoFileInfo>();
var standaloneMedia = new List<VideoFileInfo>(); var standaloneMedia = new List<VideoFileInfo>();
@@ -66,7 +83,7 @@ namespace Emby.Naming.Video
{ {
var info = new VideoInfo(stack.Name) 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>() .OfType<VideoFileInfo>()
.ToList() .ToList()
}; };
@@ -85,7 +102,9 @@ namespace Emby.Naming.Video
if (supportMultiVersion) if (supportMultiVersion)
{ {
list = GetVideosGroupedByVersion(list, namingOptions); list = collectionType is CollectionType.tvshows
? GetEpisodesGroupedByVersion(list)
: GetVideosGroupedByVersion(list);
} }
// Whatever files are left, just add them // Whatever files are left, just add them
@@ -99,7 +118,7 @@ namespace Emby.Naming.Video
return list; return list;
} }
private static List<VideoInfo> GetVideosGroupedByVersion(List<VideoInfo> videos, NamingOptions namingOptions) private List<VideoInfo> GetVideosGroupedByVersion(List<VideoInfo> videos)
{ {
if (videos.Count == 0) if (videos.Count == 0)
{ {
@@ -123,7 +142,7 @@ namespace Emby.Naming.Video
continue; continue;
} }
if (!IsEligibleForMultiVersion(folderName, video.Files[0].FileNameWithoutExtension, namingOptions)) if (!IsEligibleForMultiVersion(folderName, video.Files[0].FileNameWithoutExtension))
{ {
return videos; return videos;
} }
@@ -134,37 +153,9 @@ namespace Emby.Naming.Video
} }
} }
if (videos.Count > 1) var organized = OrganizeAlternateVersions(videos, primary, folderName.ToString());
{
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()));
}
}
}
primary ??= videos[0]; return [organized];
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;
} }
private static bool HaveSameYear(IReadOnlyList<VideoInfo> videos) private static bool HaveSameYear(IReadOnlyList<VideoInfo> videos)
@@ -186,7 +177,7 @@ namespace Emby.Naming.Video
return true; 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)) if (!testFilename.StartsWith(folderName, StringComparison.OrdinalIgnoreCase))
{ {
@@ -200,7 +191,7 @@ namespace Emby.Naming.Video
} }
// There are no span overloads for regex unfortunately // 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(); testFilename = cleanName.AsSpan().Trim();
} }
@@ -208,7 +199,117 @@ namespace Emby.Naming.Video
// The CleanStringParser should have removed common keywords etc. // The CleanStringParser should have removed common keywords etc.
return testFilename.IsEmpty return testFilename.IsEmpty
|| testFilename[0] == '-' || testFilename[0] == '-'
|| testFilename[0] == '_'
|| testFilename[0] == '.'
|| CheckMultiVersionRegex().IsMatch(testFilename); || 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;
}
} }
} }
+1 -1
View File
@@ -19,7 +19,7 @@
</ItemGroup> </ItemGroup>
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateDocumentationFile>true</GenerateDocumentationFile> <GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup> </PropertyGroup>
@@ -90,6 +90,7 @@ namespace Emby.Server.Implementations.AppBase
CreateAndCheckMarker(ProgramDataPath, "data"); CreateAndCheckMarker(ProgramDataPath, "data");
CreateAndCheckMarker(CachePath, "cache"); CreateAndCheckMarker(CachePath, "cache");
CreateAndCheckMarker(DataPath, "data"); CreateAndCheckMarker(DataPath, "data");
CreateCacheDirTag(CachePath);
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -100,6 +101,26 @@ namespace Emby.Server.Implementations.AppBase
CheckOrCreateMarker(path, $".jellyfin-{markerName}", recursive); 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) private IEnumerable<string> GetMarkers(string path, bool recursive = false)
{ {
return Directory.EnumerateFiles(path, ".jellyfin-*", recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly); return Directory.EnumerateFiles(path, ".jellyfin-*", recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly);
@@ -132,7 +132,7 @@ namespace Emby.Server.Implementations.AppBase
} }
else else
{ {
_configurationFactories = [.._configurationFactories, factory]; _configurationFactories = [.. _configurationFactories, factory];
} }
_configurationStores = _configurationFactories _configurationStores = _configurationFactories
@@ -228,6 +228,7 @@ namespace Emby.Server.Implementations.AppBase
Logger.LogInformation("Setting cache path: {Path}", cachePath); Logger.LogInformation("Setting cache path: {Path}", cachePath);
((BaseApplicationPaths)CommonApplicationPaths).CachePath = cachePath; ((BaseApplicationPaths)CommonApplicationPaths).CachePath = cachePath;
CommonApplicationPaths.CreateAndCheckMarker(((BaseApplicationPaths)CommonApplicationPaths).CachePath, "cache"); CommonApplicationPaths.CreateAndCheckMarker(((BaseApplicationPaths)CommonApplicationPaths).CachePath, "cache");
BaseApplicationPaths.CreateCacheDirTag(cachePath);
} }
/// <summary> /// <summary>
+68 -6
View File
@@ -14,6 +14,7 @@ using System.Reflection;
using System.Security.Cryptography.X509Certificates; using System.Security.Cryptography.X509Certificates;
using System.Threading.Tasks; using System.Threading.Tasks;
using Emby.Naming.Common; using Emby.Naming.Common;
using Emby.Naming.Video;
using Emby.Photos; using Emby.Photos;
using Emby.Server.Implementations.Chapters; using Emby.Server.Implementations.Chapters;
using Emby.Server.Implementations.Collections; using Emby.Server.Implementations.Collections;
@@ -25,6 +26,8 @@ using Emby.Server.Implementations.Dto;
using Emby.Server.Implementations.HttpServer.Security; using Emby.Server.Implementations.HttpServer.Security;
using Emby.Server.Implementations.IO; using Emby.Server.Implementations.IO;
using Emby.Server.Implementations.Library; 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.Localization;
using Emby.Server.Implementations.Playlists; using Emby.Server.Implementations.Playlists;
using Emby.Server.Implementations.Plugins; using Emby.Server.Implementations.Plugins;
@@ -36,6 +39,8 @@ using Emby.Server.Implementations.SyncPlay;
using Emby.Server.Implementations.TV; using Emby.Server.Implementations.TV;
using Emby.Server.Implementations.Updates; using Emby.Server.Implementations.Updates;
using Jellyfin.Api.Helpers; using Jellyfin.Api.Helpers;
using Jellyfin.Data;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Drawing; using Jellyfin.Drawing;
using Jellyfin.MediaEncoding.Hls.Playlist; using Jellyfin.MediaEncoding.Hls.Playlist;
using Jellyfin.Networking.Manager; using Jellyfin.Networking.Manager;
@@ -90,9 +95,16 @@ using MediaBrowser.Model.Net;
using MediaBrowser.Model.Serialization; using MediaBrowser.Model.Serialization;
using MediaBrowser.Model.System; using MediaBrowser.Model.System;
using MediaBrowser.Model.Tasks; 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.Lyric;
using MediaBrowser.Providers.Manager; 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;
using MediaBrowser.Providers.Plugins.Tmdb.Movies;
using MediaBrowser.Providers.Plugins.Tmdb.TV;
using MediaBrowser.Providers.Subtitles; using MediaBrowser.Providers.Subtitles;
using MediaBrowser.XbmcMetadata.Providers; using MediaBrowser.XbmcMetadata.Providers;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
@@ -166,8 +178,6 @@ namespace Emby.Server.Implementations
ConfigurationManager.Configuration, ConfigurationManager.Configuration,
ApplicationPaths.PluginsPath, ApplicationPaths.PluginsPath,
ApplicationVersion); ApplicationVersion);
_disposableParts.Add(_pluginManager);
} }
/// <summary> /// <summary>
@@ -409,6 +419,8 @@ namespace Emby.Server.Implementations
{ {
Logger.LogInformation("Running startup tasks"); Logger.LogInformation("Running startup tasks");
EnsureStartupWizardIntegrity();
Resolve<ITaskManager>().AddTasks(GetExports<IScheduledTask>(false)); Resolve<ITaskManager>().AddTasks(GetExports<IScheduledTask>(false));
ConfigurationManager.ConfigurationUpdated += OnConfigurationUpdated; ConfigurationManager.ConfigurationUpdated += OnConfigurationUpdated;
@@ -428,6 +440,24 @@ namespace Emby.Server.Implementations
return Task.CompletedTask; 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/> /// <inheritdoc/>
public void Init(IServiceCollection serviceCollection) public void Init(IServiceCollection serviceCollection)
{ {
@@ -485,6 +515,19 @@ namespace Emby.Server.Implementations
serviceCollection.AddScoped<ISystemManager, SystemManager>(); serviceCollection.AddScoped<ISystemManager, SystemManager>();
serviceCollection.AddSingleton<TmdbClientManager>(); 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); serviceCollection.AddSingleton(NetManager);
@@ -507,7 +550,13 @@ namespace Emby.Server.Implementations
serviceCollection.AddSingleton<IUserDataManager, UserDataManager>(); 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<IPeopleRepository, PeopleRepository>();
serviceCollection.AddSingleton<IChapterRepository, ChapterRepository>(); serviceCollection.AddSingleton<IChapterRepository, ChapterRepository>();
serviceCollection.AddSingleton<IMediaAttachmentRepository, MediaAttachmentRepository>(); 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<ILibraryMonitor>(provider.GetRequiredService<ILibraryMonitor>));
serviceCollection.AddTransient(provider => new Lazy<IProviderManager>(provider.GetRequiredService<IProviderManager>)); serviceCollection.AddTransient(provider => new Lazy<IProviderManager>(provider.GetRequiredService<IProviderManager>));
serviceCollection.AddTransient(provider => new Lazy<IUserViewManager>(provider.GetRequiredService<IUserViewManager>)); serviceCollection.AddTransient(provider => new Lazy<IUserViewManager>(provider.GetRequiredService<IUserViewManager>));
serviceCollection.AddTransient(provider => new Lazy<IExternalDataManager>(provider.GetRequiredService<IExternalDataManager>));
serviceCollection.AddSingleton<ILibraryManager, LibraryManager>(); serviceCollection.AddSingleton<ILibraryManager, LibraryManager>();
serviceCollection.AddSingleton<NamingOptions>(); serviceCollection.AddSingleton<NamingOptions>();
serviceCollection.AddSingleton<VideoListResolver>();
serviceCollection.AddSingleton<IMusicManager, MusicManager>(); serviceCollection.AddSingleton<IMusicManager, MusicManager>();
serviceCollection.AddSingleton<ILibraryMonitor, LibraryMonitor>(); 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>(); serviceCollection.AddSingleton<IWebSocketManager, WebSocketManager>();
@@ -641,6 +696,7 @@ namespace Emby.Server.Implementations
BaseItem.ConfigurationManager = ConfigurationManager; BaseItem.ConfigurationManager = ConfigurationManager;
BaseItem.FileSystem = Resolve<IFileSystem>(); BaseItem.FileSystem = Resolve<IFileSystem>();
BaseItem.ItemRepository = Resolve<IItemRepository>(); BaseItem.ItemRepository = Resolve<IItemRepository>();
BaseItem.ItemCountService = Resolve<IItemCountService>();
BaseItem.LibraryManager = Resolve<ILibraryManager>(); BaseItem.LibraryManager = Resolve<ILibraryManager>();
BaseItem.LocalizationManager = Resolve<ILocalizationManager>(); BaseItem.LocalizationManager = Resolve<ILocalizationManager>();
BaseItem.Logger = Resolve<ILogger<BaseItem>>(); BaseItem.Logger = Resolve<ILogger<BaseItem>>();
@@ -687,6 +743,9 @@ namespace Emby.Server.Implementations
GetExports<IExternalUrlProvider>()); GetExports<IExternalUrlProvider>());
Resolve<IMediaSourceManager>().AddParts(GetExports<IMediaSourceProvider>()); Resolve<IMediaSourceManager>().AddParts(GetExports<IMediaSourceProvider>());
Resolve<ISimilarItemsManager>().AddParts(GetExports<ISimilarItemsProvider>());
Resolve<ISearchManager>().AddParts(GetExports<ISearchProvider>());
} }
/// <summary> /// <summary>
@@ -928,8 +987,9 @@ namespace Emby.Server.Implementations
/// <inheritdoc/> /// <inheritdoc/>
public string GetLocalApiUrl(string hostname, string scheme = null, int? port = null) 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 the smartAPI isn't already a complete URL then treat it as a host or ip.
if (hostname.StartsWith("http", StringComparison.OrdinalIgnoreCase)) if (hostname.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
|| hostname.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{ {
return hostname.TrimEnd('/'); return hostname.TrimEnd('/');
} }
@@ -1006,6 +1066,8 @@ namespace Emby.Server.Implementations
} }
_disposableParts.Clear(); _disposableParts.Clear();
_pluginManager?.Dispose();
} }
_disposed = true; _disposed = true;
@@ -7,6 +7,7 @@ using System.Threading.Tasks;
using Jellyfin.Extensions; using Jellyfin.Extensions;
using MediaBrowser.Controller.Chapters; using MediaBrowser.Controller.Chapters;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.IO; using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.MediaEncoding;
@@ -128,7 +129,7 @@ public class ChapterManager : IChapterManager
var averageChapterDuration = GetAverageDurationBetweenChapters(chapters); var averageChapterDuration = GetAverageDurationBetweenChapters(chapters);
var threshold = TimeSpan.FromSeconds(1).Ticks; 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); _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; extractImages = false;
@@ -232,11 +233,21 @@ public class ChapterManager : IChapterManager
} }
/// <inheritdoc /> /// <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 if (!Supports(item))
var validChapters = chapters.Where(c => c.StartPositionTicks < video.RunTimeTicks).ToList(); {
_chapterRepository.SaveChapters(video.Id, validChapters); _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 /> /// <inheritdoc />
@@ -4,12 +4,15 @@ using System.IO;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Entities; using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Extensions;
using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Collections; using MediaBrowser.Controller.Collections;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Entities; using MediaBrowser.Model.Entities;
@@ -29,6 +32,7 @@ namespace Emby.Server.Implementations.Collections
private readonly ILibraryMonitor _iLibraryMonitor; private readonly ILibraryMonitor _iLibraryMonitor;
private readonly ILogger<CollectionManager> _logger; private readonly ILogger<CollectionManager> _logger;
private readonly IProviderManager _providerManager; private readonly IProviderManager _providerManager;
private readonly ILinkedChildrenService _linkedChildrenService;
private readonly ILocalizationManager _localizationManager; private readonly ILocalizationManager _localizationManager;
private readonly IApplicationPaths _appPaths; private readonly IApplicationPaths _appPaths;
@@ -42,6 +46,7 @@ namespace Emby.Server.Implementations.Collections
/// <param name="iLibraryMonitor">The library monitor.</param> /// <param name="iLibraryMonitor">The library monitor.</param>
/// <param name="loggerFactory">The logger factory.</param> /// <param name="loggerFactory">The logger factory.</param>
/// <param name="providerManager">The provider manager.</param> /// <param name="providerManager">The provider manager.</param>
/// <param name="linkedChildrenService">The linked children service.</param>
public CollectionManager( public CollectionManager(
ILibraryManager libraryManager, ILibraryManager libraryManager,
IApplicationPaths appPaths, IApplicationPaths appPaths,
@@ -49,13 +54,15 @@ namespace Emby.Server.Implementations.Collections
IFileSystem fileSystem, IFileSystem fileSystem,
ILibraryMonitor iLibraryMonitor, ILibraryMonitor iLibraryMonitor,
ILoggerFactory loggerFactory, ILoggerFactory loggerFactory,
IProviderManager providerManager) IProviderManager providerManager,
ILinkedChildrenService linkedChildrenService)
{ {
_libraryManager = libraryManager; _libraryManager = libraryManager;
_fileSystem = fileSystem; _fileSystem = fileSystem;
_iLibraryMonitor = iLibraryMonitor; _iLibraryMonitor = iLibraryMonitor;
_logger = loggerFactory.CreateLogger<CollectionManager>(); _logger = loggerFactory.CreateLogger<CollectionManager>();
_providerManager = providerManager; _providerManager = providerManager;
_linkedChildrenService = linkedChildrenService;
_localizationManager = localizationManager; _localizationManager = localizationManager;
_appPaths = appPaths; _appPaths = appPaths;
} }
@@ -100,7 +107,8 @@ namespace Emby.Server.Implementations.Collections
SaveLocalMetadata = true 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); await _libraryManager.AddVirtualFolder(name, CollectionTypeOptions.boxsets, libraryOptions, true).ConfigureAwait(false);
@@ -120,6 +128,22 @@ namespace Emby.Server.Implementations.Collections
return EnsureLibraryFolder(GetCollectionsFolderPath(), createIfNeeded); 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) private IEnumerable<BoxSet> GetCollections(User user)
{ {
var folder = GetCollectionsFolder(false).GetAwaiter().GetResult(); var folder = GetCollectionsFolder(false).GetAwaiter().GetResult();
@@ -272,7 +296,7 @@ namespace Emby.Server.Implementations.Collections
{ {
var childItem = _libraryManager.GetItemById(guidId); 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) 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? // 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) if (item is Video video)
{ {
foreach (var childId in video.GetLocalAlternateVersionIds()) foreach (var childId in _libraryManager.GetLocalAlternateVersionIds(video))
{ {
if (!results.ContainsKey(childId)) if (!results.ContainsKey(childId))
{ {
@@ -39,22 +39,24 @@ namespace Emby.Server.Implementations.Cryptography
{ {
if (string.Equals(hash.Id, "PBKDF2", StringComparison.Ordinal)) if (string.Equals(hash.Id, "PBKDF2", StringComparison.Ordinal))
{ {
var iterations = GetIterationsParameter(hash);
return hash.Hash.SequenceEqual( return hash.Hash.SequenceEqual(
Rfc2898DeriveBytes.Pbkdf2( Rfc2898DeriveBytes.Pbkdf2(
password, password,
hash.Salt, hash.Salt,
int.Parse(hash.Parameters["iterations"], CultureInfo.InvariantCulture), iterations,
HashAlgorithmName.SHA1, HashAlgorithmName.SHA1,
32)); 32));
} }
if (string.Equals(hash.Id, "PBKDF2-SHA512", StringComparison.Ordinal)) if (string.Equals(hash.Id, "PBKDF2-SHA512", StringComparison.Ordinal))
{ {
var iterations = GetIterationsParameter(hash);
return hash.Hash.SequenceEqual( return hash.Hash.SequenceEqual(
Rfc2898DeriveBytes.Pbkdf2( Rfc2898DeriveBytes.Pbkdf2(
password, password,
hash.Salt, hash.Salt,
int.Parse(hash.Parameters["iterations"], CultureInfo.InvariantCulture), iterations,
HashAlgorithmName.SHA512, HashAlgorithmName.SHA512,
DefaultOutputLength)); DefaultOutputLength));
} }
@@ -62,6 +64,27 @@ namespace Emby.Server.Implementations.Cryptography
throw new NotSupportedException($"Can't verify hash with id: {hash.Id}"); 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 /> /// <inheritdoc />
public byte[] GenerateSalt() public byte[] GenerateSalt()
=> GenerateSalt(DefaultSaltLength); => GenerateSalt(DefaultSaltLength);
@@ -5,10 +5,12 @@ using System.IO;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.IO; using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Playlists;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -35,7 +37,11 @@ public class CleanDatabaseScheduledTask : ILibraryPostScanTask
public async Task Run(IProgress<double> progress, CancellationToken cancellationToken) 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) private async Task CleanDeadItems(CancellationToken cancellationToken, IProgress<double> progress)
@@ -116,4 +122,32 @@ public class CleanDatabaseScheduledTask : ILibraryPostScanTask
progress.Report(100); 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);
}
} }
@@ -1,5 +1,3 @@
#pragma warning disable CS1591
using System; using System;
using System.Globalization; using System.Globalization;
using System.IO; using System.IO;
@@ -10,6 +8,9 @@ using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.Devices namespace Emby.Server.Implementations.Devices
{ {
/// <summary>
/// Provides the persistent unique identifier of this server installation.
/// </summary>
public class DeviceId public class DeviceId
{ {
private readonly IApplicationPaths _appPaths; private readonly IApplicationPaths _appPaths;
@@ -18,12 +19,20 @@ namespace Emby.Server.Implementations.Devices
private string? _id; 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) public DeviceId(IApplicationPaths appPaths, ILogger<DeviceId> logger)
{ {
_appPaths = appPaths; _appPaths = appPaths;
_logger = logger; _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(); public string Value => _id ??= GetDeviceId();
private string CachePath => Path.Combine(_appPaths.DataPath, "device.txt"); private string CachePath => Path.Combine(_appPaths.DataPath, "device.txt");
+363 -68
View File
@@ -71,6 +71,8 @@ namespace Emby.Server.Implementations.Dto
{ {
BaseItemKind.Person, [ BaseItemKind.Person, [
BaseItemKind.Audio, BaseItemKind.Audio,
BaseItemKind.AudioBook,
BaseItemKind.Book,
BaseItemKind.Episode, BaseItemKind.Episode,
BaseItemKind.Movie, BaseItemKind.Movie,
BaseItemKind.LiveTvProgram, BaseItemKind.LiveTvProgram,
@@ -153,17 +155,139 @@ namespace Emby.Server.Implementations.Dto
private ILiveTvManager LivetvManager => _livetvManagerFactory.Value; private ILiveTvManager LivetvManager => _livetvManagerFactory.Value;
/// <inheritdoc /> /// <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]; var returnItems = new BaseItemDto[accessibleItems.Count];
List<(BaseItem, BaseItemDto)>? programTuples = null; List<(BaseItem, BaseItemDto)>? programTuples = null;
List<(BaseItemDto, LiveTvChannel)>? channelTuples = 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++) for (int index = 0; index < accessibleItems.Count; index++)
{ {
var item = accessibleItems[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) if (item is LiveTvChannel tvChannel)
{ {
@@ -176,7 +300,7 @@ namespace Emby.Server.Implementations.Dto
if (options.ContainsField(ItemFields.ItemCounts)) if (options.ContainsField(ItemFields.ItemCounts))
{ {
SetItemByNameInfo(dto, user); SetItemByNameInfo(dto, user, itemCountsBatch);
} }
returnItems[index] = dto; 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) 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) if (item is LiveTvChannel tvChannel)
{ {
LivetvManager.AddChannelInfo(new[] { (dto, tvChannel) }, options, user); LivetvManager.AddChannelInfo(new[] { (dto, tvChannel) }, options, user);
@@ -215,7 +339,19 @@ namespace Emby.Server.Implementations.Dto
return 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 var dto = new BaseItemDto
{ {
@@ -229,7 +365,15 @@ namespace Emby.Server.Implementations.Dto
if (options.ContainsField(ItemFields.People)) 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)) if (options.ContainsField(ItemFields.PrimaryImageAspectRatio))
@@ -252,7 +396,15 @@ namespace Emby.Server.Implementations.Dto
if (user is not null) if (user is not null)
{ {
AttachUserSpecificInfo(dto, item, user, options); AttachUserSpecificInfo(
dto,
item,
user,
options,
userData,
childCountBatch,
playedCountBatch,
resumeData);
} }
if (item is IHasMediaSources if (item is IHasMediaSources
@@ -268,13 +420,15 @@ namespace Emby.Server.Implementations.Dto
AttachStudios(dto, item); AttachStudios(dto, item);
} }
AttachBasicFields(dto, item, owner, options); AttachBasicFields(dto, item, owner, options, artistsBatch, user, alternateVersionItemIds);
if (options.ContainsField(ItemFields.CanDelete)) if (options.ContainsField(ItemFields.CanDelete))
{ {
dto.CanDelete = user is null dto.CanDelete = user is null
? item.CanDelete() ? item.CanDelete()
: item.CanDelete(user); : allCollectionFolders is not null
? item.CanDelete(user, allCollectionFolders)
: item.CanDelete(user);
} }
if (options.ContainsField(ItemFields.CanDownload)) if (options.ContainsField(ItemFields.CanDownload))
@@ -371,44 +525,36 @@ namespace Emby.Server.Implementations.Dto
return 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)) if (!_relatedItemKinds.TryGetValue(dto.Type, out var relatedItemKinds))
{ {
return; return;
} }
var query = new InternalItemsQuery(user) var counts = prefetchedCounts?.GetValueOrDefault(dto.Id)
{ ?? _libraryManager.GetItemCountsForNameItem(dto.Type, dto.Id, relatedItemKinds, 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);
dto.AlbumCount = counts.AlbumCount; dto.AlbumCount = counts.AlbumCount;
dto.ArtistCount = counts.ArtistCount; dto.ArtistCount = counts.ArtistCount;
@@ -458,7 +604,15 @@ namespace Emby.Server.Implementations.Dto
/// <summary> /// <summary>
/// Attaches the user specific info. /// Attaches the user specific info.
/// </summary> /// </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) if (item.IsFolder)
{ {
@@ -466,7 +620,19 @@ namespace Emby.Server.Implementations.Dto
if (options.EnableUserData) 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) 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 // 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) 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; var folderChildCount = folder.LinkedChildren.Length;
// The default is an empty array, so we can't reliably use the count when it's empty // The default is an empty array, so we can't reliably use the count when it's empty
if (folderChildCount > 0) if (folderChildCount > 0)
@@ -485,7 +655,7 @@ namespace Emby.Server.Implementations.Dto
if (options.ContainsField(ItemFields.ChildCount)) 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) 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 // 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 // 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); 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); return folder.GetChildCount(user);
} }
@@ -602,12 +811,18 @@ namespace Emby.Server.Implementations.Dto
/// <param name="dto">The dto.</param> /// <param name="dto">The dto.</param>
/// <param name="item">The item.</param> /// <param name="item">The item.</param>
/// <param name="user">The requesting user.</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. // 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 is taking advantage of the fact that they both begin with A
// This should be improved in the future // 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 => .ThenBy(i =>
{ {
if (i.IsType(PersonKind.Actor)) if (i.IsType(PersonKind.Actor))
@@ -815,7 +1030,10 @@ namespace Emby.Server.Implementations.Dto
/// <param name="item">The item.</param> /// <param name="item">The item.</param>
/// <param name="owner">The owner.</param> /// <param name="owner">The owner.</param>
/// <param name="options">The options.</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)) if (options.ContainsField(ItemFields.DateCreated))
{ {
@@ -939,12 +1157,14 @@ namespace Emby.Server.Implementations.Dto
dto.OriginalTitle = item.OriginalTitle; dto.OriginalTitle = item.OriginalTitle;
} }
dto.OriginalLanguage = item.OriginalLanguage;
if (options.ContainsField(ItemFields.ParentId)) if (options.ContainsField(ItemFields.ParentId))
{ {
dto.ParentId = item.DisplayParentId; dto.ParentId = item.DisplayParentId;
} }
AddInheritedImages(dto, item, options, owner); AddInheritedImages(dto, item, options, owner, artistsBatch);
if (options.ContainsField(ItemFields.Path)) if (options.ContainsField(ItemFields.Path))
{ {
@@ -1019,6 +1239,15 @@ namespace Emby.Server.Implementations.Dto
{ {
dto.AlbumId = albumParent.Id; dto.AlbumId = albumParent.Id;
dto.AlbumPrimaryImageTag = GetTagAndFillBlurhash(dto, albumParent, ImageType.Primary); 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)) // 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 // 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 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 dto.ArtistItems = hasArtist.Artists
.Where(name => !string.IsNullOrWhiteSpace(name)) .Where(name => !string.IsNullOrWhiteSpace(name))
@@ -1085,7 +1315,8 @@ namespace Emby.Server.Implementations.Dto
// }) // })
// .ToList(); // .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 dto.AlbumArtists = hasAlbumArtist.AlbumArtists
.Where(name => !string.IsNullOrWhiteSpace(name)) .Where(name => !string.IsNullOrWhiteSpace(name))
@@ -1116,16 +1347,28 @@ namespace Emby.Server.Implementations.Dto
if (options.ContainsField(ItemFields.MediaSourceCount)) if (options.ContainsField(ItemFields.MediaSourceCount))
{ {
var mediaSourceCount = video.MediaSourceCount; // A video with no primary version and no alternate versions always has a single
if (mediaSourceCount != 1) // 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.
dto.MediaSourceCount = mediaSourceCount; // 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)) if (mayHaveAlternateVersions)
{ {
dto.Chapters = _chapterManager.GetChapters(item.Id).ToList(); // 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)) if (options.ContainsField(ItemFields.Trickplay))
@@ -1141,6 +1384,11 @@ namespace Emby.Server.Implementations.Dto
dto.ExtraType = video.ExtraType; dto.ExtraType = video.ExtraType;
} }
if (options.ContainsField(ItemFields.Chapters))
{
dto.Chapters = _chapterManager.GetChapters(item.Id).ToList();
}
if (options.ContainsField(ItemFields.MediaStreams)) if (options.ContainsField(ItemFields.MediaStreams))
{ {
// Add VideoInfo // 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)) if (options.ContainsField(ItemFields.SeriesStudio))
{ {
episodeSeries ??= episode.Series; 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) 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) if (artist is not null)
{ {
return artist; return artist;
@@ -1361,8 +1628,36 @@ namespace Emby.Server.Implementations.Dto
return parent; 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) if (!item.SupportsInheritedParentImages)
{ {
return; return;
@@ -1391,7 +1686,7 @@ namespace Emby.Server.Implementations.Dto
|| (!(imageTags is not null && imageTags.ContainsKey(ImageType.Thumb)) && thumbLimit > 0) || (!(imageTags is not null && imageTags.ContainsKey(ImageType.Thumb)) && thumbLimit > 0)
|| parent is Series) || parent is Series)
{ {
parent ??= isFirst ? GetImageDisplayParent(item, item) ?? owner : parent; parent ??= isFirst ? GetImageDisplayParent(item, item, artistsBatch) ?? owner : parent;
if (parent is null) if (parent is null)
{ {
break; break;
@@ -1450,7 +1745,7 @@ namespace Emby.Server.Implementations.Dto
break; break;
} }
parent = GetImageDisplayParent(parent, item); parent = GetImageDisplayParent(parent, item, artistsBatch);
} }
} }
@@ -27,7 +27,6 @@
<PackageReference Include="Microsoft.Data.Sqlite" /> <PackageReference Include="Microsoft.Data.Sqlite" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" /> <PackageReference Include="Microsoft.Extensions.Caching.Memory" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" /> <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
<PackageReference Include="prometheus-net.DotNetRuntime" /> <PackageReference Include="prometheus-net.DotNetRuntime" />
@@ -39,7 +38,7 @@
</ItemGroup> </ItemGroup>
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateDocumentationFile>true</GenerateDocumentationFile> <GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup> </PropertyGroup>
@@ -27,6 +27,11 @@ namespace Emby.Server.Implementations.EntryPoints;
/// </summary> /// </summary>
public sealed class LibraryChangedNotifier : IHostedService, IDisposable 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 ILibraryManager _libraryManager;
private readonly IServerConfigurationManager _configurationManager; private readonly IServerConfigurationManager _configurationManager;
private readonly IProviderManager _providerManager; private readonly IProviderManager _providerManager;
@@ -35,11 +40,11 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable
private readonly ILogger<LibraryChangedNotifier> _logger; private readonly ILogger<LibraryChangedNotifier> _logger;
private readonly Lock _libraryChangedSyncLock = new(); private readonly Lock _libraryChangedSyncLock = new();
private readonly List<Folder> _foldersAddedTo = new(); private readonly Dictionary<Guid, Folder> _foldersAddedTo = [];
private readonly List<Folder> _foldersRemovedFrom = new(); private readonly Dictionary<Guid, Folder> _foldersRemovedFrom = [];
private readonly List<BaseItem> _itemsAdded = new(); private readonly Dictionary<Guid, BaseItem> _itemsAdded = [];
private readonly List<BaseItem> _itemsRemoved = new(); private readonly Dictionary<Guid, BaseItem> _itemsRemoved = [];
private readonly List<BaseItem> _itemsUpdated = new(); private readonly Dictionary<Guid, BaseItem> _itemsUpdated = [];
private readonly ConcurrentDictionary<Guid, DateTime> _lastProgressMessageTimes = new(); private readonly ConcurrentDictionary<Guid, DateTime> _lastProgressMessageTimes = new();
private Timer? _libraryUpdateTimer; private Timer? _libraryUpdateTimer;
@@ -173,7 +178,7 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable
private void OnLibraryItemRemoved(object? sender, ItemChangeEventArgs e) private void OnLibraryItemRemoved(object? sender, ItemChangeEventArgs e)
=> OnLibraryChange(e.Item, e.Parent, _itemsRemoved, _foldersRemovedFrom); => 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)) if (!FilterItem(item))
{ {
@@ -182,23 +187,28 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable
lock (_libraryChangedSyncLock) 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) if (_libraryUpdateTimer is null)
{ {
var updateDuration = TimeSpan.FromSeconds(_configurationManager.Configuration.LibraryUpdateDuration);
_libraryUpdateTimer = new Timer(LibraryUpdateTimerCallback, null, updateDuration, Timeout.InfiniteTimeSpan); _libraryUpdateTimer = new Timer(LibraryUpdateTimerCallback, null, updateDuration, Timeout.InfiniteTimeSpan);
} }
else
{
_libraryUpdateTimer.Change(updateDuration, Timeout.InfiniteTimeSpan);
}
if (foldersList is not null && parent is Folder folder) 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; List<BaseItem> itemsRemoved;
lock (_libraryChangedSyncLock) lock (_libraryChangedSyncLock)
{ {
// Remove dupes in case some were saved multiple times foldersAddedTo = _foldersAddedTo.Values.ToList();
foldersAddedTo = _foldersAddedTo foldersRemovedFrom = _foldersRemovedFrom.Values.ToList();
.DistinctBy(x => x.Id)
.ToList();
foldersRemovedFrom = _foldersRemovedFrom
.DistinctBy(x => x.Id)
.ToList();
itemsUpdated = _itemsUpdated itemsUpdated = _itemsUpdated
.Where(i => !_itemsAdded.Contains(i)) .Where(e => !_itemsAdded.ContainsKey(e.Key))
.DistinctBy(x => x.Id) .Select(e => e.Value)
.ToList(); .ToList();
itemsAdded = _itemsAdded.ToList(); itemsAdded = _itemsAdded.Values.ToList();
itemsRemoved = _itemsRemoved.ToList(); itemsRemoved = _itemsRemoved.Values.ToList();
if (_libraryUpdateTimer is not null) if (_libraryUpdateTimer is not null)
{ {
@@ -241,6 +245,15 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable
_foldersRemovedFrom.Clear(); _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); await SendChangeNotifications(itemsAdded, itemsUpdated, itemsRemoved, foldersAddedTo, foldersRemovedFrom, CancellationToken.None).ConfigureAwait(false);
} }
@@ -18,15 +18,17 @@ namespace Emby.Server.Implementations.EntryPoints
public sealed class UserDataChangeNotifier : IHostedService, IDisposable public sealed class UserDataChangeNotifier : IHostedService, IDisposable
{ {
private const int UpdateDuration = 500; private const int UpdateDuration = 500;
internal const int MaxBatchSize = 2000;
private readonly ISessionManager _sessionManager; private readonly ISessionManager _sessionManager;
private readonly IUserDataManager _userDataManager; private readonly IUserDataManager _userDataManager;
private readonly IUserManager _userManager; 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 readonly Lock _syncLock = new();
private Timer? _updateTimer; private Timer? _updateTimer;
private int _changedItemCount;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="UserDataChangeNotifier"/> class. /// Initializes a new instance of the <see cref="UserDataChangeNotifier"/> class.
@@ -69,50 +71,64 @@ namespace Emby.Server.Implementations.EntryPoints
lock (_syncLock) lock (_syncLock)
{ {
if (_updateTimer is null) // 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
_updateTimer = new Timer( // holding every item it touched alive until the stream stops.
UpdateTimerCallback, _updateTimer ??= new Timer(
null, UpdateTimerCallback,
UpdateDuration, null,
Timeout.Infinite); UpdateDuration,
} Timeout.Infinite);
else
{
_updateTimer.Change(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; _changedItems[e.UserId] = keys;
} }
keys.Add(e.Item);
var baseItem = e.Item; var baseItem = e.Item;
// Go up one level for indicators // Go up one level for indicators
if (baseItem is not null) if (baseItem is not null)
{ {
Track(keys, baseItem);
var parent = baseItem.GetOwner() ?? baseItem.GetParent(); var parent = baseItem.GetOwner() ?? baseItem.GetParent();
if (parent is not null) 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) private async void UpdateTimerCallback(object? state)
{ {
List<KeyValuePair<Guid, List<BaseItem>>> changes; List<KeyValuePair<Guid, Dictionary<Guid, BaseItem>>> changes;
lock (_syncLock) lock (_syncLock)
{ {
// Remove dupes in case some were saved multiple times
changes = _changedItems.ToList(); changes = _changedItems.ToList();
_changedItems.Clear(); _changedItems.Clear();
_changedItemCount = 0;
if (_updateTimer is not null) 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) foreach (var (userId, changedItems) in changes)
{ {
await _sessionManager.SendMessageToUserSessions( await _sessionManager.SendMessageToUserSessions(
[userId], [userId],
SessionMessageType.UserDataChanged, SessionMessageType.UserDataChanged,
() => GetUserDataChangeInfo(userId, changedItems), () => GetUserDataChangeInfo(userId, changedItems.Values),
default).ConfigureAwait(false); default).ConfigureAwait(false);
} }
} }
private UserDataChangeInfo GetUserDataChangeInfo(Guid userId, List<BaseItem> changedItems) private UserDataChangeInfo GetUserDataChangeInfo(Guid userId, IEnumerable<BaseItem> changedItems)
{ {
var user = _userManager.GetUserById(userId) var user = _userManager.GetUserById(userId)
?? throw new ArgumentException("Invalid user ID", nameof(userId)); ?? throw new ArgumentException("Invalid user ID", nameof(userId));
@@ -140,7 +161,6 @@ namespace Emby.Server.Implementations.EntryPoints
{ {
UserId = userId, UserId = userId,
UserDataList = changedItems UserDataList = changedItems
.DistinctBy(x => x.Id)
.Select(i => .Select(i =>
{ {
var dto = _userDataManager.GetUserDataDto(i, user); var dto = _userDataManager.GetUserDataDto(i, user);
@@ -1,5 +1,6 @@
using System; using System;
using System.Buffers; using System.Buffers;
using System.Globalization;
using System.IO.Pipelines; using System.IO.Pipelines;
using System.Net; using System.Net;
using System.Net.WebSockets; using System.Net.WebSockets;
@@ -69,6 +70,11 @@ namespace Emby.Server.Implementations.HttpServer
/// <inheritdoc /> /// <inheritdoc />
public IPAddress? RemoteEndPoint { get; } public IPAddress? RemoteEndPoint { get; }
/// <summary>
/// Gets or initializes the UI culture captured from the upgrade request.
/// </summary>
public CultureInfo? RequestUICulture { get; init; }
/// <inheritdoc /> /// <inheritdoc />
public Func<WebSocketMessageInfo, Task>? OnReceive { get; set; } public Func<WebSocketMessageInfo, Task>? OnReceive { get; set; }
@@ -81,6 +87,17 @@ namespace Emby.Server.Implementations.HttpServer
/// <inheritdoc /> /// <inheritdoc />
public WebSocketState State => _socket.State; public WebSocketState State => _socket.State;
/// <inheritdoc />
public void ApplyRequestCulture()
{
if (RequestUICulture is null)
{
return;
}
CultureInfo.CurrentUICulture = RequestUICulture;
}
/// <inheritdoc /> /// <inheritdoc />
public async Task SendAsync(OutboundWebSocketMessage message, CancellationToken cancellationToken) 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); 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); _logger.LogWarning("WS {IP} error receiving data: {Message}", RemoteEndPoint, ex.Message);
break; break;
} }
@@ -4,6 +4,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using System.Linq; using System.Linq;
using System.Net.WebSockets; using System.Net.WebSockets;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -47,14 +48,18 @@ namespace Emby.Server.Implementations.HttpServer
_logger.LogInformation("WS {IP} request", context.Connection.RemoteIpAddress); _logger.LogInformation("WS {IP} request", context.Connection.RemoteIpAddress);
WebSocket webSocket = await context.WebSockets.AcceptWebSocketAsync().ConfigureAwait(false); WebSocket webSocket = await context.WebSockets.AcceptWebSocketAsync().ConfigureAwait(false);
var connection = new WebSocketConnection( var connection = new WebSocketConnection(
_loggerFactory.CreateLogger<WebSocketConnection>(), _loggerFactory.CreateLogger<WebSocketConnection>(),
webSocket, webSocket,
authorizationInfo, authorizationInfo,
context.GetNormalizedRemoteIP()) context.GetNormalizedRemoteIP())
{ {
OnReceive = ProcessWebSocketMessageReceived RequestUICulture = CultureInfo.CurrentUICulture
};
connection.OnReceive = result =>
{
connection.ApplyRequestCulture();
return ProcessWebSocketMessageReceived(result);
}; };
await using (connection.ConfigureAwait(false)) await using (connection.ConfigureAwait(false))
{ {
@@ -8,6 +8,7 @@ using Emby.Server.Implementations.Library;
using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.IO; using MediaBrowser.Model.IO;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -21,6 +22,8 @@ namespace Emby.Server.Implementations.IO
private readonly ILibraryManager _libraryManager; private readonly ILibraryManager _libraryManager;
private readonly IServerConfigurationManager _configurationManager; private readonly IServerConfigurationManager _configurationManager;
private readonly IFileSystem _fileSystem; private readonly IFileSystem _fileSystem;
private readonly IDirectoryService _directoryService;
private readonly DotIgnoreIgnoreRule _dotIgnoreIgnoreRule;
/// <summary> /// <summary>
/// The file system watchers. /// The file system watchers.
@@ -46,20 +49,27 @@ namespace Emby.Server.Implementations.IO
/// <param name="libraryManager">The library manager.</param> /// <param name="libraryManager">The library manager.</param>
/// <param name="configurationManager">The configuration manager.</param> /// <param name="configurationManager">The configuration manager.</param>
/// <param name="fileSystem">The filesystem.</param> /// <param name="fileSystem">The filesystem.</param>
/// <param name="directoryService">The directory service.</param>
/// <param name="appLifetime">The <see cref="IHostApplicationLifetime"/>.</param> /// <param name="appLifetime">The <see cref="IHostApplicationLifetime"/>.</param>
/// <param name="dotIgnoreIgnoreRule">The .ignore rule handler.</param>
public LibraryMonitor( public LibraryMonitor(
ILogger<LibraryMonitor> logger, ILogger<LibraryMonitor> logger,
ILibraryManager libraryManager, ILibraryManager libraryManager,
IServerConfigurationManager configurationManager, IServerConfigurationManager configurationManager,
IFileSystem fileSystem, IFileSystem fileSystem,
IHostApplicationLifetime appLifetime) IDirectoryService directoryService,
IHostApplicationLifetime appLifetime,
DotIgnoreIgnoreRule dotIgnoreIgnoreRule)
{ {
_libraryManager = libraryManager; _libraryManager = libraryManager;
_logger = logger; _logger = logger;
_configurationManager = configurationManager; _configurationManager = configurationManager;
_fileSystem = fileSystem; _fileSystem = fileSystem;
_directoryService = directoryService;
_dotIgnoreIgnoreRule = dotIgnoreIgnoreRule;
appLifetime.ApplicationStarted.Register(Start); appLifetime.ApplicationStarted.Register(Start);
appLifetime.ApplicationStopping.Register(Stop);
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -353,11 +363,13 @@ namespace Emby.Server.Implementations.IO
} }
var fileInfo = _fileSystem.GetFileSystemInfo(path); var fileInfo = _fileSystem.GetFileSystemInfo(path);
if (DotIgnoreIgnoreRule.IsIgnored(fileInfo, null)) if (_dotIgnoreIgnoreRule.ShouldIgnore(fileInfo, null))
{ {
return; return;
} }
_directoryService.Invalidate(path);
// Ignore certain files, If the parent of an ignored path has a change event, ignore that too // Ignore certain files, If the parent of an ignored path has a change event, ignore that too
foreach (var i in _tempIgnoredPaths.Keys) foreach (var i in _tempIgnoredPaths.Keys)
{ {
@@ -586,6 +586,12 @@ namespace Emby.Server.Implementations.IO
/// <inheritdoc /> /// <inheritdoc />
public virtual IEnumerable<FileSystemMetadata> GetFiles(string path, string searchPattern, IReadOnlyList<string>? extensions, bool enableCaseSensitiveExtensions, bool recursive = false) 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); var enumerationOptions = GetEnumerationOptions(recursive);
// On linux and macOS the search pattern is case-sensitive // 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) 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>(); return Enumerable.Empty<string>();
} }
} }
@@ -5,6 +5,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using Jellyfin.Api.Extensions;
using Jellyfin.Data.Enums; using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Enums; using Jellyfin.Database.Implementations.Enums;
using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Configuration;
@@ -14,7 +15,6 @@ using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities; using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO; using MediaBrowser.Model.IO;
using MediaBrowser.Model.Querying;
namespace Emby.Server.Implementations.Images namespace Emby.Server.Implementations.Images
{ {
@@ -28,51 +28,23 @@ namespace Emby.Server.Implementations.Images
{ {
var view = (CollectionFolder)item; var view = (CollectionFolder)item;
var viewType = view.CollectionType; var viewType = view.CollectionType;
var includeItemTypes = DtoExtensions.GetBaseItemKindsForCollectionType(viewType);
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 recursive = viewType != CollectionType.playlists; 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 return view.GetItemList(new InternalItemsQuery
{ {
CollapseBoxSetItems = false, CollapseBoxSetItems = false,
Recursive = recursive, Recursive = recursive,
DtoOptions = new DtoOptions(false), DtoOptions = new DtoOptions(false),
ImageTypes = new[] { ImageType.Primary }, ImageTypes = [ImageType.Primary],
Limit = 8, Limit = 8,
OrderBy = new[] OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)],
{
(ItemSortBy.Random, SortOrder.Ascending)
},
IncludeItemTypes = includeItemTypes IncludeItemTypes = includeItemTypes
}); });
} }
@@ -1,6 +1,8 @@
using System; using System;
using System.Collections.Generic;
using System.IO; using System.IO;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using BitFaster.Caching.Lru;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.IO; using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Resolvers; using MediaBrowser.Controller.Resolvers;
@@ -15,22 +17,36 @@ public class DotIgnoreIgnoreRule : IResolverIgnoreRule
{ {
private static readonly bool IsWindows = OperatingSystem.IsWindows(); private static readonly bool IsWindows = OperatingSystem.IsWindows();
private static FileInfo? FindIgnoreFile(DirectoryInfo directory) private readonly FastConcurrentLru<string, IgnoreFileCacheEntry> _directoryCache;
{ private readonly FastConcurrentLru<string, ParsedIgnoreCacheEntry> _rulesCache;
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);
}
}
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 /> /// <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> /// <summary>
/// Checks whether or not the file is ignored. /// 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="fileInfo">The file information.</param>
/// <param name="parent">The parent BaseItem.</param> /// <param name="parent">The parent BaseItem.</param>
/// <returns>True if the file should be ignored.</returns> /// <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 var searchDirectory = fileInfo.IsDirectory
? new DirectoryInfo(fileInfo.FullName) ? fileInfo.FullName
: new DirectoryInfo(Path.GetDirectoryName(fileInfo.FullName) ?? string.Empty); : Path.GetDirectoryName(fileInfo.FullName);
if (string.IsNullOrEmpty(searchDirectory.FullName)) if (string.IsNullOrEmpty(searchDirectory))
{ {
return false; return false;
} }
var ignoreFile = FindIgnoreFile(searchDirectory); var ignoreFile = FindIgnoreFileCached(searchDirectory);
if (ignoreFile is null) if (ignoreFile is null)
{ {
return false; return false;
} }
// Fast path in case the ignore files isn't a symlink and is empty var parsedEntry = GetParsedRules(ignoreFile);
if (ignoreFile.LinkTarget is null && ignoreFile.Length == 0) 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; return true;
} }
var content = GetFileContent(ignoreFile); return parsedEntry.Rules.IsIgnored(GetPathToCheck(fileInfo.FullName, fileInfo.IsDirectory));
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);
} }
/// <summary> /// <summary>
@@ -117,8 +131,8 @@ public class DotIgnoreIgnoreRule : IResolverIgnoreRule
return true; return true;
} }
// Mitigate the problem of the Ignore library not handling Windows paths correctly. // Mitigate the problem of the Ignore library not handling Windows paths correctly.
// See https://github.com/jellyfin/jellyfin/issues/15484 // See https://github.com/jellyfin/jellyfin/issues/15484
var pathToCheck = normalizePath ? path.NormalizePath('/') : path; var pathToCheck = normalizePath ? path.NormalizePath('/') : path;
// Add trailing slash for directories to match "folder/" // Add trailing slash for directories to match "folder/"
@@ -130,11 +144,196 @@ public class DotIgnoreIgnoreRule : IResolverIgnoreRule
return ignore.IsIgnored(pathToCheck); return ignore.IsIgnored(pathToCheck);
} }
private static string GetFileContent(FileInfo ignoreFile) private FileInfo? FindIgnoreFileCached(string directory)
{ {
ignoreFile = FileSystemHelper.ResolveLinkTarget(ignoreFile, returnFinalTarget: true) ?? ignoreFile; // Check if we have a cached result for this directory
return ignoreFile.Exists if (_directoryCache.TryGet(directory, out var cached))
? File.ReadAllText(ignoreFile.FullName) {
: string.Empty; 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; }
} }
} }
@@ -1,6 +1,5 @@
using System; using System;
using System.IO; using System.IO;
using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using MediaBrowser.Controller.Chapters; using MediaBrowser.Controller.Chapters;
@@ -52,26 +51,33 @@ public class ExternalDataManager : IExternalDataManager
/// <inheritdoc/> /// <inheritdoc/>
public async Task DeleteExternalItemDataAsync(BaseItem item, CancellationToken cancellationToken) public async Task DeleteExternalItemDataAsync(BaseItem item, CancellationToken cancellationToken)
{ {
var validPaths = _pathManager.GetExtractedDataPaths(item).Where(Directory.Exists).ToList(); DeleteExternalItemFiles(item);
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);
}
}
}
var itemId = item.Id;
await _keyframeManager.DeleteKeyframeDataAsync(itemId, cancellationToken).ConfigureAwait(false); await _keyframeManager.DeleteKeyframeDataAsync(itemId, cancellationToken).ConfigureAwait(false);
await _mediaSegmentManager.DeleteSegmentsAsync(itemId, cancellationToken).ConfigureAwait(false); await _mediaSegmentManager.DeleteSegmentsAsync(itemId, cancellationToken).ConfigureAwait(false);
await _trickplayManager.DeleteTrickplayDataAsync(itemId, cancellationToken).ConfigureAwait(false); await _trickplayManager.DeleteTrickplayDataAsync(itemId, cancellationToken).ConfigureAwait(false);
await _chapterManager.DeleteChapterDataAsync(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);
}
}
}
} }
@@ -31,6 +31,20 @@ namespace Emby.Server.Implementations.Library
"**/*.sample.?????", "**/*.sample.?????",
"**/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 // Directories
"**/metadata/**", "**/metadata/**",
"**/metadata", "**/metadata",
File diff suppressed because it is too large Load Diff
@@ -23,6 +23,8 @@ using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller; using MediaBrowser.Controller;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.MediaEncoding;
@@ -126,6 +128,11 @@ namespace Emby.Server.Implementations.Library
return true; return true;
} }
if (stream.IsVobSubSubtitleStream)
{
return true;
}
return false; 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) public async Task<IReadOnlyList<MediaSourceInfo>> GetPlaybackMediaSources(BaseItem item, User user, bool allowMediaProbe, bool enablePathSubstitution, CancellationToken cancellationToken)
{ {
var mediaSources = GetStaticMediaSources(item, enablePathSubstitution, user); 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 file is strm or main media stream is missing, force a metadata refresh with remote probing
if (allowMediaProbe && mediaSources[0].Type != MediaSourceType.Placeholder if (allowMediaProbe && mediaSources[0].Type != MediaSourceType.Placeholder
@@ -186,6 +194,7 @@ namespace Emby.Server.Implementations.Library
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);
mediaSources = GetStaticMediaSources(item, enablePathSubstitution, user); mediaSources = GetStaticMediaSources(item, enablePathSubstitution, user);
ResolveSymlinkPaths(mediaSources, enablePathSubstitution);
} }
var dynamicMediaSources = await GetDynamicMediaSources(item, cancellationToken).ConfigureAwait(false); var dynamicMediaSources = await GetDynamicMediaSources(item, cancellationToken).ConfigureAwait(false);
@@ -220,7 +229,11 @@ namespace Emby.Server.Implementations.Library
list.Add(source); 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 />> /// <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) private static void SetKeyProperties(IMediaSourceProvider provider, MediaSourceInfo mediaSource)
{ {
var prefix = provider.GetType().FullName.GetMD5().ToString("N", CultureInfo.InvariantCulture) + LiveStreamIdDelimiter; 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) 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) foreach (var source in sources)
{ {
SetDefaultAudioAndSubtitleStreamIndices(item, source, user); SetDefaultAudioAndSubtitleStreamIndices(item, source, user);
@@ -369,6 +410,59 @@ namespace Emby.Server.Implementations.Library
source.SupportsDirectStream = user.HasPermission(PermissionKind.EnablePlaybackRemuxing); 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; return sources;
@@ -423,7 +517,7 @@ namespace Emby.Server.Implementations.Library
MediaStreamSelector.SetSubtitleStreamScores(source.MediaStreams, preferredSubs, user.SubtitleMode, audioLanguage); 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) 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); source.DefaultAudioStreamIndex = MediaStreamSelector.GetDefaultAudioStreamIndex(source.MediaStreams, preferredAudio, user.PlayDefaultAudioTrack);
if (user.PlayDefaultAudioTrack) if (user.PlayDefaultAudioTrack)
@@ -462,7 +587,9 @@ namespace Emby.Server.Implementations.Library
var allowRememberingSelection = item is null || item.EnableRememberingTrackSelections; 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); SetDefaultSubtitleStreamIndex(source, userData, user, allowRememberingSelection);
} }
else if (mediaType == MediaType.Audio) 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 => // The source belonging to the queried item sorts first so it stays the default that gets played.
{ var preferredId = preferredItemId.IsEmpty()
if (i.VideoType.HasValue && i.VideoType.Value == VideoType.VideoFile) ? 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; return 1;
}).ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0) })
.ThenByDescending(i => .ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0)
{ .ThenByDescending(i =>
var stream = i.VideoStream; {
var stream = i.VideoStream;
return stream?.Width ?? 0; return stream?.Width ?? 0;
}) })
.Where(i => i.Type != MediaSourceType.Placeholder); .Where(i => i.Type != MediaSourceType.Placeholder);
} }
public async Task<Tuple<LiveStreamResponse, IDirectStreamProvider>> OpenLiveStreamInternal(LiveStreamRequest request, CancellationToken cancellationToken) public async Task<Tuple<LiveStreamResponse, IDirectStreamProvider>> OpenLiveStreamInternal(LiveStreamRequest request, CancellationToken cancellationToken)
@@ -29,28 +29,79 @@ namespace Emby.Server.Implementations.Library
throw new ArgumentException("String can't be empty.", nameof(attribute)); throw new ArgumentException("String can't be empty.", nameof(attribute));
} }
var attributeIndex = str.IndexOf(attribute, StringComparison.OrdinalIgnoreCase); // Allow tmdb as an alias for tmdbid, tvdb for tvdbid, etc.
// The code below only supports aliases for attributes in the form of "<alias>id".
// Must be at least 3 characters after the attribute =, ], any character, ReadOnlySpan<char> shortAttr = attribute switch
// 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)
{ {
var attributeEnd = attributeIndex + attribute.Length; _ when attribute.Equals("tmdbid", StringComparison.OrdinalIgnoreCase) => "tmdb",
if (attributeIndex > 0 _ when attribute.Equals("tvdbid", StringComparison.OrdinalIgnoreCase) => "tvdb",
&& str[attributeIndex - 1] == '[' _ when attribute.Equals("imdbid", StringComparison.OrdinalIgnoreCase) => "imdb",
&& (str[attributeEnd] == '=' || str[attributeEnd] == '-')) _ => 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(']'); // If we are using an alias it should be shorter (and a prefix), so let's search for that.
// Must be at least 1 character before the closing bracket. attributeIndex = subStr.IndexOf(shortAttr, StringComparison.OrdinalIgnoreCase);
if (closingIndex > 1) attributeEnd = attributeIndex + shortAttr.Length;
{ }
return str[(attributeEnd + 1)..(attributeEnd + closingIndex)].Trim().ToString(); else
} {
attributeIndex = subStr.IndexOf(attribute, StringComparison.OrdinalIgnoreCase);
attributeEnd = attributeIndex + attribute.Length;
} }
str = str[attributeEnd..]; // The next iteration should start at the end of the attribute we just found.
attributeIndex = str.IndexOf(attribute, StringComparison.OrdinalIgnoreCase); // 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 // for imdbid we also accept pattern matching
@@ -2,10 +2,12 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.IO; using System.IO;
using Jellyfin.Extensions;
using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.IO; using MediaBrowser.Controller.IO;
using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.Library; namespace Emby.Server.Implementations.Library;
@@ -14,18 +16,22 @@ namespace Emby.Server.Implementations.Library;
/// </summary> /// </summary>
public class PathManager : IPathManager public class PathManager : IPathManager
{ {
private readonly ILogger<PathManager> _logger;
private readonly IServerConfigurationManager _config; private readonly IServerConfigurationManager _config;
private readonly IApplicationPaths _appPaths; private readonly IApplicationPaths _appPaths;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="PathManager"/> class. /// Initializes a new instance of the <see cref="PathManager"/> class.
/// </summary> /// </summary>
/// <param name="logger">The logger.</param>
/// <param name="config">The server configuration manager.</param> /// <param name="config">The server configuration manager.</param>
/// <param name="appPaths">The application paths.</param> /// <param name="appPaths">The application paths.</param>
public PathManager( public PathManager(
ILogger<PathManager> logger,
IServerConfigurationManager config, IServerConfigurationManager config,
IApplicationPaths appPaths) IApplicationPaths appPaths)
{ {
_logger = logger;
_config = config; _config = config;
_appPaths = appPaths; _appPaths = appPaths;
} }
@@ -35,31 +41,55 @@ public class PathManager : IPathManager
private string AttachmentCachePath => Path.Combine(_appPaths.DataPath, "attachments"); private string AttachmentCachePath => Path.Combine(_appPaths.DataPath, "attachments");
/// <inheritdoc /> /// <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 /> /// <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); return Path.Join(AttachmentCachePath, id[..2], id);
} }
/// <inheritdoc /> /// <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); return Path.Join(SubtitleCachePath, id[..2], id);
} }
/// <inheritdoc /> /// <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 /> /// <inheritdoc />
@@ -90,12 +120,27 @@ public class PathManager : IPathManager
public IReadOnlyList<string> GetExtractedDataPaths(BaseItem item) public IReadOnlyList<string> GetExtractedDataPaths(BaseItem item)
{ {
var mediaSourceId = item.Id.ToString("N", CultureInfo.InvariantCulture); var mediaSourceId = item.Id.ToString("N", CultureInfo.InvariantCulture);
return [ List<string> paths = [];
GetAttachmentFolderPath(mediaSourceId), var attachmentFolder = GetAttachmentFolderPath(mediaSourceId);
GetSubtitleFolderPath(mediaSourceId), if (attachmentFolder is not null)
GetTrickplayDirectory(item, false), {
GetTrickplayDirectory(item, true), paths.Add(attachmentFolder);
GetChapterImageFolderPath(item) }
];
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;
} }
} }
@@ -1,16 +1,14 @@
#nullable disable
#pragma warning disable CS1591 #pragma warning disable CS1591
using System; using System;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using Emby.Naming.Book;
using Jellyfin.Data.Enums; using Jellyfin.Data.Enums;
using Jellyfin.Extensions; using Jellyfin.Extensions;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Resolvers; using MediaBrowser.Controller.Resolvers;
using MediaBrowser.Model.Entities;
namespace Emby.Server.Implementations.Library.Resolvers.Books 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" }; 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(); var collectionType = args.GetCollectionType();
@@ -35,20 +33,26 @@ namespace Emby.Server.Implementations.Library.Resolvers.Books
var extension = Path.GetExtension(args.Path.AsSpan()); var extension = Path.GetExtension(args.Path.AsSpan());
if (_validExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase)) if (!_validExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
{ {
// It's a book return null;
return new Book
{
Path = args.Path,
IsInMixedFolder = true
};
} }
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 => var bookFiles = args.FileSystemChildren.Where(f =>
{ {
@@ -59,15 +63,23 @@ namespace Emby.Server.Implementations.Library.Resolvers.Books
StringComparison.OrdinalIgnoreCase); StringComparison.OrdinalIgnoreCase);
}).ToList(); }).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) if (bookFiles.Count != 1)
{ {
return null; return null;
} }
var result = BookFileNameParser.Parse(Path.GetFileName(args.Path));
return new Book 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,
}; };
} }
} }
@@ -32,8 +32,8 @@ namespace Emby.Server.Implementations.Library.Resolvers
: base(logger, namingOptions, directoryService) : base(logger, namingOptions, directoryService)
{ {
_namingOptions = namingOptions; _namingOptions = namingOptions;
_trailerResolvers = new IItemResolver[] { new GenericVideoResolver<Trailer>(logger, namingOptions, directoryService) }; _trailerResolvers = [new GenericVideoResolver<Trailer>(logger, namingOptions, directoryService, parseName: true)];
_videoResolvers = new IItemResolver[] { this }; _videoResolvers = [this];
} }
protected override Video Resolve(ItemResolveArgs args) protected override Video Resolve(ItemResolveArgs args)
@@ -54,12 +54,13 @@ namespace Emby.Server.Implementations.Library.Resolvers
_ => _videoResolvers _ => _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); var extraResult = GetExtraInfo(path, _namingOptions, libraryRoot);
if (extraResult.ExtraType is null) if (extraResult.ExtraType is null || extraResult.Rule is null)
{ {
extraType = null; extraType = null;
extraRule = null;
return false; return false;
} }
@@ -88,6 +89,7 @@ namespace Emby.Server.Implementations.Library.Resolvers
} }
extraType = extraResult.ExtraType; extraType = extraResult.ExtraType;
extraRule = extraResult.Rule;
return isValid; return isValid;
} }
@@ -2,6 +2,7 @@
using Emby.Naming.Common; using Emby.Naming.Common;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Providers;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -14,15 +15,25 @@ namespace Emby.Server.Implementations.Library.Resolvers
public class GenericVideoResolver<T> : BaseVideoResolver<T> public class GenericVideoResolver<T> : BaseVideoResolver<T>
where T : Video, new() where T : Video, new()
{ {
private readonly bool _parseName;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="GenericVideoResolver{T}"/> class. /// Initializes a new instance of the <see cref="GenericVideoResolver{T}"/> class.
/// </summary> /// </summary>
/// <param name="logger">The logger.</param> /// <param name="logger">The logger.</param>
/// <param name="namingOptions">The naming options.</param> /// <param name="namingOptions">The naming options.</param>
/// <param name="directoryService">The directory service.</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) : base(logger, namingOptions, directoryService)
{ {
_parseName = parseName;
}
/// <inheritdoc />
protected override T Resolve(ItemResolveArgs args)
{
return ResolveVideo<T>(args, _parseName);
} }
} }
} }
@@ -28,15 +28,16 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
public partial class MovieResolver : BaseVideoResolver<Video>, IMultiItemResolver public partial class MovieResolver : BaseVideoResolver<Video>, IMultiItemResolver
{ {
private readonly IImageProcessor _imageProcessor; private readonly IImageProcessor _imageProcessor;
private readonly VideoListResolver _videoListResolver;
private static readonly CollectionType[] _validCollectionTypes = new[] private static readonly CollectionType[] _validCollectionTypes =
{ [
CollectionType.movies, CollectionType.movies,
CollectionType.homevideos, CollectionType.homevideos,
CollectionType.musicvideos, CollectionType.musicvideos,
CollectionType.tvshows, CollectionType.tvshows,
CollectionType.photos CollectionType.photos
}; ];
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="MovieResolver"/> class. /// 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="logger">The logger.</param>
/// <param name="namingOptions">The naming options.</param> /// <param name="namingOptions">The naming options.</param>
/// <param name="directoryService">The directory service.</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) : base(logger, namingOptions, directoryService)
{ {
_imageProcessor = imageProcessor; _imageProcessor = imageProcessor;
_videoListResolver = videoListResolver;
} }
/// <summary> /// <summary>
@@ -228,7 +231,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
if (collectionType == CollectionType.tvshows) if (collectionType == CollectionType.tvshows)
{ {
return ResolveVideos<Episode>(parent, files, false, collectionType, true); return ResolveVideos<Episode>(parent, files, true, collectionType, true);
} }
return null; return null;
@@ -274,7 +277,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
.Where(f => f is not null) .Where(f => f is not null)
.ToList(); .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 var result = new MultiItemResolverResult
{ {
@@ -302,7 +305,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
ProductionYear = video.Year, ProductionYear = video.Year,
Name = parseName ? video.Name : firstVideo.Name, Name = parseName ? video.Name : firstVideo.Name,
AdditionalParts = additionalParts, AdditionalParts = additionalParts,
LocalAlternateVersions = video.AlternateVersions.Select(i => i.Path).ToArray() LocalAlternateVersions = video.AlternateVersions.Select(av => av.Files[0].Path).ToArray()
}; };
SetVideoType(videoItem, firstVideo); SetVideoType(videoItem, firstVideo);
@@ -331,9 +334,13 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
for (var j = 0; j < current.AlternateVersions.Count; j++) 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) // 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 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 item.TrySetProviderId(MetadataProvider.Tmdb, GetIdFromNameOrPath(justName, fileName, "tmdbid"));
if (string.IsNullOrEmpty(tmdbid) && !item.IsInMixedFolder) 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)) if (!string.IsNullOrEmpty(item.Path))
{ {
@@ -1,8 +1,10 @@
#nullable disable #nullable disable
using System; using System;
using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using Emby.Server.Implementations.Playlists;
using Jellyfin.Data.Enums; using Jellyfin.Data.Enums;
using Jellyfin.Extensions; using Jellyfin.Extensions;
using MediaBrowser.Controller.Library; 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 // 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))) if (filePaths.Any(f => f.EndsWith(PlaylistXmlSaver.DefaultPlaylistFilename, StringComparison.OrdinalIgnoreCase)))
{ {
return new Playlist return new Playlist
@@ -1,8 +1,10 @@
#nullable disable #nullable disable
using System; using System;
using System.IO;
using System.Linq; using System.Linq;
using Emby.Naming.Common; using Emby.Naming.Common;
using Emby.Server.Implementations.Library;
using Jellyfin.Data.Enums; using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
@@ -81,10 +83,34 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
episode.ParentIndexNumber = 1; episode.ParentIndexNumber = 1;
} }
SetProviderIdFromPath(episode, args.Path);
return episode; return episode;
} }
return null; 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);
}
} }
} }
@@ -1,10 +1,15 @@
#nullable disable #nullable disable
using System;
using System.Globalization; using System.Globalization;
using System.IO;
using System.Linq;
using Emby.Naming.Common; using Emby.Naming.Common;
using Emby.Naming.TV; using Emby.Naming.TV;
using Emby.Server.Implementations.Library;
using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Globalization; using MediaBrowser.Model.Globalization;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -77,6 +82,14 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
return null; 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)) if (season.IndexNumber.HasValue && string.IsNullOrEmpty(season.Name))
@@ -86,15 +99,47 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
args.LibraryOptions.SeasonZeroDisplayName : args.LibraryOptions.SeasonZeroDisplayName :
string.Format( string.Format(
CultureInfo.InvariantCulture, CultureInfo.InvariantCulture,
_localization.GetLocalizedString("NameSeasonNumber"), _localization.GetServerLocalizedString("NameSeasonNumber"),
seasonNumber, seasonNumber,
args.LibraryOptions.PreferredMetadataLanguage); args.LibraryOptions.PreferredMetadataLanguage);
} }
SetProviderIdFromPath(season, path);
return season; return season;
} }
return null; 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);
}
} }
} }
@@ -31,7 +31,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
/// </summary> /// </summary>
/// <param name="logger">The logger.</param> /// <param name="logger">The logger.</param>
/// <param name="namingOptions">The naming options.</param> /// <param name="namingOptions">The naming options.</param>
public SeriesResolver(ILogger<SeriesResolver> logger, NamingOptions namingOptions) public SeriesResolver(ILogger<SeriesResolver> logger, NamingOptions namingOptions)
{ {
_logger = logger; _logger = logger;
_namingOptions = namingOptions; _namingOptions = namingOptions;
@@ -57,6 +57,11 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
return null; return null;
} }
if (args.Parent is not null && args.Parent.IsRoot)
{
return null;
}
var seriesInfo = Naming.TV.SeriesResolver.Resolve(_namingOptions, args.Path); var seriesInfo = Naming.TV.SeriesResolver.Resolve(_namingOptions, args.Path);
var collectionType = args.GetCollectionType(); var collectionType = args.GetCollectionType();
@@ -69,7 +74,8 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
return new Series return new Series
{ {
Path = args.Path, Path = args.Path,
Name = seriesInfo.Name Name = seriesInfo.Name,
ProductionYear = seriesInfo.Year
}; };
} }
} }
@@ -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);
}
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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();
}
}
}
@@ -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));
}
}
@@ -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));
}
}
@@ -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;
}
}
}
}
}
@@ -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));
}
}
@@ -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));
}
}
@@ -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));
}
}
@@ -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;
}
}
@@ -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));
}
}
@@ -80,7 +80,7 @@ public class SplashscreenPostScanTask : ILibraryPostScanTask
ImageTypes = [imageType], ImageTypes = [imageType],
Limit = 30, Limit = 30,
// TODO max parental rating configurable // TODO max parental rating configurable
MaxParentalRating = new(10, null), MaxParentalRating = new(13, null),
OrderBy = OrderBy =
[ [
(ItemSortBy.Random, SortOrder.Ascending) (ItemSortBy.Random, SortOrder.Ascending)
@@ -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; var cacheKey = GetCacheKey(user.InternalId, item.Id);
} if (_cache.TryGet(cacheKey, out var cachedData))
data = GetUserDataInternal(user.Id, itemId, keys);
if (data is null)
{
return new UserItemData()
{ {
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); if (itemsNeedingQuery.Count == 0)
}
private UserItemData? GetUserDataInternal(Guid userId, Guid itemId, List<string> keys)
{
if (keys.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(); 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")); UserItemData userData;
if (directDataReference is not null) 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> /// <summary>
@@ -238,12 +352,41 @@ namespace Emby.Server.Implementations.Library
/// <inheritdoc /> /// <inheritdoc />
public UserItemData? GetUserData(User user, BaseItem item) 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], 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 /> /// <inheritdoc />
public UserItemDataDto? GetUserDataDto(BaseItem item, User user) public UserItemDataDto? GetUserDataDto(BaseItem item, User user)
=> GetUserDataDto(item, null, user, new DtoOptions()); => GetUserDataDto(item, null, user, new DtoOptions());
@@ -260,6 +403,10 @@ namespace Emby.Server.Implementations.Library
var dto = GetUserItemDataDto(userData, item.Id); var dto = GetUserItemDataDto(userData, item.Id);
item.FillUserDataDtoValues(dto, userData, itemDto, user, options); 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; return dto;
} }
@@ -364,5 +511,41 @@ namespace Emby.Server.Implementations.Library
return playedToCompletion; 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();
}
} }
} }
@@ -59,8 +59,8 @@ namespace Emby.Server.Implementations.Library
var collectionFolder = folder as ICollectionFolder; var collectionFolder = folder as ICollectionFolder;
var folderViewType = collectionFolder?.CollectionType; var folderViewType = collectionFolder?.CollectionType;
// Playlist library requires special handling because the folder only references user playlists // Playlist and BoxSet libraries require special handling because the folder only references linked items
if (folderViewType == CollectionType.playlists) if (folderViewType == CollectionType.playlists || folderViewType == CollectionType.boxsets)
{ {
var items = folder.GetItemList(new InternalItemsQuery(user) var items = folder.GetItemList(new InternalItemsQuery(user)
{ {
@@ -112,7 +112,7 @@ namespace Emby.Server.Implementations.Library
if (_config.Configuration.EnableFolderView) if (_config.Configuration.EnableFolderView)
{ {
var name = _localizationManager.GetLocalizedString("Folders"); var name = _localizationManager.GetServerLocalizedString("Folders");
list.Add(_libraryManager.GetNamedView(name, CollectionType.folders, string.Empty)); 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(); 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); var orders = user.GetPreferenceValues<Guid>(PreferenceKind.OrderedViews);
return list return list
@@ -168,7 +168,7 @@ namespace Emby.Server.Implementations.Library
public UserView GetUserSubView(Guid parentId, CollectionType? type, string localizationKey, string sortName) 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); return GetUserSubViewWithName(name, parentId, type, sortName);
} }
@@ -191,7 +191,7 @@ namespace Emby.Server.Implementations.Library
return GetUserView((Folder)parents[0], viewType, string.Empty); 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); return _libraryManager.GetNamedView(user, name, viewType, sortName);
} }
@@ -205,7 +205,7 @@ namespace Emby.Server.Implementations.Library
var libraryItems = GetItemsForLatestItems(request.User, request, options); var libraryItems = GetItemsForLatestItems(request.User, request, options);
var list = new List<Tuple<BaseItem, List<BaseItem>>>(); var list = new List<Tuple<BaseItem, List<BaseItem>>>();
var containerIndexMap = new Dictionary<Guid, int>();
foreach (var item in libraryItems) foreach (var item in libraryItems)
{ {
// Only grab the index container for media // Only grab the index container for media
@@ -213,20 +213,16 @@ namespace Emby.Server.Implementations.Library
if (container is null) 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 else
{ {
var current = list.FirstOrDefault(i => i.Item1 is not null && i.Item1.Id.Equals(container.Id)); containerIndexMap[container.Id] = list.Count;
list.Add(new Tuple<BaseItem, List<BaseItem>>(container, new List<BaseItem> { item }));
if (current is not null)
{
current.Item2.Add(item);
}
else
{
list.Add(new Tuple<BaseItem, List<BaseItem>>(container, new List<BaseItem> { item }));
}
} }
if (list.Count >= request.Limit) if (list.Count >= request.Limit)
@@ -255,7 +251,7 @@ namespace Emby.Server.Implementations.Library
return _channelManager.GetLatestChannelItemsInternal( return _channelManager.GetLatestChannelItemsInternal(
new InternalItemsQuery(user) new InternalItemsQuery(user)
{ {
ChannelIds = new[] { parentId }, ChannelIds = [parentId],
IsPlayed = request.IsPlayed, IsPlayed = request.IsPlayed,
StartIndex = request.StartIndex, StartIndex = request.StartIndex,
Limit = request.Limit, Limit = request.Limit,
@@ -301,11 +297,11 @@ namespace Emby.Server.Implementations.Library
{ {
if (hasCollectionType.All(i => i.CollectionType == CollectionType.movies)) if (hasCollectionType.All(i => i.CollectionType == CollectionType.movies))
{ {
includeItemTypes = new[] { BaseItemKind.Movie }; includeItemTypes = [BaseItemKind.Movie];
} }
else if (hasCollectionType.All(i => i.CollectionType == CollectionType.tvshows)) 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 var excludeItemTypes = includeItemTypes.Length == 0 && mediaTypes.Length == 0
? new[] ?
{ [
BaseItemKind.Person, BaseItemKind.Person,
BaseItemKind.Studio, BaseItemKind.Studio,
BaseItemKind.Year, BaseItemKind.Year,
BaseItemKind.MusicGenre, BaseItemKind.MusicGenre,
BaseItemKind.Genre BaseItemKind.Genre
} ]
: Array.Empty<BaseItemKind>(); : Array.Empty<BaseItemKind>();
var query = new InternalItemsQuery(user) var query = new InternalItemsQuery(user)
{ {
IncludeItemTypes = includeItemTypes, IncludeItemTypes = includeItemTypes,
OrderBy = new[] OrderBy =
{ [
(ItemSortBy.DateCreated, SortOrder.Descending), (ItemSortBy.DateCreated, SortOrder.Descending),
(ItemSortBy.SortName, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Descending),
(ItemSortBy.ProductionYear, SortOrder.Descending) (ItemSortBy.ProductionYear, SortOrder.Descending)
}, ],
IsFolder = includeItemTypes.Length == 0 ? false : null, IsFolder = includeItemTypes.Length == 0 ? false : null,
ExcludeItemTypes = excludeItemTypes, ExcludeItemTypes = excludeItemTypes,
IsVirtualItem = false, IsVirtualItem = false,
Limit = limit * 5, Limit = limit * 2,
IsPlayed = isPlayed, IsPlayed = isPlayed,
DtoOptions = options, DtoOptions = options,
MediaTypes = mediaTypes MediaTypes = mediaTypes
@@ -394,6 +390,18 @@ namespace Emby.Server.Implementations.Library
query.Limit = limit; query.Limit = limit;
return _libraryManager.GetLatestItemList(query, parents, CollectionType.music); 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); return _libraryManager.GetItemList(query, parents);
@@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
@@ -50,25 +51,55 @@ public class ArtistsValidator
public async Task Run(IProgress<double> progress, CancellationToken cancellationToken) public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
{ {
var names = _itemRepo.GetAllArtistNames(); var names = _itemRepo.GetAllArtistNames();
var existingArtistIds = _libraryManager.GetItemIds(new InternalItemsQuery
{
IncludeItemTypes = [BaseItemKind.MusicArtist]
}).ToHashSet();
var existingArtists = _libraryManager.GetArtists(names);
var numComplete = 0; var numComplete = 0;
var count = names.Count; var count = names.Count;
var refreshed = 0;
var liveIds = new HashSet<Guid>();
var unresolved = 0;
foreach (var name in names) foreach (var name in names)
{ {
try 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) catch (OperationCanceledException)
{ {
// Don't clutter the log
throw; throw;
} }
catch (Exception ex) catch (Exception ex)
{ {
unresolved++;
_logger.LogError(ex, "Error refreshing {ArtistName}", name); _logger.LogError(ex, "Error refreshing {ArtistName}", name);
} }
@@ -80,31 +111,37 @@ public class ArtistsValidator
progress.Report(percent); 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 var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery
{ {
IncludeItemTypes = [BaseItemKind.MusicArtist], IncludeItemTypes = [BaseItemKind.MusicArtist],
IsDeadArtist = true,
IsLocked = false IsLocked = false
}).Cast<MusicArtist>().ToList(); }).OfType<MusicArtist>()
.Where(item => item.IsAccessedByName && !liveIds.Contains(item.Id))
.ToList();
foreach (var item in deadEntities) 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); _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); progress.Report(100);
} }
} }
@@ -74,7 +74,7 @@ public class CollectionPostScanTask : ILibraryPostScanTask
foreach (var m in movies) 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)) if (collectionNameMoviesMap.TryGetValue(movie.CollectionName, out var movieList))
{ {
@@ -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);
}
}
@@ -1,5 +1,6 @@
using System; using System;
using System.Globalization; using System.Globalization;
using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Data.Enums; using Jellyfin.Data.Enums;
@@ -48,17 +49,40 @@ public class GenresValidator
public async Task Run(IProgress<double> progress, CancellationToken cancellationToken) public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
{ {
var names = _itemRepo.GetGenreNames(); 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 numComplete = 0;
var count = names.Count; var count = names.Count;
var refreshed = 0;
foreach (var name in names) foreach (var name in names)
{ {
try 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) catch (OperationCanceledException)
{ {
@@ -78,6 +102,8 @@ public class GenresValidator
progress.Report(percent); progress.Report(percent);
} }
_logger.LogInformation("Refreshed metadata for {RefreshedCount} new genres out of {TotalCount} total", refreshed, count);
var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery
{ {
IncludeItemTypes = [BaseItemKind.Genre, BaseItemKind.MusicGenre], IncludeItemTypes = [BaseItemKind.Genre, BaseItemKind.MusicGenre],
@@ -88,16 +114,10 @@ public class GenresValidator
foreach (var item in deadEntities) foreach (var item in deadEntities)
{ {
_logger.LogInformation("Deleting dead {ItemType} {ItemId} {ItemName}", item.GetType().Name, item.Id.ToString("N", CultureInfo.InvariantCulture), item.Name); _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); progress.Report(100);
} }
} }
@@ -1,6 +1,9 @@
using System; using System;
using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Persistence;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -45,17 +48,25 @@ public class MusicGenresValidator
public async Task Run(IProgress<double> progress, CancellationToken cancellationToken) public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
{ {
var names = _itemRepo.GetMusicGenreNames(); var names = _itemRepo.GetMusicGenreNames();
var existingMusicGenreIds = _libraryManager.GetItemIds(new InternalItemsQuery
{
IncludeItemTypes = [BaseItemKind.MusicGenre]
}).ToHashSet();
var numComplete = 0; var numComplete = 0;
var count = names.Count; var count = names.Count;
var refreshed = 0;
foreach (var name in names) foreach (var name in names)
{ {
try try
{ {
var item = _libraryManager.GetMusicGenre(name); var item = _libraryManager.GetMusicGenre(name);
if (!existingMusicGenreIds.Contains(item.Id))
await item.RefreshMetadata(cancellationToken).ConfigureAwait(false); {
await item.RefreshMetadata(cancellationToken).ConfigureAwait(false);
refreshed++;
}
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
@@ -75,6 +86,8 @@ public class MusicGenresValidator
progress.Report(percent); progress.Report(percent);
} }
_logger.LogInformation("Refreshed metadata for {RefreshedCount} new music genres out of {TotalCount} total", refreshed, count);
progress.Report(100); progress.Report(100);
} }
} }
@@ -1,12 +1,12 @@
using System; using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Data.Enums; using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.IO;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.Library.Validators; namespace Emby.Server.Implementations.Library.Validators;
@@ -17,104 +17,143 @@ namespace Emby.Server.Implementations.Library.Validators;
public class PeopleValidator public class PeopleValidator
{ {
/// <summary> /// <summary>
/// The _library manager. /// The library manager.
/// </summary> /// </summary>
private readonly ILibraryManager _libraryManager; private readonly ILibraryManager _libraryManager;
/// <summary> /// <summary>
/// The _logger. /// The logger.
/// </summary> /// </summary>
private readonly ILogger _logger; private readonly ILogger<PeopleValidator> _logger;
private readonly IFileSystem _fileSystem;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="PeopleValidator" /> class. /// Initializes a new instance of the <see cref="PeopleValidator" /> class.
/// </summary> /// </summary>
/// <param name="libraryManager">The library manager.</param> /// <param name="libraryManager">The library manager.</param>
/// <param name="logger">The logger.</param> /// <param name="logger">The logger.</param>
/// <param name="fileSystem">The file system.</param> public PeopleValidator(ILibraryManager libraryManager, ILogger<PeopleValidator> logger)
public PeopleValidator(ILibraryManager libraryManager, ILogger logger, IFileSystem fileSystem)
{ {
_libraryManager = libraryManager; _libraryManager = libraryManager;
_logger = logger; _logger = logger;
_fileSystem = fileSystem;
} }
/// <summary> /// <summary>
/// Validates the people. /// Validates the people.
/// </summary> /// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <param name="progress">The progress.</param> /// <param name="progress">The progress.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Task.</returns> /// <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 numComplete = 0;
var count = names.Count;
var refreshed = 0;
var numPeople = people.Count; foreach (var name in names)
IProgress<double> subProgress = new Progress<double>((val) => progress.Report(val / 2));
_logger.LogDebug("Will refresh {Amount} people", numPeople);
foreach (var person in people)
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
try try
{ {
var item = _libraryManager.GetPerson(person); var item = _libraryManager.GetOrCreatePerson(name);
if (item is null) var isNew = !existingPersonIds.Contains(item.Id);
var neverRefreshed = item.DateLastRefreshed == default;
if (isNew || neverRefreshed)
{ {
_logger.LogWarning("Failed to get person: {Name}", person); await item.RefreshMetadata(cancellationToken).ConfigureAwait(false);
continue; refreshed++;
} }
var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem))
{
ImageRefreshMode = MetadataRefreshMode.ValidationOnly,
MetadataRefreshMode = MetadataRefreshMode.ValidationOnly
};
await item.RefreshMetadata(options, cancellationToken).ConfigureAwait(false);
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
// Don't clutter the log
throw; throw;
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error validating IBN entry {Person}", person); _logger.LogError(ex, "Error refreshing {PersonName}", name);
} }
// Update progress
numComplete++; numComplete++;
double percent = numComplete; double percent = numComplete;
percent /= numPeople; percent /= count;
percent *= 100;
subProgress.Report(100 * percent); progress.Report(percent);
} }
var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery _logger.LogInformation(
{ "Refreshed metadata for {RefreshedCount} people out of {TotalCount} total, {NewCount} of which had no item yet",
IncludeItemTypes = [BaseItemKind.Person], refreshed,
IsDeadPerson = true, count,
IsLocked = false 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)
foreach (var item in deadEntities.Chunk(500))
{ {
_libraryManager.DeleteItemsUnsafeFast(item); _logger.LogInformation("Deleting dead {ItemType} {ItemId} {ItemName}", item.GetType().Name, item.Id.ToString("N", CultureInfo.InvariantCulture), item.Name);
subProgress.Report(100f / deadEntities.Count * (i++ * 100));
} }
_libraryManager.DeleteItemsUnsafeFast(deadEntities, deleteSourceFiles: true);
progress.Report(100); 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);
} }
} }
@@ -1,5 +1,6 @@
using System; using System;
using System.Globalization; using System.Globalization;
using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Data.Enums; using Jellyfin.Data.Enums;
@@ -49,17 +50,40 @@ public class StudiosValidator
public async Task Run(IProgress<double> progress, CancellationToken cancellationToken) public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
{ {
var names = _itemRepo.GetStudioNames(); 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 numComplete = 0;
var count = names.Count; var count = names.Count;
var refreshed = 0;
foreach (var name in names) foreach (var name in names)
{ {
try 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) catch (OperationCanceledException)
{ {
@@ -79,6 +103,8 @@ public class StudiosValidator
progress.Report(percent); progress.Report(percent);
} }
_logger.LogInformation("Refreshed metadata for {RefreshedCount} new studios out of {TotalCount} total", refreshed, count);
var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery
{ {
IncludeItemTypes = [BaseItemKind.Studio], IncludeItemTypes = [BaseItemKind.Studio],
@@ -89,16 +115,10 @@ public class StudiosValidator
foreach (var item in deadEntities) foreach (var item in deadEntities)
{ {
_logger.LogInformation("Deleting dead {ItemType} {ItemId} {ItemName}", item.GetType().Name, item.Id.ToString("N", CultureInfo.InvariantCulture), item.Name); _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); progress.Report(100);
} }
} }
@@ -1,3 +1,3 @@
{ {
"Albums": "аальбомқәа" "AppDeviceValues": "Апп: {0}, Априбор: {1}"
} }
@@ -1,11 +1,8 @@
{ {
"Artists": "Kunstenare", "Artists": "Kunstenare",
"Channels": "Kanale",
"Folders": "Lêergidse", "Folders": "Lêergidse",
"Favorites": "Gunstelinge", "Favorites": "Gunstelinge",
"HeaderFavoriteShows": "Gunsteling Vertonings", "HeaderFavoriteShows": "Gunsteling Vertonings",
"ValueSpecialEpisodeName": "Spesiale - {0}",
"HeaderAlbumArtists": "Album kunstenaars",
"Books": "Boeke", "Books": "Boeke",
"HeaderNextUp": "Volgende", "HeaderNextUp": "Volgende",
"Movies": "Flieks", "Movies": "Flieks",
@@ -13,24 +10,13 @@
"HeaderContinueWatching": "Hou aan kyk", "HeaderContinueWatching": "Hou aan kyk",
"HeaderFavoriteEpisodes": "Gunsteling Episodes", "HeaderFavoriteEpisodes": "Gunsteling Episodes",
"Photos": "Foto's", "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", "Collections": "Versamelings",
"Inherit": "Ontvang", "Inherit": "Ontvang",
"HeaderLiveTV": "Lewendige TV", "HeaderLiveTV": "Lewendige TV",
"Application": "Program",
"AppDeviceValues": "App: {0}, Toestel: {1}", "AppDeviceValues": "App: {0}, Toestel: {1}",
"VersionNumber": "Weergawe {0}", "VersionNumber": "Weergawe {0}",
"ValueHasBeenAddedToLibrary": "{0} is by jou media biblioteek bygevoeg",
"UserStoppedPlayingItemWithValues": "{0} het klaar {1} op {2} gespeel", "UserStoppedPlayingItemWithValues": "{0} het klaar {1} op {2} gespeel",
"UserStartedPlayingItemWithValues": "{0} is besig om {1} op {2} te speel", "UserStartedPlayingItemWithValues": "{0} is besig om {1} op {2} te speel",
"UserPolicyUpdatedWithName": "Gebruiker beleid is verander vir {0}",
"UserPasswordChangedWithName": "Gebruiker {0} se wagwoord is verander", "UserPasswordChangedWithName": "Gebruiker {0} se wagwoord is verander",
"UserOnlineFromDevice": "{0} is aanlyn van {1}", "UserOnlineFromDevice": "{0} is aanlyn van {1}",
"UserOfflineFromDevice": "{0} is ontkoppel van {1}", "UserOfflineFromDevice": "{0} is ontkoppel van {1}",
@@ -38,19 +24,13 @@
"UserDownloadingItemWithValues": "{0} is besig om {1} af te laai", "UserDownloadingItemWithValues": "{0} is besig om {1} af te laai",
"UserDeletedWithName": "Gebruiker {0} is verwyder", "UserDeletedWithName": "Gebruiker {0} is verwyder",
"UserCreatedWithName": "Gebruiker {0} is geskep", "UserCreatedWithName": "Gebruiker {0} is geskep",
"User": "Gebruiker",
"TvShows": "TV Programme", "TvShows": "TV Programme",
"System": "Stelsel",
"SubtitleDownloadFailureFromForItem": "Ondertitels het misluk om af te laai van {0} vir {1}", "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.", "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", "ScheduledTaskFailedWithName": "{0} het misluk",
"ProviderValue": "Voorsiener: {0}",
"PluginUpdatedWithName": "{0} was opgedateer", "PluginUpdatedWithName": "{0} was opgedateer",
"PluginUninstalledWithName": "{0} was verwyder", "PluginUninstalledWithName": "{0} was verwyder",
"PluginInstalledWithName": "{0} is geïnstalleer", "PluginInstalledWithName": "{0} is geïnstalleer",
"Plugin": "Inprop module",
"NotificationOptionVideoPlaybackStopped": "Video terugspeel het gestop", "NotificationOptionVideoPlaybackStopped": "Video terugspeel het gestop",
"NotificationOptionVideoPlayback": "Video terugspeel het begin", "NotificationOptionVideoPlayback": "Video terugspeel het begin",
"NotificationOptionUserLockedOut": "Gebruiker uitgeslyt", "NotificationOptionUserLockedOut": "Gebruiker uitgeslyt",
@@ -74,23 +54,14 @@
"MusicVideos": "Musiek Videos", "MusicVideos": "Musiek Videos",
"Music": "Musiek", "Music": "Musiek",
"MixedContent": "Gemengde inhoud", "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", "Latest": "Nuutste",
"LabelRunningTimeValue": "Werktyd: {0}", "LabelRunningTimeValue": "Werktyd: {0}",
"LabelIpAddressValue": "IP adres: {0}", "LabelIpAddressValue": "IP adres: {0}",
"ItemRemovedWithName": "{0} is uit versameling verwyder",
"ItemAddedWithName": "{0} is by die versameling gevoeg",
"HomeVideos": "Tuis Videos", "HomeVideos": "Tuis Videos",
"HeaderRecordingGroups": "Groep Opnames",
"Genres": "Genres", "Genres": "Genres",
"FailedLoginAttemptWithUserName": "Mislukte aanmeldpoging van {0}", "FailedLoginAttemptWithUserName": "Mislukte aanmeldpoging van {0}",
"ChapterNameValue": "Hoofstuk {0}", "ChapterNameValue": "Hoofstuk {0}",
"CameraImageUploadedFrom": "'n Nuwe kamera foto is opgelaai vanaf {0}",
"AuthenticationSucceededWithUserName": "{0} suksesvol geverifieer", "AuthenticationSucceededWithUserName": "{0} suksesvol geverifieer",
"Albums": "Albums",
"TasksChannelsCategory": "Internet kanale", "TasksChannelsCategory": "Internet kanale",
"TasksApplicationCategory": "aansoek", "TasksApplicationCategory": "aansoek",
"TasksLibraryCategory": "biblioteek", "TasksLibraryCategory": "biblioteek",
@@ -128,12 +99,12 @@
"TaskRefreshTrickplayImagesDescription": "Skep fopspeel voorskou vir videos in aangeskakelde media versameling.", "TaskRefreshTrickplayImagesDescription": "Skep fopspeel voorskou vir videos in aangeskakelde media versameling.",
"TaskAudioNormalizationDescription": "Skandeer lêers vir oudio-normaliseringsdata.", "TaskAudioNormalizationDescription": "Skandeer lêers vir oudio-normaliseringsdata.",
"TaskAudioNormalization": "Odio Normalisering", "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", "TaskDownloadMissingLyrics": "Laai tekorte lirieke af",
"TaskDownloadMissingLyricsDescription": "Laai lirieke af vir liedjies", "TaskDownloadMissingLyricsDescription": "Laai lirieke af vir liedjies",
"TaskExtractMediaSegments": "Media Segment Skandeer", "TaskExtractMediaSegments": "Media Segment Skandeer",
"TaskExtractMediaSegmentsDescription": "Onttrek of verkry mediasegmente van MediaSegment-geaktiveerde inproppe.", "TaskExtractMediaSegmentsDescription": "Onttrek of verkry mediasegmente van MediaSegment-geaktiveerde inproppe.",
"TaskMoveTrickplayImages": "Migreer Trickplay Beeldligging", "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."
} }
@@ -1,142 +1,125 @@
{ {
"Albums": "ألبومات", "AppDeviceValues": "التطبيق: {0}، الجهاز: {1}",
"AppDeviceValues": "تطبيق: {0}, جهاز: {1}",
"Application": "تطبيق",
"Artists": "الفنانون", "Artists": "الفنانون",
"AuthenticationSucceededWithUserName": "نجحت عملية التوثيق بـ {0}", "AuthenticationSucceededWithUserName": "تمت مصادقة {0} بنجاح",
"Books": "الكتب", "Books": "الكتب",
"CameraImageUploadedFrom": "رُفعت صورة الكاميرا الجديدة من {0}",
"Channels": "القنوات",
"ChapterNameValue": "الفصل {0}", "ChapterNameValue": "الفصل {0}",
"Collections": "المجموعات", "Collections": "المجموعات",
"DeviceOfflineWithName": "قُطِع الاتصال ب{0}", "FailedLoginAttemptWithUserName": "محاولة تسجيل دخول فاشلة من {0}",
"DeviceOnlineWithName": "{0} متصل",
"FailedLoginAttemptWithUserName": "محاولة تسجيل الدخول فاشلة من {0}",
"Favorites": "المفضلة", "Favorites": "المفضلة",
"Folders": "المجلدات", "Folders": "المجلدات",
"Genres": "التصنيفات", "Genres": "الأنواع",
"HeaderAlbumArtists": "فناني الألبوم", "HeaderContinueWatching": "متابعة المشاهدة",
"HeaderContinueWatching": "إستئناف المشاهدة",
"HeaderFavoriteAlbums": "الألبومات المفضلة",
"HeaderFavoriteArtists": "الفنانون المفضلون",
"HeaderFavoriteEpisodes": "الحلقات المفضلة", "HeaderFavoriteEpisodes": "الحلقات المفضلة",
"HeaderFavoriteShows": "المسلسلات المفضلة", "HeaderFavoriteShows": "المسلسلات المفضلة",
"HeaderFavoriteSongs": "الأغاني المفضلة", "HeaderLiveTV": "البث التلفزيوني المباشر",
"HeaderLiveTV": "التلفاز المباشر",
"HeaderNextUp": "التالي", "HeaderNextUp": "التالي",
"HeaderRecordingGroups": "مجموعات التسجيل", "HomeVideos": "فيديوهات منزلية",
"HomeVideos": "الفيديوهات الشخصية", "Inherit": "وراثة",
"Inherit": "توريث", "LabelIpAddressValue": "عنوان IP: {0}",
"ItemAddedWithName": "أُضيف {0} للمكتبة",
"ItemRemovedWithName": "أُزيل {0} من المكتبة",
"LabelIpAddressValue": "عنوان الآي بي: {0}",
"LabelRunningTimeValue": "مدة التشغيل: {0}", "LabelRunningTimeValue": "مدة التشغيل: {0}",
"Latest": "الأحدث", "Latest": "الأحدث",
"MessageApplicationUpdated": "حُدث خادم Jellyfin",
"MessageApplicationUpdatedTo": "حُدث خادم Jellyfin إلى {0}",
"MessageNamedServerConfigurationUpdatedWithValue": "حُدثت إعدادات الخادم في قسم {0}",
"MessageServerConfigurationUpdated": "حُدثت إعدادات الخادم",
"MixedContent": "محتوى مختلط", "MixedContent": "محتوى مختلط",
"Movies": "الأفلام", "Movies": "الأفلام",
"Music": "الموسيقى", "Music": "الموسيقى",
"MusicVideos": "الفيديوهات الموسيقية", "MusicVideos": "الفيديوهات الموسيقية",
"NameInstallFailed": "فشل تثبيت {0}", "NameInstallFailed": "فشل تثبيت {0}",
"NameSeasonNumber": "الموسم {0}", "NameSeasonNumber": "الموسم {0}",
"NameSeasonUnknown": "الموسم غير معروف", "NameSeasonUnknown": "موسم غير معروف",
"NewVersionIsAvailable": "نسخة جديدة من خادم Jellyfin متوفرة للتحميل.", "NewVersionIsAvailable": "يتوفر إصدار جديد من خادم Jellyfin للتنزيل.",
"NotificationOptionApplicationUpdateAvailable": "يوجد تحديث للتطبيق", "NotificationOptionApplicationUpdateAvailable": "تحديث التطبيق متاح",
"NotificationOptionApplicationUpdateInstalled": "نُصب تحديث التطبيق", "NotificationOptionApplicationUpdateInstalled": "تم تثبيت تحديث التطبيق",
"NotificationOptionAudioPlayback": "بدأ تشغيل المقطع الصوتي", "NotificationOptionAudioPlayback": "بدأ تشغيل الصوت",
"NotificationOptionAudioPlaybackStopped": "أُوقف تشغيل المقطع الصوتي", "NotificationOptionAudioPlaybackStopped": "توقف تشغيل الصوت",
"NotificationOptionCameraImageUploaded": "رُفعت صورة الكاميرا", "NotificationOptionCameraImageUploaded": "تم رفع صورة كاميرا",
"NotificationOptionInstallationFailed": "فشل في التثبيت", "NotificationOptionInstallationFailed": "فشل التثبيت",
"NotificationOptionNewLibraryContent": "أُضيف محتوى جديدا", "NotificationOptionNewLibraryContent": "تمت إضافة محتوى جديد",
"NotificationOptionPluginError": "فشل في الملحق", "NotificationOptionPluginError": "خطأ في الملحق",
"NotificationOptionPluginInstalled": "ثُبتت الملحق", "NotificationOptionPluginInstalled": "تم تثبيت الملحق",
"NotificationOptionPluginUninstalled": "تمت إزالة الملحق", "NotificationOptionPluginUninstalled": "تمت إزالة الملحق",
"NotificationOptionPluginUpdateInstalled": "تم تثبيت تحديثات الملحق", "NotificationOptionPluginUpdateInstalled": "تم تحديث الملحق",
"NotificationOptionServerRestartRequired": "يجب إعادة تشغيل الخادم", "NotificationOptionServerRestartRequired": "مطلوب إعادة تشغيل الخادم",
"NotificationOptionTaskFailed": "فشل في المهمة المجدولة", "NotificationOptionTaskFailed": "فشل المهمة المجدولة",
"NotificationOptionUserLockedOut": "تم إقفال حساب المستخدم", "NotificationOptionUserLockedOut": "تم قفل حساب المستخدم",
"NotificationOptionVideoPlayback": "بدأ تشغيل الفيديو", "NotificationOptionVideoPlayback": "بدأ تشغيل الفيديو",
"NotificationOptionVideoPlaybackStopped": "تم إيقاف تشغيل الفيديو", "NotificationOptionVideoPlaybackStopped": "توقف تشغيل الفيديو",
"Photos": "الصور", "Photos": "الصور",
"Playlists": "قوائم التشغيل",
"Plugin": "الملحق",
"PluginInstalledWithName": "تم تثبيت {0}", "PluginInstalledWithName": "تم تثبيت {0}",
"PluginUninstalledWithName": "تمت إزالة {0}", "PluginUninstalledWithName": "تمت إزالة {0}",
"PluginUpdatedWithName": "تم تحديث {0}", "PluginUpdatedWithName": "تم تحديث {0}",
"ProviderValue": "المزود: {0}", "ScheduledTaskFailedWithName": "فشلت {0}",
"ScheduledTaskFailedWithName": "فشلت العملية {0}", "Shows": "المسلسلات",
"ScheduledTaskStartedWithName": "تم بدء العملية {0}", "StartupEmbyServerIsLoading": "يتم الآن تحميل خادم Jellyfin. يرجى المحاولة مرة أخرى بعد قليل.",
"ServerNameNeedsToBeRestarted": "يحتاج {0} لإعادة التشغيل", "SubtitleDownloadFailureFromForItem": "فشل تنزيل الترجمات من {0} لـ {1}",
"Shows": "العروض",
"Songs": "الأغاني",
"StartupEmbyServerIsLoading": "يتم تحميل خادم Jellyfin . الرجاء المحاولة بعد قليل.",
"SubtitleDownloadFailureForItem": "عملية إنزال الترجمة فشلت لـ{0}",
"SubtitleDownloadFailureFromForItem": "فشل تحميل الترجمات من {0} ل {1}",
"Sync": "مزامنة",
"System": "النظام",
"TvShows": "البرامج التلفزيونية", "TvShows": "البرامج التلفزيونية",
"User": "المستخدم",
"UserCreatedWithName": "تم إنشاء المستخدم {0}", "UserCreatedWithName": "تم إنشاء المستخدم {0}",
"UserDeletedWithName": "تم حذف المستخدم {0}", "UserDeletedWithName": "تم حذف المستخدم {0}",
"UserDownloadingItemWithValues": "يقوم {0} بتنزيل {1}", "UserDownloadingItemWithValues": "{0} يقوم بتنزيل {1}",
"UserLockedOutWithName": "تم منع المستخدم {0} من الدخول", "UserLockedOutWithName": "تم قفل حساب المستخدم {0}",
"UserOfflineFromDevice": "تم قطع اتصال {0} من {1}", "UserOfflineFromDevice": "انقطع اتصال {0} من {1}",
"UserOnlineFromDevice": "{0} متصل عبر {1}", "UserOnlineFromDevice": "{0} متصل من {1}",
"UserPasswordChangedWithName": "تم تغيير كلمة السر للمستخدم {0}", "UserPasswordChangedWithName": "تم تغيير كلمة المرور للمستخدم {0}",
"UserPolicyUpdatedWithName": "تم تحديث سياسة المستخدم {0}", "UserStartedPlayingItemWithValues": "{0} يقوم بتشغيل {1} على {2}",
"UserStartedPlayingItemWithValues": "قام {0} ببدء تشغيل {1} على {2}", "UserStoppedPlayingItemWithValues": "أنهى {0} تشغيل {1} على {2}",
"UserStoppedPlayingItemWithValues": "قام {0} بإيقاف تشغيل {1} على {2}",
"ValueHasBeenAddedToLibrary": "تمت اضافت {0} إلى مكتبة الوسائط",
"ValueSpecialEpisodeName": "حلقة خاصه - {0}",
"VersionNumber": "الإصدار {0}", "VersionNumber": "الإصدار {0}",
"TaskCleanCacheDescription": "يحذف الملفات المؤقتة التي لم يعد النظام بحاجة إليها.", "TaskCleanCacheDescription": "يحذف ملفات ذاكرة التخزين المؤقت التي لم يعد النظام بحاجة إليها.",
"TaskCleanCache": "حذف الملفات المؤقتة", "TaskCleanCache": "تنظيف مجلد ذاكرة التخزين المؤقت",
"TasksChannelsCategory": "قنوات الإنترنت", "TasksChannelsCategory": "قنوات الإنترنت",
"TasksLibraryCategory": "مكتبة", "TasksLibraryCategory": "المكتبة",
"TasksMaintenanceCategory": "صيانة", "TasksMaintenanceCategory": "الصيانة",
"TaskRefreshLibraryDescription": "يفحص مكتبة الوسائط الخاصة بك باحثا عن ملفات جديدة، ومن ثم يُحدث البيانات الوصفية.", "TaskRefreshLibraryDescription": "يفحص مكتبة المحتوى الخاصة بك بحثاً عن ملفات جديدة ويحدّث البيانات الوصفية.",
"TaskRefreshLibrary": "افحص مكتبة الوسائط", "TaskRefreshLibrary": "فحص مكتبة المحتوى",
"TaskRefreshChapterImagesDescription": "يُنشئ صور مصغرة لمقاطع الفيديو التي تحتوي على فصول.", "TaskRefreshChapterImagesDescription": "ينشئ صوراً مصغرة للفيديوهات التي تحتوي على فصول.",
"TaskRefreshChapterImages": "استخراج صور الفصل", "TaskRefreshChapterImages": "استخراج صور الفصول",
"TasksApplicationCategory": "تطبيق", "TasksApplicationCategory": "التطبيق",
"TaskDownloadMissingSubtitlesDescription": "يبحث في الإنترنت على الترجمات الناقصة استنادا على البيانات الوصفية.", "TaskDownloadMissingSubtitlesDescription": "يبحث في الإنترنت عن الترجمات المفقودة بناءً على إعدادات البيانات الوصفية.",
"TaskDownloadMissingSubtitles": "تحميل الترجمات الناقصة", "TaskDownloadMissingSubtitles": "تنزيل الترجمات المفقودة",
"TaskRefreshChannelsDescription": "يحدث معلومات قنوات الإنترنت.", "TaskRefreshChannelsDescription": "يحدّث معلومات قنوات الإنترنت.",
"TaskRefreshChannels": "إعادة تحديث القنوات", "TaskRefreshChannels": "تحديث القنوات",
"TaskCleanTranscodeDescription": "يحذف ملفات الترميز الأقدم من يوم واحد.", "TaskCleanTranscodeDescription": "يحذف ملفات تحويل الترميز التي مر عليها أكثر من يوم واحد.",
"TaskCleanTranscode": "حذف ما بمجلد الترميز", "TaskCleanTranscode": "تنظيف مجلد تحويل الترميز",
"TaskUpdatePluginsDescription": "تحميل وتثبيت الإضافات التي تم تفعيل التحديث التلقائي لها.", "TaskUpdatePluginsDescription": "ينزّل ويثبّت التحديثات للملحقات المهيأة للتحديث التلقائي.",
"TaskUpdatePlugins": "تحديث الإضافات", "TaskUpdatePlugins": "تحديث الملحقات",
"TaskRefreshPeopleDescription": "يقوم بتحديث البيانات الوصفية للممثلين والمخرجين في مكتبة الوسائط الخاصة بك.", "TaskRefreshPeopleDescription": "يحدّث البيانات الوصفية للممثلين والمخرجين في مكتبة المحتوى الخاصة بك.",
"TaskRefreshPeople": "إعادة تحميل الأشخاص", "TaskRefreshPeople": "تحديث الأشخاص",
"TaskCleanLogsDescription": "يحذف السجلات الأقدم من {0} يوم.", "TaskCleanLogsDescription": "يحذف ملفات السجل التي يزيد عمرها عن {0} أيام.",
"TaskCleanLogs": "حذف مسار السجل", "TaskCleanLogs": "تنظيف مجلد السجلات",
"TaskCleanActivityLogDescription": "يحذف سجل الأنشطة الأقدم من الوقت الذي تم تحديده.", "TaskCleanActivityLogDescription": "يحذف إدخالات سجل النشاط الأقدم من العمر المحدد.",
"TaskCleanActivityLog": "حذف سجل الأنشطة", "TaskCleanActivityLog": "تنظيف سجل النشاط",
"Default": "افتراضي", "Default": "الافتراضي",
"Undefined": "غير معرف", "Undefined": "غير محدد",
"Forced": "ملحقة", "Forced": "إجباري",
"TaskOptimizeDatabaseDescription": "يضغط قاعدة البيانات ويقتطع المساحة الحرة. تشغيل هذه المهمة بعد فحص المكتبة أو إجراء تغييرات أخرى تتضمن تعديلات في قاعدة البيانات قد تؤدي إلى تحسين الأداء.", "TaskOptimizeDatabaseDescription": "يضغط قاعدة البيانات ويقلل المساحة الحرة. قد يؤدي تشغيل هذه المهمة بعد فحص المكتبة أو إجراء تغييرات أخرى تتضمن تعديلات على قاعدة البيانات إلى تحسين الأداء.",
"TaskOptimizeDatabase": "تحسين قاعدة البيانات", "TaskOptimizeDatabase": "تحسين قاعدة البيانات",
"TaskKeyframeExtractorDescription": "يستخرج الإطارات الرئيسية من ملفات الفيديو لكي ينشئ قوائم تشغيل بث HTTP المباشر. قد تستمر هذه العملية لوقت طويل.", "TaskKeyframeExtractorDescription": "يستخرج الإطارات الرئيسية من ملفات الفيديو لإنشاء قوائم تشغيل HLS أكثر دقة. قد يستغرق تشغيل هذه المهمة وقتاً طويلاً.",
"TaskKeyframeExtractor": "مستخرج الإطار الرئيسي", "TaskKeyframeExtractor": "مستخرج الإطارات الرئيسية",
"External": "خارجي", "External": "خارجي",
"HearingImpaired": "ضعاف السمع", "HearingImpaired": "لضعاف السمع",
"TaskRefreshTrickplayImages": "توليد صور المعاينة السريعة", "TaskRefreshTrickplayImages": "إنشاء صور معاينات التنقل (Trickplay)",
"TaskRefreshTrickplayImagesDescription": "يُولّد معاينات تنقل سريع لمقاطع الفيديو ضمن المكتبات المفعّلة.", "TaskRefreshTrickplayImagesDescription": "ينشئ صور معاينات التنقل السريع للفيديوهات في المكتبات المفعّلة.",
"TaskCleanCollectionsAndPlaylists": "حذف المجموعات وقوائم التشغيل", "TaskAudioNormalization": "تطبيع الصوت",
"TaskCleanCollectionsAndPlaylistsDescription": "حذف عناصر من المجموعات وقوائم التشغيل التي لم تعد موجودة.", "TaskAudioNormalizationDescription": "يفحص الملفات لجمع بيانات تطبيع الصوت.",
"TaskAudioNormalization": "تسوية الصوت", "TaskDownloadMissingLyrics": "تنزيل الكلمات المفقودة",
"TaskAudioNormalizationDescription": "مسح الملفات لتطبيع بيانات الصوت.", "TaskDownloadMissingLyricsDescription": "تحميل كلمات الأغاني",
"TaskDownloadMissingLyrics": "تنزيل عبارات القصيدة", "TaskExtractMediaSegments": "فحص مقاطع المحتوى",
"TaskDownloadMissingLyricsDescription": "كلمات", "TaskExtractMediaSegmentsDescription": "يستخرج أو يحصل على مقاطع المحتوى من الملحقات المفعّلة لمقاطع المحتوى (MediaSegment).",
"TaskExtractMediaSegments": "فحص مقاطع الوسائط", "TaskMoveTrickplayImages": "نقل موقع صور معاينات التنقل",
"TaskExtractMediaSegmentsDescription": "يستخرج مقاطع وسائط من إضافات MediaSegment المُفعّلة.", "TaskMoveTrickplayImagesDescription": "ينقل ملفات معاينات التنقل الحالية وفقاً لإعدادات المكتبة.",
"TaskMoveTrickplayImages": "تغيير مكان صور المعاينة السريعة",
"TaskMoveTrickplayImagesDescription": "تُنقل ملفات التشغيل السريع الحالية بناءً على إعدادات المكتبة.",
"CleanupUserDataTask": "مهمة تنظيف بيانات المستخدم", "CleanupUserDataTask": "مهمة تنظيف بيانات المستخدم",
"CleanupUserDataTaskDescription": "مسح جميع بيانات المستخدم (حالة المشاهدة، والحالة المفضلة وما إلى ذلك) من الوسائط التي لم تعد موجودة لمدة 90 يومًا على الأقل." "CleanupUserDataTaskDescription": "ينظف جميع بيانات المستخدم (مثل حالة المشاهدة وحالة المفضلة وغيرها) للمحتوى الذي لم يعد موجوداً لمدة 90 يوماً على الأقل.",
"Original": "فريد",
"LyricDownloadFailureFromForItem": "فشل تحميل الكلمات من {0} إلى {1}",
"NameExtraBehindTheScenes": "خلف المشاهد",
"NameExtraClip": "مقطع",
"NameExtraDeletedScene": "المشهد المحذوف",
"NameExtraInterview": "مقابلة",
"NameExtraNumbered": "{0} {1}",
"NameExtraSample": "عيّنة",
"NameExtraScene": "مشهد",
"NameExtraShort": "قصير",
"NameExtraThemeSong": "الاغنية السمة",
"NameExtraThemeVideo": "الفيديو السمة",
"NameExtraFeaturette": "فيلم قصير إضافي",
"NameExtraTrailer": "إعلان ترويجي",
"NameExtraUnknown": "إضافي"
} }
@@ -1,18 +1,13 @@
{ {
"Albums": "এলবাম",
"Application": "আবেদন",
"AppDeviceValues": "এপ্‌: {0}, ডিভাইচ: {1}", "AppDeviceValues": "এপ্‌: {0}, ডিভাইচ: {1}",
"Artists": "শিল্পী", "Artists": "শিল্পী",
"Channels": "চেনেলস",
"Default": "ডিফল্ট", "Default": "ডিফল্ট",
"AuthenticationSucceededWithUserName": "{0} সফলভাবে প্রমাণিত", "AuthenticationSucceededWithUserName": "{0} সফলভাবে প্রমাণিত",
"Books": "পুস্তক", "Books": "পুস্তক",
"Movies": "চলচ্চিত্ৰ", "Movies": "চলচ্চিত্ৰ",
"CameraImageUploadedFrom": "একটি নতুন ক্যামেরা চিত্র আপলোড করা হয়েছে {0}",
"Collections": "সংগ্রহ", "Collections": "সংগ্রহ",
"HeaderFavoriteShows": "প্রিয় শোসমূহ", "HeaderFavoriteShows": "প্রিয় শোসমূহ",
"Latest": "শেহতীয়া", "Latest": "শেহতীয়া",
"MessageApplicationUpdated": "জেলিফিন চাইভাৰ আপডেট কৰা হৈছে",
"MixedContent": "মিশ্ৰিত সমগ্ৰতা", "MixedContent": "মিশ্ৰিত সমগ্ৰতা",
"NewVersionIsAvailable": "ডাউনলোড কৰিবলৈ জেলিফিন চাইভাৰৰ এটা নতুন সংস্কৰণ উপলব্ধ আছে.", "NewVersionIsAvailable": "ডাউনলোড কৰিবলৈ জেলিফিন চাইভাৰৰ এটা নতুন সংস্কৰণ উপলব্ধ আছে.",
"NotificationOptionCameraImageUploaded": "কেমেৰাৰ চিত্ৰ আপল'ড কৰা হ'ল", "NotificationOptionCameraImageUploaded": "কেমেৰাৰ চিত্ৰ আপল'ড কৰা হ'ল",
@@ -21,20 +16,14 @@
"Folders": "ফোল্ডাৰ", "Folders": "ফোল্ডাৰ",
"Forced": "বলপূর্বক", "Forced": "বলপূর্বক",
"Genres": "শ্রেণী", "Genres": "শ্রেণী",
"HeaderAlbumArtists": "অ্যালবাম শিল্পী",
"HeaderContinueWatching": "দেখা চালিয়ে যান", "HeaderContinueWatching": "দেখা চালিয়ে যান",
"FailedLoginAttemptWithUserName": "লগইন ব্যর্থ চেষ্টা কৰা হৈছে থেকে {0}", "FailedLoginAttemptWithUserName": "লগইন ব্যর্থ চেষ্টা কৰা হৈছে থেকে {0}",
"HeaderFavoriteAlbums": "প্রিয় অ্যালবামসমূহ",
"HeaderFavoriteArtists": "প্রিয় শিল্পীসমূহ",
"HeaderFavoriteEpisodes": "প্রিয় পর্বসমূহ", "HeaderFavoriteEpisodes": "প্রিয় পর্বসমূহ",
"HeaderFavoriteSongs": "প্ৰিয় গীত",
"HeaderLiveTV": "প্ৰতিবেদন টিভি", "HeaderLiveTV": "প্ৰতিবেদন টিভি",
"HeaderNextUp": "পৰৱৰ্তী অংশ", "HeaderNextUp": "পৰৱৰ্তী অংশ",
"HeaderRecordingGroups": "অলংকৰণ গোষ্ঠীসমূহ",
"HearingImpaired": "শ্ৰবণ অক্ষম", "HearingImpaired": "শ্ৰবণ অক্ষম",
"HomeVideos": "ঘৰৰ ভিডিঅ'সমূহ", "HomeVideos": "ঘৰৰ ভিডিঅ'সমূহ",
"Inherit": "উত্তপ্ত কৰা", "Inherit": "উত্তপ্ত কৰা",
"MessageServerConfigurationUpdated": "চাইভাৰ কনফিগাৰেশ্যন আপডেট কৰা হৈছে",
"NotificationOptionApplicationUpdateAvailable": "অ্যাপ্লিকেশ্যন আপডেট উপলব্ধ", "NotificationOptionApplicationUpdateAvailable": "অ্যাপ্লিকেশ্যন আপডেট উপলব্ধ",
"NotificationOptionApplicationUpdateInstalled": "অ্যাপ্লিকেশ্যন আপডেট ইনষ্টল কৰা হ'ল", "NotificationOptionApplicationUpdateInstalled": "অ্যাপ্লিকেশ্যন আপডেট ইনষ্টল কৰা হ'ল",
"NotificationOptionAudioPlayback": "অডিঅ' প্লেবেক আৰম্ভ হ'ল", "NotificationOptionAudioPlayback": "অডিঅ' প্লেবেক আৰম্ভ হ'ল",
@@ -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"
}
@@ -1,36 +1,24 @@
{ {
"Sync": "Сінхранізаваць",
"Playlists": "Плэй-лісты",
"Latest": "Апошняе", "Latest": "Апошняе",
"LabelIpAddressValue": "IP-адрас: {0}", "LabelIpAddressValue": "IP-адрас: {0}",
"ItemAddedWithName": "{0} даданы ў бібліятэку",
"MessageApplicationUpdated": "Сервер Jellyfin абноўлены",
"NotificationOptionApplicationUpdateInstalled": "Абнаўленне праграмы ўсталявана", "NotificationOptionApplicationUpdateInstalled": "Абнаўленне праграмы ўсталявана",
"PluginInstalledWithName": "{0} быў усталяваны", "PluginInstalledWithName": "{0} быў усталяваны",
"UserCreatedWithName": "Карыстальнік {0} быў створаны", "UserCreatedWithName": "Карыстальнік {0} быў створаны",
"Albums": "Альбомы",
"Application": "Праграма",
"AuthenticationSucceededWithUserName": "{0} паспяхова аўтарызаваны", "AuthenticationSucceededWithUserName": "{0} паспяхова аўтарызаваны",
"Channels": "Каналы",
"ChapterNameValue": "Раздзел {0}", "ChapterNameValue": "Раздзел {0}",
"Collections": "Калекцыі", "Collections": "Калекцыі",
"Default": "Па змаўчанні", "Default": "Прадвызначана",
"FailedLoginAttemptWithUserName": "Няўдалая спроба ўваходу з {0}", "FailedLoginAttemptWithUserName": "Няўдалая спроба ўваходу з {0}",
"Folders": "Тэчкі", "Folders": "Папкі",
"Favorites": "Абранае", "Favorites": "Абранае",
"External": "Знешні", "External": "Знешні",
"Genres": "Жанры", "Genres": "Жанры",
"HeaderContinueWatching": "Працягнуць прагляд", "HeaderContinueWatching": "Працягнуць прагляд",
"HeaderFavoriteAlbums": "Абраныя альбомы",
"HeaderFavoriteEpisodes": "Абраныя серыі", "HeaderFavoriteEpisodes": "Абраныя серыі",
"HeaderFavoriteShows": "Абраныя шоу", "HeaderFavoriteShows": "Абраныя шоу",
"HeaderFavoriteSongs": "Абраныя песні",
"HeaderLiveTV": "Прамы эфір", "HeaderLiveTV": "Прамы эфір",
"HeaderAlbumArtists": "Выканаўцы альбома",
"LabelRunningTimeValue": "Працягласць: {0}", "LabelRunningTimeValue": "Працягласць: {0}",
"HomeVideos": "Хатнія відэа", "HomeVideos": "Хатнія відэа",
"ItemRemovedWithName": "{0} выдалены з бібліятэкі",
"MessageApplicationUpdatedTo": "Сервер Jellyfin абноўлены да версіі {0}",
"Movies": "Фільмы", "Movies": "Фільмы",
"Music": "Музыка", "Music": "Музыка",
"MusicVideos": "Музычныя кліпы", "MusicVideos": "Музычныя кліпы",
@@ -41,48 +29,35 @@
"NotificationOptionPluginUpdateInstalled": "Абнаўленне плагіна ўсталявана", "NotificationOptionPluginUpdateInstalled": "Абнаўленне плагіна ўсталявана",
"NotificationOptionServerRestartRequired": "Патрабуецца перазапуск сервера", "NotificationOptionServerRestartRequired": "Патрабуецца перазапуск сервера",
"Photos": "Фотаздымкі", "Photos": "Фотаздымкі",
"Plugin": "Плагін",
"PluginUninstalledWithName": "{0} быў выдалены", "PluginUninstalledWithName": "{0} быў выдалены",
"PluginUpdatedWithName": "{0} быў абноўлены", "PluginUpdatedWithName": "{0} быў абноўлены",
"ProviderValue": "Пастаўшчык: {0}",
"Songs": "Песні",
"System": "Сістэма",
"User": "Карыстальнік",
"UserDeletedWithName": "Карыстальнік {0} быў выдалены", "UserDeletedWithName": "Карыстальнік {0} быў выдалены",
"UserDownloadingItemWithValues": "{0} спампоўваецца {1}", "UserDownloadingItemWithValues": "{0} спампоўваецца {1}",
"TaskOptimizeDatabase": "Аптымізаваць базу дадзеных", "TaskOptimizeDatabase": "Аптымізацыя базы даных",
"Artists": "Выканаўцы", "Artists": "Выканаўцы",
"UserOfflineFromDevice": "{0} адлучыўся ад {1}", "UserOfflineFromDevice": "{0} адлучыўся ад {1}",
"UserPolicyUpdatedWithName": "Палітыка карыстальніка абноўлена для {0}",
"TaskCleanActivityLogDescription": "Выдаляе запісы старэйшыя за зададзены ўзрост ў журнале актыўнасці.", "TaskCleanActivityLogDescription": "Выдаляе запісы старэйшыя за зададзены ўзрост ў журнале актыўнасці.",
"TaskRefreshChapterImagesDescription": "Стварае мініяцюры для відэа, якія маюць раздзелы.", "TaskRefreshChapterImagesDescription": "Стварае мініяцюры для відэа, якія маюць раздзелы.",
"TaskCleanLogsDescription": "Выдаляе файлы журналу, якім больш за {0} дзён.", "TaskCleanLogsDescription": "Выдаляе файлы журналу, якім больш за {0} дзён.",
"TaskUpdatePluginsDescription": "Спампоўвае і ўсталёўвае абнаўленні для плагінаў, якія сканфігураваныя на аўтаматычнае абнаўленне.", "TaskUpdatePluginsDescription": "Спампоўвае і ўсталёўвае абнаўленні для плагінаў, якія сканфігураваныя на аўтаматычнае абнаўленне.",
"TaskRefreshChannelsDescription": "Абнаўляе інфармацыю аб інтэрнэт-канале.", "TaskRefreshChannelsDescription": "Абнаўляе інфармацыю аб інтэрнэт-канале.",
"TaskDownloadMissingSubtitlesDescription": "Шукае ў інтэрнэце адсутныя субцітры на аснове канфігурацыі метададзеных.", "TaskDownloadMissingSubtitlesDescription": "Шукае ў інтэрнэце адсутныя субцітры на аснове канфігурацыі метаданых.",
"TaskOptimizeDatabaseDescription": "Ушчыльняе базу дадзеных і скарачае вольную прастору. Выкананне гэтай задачы пасля сканавання бібліятэкі або ўнясення іншых зменаў, якія прадугледжваюць мадыфікацыю базы дадзеных, можа палепшыць выдайнасць.", "TaskOptimizeDatabaseDescription": "Сціскае базу даных і вызваляе вольную прастору. Выкананне гэтай задачы пасля сканіравання бібліятэкі або іншых змяненняў, якія мадыфікуюць базу даных, можа палепшыць прадукцыйнасць.",
"TaskKeyframeExtractor": "Экстрактар ключавых кадраў", "TaskKeyframeExtractor": "Экстрактар ключавых кадраў",
"TasksApplicationCategory": "Праграма", "TasksApplicationCategory": "Праграма",
"AppDeviceValues": "Праграма: {0}, Прылада: {1}", "AppDeviceValues": "Праграма: {0}, Прылада: {1}",
"Books": "Кнігі", "Books": "Кнігі",
"CameraImageUploadedFrom": "Новая выява камеры была загружана з {0}",
"DeviceOfflineWithName": "{0} адлучыўся",
"DeviceOnlineWithName": "{0} падлучаны",
"Forced": "Прымусова", "Forced": "Прымусова",
"HeaderRecordingGroups": "Групы запісаў",
"HeaderNextUp": "Наступнае", "HeaderNextUp": "Наступнае",
"HeaderFavoriteArtists": "Абраныя выканаўцы",
"HearingImpaired": "Са слабым слыхам", "HearingImpaired": "Са слабым слыхам",
"Inherit": "Атрымаць у спадчыну", "Inherit": "Атрымаць у спадчыну",
"MessageNamedServerConfigurationUpdatedWithValue": "Канфігурацыя сервера (секцыя {0}) абноўлена",
"MessageServerConfigurationUpdated": "Канфігурацыя сервера абноўлена",
"MixedContent": "Змешаны змест", "MixedContent": "Змешаны змест",
"NameSeasonUnknown": "Невядомы сезон", "NameSeasonUnknown": "Невядомы сезон",
"NotificationOptionInstallationFailed": "Збой усталёўкі", "NotificationOptionInstallationFailed": "Збой усталёўкі",
"NewVersionIsAvailable": "Новая версія сервера Jellyfin даступная для cпампоўкі.", "NewVersionIsAvailable": "Новая версія сервера Jellyfin даступная для cпампоўкі.",
"NotificationOptionCameraImageUploaded": "Выява камеры запампавана", "NotificationOptionCameraImageUploaded": "Выява камеры запампавана",
"NotificationOptionAudioPlaybackStopped": "Прайграванне аўдыё спынена", "NotificationOptionAudioPlaybackStopped": "Прайграванне аўдыя спынена",
"NotificationOptionAudioPlayback": "Прайграванне аўдыё пачалося", "NotificationOptionAudioPlayback": "Прайграванне аўдыя пачалося",
"NotificationOptionNewLibraryContent": "Дададзены новы кантэнт", "NotificationOptionNewLibraryContent": "Дададзены новы кантэнт",
"NotificationOptionPluginError": "Збой плагіна", "NotificationOptionPluginError": "Збой плагіна",
"NotificationOptionPluginUninstalled": "Плагін выдалены", "NotificationOptionPluginUninstalled": "Плагін выдалены",
@@ -91,11 +66,9 @@
"NotificationOptionVideoPlayback": "Пачалося прайграванне відэа", "NotificationOptionVideoPlayback": "Пачалося прайграванне відэа",
"NotificationOptionVideoPlaybackStopped": "Прайграванне відэа спынена", "NotificationOptionVideoPlaybackStopped": "Прайграванне відэа спынена",
"ScheduledTaskFailedWithName": "{0} не атрымалася", "ScheduledTaskFailedWithName": "{0} не атрымалася",
"ScheduledTaskStartedWithName": "{0} пачалося",
"ServerNameNeedsToBeRestarted": "{0} патрабуе перазапуску",
"Shows": "Шоу", "Shows": "Шоу",
"StartupEmbyServerIsLoading": "Jellyfin Server загружаецца. Калі ласка, паўтарыце спробу крыху пазней.", "StartupEmbyServerIsLoading": "Jellyfin Server загружаецца. Калі ласка, паўтарыце спробу крыху пазней.",
"SubtitleDownloadFailureFromForItem": "Не атрымалася спампаваць субтытры з {0} для {1}", "SubtitleDownloadFailureFromForItem": "Субцітры для {1} не ўдалося спампаваць з {0}",
"TvShows": "Тэлепраграма", "TvShows": "Тэлепраграма",
"Undefined": "Нявызначана", "Undefined": "Нявызначана",
"UserLockedOutWithName": "Карыстальнік {0} быў заблакіраваны", "UserLockedOutWithName": "Карыстальнік {0} быў заблакіраваны",
@@ -103,8 +76,6 @@
"UserPasswordChangedWithName": "Пароль быў зменены для карыстальніка {0}", "UserPasswordChangedWithName": "Пароль быў зменены для карыстальніка {0}",
"UserStartedPlayingItemWithValues": "{0} прайграваецца {1} на {2}", "UserStartedPlayingItemWithValues": "{0} прайграваецца {1} на {2}",
"UserStoppedPlayingItemWithValues": "{0} скончыў прайграванне {1} на {2}", "UserStoppedPlayingItemWithValues": "{0} скончыў прайграванне {1} на {2}",
"ValueHasBeenAddedToLibrary": "{0} быў дададзены ў вашу медыятэку",
"ValueSpecialEpisodeName": "Спецэпізод - {0}",
"VersionNumber": "Версія {0}", "VersionNumber": "Версія {0}",
"TasksMaintenanceCategory": "Абслугоўванне", "TasksMaintenanceCategory": "Абслугоўванне",
"TasksLibraryCategory": "Бібліятэка", "TasksLibraryCategory": "Бібліятэка",
@@ -114,7 +85,7 @@
"TaskCleanCacheDescription": "Выдаляе файлы кэша, якія больш не патрэбныя сістэме.", "TaskCleanCacheDescription": "Выдаляе файлы кэша, якія больш не патрэбныя сістэме.",
"TaskRefreshChapterImages": "Вынуць выявы раздзелаў", "TaskRefreshChapterImages": "Вынуць выявы раздзелаў",
"TaskRefreshLibrary": "Сканаваць бібліятэку", "TaskRefreshLibrary": "Сканаваць бібліятэку",
"TaskRefreshLibraryDescription": "Скануе вашу медыятэку на наяўнасць новых файлаў і абнаўляе метададзеныя.", "TaskRefreshLibraryDescription": "Сканіруе вашу медыятэку на наяўнасць новых файлаў і абнаўляе метаданыя.",
"TaskCleanLogs": "Ачысціць журнал", "TaskCleanLogs": "Ачысціць журнал",
"TaskRefreshPeople": "Абнавіць выканаўцаў", "TaskRefreshPeople": "Абнавіць выканаўцаў",
"TaskRefreshPeopleDescription": "Абнаўленне метаданых для акцёраў і рэжысёраў у вашай медыятэцы.", "TaskRefreshPeopleDescription": "Абнаўленне метаданых для акцёраў і рэжысёраў у вашай медыятэцы.",
@@ -123,11 +94,9 @@
"TaskCleanTranscodeDescription": "Выдаляе перакадзіраваныя файлы, старэйшыя за адзін дзень.", "TaskCleanTranscodeDescription": "Выдаляе перакадзіраваныя файлы, старэйшыя за адзін дзень.",
"TaskRefreshChannels": "Абнавіць каналы", "TaskRefreshChannels": "Абнавіць каналы",
"TaskDownloadMissingSubtitles": "Спампаваць адсутныя субцітры", "TaskDownloadMissingSubtitles": "Спампаваць адсутныя субцітры",
"TaskKeyframeExtractorDescription": "Выдае ключавыя кадры з відэафайлаў для стварэння больш дакладных плэй-лістоў HLS. Гэта задача можа працягнуцца шмат часу.", "TaskKeyframeExtractorDescription": "Выдае ключавыя кадры з відэафайлаў для стварэння больш дакладных плэй-лістоў HLS. Гэта задача можа выконвацца доўга.",
"TaskRefreshTrickplayImages": "Стварыць выявы Trickplay", "TaskRefreshTrickplayImages": "Стварыць выявы Trickplay",
"TaskRefreshTrickplayImagesDescription": "Стварае перадпрагляды відэаролікаў для Trickplay у падключаных бібліятэках.", "TaskRefreshTrickplayImagesDescription": "Стварае перадпрагляды відэаролікаў для Trickplay у падключаных бібліятэках.",
"TaskCleanCollectionsAndPlaylists": "Ачысціце калекцыі і плэй-лісты",
"TaskCleanCollectionsAndPlaylistsDescription": "Выдаляе элементы з калекцый і плэй-лістоў, якія больш не існуюць.",
"TaskAudioNormalizationDescription": "Скануе файлы на прадмет нармалізацыі гуку.", "TaskAudioNormalizationDescription": "Скануе файлы на прадмет нармалізацыі гуку.",
"TaskAudioNormalization": "Нармалізацыя гуку", "TaskAudioNormalization": "Нармалізацыя гуку",
"TaskExtractMediaSegmentsDescription": "Выдае або атрымлівае медыясегменты з убудоў з падтрымкай MediaSegment.", "TaskExtractMediaSegmentsDescription": "Выдае або атрымлівае медыясегменты з убудоў з падтрымкай MediaSegment.",
@@ -136,6 +105,18 @@
"TaskDownloadMissingLyricsDescription": "Спампоўвае тэксты для песняў", "TaskDownloadMissingLyricsDescription": "Спампоўвае тэксты для песняў",
"TaskExtractMediaSegments": "Сканіраванне медыя-сегмента", "TaskExtractMediaSegments": "Сканіраванне медыя-сегмента",
"TaskMoveTrickplayImages": "Перанесці месцазнаходжанне выявы Trickplay", "TaskMoveTrickplayImages": "Перанесці месцазнаходжанне выявы Trickplay",
"CleanupUserDataTask": "Задача па ачыстцы дадзеных карыстальніка", "CleanupUserDataTask": "Задача па ачыстцы даных карыстальніка",
"CleanupUserDataTaskDescription": "Ачысьціць усе дадзеныя карыстальніка (стан прагляду, абранае і г.д.) для медыяфайлаў, што адсутнічаюць больш за 90 дзён." "CleanupUserDataTaskDescription": "Ачышчае ўсе даныя карыстальніка (стан прагляду, абранае і г.д.) для медыяфайлаў, што адсутнічаюць больш за 90 дзён.",
"LyricDownloadFailureFromForItem": "Не ўдалося загрузіць тэкст песні з {0} для {1}",
"NameExtraDeletedScene": "Выдаленая сцэна",
"NameExtraInterview": "Інтэрв'ю",
"NameExtraNumbered": "{0} {1}",
"NameExtraScene": "Сцэна",
"NameExtraTrailer": "Трэйлер",
"NameExtraBehindTheScenes": "За кулісамі",
"NameExtraClip": "Кліп",
"NameExtraFeaturette": "Кароткаметражка",
"NameExtraSample": "Прыклад",
"NameExtraShort": "Кароткаметражка",
"NameExtraThemeSong": "Тэматычная песня"
} }
@@ -1,41 +1,24 @@
{ {
"Albums": "Албуми",
"AppDeviceValues": "Програма: {0}, Устройство: {1}", "AppDeviceValues": "Програма: {0}, Устройство: {1}",
"Application": "Програма",
"Artists": "Артисти", "Artists": "Артисти",
"AuthenticationSucceededWithUserName": "{0} се удостовери успешно", "AuthenticationSucceededWithUserName": "{0} се удостовери успешно",
"Books": "Книги", "Books": "Книги",
"CameraImageUploadedFrom": "Нова снимка от камера беше качена от {0}",
"Channels": "Канали",
"ChapterNameValue": "Глава {0}", "ChapterNameValue": "Глава {0}",
"Collections": "Колекции", "Collections": "Колекции",
"DeviceOfflineWithName": "{0} се разкачи",
"DeviceOnlineWithName": "{0} е свързан",
"FailedLoginAttemptWithUserName": "Неуспешен опит за влизане от {0}", "FailedLoginAttemptWithUserName": "Неуспешен опит за влизане от {0}",
"Favorites": "Любими", "Favorites": "Любими",
"Folders": "Папки", "Folders": "Папки",
"Genres": "Жанрове", "Genres": "Жанрове",
"HeaderAlbumArtists": "Изпълнители на албуми",
"HeaderContinueWatching": "Продължаване на гледането", "HeaderContinueWatching": "Продължаване на гледането",
"HeaderFavoriteAlbums": "Любими албуми",
"HeaderFavoriteArtists": "Любими изпълнители",
"HeaderFavoriteEpisodes": "Любими епизоди", "HeaderFavoriteEpisodes": "Любими епизоди",
"HeaderFavoriteShows": "Любими сериали", "HeaderFavoriteShows": "Любими сериали",
"HeaderFavoriteSongs": "Любими песни",
"HeaderLiveTV": "Телевизия на живо", "HeaderLiveTV": "Телевизия на живо",
"HeaderNextUp": "Следва", "HeaderNextUp": "Следва",
"HeaderRecordingGroups": "Запис групи",
"HomeVideos": "Домашни Клипове", "HomeVideos": "Домашни Клипове",
"Inherit": "Наследяване", "Inherit": "Наследяване",
"ItemAddedWithName": "{0} е добавено към библиотеката",
"ItemRemovedWithName": "{0} е премахнато от библиотеката",
"LabelIpAddressValue": "IP адрес: {0}", "LabelIpAddressValue": "IP адрес: {0}",
"LabelRunningTimeValue": "Продължителност: {0}", "LabelRunningTimeValue": "Продължителност: {0}",
"Latest": "Последни", "Latest": "Последни",
"MessageApplicationUpdated": "Сървърът беше обновен",
"MessageApplicationUpdatedTo": "Сървърът беше обновен до {0}",
"MessageNamedServerConfigurationUpdatedWithValue": "Секцията {0} от сървърната конфигурация беше актуализирана",
"MessageServerConfigurationUpdated": "Конфигурацията на сървъра беше актуализирана",
"MixedContent": "Смесено съдържание", "MixedContent": "Смесено съдържание",
"Movies": "Филми", "Movies": "Филми",
"Music": "Музика", "Music": "Музика",
@@ -61,24 +44,14 @@
"NotificationOptionVideoPlayback": "Възпроизвеждането на видео започна", "NotificationOptionVideoPlayback": "Възпроизвеждането на видео започна",
"NotificationOptionVideoPlaybackStopped": "Възпроизвеждането на видео е спряно", "NotificationOptionVideoPlaybackStopped": "Възпроизвеждането на видео е спряно",
"Photos": "Снимки", "Photos": "Снимки",
"Playlists": "Списъци",
"Plugin": "Добавка",
"PluginInstalledWithName": "{0} е инсталиранa", "PluginInstalledWithName": "{0} е инсталиранa",
"PluginUninstalledWithName": "{0} е деинсталиранa", "PluginUninstalledWithName": "{0} е деинсталиранa",
"PluginUpdatedWithName": "{0} е обновенa", "PluginUpdatedWithName": "{0} е обновенa",
"ProviderValue": "Доставчик: {0}",
"ScheduledTaskFailedWithName": "{0} се провали", "ScheduledTaskFailedWithName": "{0} се провали",
"ScheduledTaskStartedWithName": "{0} започна",
"ServerNameNeedsToBeRestarted": "{0} трябва да се рестартира",
"Shows": "Сериали", "Shows": "Сериали",
"Songs": "Песни",
"StartupEmbyServerIsLoading": "Сървърът зарежда. Моля, опитайте отново след малко.", "StartupEmbyServerIsLoading": "Сървърът зарежда. Моля, опитайте отново след малко.",
"SubtitleDownloadFailureForItem": "Неуспешно изтегляне на субтитри за {0}",
"SubtitleDownloadFailureFromForItem": "Субтитрите за {1} от {0} не можаха да бъдат изтеглени", "SubtitleDownloadFailureFromForItem": "Субтитрите за {1} от {0} не можаха да бъдат изтеглени",
"Sync": "Синхронизиране",
"System": "Система",
"TvShows": "Телевизионни сериали", "TvShows": "Телевизионни сериали",
"User": "Потребител",
"UserCreatedWithName": "Потребителят {0} е създаден", "UserCreatedWithName": "Потребителят {0} е създаден",
"UserDeletedWithName": "Потребителят {0} е изтрит", "UserDeletedWithName": "Потребителят {0} е изтрит",
"UserDownloadingItemWithValues": "{0} изтегля {1}", "UserDownloadingItemWithValues": "{0} изтегля {1}",
@@ -86,11 +59,8 @@
"UserOfflineFromDevice": "{0} се разкачи от {1}", "UserOfflineFromDevice": "{0} се разкачи от {1}",
"UserOnlineFromDevice": "{0} е на линия от {1}", "UserOnlineFromDevice": "{0} е на линия от {1}",
"UserPasswordChangedWithName": "Паролата на потребителя {0} е променена", "UserPasswordChangedWithName": "Паролата на потребителя {0} е променена",
"UserPolicyUpdatedWithName": "Потребителската политика за {0} се актуализира",
"UserStartedPlayingItemWithValues": "{0} пусна {1}", "UserStartedPlayingItemWithValues": "{0} пусна {1}",
"UserStoppedPlayingItemWithValues": "{0} спря {1}", "UserStoppedPlayingItemWithValues": "{0} спря {1}",
"ValueHasBeenAddedToLibrary": "{0} беше добавен във Вашата библиотека",
"ValueSpecialEpisodeName": "Специални - {0}",
"VersionNumber": "Версия {0}", "VersionNumber": "Версия {0}",
"TaskDownloadMissingSubtitlesDescription": "Търси Интернет за липсващи субтитри, на база конфигурацията за мета-данни.", "TaskDownloadMissingSubtitlesDescription": "Търси Интернет за липсващи субтитри, на база конфигурацията за мета-данни.",
"TaskDownloadMissingSubtitles": "Изтегляне на липсващи субтитри", "TaskDownloadMissingSubtitles": "Изтегляне на липсващи субтитри",
@@ -129,8 +99,6 @@
"TaskRefreshTrickplayImagesDescription": "Създава прегледи на Trickplay за видеа в активирани библиотеки.", "TaskRefreshTrickplayImagesDescription": "Създава прегледи на Trickplay за видеа в активирани библиотеки.",
"TaskDownloadMissingLyrics": "Свали липсващи текстове", "TaskDownloadMissingLyrics": "Свали липсващи текстове",
"TaskDownloadMissingLyricsDescription": "Свали текстове за песни", "TaskDownloadMissingLyricsDescription": "Свали текстове за песни",
"TaskCleanCollectionsAndPlaylists": "Изчисти колекциите и плейлистите",
"TaskCleanCollectionsAndPlaylistsDescription": "Премахни несъществуващи файлове в колекциите и плейлистите.",
"TaskAudioNormalization": "Нормализиране на звука", "TaskAudioNormalization": "Нормализиране на звука",
"TaskAudioNormalizationDescription": "Сканирай файловете за нормализация на звука.", "TaskAudioNormalizationDescription": "Сканирай файловете за нормализация на звука.",
"TaskExtractMediaSegmentsDescription": "Изважда медиини сегменти от MediaSegment плъгини.", "TaskExtractMediaSegmentsDescription": "Изважда медиини сегменти от MediaSegment плъгини.",
@@ -138,5 +106,20 @@
"TaskMoveTrickplayImagesDescription": "Премества съществуващите trickplay изображения спрямо настройките на библиотеката.", "TaskMoveTrickplayImagesDescription": "Премества съществуващите trickplay изображения спрямо настройките на библиотеката.",
"TaskExtractMediaSegments": "Сканиране за сегменти", "TaskExtractMediaSegments": "Сканиране за сегменти",
"CleanupUserDataTask": "Задача за почистване на потребителски данни", "CleanupUserDataTask": "Задача за почистване на потребителски данни",
"CleanupUserDataTaskDescription": "Почиства всички потребителски данни (статус на гледане, любими и т.н.) от медия, която вече не е налична от поне 90 дни." "CleanupUserDataTaskDescription": "Почиства всички потребителски данни (статус на гледане, любими и т.н.) от медия, която вече не е налична от поне 90 дни.",
"LyricDownloadFailureFromForItem": "Текстът на песента не успя да се изтегли от {0} за {1}",
"NameExtraBehindTheScenes": "Зад кулисите",
"NameExtraScene": "Сцена",
"NameExtraShort": "Откъс",
"NameExtraThemeVideo": "Тематично видео",
"NameExtraTrailer": "Трейлър",
"NameExtraUnknown": "Екстра",
"NameExtraClip": "Клип",
"NameExtraDeletedScene": "Изтрита Сцена",
"NameExtraFeaturette": "Кратък филм",
"NameExtraInterview": "Интервю",
"NameExtraNumbered": "{0} {1}",
"NameExtraSample": "Пример",
"NameExtraThemeSong": "Тема-песен",
"Original": "Оригинал"
} }
@@ -1,31 +1,19 @@
{ {
"DeviceOnlineWithName": "{0}-এর সাথে সংযুক্ত হয়েছে",
"DeviceOfflineWithName": "{0}-এর সাথে সংযোগ বিচ্ছিন্ন হয়েছে",
"Collections": "সংগ্রহশালা", "Collections": "সংগ্রহশালা",
"ChapterNameValue": "অধ্যায় {0}", "ChapterNameValue": "অধ্যায় {0}",
"Channels": "চ্যানেলসমূহ",
"CameraImageUploadedFrom": "{0} থেকে একটি নতুন ক্যামেরার চিত্র আপলোড করা হয়েছে",
"Books": "পুস্তকসমূহ", "Books": "পুস্তকসমূহ",
"AuthenticationSucceededWithUserName": "{0} সফলভাবে অথেন্টিকেট করেছেন", "AuthenticationSucceededWithUserName": "{0} সফলভাবে অথেন্টিকেট করেছেন",
"Artists": "শিল্পীগণ", "Artists": "শিল্পীগণ",
"Application": "অ্যাপ্লিকেশন",
"Albums": "অ্যালবামসমূহ",
"HeaderFavoriteEpisodes": "প্রিয় পর্বগুলো", "HeaderFavoriteEpisodes": "প্রিয় পর্বগুলো",
"HeaderFavoriteArtists": "প্রিয় শিল্পীরা",
"HeaderFavoriteAlbums": "প্রিয় এলবামগুলো",
"HeaderContinueWatching": "দেখতে থাকুন", "HeaderContinueWatching": "দেখতে থাকুন",
"HeaderAlbumArtists": "অ্যালবাম শিল্পীবৃন্দ",
"Genres": "ধরণ", "Genres": "ধরণ",
"Folders": "ফোল্ডারসমূহ", "Folders": "ফোল্ডারসমূহ",
"Favorites": "পছন্দসমূহ", "Favorites": "পছন্দসমূহ",
"FailedLoginAttemptWithUserName": "{0} লগিন করতে ব্যর্থ হয়েছে", "FailedLoginAttemptWithUserName": "{0} লগিন করতে ব্যর্থ হয়েছে",
"AppDeviceValues": "অ্যাপ: {0}, ডিভাইস: {1}", "AppDeviceValues": "অ্যাপ: {0}, ডিভাইস: {1}",
"VersionNumber": "সংস্করণ {0}", "VersionNumber": "সংস্করণ {0}",
"ValueSpecialEpisodeName": "বিশেষ পর্ব - {0}",
"ValueHasBeenAddedToLibrary": "আপনার লাইব্রেরিতে {0} যোগ করা হয়েছে",
"UserStoppedPlayingItemWithValues": "{2}তে {1} প্লে শেষ করেছেন {0}", "UserStoppedPlayingItemWithValues": "{2}তে {1} প্লে শেষ করেছেন {0}",
"UserStartedPlayingItemWithValues": "{2}তে {1} প্লে করেছেন {0}", "UserStartedPlayingItemWithValues": "{2}তে {1} প্লে করেছেন {0}",
"UserPolicyUpdatedWithName": "{0} এর জন্য ব্যবহার নীতি আপডেট করা হয়েছে",
"UserPasswordChangedWithName": "ব্যবহারকারী {0} এর পাসওয়ার্ড পরিবর্তিত হয়েছে", "UserPasswordChangedWithName": "ব্যবহারকারী {0} এর পাসওয়ার্ড পরিবর্তিত হয়েছে",
"UserOnlineFromDevice": "{0}, {1} থেকে অনলাইন আছে", "UserOnlineFromDevice": "{0}, {1} থেকে অনলাইন আছে",
"UserOfflineFromDevice": "{0} {1} থেকে বিচ্ছিন্ন হয়ে গেছে", "UserOfflineFromDevice": "{0} {1} থেকে বিচ্ছিন্ন হয়ে গেছে",
@@ -33,23 +21,14 @@
"UserDownloadingItemWithValues": "{0}, {1} ডাউনলোড করছে", "UserDownloadingItemWithValues": "{0}, {1} ডাউনলোড করছে",
"UserDeletedWithName": "ব্যবহারকারী {0}কে বাদ দেয়া হয়েছে", "UserDeletedWithName": "ব্যবহারকারী {0}কে বাদ দেয়া হয়েছে",
"UserCreatedWithName": "ব্যবহারকারী {0} সৃষ্টি করা হয়েছে", "UserCreatedWithName": "ব্যবহারকারী {0} সৃষ্টি করা হয়েছে",
"User": "ব্যবহারকারী",
"TvShows": "টিভি শোগুলো", "TvShows": "টিভি শোগুলো",
"System": "সিস্টেম",
"Sync": "সমন্বয় করুন",
"SubtitleDownloadFailureFromForItem": "{0} থেকে {1} এর জন্য সাবটাইটেল ডাউনলোড ব্যর্থ হয়েছে", "SubtitleDownloadFailureFromForItem": "{0} থেকে {1} এর জন্য সাবটাইটেল ডাউনলোড ব্যর্থ হয়েছে",
"StartupEmbyServerIsLoading": "জেলিফিন সার্ভার লোড হচ্ছে। দয়া করে একটু পরে আবার চেষ্টা করুন।", "StartupEmbyServerIsLoading": "জেলিফিন সার্ভার লোড হচ্ছে। দয়া করে একটু পরে আবার চেষ্টা করুন।",
"Songs": "সঙ্গীত সমূহ",
"Shows": "শো সমূহ", "Shows": "শো সমূহ",
"ServerNameNeedsToBeRestarted": "{0} রিস্টার্ট করা প্রয়োজন",
"ScheduledTaskStartedWithName": "{0} শুরু হয়েছে",
"ScheduledTaskFailedWithName": "{0} ব্যর্থ", "ScheduledTaskFailedWithName": "{0} ব্যর্থ",
"ProviderValue": "প্রদানকারী: {0}",
"PluginUpdatedWithName": "{0} আপডেট করা হয়েছে", "PluginUpdatedWithName": "{0} আপডেট করা হয়েছে",
"PluginUninstalledWithName": "{0} আনইন্সটল হয়েছে", "PluginUninstalledWithName": "{0} আনইন্সটল হয়েছে",
"PluginInstalledWithName": "{0} ইন্সটল হয়েছে", "PluginInstalledWithName": "{0} ইন্সটল হয়েছে",
"Plugin": "প্লাগিন",
"Playlists": "প্লে লিস্ট সমূহ",
"Photos": "ছবিসমূহ", "Photos": "ছবিসমূহ",
"NotificationOptionVideoPlaybackStopped": "ভিডিও প্লেব্যাক বন্ধ হয়েছে", "NotificationOptionVideoPlaybackStopped": "ভিডিও প্লেব্যাক বন্ধ হয়েছে",
"NotificationOptionVideoPlayback": "ভিডিও প্লেব্যাক শুরু হয়েছে", "NotificationOptionVideoPlayback": "ভিডিও প্লেব্যাক শুরু হয়েছে",
@@ -75,21 +54,13 @@
"Music": "গান", "Music": "গান",
"Movies": "চলচ্চিত্রসমূহ", "Movies": "চলচ্চিত্রসমূহ",
"MixedContent": "মিশ্র কন্টেন্ট", "MixedContent": "মিশ্র কন্টেন্ট",
"MessageServerConfigurationUpdated": "সার্ভারের কনফিগারেশন আপডেট করা হয়েছে",
"HeaderRecordingGroups": "রেকর্ডিং গ্রুপগুলো",
"MessageNamedServerConfigurationUpdatedWithValue": "সার্ভার কনফিগারেশন সেকশন {0} আপডেট করা হয়েছে",
"MessageApplicationUpdatedTo": "জেলিফিন সার্ভার {0} তে আপডেট করা হয়েছে",
"MessageApplicationUpdated": "জেলিফিন সার্ভার আপডেট করা হয়েছে",
"Latest": "সর্বশেষ", "Latest": "সর্বশেষ",
"LabelRunningTimeValue": "চলার সময়: {0}", "LabelRunningTimeValue": "চলার সময়: {0}",
"LabelIpAddressValue": "আইপি এড্রেস: {0}", "LabelIpAddressValue": "আইপি এড্রেস: {0}",
"ItemRemovedWithName": "{0} লাইব্রেরি থেকে বাদ দেয়া হয়েছে",
"ItemAddedWithName": "{0} লাইব্রেরিতে যোগ করা হয়েছে",
"Inherit": "উত্তরাধিকারসূত্র থেকে গ্রহণ করুন", "Inherit": "উত্তরাধিকারসূত্র থেকে গ্রহণ করুন",
"HomeVideos": "হোম ভিডিও", "HomeVideos": "হোম ভিডিও",
"HeaderNextUp": "এরপরে আসছে", "HeaderNextUp": "এরপরে আসছে",
"HeaderLiveTV": "লাইভ টিভি", "HeaderLiveTV": "লাইভ টিভি",
"HeaderFavoriteSongs": "প্রিয় গানগুলো",
"HeaderFavoriteShows": "প্রিয় শোগুলো", "HeaderFavoriteShows": "প্রিয় শোগুলো",
"TasksLibraryCategory": "লাইব্রেরি", "TasksLibraryCategory": "লাইব্রেরি",
"TasksMaintenanceCategory": "রক্ষণাবেক্ষণ", "TasksMaintenanceCategory": "রক্ষণাবেক্ষণ",
@@ -127,8 +98,6 @@
"TaskRefreshTrickplayImages": "ট্রিকপ্লে ইমেজ তৈরি", "TaskRefreshTrickplayImages": "ট্রিকপ্লে ইমেজ তৈরি",
"TaskRefreshTrickplayImagesDescription": "সক্ষম লাইব্রেরিতে ভিডিওর জন্য ট্রিকপ্লে প্রিভিউ তৈরি করে।", "TaskRefreshTrickplayImagesDescription": "সক্ষম লাইব্রেরিতে ভিডিওর জন্য ট্রিকপ্লে প্রিভিউ তৈরি করে।",
"TaskDownloadMissingLyricsDescription": "গানের জন্য লিরিকস ডাউনলোড করুন", "TaskDownloadMissingLyricsDescription": "গানের জন্য লিরিকস ডাউনলোড করুন",
"TaskCleanCollectionsAndPlaylists": "কালেকশন এবং প্লেলিস্ট পরিষ্কার করুন",
"TaskCleanCollectionsAndPlaylistsDescription": "কালেকশন এবং প্লেলিস্ট থেকে আইটেমগুলি সরিয়ে দেয় যা আর বিদ্যমান নেই।",
"TaskExtractMediaSegments": "মিডিয়া সেগমেন্ট স্ক্যান", "TaskExtractMediaSegments": "মিডিয়া সেগমেন্ট স্ক্যান",
"TaskExtractMediaSegmentsDescription": "মিডিয়া সেগমেন্ট সক্ষম প্লাগইনগুলি থেকে মিডিয়া সেগমেন্ট বের করে বা অর্জন করে।", "TaskExtractMediaSegmentsDescription": "মিডিয়া সেগমেন্ট সক্ষম প্লাগইনগুলি থেকে মিডিয়া সেগমেন্ট বের করে বা অর্জন করে।",
"TaskDownloadMissingLyrics": "অনুপস্থিত গান ডাউনলোড করুন", "TaskDownloadMissingLyrics": "অনুপস্থিত গান ডাউনলোড করুন",
@@ -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"
}
@@ -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"
}
@@ -1,41 +1,24 @@
{ {
"Albums": "Àlbums",
"AppDeviceValues": "Aplicació: {0}, Dispositiu: {1}", "AppDeviceValues": "Aplicació: {0}, Dispositiu: {1}",
"Application": "Aplicació",
"Artists": "Artistes", "Artists": "Artistes",
"AuthenticationSucceededWithUserName": "{0} s'ha autenticat correctament", "AuthenticationSucceededWithUserName": "{0} s'ha autenticat correctament",
"Books": "Llibres", "Books": "Llibres",
"CameraImageUploadedFrom": "S'ha pujat una nova imatge de càmera des de {0}",
"Channels": "Canals",
"ChapterNameValue": "Capítol {0}", "ChapterNameValue": "Capítol {0}",
"Collections": "Col·leccions", "Collections": "Col·leccions",
"DeviceOfflineWithName": "{0} s'ha desconnectat",
"DeviceOnlineWithName": "{0} està connectat",
"FailedLoginAttemptWithUserName": "Intent de connexió fallit des de {0}", "FailedLoginAttemptWithUserName": "Intent de connexió fallit des de {0}",
"Favorites": "Preferits", "Favorites": "Preferits",
"Folders": "Directoris", "Folders": "Directoris",
"Genres": "Gèneres", "Genres": "Gèneres",
"HeaderAlbumArtists": "Artistes de l'àlbum",
"HeaderContinueWatching": "Continueu mirant", "HeaderContinueWatching": "Continueu mirant",
"HeaderFavoriteAlbums": "Àlbums preferits",
"HeaderFavoriteArtists": "Artistes preferits",
"HeaderFavoriteEpisodes": "Episodis preferits", "HeaderFavoriteEpisodes": "Episodis preferits",
"HeaderFavoriteShows": "Sèries preferides", "HeaderFavoriteShows": "Sèries preferides",
"HeaderFavoriteSongs": "Cançons preferides",
"HeaderLiveTV": "TV en directe", "HeaderLiveTV": "TV en directe",
"HeaderNextUp": "A continuació", "HeaderNextUp": "A continuació",
"HeaderRecordingGroups": "Grups musicals",
"HomeVideos": "Vídeos domèstics", "HomeVideos": "Vídeos domèstics",
"Inherit": "Heretat", "Inherit": "Heretat",
"ItemAddedWithName": "{0} s'ha afegit a la mediateca",
"ItemRemovedWithName": "{0} s'ha eliminat de la mediateca",
"LabelIpAddressValue": "Adreça IP: {0}", "LabelIpAddressValue": "Adreça IP: {0}",
"LabelRunningTimeValue": "Temps en marxa: {0}", "LabelRunningTimeValue": "Temps en marxa: {0}",
"Latest": "Darrers", "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", "MixedContent": "Contingut barrejat",
"Movies": "Pel·lícules", "Movies": "Pel·lícules",
"Music": "Música", "Music": "Música",
@@ -61,24 +44,14 @@
"NotificationOptionVideoPlayback": "Reproducció de vídeo iniciada", "NotificationOptionVideoPlayback": "Reproducció de vídeo iniciada",
"NotificationOptionVideoPlaybackStopped": "Reproducció de vídeo aturada", "NotificationOptionVideoPlaybackStopped": "Reproducció de vídeo aturada",
"Photos": "Fotos", "Photos": "Fotos",
"Playlists": "Llistes de reproducció", "PluginInstalledWithName": "S'ha instal·lat {0}",
"Plugin": "Complement", "PluginUninstalledWithName": "S'ha desinstal·lat {0}",
"PluginInstalledWithName": "{0} ha estat instal·lat",
"PluginUninstalledWithName": "S'ha instal·lat {0}",
"PluginUpdatedWithName": "S'ha actualitzat {0}", "PluginUpdatedWithName": "S'ha actualitzat {0}",
"ProviderValue": "Proveïdor: {0}",
"ScheduledTaskFailedWithName": "{0} ha fallat", "ScheduledTaskFailedWithName": "{0} ha fallat",
"ScheduledTaskStartedWithName": "S'ha iniciat {0}",
"ServerNameNeedsToBeRestarted": "S'ha de reiniciar {0}",
"Shows": "Sèries", "Shows": "Sèries",
"Songs": "Cançons",
"StartupEmbyServerIsLoading": "El servidor de Jellyfin s'està carregant. Proveu-ho de nou en una estona.", "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}", "SubtitleDownloadFailureFromForItem": "Els subtítols per a {1} no s'han pogut baixar de {0}",
"Sync": "Sincronitza",
"System": "Sistema",
"TvShows": "Sèries de TV", "TvShows": "Sèries de TV",
"User": "Usuari",
"UserCreatedWithName": "S'ha creat l'usuari {0}", "UserCreatedWithName": "S'ha creat l'usuari {0}",
"UserDeletedWithName": "S'ha eliminat l'usuari {0}", "UserDeletedWithName": "S'ha eliminat l'usuari {0}",
"UserDownloadingItemWithValues": "{0} està descarregant {1}", "UserDownloadingItemWithValues": "{0} està descarregant {1}",
@@ -86,11 +59,8 @@
"UserOfflineFromDevice": "{0} s'ha desconnectat de {1}", "UserOfflineFromDevice": "{0} s'ha desconnectat de {1}",
"UserOnlineFromDevice": "{0} està connectat des de {1}", "UserOnlineFromDevice": "{0} està connectat des de {1}",
"UserPasswordChangedWithName": "S'ha canviat la contrasenya per a l'usuari {0}", "UserPasswordChangedWithName": "S'ha canviat la contrasenya per a l'usuari {0}",
"UserPolicyUpdatedWithName": "La política d'usuari s'ha actualitzat per a {0}",
"UserStartedPlayingItemWithValues": "{0} ha començat a reproduir {1} a {2}", "UserStartedPlayingItemWithValues": "{0} ha començat a reproduir {1} a {2}",
"UserStoppedPlayingItemWithValues": "{0} ha parat de 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}", "VersionNumber": "Versió {0}",
"TaskDownloadMissingSubtitlesDescription": "Cerca a internet els subtítols que faltin a partir de la configuració de metadades.", "TaskDownloadMissingSubtitlesDescription": "Cerca a internet els subtítols que faltin a partir de la configuració de metadades.",
"TaskDownloadMissingSubtitles": "Descàrrega dels subtítols que faltin", "TaskDownloadMissingSubtitles": "Descàrrega dels subtítols que faltin",
@@ -105,7 +75,7 @@
"TaskCleanLogsDescription": "Esborra els registres que tinguin més de {0} dies.", "TaskCleanLogsDescription": "Esborra els registres que tinguin més de {0} dies.",
"TaskCleanLogs": "Neteja dels registres", "TaskCleanLogs": "Neteja dels registres",
"TaskRefreshLibraryDescription": "Escaneja les mediateques, a la cerca de fitxers nous i refresca les metadades.", "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.", "TaskRefreshChapterImagesDescription": "Creació de les miniatures dels vídeos que tinguin capítols.",
"TaskRefreshChapterImages": "Extracció de les imatges dels capítols", "TaskRefreshChapterImages": "Extracció de les imatges dels capítols",
"TaskCleanCacheDescription": "Eliminació de la memòria cau no necessària per al servidor.", "TaskCleanCacheDescription": "Eliminació de la memòria cau no necessària per al servidor.",
@@ -127,8 +97,6 @@
"HearingImpaired": "Discapacitat auditiva", "HearingImpaired": "Discapacitat auditiva",
"TaskRefreshTrickplayImages": "Generació d'imatges de previsualització", "TaskRefreshTrickplayImages": "Generació d'imatges de previsualització",
"TaskRefreshTrickplayImagesDescription": "Creació d'imatges de previsualització per a vídeos en les mediateques habilitades.", "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", "TaskAudioNormalization": "Estabilització de l'àudio",
"TaskAudioNormalizationDescription": "Escaneja els fitxer per a obtenir dades de normalització 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", "TaskDownloadMissingLyricsDescription": "Descàrrega de les lletres de les cançons",
@@ -138,5 +106,20 @@
"TaskMoveTrickplayImages": "Migració de la ubicació de la imatge de previsualització", "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.", "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.", "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"
} }
@@ -1,44 +1,30 @@
{ {
"ChapterNameValue": "Didanedi {0}", "ChapterNameValue": "Didanedi {0}",
"HeaderAlbumArtists": "Didanidanolisgisgi",
"HeaderFavoriteAlbums": "Dvganidi didanidisgisgi",
"HeaderLiveTV": "Anigadi didanidisgosgi", "HeaderLiveTV": "Anigadi didanidisgosgi",
"HeaderRecordingGroups": "Didanisquodiisgisgi",
"HomeVideos": "Diganadi dinagadisgisgi", "HomeVideos": "Diganadi dinagadisgisgi",
"Inherit": "Anigwe", "Inherit": "Anigwe",
"MessageApplicationUpdatedTo": "Tsenigwidinonvhi Jellyfin Server tsadanidigwe anigadi {0}",
"MixedContent": "Ganinidi dininoladisgisgi", "MixedContent": "Ganinidi dininoladisgisgi",
"Movies": "Anidvnisgisgi", "Movies": "Anidvnisgisgi",
"MusicVideos": "Danodisgisgi didanidisgosgi", "MusicVideos": "Danodisgisgi didanidisgosgi",
"NotificationOptionAudioPlayback": "Didanidigwe diganuyisgisgi anigadi", "NotificationOptionAudioPlayback": "Didanidigwe diganuyisgisgi anigadi",
"NotificationOptionInstallationFailed": "Diudvdi anadvnatisgisgi", "NotificationOptionInstallationFailed": "Diudvdi anadvnatisgisgi",
"NotificationOptionPluginUninstalled": "Ditsigvhnidv anawvdisgisgi", "NotificationOptionPluginUninstalled": "Ditsigvhnidv anawvdisgisgi",
"Albums": "Anigawidaniyv",
"Application": "Didanvyi",
"Artists": "Dinidaniyi", "Artists": "Dinidaniyi",
"AuthenticationSucceededWithUserName": "{0} Sesoquonisdi nagadani", "AuthenticationSucceededWithUserName": "{0} Sesoquonisdi nagadani",
"Books": "Didanedi", "Books": "Didanedi",
"CameraImageUploadedFrom": "Anigawidaniyv nasgi didagwalanvyi {0}",
"Channels": "Diganadasgi",
"Collections": "Diganadisgi", "Collections": "Diganadisgi",
"Default": "Dinadi", "Default": "Dinadi",
"DeviceOfflineWithName": "{0} Aniyvolehvi nasgi",
"External": "Amohdi", "External": "Amohdi",
"Favorites": "Nvdayelvdisgi", "Favorites": "Nvdayelvdisgi",
"Folders": "Didanididisgi", "Folders": "Didanididisgi",
"Forced": "Ganedi", "Forced": "Ganedi",
"Genres": "Diganadisgi", "Genres": "Diganadisgi",
"HeaderContinueWatching": "Uwoditsu asdanidisgisgi", "HeaderContinueWatching": "Uwoditsu asdanidisgisgi",
"HeaderFavoriteArtists": "Dvganidi dinidanolisgisgi",
"HeaderFavoriteEpisodes": "Dvganidi didanidilisgadisgisgi", "HeaderFavoriteEpisodes": "Dvganidi didanidilisgadisgisgi",
"HeaderFavoriteShows": "Dvganidi didanididanolisgisgi)", "HeaderFavoriteShows": "Dvganidi didanididanolisgisgi)",
"HeaderFavoriteSongs": "Dvganidi danodisgisgi",
"HeaderNextUp": "Anidvli uwodoli", "HeaderNextUp": "Anidvli uwodoli",
"HearingImpaired": "Anitsunidi talunidisgisgi", "HearingImpaired": "Anitsunidi talunidisgisgi",
"ItemAddedWithName": "{0} Dinigwe anididanidisgi",
"Latest": "Uwodoli", "Latest": "Uwodoli",
"MessageApplicationUpdated": "Tsenigwidinonvhi Jellyfin Server tsadanidigwe",
"MessageServerConfigurationUpdated": "Sedanidvdi anigadi diganidinonvhi",
"Music": "Danodisgisgi", "Music": "Danodisgisgi",
"NameSeasonUnknown": "Tsunita anidvdisgi", "NameSeasonUnknown": "Tsunita anidvdisgi",
"NewVersionIsAvailable": "Danodigwe anigadi Jellyfin Server tsadanidigwe adisdi uwodvdi diganidinonvhi.", "NewVersionIsAvailable": "Danodigwe anigadi Jellyfin Server tsadanidigwe adisdi uwodvdi diganidinonvhi.",
@@ -1,41 +1,24 @@
{ {
"Albums": "Alba",
"AppDeviceValues": "Aplikace: {0}, Zařízení: {1}", "AppDeviceValues": "Aplikace: {0}, Zařízení: {1}",
"Application": "Aplikace",
"Artists": "Umělci", "Artists": "Umělci",
"AuthenticationSucceededWithUserName": "{0} úspěšně ověřen", "AuthenticationSucceededWithUserName": "{0} úspěšně ověřen",
"Books": "Knihy", "Books": "Knihy",
"CameraImageUploadedFrom": "Z {0} byla nahrána nová fotografie z fotoaparátu",
"Channels": "Kanály",
"ChapterNameValue": "Kapitola {0}", "ChapterNameValue": "Kapitola {0}",
"Collections": "Kolekce", "Collections": "Kolekce",
"DeviceOfflineWithName": "{0} se odpojil",
"DeviceOnlineWithName": "{0} je připojen",
"FailedLoginAttemptWithUserName": "Neúspěšný pokus o přihlášení z {0}", "FailedLoginAttemptWithUserName": "Neúspěšný pokus o přihlášení z {0}",
"Favorites": "Oblíbené", "Favorites": "Oblíbené",
"Folders": "Složky", "Folders": "Složky",
"Genres": "Žánry", "Genres": "Žánry",
"HeaderAlbumArtists": "Umělci alba",
"HeaderContinueWatching": "Pokračovat ve sledování", "HeaderContinueWatching": "Pokračovat ve sledování",
"HeaderFavoriteAlbums": "Oblíbená alba",
"HeaderFavoriteArtists": "Oblíbení interpreti",
"HeaderFavoriteEpisodes": "Oblíbené epizody", "HeaderFavoriteEpisodes": "Oblíbené epizody",
"HeaderFavoriteShows": "Oblíbené seriály", "HeaderFavoriteShows": "Oblíbené seriály",
"HeaderFavoriteSongs": "Oblíbená hudba",
"HeaderLiveTV": "TV vysílání", "HeaderLiveTV": "TV vysílání",
"HeaderNextUp": "Další díly", "HeaderNextUp": "Další díly",
"HeaderRecordingGroups": "Skupiny nahrávek",
"HomeVideos": "Domácí videa", "HomeVideos": "Domácí videa",
"Inherit": "Zdědit", "Inherit": "Zdědit",
"ItemAddedWithName": "{0} byl přidán do knihovny",
"ItemRemovedWithName": "{0} byl odstraněn z knihovny",
"LabelIpAddressValue": "IP adresa: {0}", "LabelIpAddressValue": "IP adresa: {0}",
"LabelRunningTimeValue": "Délka média: {0}", "LabelRunningTimeValue": "Délka média: {0}",
"Latest": "Nejnovější", "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", "MixedContent": "Smíšený obsah",
"Movies": "Filmy", "Movies": "Filmy",
"Music": "Hudba", "Music": "Hudba",
@@ -61,24 +44,14 @@
"NotificationOptionVideoPlayback": "Přehrávání videa zahájeno", "NotificationOptionVideoPlayback": "Přehrávání videa zahájeno",
"NotificationOptionVideoPlaybackStopped": "Přehrávání videa ukončeno", "NotificationOptionVideoPlaybackStopped": "Přehrávání videa ukončeno",
"Photos": "Fotky", "Photos": "Fotky",
"Playlists": "Seznamy skladeb",
"Plugin": "Zásuvný modul",
"PluginInstalledWithName": "{0} byl nainstalován", "PluginInstalledWithName": "{0} byl nainstalován",
"PluginUninstalledWithName": "{0} byl odinstalován", "PluginUninstalledWithName": "{0} byl odinstalován",
"PluginUpdatedWithName": "{0} byl aktualizován", "PluginUpdatedWithName": "{0} byl aktualizován",
"ProviderValue": "Poskytl: {0}",
"ScheduledTaskFailedWithName": "{0} selhalo", "ScheduledTaskFailedWithName": "{0} selhalo",
"ScheduledTaskStartedWithName": "{0} zahájeno",
"ServerNameNeedsToBeRestarted": "{0} vyžaduje restart",
"Shows": "Seriály", "Shows": "Seriály",
"Songs": "Skladby",
"StartupEmbyServerIsLoading": "Jellyfin Server je spouštěn. Zkuste to prosím v brzké době znovu.", "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", "SubtitleDownloadFailureFromForItem": "Stažení titulků pro {1} z {0} selhalo",
"Sync": "Synchronizace",
"System": "Systém",
"TvShows": "Seriály", "TvShows": "Seriály",
"User": "Uživatel",
"UserCreatedWithName": "Uživatel {0} byl vytvořen", "UserCreatedWithName": "Uživatel {0} byl vytvořen",
"UserDeletedWithName": "Uživatel {0} byl smazán", "UserDeletedWithName": "Uživatel {0} byl smazán",
"UserDownloadingItemWithValues": "{0} stahuje {1}", "UserDownloadingItemWithValues": "{0} stahuje {1}",
@@ -86,11 +59,8 @@
"UserOfflineFromDevice": "{0} se odpojil ze zařízení {1}", "UserOfflineFromDevice": "{0} se odpojil ze zařízení {1}",
"UserOnlineFromDevice": "{0} se připojil ze zařízení {1}", "UserOnlineFromDevice": "{0} se připojil ze zařízení {1}",
"UserPasswordChangedWithName": "Provedena změna hesla pro uživatele {0}", "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}", "UserStartedPlayingItemWithValues": "{0} spustil přehrávání {1}",
"UserStoppedPlayingItemWithValues": "{0} zastavil 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}", "VersionNumber": "Verze {0}",
"TaskDownloadMissingSubtitlesDescription": "Vyhledá na internetu chybějící titulky na základě nastavení metadat.", "TaskDownloadMissingSubtitlesDescription": "Vyhledá na internetu chybějící titulky na základě nastavení metadat.",
"TaskDownloadMissingSubtitles": "Stáhnout chybějící titulky", "TaskDownloadMissingSubtitles": "Stáhnout chybějící titulky",
@@ -127,8 +97,6 @@
"HearingImpaired": "Sluchově postižení", "HearingImpaired": "Sluchově postižení",
"TaskRefreshTrickplayImages": "Generovat obrázky pro Trickplay", "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.", "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", "TaskAudioNormalization": "Normalizace zvuku",
"TaskAudioNormalizationDescription": "Skenovat soubory za účelem normalizace zvuku.", "TaskAudioNormalizationDescription": "Skenovat soubory za účelem normalizace zvuku.",
"TaskDownloadMissingLyrics": "Stáhnout chybějící texty k písni", "TaskDownloadMissingLyrics": "Stáhnout chybějící texty k písni",
@@ -138,5 +106,20 @@
"TaskMoveTrickplayImages": "Přesunout úložiště obrázků Trickplay", "TaskMoveTrickplayImages": "Přesunout úložiště obrázků Trickplay",
"TaskMoveTrickplayImagesDescription": "Přesune existující soubory Trickplay podle nastavení knihovny.", "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í.", "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"
} }
@@ -1,16 +1,11 @@
{ {
"DeviceOnlineWithName": "Mae {0} wedi'i gysylltu",
"DeviceOfflineWithName": "Mae {0} wedi datgysylltu",
"Default": "Diofyn", "Default": "Diofyn",
"Collections": "Casgliadau", "Collections": "Casgliadau",
"ChapterNameValue": "Pennod {0}", "ChapterNameValue": "Pennod {0}",
"Channels": "Sianeli",
"CameraImageUploadedFrom": "Mae delwedd camera newydd wedi'i lanlwytho o {0}",
"Books": "Llyfrau", "Books": "Llyfrau",
"AuthenticationSucceededWithUserName": "{0} wedi’i ddilysu’n llwyddiannus", "AuthenticationSucceededWithUserName": "{0} wedi’i ddilysu’n llwyddiannus",
"Artists": "Artistiaid", "Artists": "Crewyr",
"AppDeviceValues": "Ap: {0}, Dyfais: {1}", "AppDeviceValues": "Ap: {0}, Dyfais: {1}",
"Albums": "Albwmau",
"Genres": "Genres", "Genres": "Genres",
"Folders": "Ffolderi", "Folders": "Ffolderi",
"Favorites": "Ffefrynnau", "Favorites": "Ffefrynnau",
@@ -20,9 +15,7 @@
"TaskRefreshPeople": "Adnewyddu Pobl", "TaskRefreshPeople": "Adnewyddu Pobl",
"TasksChannelsCategory": "Sianeli Internet", "TasksChannelsCategory": "Sianeli Internet",
"VersionNumber": "Fersiwn {0}", "VersionNumber": "Fersiwn {0}",
"ScheduledTaskStartedWithName": "{0} wedi dechrau",
"ScheduledTaskFailedWithName": "{0} wedi methu", "ScheduledTaskFailedWithName": "{0} wedi methu",
"ProviderValue": "Darparwr: {0}",
"NotificationOptionInstallationFailed": "Fethu Gosod", "NotificationOptionInstallationFailed": "Fethu Gosod",
"NameSeasonUnknown": "Tymor Anhysbys", "NameSeasonUnknown": "Tymor Anhysbys",
"NameSeasonNumber": "Tymor {0}", "NameSeasonNumber": "Tymor {0}",
@@ -30,31 +23,20 @@
"MixedContent": "Cynnwys amrywiol", "MixedContent": "Cynnwys amrywiol",
"HomeVideos": "Genres", "HomeVideos": "Genres",
"HeaderNextUp": "Nesaf i Fyny", "HeaderNextUp": "Nesaf i Fyny",
"HeaderFavoriteArtists": "Ffefryn Artistiaid",
"HeaderFavoriteAlbums": "Ffefryn Albwmau",
"HeaderContinueWatching": "Parhewch i Wylio", "HeaderContinueWatching": "Parhewch i Wylio",
"TasksApplicationCategory": "Rhaglen", "TasksApplicationCategory": "Rhaglen",
"TasksLibraryCategory": "Llyfrgell", "TasksLibraryCategory": "Llyfrgell",
"TasksMaintenanceCategory": "Cynnal a Chadw", "TasksMaintenanceCategory": "Cynnal a Chadw",
"System": "System",
"Plugin": "Ategyn",
"Music": "Cerddoriaeth", "Music": "Cerddoriaeth",
"Latest": "Diweddaraf", "Latest": "Diweddaraf",
"Inherit": "Etifeddu", "Inherit": "Etifeddu",
"Forced": "Orfodi", "Forced": "Orfodi",
"Application": "Rhaglen",
"HeaderAlbumArtists": "Artistiaid albwm",
"Sync": "Cysoni",
"Songs": "Caneuon",
"Shows": "Rhaglenni", "Shows": "Rhaglenni",
"Playlists": "Rhestri Chwarae",
"Photos": "Lluniau", "Photos": "Lluniau",
"ValueSpecialEpisodeName": "Arbennig - {0}",
"Movies": "Ffilmiau", "Movies": "Ffilmiau",
"Undefined": "Heb ddiffiniad", "Undefined": "Heb ddiffiniad",
"TvShows": "Rhaglenni teledu", "TvShows": "Rhaglenni teledu",
"HeaderLiveTV": "Teledu Byw", "HeaderLiveTV": "Teledu Byw",
"User": "Defnyddiwr",
"TaskCleanLogsDescription": "Dileu ffeiliau log sy'n fwy na {0} diwrnod oed.", "TaskCleanLogsDescription": "Dileu ffeiliau log sy'n fwy na {0} diwrnod oed.",
"TaskCleanLogs": "Glanhau ffolder log", "TaskCleanLogs": "Glanhau ffolder log",
"TaskRefreshLibraryDescription": "Sganio'ch llyfrgell gyfryngau am ffeiliau newydd ac yn adnewyddu metaddata.", "TaskRefreshLibraryDescription": "Sganio'ch llyfrgell gyfryngau am ffeiliau newydd ac yn adnewyddu metaddata.",
@@ -65,13 +47,9 @@
"NotificationOptionPluginError": "Methodd ategyn", "NotificationOptionPluginError": "Methodd ategyn",
"NotificationOptionAudioPlaybackStopped": "Stopiwyd chwarae sain", "NotificationOptionAudioPlaybackStopped": "Stopiwyd chwarae sain",
"NotificationOptionAudioPlayback": "Dechreuwyd chwarae sain", "NotificationOptionAudioPlayback": "Dechreuwyd chwarae sain",
"MessageServerConfigurationUpdated": "Mae gosodiadau gweinydd wedi'i ddiweddaru", "FailedLoginAttemptWithUserName": "Cais mewngofnodi wedi methu o {0}",
"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",
"UserStoppedPlayingItemWithValues": "{0} wedi gorffen chwarae {1} ar {2}", "UserStoppedPlayingItemWithValues": "{0} wedi gorffen chwarae {1} ar {2}",
"UserStartedPlayingItemWithValues": "{0} yn 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}", "UserPasswordChangedWithName": "Cyfrinair wedi'i newid ar gyfer defnyddiwr {0}",
"UserOnlineFromDevice": "Mae {0} ar-lein o {1}", "UserOnlineFromDevice": "Mae {0} ar-lein o {1}",
"UserOfflineFromDevice": "Mae {0} wedi datgysylltu o {1}", "UserOfflineFromDevice": "Mae {0} wedi datgysylltu o {1}",
@@ -80,7 +58,6 @@
"UserDeletedWithName": "Defnyddiwr {0} wedi'i ddileu", "UserDeletedWithName": "Defnyddiwr {0} wedi'i ddileu",
"UserCreatedWithName": "Defnyddiwr {0} wedi'i greu", "UserCreatedWithName": "Defnyddiwr {0} wedi'i greu",
"StartupEmbyServerIsLoading": "Gweinydd Jellyfin yn llwytho. Triwch eto mewn ychydig.", "StartupEmbyServerIsLoading": "Gweinydd Jellyfin yn llwytho. Triwch eto mewn ychydig.",
"ServerNameNeedsToBeRestarted": "Mae angen ailddechrau {0}",
"PluginUpdatedWithName": "{0} wedi'i ddiweddaru", "PluginUpdatedWithName": "{0} wedi'i ddiweddaru",
"PluginUninstalledWithName": "{0} wedi'i ddadosod", "PluginUninstalledWithName": "{0} wedi'i ddadosod",
"PluginInstalledWithName": "{0} wedi'i osod", "PluginInstalledWithName": "{0} wedi'i osod",
@@ -98,13 +75,7 @@
"NotificationOptionApplicationUpdateAvailable": "Diweddariad ap ar gael", "NotificationOptionApplicationUpdateAvailable": "Diweddariad ap ar gael",
"NewVersionIsAvailable": "Mae fersiwn diweddarach o'r gweinydd Jellyfin ar gael.", "NewVersionIsAvailable": "Mae fersiwn diweddarach o'r gweinydd Jellyfin ar gael.",
"NameInstallFailed": "Gosodiad {0} wedi methu", "NameInstallFailed": "Gosodiad {0} wedi methu",
"MessageApplicationUpdatedTo": "Gweinydd Jellyfin wedi'i ddiweddaru i {0}",
"MessageApplicationUpdated": "Gweinydd Jellyfin wedi'i ddiweddaru",
"LabelIpAddressValue": "Cyfeiriad IP: {0}", "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", "HeaderFavoriteShows": "Ffefryn Shoeau",
"HeaderFavoriteEpisodes": "Ffefryn Rhaglenni", "HeaderFavoriteEpisodes": "Ffefryn Rhaglenni",
"TaskDownloadMissingSubtitlesDescription": "Chwilio'r rhyngrwyd am is-deitlau coll yn seiliedig ar gosodiadau metaddata.", "TaskDownloadMissingSubtitlesDescription": "Chwilio'r rhyngrwyd am is-deitlau coll yn seiliedig ar gosodiadau metaddata.",
@@ -123,5 +94,12 @@
"TaskRefreshChapterImages": "Echdynnu Lluniau Pennod", "TaskRefreshChapterImages": "Echdynnu Lluniau Pennod",
"TaskCleanCacheDescription": "Dileu ffeiliau cache nad oes eu hangen ar y system mwyach.", "TaskCleanCacheDescription": "Dileu ffeiliau cache nad oes eu hangen ar y system mwyach.",
"TaskCleanCache": "Gwaghau Ffolder Cache", "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"
} }

Some files were not shown because too many files have changed in this diff Show More