Merge branch 'master' into feature/DatabaseRefactor

pull/13589/head
JPVenson 4 weeks ago
commit 850f1c79f1

@ -3,7 +3,7 @@
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "9.0.2",
"version": "9.0.3",
"commands": [
"dotnet-ef"
]

@ -22,16 +22,16 @@ jobs:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup .NET
uses: actions/setup-dotnet@3951f0dfe7a07e2313ec93c75700083e2005cbab # v4.3.0
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
with:
dotnet-version: '9.0.x'
- name: Initialize CodeQL
uses: github/codeql-action/init@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3.28.10
uses: github/codeql-action/init@5f8171a638ada777af81d42b55959a643bb29017 # v3.28.12
with:
languages: ${{ matrix.language }}
queries: +security-extended
- name: Autobuild
uses: github/codeql-action/autobuild@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3.28.10
uses: github/codeql-action/autobuild@5f8171a638ada777af81d42b55959a643bb29017 # v3.28.12
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3.28.10
uses: github/codeql-action/analyze@5f8171a638ada777af81d42b55959a643bb29017 # v3.28.12

@ -17,7 +17,7 @@ jobs:
repository: ${{ github.event.pull_request.head.repo.full_name }}
- name: Setup .NET
uses: actions/setup-dotnet@3951f0dfe7a07e2313ec93c75700083e2005cbab # v4.3.0
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
with:
dotnet-version: '9.0.x'
@ -26,7 +26,7 @@ jobs:
dotnet build Jellyfin.Server -o ./out
- name: Upload Head
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: abi-head
retention-days: 14
@ -47,7 +47,7 @@ jobs:
fetch-depth: 0
- name: Setup .NET
uses: actions/setup-dotnet@3951f0dfe7a07e2313ec93c75700083e2005cbab # v4.3.0
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
with:
dotnet-version: '9.0.x'
@ -65,7 +65,7 @@ jobs:
dotnet build Jellyfin.Server -o ./out
- name: Upload Head
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: abi-base
retention-days: 14
@ -85,13 +85,13 @@ jobs:
steps:
- name: Download abi-head
uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9
uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1
with:
name: abi-head
path: abi-head
- name: Download abi-base
uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9
uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1
with:
name: abi-base
path: abi-base

@ -21,13 +21,13 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- name: Setup .NET
uses: actions/setup-dotnet@3951f0dfe7a07e2313ec93c75700083e2005cbab # v4.3.0
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
with:
dotnet-version: '9.0.x'
- name: Generate openapi.json
run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests"
- name: Upload openapi.json
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: openapi-head
retention-days: 14
@ -55,13 +55,13 @@ jobs:
ANCESTOR_REF=$(git merge-base upstream/${{ github.base_ref }} origin/$HEAD_REF)
git checkout --progress --force $ANCESTOR_REF
- name: Setup .NET
uses: actions/setup-dotnet@3951f0dfe7a07e2313ec93c75700083e2005cbab # v4.3.0
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
with:
dotnet-version: '9.0.x'
- name: Generate openapi.json
run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests"
- name: Upload openapi.json
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: openapi-base
retention-days: 14
@ -80,12 +80,12 @@ jobs:
- openapi-base
steps:
- name: Download openapi-head
uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9
uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1
with:
name: openapi-head
path: openapi-head
- name: Download openapi-base
uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9
uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1
with:
name: openapi-base
path: openapi-base
@ -158,7 +158,7 @@ jobs:
run: |-
echo "JELLYFIN_VERSION=$(date +'%Y%m%d%H%M%S')" >> $GITHUB_ENV
- name: Download openapi-head
uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9
uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1
with:
name: openapi-head
path: openapi-head
@ -172,7 +172,7 @@ jobs:
strip_components: 1
target: "/srv/incoming/openapi/unstable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}"
- name: Move openapi.json (unstable) into place
uses: appleboy/ssh-action@8faa84277b88b6cd1455986f459aa66cf72bc8a3 # v1.2.1
uses: appleboy/ssh-action@2ead5e36573f08b82fbfce1504f1a4b05a647c6f # v1.2.2
with:
host: "${{ secrets.REPO_HOST }}"
username: "${{ secrets.REPO_USER }}"
@ -220,7 +220,7 @@ jobs:
run: |-
echo "JELLYFIN_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
- name: Download openapi-head
uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9
uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1
with:
name: openapi-head
path: openapi-head
@ -234,7 +234,7 @@ jobs:
strip_components: 1
target: "/srv/incoming/openapi/stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}"
- name: Move openapi.json (stable) into place
uses: appleboy/ssh-action@8faa84277b88b6cd1455986f459aa66cf72bc8a3 # v1.2.1
uses: appleboy/ssh-action@2ead5e36573f08b82fbfce1504f1a4b05a647c6f # v1.2.2
with:
host: "${{ secrets.REPO_HOST }}"
username: "${{ secrets.REPO_USER }}"

@ -22,7 +22,7 @@ jobs:
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-dotnet@3951f0dfe7a07e2313ec93c75700083e2005cbab # v4.3.0
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
with:
dotnet-version: ${{ env.SDK_VERSION }}

@ -46,7 +46,7 @@ jobs:
- name: install python
uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5.4.0
with:
python-version: '3.12'
python-version: '3.13'
cache: 'pip'
- name: install python packages
run: pip install -r rename/requirements.txt

@ -16,7 +16,7 @@ jobs:
- name: install python
uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5.4.0
with:
python-version: '3.12'
python-version: '3.13'
cache: 'pip'
- name: install python packages
run: pip install -r main-repo-triage/requirements.txt

@ -22,31 +22,31 @@
<PackageVersion Include="IDisposableAnalyzers" Version="4.0.8" />
<PackageVersion Include="Jellyfin.XmlTv" Version="10.8.0" />
<PackageVersion Include="libse" Version="4.0.10" />
<PackageVersion Include="LrcParser" Version="2024.0728.2" />
<PackageVersion Include="LrcParser" Version="2025.228.1" />
<PackageVersion Include="MetaBrainz.MusicBrainz" Version="6.1.0" />
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="9.0.2" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.2" />
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="9.0.3" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.3" />
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.2" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.2" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.2" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.2" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.2" />
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.2" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="9.0.2" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.2" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.2" />
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.2" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="9.0.2" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.2" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.2" />
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.2" />
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="9.0.2" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.2" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="9.0.2" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.2" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.2" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.2" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.3" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.3" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.3" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.3" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.3" />
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.3" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="9.0.3" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.3" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.3" />
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.3" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="9.0.3" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.3" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.3" />
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.3" />
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="9.0.3" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.3" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="9.0.3" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.3" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.3" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.3" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageVersion Include="MimeTypes" Version="2.5.2" />
<PackageVersion Include="Moq" Version="4.18.4" />
@ -75,11 +75,11 @@
<PackageVersion Include="Swashbuckle.AspNetCore" Version="6.2.3" />
<PackageVersion Include="System.Globalization" Version="4.3.0" />
<PackageVersion Include="System.Linq.Async" Version="6.0.1" />
<PackageVersion Include="System.Text.Encoding.CodePages" Version="9.0.2" />
<PackageVersion Include="System.Text.Json" Version="9.0.2" />
<PackageVersion Include="System.Threading.Tasks.Dataflow" Version="9.0.2" />
<PackageVersion Include="System.Text.Encoding.CodePages" Version="9.0.3" />
<PackageVersion Include="System.Text.Json" Version="9.0.3" />
<PackageVersion Include="System.Threading.Tasks.Dataflow" Version="9.0.3" />
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
<PackageVersion Include="z440.atl.core" Version="6.17.0" />
<PackageVersion Include="z440.atl.core" Version="6.19.0" />
<PackageVersion Include="TMDbLib" Version="2.2.0" />
<PackageVersion Include="UTF.Unknown" Version="2.5.1" />
<PackageVersion Include="Xunit.Priority" Version="1.1.6" />

@ -1,43 +1,35 @@
using System;
using System.Globalization;
using System.IO;
using System.Text.RegularExpressions;
namespace Emby.Naming.TV
{
/// <summary>
/// Class to parse season paths.
/// </summary>
public static class SeasonPathParser
public static partial class SeasonPathParser
{
/// <summary>
/// A season folder must contain one of these somewhere in the name.
/// </summary>
private static readonly string[] _seasonFolderNames =
{
"season",
"sæson",
"temporada",
"saison",
"staffel",
"series",
"сезон",
"stagione"
};
private static readonly char[] _splitChars = ['.', '_', ' ', '-'];
[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>.*)$")]
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+)(?!\s*[Ee]\d+))(?<rightpart>.*)$")]
private static partial Regex ProcessPost();
/// <summary>
/// Attempts to parse season number from path.
/// </summary>
/// <param name="path">Path to season.</param>
/// <param name="parentPath">Folder name of the parent.</param>
/// <param name="supportSpecialAliases">Support special aliases when parsing.</param>
/// <param name="supportNumericSeasonFolders">Support numeric season folders when parsing.</param>
/// <returns>Returns <see cref="SeasonPathParserResult"/> object.</returns>
public static SeasonPathParserResult Parse(string path, bool supportSpecialAliases, bool supportNumericSeasonFolders)
public static SeasonPathParserResult Parse(string path, string? parentPath, bool supportSpecialAliases, bool supportNumericSeasonFolders)
{
var result = new SeasonPathParserResult();
var parentFolderName = parentPath is null ? null : new DirectoryInfo(parentPath).Name;
var (seasonNumber, isSeasonFolder) = GetSeasonNumberFromPath(path, supportSpecialAliases, supportNumericSeasonFolders);
var (seasonNumber, isSeasonFolder) = GetSeasonNumberFromPath(path, parentFolderName, supportSpecialAliases, supportNumericSeasonFolders);
result.SeasonNumber = seasonNumber;
@ -54,15 +46,24 @@ namespace Emby.Naming.TV
/// Gets the season number from path.
/// </summary>
/// <param name="path">The path.</param>
/// <param name="parentFolderName">The parent folder name.</param>
/// <param name="supportSpecialAliases">if set to <c>true</c> [support special aliases].</param>
/// <param name="supportNumericSeasonFolders">if set to <c>true</c> [support numeric season folders].</param>
/// <returns>System.Nullable{System.Int32}.</returns>
private static (int? SeasonNumber, bool IsSeasonFolder) GetSeasonNumberFromPath(
string path,
string? parentFolderName,
bool supportSpecialAliases,
bool supportNumericSeasonFolders)
{
string filename = Path.GetFileName(path);
filename = Regex.Replace(filename, "[ ._-]", string.Empty);
if (parentFolderName is not null)
{
parentFolderName = Regex.Replace(parentFolderName, "[ ._-]", string.Empty);
filename = filename.Replace(parentFolderName, string.Empty, StringComparison.OrdinalIgnoreCase);
}
if (supportSpecialAliases)
{
@ -85,53 +86,38 @@ namespace Emby.Naming.TV
}
}
if (TryGetSeasonNumberFromPart(filename, out int seasonNumber))
if (filename.StartsWith('s'))
{
return (seasonNumber, true);
}
var testFilename = filename.AsSpan()[1..];
// Look for one of the season folder names
foreach (var name in _seasonFolderNames)
{
if (filename.Contains(name, StringComparison.OrdinalIgnoreCase))
if (int.TryParse(testFilename, NumberStyles.Integer, CultureInfo.InvariantCulture, out var val))
{
var result = GetSeasonNumberFromPathSubstring(filename.Replace(name, " ", StringComparison.OrdinalIgnoreCase));
if (result.SeasonNumber.HasValue)
{
return result;
}
break;
return (val, true);
}
}
var parts = filename.Split(_splitChars, StringSplitOptions.RemoveEmptyEntries);
foreach (var part in parts)
var preMatch = ProcessPre().Match(filename);
if (preMatch.Success)
{
if (TryGetSeasonNumberFromPart(part, out seasonNumber))
{
return (seasonNumber, true);
}
return CheckMatch(preMatch);
}
return (null, true);
}
private static bool TryGetSeasonNumberFromPart(ReadOnlySpan<char> part, out int seasonNumber)
{
seasonNumber = 0;
if (part.Length < 2 || !part.StartsWith("s", StringComparison.OrdinalIgnoreCase))
else
{
return false;
var postMatch = ProcessPost().Match(filename);
return CheckMatch(postMatch);
}
}
if (int.TryParse(part.Slice(1), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value))
private static (int? SeasonNumber, bool IsSeasonFolder) CheckMatch(Match match)
{
var numberString = match.Groups["seasonnumber"];
if (numberString.Success)
{
seasonNumber = value;
return true;
var seasonNumber = int.Parse(numberString.Value, CultureInfo.InvariantCulture);
return (seasonNumber, true);
}
return false;
return (null, false);
}
/// <summary>

@ -34,76 +34,46 @@ namespace Emby.Server.Implementations.AppBase
DataPath = Directory.CreateDirectory(Path.Combine(ProgramDataPath, "data")).FullName;
}
/// <summary>
/// Gets the path to the program data folder.
/// </summary>
/// <value>The program data path.</value>
/// <inheritdoc/>
public string ProgramDataPath { get; }
/// <inheritdoc/>
public string WebPath { get; }
/// <summary>
/// Gets the path to the system folder.
/// </summary>
/// <value>The path to the system folder.</value>
/// <inheritdoc/>
public string ProgramSystemPath { get; } = AppContext.BaseDirectory;
/// <summary>
/// Gets the folder path to the data directory.
/// </summary>
/// <value>The data directory.</value>
/// <inheritdoc/>
public string DataPath { get; }
/// <inheritdoc />
public string VirtualDataPath => "%AppDataPath%";
/// <summary>
/// Gets the image cache path.
/// </summary>
/// <value>The image cache path.</value>
/// <inheritdoc/>
public string ImageCachePath => Path.Combine(CachePath, "images");
/// <summary>
/// Gets the path to the plugin directory.
/// </summary>
/// <value>The plugins path.</value>
/// <inheritdoc/>
public string PluginsPath => Path.Combine(ProgramDataPath, "plugins");
/// <summary>
/// Gets the path to the plugin configurations directory.
/// </summary>
/// <value>The plugin configurations path.</value>
/// <inheritdoc/>
public string PluginConfigurationsPath => Path.Combine(PluginsPath, "configurations");
/// <summary>
/// Gets the path to the log directory.
/// </summary>
/// <value>The log directory path.</value>
/// <inheritdoc/>
public string LogDirectoryPath { get; }
/// <summary>
/// Gets the path to the application configuration root directory.
/// </summary>
/// <value>The configuration directory path.</value>
/// <inheritdoc/>
public string ConfigurationDirectoryPath { get; }
/// <summary>
/// Gets the path to the system configuration file.
/// </summary>
/// <value>The system configuration file path.</value>
/// <inheritdoc/>
public string SystemConfigurationFilePath => Path.Combine(ConfigurationDirectoryPath, "system.xml");
/// <summary>
/// Gets or sets the folder path to the cache directory.
/// </summary>
/// <value>The cache directory.</value>
/// <inheritdoc/>
public string CachePath { get; set; }
/// <summary>
/// Gets the folder path to the temp directory within the cache folder.
/// </summary>
/// <value>The temp directory.</value>
/// <inheritdoc/>
public string TempDirectory => Path.Join(Path.GetTempPath(), "jellyfin");
/// <inheritdoc />
public string TrickplayPath => Path.Combine(DataPath, "trickplay");
}
}

@ -57,6 +57,7 @@ using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Drawing;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Controller.Lyrics;
@ -508,6 +509,7 @@ namespace Emby.Server.Implementations
serviceCollection.AddSingleton<IMediaEncoder, MediaBrowser.MediaEncoding.Encoder.MediaEncoder>();
serviceCollection.AddSingleton<EncodingHelper>();
serviceCollection.AddSingleton<IPathManager, PathManager>();
// TODO: Refactor to eliminate the circular dependencies here so that Lazy<T> isn't required
serviceCollection.AddTransient(provider => new Lazy<ILibraryMonitor>(provider.GetRequiredService<ILibraryMonitor>));

@ -5,80 +5,80 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Server.Implementations;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Trickplay;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.Data
namespace Emby.Server.Implementations.Data;
public class CleanDatabaseScheduledTask : ILibraryPostScanTask
{
public class CleanDatabaseScheduledTask : ILibraryPostScanTask
private readonly ILibraryManager _libraryManager;
private readonly ILogger<CleanDatabaseScheduledTask> _logger;
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
public CleanDatabaseScheduledTask(
ILibraryManager libraryManager,
ILogger<CleanDatabaseScheduledTask> logger,
IDbContextFactory<JellyfinDbContext> dbProvider)
{
private readonly ILibraryManager _libraryManager;
private readonly ILogger<CleanDatabaseScheduledTask> _logger;
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
_libraryManager = libraryManager;
_logger = logger;
_dbProvider = dbProvider;
}
public CleanDatabaseScheduledTask(
ILibraryManager libraryManager,
ILogger<CleanDatabaseScheduledTask> logger,
IDbContextFactory<JellyfinDbContext> dbProvider)
{
_libraryManager = libraryManager;
_logger = logger;
_dbProvider = dbProvider;
}
public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
{
await CleanDeadItems(cancellationToken, progress).ConfigureAwait(false);
}
public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
private async Task CleanDeadItems(CancellationToken cancellationToken, IProgress<double> progress)
{
var itemIds = _libraryManager.GetItemIds(new InternalItemsQuery
{
await CleanDeadItems(cancellationToken, progress).ConfigureAwait(false);
}
HasDeadParentId = true
});
private async Task CleanDeadItems(CancellationToken cancellationToken, IProgress<double> progress)
{
var itemIds = _libraryManager.GetItemIds(new InternalItemsQuery
{
HasDeadParentId = true
});
var numComplete = 0;
var numItems = itemIds.Count + 1;
var numComplete = 0;
var numItems = itemIds.Count + 1;
_logger.LogDebug("Cleaning {Number} items with dead parent links", numItems);
_logger.LogDebug("Cleaning {0} items with dead parent links", numItems);
foreach (var itemId in itemIds)
{
cancellationToken.ThrowIfCancellationRequested();
foreach (var itemId in itemIds)
var item = _libraryManager.GetItemById(itemId);
if (item is not null)
{
cancellationToken.ThrowIfCancellationRequested();
var item = _libraryManager.GetItemById(itemId);
_logger.LogInformation("Cleaning item {Item} type: {Type} path: {Path}", item.Name, item.GetType().Name, item.Path ?? string.Empty);
if (item is not null)
_libraryManager.DeleteItem(item, new DeleteOptions
{
_logger.LogInformation("Cleaning item {0} type: {1} path: {2}", item.Name, item.GetType().Name, item.Path ?? string.Empty);
_libraryManager.DeleteItem(item, new DeleteOptions
{
DeleteFileLocation = false
});
}
numComplete++;
double percent = numComplete;
percent /= numItems;
progress.Report(percent * 100);
DeleteFileLocation = false
});
}
var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (context.ConfigureAwait(false))
numComplete++;
double percent = numComplete;
percent /= numItems;
progress.Report(percent * 100);
}
var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (context.ConfigureAwait(false))
{
var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
await using (transaction.ConfigureAwait(false))
{
var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
await using (transaction.ConfigureAwait(false))
{
await context.ItemValues.Where(e => e.BaseItemsMap!.Count == 0).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
}
await context.ItemValues.Where(e => e.BaseItemsMap!.Count == 0).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
}
progress.Report(100);
}
progress.Report(100);
}
}

@ -79,6 +79,7 @@ namespace Emby.Server.Implementations.Library
private readonly NamingOptions _namingOptions;
private readonly IPeopleRepository _peopleRepository;
private readonly ExtraResolver _extraResolver;
private readonly IPathManager _pathManager;
/// <summary>
/// The _root folder sync lock.
@ -114,7 +115,8 @@ namespace Emby.Server.Implementations.Library
/// <param name="imageProcessor">The image processor.</param>
/// <param name="namingOptions">The naming options.</param>
/// <param name="directoryService">The directory service.</param>
/// <param name="peopleRepository">The People Repository.</param>
/// <param name="peopleRepository">The people repository.</param>
/// <param name="pathManager">The path manager.</param>
public LibraryManager(
IServerApplicationHost appHost,
ILoggerFactory loggerFactory,
@ -131,7 +133,8 @@ namespace Emby.Server.Implementations.Library
IImageProcessor imageProcessor,
NamingOptions namingOptions,
IDirectoryService directoryService,
IPeopleRepository peopleRepository)
IPeopleRepository peopleRepository,
IPathManager pathManager)
{
_appHost = appHost;
_logger = loggerFactory.CreateLogger<LibraryManager>();
@ -149,6 +152,7 @@ namespace Emby.Server.Implementations.Library
_cache = new ConcurrentDictionary<Guid, BaseItem>();
_namingOptions = namingOptions;
_peopleRepository = peopleRepository;
_pathManager = pathManager;
_extraResolver = new ExtraResolver(loggerFactory.CreateLogger<ExtraResolver>(), namingOptions, directoryService);
_configurationManager.ConfigurationUpdated += ConfigurationUpdated;
@ -201,33 +205,33 @@ namespace Emby.Server.Implementations.Library
/// Gets or sets the postscan tasks.
/// </summary>
/// <value>The postscan tasks.</value>
private ILibraryPostScanTask[] PostscanTasks { get; set; } = Array.Empty<ILibraryPostScanTask>();
private ILibraryPostScanTask[] PostscanTasks { get; set; } = [];
/// <summary>
/// Gets or sets the intro providers.
/// </summary>
/// <value>The intro providers.</value>
private IIntroProvider[] IntroProviders { get; set; } = Array.Empty<IIntroProvider>();
private IIntroProvider[] IntroProviders { get; set; } = [];
/// <summary>
/// Gets or sets the list of entity resolution ignore rules.
/// </summary>
/// <value>The entity resolution ignore rules.</value>
private IResolverIgnoreRule[] EntityResolutionIgnoreRules { get; set; } = Array.Empty<IResolverIgnoreRule>();
private IResolverIgnoreRule[] EntityResolutionIgnoreRules { get; set; } = [];
/// <summary>
/// Gets or sets the list of currently registered entity resolvers.
/// </summary>
/// <value>The entity resolvers enumerable.</value>
private IItemResolver[] EntityResolvers { get; set; } = Array.Empty<IItemResolver>();
private IItemResolver[] EntityResolvers { get; set; } = [];
private IMultiItemResolver[] MultiItemResolvers { get; set; } = Array.Empty<IMultiItemResolver>();
private IMultiItemResolver[] MultiItemResolvers { get; set; } = [];
/// <summary>
/// Gets or sets the comparers.
/// </summary>
/// <value>The comparers.</value>
private IBaseItemComparer[] Comparers { get; set; } = Array.Empty<IBaseItemComparer>();
private IBaseItemComparer[] Comparers { get; set; } = [];
public bool IsScanRunning { get; private set; }
@ -360,7 +364,7 @@ namespace Emby.Server.Implementations.Library
var children = item.IsFolder
? ((Folder)item).GetRecursiveChildren(false)
: Array.Empty<BaseItem>();
: [];
foreach (var metadataPath in GetMetadataPaths(item, children))
{
@ -466,14 +470,28 @@ namespace Emby.Server.Implementations.Library
ReportItemRemoved(item, parent);
}
private static List<string> GetMetadataPaths(BaseItem item, IEnumerable<BaseItem> children)
private List<string> GetMetadataPaths(BaseItem item, IEnumerable<BaseItem> children)
{
var list = GetInternalMetadataPaths(item);
foreach (var child in children)
{
list.AddRange(GetInternalMetadataPaths(child));
}
return list;
}
private List<string> GetInternalMetadataPaths(BaseItem item)
{
var list = new List<string>
{
item.GetInternalMetadataPath()
};
list.AddRange(children.Select(i => i.GetInternalMetadataPath()));
if (item is Video video)
{
list.Add(_pathManager.GetTrickplayDirectory(video));
}
return list;
}
@ -594,7 +612,7 @@ namespace Emby.Server.Implementations.Library
{
_logger.LogError(ex, "Error in GetFilteredFileSystemEntries isPhysicalRoot: {0} IsVf: {1}", isPhysicalRoot, isVf);
files = Array.Empty<FileSystemMetadata>();
files = [];
}
else
{
@ -1345,6 +1363,21 @@ namespace Emby.Server.Implementations.Library
return _itemRepository.GetItemList(query);
}
public IReadOnlyList<string> GetNextUpSeriesKeys(InternalItemsQuery query, IReadOnlyCollection<BaseItem> parents, DateTime dateCutoff)
{
SetTopParentIdsOrAncestors(query, parents);
if (query.AncestorIds.Length == 0 && query.TopParentIds.Length == 0)
{
if (query.User is not null)
{
AddUserToQuery(query, query.User);
}
}
return _itemRepository.GetNextUpSeriesKeys(query, dateCutoff);
}
public QueryResult<BaseItem> QueryItems(InternalItemsQuery query)
{
if (query.User is not null)
@ -1449,7 +1482,7 @@ namespace Emby.Server.Implementations.Library
// Optimize by querying against top level views
query.TopParentIds = parents.SelectMany(i => GetTopParentIdsForQuery(i, query.User)).ToArray();
query.AncestorIds = Array.Empty<Guid>();
query.AncestorIds = [];
// Prevent searching in all libraries due to empty filter
if (query.TopParentIds.Length == 0)
@ -1569,7 +1602,7 @@ namespace Emby.Server.Implementations.Library
return GetTopParentIdsForQuery(displayParent, user);
}
return Array.Empty<Guid>();
return [];
}
if (!view.ParentId.IsEmpty())
@ -1580,7 +1613,7 @@ namespace Emby.Server.Implementations.Library
return GetTopParentIdsForQuery(displayParent, user);
}
return Array.Empty<Guid>();
return [];
}
// Handle grouping
@ -1595,7 +1628,7 @@ namespace Emby.Server.Implementations.Library
.SelectMany(i => GetTopParentIdsForQuery(i, user));
}
return Array.Empty<Guid>();
return [];
}
if (item is CollectionFolder collectionFolder)
@ -1609,7 +1642,7 @@ namespace Emby.Server.Implementations.Library
return new[] { topParent.Id };
}
return Array.Empty<Guid>();
return [];
}
/// <summary>
@ -1653,7 +1686,7 @@ namespace Emby.Server.Implementations.Library
{
_logger.LogError(ex, "Error getting intros");
return Enumerable.Empty<IntroInfo>();
return [];
}
}
@ -2480,8 +2513,11 @@ namespace Emby.Server.Implementations.Library
}
/// <inheritdoc />
public int? GetSeasonNumberFromPath(string path)
=> SeasonPathParser.Parse(path, true, true).SeasonNumber;
public int? GetSeasonNumberFromPath(string path, Guid? parentId)
{
var parentPath = parentId.HasValue ? GetItemById(parentId.Value)?.ContainingFolderPath : null;
return SeasonPathParser.Parse(path, parentPath, true, true).SeasonNumber;
}
/// <inheritdoc />
public bool FillMissingEpisodeNumbersFromPath(Episode episode, bool forceRefresh)
@ -2880,7 +2916,7 @@ namespace Emby.Server.Implementations.Library
{
var path = Path.Combine(virtualFolderPath, collectionType.ToString()!.ToLowerInvariant() + ".collection"); // Can't be null with legal values?
await File.WriteAllBytesAsync(path, Array.Empty<byte>()).ConfigureAwait(false);
await File.WriteAllBytesAsync(path, []).ConfigureAwait(false);
}
CollectionFolder.SaveLibraryOptions(virtualFolderPath, options);

@ -783,9 +783,13 @@ namespace Emby.Server.Implementations.Library
{
ArgumentException.ThrowIfNullOrEmpty(id);
// TODO probably shouldn't throw here but it is kept for "backwards compatibility"
var info = GetLiveStreamInfo(id) ?? throw new ResourceNotFoundException();
return Task.FromResult(new Tuple<MediaSourceInfo, IDirectStreamProvider>(info.MediaSource, info as IDirectStreamProvider));
var info = GetLiveStreamInfo(id);
if (info is null)
{
return Task.FromResult<Tuple<MediaSourceInfo, IDirectStreamProvider>>(new Tuple<MediaSourceInfo, IDirectStreamProvider>(null, null));
}
return Task.FromResult<Tuple<MediaSourceInfo, IDirectStreamProvider>>(new Tuple<MediaSourceInfo, IDirectStreamProvider>(info.MediaSource, info as IDirectStreamProvider));
}
public ILiveStream GetLiveStreamInfo(string id)

@ -39,46 +39,48 @@ namespace Emby.Server.Implementations.Library
return null;
}
// Sort in the following order: Default > No tag > Forced
var sortedStreams = streams
.Where(i => i.Type == MediaStreamType.Subtitle)
.OrderByDescending(x => x.IsExternal)
.ThenByDescending(x => x.IsForced && string.Equals(x.Language, audioTrackLanguage, StringComparison.OrdinalIgnoreCase))
.ThenByDescending(x => x.IsForced)
.ThenByDescending(x => x.IsDefault)
.ThenByDescending(x => preferredLanguages.Contains(x.Language, StringComparison.OrdinalIgnoreCase))
.ThenByDescending(x => !x.IsForced && MatchesPreferredLanguage(x.Language, preferredLanguages))
.ThenByDescending(x => x.IsForced && MatchesPreferredLanguage(x.Language, preferredLanguages))
.ThenByDescending(x => x.IsForced && IsLanguageUndefined(x.Language))
.ThenByDescending(x => x.IsForced)
.ToList();
MediaStream? stream = null;
if (mode == SubtitlePlaybackMode.Default)
{
// Load subtitles according to external, forced and default flags.
stream = sortedStreams.FirstOrDefault(x => x.IsExternal || x.IsForced || x.IsDefault);
// Load subtitles according to external, default and forced flags.
stream = sortedStreams.FirstOrDefault(x => x.IsExternal || x.IsDefault || x.IsForced);
}
else if (mode == SubtitlePlaybackMode.Smart)
{
// Only attempt to load subtitles if the audio language is not one of the user's preferred subtitle languages.
// If no subtitles of preferred language available, use default behaviour.
// If no subtitles of preferred language available, use none.
// If the audio language is one of the user's preferred subtitle languages behave like OnlyForced.
if (!preferredLanguages.Contains(audioTrackLanguage, StringComparison.OrdinalIgnoreCase))
{
stream = sortedStreams.FirstOrDefault(x => preferredLanguages.Contains(x.Language, StringComparison.OrdinalIgnoreCase)) ??
sortedStreams.FirstOrDefault(x => x.IsExternal || x.IsForced || x.IsDefault);
stream = sortedStreams.FirstOrDefault(x => MatchesPreferredLanguage(x.Language, preferredLanguages));
}
else
{
// Respect forced flag.
stream = sortedStreams.FirstOrDefault(x => x.IsForced);
stream = BehaviorOnlyForced(sortedStreams, preferredLanguages).FirstOrDefault();
}
}
else if (mode == SubtitlePlaybackMode.Always)
{
// Always load (full/non-forced) subtitles of the user's preferred subtitle language if possible, otherwise default behaviour.
stream = sortedStreams.FirstOrDefault(x => !x.IsForced && preferredLanguages.Contains(x.Language, StringComparison.OrdinalIgnoreCase)) ??
sortedStreams.FirstOrDefault(x => x.IsExternal || x.IsForced || x.IsDefault);
// Always load (full/non-forced) subtitles of the user's preferred subtitle language if possible, otherwise OnlyForced behaviour.
stream = sortedStreams.FirstOrDefault(x => !x.IsForced && MatchesPreferredLanguage(x.Language, preferredLanguages)) ??
BehaviorOnlyForced(sortedStreams, preferredLanguages).FirstOrDefault();
}
else if (mode == SubtitlePlaybackMode.OnlyForced)
{
// Only load subtitles that are flagged forced.
stream = sortedStreams.FirstOrDefault(x => x.IsForced);
// Load subtitles that are flagged forced of the user's preferred subtitle language or with an undefined language
stream = BehaviorOnlyForced(sortedStreams, preferredLanguages).FirstOrDefault();
}
return stream?.Index;
@ -110,40 +112,72 @@ namespace Emby.Server.Implementations.Library
if (mode == SubtitlePlaybackMode.Default)
{
// Prefer embedded metadata over smart logic
filteredStreams = sortedStreams.Where(s => s.IsForced || s.IsDefault)
// Load subtitles according to external, default, and forced flags.
filteredStreams = sortedStreams.Where(s => s.IsExternal || s.IsDefault || s.IsForced)
.ToList();
}
else if (mode == SubtitlePlaybackMode.Smart)
{
// Prefer smart logic over embedded metadata
// Only attempt to load subtitles if the audio language is not one of the user's preferred subtitle languages, otherwise OnlyForced behavior.
if (!preferredLanguages.Contains(audioTrackLanguage, StringComparison.OrdinalIgnoreCase))
{
filteredStreams = sortedStreams.Where(s => !s.IsForced && preferredLanguages.Contains(s.Language, StringComparison.OrdinalIgnoreCase))
filteredStreams = sortedStreams.Where(s => MatchesPreferredLanguage(s.Language, preferredLanguages))
.ToList();
}
else
{
filteredStreams = BehaviorOnlyForced(sortedStreams, preferredLanguages);
}
}
else if (mode == SubtitlePlaybackMode.Always)
{
// Always load the most suitable full subtitles
filteredStreams = sortedStreams.Where(s => !s.IsForced).ToList();
// Always load (full/non-forced) subtitles of the user's preferred subtitle language if possible, otherwise OnlyForced behavior.
filteredStreams = sortedStreams.Where(s => !s.IsForced && MatchesPreferredLanguage(s.Language, preferredLanguages))
.ToList() ?? BehaviorOnlyForced(sortedStreams, preferredLanguages);
}
else if (mode == SubtitlePlaybackMode.OnlyForced)
{
// Always load the most suitable full subtitles
filteredStreams = sortedStreams.Where(s => s.IsForced).ToList();
// Load subtitles that are flagged forced of the user's preferred subtitle language or with an undefined language
filteredStreams = BehaviorOnlyForced(sortedStreams, preferredLanguages);
}
// Load forced subs if we have found no suitable full subtitles
var iterStreams = filteredStreams is null || filteredStreams.Count == 0
? sortedStreams.Where(s => s.IsForced && string.Equals(s.Language, audioTrackLanguage, StringComparison.OrdinalIgnoreCase))
: filteredStreams;
// If filteredStreams is null, initialize it as an empty list to avoid null reference errors
filteredStreams ??= new List<MediaStream>();
foreach (var stream in iterStreams)
foreach (var stream in filteredStreams)
{
stream.Score = GetStreamScore(stream, preferredLanguages);
}
}
private static bool MatchesPreferredLanguage(string language, IReadOnlyList<string> preferredLanguages)
{
// If preferredLanguages is empty, treat it as "any language" (wildcard)
return preferredLanguages.Count == 0 ||
preferredLanguages.Contains(language, StringComparison.OrdinalIgnoreCase);
}
private static bool IsLanguageUndefined(string language)
{
// Check for null, empty, or known placeholders
return string.IsNullOrEmpty(language) ||
language.Equals("und", StringComparison.OrdinalIgnoreCase) ||
language.Equals("unknown", StringComparison.OrdinalIgnoreCase) ||
language.Equals("undetermined", StringComparison.OrdinalIgnoreCase) ||
language.Equals("mul", StringComparison.OrdinalIgnoreCase) ||
language.Equals("zxx", StringComparison.OrdinalIgnoreCase);
}
private static List<MediaStream> BehaviorOnlyForced(IEnumerable<MediaStream> sortedStreams, IReadOnlyList<string> preferredLanguages)
{
return sortedStreams
.Where(s => s.IsForced && (MatchesPreferredLanguage(s.Language, preferredLanguages) || IsLanguageUndefined(s.Language)))
.OrderByDescending(s => MatchesPreferredLanguage(s.Language, preferredLanguages))
.ThenByDescending(s => IsLanguageUndefined(s.Language))
.ToList();
}
internal static int GetStreamScore(MediaStream stream, IReadOnlyList<string> languagePreferences)
{
var index = languagePreferences.FindIndex(x => string.Equals(x, stream.Language, StringComparison.OrdinalIgnoreCase));

@ -0,0 +1,36 @@
using System.Globalization;
using System.IO;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.IO;
namespace Emby.Server.Implementations.Library;
/// <summary>
/// IPathManager implementation.
/// </summary>
public class PathManager : IPathManager
{
private readonly IServerConfigurationManager _config;
/// <summary>
/// Initializes a new instance of the <see cref="PathManager"/> class.
/// </summary>
/// <param name="config">The server configuration manager.</param>
public PathManager(
IServerConfigurationManager config)
{
_config = config;
}
/// <inheritdoc />
public string GetTrickplayDirectory(BaseItem item, bool saveWithMedia = false)
{
var basePath = _config.ApplicationPaths.TrickplayPath;
var idString = item.Id.ToString("N", CultureInfo.InvariantCulture);
return saveWithMedia
? Path.Combine(item.ContainingFolderPath, Path.ChangeExtension(item.Path, ".trickplay"))
: Path.Combine(basePath, idString);
}
}

@ -48,7 +48,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
var path = args.Path;
var seasonParserResult = SeasonPathParser.Parse(path, true, true);
var seasonParserResult = SeasonPathParser.Parse(path, series.ContainingFolderPath, true, true);
var season = new Season
{

@ -118,7 +118,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
{
if (child.IsDirectory)
{
if (IsSeasonFolder(child.FullName, isTvContentType))
if (IsSeasonFolder(child.FullName, path, isTvContentType))
{
_logger.LogDebug("{Path} is a series because of season folder {Dir}.", path, child.FullName);
return true;
@ -155,11 +155,12 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
/// Determines whether [is season folder] [the specified path].
/// </summary>
/// <param name="path">The path.</param>
/// <param name="parentPath">The parentpath.</param>
/// <param name="isTvContentType">if set to <c>true</c> [is tv content type].</param>
/// <returns><c>true</c> if [is season folder] [the specified path]; otherwise, <c>false</c>.</returns>
private static bool IsSeasonFolder(string path, bool isTvContentType)
private static bool IsSeasonFolder(string path, string parentPath, bool isTvContentType)
{
var seasonNumber = SeasonPathParser.Parse(path, isTvContentType, isTvContentType).SeasonNumber;
var seasonNumber = SeasonPathParser.Parse(path, parentPath, isTvContentType, isTvContentType).SeasonNumber;
return seasonNumber.HasValue;
}

@ -1,6 +1,6 @@
{
"Sync": "Сінхранізаваць",
"Playlists": "Плэйлісты",
"Playlists": "Спісы прайгравання",
"Latest": "Апошні",
"LabelIpAddressValue": "IP-адрас: {0}",
"ItemAddedWithName": "{0} быў дададзены ў бібліятэку",
@ -16,7 +16,7 @@
"Collections": "Калекцыі",
"Default": "Па змаўчанні",
"FailedLoginAttemptWithUserName": "Няўдалая спроба ўваходу з {0}",
"Folders": "Папкі",
"Folders": "Тэчкі",
"Favorites": "Абранае",
"External": "Знешні",
"Genres": "Жанры",

@ -11,7 +11,7 @@
"Collections": "Συλλογές",
"DeviceOfflineWithName": "Ο/Η {0} αποσυνδέθηκε",
"DeviceOnlineWithName": "Ο/Η {0} συνδέθηκε",
"FailedLoginAttemptWithUserName": "Αποτυχημένη προσπάθεια σύνδεσης από {0}",
"FailedLoginAttemptWithUserName": "Αποτυχία προσπάθειας σύνδεσης από {0}",
"Favorites": "Αγαπημένα",
"Folders": "Φάκελοι",
"Genres": "Είδη",
@ -27,8 +27,8 @@
"HeaderRecordingGroups": "Ομάδες Ηχογράφησης",
"HomeVideos": "Προσωπικά Βίντεο",
"Inherit": "Κληρονόμηση",
"ItemAddedWithName": "{0} προστέθηκε στη βιβλιοθήκη",
"ItemRemovedWithName": "{0} διαγράφηκε από τη βιβλιοθήκη",
"ItemAddedWithName": "Το {0} προστέθηκε στη βιβλιοθήκη",
"ItemRemovedWithName": "Το {0} διαγράφτηκε από τη βιβλιοθήκη",
"LabelIpAddressValue": "Διεύθυνση IP: {0}",
"LabelRunningTimeValue": "Διάρκεια: {0}",
"Latest": "Πρόσφατα",
@ -40,7 +40,7 @@
"Movies": "Ταινίες",
"Music": "Μουσική",
"MusicVideos": "Μουσικά Βίντεο",
"NameInstallFailed": "{0} η εγκατάσταση απέτυχε",
"NameInstallFailed": "H εγκατάσταση του {0} απέτυχε",
"NameSeasonNumber": "Κύκλος {0}",
"NameSeasonUnknown": "Άγνωστος Κύκλος",
"NewVersionIsAvailable": "Μια νέα έκδοση του διακομιστή Jellyfin είναι διαθέσιμη για λήψη.",
@ -54,7 +54,7 @@
"NotificationOptionPluginError": "Αποτυχία του πρόσθετου",
"NotificationOptionPluginInstalled": "Το πρόσθετο εγκαταστάθηκε",
"NotificationOptionPluginUninstalled": "Το πρόσθετο απεγκαταστάθηκε",
"NotificationOptionPluginUpdateInstalled": "Η αναβάθμιση του πρόσθετου εγκαταστάθηκε",
"NotificationOptionPluginUpdateInstalled": "Η ενημέρωση του πρόσθετου εγκαταστάθηκε",
"NotificationOptionServerRestartRequired": "Ο διακομιστής χρειάζεται επανεκκίνηση",
"NotificationOptionTaskFailed": "Αποτυχία προγραμματισμένης εργασίας",
"NotificationOptionUserLockedOut": "Ο χρήστης αποκλείστηκε",
@ -63,9 +63,9 @@
"Photos": "Φωτογραφίες",
"Playlists": "Λίστες αναπαραγωγής",
"Plugin": "Πρόσθετο",
"PluginInstalledWithName": "{0} εγκαταστήθηκε",
"PluginUninstalledWithName": "{0} έχει απεγκατασταθεί",
"PluginUpdatedWithName": "{0} έχει αναβαθμιστεί",
"PluginInstalledWithName": "Το {0} εγκαταστάθηκε",
"PluginUninstalledWithName": "Το {0} έχει απεγκατασταθεί",
"PluginUpdatedWithName": "Το {0} ενημερώθηκε",
"ProviderValue": "Πάροχος: {0}",
"ScheduledTaskFailedWithName": "{0} αποτυχία",
"ScheduledTaskStartedWithName": "{0} ξεκίνησε",
@ -96,7 +96,7 @@
"TaskCleanLogsDescription": "Διαγράφει αρχεία καταγραφής που είναι πάνω από {0} ημέρες.",
"TaskCleanLogs": "Εκκαθάριση Καταλόγου Καταγραφής",
"TaskRefreshLibraryDescription": "Σαρώνει την βιβλιοθήκη πολυμέσων σας για νέα αρχεία και ανανεώνει τα μεταδεδομένα.",
"TaskRefreshLibrary": "Βιβλιοθήκη Σάρωσης Πολυμέσων",
"TaskRefreshLibrary": "Σάρωση Βιβλιοθήκης Πολυμέσων",
"TaskRefreshChapterImagesDescription": "Δημιουργεί μικρογραφίες για βίντεο που έχουν κεφάλαια.",
"TaskRefreshChapterImages": "Εξαγωγή Εικόνων Κεφαλαίου",
"TaskCleanCacheDescription": "Διαγράφει αρχεία προσωρινής μνήμης που δεν χρειάζονται πλέον το σύστημα.",
@ -125,7 +125,7 @@
"TaskKeyframeExtractor": "Εξαγωγέας βασικών καρέ βίντεο",
"External": "Εξωτερικό",
"HearingImpaired": "Με προβλήματα ακοής",
"TaskRefreshTrickplayImages": "Δημιουργήστε εικόνες Trickplay",
"TaskRefreshTrickplayImages": "Δημιουργία εικόνων Trickplay",
"TaskRefreshTrickplayImagesDescription": "Δημιουργεί προεπισκοπήσεις trickplay για βίντεο σε ενεργοποιημένες βιβλιοθήκες.",
"TaskAudioNormalization": "Ομοιομορφία ήχου",
"TaskAudioNormalizationDescription": "Ανίχνευση αρχείων για δεδομένα ομοιομορφίας ήχου.",

@ -122,5 +122,9 @@
"AuthenticationSucceededWithUserName": "{0} sukcese aŭtentikigis",
"TaskKeyframeExtractorDescription": "Eltiras ĉefkadrojn el videodosieroj por krei pli precizajn HLS-ludlistojn. Ĉi tiu tasko povas funkcii dum longa tempo.",
"TaskKeyframeExtractor": "Eltiri Ĉefkadrojn",
"External": "Ekstera"
"External": "Ekstera",
"TaskAudioNormalizationDescription": "Skanas dosierojn por sonnivelaj normaligaj datumoj.",
"TaskRefreshTrickplayImages": "Generi la bildojn por TrickPlay (Antaŭrigardo rapida antaŭen)",
"TaskAudioNormalization": "Normaligo Sonnivela",
"HearingImpaired": "Surda"
}

@ -1,6 +1,6 @@
{
"Albums": "Álbuns",
"AppDeviceValues": "Aplicação {0}, Dispositivo: {1}",
"AppDeviceValues": "Aplicação: {0}, Dispositivo: {1}",
"Application": "Aplicação",
"Artists": "Artistas",
"AuthenticationSucceededWithUserName": "{0} autenticado com sucesso",

@ -286,8 +286,10 @@ namespace Emby.Server.Implementations.Localization
}
// Fairly common for some users to have "Rated R" in their rating field
rating = rating.Replace("Rated :", string.Empty, StringComparison.OrdinalIgnoreCase);
rating = rating.Replace("Rated ", string.Empty, StringComparison.OrdinalIgnoreCase);
rating = rating.Replace("Rated :", string.Empty, StringComparison.OrdinalIgnoreCase)
.Replace("Rated:", string.Empty, StringComparison.OrdinalIgnoreCase)
.Replace("Rated ", string.Empty, StringComparison.OrdinalIgnoreCase)
.Trim();
// Use rating system matching the language
if (!string.IsNullOrEmpty(countryCode))

@ -336,7 +336,7 @@
"TwoLetterISORegionName": "IE"
},
{
"DisplayName": "Islamic Republic of Pakistan",
"DisplayName": "Pakistan",
"Name": "PK",
"ThreeLetterISORegionName": "PAK",
"TwoLetterISORegionName": "PK"

@ -310,7 +310,7 @@ namespace Emby.Server.Implementations.Playlists
var item = playlist.LinkedChildren.FirstOrDefault(i => string.Equals(entryId, i.ItemId?.ToString("N", CultureInfo.InvariantCulture), StringComparison.OrdinalIgnoreCase));
if (item is null)
{
_logger.LogWarning("Modified item not found in playlist. ItemId: {ItemId}, PlaylistId: {PlaylistId}", item.ItemId, playlistId);
_logger.LogWarning("Modified item not found in playlist. ItemId: {ItemId}, PlaylistId: {PlaylistId}", entryId, playlistId);
return;
}

@ -344,6 +344,11 @@ namespace Emby.Server.Implementations.Session
/// <returns>Task.</returns>
private async Task UpdateNowPlayingItem(SessionInfo session, PlaybackProgressInfo info, BaseItem libraryItem, bool updateLastCheckInTime)
{
if (session is null)
{
return;
}
if (string.IsNullOrEmpty(info.MediaSourceId))
{
info.MediaSourceId = info.ItemId.ToString("N", CultureInfo.InvariantCulture);
@ -676,6 +681,11 @@ namespace Emby.Server.Implementations.Session
private BaseItem GetNowPlayingItem(SessionInfo session, Guid itemId)
{
if (session is null)
{
return null;
}
var item = session.FullNowPlayingItem;
if (item is not null && item.Id.Equals(itemId))
{
@ -795,7 +805,11 @@ namespace Emby.Server.Implementations.Session
ArgumentNullException.ThrowIfNull(info);
var session = GetSession(info.SessionId);
var session = GetSession(info.SessionId, false);
if (session is null)
{
return;
}
var libraryItem = info.ItemId.IsEmpty()
? null

@ -92,7 +92,7 @@ namespace Emby.Server.Implementations.TV
if (!string.IsNullOrEmpty(presentationUniqueKey))
{
return GetResult(GetNextUpEpisodes(request, user, new[] { presentationUniqueKey }, options), request);
return GetResult(GetNextUpEpisodes(request, user, [presentationUniqueKey], options), request);
}
if (limit.HasValue)
@ -100,25 +100,9 @@ namespace Emby.Server.Implementations.TV
limit = limit.Value + 10;
}
var items = _libraryManager
.GetItemList(
new InternalItemsQuery(user)
{
IncludeItemTypes = new[] { BaseItemKind.Episode },
OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending) },
SeriesPresentationUniqueKey = presentationUniqueKey,
Limit = limit,
DtoOptions = new DtoOptions { Fields = new[] { ItemFields.SeriesPresentationUniqueKey }, EnableImages = false },
GroupBySeriesPresentationUniqueKey = true
},
parentsFolders.ToList())
.Cast<Episode>()
.Where(episode => !string.IsNullOrEmpty(episode.SeriesPresentationUniqueKey))
.Select(GetUniqueSeriesKey)
.ToList();
// Avoid implicitly captured closure
var episodes = GetNextUpEpisodes(request, user, items.Distinct().ToArray(), options);
var nextUpSeriesKeys = _libraryManager.GetNextUpSeriesKeys(new InternalItemsQuery(user) { Limit = limit }, parentsFolders, request.NextUpDateCutoff);
var episodes = GetNextUpEpisodes(request, user, nextUpSeriesKeys, options);
return GetResult(episodes, request);
}
@ -134,36 +118,11 @@ namespace Emby.Server.Implementations.TV
.OrderByDescending(i => i.LastWatchedDate);
}
// If viewing all next up for all series, remove first episodes
// But if that returns empty, keep those first episodes (avoid completely empty view)
var alwaysEnableFirstEpisode = !request.SeriesId.IsNullOrEmpty();
var anyFound = false;
return allNextUp
.Where(i =>
{
if (request.DisableFirstEpisode)
{
return i.LastWatchedDate != DateTime.MinValue;
}
if (alwaysEnableFirstEpisode || (i.LastWatchedDate != DateTime.MinValue && i.LastWatchedDate.Date >= request.NextUpDateCutoff))
{
anyFound = true;
return true;
}
return !anyFound && i.LastWatchedDate == DateTime.MinValue;
})
.Select(i => i.GetEpisodeFunction())
.Where(i => i is not null)!;
}
private static string GetUniqueSeriesKey(Episode episode)
{
return episode.SeriesPresentationUniqueKey;
}
private static string GetUniqueSeriesKey(Series series)
{
return series.GetPresentationUniqueKey();
@ -179,13 +138,13 @@ namespace Emby.Server.Implementations.TV
{
AncestorWithPresentationUniqueKey = null,
SeriesPresentationUniqueKey = seriesKey,
IncludeItemTypes = new[] { BaseItemKind.Episode },
IncludeItemTypes = [BaseItemKind.Episode],
IsPlayed = true,
Limit = 1,
ParentIndexNumberNotEquals = 0,
DtoOptions = new DtoOptions
{
Fields = new[] { ItemFields.SortName },
Fields = [ItemFields.SortName],
EnableImages = false
}
};
@ -203,8 +162,8 @@ namespace Emby.Server.Implementations.TV
{
AncestorWithPresentationUniqueKey = null,
SeriesPresentationUniqueKey = seriesKey,
IncludeItemTypes = new[] { BaseItemKind.Episode },
OrderBy = new[] { (ItemSortBy.ParentIndexNumber, SortOrder.Ascending), (ItemSortBy.IndexNumber, SortOrder.Ascending) },
IncludeItemTypes = [BaseItemKind.Episode],
OrderBy = [(ItemSortBy.ParentIndexNumber, SortOrder.Ascending), (ItemSortBy.IndexNumber, SortOrder.Ascending)],
Limit = 1,
IsPlayed = includePlayed,
IsVirtualItem = false,
@ -229,7 +188,7 @@ namespace Emby.Server.Implementations.TV
AncestorWithPresentationUniqueKey = null,
SeriesPresentationUniqueKey = seriesKey,
ParentIndexNumber = 0,
IncludeItemTypes = new[] { BaseItemKind.Episode },
IncludeItemTypes = [BaseItemKind.Episode],
IsPlayed = includePlayed,
IsVirtualItem = false,
DtoOptions = dtoOptions
@ -249,7 +208,7 @@ namespace Emby.Server.Implementations.TV
consideredEpisodes.Add(nextEpisode);
}
var sortedConsideredEpisodes = _libraryManager.Sort(consideredEpisodes, user, new[] { (ItemSortBy.AiredEpisodeOrder, SortOrder.Ascending) })
var sortedConsideredEpisodes = _libraryManager.Sort(consideredEpisodes, user, [(ItemSortBy.AiredEpisodeOrder, SortOrder.Ascending)])
.Cast<Episode>();
if (lastWatchedEpisode is not null)
{

@ -698,6 +698,7 @@ public class LiveTvController : BaseJellyfinApiController
/// Gets recommended live tv epgs.
/// </summary>
/// <param name="userId">Optional. filter by user id.</param>
/// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
/// <param name="limit">Optional. The maximum number of records to return.</param>
/// <param name="isAiring">Optional. Filter by programs that are currently airing, or not.</param>
/// <param name="hasAired">Optional. Filter by programs that have completed airing, or not.</param>
@ -720,6 +721,7 @@ public class LiveTvController : BaseJellyfinApiController
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<QueryResult<BaseItemDto>>> GetRecommendedPrograms(
[FromQuery] Guid? userId,
[FromQuery] int? startIndex,
[FromQuery] int? limit,
[FromQuery] bool? isAiring,
[FromQuery] bool? hasAired,
@ -744,6 +746,7 @@ public class LiveTvController : BaseJellyfinApiController
var query = new InternalItemsQuery(user)
{
IsAiring = isAiring,
StartIndex = startIndex,
Limit = limit,
HasAired = hasAired,
IsSeries = isSeries,

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using Jellyfin.Api.Attributes;
using Jellyfin.Api.Extensions;
using Jellyfin.Api.Helpers;
using Jellyfin.Api.ModelBinders;
@ -86,7 +87,7 @@ public class TvShowsController : BaseJellyfinApiController
[FromQuery] bool? enableUserData,
[FromQuery] DateTime? nextUpDateCutoff,
[FromQuery] bool enableTotalRecordCount = true,
[FromQuery] bool disableFirstEpisode = false,
[FromQuery][ParameterObsolete] bool disableFirstEpisode = false,
[FromQuery] bool enableResumable = true,
[FromQuery] bool enableRewatching = false)
{
@ -109,7 +110,6 @@ public class TvShowsController : BaseJellyfinApiController
StartIndex = startIndex,
User = user,
EnableTotalRecordCount = enableTotalRecordCount,
DisableFirstEpisode = disableFirstEpisode,
NextUpDateCutoff = nextUpDateCutoff ?? DateTime.MinValue,
EnableResumable = enableResumable,
EnableRewatching = enableRewatching

@ -101,16 +101,23 @@ public sealed class BaseItemRepository
using var context = _dbProvider.CreateDbContext();
using var transaction = context.Database.BeginTransaction();
context.PeopleBaseItemMap.Where(e => e.ItemId == id).ExecuteDelete();
context.Peoples.Where(e => e.BaseItems!.Count == 0).ExecuteDelete();
context.Chapters.Where(e => e.ItemId == id).ExecuteDelete();
context.MediaStreamInfos.Where(e => e.ItemId == id).ExecuteDelete();
context.AncestorIds.Where(e => e.ItemId == id || e.ParentItemId == id).ExecuteDelete();
context.ItemValuesMap.Where(e => e.ItemId == id).ExecuteDelete();
context.ItemValues.Where(e => e.BaseItemsMap!.Count == 0).ExecuteDelete();
context.AttachmentStreamInfos.Where(e => e.ItemId == id).ExecuteDelete();
context.BaseItemImageInfos.Where(e => e.ItemId == id).ExecuteDelete();
context.BaseItemMetadataFields.Where(e => e.ItemId == id).ExecuteDelete();
context.BaseItemProviders.Where(e => e.ItemId == id).ExecuteDelete();
context.BaseItemTrailerTypes.Where(e => e.ItemId == id).ExecuteDelete();
context.BaseItems.Where(e => e.Id == id).ExecuteDelete();
context.Chapters.Where(e => e.ItemId == id).ExecuteDelete();
context.CustomItemDisplayPreferences.Where(e => e.ItemId == id).ExecuteDelete();
context.ItemDisplayPreferences.Where(e => e.ItemId == id).ExecuteDelete();
context.ItemValues.Where(e => e.BaseItemsMap!.Count == 0).ExecuteDelete();
context.ItemValuesMap.Where(e => e.ItemId == id).ExecuteDelete();
context.MediaSegments.Where(e => e.ItemId == id).ExecuteDelete();
context.MediaStreamInfos.Where(e => e.ItemId == id).ExecuteDelete();
context.PeopleBaseItemMap.Where(e => e.ItemId == id).ExecuteDelete();
context.Peoples.Where(e => e.BaseItems!.Count == 0).ExecuteDelete();
context.TrickplayInfos.Where(e => e.ItemId == id).ExecuteDelete();
context.SaveChanges();
transaction.Commit();
}
@ -255,6 +262,37 @@ public sealed class BaseItemRepository
return dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserialiseBaseItem(w, filter.SkipDeserialization)).ToArray();
}
/// <inheritdoc />
public IReadOnlyList<string> GetNextUpSeriesKeys(InternalItemsQuery filter, DateTime dateCutoff)
{
ArgumentNullException.ThrowIfNull(filter);
ArgumentNullException.ThrowIfNull(filter.User);
using var context = _dbProvider.CreateDbContext();
var query = context.BaseItems
.AsNoTracking()
.Where(i => filter.TopParentIds.Contains(i.TopParentId!.Value))
.Where(i => i.Type == _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode])
.Join(
context.UserData.AsNoTracking(),
i => new { UserId = filter.User.Id, ItemId = i.Id },
u => new { UserId = u.UserId, ItemId = u.ItemId },
(entity, data) => new { Item = entity, UserData = data })
.GroupBy(g => g.Item.SeriesPresentationUniqueKey)
.Select(g => new { g.Key, LastPlayedDate = g.Max(u => u.UserData.LastPlayedDate) })
.Where(g => g.Key != null && g.LastPlayedDate != null && g.LastPlayedDate >= dateCutoff)
.OrderByDescending(g => g.LastPlayedDate)
.Select(g => g.Key!);
if (filter.Limit.HasValue)
{
query = query.Take(filter.Limit.Value);
}
return query.ToArray();
}
private IQueryable<BaseItemEntity> ApplyGroupingFilter(IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter)
{
// This whole block is needed to filter duplicate entries on request
@ -925,25 +963,11 @@ public sealed class BaseItemRepository
using var context = _dbProvider.CreateDbContext();
var innerQuery = new InternalItemsQuery(filter.User)
{
ExcludeItemTypes = filter.ExcludeItemTypes,
IncludeItemTypes = filter.IncludeItemTypes,
MediaTypes = filter.MediaTypes,
AncestorIds = filter.AncestorIds,
ItemIds = filter.ItemIds,
TopParentIds = filter.TopParentIds,
ParentId = filter.ParentId,
IsAiring = filter.IsAiring,
IsMovie = filter.IsMovie,
IsSports = filter.IsSports,
IsKids = filter.IsKids,
IsNews = filter.IsNews,
IsSeries = filter.IsSeries
};
var query = TranslateQuery(context.BaseItems.AsNoTracking(), context, innerQuery);
var query = TranslateQuery(context.BaseItems.AsNoTracking(), context, filter);
query = query.Where(e => e.Type == returnType && e.ItemValues!.Any(f => e.CleanName == f.ItemValue.CleanValue && itemValueTypes.Any(w => (ItemValueType)w == f.ItemValue.Type)));
query = query.Where(e => e.Type == returnType);
// this does not seem to be nesseary but it does not make any sense why this isn't working.
// && e.ItemValues!.Any(f => e.CleanName == f.ItemValue.CleanValue && itemValueTypes.Any(w => (ItemValueType)w == f.ItemValue.Type)));
if (filter.OrderBy.Count != 0
|| !string.IsNullOrEmpty(filter.SearchTerm))

@ -12,6 +12,7 @@ using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Drawing;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Controller.Trickplay;
@ -37,9 +38,10 @@ public class TrickplayManager : ITrickplayManager
private readonly IImageEncoder _imageEncoder;
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
private readonly IApplicationPaths _appPaths;
private readonly IPathManager _pathManager;
private static readonly AsyncNonKeyedLocker _resourcePool = new(1);
private static readonly string[] _trickplayImgExtensions = { ".jpg" };
private static readonly string[] _trickplayImgExtensions = [".jpg"];
/// <summary>
/// Initializes a new instance of the <see cref="TrickplayManager"/> class.
@ -53,6 +55,7 @@ public class TrickplayManager : ITrickplayManager
/// <param name="imageEncoder">The image encoder.</param>
/// <param name="dbProvider">The database provider.</param>
/// <param name="appPaths">The application paths.</param>
/// <param name="pathManager">The path manager.</param>
public TrickplayManager(
ILogger<TrickplayManager> logger,
IMediaEncoder mediaEncoder,
@ -62,7 +65,8 @@ public class TrickplayManager : ITrickplayManager
IServerConfigurationManager config,
IImageEncoder imageEncoder,
IDbContextFactory<JellyfinDbContext> dbProvider,
IApplicationPaths appPaths)
IApplicationPaths appPaths,
IPathManager pathManager)
{
_logger = logger;
_mediaEncoder = mediaEncoder;
@ -73,6 +77,7 @@ public class TrickplayManager : ITrickplayManager
_imageEncoder = imageEncoder;
_dbProvider = dbProvider;
_appPaths = appPaths;
_pathManager = pathManager;
}
/// <inheritdoc />
@ -610,10 +615,7 @@ public class TrickplayManager : ITrickplayManager
/// <inheritdoc />
public string GetTrickplayDirectory(BaseItem item, int tileWidth, int tileHeight, int width, bool saveWithMedia = false)
{
var path = saveWithMedia
? Path.Combine(item.ContainingFolderPath, Path.ChangeExtension(item.Path, ".trickplay"))
: Path.Combine(item.GetInternalMetadataPath(), "trickplay");
var path = _pathManager.GetTrickplayDirectory(item, saveWithMedia);
var subdirectory = string.Format(
CultureInfo.InvariantCulture,
"{0} - {1}x{2}",

@ -94,7 +94,7 @@ public class MigrateLibraryDb : IMigrationRoutine
Audio, ExternalServiceId, IsInMixedFolder, DateLastSaved, LockedFields, Studios, Tags, TrailerTypes, OriginalTitle, PrimaryVersionId,
DateLastMediaAdded, Album, LUFS, NormalizationGain, CriticRating, IsVirtualItem, SeriesName, UserDataKey, SeasonName, SeasonId, SeriesId,
PresentationUniqueKey, InheritedParentalRatingValue, ExternalSeriesId, Tagline, ProviderIds, Images, ProductionLocations, ExtraIds, TotalBitrate,
ExtraType, Artists, AlbumArtists, ExternalId, SeriesPresentationUniqueKey, ShowId, OwnerId, MediaType FROM TypedBaseItems
ExtraType, Artists, AlbumArtists, ExternalId, SeriesPresentationUniqueKey, ShowId, OwnerId, MediaType, SortName, CleanName FROM TypedBaseItems
""";
dbContext.BaseItems.ExecuteDelete();
@ -168,7 +168,6 @@ public class MigrateLibraryDb : IMigrationRoutine
dbContext.UserData.ExecuteDelete();
var users = dbContext.Users.AsNoTracking().ToImmutableArray();
var oldUserdata = new Dictionary<string, UserData>();
foreach (var entity in queryResult)
{
@ -189,6 +188,8 @@ public class MigrateLibraryDb : IMigrationRoutine
dbContext.UserData.Add(userData);
}
users.Clear();
legacyBaseItemWithUserKeys.Clear();
_logger.LogInformation("Try saving {0} UserData entries.", dbContext.UserData.Local.Count);
dbContext.SaveChanges();
@ -225,11 +226,12 @@ public class MigrateLibraryDb : IMigrationRoutine
dbContext.PeopleBaseItemMap.ExecuteDelete();
var peopleCache = new Dictionary<string, (People Person, List<PeopleBaseItemMap> Items)>();
var baseItemIds = dbContext.BaseItems.Select(b => b.Id).ToHashSet();
foreach (SqliteDataReader reader in connection.Query(personsQuery))
{
var itemId = reader.GetGuid(0);
if (!dbContext.BaseItems.Any(f => f.Id == itemId))
if (!baseItemIds.Contains(itemId))
{
_logger.LogError("Dont save person {0} because its not in use by any BaseItem", reader.GetString(1));
continue;
@ -261,12 +263,16 @@ public class MigrateLibraryDb : IMigrationRoutine
});
}
baseItemIds.Clear();
foreach (var item in peopleCache)
{
dbContext.Peoples.Add(item.Value.Person);
dbContext.PeopleBaseItemMap.AddRange(item.Value.Items.DistinctBy(e => (e.ItemId, e.PeopleId)));
}
peopleCache.Clear();
_logger.LogInformation("Try saving {0} People entries.", dbContext.MediaStreamInfos.Local.Count);
dbContext.SaveChanges();
migrationTotalTime += stopwatch.Elapsed;
@ -1029,6 +1035,16 @@ public class MigrateLibraryDb : IMigrationRoutine
entity.MediaType = mediaType;
}
if (reader.TryGetString(index++, out var sortName))
{
entity.SortName = sortName;
}
if (reader.TryGetString(index++, out var cleanName))
{
entity.CleanName = cleanName;
}
var baseItem = BaseItemRepository.DeserialiseBaseItem(entity, _logger, null, false);
var dataKeys = baseItem.GetUserDataKeys();
userDataKeys.AddRange(dataKeys);

@ -39,7 +39,7 @@ public class MoveTrickplayFiles : IMigrationRoutine
}
/// <inheritdoc />
public Guid Id => new("4EF123D5-8EFF-4B0B-869D-3AED07A60E1B");
public Guid Id => new("9540D44A-D8DC-11EF-9CBB-B77274F77C52");
/// <inheritdoc />
public string Name => "MoveTrickplayFiles";
@ -89,6 +89,12 @@ public class MoveTrickplayFiles : IMigrationRoutine
{
_fileSystem.MoveDirectory(oldPath, newPath);
}
oldPath = GetNewOldTrickplayDirectory(item, trickplayInfo.TileWidth, trickplayInfo.TileHeight, trickplayInfo.Width, false);
if (_fileSystem.DirectoryExists(oldPath))
{
_fileSystem.MoveDirectory(oldPath, newPath);
}
}
} while (previousCount == Limit);
@ -101,4 +107,20 @@ public class MoveTrickplayFiles : IMigrationRoutine
return width.HasValue ? Path.Combine(path, width.Value.ToString(CultureInfo.InvariantCulture)) : path;
}
private string GetNewOldTrickplayDirectory(BaseItem item, int tileWidth, int tileHeight, int width, bool saveWithMedia = false)
{
var path = saveWithMedia
? Path.Combine(item.ContainingFolderPath, Path.ChangeExtension(item.Path, ".trickplay"))
: Path.Combine(item.GetInternalMetadataPath(), "trickplay");
var subdirectory = string.Format(
CultureInfo.InvariantCulture,
"{0} - {1}x{2}",
width.ToString(CultureInfo.InvariantCulture),
tileWidth.ToString(CultureInfo.InvariantCulture),
tileHeight.ToString(CultureInfo.InvariantCulture));
return Path.Combine(path, subdirectory);
}
}

@ -11,7 +11,9 @@ using Emby.Server.Implementations;
using Jellyfin.Server.Extensions;
using Jellyfin.Server.Helpers;
using Jellyfin.Server.Implementations;
using Jellyfin.Server.ServerSetupApp;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Data.Sqlite;
@ -44,6 +46,9 @@ namespace Jellyfin.Server
public const string LoggingConfigFileSystem = "logging.json";
private static readonly SerilogLoggerFactory _loggerFactory = new SerilogLoggerFactory();
private static SetupServer _setupServer = new();
private static CoreAppHost? _appHost;
private static IHost? _jellyfinHost = null;
private static long _startTimestamp;
private static ILogger _logger = NullLogger.Instance;
private static bool _restartOnShutdown;
@ -70,6 +75,7 @@ namespace Jellyfin.Server
{
_startTimestamp = Stopwatch.GetTimestamp();
ServerApplicationPaths appPaths = StartupHelpers.CreateApplicationPaths(options);
await _setupServer.RunAsync(static () => _jellyfinHost?.Services?.GetService<INetworkManager>(), appPaths, static () => _appHost).ConfigureAwait(false);
// $JELLYFIN_LOG_DIR needs to be set for the logger configuration manager
Environment.SetEnvironmentVariable("JELLYFIN_LOG_DIR", appPaths.LogDirectoryPath);
@ -124,22 +130,23 @@ namespace Jellyfin.Server
if (_restartOnShutdown)
{
_startTimestamp = Stopwatch.GetTimestamp();
_setupServer = new SetupServer();
await _setupServer.RunAsync(static () => _jellyfinHost?.Services?.GetService<INetworkManager>(), appPaths, static () => _appHost).ConfigureAwait(false);
}
} while (_restartOnShutdown);
}
private static async Task StartServer(IServerApplicationPaths appPaths, StartupOptions options, IConfiguration startupConfig)
{
using var appHost = new CoreAppHost(
appPaths,
_loggerFactory,
options,
startupConfig);
IHost? host = null;
using CoreAppHost appHost = new CoreAppHost(
appPaths,
_loggerFactory,
options,
startupConfig);
_appHost = appHost;
try
{
host = Host.CreateDefaultBuilder()
_jellyfinHost = Host.CreateDefaultBuilder()
.UseConsoleLifetime()
.ConfigureServices(services => appHost.Init(services))
.ConfigureWebHostDefaults(webHostBuilder =>
@ -156,14 +163,17 @@ namespace Jellyfin.Server
.Build();
// Re-use the host service provider in the app host since ASP.NET doesn't allow a custom service collection.
appHost.ServiceProvider = host.Services;
appHost.ServiceProvider = _jellyfinHost.Services;
await appHost.InitializeServices(startupConfig).ConfigureAwait(false);
Migrations.MigrationRunner.Run(appHost, _loggerFactory);
try
{
await host.StartAsync().ConfigureAwait(false);
await _setupServer.StopAsync().ConfigureAwait(false);
_setupServer.Dispose();
_setupServer = null!;
await _jellyfinHost.StartAsync().ConfigureAwait(false);
if (!OperatingSystem.IsWindows() && startupConfig.UseUnixSocket())
{
@ -182,7 +192,7 @@ namespace Jellyfin.Server
_logger.LogInformation("Startup complete {Time:g}", Stopwatch.GetElapsedTime(_startTimestamp));
await host.WaitForShutdownAsync().ConfigureAwait(false);
await _jellyfinHost.WaitForShutdownAsync().ConfigureAwait(false);
_restartOnShutdown = appHost.ShouldRestart;
}
catch (Exception ex)
@ -203,7 +213,8 @@ namespace Jellyfin.Server
await databaseProvider.RunShutdownTask(shutdownSource.Token).ConfigureAwait(false);
}
host?.Dispose();
_appHost = null;
_jellyfinHost?.Dispose();
}
}

@ -0,0 +1,172 @@
using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller;
using MediaBrowser.Model.System;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Hosting;
namespace Jellyfin.Server.ServerSetupApp;
/// <summary>
/// Creates a fake application pipeline that will only exist for as long as the main app is not started.
/// </summary>
public sealed class SetupServer : IDisposable
{
private IHost? _startupServer;
private bool _disposed;
/// <summary>
/// Starts the Bind-All Setup aspcore server to provide a reflection on the current core setup.
/// </summary>
/// <param name="networkManagerFactory">The networkmanager.</param>
/// <param name="applicationPaths">The application paths.</param>
/// <param name="serverApplicationHost">The servers application host.</param>
/// <returns>A Task.</returns>
public async Task RunAsync(
Func<INetworkManager?> networkManagerFactory,
IApplicationPaths applicationPaths,
Func<IServerApplicationHost?> serverApplicationHost)
{
ThrowIfDisposed();
_startupServer = Host.CreateDefaultBuilder()
.UseConsoleLifetime()
.ConfigureServices(serv =>
{
serv.AddHealthChecks()
.AddCheck<SetupHealthcheck>("StartupCheck");
})
.ConfigureWebHostDefaults(webHostBuilder =>
{
webHostBuilder
.UseKestrel()
.Configure(app =>
{
app.UseHealthChecks("/health");
app.Map("/startup/logger", loggerRoute =>
{
loggerRoute.Run(async context =>
{
var networkManager = networkManagerFactory();
if (context.Connection.RemoteIpAddress is null || networkManager is null || !networkManager.IsInLocalNetwork(context.Connection.RemoteIpAddress))
{
context.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
return;
}
var logFilePath = new DirectoryInfo(applicationPaths.LogDirectoryPath)
.EnumerateFiles()
.OrderBy(f => f.CreationTimeUtc)
.FirstOrDefault()
?.FullName;
if (logFilePath is not null)
{
await context.Response.SendFileAsync(logFilePath, CancellationToken.None).ConfigureAwait(false);
}
});
});
app.Map("/System/Info/Public", systemRoute =>
{
systemRoute.Run(async context =>
{
var jfApplicationHost = serverApplicationHost();
var retryCounter = 0;
while (jfApplicationHost is null && retryCounter < 5)
{
await Task.Delay(500).ConfigureAwait(false);
jfApplicationHost = serverApplicationHost();
retryCounter++;
}
if (jfApplicationHost is null)
{
context.Response.StatusCode = (int)HttpStatusCode.ServiceUnavailable;
context.Response.Headers.RetryAfter = new Microsoft.Extensions.Primitives.StringValues("60");
return;
}
var sysInfo = new PublicSystemInfo
{
Version = jfApplicationHost.ApplicationVersionString,
ProductName = jfApplicationHost.Name,
Id = jfApplicationHost.SystemId,
ServerName = jfApplicationHost.FriendlyName,
LocalAddress = jfApplicationHost.GetSmartApiUrl(context.Request),
StartupWizardCompleted = false
};
await context.Response.WriteAsJsonAsync(sysInfo).ConfigureAwait(false);
});
});
app.Run((context) =>
{
context.Response.StatusCode = (int)HttpStatusCode.ServiceUnavailable;
context.Response.Headers.RetryAfter = new Microsoft.Extensions.Primitives.StringValues("60");
context.Response.WriteAsync("<p>Jellyfin Server still starting. Please wait.</p>");
var networkManager = networkManagerFactory();
if (networkManager is not null && context.Connection.RemoteIpAddress is not null && networkManager.IsInLocalNetwork(context.Connection.RemoteIpAddress))
{
context.Response.WriteAsync("<p>You can download the current logfiles <a href='/startup/logger'>here</a>.</p>");
}
return Task.CompletedTask;
});
});
})
.Build();
await _startupServer.StartAsync().ConfigureAwait(false);
}
/// <summary>
/// Stops the Setup server.
/// </summary>
/// <returns>A task. Duh.</returns>
public async Task StopAsync()
{
ThrowIfDisposed();
if (_startupServer is null)
{
throw new InvalidOperationException("Tried to stop a non existing startup server");
}
await _startupServer.StopAsync().ConfigureAwait(false);
}
/// <inheritdoc/>
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_startupServer?.Dispose();
}
private void ThrowIfDisposed()
{
ObjectDisposedException.ThrowIf(_disposed, this);
}
private class SetupHealthcheck : IHealthCheck
{
public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
{
return Task.FromResult(HealthCheckResult.Degraded("Server is still starting up."));
}
}
}

@ -84,5 +84,11 @@ namespace MediaBrowser.Common.Configuration
/// </summary>
/// <value>The magic string used for virtual path manipulation.</value>
string VirtualDataPath { get; }
/// <summary>
/// Gets the path used for storing trickplay files.
/// </summary>
/// <value>The trickplay path.</value>
string TrickplayPath { get; }
}
}

@ -326,4 +326,23 @@ public static partial class NetworkUtils
return new IPAddress(BitConverter.GetBytes(broadCastIPAddress));
}
/// <summary>
/// Check if a subnet contains an address. This method also handles IPv4 mapped to IPv6 addresses.
/// </summary>
/// <param name="network">The <see cref="IPNetwork"/>.</param>
/// <param name="address">The <see cref="IPAddress"/>.</param>
/// <returns>Whether the supplied IP is in the supplied network.</returns>
public static bool SubnetContainsAddress(IPNetwork network, IPAddress address)
{
ArgumentNullException.ThrowIfNull(address);
ArgumentNullException.ThrowIfNull(network);
if (address.IsIPv4MappedToIPv6)
{
address = address.MapToIPv4();
}
return network.Contains(address);
}
}

@ -139,11 +139,9 @@ namespace MediaBrowser.Controller.Entities.Audio
private static List<string> GetUserDataKeys(MusicArtist item)
{
var list = new List<string>();
var id = item.GetProviderId(MetadataProvider.MusicBrainzArtist);
if (!string.IsNullOrEmpty(id))
if (item.TryGetProviderId(MetadataProvider.MusicBrainzArtist, out var externalId))
{
list.Add("Artist-Musicbrainz-" + id);
list.Add("Artist-Musicbrainz-" + externalId);
}
list.Add("Artist-" + (item.Name ?? string.Empty).RemoveDiacritics());

@ -920,7 +920,7 @@ namespace MediaBrowser.Controller.Entities
// Remove from middle if surrounded by spaces
sortable = sortable.Replace(" " + search + " ", " ", StringComparison.Ordinal);
// Remove from end if followed by a space
// Remove from end if preceeded by a space
if (sortable.EndsWith(" " + search, StringComparison.Ordinal))
{
sortable = sortable.Remove(sortable.Length - (search.Length + 1));
@ -1776,7 +1776,6 @@ namespace MediaBrowser.Controller.Entities
public void AddStudio(string name)
{
ArgumentException.ThrowIfNullOrEmpty(name);
var current = Studios;
if (!current.Contains(name, StringComparison.OrdinalIgnoreCase))
@ -1795,7 +1794,7 @@ namespace MediaBrowser.Controller.Entities
public void SetStudios(IEnumerable<string> names)
{
Studios = names.Distinct().ToArray();
Studios = names.Trimmed().Distinct().ToArray();
}
/// <summary>

@ -15,6 +15,8 @@ namespace MediaBrowser.Controller.Entities
ArgumentNullException.ThrowIfNull(person);
ArgumentException.ThrowIfNullOrEmpty(person.Name);
person.Name = person.Name.Trim();
// Normalize
if (string.Equals(person.Role, PersonType.GuestStar, StringComparison.OrdinalIgnoreCase))
{

@ -257,7 +257,7 @@ namespace MediaBrowser.Controller.Entities.TV
if (!IndexNumber.HasValue && !string.IsNullOrEmpty(Path))
{
IndexNumber ??= LibraryManager.GetSeasonNumberFromPath(Path);
IndexNumber ??= LibraryManager.GetSeasonNumberFromPath(Path, ParentId);
// If a change was made record it
if (IndexNumber.HasValue)

@ -0,0 +1,17 @@
using MediaBrowser.Controller.Entities;
namespace MediaBrowser.Controller.IO;
/// <summary>
/// Interface ITrickplayManager.
/// </summary>
public interface IPathManager
{
/// <summary>
/// Gets the path to the trickplay image base folder.
/// </summary>
/// <param name="item">The item.</param>
/// <param name="saveWithMedia">Whether or not the tile should be saved next to the media file.</param>
/// <returns>The absolute path.</returns>
public string GetTrickplayDirectory(BaseItem item, bool saveWithMedia = false);
}

@ -426,8 +426,9 @@ namespace MediaBrowser.Controller.Library
/// Gets the season number from path.
/// </summary>
/// <param name="path">The path.</param>
/// <param name="parentId">The parent id.</param>
/// <returns>System.Nullable&lt;System.Int32&gt;.</returns>
int? GetSeasonNumberFromPath(string path);
int? GetSeasonNumberFromPath(string path, Guid? parentId);
/// <summary>
/// Fills the missing episode numbers from path.
@ -565,6 +566,15 @@ namespace MediaBrowser.Controller.Library
/// <returns>List of items.</returns>
IReadOnlyList<BaseItem> GetItemList(InternalItemsQuery query, List<BaseItem> parents);
/// <summary>
/// Gets the list of series presentation keys for next up.
/// </summary>
/// <param name="query">The query to use.</param>
/// <param name="parents">Items to use for query.</param>
/// <param name="dateCutoff">The minimum date for a series to have been most recently watched.</param>
/// <returns>List of series presentation keys.</returns>
IReadOnlyList<string> GetNextUpSeriesKeys(InternalItemsQuery query, IReadOnlyCollection<BaseItem> parents, DateTime dateCutoff);
/// <summary>
/// Gets the items result.
/// </summary>

@ -59,6 +59,14 @@ public interface IItemRepository
/// <returns>List&lt;BaseItem&gt;.</returns>
IReadOnlyList<BaseItem> GetItemList(InternalItemsQuery filter);
/// <summary>
/// Gets the list of series presentation keys for next up.
/// </summary>
/// <param name="filter">The query.</param>
/// <param name="dateCutoff">The minimum date for a series to have been most recently watched.</param>
/// <returns>The list of keys.</returns>
IReadOnlyList<string> GetNextUpSeriesKeys(InternalItemsQuery filter, DateTime dateCutoff);
/// <summary>
/// Updates the inherited values.
/// </summary>

@ -31,12 +31,6 @@ namespace MediaBrowser.Controller.Providers
/// </remarks>
ExternalIdMediaType? Type { get; }
/// <summary>
/// Gets the URL format string for this id.
/// </summary>
[Obsolete("Obsolete in 10.10, to be removed in 10.11")]
string? UrlFormatString { get; }
/// <summary>
/// Determines whether this id supports a given item type.
/// </summary>

@ -234,8 +234,8 @@ namespace MediaBrowser.LocalMetadata.Parsers
item.CustomRating = reader.ReadNormalizedString();
break;
case "RunningTime":
var runtimeText = reader.ReadElementContentAsString();
if (!string.IsNullOrWhiteSpace(runtimeText))
var runtimeText = reader.ReadNormalizedString();
if (!string.IsNullOrEmpty(runtimeText))
{
if (int.TryParse(runtimeText.AsSpan().LeftPart(' '), NumberStyles.Integer, CultureInfo.InvariantCulture, out var runtime))
{
@ -253,7 +253,7 @@ namespace MediaBrowser.LocalMetadata.Parsers
break;
case "LockData":
item.IsLocked = string.Equals(reader.ReadElementContentAsString(), "true", StringComparison.OrdinalIgnoreCase);
item.IsLocked = string.Equals(reader.ReadNormalizedString(), "true", StringComparison.OrdinalIgnoreCase);
break;
case "Network":
foreach (var name in reader.GetStringArray())
@ -331,9 +331,9 @@ namespace MediaBrowser.LocalMetadata.Parsers
case "Rating":
case "IMDBrating":
{
var rating = reader.ReadElementContentAsString();
var rating = reader.ReadNormalizedString();
if (!string.IsNullOrWhiteSpace(rating))
if (!string.IsNullOrEmpty(rating))
{
// All external meta is saving this as '.' for decimal I believe...but just to be sure
if (float.TryParse(rating.Replace(',', '.'), NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var val))
@ -449,7 +449,7 @@ namespace MediaBrowser.LocalMetadata.Parsers
case "OwnerUserId":
{
var val = reader.ReadElementContentAsString();
var val = reader.ReadNormalizedString();
if (Guid.TryParse(val, out var guid) && !guid.Equals(Guid.Empty))
{
@ -464,7 +464,7 @@ namespace MediaBrowser.LocalMetadata.Parsers
case "Format3D":
{
var val = reader.ReadElementContentAsString();
var val = reader.ReadNormalizedString();
if (item is Video video)
{
@ -498,7 +498,7 @@ namespace MediaBrowser.LocalMetadata.Parsers
string readerName = reader.Name;
if (_validProviderIds!.TryGetValue(readerName, out string? providerIdValue))
{
var id = reader.ReadElementContentAsString();
var id = reader.ReadNormalizedString();
item.TrySetProviderId(providerIdValue, id);
}
else
@ -580,7 +580,12 @@ namespace MediaBrowser.LocalMetadata.Parsers
switch (reader.Name)
{
case "Tagline":
item.Tagline = reader.ReadNormalizedString();
var val = reader.ReadNormalizedString();
if (!string.IsNullOrEmpty(val))
{
item.Tagline = val;
}
break;
default:
reader.Skip();
@ -842,7 +847,7 @@ namespace MediaBrowser.LocalMetadata.Parsers
userId = reader.ReadNormalizedString();
break;
case "CanEdit":
canEdit = string.Equals(reader.ReadElementContentAsString(), "true", StringComparison.OrdinalIgnoreCase);
canEdit = string.Equals(reader.ReadNormalizedString(), "true", StringComparison.OrdinalIgnoreCase);
break;
default:
reader.Skip();
@ -856,7 +861,7 @@ namespace MediaBrowser.LocalMetadata.Parsers
}
// This is valid
if (!string.IsNullOrWhiteSpace(userId) && Guid.TryParse(userId, out var guid))
if (!string.IsNullOrEmpty(userId) && Guid.TryParse(userId, out var guid))
{
return new PlaylistUserPermissions(guid, canEdit);
}

@ -10,6 +10,7 @@ using System.Text.RegularExpressions;
using System.Xml;
using Jellyfin.Data.Enums;
using Jellyfin.Extensions;
using MediaBrowser.Controller.Extensions;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
@ -531,42 +532,44 @@ namespace MediaBrowser.MediaEncoding.Probing
private void ProcessPairs(string key, List<NameValuePair> pairs, MediaInfo info)
{
List<BaseItemPerson> peoples = new List<BaseItemPerson>();
var distinctPairs = pairs.Select(p => p.Value)
.Where(i => !string.IsNullOrWhiteSpace(i))
.Trimmed()
.Distinct(StringComparer.OrdinalIgnoreCase);
if (string.Equals(key, "studio", StringComparison.OrdinalIgnoreCase))
{
info.Studios = pairs.Select(p => p.Value)
.Where(i => !string.IsNullOrWhiteSpace(i))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
info.Studios = distinctPairs.ToArray();
}
else if (string.Equals(key, "screenwriters", StringComparison.OrdinalIgnoreCase))
{
foreach (var pair in pairs)
foreach (var pair in distinctPairs)
{
peoples.Add(new BaseItemPerson
{
Name = pair.Value,
Name = pair,
Type = PersonKind.Writer
});
}
}
else if (string.Equals(key, "producers", StringComparison.OrdinalIgnoreCase))
{
foreach (var pair in pairs)
foreach (var pair in distinctPairs)
{
peoples.Add(new BaseItemPerson
{
Name = pair.Value,
Name = pair,
Type = PersonKind.Producer
});
}
}
else if (string.Equals(key, "directors", StringComparison.OrdinalIgnoreCase))
{
foreach (var pair in pairs)
foreach (var pair in distinctPairs)
{
peoples.Add(new BaseItemPerson
{
Name = pair.Value,
Name = pair,
Type = PersonKind.Director
});
}
@ -591,10 +594,10 @@ namespace MediaBrowser.MediaEncoding.Probing
switch (reader.Name)
{
case "key":
name = reader.ReadElementContentAsString();
name = reader.ReadNormalizedString();
break;
case "string":
value = reader.ReadElementContentAsString();
value = reader.ReadNormalizedString();
break;
default:
reader.Skip();
@ -607,8 +610,8 @@ namespace MediaBrowser.MediaEncoding.Probing
}
}
if (string.IsNullOrWhiteSpace(name)
|| string.IsNullOrWhiteSpace(value))
if (string.IsNullOrEmpty(name)
|| string.IsNullOrEmpty(value))
{
return null;
}
@ -1453,7 +1456,7 @@ namespace MediaBrowser.MediaEncoding.Probing
var genres = new List<string>(info.Genres);
foreach (var genre in Split(genreVal, true))
{
if (string.IsNullOrWhiteSpace(genre))
if (string.IsNullOrEmpty(genre))
{
continue;
}

@ -1,5 +1,3 @@
using System;
namespace MediaBrowser.Model.Providers
{
/// <summary>
@ -13,15 +11,11 @@ namespace MediaBrowser.Model.Providers
/// <param name="name">Name of the external id provider (IE: IMDB, MusicBrainz, etc).</param>
/// <param name="key">Key for this id. This key should be unique across all providers.</param>
/// <param name="type">Specific media type for this id.</param>
/// <param name="urlFormatString">URL format string.</param>
public ExternalIdInfo(string name, string key, ExternalIdMediaType? type, string? urlFormatString)
public ExternalIdInfo(string name, string key, ExternalIdMediaType? type)
{
Name = name;
Key = key;
Type = type;
#pragma warning disable CS0618 // Type or member is obsolete - Remove 10.11
UrlFormatString = urlFormatString;
#pragma warning restore CS0618 // Type or member is obsolete
}
/// <summary>
@ -46,11 +40,5 @@ namespace MediaBrowser.Model.Providers
/// This can be used along with the <see cref="Name"/> to localize the external id on the client.
/// </remarks>
public ExternalIdMediaType? Type { get; set; }
/// <summary>
/// Gets or sets the URL format string.
/// </summary>
[Obsolete("Obsolete in 10.10, to be removed in 10.11")]
public string? UrlFormatString { get; set; }
}
}

@ -4,76 +4,69 @@ using System;
using Jellyfin.Data.Entities;
using MediaBrowser.Model.Entities;
namespace MediaBrowser.Model.Querying
namespace MediaBrowser.Model.Querying;
public class NextUpQuery
{
public class NextUpQuery
public NextUpQuery()
{
public NextUpQuery()
{
EnableImageTypes = Array.Empty<ImageType>();
EnableTotalRecordCount = true;
DisableFirstEpisode = false;
NextUpDateCutoff = DateTime.MinValue;
EnableResumable = false;
EnableRewatching = false;
}
/// <summary>
/// Gets or sets the user.
/// </summary>
/// <value>The user.</value>
public required User User { get; set; }
EnableImageTypes = Array.Empty<ImageType>();
EnableTotalRecordCount = true;
NextUpDateCutoff = DateTime.MinValue;
EnableResumable = false;
EnableRewatching = false;
}
/// <summary>
/// Gets or sets the parent identifier.
/// </summary>
/// <value>The parent identifier.</value>
public Guid? ParentId { get; set; }
/// <summary>
/// Gets or sets the user.
/// </summary>
/// <value>The user.</value>
public required User User { get; set; }
/// <summary>
/// Gets or sets the series id.
/// </summary>
/// <value>The series id.</value>
public Guid? SeriesId { get; set; }
/// <summary>
/// Gets or sets the parent identifier.
/// </summary>
/// <value>The parent identifier.</value>
public Guid? ParentId { get; set; }
/// <summary>
/// Gets or sets the start index. Use for paging.
/// </summary>
/// <value>The start index.</value>
public int? StartIndex { get; set; }
/// <summary>
/// Gets or sets the series id.
/// </summary>
/// <value>The series id.</value>
public Guid? SeriesId { get; set; }
/// <summary>
/// Gets or sets the maximum number of items to return.
/// </summary>
/// <value>The limit.</value>
public int? Limit { get; set; }
/// <summary>
/// Gets or sets the start index. Use for paging.
/// </summary>
/// <value>The start index.</value>
public int? StartIndex { get; set; }
/// <summary>
/// Gets or sets the enable image types.
/// </summary>
/// <value>The enable image types.</value>
public ImageType[] EnableImageTypes { get; set; }
/// <summary>
/// Gets or sets the maximum number of items to return.
/// </summary>
/// <value>The limit.</value>
public int? Limit { get; set; }
public bool EnableTotalRecordCount { get; set; }
/// <summary>
/// Gets or sets the enable image types.
/// </summary>
/// <value>The enable image types.</value>
public ImageType[] EnableImageTypes { get; set; }
/// <summary>
/// Gets or sets a value indicating whether do disable sending first episode as next up.
/// </summary>
public bool DisableFirstEpisode { get; set; }
public bool EnableTotalRecordCount { get; set; }
/// <summary>
/// Gets or sets a value indicating the oldest date for a show to appear in Next Up.
/// </summary>
public DateTime NextUpDateCutoff { get; set; }
/// <summary>
/// Gets or sets a value indicating the oldest date for a show to appear in Next Up.
/// </summary>
public DateTime NextUpDateCutoff { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to include resumable episodes as next up.
/// </summary>
public bool EnableResumable { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to include resumable episodes as next up.
/// </summary>
public bool EnableResumable { get; set; }
/// <summary>
/// Gets or sets a value indicating whether getting rewatching next up list.
/// </summary>
public bool EnableRewatching { get; set; }
}
/// <summary>
/// Gets or sets a value indicating whether getting rewatching next up list.
/// </summary>
public bool EnableRewatching { get; set; }
}

@ -1146,13 +1146,24 @@ namespace MediaBrowser.Providers.Manager
private static void MergePeople(IReadOnlyList<PersonInfo> source, IReadOnlyList<PersonInfo> target)
{
foreach (var person in target)
var sourceByName = source.ToLookup(p => p.Name.RemoveDiacritics(), StringComparer.OrdinalIgnoreCase);
var targetByName = target.ToLookup(p => p.Name.RemoveDiacritics(), StringComparer.OrdinalIgnoreCase);
foreach (var name in targetByName.Select(g => g.Key))
{
var normalizedName = person.Name.RemoveDiacritics();
var personInSource = source.FirstOrDefault(i => string.Equals(i.Name.RemoveDiacritics(), normalizedName, StringComparison.OrdinalIgnoreCase));
var targetPeople = targetByName[name].ToArray();
var sourcePeople = sourceByName[name].ToArray();
if (sourcePeople.Length == 0)
{
continue;
}
if (personInSource is not null)
for (int i = 0; i < targetPeople.Length; i++)
{
var person = targetPeople[i];
var personInSource = i < sourcePeople.Length ? sourcePeople[i] : sourcePeople[0];
foreach (var providerId in personInSource.ProviderIds)
{
person.ProviderIds.TryAdd(providerId.Key, providerId.Value);
@ -1162,6 +1173,16 @@ namespace MediaBrowser.Providers.Manager
{
person.ImageUrl = personInSource.ImageUrl;
}
if (!string.IsNullOrWhiteSpace(personInSource.Role) && string.IsNullOrWhiteSpace(person.Role))
{
person.Role = personInSource.Role;
}
if (personInSource.SortOrder.HasValue && !person.SortOrder.HasValue)
{
person.SortOrder = personInSource.SortOrder;
}
}
}
}

@ -899,35 +899,10 @@ namespace MediaBrowser.Providers.Manager
/// <inheritdoc/>
public IEnumerable<ExternalUrl> GetExternalUrls(BaseItem item)
{
#pragma warning disable CS0618 // Type or member is obsolete - Remove 10.11
var legacyExternalIdUrls = GetExternalIds(item)
.Select(i =>
{
var urlFormatString = i.UrlFormatString;
if (string.IsNullOrEmpty(urlFormatString)
|| !item.TryGetProviderId(i.Key, out var providerId))
{
return null;
}
return new ExternalUrl
{
Name = i.ProviderName,
Url = string.Format(
CultureInfo.InvariantCulture,
urlFormatString,
providerId)
};
})
.OfType<ExternalUrl>();
#pragma warning restore CS0618 // Type or member is obsolete
var externalUrls = _externalUrlProviders
return _externalUrlProviders
.SelectMany(p => p
.GetExternalUrls(item)
.Select(externalUrl => new ExternalUrl { Name = p.Name, Url = externalUrl }));
return legacyExternalIdUrls.Concat(externalUrls).OrderBy(u => u.Name);
}
/// <inheritdoc/>
@ -937,10 +912,7 @@ namespace MediaBrowser.Providers.Manager
.Select(i => new ExternalIdInfo(
name: i.ProviderName,
key: i.Key,
type: i.Type,
#pragma warning disable CS0618 // Type or member is obsolete - Remove 10.11
urlFormatString: i.UrlFormatString));
#pragma warning restore CS0618 // Type or member is obsolete
type: i.Type));
}
/// <inheritdoc/>

@ -6,6 +6,7 @@ using System.Threading;
using System.Threading.Tasks;
using ATL;
using Jellyfin.Data.Enums;
using Jellyfin.Extensions;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Library;
@ -175,11 +176,15 @@ namespace MediaBrowser.Providers.MediaInfo
_logger.LogWarning("File {File} only has ID3v1 tags, some fields may be truncated", audio.Path);
}
track.Title = string.IsNullOrEmpty(track.Title) ? mediaInfo.Name : track.Title;
track.Album = string.IsNullOrEmpty(track.Album) ? mediaInfo.Album : track.Album;
track.Year = track.Year is null or 0 ? mediaInfo.ProductionYear : track.Year;
track.TrackNumber = track.TrackNumber is null or 0 ? mediaInfo.IndexNumber : track.TrackNumber;
track.DiscNumber = track.DiscNumber is null or 0 ? mediaInfo.ParentIndexNumber : track.DiscNumber;
// We should never use the property setter of the ATL.Track class.
// That setter is meant for its own tag parser and external editor usage and will have unwanted side effects
// For example, setting the Year property will also set the Date property, which is not what we want here.
// To properly handle fallback values, we make a clone of those fields when valid.
var trackTitle = (string.IsNullOrEmpty(track.Title) ? mediaInfo.Name : track.Title).Trim();
var trackAlbum = (string.IsNullOrEmpty(track.Album) ? mediaInfo.Album : track.Album).Trim();
var trackYear = track.Year is null or 0 ? mediaInfo.ProductionYear : track.Year;
var trackTrackNumber = track.TrackNumber is null or 0 ? mediaInfo.IndexNumber : track.TrackNumber;
var trackDiscNumber = track.DiscNumber is null or 0 ? mediaInfo.ParentIndexNumber : track.DiscNumber;
if (audio.SupportsPeople && !audio.LockedFields.Contains(MetadataField.Cast))
{
@ -193,11 +198,11 @@ namespace MediaBrowser.Providers.MediaInfo
foreach (var albumArtist in albumArtists)
{
if (!string.IsNullOrEmpty(albumArtist))
if (!string.IsNullOrWhiteSpace(albumArtist))
{
PeopleHelper.AddPerson(people, new PersonInfo
{
Name = albumArtist,
Name = albumArtist.Trim(),
Type = PersonKind.AlbumArtist
});
}
@ -225,11 +230,11 @@ namespace MediaBrowser.Providers.MediaInfo
foreach (var performer in performers)
{
if (!string.IsNullOrEmpty(performer))
if (!string.IsNullOrWhiteSpace(performer))
{
PeopleHelper.AddPerson(people, new PersonInfo
{
Name = performer,
Name = performer.Trim(),
Type = PersonKind.Artist
});
}
@ -237,11 +242,11 @@ namespace MediaBrowser.Providers.MediaInfo
foreach (var composer in track.Composer.Split(InternalValueSeparator))
{
if (!string.IsNullOrEmpty(composer))
if (!string.IsNullOrWhiteSpace(composer))
{
PeopleHelper.AddPerson(people, new PersonInfo
{
Name = composer,
Name = composer.Trim(),
Type = PersonKind.Composer
});
}
@ -276,22 +281,22 @@ namespace MediaBrowser.Providers.MediaInfo
}
}
if (!audio.LockedFields.Contains(MetadataField.Name) && !string.IsNullOrEmpty(track.Title))
if (!audio.LockedFields.Contains(MetadataField.Name) && !string.IsNullOrEmpty(trackTitle))
{
audio.Name = track.Title;
audio.Name = trackTitle;
}
if (options.ReplaceAllMetadata)
{
audio.Album = track.Album;
audio.IndexNumber = track.TrackNumber;
audio.ParentIndexNumber = track.DiscNumber;
audio.Album = trackAlbum;
audio.IndexNumber = trackTrackNumber;
audio.ParentIndexNumber = trackDiscNumber;
}
else
{
audio.Album ??= track.Album;
audio.IndexNumber ??= track.TrackNumber;
audio.ParentIndexNumber ??= track.DiscNumber;
audio.Album ??= trackAlbum;
audio.IndexNumber ??= trackTrackNumber;
audio.ParentIndexNumber ??= trackDiscNumber;
}
if (track.Date.HasValue)
@ -299,11 +304,12 @@ namespace MediaBrowser.Providers.MediaInfo
audio.PremiereDate = track.Date;
}
if (track.Year.HasValue)
if (trackYear.HasValue)
{
var year = track.Year.Value;
var year = trackYear.Value;
audio.ProductionYear = year;
// ATL library handles such fallback this with its own internal logic, but we also need to handle it here for the ffprobe fallbacks.
if (!audio.PremiereDate.HasValue)
{
try
@ -312,7 +318,7 @@ namespace MediaBrowser.Providers.MediaInfo
}
catch (ArgumentOutOfRangeException ex)
{
_logger.LogError(ex, "Error parsing YEAR tag in {File}. '{TagValue}' is an invalid year", audio.Path, track.Year);
_logger.LogError(ex, "Error parsing YEAR tag in {File}. '{TagValue}' is an invalid year", audio.Path, trackYear);
}
}
}
@ -326,6 +332,8 @@ namespace MediaBrowser.Providers.MediaInfo
genres = genres.SelectMany(g => SplitWithCustomDelimiter(g, libraryOptions.GetCustomTagDelimiters(), libraryOptions.DelimiterWhitelist)).ToArray();
}
genres = genres.Trimmed().Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
audio.Genres = options.ReplaceAllMetadata || audio.Genres is null || audio.Genres.Length == 0
? genres
: audio.Genres;

@ -6,6 +6,7 @@ using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Extensions;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Chapters;
using MediaBrowser.Controller.Configuration;
@ -407,7 +408,7 @@ namespace MediaBrowser.Providers.MediaInfo
{
video.Genres = Array.Empty<string>();
foreach (var genre in data.Genres)
foreach (var genre in data.Genres.Trimmed())
{
video.AddGenre(genre);
}
@ -516,9 +517,9 @@ namespace MediaBrowser.Providers.MediaInfo
{
PeopleHelper.AddPerson(people, new PersonInfo
{
Name = person.Name,
Name = person.Name.Trim(),
Type = person.Type,
Role = person.Role
Role = person.Role.Trim()
});
}

@ -21,9 +21,6 @@ namespace MediaBrowser.Providers.Movies
/// <inheritdoc />
public ExternalIdMediaType? Type => null;
/// <inheritdoc />
public string UrlFormatString => "https://www.imdb.com/title/{0}";
/// <inheritdoc />
public bool Supports(IHasProviderIds item)
{

@ -0,0 +1,32 @@
using System.Collections.Generic;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
namespace MediaBrowser.Providers.Movies;
/// <summary>
/// External URLs for IMDb.
/// </summary>
public class ImdbExternalUrlProvider : IExternalUrlProvider
{
/// <inheritdoc/>
public string Name => "IMDb";
/// <inheritdoc/>
public IEnumerable<string> GetExternalUrls(BaseItem item)
{
var baseUrl = "https://www.imdb.com/";
if (item.TryGetProviderId(MetadataProvider.Imdb, out var externalId))
{
if (item is Person)
{
yield return baseUrl + $"name/{externalId}";
}
else
{
yield return baseUrl + $"title/{externalId}";
}
}
}
}

@ -18,9 +18,6 @@ namespace MediaBrowser.Providers.Movies
/// <inheritdoc />
public ExternalIdMediaType? Type => ExternalIdMediaType.Person;
/// <inheritdoc />
public string UrlFormatString => "https://www.imdb.com/name/{0}";
/// <inheritdoc />
public bool Supports(IHasProviderIds item) => item is Person;
}

@ -187,7 +187,7 @@ namespace MediaBrowser.Providers.Music
{
PeopleHelper.AddPerson(people, new PersonInfo
{
Name = albumArtist,
Name = albumArtist.Trim(),
Type = PersonKind.AlbumArtist
});
}
@ -196,7 +196,7 @@ namespace MediaBrowser.Providers.Music
{
PeopleHelper.AddPerson(people, new PersonInfo
{
Name = artist,
Name = artist.Trim(),
Type = PersonKind.Artist
});
}

@ -18,9 +18,6 @@ namespace MediaBrowser.Providers.Music
/// <inheritdoc />
public ExternalIdMediaType? Type => null;
/// <inheritdoc />
public string? UrlFormatString => null;
/// <inheritdoc />
public bool Supports(IHasProviderIds item)
=> item is MusicVideo;

@ -18,9 +18,6 @@ namespace MediaBrowser.Providers.Plugins.AudioDb
/// <inheritdoc />
public ExternalIdMediaType? Type => null;
/// <inheritdoc />
public string UrlFormatString => "https://www.theaudiodb.com/album/{0}";
/// <inheritdoc />
public bool Supports(IHasProviderIds item) => item is MusicAlbum;
}

@ -0,0 +1,31 @@
using System.Collections.Generic;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
namespace MediaBrowser.Providers.Plugins.AudioDb;
/// <summary>
/// External artist URLs for AudioDb.
/// </summary>
public class AudioDbAlbumExternalUrlProvider : IExternalUrlProvider
{
/// <inheritdoc/>
public string Name => "TheAudioDb Album";
/// <inheritdoc/>
public IEnumerable<string> GetExternalUrls(BaseItem item)
{
if (item.TryGetProviderId(MetadataProvider.AudioDbAlbum, out var externalId))
{
var baseUrl = "https://www.theaudiodb.com/";
switch (item)
{
case MusicAlbum:
yield return baseUrl + $"album/{externalId}";
break;
}
}
}
}

@ -4,7 +4,6 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
@ -50,9 +49,7 @@ namespace MediaBrowser.Providers.Plugins.AudioDb
/// <inheritdoc />
public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken)
{
var id = item.GetProviderId(MetadataProvider.MusicBrainzReleaseGroup);
if (!string.IsNullOrWhiteSpace(id))
if (item.TryGetProviderId(MetadataProvider.MusicBrainzReleaseGroup, out var id))
{
await AudioDbAlbumProvider.Current.EnsureInfo(id, cancellationToken).ConfigureAwait(false);
@ -70,7 +67,7 @@ namespace MediaBrowser.Providers.Plugins.AudioDb
}
}
return Enumerable.Empty<RemoteImageInfo>();
return [];
}
private List<RemoteImageInfo> GetImages(AudioDbAlbumProvider.Album item)

@ -18,9 +18,6 @@ namespace MediaBrowser.Providers.Plugins.AudioDb
/// <inheritdoc />
public ExternalIdMediaType? Type => ExternalIdMediaType.Artist;
/// <inheritdoc />
public string UrlFormatString => "https://www.theaudiodb.com/artist/{0}";
/// <inheritdoc />
public bool Supports(IHasProviderIds item) => item is MusicArtist;
}

@ -0,0 +1,32 @@
using System.Collections.Generic;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
namespace MediaBrowser.Providers.Plugins.AudioDb;
/// <summary>
/// External artist URLs for AudioDb.
/// </summary>
public class AudioDbArtistExternalUrlProvider : IExternalUrlProvider
{
/// <inheritdoc/>
public string Name => "TheAudioDb Artist";
/// <inheritdoc/>
public IEnumerable<string> GetExternalUrls(BaseItem item)
{
if (item.TryGetProviderId(MetadataProvider.AudioDbArtist, out var externalId))
{
var baseUrl = "https://www.theaudiodb.com/";
switch (item)
{
case MusicAlbum:
case Person:
yield return baseUrl + $"artist/{externalId}";
break;
}
}
}
}

@ -4,7 +4,6 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
@ -43,21 +42,19 @@ namespace MediaBrowser.Providers.Plugins.AudioDb
/// <inheritdoc />
public IEnumerable<ImageType> GetSupportedImages(BaseItem item)
{
return new ImageType[]
{
return
[
ImageType.Primary,
ImageType.Logo,
ImageType.Banner,
ImageType.Backdrop
};
];
}
/// <inheritdoc />
public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken)
{
var id = item.GetProviderId(MetadataProvider.MusicBrainzArtist);
if (!string.IsNullOrWhiteSpace(id))
if (item.TryGetProviderId(MetadataProvider.MusicBrainzArtist, out var id))
{
await AudioDbArtistProvider.Current.EnsureArtistInfo(id, cancellationToken).ConfigureAwait(false);
@ -75,7 +72,7 @@ namespace MediaBrowser.Providers.Plugins.AudioDb
}
}
return Enumerable.Empty<RemoteImageInfo>();
return [];
}
private List<RemoteImageInfo> GetImages(AudioDbArtistProvider.Artist item)

@ -18,9 +18,6 @@ namespace MediaBrowser.Providers.Plugins.AudioDb
/// <inheritdoc />
public ExternalIdMediaType? Type => ExternalIdMediaType.Album;
/// <inheritdoc />
public string UrlFormatString => "https://www.theaudiodb.com/album/{0}";
/// <inheritdoc />
public bool Supports(IHasProviderIds item) => item is Audio;
}

@ -18,9 +18,6 @@ namespace MediaBrowser.Providers.Plugins.AudioDb
/// <inheritdoc />
public ExternalIdMediaType? Type => ExternalIdMediaType.OtherArtist;
/// <inheritdoc />
public string UrlFormatString => "https://www.theaudiodb.com/artist/{0}";
/// <inheritdoc />
public bool Supports(IHasProviderIds item) => item is Audio || item is MusicAlbum;
}

@ -19,9 +19,6 @@ public class MusicBrainzAlbumArtistExternalId : IExternalId
/// <inheritdoc />
public ExternalIdMediaType? Type => ExternalIdMediaType.AlbumArtist;
/// <inheritdoc />
public string UrlFormatString => Plugin.Instance!.Configuration.Server + "/artist/{0}";
/// <inheritdoc />
public bool Supports(IHasProviderIds item) => item is Audio;
}

@ -0,0 +1,28 @@
using System.Collections.Generic;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
namespace MediaBrowser.Providers.Plugins.MusicBrainz;
/// <summary>
/// External album artist URLs for MusicBrainz.
/// </summary>
public class MusicBrainzAlbumArtistExternalUrlProvider : IExternalUrlProvider
{
/// <inheritdoc/>
public string Name => "MusicBrainz Album Artist";
/// <inheritdoc/>
public IEnumerable<string> GetExternalUrls(BaseItem item)
{
if (item is MusicAlbum)
{
if (item.TryGetProviderId(MetadataProvider.MusicBrainzAlbumArtist, out var externalId))
{
yield return Plugin.Instance!.Configuration.Server + $"/artist/{externalId}";
}
}
}
}

@ -19,9 +19,6 @@ public class MusicBrainzAlbumExternalId : IExternalId
/// <inheritdoc />
public ExternalIdMediaType? Type => ExternalIdMediaType.Album;
/// <inheritdoc />
public string UrlFormatString => Plugin.Instance!.Configuration.Server + "/release/{0}";
/// <inheritdoc />
public bool Supports(IHasProviderIds item) => item is Audio || item is MusicAlbum;
}

@ -0,0 +1,28 @@
using System.Collections.Generic;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
namespace MediaBrowser.Providers.Plugins.MusicBrainz;
/// <summary>
/// External album URLs for MusicBrainz.
/// </summary>
public class MusicBrainzAlbumExternalUrlProvider : IExternalUrlProvider
{
/// <inheritdoc/>
public string Name => "MusicBrainz Album";
/// <inheritdoc/>
public IEnumerable<string> GetExternalUrls(BaseItem item)
{
if (item is MusicAlbum)
{
if (item.TryGetProviderId(MetadataProvider.MusicBrainzAlbum, out var externalId))
{
yield return Plugin.Instance!.Configuration.Server + $"/release/{externalId}";
}
}
}
}

@ -19,9 +19,6 @@ public class MusicBrainzArtistExternalId : IExternalId
/// <inheritdoc />
public ExternalIdMediaType? Type => ExternalIdMediaType.Artist;
/// <inheritdoc />
public string UrlFormatString => Plugin.Instance!.Configuration.Server + "/artist/{0}";
/// <inheritdoc />
public bool Supports(IHasProviderIds item) => item is MusicArtist;
}

@ -0,0 +1,32 @@
using System.Collections.Generic;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
namespace MediaBrowser.Providers.Plugins.MusicBrainz;
/// <summary>
/// External artist URLs for MusicBrainz.
/// </summary>
public class MusicBrainzArtistExternalUrlProvider : IExternalUrlProvider
{
/// <inheritdoc/>
public string Name => "MusicBrainz Artist";
/// <inheritdoc/>
public IEnumerable<string> GetExternalUrls(BaseItem item)
{
if (item.TryGetProviderId(MetadataProvider.MusicBrainzArtist, out var externalId))
{
switch (item)
{
case MusicAlbum:
case Person:
yield return Plugin.Instance!.Configuration.Server + $"/artist/{externalId}";
break;
}
}
}
}

@ -19,9 +19,6 @@ public class MusicBrainzOtherArtistExternalId : IExternalId
/// <inheritdoc />
public ExternalIdMediaType? Type => ExternalIdMediaType.OtherArtist;
/// <inheritdoc />
public string UrlFormatString => Plugin.Instance!.Configuration.Server + "/artist/{0}";
/// <inheritdoc />
public bool Supports(IHasProviderIds item) => item is Audio or MusicAlbum;
}

@ -19,9 +19,6 @@ public class MusicBrainzRecordingId : IExternalId
/// <inheritdoc />
public ExternalIdMediaType? Type => ExternalIdMediaType.Recording;
/// <inheritdoc />
public string UrlFormatString => Plugin.Instance!.Configuration.Server + "/recording/{0}";
/// <inheritdoc />
public bool Supports(IHasProviderIds item) => item is Audio;
}

@ -19,9 +19,6 @@ public class MusicBrainzReleaseGroupExternalId : IExternalId
/// <inheritdoc />
public ExternalIdMediaType? Type => ExternalIdMediaType.ReleaseGroup;
/// <inheritdoc />
public string UrlFormatString => Plugin.Instance!.Configuration.Server + "/release-group/{0}";
/// <inheritdoc />
public bool Supports(IHasProviderIds item) => item is Audio or MusicAlbum;
}

@ -0,0 +1,28 @@
using System.Collections.Generic;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
namespace MediaBrowser.Providers.Plugins.MusicBrainz;
/// <summary>
/// External release group URLs for MusicBrainz.
/// </summary>
public class MusicBrainzReleaseGroupExternalUrlProvider : IExternalUrlProvider
{
/// <inheritdoc/>
public string Name => "MusicBrainz Release Group";
/// <inheritdoc/>
public IEnumerable<string> GetExternalUrls(BaseItem item)
{
if (item is MusicAlbum)
{
if (item.TryGetProviderId(MetadataProvider.MusicBrainzReleaseGroup, out var externalId))
{
yield return Plugin.Instance!.Configuration.Server + $"/release-group/{externalId}";
}
}
}
}

@ -0,0 +1,28 @@
using System.Collections.Generic;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
namespace MediaBrowser.Providers.Plugins.MusicBrainz;
/// <summary>
/// External track URLs for MusicBrainz.
/// </summary>
public class MusicBrainzTrackExternalUrlProvider : IExternalUrlProvider
{
/// <inheritdoc/>
public string Name => "MusicBrainz Track";
/// <inheritdoc/>
public IEnumerable<string> GetExternalUrls(BaseItem item)
{
if (item is Audio)
{
if (item.TryGetProviderId(MetadataProvider.MusicBrainzTrack, out var externalId))
{
yield return Plugin.Instance!.Configuration.Server + $"/track/{externalId}";
}
}
}
}

@ -19,9 +19,6 @@ public class MusicBrainzTrackId : IExternalId
/// <inheritdoc />
public ExternalIdMediaType? Type => ExternalIdMediaType.Track;
/// <inheritdoc />
public string UrlFormatString => Plugin.Instance!.Configuration.Server + "/track/{0}";
/// <inheritdoc />
public bool Supports(IHasProviderIds item) => item is Audio;
}

@ -421,7 +421,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb
{
var person = new PersonInfo
{
Name = result.Director,
Name = result.Director.Trim(),
Type = PersonKind.Director
};
@ -432,7 +432,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb
{
var person = new PersonInfo
{
Name = result.Writer,
Name = result.Writer.Trim(),
Type = PersonKind.Writer
};

@ -20,9 +20,6 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets
/// <inheritdoc />
public ExternalIdMediaType? Type => ExternalIdMediaType.BoxSet;
/// <inheritdoc />
public string UrlFormatString => TmdbUtils.BaseTmdbUrl + "collection/{0}";
/// <inheritdoc />
public bool Supports(IHasProviderIds item)
{

@ -20,9 +20,6 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
/// <inheritdoc />
public ExternalIdMediaType? Type => ExternalIdMediaType.Movie;
/// <inheritdoc />
public string UrlFormatString => TmdbUtils.BaseTmdbUrl + "movie/{0}";
/// <inheritdoc />
public bool Supports(IHasProviderIds item)
{

@ -234,7 +234,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
var genres = movieResult.Genres;
foreach (var genre in genres.Select(g => g.Name))
foreach (var genre in genres.Select(g => g.Name).Trimmed())
{
movie.AddGenre(genre);
}
@ -254,7 +254,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
var personInfo = new PersonInfo
{
Name = actor.Name.Trim(),
Role = actor.Character,
Role = actor.Character.Trim(),
Type = PersonKind.Actor,
SortOrder = actor.Order
};
@ -289,7 +289,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
var personInfo = new PersonInfo
{
Name = person.Name.Trim(),
Role = person.Job,
Role = person.Job?.Trim(),
Type = type
};

@ -19,9 +19,6 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People
/// <inheritdoc />
public ExternalIdMediaType? Type => ExternalIdMediaType.Person;
/// <inheritdoc />
public string UrlFormatString => TmdbUtils.BaseTmdbUrl + "person/{0}";
/// <inheritdoc />
public bool Supports(IHasProviderIds item)
{

@ -211,7 +211,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
metadataResult.AddPerson(new PersonInfo
{
Name = actor.Name.Trim(),
Role = actor.Character,
Role = actor.Character.Trim(),
Type = PersonKind.Actor,
SortOrder = actor.Order
});
@ -225,7 +225,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
metadataResult.AddPerson(new PersonInfo
{
Name = guest.Name.Trim(),
Role = guest.Character,
Role = guest.Character.Trim(),
Type = PersonKind.GuestStar,
SortOrder = guest.Order
});
@ -249,7 +249,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
metadataResult.AddPerson(new PersonInfo
{
Name = person.Name.Trim(),
Role = person.Job,
Role = person.Job?.Trim(),
Type = type
});
}

@ -82,12 +82,13 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
var cast = credits.Cast.OrderBy(c => c.Order).Take(Plugin.Instance.Configuration.MaxCastMembers).ToList();
for (var i = 0; i < cast.Count; i++)
{
var member = cast[i];
result.AddPerson(new PersonInfo
{
Name = cast[i].Name.Trim(),
Role = cast[i].Character,
Name = member.Name.Trim(),
Role = member.Character.Trim(),
Type = PersonKind.Actor,
SortOrder = cast[i].Order
SortOrder = member.Order
});
}
}
@ -108,7 +109,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
result.AddPerson(new PersonInfo
{
Name = person.Name.Trim(),
Role = person.Job,
Role = person.Job?.Trim(),
Type = type
});
}

@ -19,9 +19,6 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
/// <inheritdoc />
public ExternalIdMediaType? Type => ExternalIdMediaType.Series;
/// <inheritdoc />
public string UrlFormatString => TmdbUtils.BaseTmdbUrl + "tv/{0}";
/// <inheritdoc />
public bool Supports(IHasProviderIds item)
{

@ -330,7 +330,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
var personInfo = new PersonInfo
{
Name = actor.Name.Trim(),
Role = actor.Character,
Role = actor.Character.Trim(),
Type = PersonKind.Actor,
SortOrder = actor.Order,
ImageUrl = _tmdbClientManager.GetPosterUrl(actor.ProfilePath)
@ -368,7 +368,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
yield return new PersonInfo
{
Name = person.Name.Trim(),
Role = person.Job,
Role = person.Job?.Trim(),
Type = type
};
}

@ -0,0 +1,95 @@
using System;
using System.Collections.Generic;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using TMDbLib.Objects.TvShows;
namespace MediaBrowser.Providers.Plugins.Tmdb;
/// <summary>
/// External URLs for TMDb.
/// </summary>
public class TmdbExternalUrlProvider : IExternalUrlProvider
{
/// <inheritdoc/>
public string Name => "TMDB";
/// <inheritdoc/>
public IEnumerable<string> GetExternalUrls(BaseItem item)
{
switch (item)
{
case Series:
if (item.TryGetProviderId(MetadataProvider.Tmdb, out var externalId))
{
yield return TmdbUtils.BaseTmdbUrl + $"tv/{externalId}";
}
break;
case Season season:
if (season.Series.TryGetProviderId(MetadataProvider.Tmdb, out var seriesExternalId))
{
var orderString = season.Series.DisplayOrder;
if (string.IsNullOrEmpty(orderString))
{
// Default order is airdate
yield return TmdbUtils.BaseTmdbUrl + $"tv/{seriesExternalId}/season/{season.IndexNumber}";
}
if (Enum.TryParse<TvGroupType>(season.Series.DisplayOrder, out var order))
{
if (order.Equals(TvGroupType.OriginalAirDate))
{
yield return TmdbUtils.BaseTmdbUrl + $"tv/{seriesExternalId}/season/{season.IndexNumber}";
}
}
}
break;
case Episode episode:
if (episode.Series.TryGetProviderId(MetadataProvider.Imdb, out seriesExternalId))
{
var orderString = episode.Series.DisplayOrder;
if (string.IsNullOrEmpty(orderString))
{
// Default order is airdate
yield return TmdbUtils.BaseTmdbUrl + $"tv/{seriesExternalId}/season/{episode.Season.IndexNumber}/episode/{episode.IndexNumber}";
}
if (Enum.TryParse<TvGroupType>(orderString, out var order))
{
if (order.Equals(TvGroupType.OriginalAirDate))
{
yield return TmdbUtils.BaseTmdbUrl + $"tv/{seriesExternalId}/season/{episode.Season.IndexNumber}/episode/{episode.IndexNumber}";
}
}
}
break;
case Movie:
if (item.TryGetProviderId(MetadataProvider.Tmdb, out externalId))
{
yield return TmdbUtils.BaseTmdbUrl + $"movie/{externalId}";
}
break;
case Person:
if (item.TryGetProviderId(MetadataProvider.Tmdb, out externalId))
{
yield return TmdbUtils.BaseTmdbUrl + $"person/{externalId}";
}
break;
case BoxSet:
if (item.TryGetProviderId(MetadataProvider.Tmdb, out externalId))
{
yield return TmdbUtils.BaseTmdbUrl + $"collection/{externalId}";
}
break;
}
}
}

@ -18,9 +18,6 @@ namespace MediaBrowser.Providers.TV
/// <inheritdoc />
public ExternalIdMediaType? Type => null;
/// <inheritdoc />
public string UrlFormatString => "http://tvlistings.zap2it.com/overview.html?programSeriesId={0}";
/// <inheritdoc />
public bool Supports(IHasProviderIds item) => item is Series;
}

@ -0,0 +1,24 @@
using System.Collections.Generic;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
namespace MediaBrowser.Providers.TV;
/// <summary>
/// External URLs for TMDb.
/// </summary>
public class Zap2ItExternalUrlProvider : IExternalUrlProvider
{
/// <inheritdoc/>
public string Name => "Zap2It";
/// <inheritdoc/>
public IEnumerable<string> GetExternalUrls(BaseItem item)
{
if (item.TryGetProviderId(MetadataProvider.Zap2It, out var externalId))
{
yield return $"http://tvlistings.zap2it.com/overview.html?programSeriesId={externalId}";
}
}
}

@ -4,6 +4,7 @@ using System.Globalization;
using System.IO;
using System.Linq;
using System.Xml;
using Jellyfin.Extensions;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
@ -55,12 +56,12 @@ namespace MediaBrowser.XbmcMetadata.Savers
{
var album = (MusicAlbum)item;
foreach (var artist in album.Artists)
foreach (var artist in album.Artists.Trimmed().OrderBy(artist => artist))
{
writer.WriteElementString("artist", artist);
}
foreach (var artist in album.AlbumArtists)
foreach (var artist in album.AlbumArtists.Trimmed().OrderBy(artist => artist))
{
writer.WriteElementString("albumartist", artist);
}
@ -70,11 +71,20 @@ namespace MediaBrowser.XbmcMetadata.Savers
private void AddTracks(IEnumerable<BaseItem> tracks, XmlWriter writer)
{
foreach (var track in tracks.OrderBy(i => i.ParentIndexNumber ?? 0).ThenBy(i => i.IndexNumber ?? 0))
foreach (var track in tracks
.OrderBy(i => i.ParentIndexNumber ?? 0)
.ThenBy(i => i.IndexNumber ?? 0)
.ThenBy(i => SortNameOrName(i))
.ThenBy(i => i.Name?.Trim()))
{
writer.WriteStartElement("track");
if (track.IndexNumber.HasValue)
if (track.ParentIndexNumber.HasValue && track.ParentIndexNumber.Value != 0)
{
writer.WriteElementString("disc", track.ParentIndexNumber.Value.ToString(CultureInfo.InvariantCulture));
}
if (track.IndexNumber.HasValue && track.IndexNumber.Value != 0)
{
writer.WriteElementString("position", track.IndexNumber.Value.ToString(CultureInfo.InvariantCulture));
}

@ -1,6 +1,7 @@
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Xml;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
@ -69,7 +70,10 @@ namespace MediaBrowser.XbmcMetadata.Savers
private void AddAlbums(IReadOnlyList<BaseItem> albums, XmlWriter writer)
{
foreach (var album in albums)
foreach (var album in albums
.OrderBy(album => album.ProductionYear ?? 0)
.ThenBy(album => SortNameOrName(album))
.ThenBy(album => album.Name?.Trim()))
{
writer.WriteStartElement("album");

@ -488,7 +488,9 @@ namespace MediaBrowser.XbmcMetadata.Savers
var directors = people
.Where(i => i.IsType(PersonKind.Director))
.Select(i => i.Name)
.Select(i => i.Name?.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(i => i)
.ToList();
foreach (var person in directors)
@ -498,8 +500,9 @@ namespace MediaBrowser.XbmcMetadata.Savers
var writers = people
.Where(i => i.IsType(PersonKind.Writer))
.Select(i => i.Name)
.Select(i => i.Name?.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(i => i)
.ToList();
foreach (var person in writers)
@ -512,7 +515,7 @@ namespace MediaBrowser.XbmcMetadata.Savers
writer.WriteElementString("credits", person);
}
foreach (var trailer in item.RemoteTrailers)
foreach (var trailer in item.RemoteTrailers.OrderBy(t => t.Url?.Trim()))
{
writer.WriteElementString("trailer", GetOutputTrailerUrl(trailer.Url));
}
@ -544,16 +547,13 @@ namespace MediaBrowser.XbmcMetadata.Savers
writer.WriteElementString("aspectratio", hasAspectRatio.AspectRatio);
}
var tmdbCollection = item.GetProviderId(MetadataProvider.TmdbCollection);
if (!string.IsNullOrEmpty(tmdbCollection))
if (item.TryGetProviderId(MetadataProvider.Tmdb, out var tmdbCollection))
{
writer.WriteElementString("collectionnumber", tmdbCollection);
writtenProviderIds.Add(MetadataProvider.TmdbCollection.ToString());
}
var imdb = item.GetProviderId(MetadataProvider.Imdb);
if (!string.IsNullOrEmpty(imdb))
if (item.TryGetProviderId(MetadataProvider.Imdb, out var imdb))
{
if (item is Series)
{
@ -570,16 +570,14 @@ namespace MediaBrowser.XbmcMetadata.Savers
// Series xml saver already saves this
if (item is not Series)
{
var tvdb = item.GetProviderId(MetadataProvider.Tvdb);
if (!string.IsNullOrEmpty(tvdb))
if (item.TryGetProviderId(MetadataProvider.Tvdb, out var tvdb))
{
writer.WriteElementString("tvdbid", tvdb);
writtenProviderIds.Add(MetadataProvider.Tvdb.ToString());
}
}
var tmdb = item.GetProviderId(MetadataProvider.Tmdb);
if (!string.IsNullOrEmpty(tmdb))
if (item.TryGetProviderId(MetadataProvider.Tmdb, out var tmdb))
{
writer.WriteElementString("tmdbid", tmdb);
writtenProviderIds.Add(MetadataProvider.Tmdb.ToString());
@ -660,22 +658,22 @@ namespace MediaBrowser.XbmcMetadata.Savers
writer.WriteElementString("tagline", item.Tagline);
}
foreach (var country in item.ProductionLocations)
foreach (var country in item.ProductionLocations.Trimmed().OrderBy(country => country))
{
writer.WriteElementString("country", country);
}
foreach (var genre in item.Genres)
foreach (var genre in item.Genres.Trimmed().OrderBy(genre => genre))
{
writer.WriteElementString("genre", genre);
}
foreach (var studio in item.Studios)
foreach (var studio in item.Studios.Trimmed().OrderBy(studio => studio))
{
writer.WriteElementString("studio", studio);
}
foreach (var tag in item.Tags)
foreach (var tag in item.Tags.Trimmed().OrderBy(tag => tag))
{
if (item is MusicAlbum || item is MusicArtist)
{
@ -687,64 +685,49 @@ namespace MediaBrowser.XbmcMetadata.Savers
}
}
var externalId = item.GetProviderId(MetadataProvider.AudioDbArtist);
if (!string.IsNullOrEmpty(externalId))
if (item.TryGetProviderId(MetadataProvider.AudioDbArtist, out var externalId))
{
writer.WriteElementString("audiodbartistid", externalId);
writtenProviderIds.Add(MetadataProvider.AudioDbArtist.ToString());
}
externalId = item.GetProviderId(MetadataProvider.AudioDbAlbum);
if (!string.IsNullOrEmpty(externalId))
if (item.TryGetProviderId(MetadataProvider.AudioDbAlbum, out externalId))
{
writer.WriteElementString("audiodbalbumid", externalId);
writtenProviderIds.Add(MetadataProvider.AudioDbAlbum.ToString());
}
externalId = item.GetProviderId(MetadataProvider.Zap2It);
if (!string.IsNullOrEmpty(externalId))
if (item.TryGetProviderId(MetadataProvider.Zap2It, out externalId))
{
writer.WriteElementString("zap2itid", externalId);
writtenProviderIds.Add(MetadataProvider.Zap2It.ToString());
}
externalId = item.GetProviderId(MetadataProvider.MusicBrainzAlbum);
if (!string.IsNullOrEmpty(externalId))
if (item.TryGetProviderId(MetadataProvider.MusicBrainzAlbum, out externalId))
{
writer.WriteElementString("musicbrainzalbumid", externalId);
writtenProviderIds.Add(MetadataProvider.MusicBrainzAlbum.ToString());
}
externalId = item.GetProviderId(MetadataProvider.MusicBrainzAlbumArtist);
if (!string.IsNullOrEmpty(externalId))
if (item.TryGetProviderId(MetadataProvider.MusicBrainzAlbumArtist, out externalId))
{
writer.WriteElementString("musicbrainzalbumartistid", externalId);
writtenProviderIds.Add(MetadataProvider.MusicBrainzAlbumArtist.ToString());
}
externalId = item.GetProviderId(MetadataProvider.MusicBrainzArtist);
if (!string.IsNullOrEmpty(externalId))
if (item.TryGetProviderId(MetadataProvider.MusicBrainzArtist, out externalId))
{
writer.WriteElementString("musicbrainzartistid", externalId);
writtenProviderIds.Add(MetadataProvider.MusicBrainzArtist.ToString());
}
externalId = item.GetProviderId(MetadataProvider.MusicBrainzReleaseGroup);
if (!string.IsNullOrEmpty(externalId))
if (item.TryGetProviderId(MetadataProvider.MusicBrainzReleaseGroup, out externalId))
{
writer.WriteElementString("musicbrainzreleasegroupid", externalId);
writtenProviderIds.Add(MetadataProvider.MusicBrainzReleaseGroup.ToString());
}
externalId = item.GetProviderId(MetadataProvider.TvRage);
if (!string.IsNullOrEmpty(externalId))
if (item.TryGetProviderId(MetadataProvider.TvRage, out externalId))
{
writer.WriteElementString("tvrageid", externalId);
writtenProviderIds.Add(MetadataProvider.TvRage.ToString());
@ -752,7 +735,7 @@ namespace MediaBrowser.XbmcMetadata.Savers
if (item.ProviderIds is not null)
{
foreach (var providerKey in item.ProviderIds.Keys)
foreach (var providerKey in item.ProviderIds.Keys.OrderBy(providerKey => providerKey))
{
var providerId = item.ProviderIds[providerKey];
if (!string.IsNullOrEmpty(providerId) && !writtenProviderIds.Contains(providerKey))
@ -764,7 +747,7 @@ namespace MediaBrowser.XbmcMetadata.Savers
XmlConvert.VerifyName(tagName);
Logger.LogDebug("Saving custom provider tagname {0}", tagName);
writer.WriteElementString(GetTagForProviderKey(providerKey), providerId);
writer.WriteElementString(tagName, providerId);
}
catch (ArgumentException)
{
@ -785,7 +768,10 @@ namespace MediaBrowser.XbmcMetadata.Savers
AddUserData(item, writer, userManager, userDataRepo, options);
AddActors(people, writer, libraryManager, options.SaveImagePathsInNfo);
if (item is not MusicAlbum && item is not MusicArtist)
{
AddActors(people, writer, libraryManager, options.SaveImagePathsInNfo);
}
if (item is BoxSet folder)
{
@ -797,6 +783,8 @@ namespace MediaBrowser.XbmcMetadata.Savers
{
var items = item.LinkedChildren
.Where(i => i.Type == LinkedChildType.Manual)
.OrderBy(i => i.Path?.Trim())
.ThenBy(i => i.LibraryItemId?.Trim())
.ToList();
foreach (var link in items)
@ -839,7 +827,7 @@ namespace MediaBrowser.XbmcMetadata.Savers
writer.WriteElementString("poster", GetImagePathToSave(image, libraryManager));
}
foreach (var backdrop in item.GetImages(ImageType.Backdrop))
foreach (var backdrop in item.GetImages(ImageType.Backdrop).OrderBy(b => b.Path?.Trim()))
{
writer.WriteElementString("fanart", GetImagePathToSave(backdrop, libraryManager));
}
@ -916,7 +904,9 @@ namespace MediaBrowser.XbmcMetadata.Savers
private void AddActors(IReadOnlyList<PersonInfo> people, XmlWriter writer, ILibraryManager libraryManager, bool saveImagePath)
{
foreach (var person in people)
foreach (var person in people
.OrderBy(person => person.SortOrder ?? 0)
.ThenBy(person => person.Name?.Trim()))
{
if (person.IsType(PersonKind.Director) || person.IsType(PersonKind.Writer))
{
@ -1027,5 +1017,24 @@ namespace MediaBrowser.XbmcMetadata.Savers
private string GetTagForProviderKey(string providerKey)
=> providerKey.ToLowerInvariant() + "id";
protected static string SortNameOrName(BaseItem item)
{
if (item == null)
{
return string.Empty;
}
if (item.SortName != null)
{
string trimmed = item.SortName.Trim();
if (trimmed.Length > 0)
{
return trimmed;
}
}
return (item.Name ?? string.Empty).Trim();
}
}
}

@ -2,6 +2,7 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Xml;
using Jellyfin.Extensions;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
@ -91,16 +92,14 @@ namespace MediaBrowser.XbmcMetadata.Savers
/// <inheritdoc />
protected override void WriteCustomElements(BaseItem item, XmlWriter writer)
{
var imdb = item.GetProviderId(MetadataProvider.Imdb);
if (!string.IsNullOrEmpty(imdb))
if (item.TryGetProviderId(MetadataProvider.Imdb, out var imdb))
{
writer.WriteElementString("id", imdb);
}
if (item is MusicVideo musicVideo)
{
foreach (var artist in musicVideo.Artists)
foreach (var artist in musicVideo.Artists.Trimmed().OrderBy(artist => artist))
{
writer.WriteElementString("artist", artist);
}

@ -54,9 +54,7 @@ namespace MediaBrowser.XbmcMetadata.Savers
{
var series = (Series)item;
var tvdb = item.GetProviderId(MetadataProvider.Tvdb);
if (!string.IsNullOrEmpty(tvdb))
if (item.TryGetProviderId(MetadataProvider.Tvdb, out var tvdb))
{
writer.WriteElementString("id", tvdb);

@ -1,4 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using ICU4N.Text;
@ -123,5 +125,15 @@ namespace Jellyfin.Extensions
{
return (_transliterator.Value is null) ? text : _transliterator.Value.Transliterate(text);
}
/// <summary>
/// Ensures all strings are non-null and trimmed of leading an trailing blanks.
/// </summary>
/// <param name="values">The enumerable of strings to trim.</param>
/// <returns>The enumeration of trimmed strings.</returns>
public static IEnumerable<string> Trimmed(this IEnumerable<string> values)
{
return values.Select(i => (i ?? string.Empty).Trim());
}
}
}

@ -344,15 +344,12 @@ public class RecordingsMetadataManager
await writer.WriteElementStringAsync(null, "credits", null, person).ConfigureAwait(false);
}
var tmdbCollection = item.GetProviderId(MetadataProvider.TmdbCollection);
if (!string.IsNullOrEmpty(tmdbCollection))
if (item.TryGetProviderId(MetadataProvider.TmdbCollection, out var tmdbCollection))
{
await writer.WriteElementStringAsync(null, "collectionnumber", null, tmdbCollection).ConfigureAwait(false);
}
var imdb = item.GetProviderId(MetadataProvider.Imdb);
if (!string.IsNullOrEmpty(imdb))
if (item.TryGetProviderId(MetadataProvider.Imdb, out var imdb))
{
if (!isSeriesEpisode)
{
@ -365,8 +362,7 @@ public class RecordingsMetadataManager
lockData = false;
}
var tvdb = item.GetProviderId(MetadataProvider.Tvdb);
if (!string.IsNullOrEmpty(tvdb))
if (item.TryGetProviderId(MetadataProvider.Tvdb, out var tvdb))
{
await writer.WriteElementStringAsync(null, "tvdbid", null, tvdb).ConfigureAwait(false);
@ -374,8 +370,7 @@ public class RecordingsMetadataManager
lockData = false;
}
var tmdb = item.GetProviderId(MetadataProvider.Tmdb);
if (!string.IsNullOrEmpty(tmdb))
if (item.TryGetProviderId(MetadataProvider.Tmdb, out var tmdb))
{
await writer.WriteElementStringAsync(null, "tmdbid", null, tmdb).ConfigureAwait(false);

@ -673,10 +673,10 @@ public class NetworkManager : INetworkManager, IDisposable
{
// Comma separated list of IP addresses or IP/netmask entries for networks that will be allowed to connect remotely.
// If left blank, all remote addresses will be allowed.
if (_remoteAddressFilter.Any() && !_lanSubnets.Any(x => x.Contains(remoteIP)))
if (_remoteAddressFilter.Any() && !IsInLocalNetwork(remoteIP))
{
// remoteAddressFilter is a whitelist or blacklist.
var matches = _remoteAddressFilter.Count(remoteNetwork => remoteNetwork.Contains(remoteIP));
var matches = _remoteAddressFilter.Count(remoteNetwork => NetworkUtils.SubnetContainsAddress(remoteNetwork, remoteIP));
if ((!config.IsRemoteIPFilterBlacklist && matches > 0)
|| (config.IsRemoteIPFilterBlacklist && matches == 0))
{
@ -793,7 +793,7 @@ public class NetworkManager : INetworkManager, IDisposable
_logger.LogWarning("IPv4 is disabled in Jellyfin, but enabled in the OS. This may affect how the interface is selected.");
}
bool isExternal = !_lanSubnets.Any(network => network.Contains(source));
bool isExternal = !IsInLocalNetwork(source);
_logger.LogDebug("Trying to get bind address for source {Source} - External: {IsExternal}", source, isExternal);
if (!skipOverrides && MatchesPublishedServerUrl(source, isExternal, out result))
@ -840,7 +840,7 @@ public class NetworkManager : INetworkManager, IDisposable
// (For systems with multiple internal network cards, and multiple subnets)
foreach (var intf in availableInterfaces)
{
if (intf.Subnet.Contains(source))
if (NetworkUtils.SubnetContainsAddress(intf.Subnet, source))
{
result = NetworkUtils.FormatIPString(intf.Address);
_logger.LogDebug("{Source}: Found interface with matching subnet, using it as bind address: {Result}", source, result);
@ -868,21 +868,11 @@ public class NetworkManager : INetworkManager, IDisposable
{
if (NetworkUtils.TryParseToSubnet(address, out var subnet))
{
return IPAddress.IsLoopback(subnet.Prefix) || (_lanSubnets.Any(x => x.Contains(subnet.Prefix)) && !_excludedSubnets.Any(x => x.Contains(subnet.Prefix)));
return IsInLocalNetwork(subnet.Prefix);
}
if (NetworkUtils.TryParseHost(address, out var addresses, IsIPv4Enabled, IsIPv6Enabled))
{
foreach (var ept in addresses)
{
if (IPAddress.IsLoopback(ept) || (_lanSubnets.Any(x => x.Contains(ept)) && !_excludedSubnets.Any(x => x.Contains(ept))))
{
return true;
}
}
}
return false;
return NetworkUtils.TryParseHost(address, out var addresses, IsIPv4Enabled, IsIPv6Enabled)
&& addresses.Any(IsInLocalNetwork);
}
/// <summary>
@ -917,6 +907,11 @@ public class NetworkManager : INetworkManager, IDisposable
return CheckIfLanAndNotExcluded(address);
}
/// <summary>
/// Check if the address is in the LAN and not excluded.
/// </summary>
/// <param name="address">The IP address to check. The caller should make sure this is not an IPv4MappedToIPv6 address.</param>
/// <returns>Boolean indicates whether the address is in LAN.</returns>
private bool CheckIfLanAndNotExcluded(IPAddress address)
{
foreach (var lanSubnet in _lanSubnets)
@ -956,7 +951,7 @@ public class NetworkManager : INetworkManager, IDisposable
{
// Only use matching internal subnets
// Prefer more specific (bigger subnet prefix) overrides
validPublishedServerUrls = _publishedServerUrls.Where(x => x.IsInternalOverride && x.Data.Subnet.Contains(source))
validPublishedServerUrls = _publishedServerUrls.Where(x => x.IsInternalOverride && NetworkUtils.SubnetContainsAddress(x.Data.Subnet, source))
.OrderByDescending(x => x.Data.Subnet.PrefixLength)
.ToList();
}
@ -964,7 +959,7 @@ public class NetworkManager : INetworkManager, IDisposable
{
// Only use matching external subnets
// Prefer more specific (bigger subnet prefix) overrides
validPublishedServerUrls = _publishedServerUrls.Where(x => x.IsExternalOverride && x.Data.Subnet.Contains(source))
validPublishedServerUrls = _publishedServerUrls.Where(x => x.IsExternalOverride && NetworkUtils.SubnetContainsAddress(x.Data.Subnet, source))
.OrderByDescending(x => x.Data.Subnet.PrefixLength)
.ToList();
}
@ -972,7 +967,7 @@ public class NetworkManager : INetworkManager, IDisposable
foreach (var data in validPublishedServerUrls)
{
// Get interface matching override subnet
var intf = _interfaces.OrderBy(x => x.Index).FirstOrDefault(x => data.Data.Subnet.Contains(x.Address));
var intf = _interfaces.OrderBy(x => x.Index).FirstOrDefault(x => NetworkUtils.SubnetContainsAddress(data.Data.Subnet, x.Address));
if (intf?.Address is not null
|| (data.Data.AddressFamily == AddressFamily.InterNetwork && data.Data.Address.Equals(IPAddress.Any))
@ -1035,6 +1030,7 @@ public class NetworkManager : INetworkManager, IDisposable
if (isInExternalSubnet)
{
var externalInterfaces = _interfaces.Where(x => !IsInLocalNetwork(x.Address))
.Where(x => !IsLinkLocalAddress(x.Address))
.OrderBy(x => x.Index)
.ToList();
if (externalInterfaces.Count > 0)
@ -1042,7 +1038,7 @@ public class NetworkManager : INetworkManager, IDisposable
// Check to see if any of the external bind interfaces are in the same subnet as the source.
// If none exists, this will select the first external interface if there is one.
bindAddress = externalInterfaces
.OrderByDescending(x => x.Subnet.Contains(source))
.OrderByDescending(x => NetworkUtils.SubnetContainsAddress(x.Subnet, source))
.ThenByDescending(x => x.Subnet.PrefixLength)
.ThenBy(x => x.Index)
.Select(x => x.Address)
@ -1060,7 +1056,7 @@ public class NetworkManager : INetworkManager, IDisposable
// Check to see if any of the internal bind interfaces are in the same subnet as the source.
// If none exists, this will select the first internal interface if there is one.
bindAddress = _interfaces.Where(x => IsInLocalNetwork(x.Address))
.OrderByDescending(x => x.Subnet.Contains(source))
.OrderByDescending(x => NetworkUtils.SubnetContainsAddress(x.Subnet, source))
.ThenByDescending(x => x.Subnet.PrefixLength)
.ThenBy(x => x.Index)
.Select(x => x.Address)
@ -1104,7 +1100,7 @@ public class NetworkManager : INetworkManager, IDisposable
// (For systems with multiple network cards and/or multiple subnets)
foreach (var intf in extResult)
{
if (intf.Subnet.Contains(source))
if (NetworkUtils.SubnetContainsAddress(intf.Subnet, source))
{
result = NetworkUtils.FormatIPString(intf.Address);
_logger.LogDebug("{Source}: Found external interface with matching subnet, using it as bind address: {Result}", source, result);

@ -6,32 +6,54 @@ namespace Jellyfin.Naming.Tests.TV;
public class SeasonPathParserTests
{
[Theory]
[InlineData("/Drive/Season 1", 1, true)]
[InlineData("/Drive/s1", 1, true)]
[InlineData("/Drive/S1", 1, true)]
[InlineData("/Drive/Season 2", 2, true)]
[InlineData("/Drive/Season 02", 2, true)]
[InlineData("/Drive/Seinfeld/S02", 2, true)]
[InlineData("/Drive/Seinfeld/2", 2, true)]
[InlineData("/Drive/Seinfeld - S02", 2, true)]
[InlineData("/Drive/Season 2009", 2009, true)]
[InlineData("/Drive/Season1", 1, true)]
[InlineData("The Wonder Years/The.Wonder.Years.S04.PDTV.x264-JCH", 4, true)]
[InlineData("/Drive/Season 7 (2016)", 7, false)]
[InlineData("/Drive/Staffel 7 (2016)", 7, false)]
[InlineData("/Drive/Stagione 7 (2016)", 7, false)]
[InlineData("/Drive/Season (8)", null, false)]
[InlineData("/Drive/3.Staffel", 3, false)]
[InlineData("/Drive/s06e05", null, false)]
[InlineData("/Drive/The.Legend.of.Condor.Heroes.2017.V2.web-dl.1080p.h264.aac-hdctv", null, false)]
[InlineData("/Drive/extras", 0, true)]
[InlineData("/Drive/specials", 0, true)]
public void GetSeasonNumberFromPathTest(string path, int? seasonNumber, bool isSeasonDirectory)
[InlineData("/Drive/Season 1", "/Drive", 1, true)]
[InlineData("/Drive/Staffel 1", "/Drive", 1, true)]
[InlineData("/Drive/Stagione 1", "/Drive", 1, true)]
[InlineData("/Drive/sæson 1", "/Drive", 1, true)]
[InlineData("/Drive/Temporada 1", "/Drive", 1, true)]
[InlineData("/Drive/series 1", "/Drive", 1, true)]
[InlineData("/Drive/Kausi 1", "/Drive", 1, true)]
[InlineData("/Drive/Säsong 1", "/Drive", 1, true)]
[InlineData("/Drive/Seizoen 1", "/Drive", 1, true)]
[InlineData("/Drive/Seasong 1", "/Drive", 1, true)]
[InlineData("/Drive/Sezon 1", "/Drive", 1, true)]
[InlineData("/Drive/sezona 1", "/Drive", 1, true)]
[InlineData("/Drive/sezóna 1", "/Drive", 1, true)]
[InlineData("/Drive/Sezonul 1", "/Drive", 1, true)]
[InlineData("/Drive/시즌 1", "/Drive", 1, true)]
[InlineData("/Drive/シーズン 1", "/Drive", 1, true)]
[InlineData("/Drive/сезон 1", "/Drive", 1, true)]
[InlineData("/Drive/Сезон 1", "/Drive", 1, true)]
[InlineData("/Drive/Season 10", "/Drive", 10, true)]
[InlineData("/Drive/Season 100", "/Drive", 100, true)]
[InlineData("/Drive/s1", "/Drive", 1, true)]
[InlineData("/Drive/S1", "/Drive", 1, true)]
[InlineData("/Drive/Season 2", "/Drive", 2, true)]
[InlineData("/Drive/Season 02", "/Drive", 2, true)]
[InlineData("/Drive/Seinfeld/S02", "/Seinfeld", 2, true)]
[InlineData("/Drive/Seinfeld/2", "/Seinfeld", 2, true)]
[InlineData("/Drive/Seinfeld Season 2", "/Drive", null, false)]
[InlineData("/Drive/Season 2009", "/Drive", 2009, true)]
[InlineData("/Drive/Season1", "/Drive", 1, true)]
[InlineData("The Wonder Years/The.Wonder.Years.S04.PDTV.x264-JCH", "/The Wonder Years", 4, true)]
[InlineData("/Drive/Season 7 (2016)", "/Drive", 7, true)]
[InlineData("/Drive/Staffel 7 (2016)", "/Drive", 7, true)]
[InlineData("/Drive/Stagione 7 (2016)", "/Drive", 7, true)]
[InlineData("/Drive/Stargate SG-1/Season 1", "/Drive/Stargate SG-1", 1, true)]
[InlineData("/Drive/Stargate SG-1/Stargate SG-1 Season 1", "/Drive/Stargate SG-1", 1, true)]
[InlineData("/Drive/Season (8)", "/Drive", null, false)]
[InlineData("/Drive/3.Staffel", "/Drive", 3, true)]
[InlineData("/Drive/s06e05", "/Drive", null, false)]
[InlineData("/Drive/The.Legend.of.Condor.Heroes.2017.V2.web-dl.1080p.h264.aac-hdctv", "/Drive", null, false)]
[InlineData("/Drive/extras", "/Drive", 0, true)]
[InlineData("/Drive/specials", "/Drive", 0, true)]
[InlineData("/Drive/Episode 1 Season 2", "/Drive", null, false)]
public void GetSeasonNumberFromPathTest(string path, string? parentPath, int? seasonNumber, bool isSeasonDirectory)
{
var result = SeasonPathParser.Parse(path, true, true);
var result = SeasonPathParser.Parse(path, parentPath, true, true);
Assert.Equal(result.SeasonNumber is not null, result.Success);
Assert.Equal(result.SeasonNumber, seasonNumber);
Assert.Equal(seasonNumber, result.SeasonNumber);
Assert.Equal(isSeasonDirectory, result.IsSeasonFolder);
}
}

@ -217,68 +217,58 @@ public class MediaInfoResolverTests
string file = "My.Video.srt";
data.Add(
file,
new[]
{
[
CreateMediaStream(VideoDirectoryPath + "/" + file, null, null, 0)
},
new[]
{
],
[
CreateMediaStream(VideoDirectoryPath + "/" + file, null, null, 0)
});
]);
// filename has metadata
file = "My.Video.Title1.default.forced.sdh.en.srt";
data.Add(
file,
new[]
{
[
CreateMediaStream(VideoDirectoryPath + "/" + file, null, null, 0)
},
new[]
{
],
[
CreateMediaStream(VideoDirectoryPath + "/" + file, "eng", "Title1", 0, true, true, true)
});
]);
// single stream with metadata
file = "My.Video.mks";
data.Add(
file,
new[]
{
[
CreateMediaStream(VideoDirectoryPath + "/" + file, "eng", "Title", 0, true, true, true)
},
new[]
{
CreateMediaStream(VideoDirectoryPath + "/" + file, "eng", "Title", 0, true, true, true)
});
],
[
CreateMediaStream(VideoDirectoryPath + "/" + file, "eng", "Title", 0, true, false, true)
]);
// stream wins for title/language, filename wins for flags when conflicting
file = "My.Video.Title2.default.forced.sdh.en.srt";
data.Add(
file,
new[]
{
[
CreateMediaStream(VideoDirectoryPath + "/" + file, "fra", "Metadata", 0)
},
new[]
{
],
[
CreateMediaStream(VideoDirectoryPath + "/" + file, "fra", "Metadata", 0, true, true, true)
});
]);
// multiple stream with metadata - filename flags ignored but other data filled in when missing from stream
file = "My.Video.Title3.default.forced.en.srt";
data.Add(
file,
new[]
{
[
CreateMediaStream(VideoDirectoryPath + "/" + file, null, null, 0, true, true),
CreateMediaStream(VideoDirectoryPath + "/" + file, "fra", "Metadata", 1)
},
new[]
{
],
[
CreateMediaStream(VideoDirectoryPath + "/" + file, "eng", "Title3", 0, true, true),
CreateMediaStream(VideoDirectoryPath + "/" + file, "fra", "Metadata", 1)
});
]);
return data;
}

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

Loading…
Cancel
Save