aboutsummaryrefslogtreecommitdiff
path: root/Jellyfin.Server.Implementations
diff options
context:
space:
mode:
authorBond-009 <bond.009@outlook.com>2026-02-14 12:07:30 +0100
committerGitHub <noreply@github.com>2026-02-14 12:07:30 +0100
commit29582ed461b693368ec56567c2e40cfa20ef4bf5 (patch)
tree04721b833e8e6108c2e13c4f0ea9f4dc7b2ae946 /Jellyfin.Server.Implementations
parentca6d499680f9fbb369844a11eb0e0213b66bb00b (diff)
parent3b6985986709473c69ba785460c702c6bbe3771d (diff)
Merge branch 'master' into issue15137
Diffstat (limited to 'Jellyfin.Server.Implementations')
-rw-r--r--Jellyfin.Server.Implementations/Activity/ActivityManager.cs242
-rw-r--r--Jellyfin.Server.Implementations/Devices/DeviceManager.cs2
-rw-r--r--Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs232
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.cs446
-rw-r--r--Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs6
-rw-r--r--Jellyfin.Server.Implementations/Item/OrderMapper.cs99
-rw-r--r--Jellyfin.Server.Implementations/Item/PeopleRepository.cs5
-rw-r--r--Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj3
-rw-r--r--Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs7
-rw-r--r--Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs4
-rw-r--r--Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs6
-rw-r--r--Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs40
-rw-r--r--Jellyfin.Server.Implementations/Users/InvalidAuthProvider.cs6
-rw-r--r--Jellyfin.Server.Implementations/Users/UserManager.cs29
14 files changed, 722 insertions, 405 deletions
diff --git a/Jellyfin.Server.Implementations/Activity/ActivityManager.cs b/Jellyfin.Server.Implementations/Activity/ActivityManager.cs
index 8d492f7cd..fe987b9d8 100644
--- a/Jellyfin.Server.Implementations/Activity/ActivityManager.cs
+++ b/Jellyfin.Server.Implementations/Activity/ActivityManager.cs
@@ -1,103 +1,213 @@
using System;
+using System.Collections.Generic;
using System.Linq;
+using System.Linq.Expressions;
using System.Threading.Tasks;
+using Jellyfin.Data.Enums;
using Jellyfin.Data.Events;
using Jellyfin.Data.Queries;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Database.Implementations.Enums;
+using Jellyfin.Extensions;
using MediaBrowser.Model.Activity;
using MediaBrowser.Model.Querying;
using Microsoft.EntityFrameworkCore;
-namespace Jellyfin.Server.Implementations.Activity
+namespace Jellyfin.Server.Implementations.Activity;
+
+/// <summary>
+/// Manages the storage and retrieval of <see cref="ActivityLog"/> instances.
+/// </summary>
+public class ActivityManager : IActivityManager
{
+ private readonly IDbContextFactory<JellyfinDbContext> _provider;
+
/// <summary>
- /// Manages the storage and retrieval of <see cref="ActivityLog"/> instances.
+ /// Initializes a new instance of the <see cref="ActivityManager"/> class.
/// </summary>
- public class ActivityManager : IActivityManager
+ /// <param name="provider">The Jellyfin database provider.</param>
+ public ActivityManager(IDbContextFactory<JellyfinDbContext> provider)
{
- private readonly IDbContextFactory<JellyfinDbContext> _provider;
+ _provider = provider;
+ }
+
+ /// <inheritdoc/>
+ public event EventHandler<GenericEventArgs<ActivityLogEntry>>? EntryCreated;
- /// <summary>
- /// Initializes a new instance of the <see cref="ActivityManager"/> class.
- /// </summary>
- /// <param name="provider">The Jellyfin database provider.</param>
- public ActivityManager(IDbContextFactory<JellyfinDbContext> provider)
+ /// <inheritdoc/>
+ public async Task CreateAsync(ActivityLog entry)
+ {
+ var dbContext = await _provider.CreateDbContextAsync().ConfigureAwait(false);
+ await using (dbContext.ConfigureAwait(false))
{
- _provider = provider;
+ dbContext.ActivityLogs.Add(entry);
+ await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
- /// <inheritdoc/>
- public event EventHandler<GenericEventArgs<ActivityLogEntry>>? EntryCreated;
+ EntryCreated?.Invoke(this, new GenericEventArgs<ActivityLogEntry>(ConvertToOldModel(entry)));
+ }
+
+ /// <inheritdoc/>
+ public async Task<QueryResult<ActivityLogEntry>> GetPagedResultAsync(ActivityLogQuery query)
+ {
+ // TODO allow sorting and filtering by item id. Currently not possible because ActivityLog stores the item id as a string.
- /// <inheritdoc/>
- public async Task CreateAsync(ActivityLog entry)
+ var dbContext = await _provider.CreateDbContextAsync().ConfigureAwait(false);
+ await using (dbContext.ConfigureAwait(false))
{
- var dbContext = await _provider.CreateDbContextAsync().ConfigureAwait(false);
- await using (dbContext.ConfigureAwait(false))
+ // TODO switch to LeftJoin in .NET 10.
+ var entries = from a in dbContext.ActivityLogs
+ join u in dbContext.Users on a.UserId equals u.Id into ugj
+ from u in ugj.DefaultIfEmpty()
+ select new ExpandedActivityLog { ActivityLog = a, Username = u.Username };
+
+ if (query.HasUserId is not null)
{
- dbContext.ActivityLogs.Add(entry);
- await dbContext.SaveChangesAsync().ConfigureAwait(false);
+ entries = entries.Where(e => e.ActivityLog.UserId.Equals(default) != query.HasUserId.Value);
}
- EntryCreated?.Invoke(this, new GenericEventArgs<ActivityLogEntry>(ConvertToOldModel(entry)));
- }
+ if (query.MinDate is not null)
+ {
+ entries = entries.Where(e => e.ActivityLog.DateCreated >= query.MinDate.Value);
+ }
- /// <inheritdoc/>
- public async Task<QueryResult<ActivityLogEntry>> GetPagedResultAsync(ActivityLogQuery query)
- {
- var dbContext = await _provider.CreateDbContextAsync().ConfigureAwait(false);
- await using (dbContext.ConfigureAwait(false))
+ if (query.MaxDate is not null)
{
- var entries = dbContext.ActivityLogs
- .OrderByDescending(entry => entry.DateCreated)
- .Where(entry => query.MinDate == null || entry.DateCreated >= query.MinDate)
- .Where(entry => !query.HasUserId.HasValue || entry.UserId.Equals(default) != query.HasUserId.Value);
-
- return new QueryResult<ActivityLogEntry>(
- query.Skip,
- await entries.CountAsync().ConfigureAwait(false),
- await entries
- .Skip(query.Skip ?? 0)
- .Take(query.Limit ?? 100)
- .Select(entity => new ActivityLogEntry(entity.Name, entity.Type, entity.UserId)
- {
- Id = entity.Id,
- Overview = entity.Overview,
- ShortOverview = entity.ShortOverview,
- ItemId = entity.ItemId,
- Date = entity.DateCreated,
- Severity = entity.LogSeverity
- })
- .ToListAsync()
- .ConfigureAwait(false));
+ entries = entries.Where(e => e.ActivityLog.DateCreated <= query.MaxDate.Value);
}
- }
- /// <inheritdoc />
- public async Task CleanAsync(DateTime startDate)
- {
- var dbContext = await _provider.CreateDbContextAsync().ConfigureAwait(false);
- await using (dbContext.ConfigureAwait(false))
+ if (!string.IsNullOrEmpty(query.Name))
+ {
+ entries = entries.Where(e => EF.Functions.Like(e.ActivityLog.Name, $"%{query.Name}%"));
+ }
+
+ if (!string.IsNullOrEmpty(query.Overview))
+ {
+ entries = entries.Where(e => EF.Functions.Like(e.ActivityLog.Overview, $"%{query.Overview}%"));
+ }
+
+ if (!string.IsNullOrEmpty(query.ShortOverview))
+ {
+ entries = entries.Where(e => EF.Functions.Like(e.ActivityLog.ShortOverview, $"%{query.ShortOverview}%"));
+ }
+
+ if (!string.IsNullOrEmpty(query.Type))
+ {
+ entries = entries.Where(e => EF.Functions.Like(e.ActivityLog.Type, $"%{query.Type}%"));
+ }
+
+ if (!query.ItemId.IsNullOrEmpty())
+ {
+ var itemId = query.ItemId.Value.ToString("N");
+ entries = entries.Where(e => e.ActivityLog.ItemId == itemId);
+ }
+
+ if (!string.IsNullOrEmpty(query.Username))
+ {
+ entries = entries.Where(e => EF.Functions.Like(e.Username, $"%{query.Username}%"));
+ }
+
+ if (query.Severity is not null)
{
- await dbContext.ActivityLogs
- .Where(entry => entry.DateCreated <= startDate)
- .ExecuteDeleteAsync()
- .ConfigureAwait(false);
+ entries = entries.Where(e => e.ActivityLog.LogSeverity == query.Severity);
}
+
+ return new QueryResult<ActivityLogEntry>(
+ query.Skip,
+ await entries.CountAsync().ConfigureAwait(false),
+ await ApplyOrdering(entries, query.OrderBy)
+ .Skip(query.Skip ?? 0)
+ .Take(query.Limit ?? 100)
+ .Select(entity => new ActivityLogEntry(entity.ActivityLog.Name, entity.ActivityLog.Type, entity.ActivityLog.UserId)
+ {
+ Id = entity.ActivityLog.Id,
+ Overview = entity.ActivityLog.Overview,
+ ShortOverview = entity.ActivityLog.ShortOverview,
+ ItemId = entity.ActivityLog.ItemId,
+ Date = entity.ActivityLog.DateCreated,
+ Severity = entity.ActivityLog.LogSeverity
+ })
+ .ToListAsync()
+ .ConfigureAwait(false));
+ }
+ }
+
+ /// <inheritdoc />
+ public async Task CleanAsync(DateTime startDate)
+ {
+ var dbContext = await _provider.CreateDbContextAsync().ConfigureAwait(false);
+ await using (dbContext.ConfigureAwait(false))
+ {
+ await dbContext.ActivityLogs
+ .Where(entry => entry.DateCreated <= startDate)
+ .ExecuteDeleteAsync()
+ .ConfigureAwait(false);
}
+ }
+
+ private static ActivityLogEntry ConvertToOldModel(ActivityLog entry)
+ {
+ return new ActivityLogEntry(entry.Name, entry.Type, entry.UserId)
+ {
+ Id = entry.Id,
+ Overview = entry.Overview,
+ ShortOverview = entry.ShortOverview,
+ ItemId = entry.ItemId,
+ Date = entry.DateCreated,
+ Severity = entry.LogSeverity
+ };
+ }
- private static ActivityLogEntry ConvertToOldModel(ActivityLog entry)
+ private IOrderedQueryable<ExpandedActivityLog> ApplyOrdering(IQueryable<ExpandedActivityLog> query, IReadOnlyCollection<(ActivityLogSortBy, SortOrder)>? sorting)
+ {
+ if (sorting is null || sorting.Count == 0)
{
- return new ActivityLogEntry(entry.Name, entry.Type, entry.UserId)
+ return query.OrderByDescending(e => e.ActivityLog.DateCreated);
+ }
+
+ IOrderedQueryable<ExpandedActivityLog> ordered = null!;
+
+ foreach (var (sortBy, sortOrder) in sorting)
+ {
+ var orderBy = MapOrderBy(sortBy);
+
+ if (ordered == null)
+ {
+ ordered = sortOrder == SortOrder.Ascending
+ ? query.OrderBy(orderBy)
+ : query.OrderByDescending(orderBy);
+ }
+ else
{
- Id = entry.Id,
- Overview = entry.Overview,
- ShortOverview = entry.ShortOverview,
- ItemId = entry.ItemId,
- Date = entry.DateCreated,
- Severity = entry.LogSeverity
- };
+ ordered = sortOrder == SortOrder.Ascending
+ ? ordered.ThenBy(orderBy)
+ : ordered.ThenByDescending(orderBy);
+ }
}
+
+ return ordered;
+ }
+
+ private Expression<Func<ExpandedActivityLog, object?>> MapOrderBy(ActivityLogSortBy sortBy)
+ {
+ return sortBy switch
+ {
+ ActivityLogSortBy.Name => e => e.ActivityLog.Name,
+ ActivityLogSortBy.Overiew => e => e.ActivityLog.Overview,
+ ActivityLogSortBy.ShortOverview => e => e.ActivityLog.ShortOverview,
+ ActivityLogSortBy.Type => e => e.ActivityLog.Type,
+ ActivityLogSortBy.DateCreated => e => e.ActivityLog.DateCreated,
+ ActivityLogSortBy.Username => e => e.Username,
+ ActivityLogSortBy.LogSeverity => e => e.ActivityLog.LogSeverity,
+ _ => throw new ArgumentOutOfRangeException(nameof(sortBy), sortBy, "Unhandled ActivityLogSortBy")
+ };
+ }
+
+ private class ExpandedActivityLog
+ {
+ public ActivityLog ActivityLog { get; set; } = null!;
+
+ public string? Username { get; set; }
}
}
diff --git a/Jellyfin.Server.Implementations/Devices/DeviceManager.cs b/Jellyfin.Server.Implementations/Devices/DeviceManager.cs
index 51a118645..bcf348f8c 100644
--- a/Jellyfin.Server.Implementations/Devices/DeviceManager.cs
+++ b/Jellyfin.Server.Implementations/Devices/DeviceManager.cs
@@ -158,7 +158,7 @@ namespace Jellyfin.Server.Implementations.Devices
devices = devices.Skip(query.Skip.Value);
}
- if (query.Limit.HasValue)
+ if (query.Limit.HasValue && query.Limit.Value > 0)
{
devices = devices.Take(query.Limit.Value);
}
diff --git a/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs b/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs
index e5c3cef3d..30094a88c 100644
--- a/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs
+++ b/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs
@@ -102,7 +102,7 @@ public class BackupService : IBackupService
}
BackupManifest? manifest;
- var manifestStream = zipArchiveEntry.Open();
+ var manifestStream = await zipArchiveEntry.OpenAsync().ConfigureAwait(false);
await using (manifestStream.ConfigureAwait(false))
{
manifest = await JsonSerializer.DeserializeAsync<BackupManifest>(manifestStream, _serializerSettings).ConfigureAwait(false);
@@ -128,7 +128,8 @@ public class BackupService : IBackupService
var targetPath = Path.GetFullPath(Path.Combine(target, Path.GetRelativePath(source, item.FullName)));
if (!sourcePath.StartsWith(fullSourcePath, StringComparison.Ordinal)
- || !targetPath.StartsWith(fullTargetRoot, StringComparison.Ordinal))
+ || !targetPath.StartsWith(fullTargetRoot, StringComparison.Ordinal)
+ || Path.EndsInDirectorySeparator(item.FullName))
{
continue;
}
@@ -159,7 +160,7 @@ public class BackupService : IBackupService
}
HistoryRow[] historyEntries;
- var historyArchive = historyEntry.Open();
+ var historyArchive = await historyEntry.OpenAsync().ConfigureAwait(false);
await using (historyArchive.ConfigureAwait(false))
{
historyEntries = await JsonSerializer.DeserializeAsync<HistoryRow[]>(historyArchive).ConfigureAwait(false) ??
@@ -199,11 +200,11 @@ public class BackupService : IBackupService
var zipEntry = zipArchive.GetEntry(NormalizePathSeparator(Path.Combine("Database", $"{entityType.Type.Name}.json")));
if (zipEntry is null)
{
- _logger.LogInformation("No backup of expected table {Table} is present in backup. Continue anyway.", entityType.Type.Name);
+ _logger.LogInformation("No backup of expected table {Table} is present in backup, continuing anyway", entityType.Type.Name);
continue;
}
- var zipEntryStream = zipEntry.Open();
+ var zipEntryStream = await zipEntry.OpenAsync().ConfigureAwait(false);
await using (zipEntryStream.ConfigureAwait(false))
{
_logger.LogInformation("Restore backup of {Table}", entityType.Type.Name);
@@ -223,7 +224,7 @@ public class BackupService : IBackupService
}
catch (Exception ex)
{
- _logger.LogError(ex, "Could not store entity {Entity} continue anyway.", item);
+ _logger.LogError(ex, "Could not store entity {Entity}, continuing anyway", item);
}
}
@@ -233,11 +234,11 @@ public class BackupService : IBackupService
_logger.LogInformation("Try restore Database");
await dbContext.SaveChangesAsync().ConfigureAwait(false);
- _logger.LogInformation("Restored database.");
+ _logger.LogInformation("Restored database");
}
}
- _logger.LogInformation("Restored Jellyfin system from {Date}.", manifest.DateCreated);
+ _logger.LogInformation("Restored Jellyfin system from {Date}", manifest.DateCreated);
}
}
@@ -263,6 +264,8 @@ public class BackupService : IBackupService
Options = Map(backupOptions)
};
+ _logger.LogInformation("Running database optimization before backup");
+
await _jellyfinDatabaseProvider.RunScheduledOptimisation(CancellationToken.None).ConfigureAwait(false);
var backupFolder = Path.Combine(_applicationPaths.BackupPath);
@@ -281,130 +284,155 @@ public class BackupService : IBackupService
}
var backupPath = Path.Combine(backupFolder, $"jellyfin-backup-{manifest.DateCreated.ToLocalTime():yyyyMMddHHmmss}.zip");
- _logger.LogInformation("Attempt to create a new backup at {BackupPath}", backupPath);
- var fileStream = File.OpenWrite(backupPath);
- await using (fileStream.ConfigureAwait(false))
- using (var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Create, false))
+
+ try
{
- _logger.LogInformation("Start backup process.");
- var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
- await using (dbContext.ConfigureAwait(false))
+ _logger.LogInformation("Attempting to create a new backup at {BackupPath}", backupPath);
+ var fileStream = File.OpenWrite(backupPath);
+ await using (fileStream.ConfigureAwait(false))
+ using (var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Create, false))
{
- dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
- static IAsyncEnumerable<object> GetValues(IQueryable dbSet)
+ _logger.LogInformation("Starting backup process");
+ var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
+ await using (dbContext.ConfigureAwait(false))
{
- var method = dbSet.GetType().GetMethod(nameof(DbSet<object>.AsAsyncEnumerable))!;
- var enumerable = method.Invoke(dbSet, null)!;
- return (IAsyncEnumerable<object>)enumerable;
- }
+ dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
- // include the migration history as well
- var historyRepository = dbContext.GetService<IHistoryRepository>();
- var migrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false);
-
- ICollection<(Type Type, string SourceName, Func<IAsyncEnumerable<object>> ValueFactory)> entityTypes = [
- .. typeof(JellyfinDbContext)
- .GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
- .Where(e => e.PropertyType.IsAssignableTo(typeof(IQueryable)))
- .Select(e => (Type: e.PropertyType, dbContext.Model.FindEntityType(e.PropertyType.GetGenericArguments()[0])!.GetSchemaQualifiedTableName()!, ValueFactory: new Func<IAsyncEnumerable<object>>(() => GetValues((IQueryable)e.GetValue(dbContext)!)))),
- (Type: typeof(HistoryRow), SourceName: nameof(HistoryRow), ValueFactory: () => migrations.ToAsyncEnumerable())
- ];
- manifest.DatabaseTables = entityTypes.Select(e => e.Type.Name).ToArray();
- var transaction = await dbContext.Database.BeginTransactionAsync().ConfigureAwait(false);
-
- await using (transaction.ConfigureAwait(false))
- {
- _logger.LogInformation("Begin Database backup");
+ static IAsyncEnumerable<object> GetValues(IQueryable dbSet)
+ {
+ var method = dbSet.GetType().GetMethod(nameof(DbSet<object>.AsAsyncEnumerable))!;
+ var enumerable = method.Invoke(dbSet, null)!;
+ return (IAsyncEnumerable<object>)enumerable;
+ }
- foreach (var entityType in entityTypes)
+ // include the migration history as well
+ var historyRepository = dbContext.GetService<IHistoryRepository>();
+ var migrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false);
+
+ ICollection<(Type Type, string SourceName, Func<IAsyncEnumerable<object>> ValueFactory)> entityTypes =
+ [
+ .. typeof(JellyfinDbContext)
+ .GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
+ .Where(e => e.PropertyType.IsAssignableTo(typeof(IQueryable)))
+ .Select(e => (Type: e.PropertyType, dbContext.Model.FindEntityType(e.PropertyType.GetGenericArguments()[0])!.GetSchemaQualifiedTableName()!, ValueFactory: new Func<IAsyncEnumerable<object>>(() => GetValues((IQueryable)e.GetValue(dbContext)!)))),
+ (Type: typeof(HistoryRow), SourceName: nameof(HistoryRow), ValueFactory: () => migrations.ToAsyncEnumerable())
+ ];
+ manifest.DatabaseTables = entityTypes.Select(e => e.Type.Name).ToArray();
+ var transaction = await dbContext.Database.BeginTransactionAsync().ConfigureAwait(false);
+
+ await using (transaction.ConfigureAwait(false))
{
- _logger.LogInformation("Begin backup of entity {Table}", entityType.SourceName);
- var zipEntry = zipArchive.CreateEntry(NormalizePathSeparator(Path.Combine("Database", $"{entityType.SourceName}.json")));
- var entities = 0;
- var zipEntryStream = zipEntry.Open();
- await using (zipEntryStream.ConfigureAwait(false))
+ _logger.LogInformation("Begin Database backup");
+
+ foreach (var entityType in entityTypes)
{
- var jsonSerializer = new Utf8JsonWriter(zipEntryStream);
- await using (jsonSerializer.ConfigureAwait(false))
+ _logger.LogInformation("Begin backup of entity {Table}", entityType.SourceName);
+ var zipEntry = zipArchive.CreateEntry(NormalizePathSeparator(Path.Combine("Database", $"{entityType.SourceName}.json")));
+ var entities = 0;
+ var zipEntryStream = await zipEntry.OpenAsync().ConfigureAwait(false);
+ await using (zipEntryStream.ConfigureAwait(false))
{
- jsonSerializer.WriteStartArray();
-
- var set = entityType.ValueFactory().ConfigureAwait(false);
- await foreach (var item in set.ConfigureAwait(false))
+ var jsonSerializer = new Utf8JsonWriter(zipEntryStream);
+ await using (jsonSerializer.ConfigureAwait(false))
{
- entities++;
- try
- {
- JsonSerializer.SerializeToDocument(item, _serializerSettings).WriteTo(jsonSerializer);
- }
- catch (Exception ex)
+ jsonSerializer.WriteStartArray();
+
+ var set = entityType.ValueFactory().ConfigureAwait(false);
+ await foreach (var item in set.ConfigureAwait(false))
{
- _logger.LogError(ex, "Could not load entity {Entity}", item);
- throw;
+ entities++;
+ try
+ {
+ using var document = JsonSerializer.SerializeToDocument(item, _serializerSettings);
+ document.WriteTo(jsonSerializer);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Could not load entity {Entity}", item);
+ throw;
+ }
}
- }
- jsonSerializer.WriteEndArray();
+ jsonSerializer.WriteEndArray();
+ }
}
- }
- _logger.LogInformation("backup of entity {Table} with {Number} created", entityType.Type.Name, entities);
+ _logger.LogInformation("Backup of entity {Table} with {Number} created", entityType.SourceName, entities);
+ }
}
}
- }
- _logger.LogInformation("Backup of folder {Table}", _applicationPaths.ConfigurationDirectoryPath);
- foreach (var item in Directory.EnumerateFiles(_applicationPaths.ConfigurationDirectoryPath, "*.xml", SearchOption.TopDirectoryOnly)
- .Union(Directory.EnumerateFiles(_applicationPaths.ConfigurationDirectoryPath, "*.json", SearchOption.TopDirectoryOnly)))
- {
- zipArchive.CreateEntryFromFile(item, NormalizePathSeparator(Path.Combine("Config", Path.GetFileName(item))));
- }
+ _logger.LogInformation("Backup of folder {Table}", _applicationPaths.ConfigurationDirectoryPath);
+ foreach (var item in Directory.EnumerateFiles(_applicationPaths.ConfigurationDirectoryPath, "*.xml", SearchOption.TopDirectoryOnly)
+ .Union(Directory.EnumerateFiles(_applicationPaths.ConfigurationDirectoryPath, "*.json", SearchOption.TopDirectoryOnly)))
+ {
+ await zipArchive.CreateEntryFromFileAsync(item, NormalizePathSeparator(Path.Combine("Config", Path.GetFileName(item)))).ConfigureAwait(false);
+ }
- void CopyDirectory(string source, string target, string filter = "*")
- {
- if (!Directory.Exists(source))
+ void CopyDirectory(string source, string target, string filter = "*")
{
- return;
+ if (!Directory.Exists(source))
+ {
+ return;
+ }
+
+ _logger.LogInformation("Backup of folder {Table}", source);
+
+ foreach (var item in Directory.EnumerateFiles(source, filter, SearchOption.AllDirectories))
+ {
+ // TODO: @bond make async
+ zipArchive.CreateEntryFromFile(item, NormalizePathSeparator(Path.Combine(target, Path.GetRelativePath(source, item))));
+ }
}
- _logger.LogInformation("Backup of folder {Table}", source);
+ CopyDirectory(Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "users"), Path.Combine("Config", "users"));
+ CopyDirectory(Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "ScheduledTasks"), Path.Combine("Config", "ScheduledTasks"));
+ CopyDirectory(Path.Combine(_applicationPaths.RootFolderPath), "Root");
+ CopyDirectory(Path.Combine(_applicationPaths.DataPath, "collections"), Path.Combine("Data", "collections"));
+ CopyDirectory(Path.Combine(_applicationPaths.DataPath, "playlists"), Path.Combine("Data", "playlists"));
+ CopyDirectory(Path.Combine(_applicationPaths.DataPath, "ScheduledTasks"), Path.Combine("Data", "ScheduledTasks"));
+ if (backupOptions.Subtitles)
+ {
+ CopyDirectory(Path.Combine(_applicationPaths.DataPath, "subtitles"), Path.Combine("Data", "subtitles"));
+ }
- foreach (var item in Directory.EnumerateFiles(source, filter, SearchOption.AllDirectories))
+ if (backupOptions.Trickplay)
{
- zipArchive.CreateEntryFromFile(item, NormalizePathSeparator(Path.Combine(target, Path.GetRelativePath(source, item))));
+ CopyDirectory(Path.Combine(_applicationPaths.DataPath, "trickplay"), Path.Combine("Data", "trickplay"));
}
- }
- CopyDirectory(Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "users"), Path.Combine("Config", "users"));
- CopyDirectory(Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "ScheduledTasks"), Path.Combine("Config", "ScheduledTasks"));
- CopyDirectory(Path.Combine(_applicationPaths.RootFolderPath), "Root");
- CopyDirectory(Path.Combine(_applicationPaths.DataPath, "collections"), Path.Combine("Data", "collections"));
- CopyDirectory(Path.Combine(_applicationPaths.DataPath, "playlists"), Path.Combine("Data", "playlists"));
- CopyDirectory(Path.Combine(_applicationPaths.DataPath, "ScheduledTasks"), Path.Combine("Data", "ScheduledTasks"));
- if (backupOptions.Subtitles)
- {
- CopyDirectory(Path.Combine(_applicationPaths.DataPath, "subtitles"), Path.Combine("Data", "subtitles"));
- }
+ if (backupOptions.Metadata)
+ {
+ CopyDirectory(Path.Combine(_applicationPaths.InternalMetadataPath), Path.Combine("Data", "metadata"));
+ }
- if (backupOptions.Trickplay)
- {
- CopyDirectory(Path.Combine(_applicationPaths.DataPath, "trickplay"), Path.Combine("Data", "trickplay"));
+ var manifestStream = await zipArchive.CreateEntry(ManifestEntryName).OpenAsync().ConfigureAwait(false);
+ await using (manifestStream.ConfigureAwait(false))
+ {
+ await JsonSerializer.SerializeAsync(manifestStream, manifest).ConfigureAwait(false);
+ }
}
- if (backupOptions.Metadata)
+ _logger.LogInformation("Backup created");
+ return Map(manifest, backupPath);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to create backup, removing {BackupPath}", backupPath);
+ try
{
- CopyDirectory(Path.Combine(_applicationPaths.InternalMetadataPath), Path.Combine("Data", "metadata"));
+ if (File.Exists(backupPath))
+ {
+ File.Delete(backupPath);
+ }
}
-
- var manifestStream = zipArchive.CreateEntry(ManifestEntryName).Open();
- await using (manifestStream.ConfigureAwait(false))
+ catch (Exception innerEx)
{
- await JsonSerializer.SerializeAsync(manifestStream, manifest).ConfigureAwait(false);
+ _logger.LogWarning(innerEx, "Unable to remove failed backup");
}
- }
- _logger.LogInformation("Backup created");
- return Map(manifest, backupPath);
+ throw;
+ }
}
/// <inheritdoc/>
@@ -422,7 +450,7 @@ public class BackupService : IBackupService
}
catch (Exception ex)
{
- _logger.LogError(ex, "Tried to load archive from {Path} but failed.", archivePath);
+ _logger.LogWarning(ex, "Tried to load manifest from archive {Path} but failed", archivePath);
return null;
}
@@ -459,7 +487,7 @@ public class BackupService : IBackupService
}
catch (Exception ex)
{
- _logger.LogError(ex, "Could not load {BackupArchive} path.", item);
+ _logger.LogWarning(ex, "Tried to load manifest from archive {Path} but failed", item);
}
}
@@ -478,7 +506,7 @@ public class BackupService : IBackupService
return null;
}
- var manifestStream = manifestEntry.Open();
+ var manifestStream = await manifestEntry.OpenAsync().ConfigureAwait(false);
await using (manifestStream.ConfigureAwait(false))
{
return await JsonSerializer.DeserializeAsync<BackupManifest>(manifestStream, _serializerSettings).ConfigureAwait(false);
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs
index eb88eac00..5bb4494dd 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs
@@ -250,7 +250,7 @@ public sealed class BaseItemRepository
public QueryResult<BaseItemDto> GetItems(InternalItemsQuery filter)
{
ArgumentNullException.ThrowIfNull(filter);
- if (!filter.EnableTotalRecordCount || (!filter.Limit.HasValue && (filter.StartIndex ?? 0) == 0))
+ if (!filter.EnableTotalRecordCount || ((filter.Limit ?? 0) == 0 && (filter.StartIndex ?? 0) == 0))
{
var returnList = GetItemList(filter);
return new QueryResult<BaseItemDto>(
@@ -275,8 +275,9 @@ public sealed class BaseItemRepository
}
dbQuery = ApplyQueryPaging(dbQuery, filter);
+ dbQuery = ApplyNavigations(dbQuery, filter);
- result.Items = dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray();
+ result.Items = dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).Where(dto => dto is not null).ToArray()!;
result.StartIndex = filter.StartIndex ?? 0;
return result;
}
@@ -295,7 +296,27 @@ public sealed class BaseItemRepository
dbQuery = ApplyGroupingFilter(context, dbQuery, filter);
dbQuery = ApplyQueryPaging(dbQuery, filter);
- return dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray();
+ var hasRandomSort = filter.OrderBy.Any(e => e.OrderBy == ItemSortBy.Random);
+ if (hasRandomSort)
+ {
+ var orderedIds = dbQuery.Select(e => e.Id).ToList();
+ if (orderedIds.Count == 0)
+ {
+ return Array.Empty<BaseItemDto>();
+ }
+
+ var itemsById = ApplyNavigations(context.BaseItems.Where(e => orderedIds.Contains(e.Id)), filter)
+ .AsEnumerable()
+ .Select(w => DeserializeBaseItem(w, filter.SkipDeserialization))
+ .Where(dto => dto is not null)
+ .ToDictionary(i => i!.Id);
+
+ return orderedIds.Where(itemsById.ContainsKey).Select(id => itemsById[id]).ToArray()!;
+ }
+
+ dbQuery = ApplyNavigations(dbQuery, filter);
+
+ return dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).Where(dto => dto is not null).ToArray()!;
}
/// <inheritdoc/>
@@ -324,7 +345,7 @@ public sealed class BaseItemRepository
.OrderByDescending(g => g.MaxDateCreated)
.Select(g => g);
- if (filter.Limit.HasValue)
+ if (filter.Limit.HasValue && filter.Limit.Value > 0)
{
subqueryGrouped = subqueryGrouped.Take(filter.Limit.Value);
}
@@ -337,7 +358,9 @@ public sealed class BaseItemRepository
mainquery = ApplyGroupingFilter(context, mainquery, filter);
mainquery = ApplyQueryPaging(mainquery, filter);
- return mainquery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray();
+ mainquery = ApplyNavigations(mainquery, filter);
+
+ return mainquery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).Where(dto => dto is not null).ToArray()!;
}
/// <inheritdoc />
@@ -363,7 +386,7 @@ public sealed class BaseItemRepository
.OrderByDescending(g => g.LastPlayedDate)
.Select(g => g.Key!);
- if (filter.Limit.HasValue)
+ if (filter.Limit.HasValue && filter.Limit.Value > 0)
{
query = query.Take(filter.Limit.Value);
}
@@ -399,19 +422,32 @@ public sealed class BaseItemRepository
dbQuery = dbQuery.Distinct();
}
- dbQuery = ApplyOrder(dbQuery, filter);
-
- dbQuery = ApplyNavigations(dbQuery, filter);
+ dbQuery = ApplyOrder(dbQuery, filter, context);
return dbQuery;
}
private static IQueryable<BaseItemEntity> ApplyNavigations(IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter)
{
- dbQuery = dbQuery.Include(e => e.TrailerTypes)
- .Include(e => e.Provider)
- .Include(e => e.LockedFields)
- .Include(e => e.UserData);
+ if (filter.TrailerTypes.Length > 0 || filter.IncludeItemTypes.Contains(BaseItemKind.Trailer))
+ {
+ dbQuery = dbQuery.Include(e => e.TrailerTypes);
+ }
+
+ if (filter.DtoOptions.ContainsField(ItemFields.ProviderIds))
+ {
+ dbQuery = dbQuery.Include(e => e.Provider);
+ }
+
+ if (filter.DtoOptions.ContainsField(ItemFields.Settings))
+ {
+ dbQuery = dbQuery.Include(e => e.LockedFields);
+ }
+
+ if (filter.DtoOptions.EnableUserData)
+ {
+ dbQuery = dbQuery.Include(e => e.UserData);
+ }
if (filter.DtoOptions.EnableImages)
{
@@ -423,19 +459,14 @@ public sealed class BaseItemRepository
private IQueryable<BaseItemEntity> ApplyQueryPaging(IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter)
{
- if (filter.Limit.HasValue || filter.StartIndex.HasValue)
+ if (filter.StartIndex.HasValue && filter.StartIndex.Value > 0)
{
- var offset = filter.StartIndex ?? 0;
-
- if (offset > 0)
- {
- dbQuery = dbQuery.Skip(offset);
- }
+ dbQuery = dbQuery.Skip(filter.StartIndex.Value);
+ }
- if (filter.Limit.HasValue)
- {
- dbQuery = dbQuery.Take(filter.Limit.Value);
- }
+ if (filter.Limit.HasValue && filter.Limit.Value > 0)
+ {
+ dbQuery = dbQuery.Take(filter.Limit.Value);
}
return dbQuery;
@@ -446,6 +477,7 @@ public sealed class BaseItemRepository
dbQuery = TranslateQuery(dbQuery, context, filter);
dbQuery = ApplyGroupingFilter(context, dbQuery, filter);
dbQuery = ApplyQueryPaging(dbQuery, filter);
+ dbQuery = ApplyNavigations(dbQuery, filter);
return dbQuery;
}
@@ -549,22 +581,34 @@ public sealed class BaseItemRepository
}
/// <inheritdoc />
- public void SaveImages(BaseItemDto item)
+ public async Task SaveImagesAsync(BaseItemDto item, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(item);
- var images = item.ImageInfos.Select(e => Map(item.Id, e));
- using var context = _dbProvider.CreateDbContext();
+ var images = item.ImageInfos.Select(e => Map(item.Id, e)).ToArray();
- if (!context.BaseItems.Any(bi => bi.Id == item.Id))
+ var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
+ await using (context.ConfigureAwait(false))
{
- _logger.LogWarning("Unable to save ImageInfo for non existing BaseItem");
- return;
- }
+ if (!await context.BaseItems
+ .AnyAsync(bi => bi.Id == item.Id, cancellationToken)
+ .ConfigureAwait(false))
+ {
+ _logger.LogWarning("Unable to save ImageInfo for non existing BaseItem");
+ return;
+ }
- context.BaseItemImageInfos.Where(e => e.ItemId == item.Id).ExecuteDelete();
- context.BaseItemImageInfos.AddRange(images);
- context.SaveChanges();
+ await context.BaseItemImageInfos
+ .Where(e => e.ItemId == item.Id)
+ .ExecuteDeleteAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ await context.BaseItemImageInfos
+ .AddRangeAsync(images, cancellationToken)
+ .ConfigureAwait(false);
+
+ await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ }
}
/// <inheritdoc />
@@ -599,7 +643,6 @@ public sealed class BaseItemRepository
var ids = tuples.Select(f => f.Item.Id).ToArray();
var existingItems = context.BaseItems.Where(e => ids.Contains(e.Id)).Select(f => f.Id).ToArray();
- var newItems = tuples.Where(e => !existingItems.Contains(e.Item.Id)).ToArray();
foreach (var item in tuples)
{
@@ -614,25 +657,25 @@ public sealed class BaseItemRepository
else
{
context.BaseItemProviders.Where(e => e.ItemId == entity.Id).ExecuteDelete();
+ context.BaseItemImageInfos.Where(e => e.ItemId == entity.Id).ExecuteDelete();
+ context.BaseItemMetadataFields.Where(e => e.ItemId == entity.Id).ExecuteDelete();
+
+ if (entity.Images is { Count: > 0 })
+ {
+ context.BaseItemImageInfos.AddRange(entity.Images);
+ }
+
+ if (entity.LockedFields is { Count: > 0 })
+ {
+ context.BaseItemMetadataFields.AddRange(entity.LockedFields);
+ }
+
context.BaseItems.Attach(entity).State = EntityState.Modified;
}
}
context.SaveChanges();
- foreach (var item in newItems)
- {
- // reattach old userData entries
- var userKeys = item.UserDataKey.ToArray();
- var retentionDate = (DateTime?)null;
- context.UserData
- .Where(e => e.ItemId == PlaceholderId)
- .Where(e => userKeys.Contains(e.CustomDataKey))
- .ExecuteUpdate(e => e
- .SetProperty(f => f.ItemId, item.Item.Id)
- .SetProperty(f => f.RetentionDate, retentionDate));
- }
-
var itemValueMaps = tuples
.Select(e => (e.Item, Values: GetItemValuesToSave(e.Item, e.InheritedTags)))
.ToArray();
@@ -729,6 +772,43 @@ public sealed class BaseItemRepository
}
/// <inheritdoc />
+ public async Task ReattachUserDataAsync(BaseItemDto item, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(item);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
+
+ await using (dbContext.ConfigureAwait(false))
+ {
+ var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
+ await using (transaction.ConfigureAwait(false))
+ {
+ var userKeys = item.GetUserDataKeys().ToArray();
+ var retentionDate = (DateTime?)null;
+
+ await dbContext.UserData
+ .Where(e => e.ItemId == PlaceholderId)
+ .Where(e => userKeys.Contains(e.CustomDataKey))
+ .ExecuteUpdateAsync(
+ e => e
+ .SetProperty(f => f.ItemId, item.Id)
+ .SetProperty(f => f.RetentionDate, retentionDate),
+ cancellationToken).ConfigureAwait(false);
+
+ // Rehydrate the cached userdata
+ item.UserData = await dbContext.UserData
+ .AsNoTracking()
+ .Where(e => e.ItemId == item.Id)
+ .ToArrayAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
+ }
+ }
+ }
+
+ /// <inheritdoc />
public BaseItemDto? RetrieveItem(Guid id)
{
if (id.IsEmpty())
@@ -835,7 +915,7 @@ public sealed class BaseItemRepository
}
dto.ExtraIds = string.IsNullOrWhiteSpace(entity.ExtraIds) ? [] : entity.ExtraIds.Split('|').Select(e => Guid.Parse(e)).ToArray();
- dto.ProductionLocations = entity.ProductionLocations?.Split('|') ?? [];
+ dto.ProductionLocations = entity.ProductionLocations?.Split('|', StringSplitOptions.RemoveEmptyEntries) ?? [];
dto.Studios = entity.Studios?.Split('|') ?? [];
dto.Tags = string.IsNullOrWhiteSpace(entity.Tags) ? [] : entity.Tags.Split('|');
@@ -997,7 +1077,7 @@ public sealed class BaseItemRepository
}
entity.ExtraIds = dto.ExtraIds is not null ? string.Join('|', dto.ExtraIds) : null;
- entity.ProductionLocations = dto.ProductionLocations is not null ? string.Join('|', dto.ProductionLocations) : null;
+ entity.ProductionLocations = dto.ProductionLocations is not null ? string.Join('|', dto.ProductionLocations.Where(p => !string.IsNullOrWhiteSpace(p))) : null;
entity.Studios = dto.Studios is not null ? string.Join('|', dto.Studios) : null;
entity.Tags = dto.Tags is not null ? string.Join('|', dto.Tags) : null;
entity.LockedFields = dto.LockedFields is not null ? dto.LockedFields
@@ -1121,7 +1201,7 @@ public sealed class BaseItemRepository
return type.GetCustomAttribute<RequiresSourceSerialisationAttribute>() == null;
}
- private BaseItemDto DeserializeBaseItem(BaseItemEntity baseItemEntity, bool skipDeserialization = false)
+ private BaseItemDto? DeserializeBaseItem(BaseItemEntity baseItemEntity, bool skipDeserialization = false)
{
ArgumentNullException.ThrowIfNull(baseItemEntity, nameof(baseItemEntity));
if (_serverConfigurationManager?.Configuration is null)
@@ -1144,11 +1224,19 @@ public sealed class BaseItemRepository
/// <param name="logger">Logger.</param>
/// <param name="appHost">The application server Host.</param>
/// <param name="skipDeserialization">If only mapping should be processed.</param>
- /// <returns>A mapped BaseItem.</returns>
- /// <exception cref="InvalidOperationException">Will be thrown if an invalid serialisation is requested.</exception>
- public static BaseItemDto DeserializeBaseItem(BaseItemEntity baseItemEntity, ILogger logger, IServerApplicationHost? appHost, bool skipDeserialization = false)
+ /// <returns>A mapped BaseItem, or null if the item type is unknown.</returns>
+ public static BaseItemDto? DeserializeBaseItem(BaseItemEntity baseItemEntity, ILogger logger, IServerApplicationHost? appHost, bool skipDeserialization = false)
{
- var type = GetType(baseItemEntity.Type) ?? throw new InvalidOperationException("Cannot deserialize unknown type.");
+ var type = GetType(baseItemEntity.Type);
+ if (type is null)
+ {
+ logger.LogWarning(
+ "Skipping item {ItemId} with unknown type '{ItemType}'. This may indicate a removed plugin or database corruption.",
+ baseItemEntity.Id,
+ baseItemEntity.Type);
+ return null;
+ }
+
BaseItemDto? dto = null;
if (TypeRequiresDeserialization(type) && baseItemEntity.Data is not null && !skipDeserialization)
{
@@ -1174,7 +1262,7 @@ public sealed class BaseItemRepository
{
ArgumentNullException.ThrowIfNull(filter);
- if (!filter.Limit.HasValue)
+ if (!(filter.Limit.HasValue && filter.Limit.Value > 0))
{
filter.EnableTotalRecordCount = false;
}
@@ -1245,7 +1333,7 @@ public sealed class BaseItemRepository
.AsSingleQuery()
.Where(e => masterQuery.Contains(e.Id));
- query = ApplyOrder(query, filter);
+ query = ApplyOrder(query, filter, context);
var result = new QueryResult<(BaseItemDto, ItemCounts?)>();
if (filter.EnableTotalRecordCount)
@@ -1253,19 +1341,14 @@ public sealed class BaseItemRepository
result.TotalRecordCount = query.Count();
}
- if (filter.Limit.HasValue || filter.StartIndex.HasValue)
+ if (filter.StartIndex.HasValue && filter.StartIndex.Value > 0)
{
- var offset = filter.StartIndex ?? 0;
-
- if (offset > 0)
- {
- query = query.Skip(offset);
- }
+ query = query.Skip(filter.StartIndex.Value);
+ }
- if (filter.Limit.HasValue)
- {
- query = query.Take(filter.Limit.Value);
- }
+ if (filter.Limit.HasValue && filter.Limit.Value > 0)
+ {
+ query = query.Take(filter.Limit.Value);
}
IQueryable<BaseItemEntity>? itemCountQuery = null;
@@ -1320,10 +1403,9 @@ public sealed class BaseItemRepository
.. resultQuery
.AsEnumerable()
.Where(e => e is not null)
- .Select(e =>
- {
- return (DeserializeBaseItem(e.item, filter.SkipDeserialization), e.itemCount);
- })
+ .Select(e => (Item: DeserializeBaseItem(e.item, filter.SkipDeserialization), e.itemCount))
+ .Where(e => e.Item is not null)
+ .Select(e => (e.Item!, e.itemCount))
];
}
else
@@ -1334,10 +1416,9 @@ public sealed class BaseItemRepository
.. query
.AsEnumerable()
.Where(e => e is not null)
- .Select<BaseItemEntity, (BaseItemDto, ItemCounts?)>(e =>
- {
- return (DeserializeBaseItem(e, filter.SkipDeserialization), null);
- })
+ .Select(e => (Item: DeserializeBaseItem(e, filter.SkipDeserialization), ItemCounts: (ItemCounts?)null))
+ .Where(e => e.Item is not null)
+ .Select(e => (e.Item!, e.ItemCounts))
];
}
@@ -1346,7 +1427,7 @@ public sealed class BaseItemRepository
private static void PrepareFilterQuery(InternalItemsQuery query)
{
- if (query.Limit.HasValue && query.EnableGroupByMetadataKey)
+ if (query.Limit.HasValue && query.Limit.Value > 0 && query.EnableGroupByMetadataKey)
{
query.Limit = query.Limit.Value + 4;
}
@@ -1357,14 +1438,54 @@ public sealed class BaseItemRepository
}
}
- private string GetCleanValue(string value)
+ /// <summary>
+ /// Gets the clean value for search and sorting purposes.
+ /// </summary>
+ /// <param name="value">The value to clean.</param>
+ /// <returns>The cleaned value.</returns>
+ public static string GetCleanValue(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return value;
}
- return value.RemoveDiacritics().ToLowerInvariant();
+ var noDiacritics = value.RemoveDiacritics();
+
+ // Build a string where any punctuation or symbol is treated as a separator (space).
+ var sb = new StringBuilder(noDiacritics.Length);
+ var previousWasSpace = false;
+ foreach (var ch in noDiacritics)
+ {
+ char outCh;
+ if (char.IsLetterOrDigit(ch) || char.IsWhiteSpace(ch))
+ {
+ outCh = ch;
+ }
+ else
+ {
+ outCh = ' ';
+ }
+
+ // normalize any whitespace character to a single ASCII space.
+ if (char.IsWhiteSpace(outCh))
+ {
+ if (!previousWasSpace)
+ {
+ sb.Append(' ');
+ previousWasSpace = true;
+ }
+ }
+ else
+ {
+ sb.Append(outCh);
+ previousWasSpace = false;
+ }
+ }
+
+ // trim leading/trailing spaces that may have been added.
+ var collapsed = sb.ToString().Trim();
+ return collapsed.ToLowerInvariant();
}
private List<(ItemValueType MagicNumber, string Value)> GetItemValuesToSave(BaseItemDto item, List<string> inheritedTags)
@@ -1511,51 +1632,58 @@ public sealed class BaseItemRepository
|| query.IncludeItemTypes.Contains(BaseItemKind.Season);
}
- private IQueryable<BaseItemEntity> ApplyOrder(IQueryable<BaseItemEntity> query, InternalItemsQuery filter)
+ private IQueryable<BaseItemEntity> ApplyOrder(IQueryable<BaseItemEntity> query, InternalItemsQuery filter, JellyfinDbContext context)
{
- var orderBy = filter.OrderBy;
+ var orderBy = filter.OrderBy.Where(e => e.OrderBy != ItemSortBy.Default).ToArray();
var hasSearch = !string.IsNullOrEmpty(filter.SearchTerm);
if (hasSearch)
{
- orderBy = filter.OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending), .. orderBy];
+ orderBy = [(ItemSortBy.SortName, SortOrder.Ascending), .. orderBy];
}
- else if (orderBy.Count == 0)
+ else if (orderBy.Length == 0)
{
return query.OrderBy(e => e.SortName);
}
IOrderedQueryable<BaseItemEntity>? orderedQuery = null;
+ // When searching, prioritize by match quality: exact match > prefix match > contains
+ if (hasSearch)
+ {
+ orderedQuery = query.OrderBy(OrderMapper.MapSearchRelevanceOrder(filter.SearchTerm!));
+ }
+
var firstOrdering = orderBy.FirstOrDefault();
if (firstOrdering != default)
{
- var expression = OrderMapper.MapOrderByField(firstOrdering.OrderBy, filter);
- if (firstOrdering.SortOrder == SortOrder.Ascending)
+ var expression = OrderMapper.MapOrderByField(firstOrdering.OrderBy, filter, context);
+ if (orderedQuery is null)
{
- orderedQuery = query.OrderBy(expression);
+ // No search relevance ordering, start fresh
+ orderedQuery = firstOrdering.SortOrder == SortOrder.Ascending
+ ? query.OrderBy(expression)
+ : query.OrderByDescending(expression);
}
else
{
- orderedQuery = query.OrderByDescending(expression);
+ // Search relevance ordering already applied, chain with ThenBy
+ orderedQuery = firstOrdering.SortOrder == SortOrder.Ascending
+ ? orderedQuery.ThenBy(expression)
+ : orderedQuery.ThenByDescending(expression);
}
if (firstOrdering.OrderBy is ItemSortBy.Default or ItemSortBy.SortName)
{
- if (firstOrdering.SortOrder is SortOrder.Ascending)
- {
- orderedQuery = orderedQuery.ThenBy(e => e.Name);
- }
- else
- {
- orderedQuery = orderedQuery.ThenByDescending(e => e.Name);
- }
+ orderedQuery = firstOrdering.SortOrder is SortOrder.Ascending
+ ? orderedQuery.ThenBy(e => e.Name)
+ : orderedQuery.ThenByDescending(e => e.Name);
}
}
foreach (var item in orderBy.Skip(1))
{
- var expression = OrderMapper.MapOrderByField(item.OrderBy, filter);
+ var expression = OrderMapper.MapOrderByField(item.OrderBy, filter, context);
if (item.SortOrder == SortOrder.Ascending)
{
orderedQuery = orderedQuery!.ThenBy(expression);
@@ -1637,19 +1765,18 @@ public sealed class BaseItemRepository
var tags = filter.Tags.ToList();
var excludeTags = filter.ExcludeTags.ToList();
- if (filter.IsMovie == true)
+ if (filter.IsMovie.HasValue)
{
- if (filter.IncludeItemTypes.Length == 0
- || filter.IncludeItemTypes.Contains(BaseItemKind.Movie)
- || filter.IncludeItemTypes.Contains(BaseItemKind.Trailer))
+ var shouldIncludeAllMovieTypes = filter.IsMovie.Value
+ && (filter.IncludeItemTypes.Length == 0
+ || filter.IncludeItemTypes.Contains(BaseItemKind.Movie)
+ || filter.IncludeItemTypes.Contains(BaseItemKind.Trailer));
+
+ if (!shouldIncludeAllMovieTypes)
{
- baseQuery = baseQuery.Where(e => e.IsMovie);
+ baseQuery = baseQuery.Where(e => e.IsMovie == filter.IsMovie.Value);
}
}
- else if (filter.IsMovie.HasValue)
- {
- baseQuery = baseQuery.Where(e => e.IsMovie == filter.IsMovie);
- }
if (filter.IsSeries.HasValue)
{
@@ -1694,15 +1821,16 @@ public sealed class BaseItemRepository
if (!string.IsNullOrEmpty(filter.SearchTerm))
{
- var searchTerm = filter.SearchTerm.ToLower();
- if (SearchWildcardTerms.Any(f => searchTerm.Contains(f)))
+ var cleanedSearchTerm = GetCleanValue(filter.SearchTerm);
+ var originalSearchTerm = filter.SearchTerm.ToLower();
+ if (SearchWildcardTerms.Any(f => cleanedSearchTerm.Contains(f)))
{
- searchTerm = $"%{searchTerm.Trim('%')}%";
- baseQuery = baseQuery.Where(e => EF.Functions.Like(e.CleanName!.ToLower(), searchTerm) || (e.OriginalTitle != null && EF.Functions.Like(e.OriginalTitle.ToLower(), searchTerm)));
+ cleanedSearchTerm = $"%{cleanedSearchTerm.Trim('%')}%";
+ baseQuery = baseQuery.Where(e => EF.Functions.Like(e.CleanName!, cleanedSearchTerm) || (e.OriginalTitle != null && EF.Functions.Like(e.OriginalTitle.ToLower(), originalSearchTerm)));
}
else
{
- baseQuery = baseQuery.Where(e => e.CleanName!.ToLower().Contains(searchTerm) || (e.OriginalTitle != null && e.OriginalTitle.ToLower().Contains(searchTerm)));
+ baseQuery = baseQuery.Where(e => e.CleanName!.Contains(cleanedSearchTerm) || (e.OriginalTitle != null && e.OriginalTitle.ToLower().Contains(originalSearchTerm)));
}
}
@@ -1756,7 +1884,8 @@ public sealed class BaseItemRepository
if (!string.IsNullOrWhiteSpace(filter.Path))
{
- baseQuery = baseQuery.Where(e => e.Path == filter.Path);
+ var pathToQuery = GetPathToSave(filter.Path);
+ baseQuery = baseQuery.Where(e => e.Path == pathToQuery);
}
if (!string.IsNullOrWhiteSpace(filter.PresentationUniqueKey))
@@ -1913,8 +2042,15 @@ public sealed class BaseItemRepository
if (!string.IsNullOrWhiteSpace(filter.Name))
{
- var cleanName = GetCleanValue(filter.Name);
- baseQuery = baseQuery.Where(e => e.CleanName == cleanName);
+ if (filter.UseRawName == true)
+ {
+ baseQuery = baseQuery.Where(e => e.Name == filter.Name);
+ }
+ else
+ {
+ var cleanName = GetCleanValue(filter.Name);
+ baseQuery = baseQuery.Where(e => e.CleanName == cleanName);
+ }
}
// These are the same, for now
@@ -1936,19 +2072,20 @@ public sealed class BaseItemRepository
if (!string.IsNullOrWhiteSpace(filter.NameStartsWith))
{
- baseQuery = baseQuery.Where(e => e.SortName!.StartsWith(filter.NameStartsWith));
+ var startsWithLower = filter.NameStartsWith.ToLowerInvariant();
+ baseQuery = baseQuery.Where(e => e.SortName!.StartsWith(startsWithLower));
}
if (!string.IsNullOrWhiteSpace(filter.NameStartsWithOrGreater))
{
- // i hate this
- baseQuery = baseQuery.Where(e => e.SortName!.FirstOrDefault() > filter.NameStartsWithOrGreater[0] || e.Name!.FirstOrDefault() > filter.NameStartsWithOrGreater[0]);
+ var startsOrGreaterLower = filter.NameStartsWithOrGreater.ToLowerInvariant();
+ baseQuery = baseQuery.Where(e => e.SortName!.CompareTo(startsOrGreaterLower) >= 0);
}
if (!string.IsNullOrWhiteSpace(filter.NameLessThan))
{
- // i hate this
- baseQuery = baseQuery.Where(e => e.SortName!.FirstOrDefault() < filter.NameLessThan[0] || e.Name!.FirstOrDefault() < filter.NameLessThan[0]);
+ var lessThanLower = filter.NameLessThan.ToLowerInvariant();
+ baseQuery = baseQuery.Where(e => e.SortName!.CompareTo(lessThanLower ) < 0);
}
if (filter.ImageTypes.Length > 0)
@@ -2046,7 +2183,7 @@ public sealed class BaseItemRepository
if (filter.ExcludeArtistIds.Length > 0)
{
- baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Artist, filter.ExcludeArtistIds, true);
+ baseQuery = baseQuery.WhereReferencedItemMultipleTypes(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ExcludeArtistIds, true);
}
if (filter.GenreIds.Count > 0)
@@ -2353,17 +2490,23 @@ public sealed class BaseItemRepository
if (filter.HasImdbId.HasValue)
{
- baseQuery = baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId == "imdb"));
+ baseQuery = filter.HasImdbId.Value
+ ? baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId.ToLower() == MetadataProvider.Imdb.ToString().ToLower()))
+ : baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != MetadataProvider.Imdb.ToString().ToLower()));
}
if (filter.HasTmdbId.HasValue)
{
- baseQuery = baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId == "tmdb"));
+ baseQuery = filter.HasTmdbId.Value
+ ? baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId.ToLower() == MetadataProvider.Tmdb.ToString().ToLower()))
+ : baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != MetadataProvider.Tmdb.ToString().ToLower()));
}
if (filter.HasTvdbId.HasValue)
{
- baseQuery = baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId == "tvdb"));
+ baseQuery = filter.HasTvdbId.Value
+ ? baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId.ToLower() == MetadataProvider.Tvdb.ToString().ToLower()))
+ : baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != MetadataProvider.Tvdb.ToString().ToLower()));
}
var queryTopParentIds = filter.TopParentIds;
@@ -2401,40 +2544,24 @@ public sealed class BaseItemRepository
if (filter.ExcludeInheritedTags.Length > 0)
{
- baseQuery = baseQuery
- .Where(e => !e.ItemValues!.Where(w => w.ItemValue.Type == ItemValueType.InheritedTags || w.ItemValue.Type == ItemValueType.Tags)
- .Any(f => filter.ExcludeInheritedTags.Contains(f.ItemValue.CleanValue)));
+ var excludedTags = filter.ExcludeInheritedTags;
+ baseQuery = baseQuery.Where(e =>
+ !e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && excludedTags.Contains(f.ItemValue.CleanValue))
+ && (!e.SeriesId.HasValue || !context.ItemValuesMap.Any(f => f.ItemId == e.SeriesId.Value && f.ItemValue.Type == ItemValueType.Tags && excludedTags.Contains(f.ItemValue.CleanValue))));
}
if (filter.IncludeInheritedTags.Length > 0)
{
- // Episodes do not store inherit tags from their parents in the database, and the tag may be still required by the client.
- // In addition to the tags for the episodes themselves, we need to manually query its parent (the season)'s tags as well.
- if (includeTypes.Length == 1 && includeTypes.FirstOrDefault() is BaseItemKind.Episode)
- {
- baseQuery = baseQuery
- .Where(e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.InheritedTags || f.ItemValue.Type == ItemValueType.Tags)
- .Any(f => filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue))
- ||
- (e.ParentId.HasValue && context.ItemValuesMap.Where(w => w.ItemId == e.ParentId.Value && (w.ItemValue.Type == ItemValueType.InheritedTags || w.ItemValue.Type == ItemValueType.Tags))
- .Any(f => filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue))));
- }
+ var includeTags = filter.IncludeInheritedTags;
+ var isPlaylistOnlyQuery = includeTypes.Length == 1 && includeTypes.FirstOrDefault() == BaseItemKind.Playlist;
+ baseQuery = baseQuery.Where(e =>
+ e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue))
- // A playlist should be accessible to its owner regardless of allowed tags.
- else if (includeTypes.Length == 1 && includeTypes.FirstOrDefault() is BaseItemKind.Playlist)
- {
- baseQuery = baseQuery
- .Where(e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.InheritedTags || f.ItemValue.Type == ItemValueType.Tags)
- .Any(f => filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue))
- || e.Data!.Contains($"OwnerUserId\":\"{filter.User!.Id:N}\""));
- // d ^^ this is stupid it hate this.
- }
- else
- {
- baseQuery = baseQuery
- .Where(e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.InheritedTags || f.ItemValue.Type == ItemValueType.Tags)
- .Any(f => filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue)));
- }
+ // For seasons and episodes, we also need to check the parent series' tags.
+ || (e.SeriesId.HasValue && context.ItemValuesMap.Any(f => f.ItemId == e.SeriesId.Value && f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue)))
+
+ // A playlist should be accessible to its owner regardless of allowed tags
+ || (isPlaylistOnlyQuery && e.Data!.Contains($"OwnerUserId\":\"{filter.User!.Id:N}\"")));
}
if (filter.SeriesStatuses.Length > 0)
@@ -2588,6 +2715,21 @@ public sealed class BaseItemRepository
.Where(e => artistNames.Contains(e.Name))
.ToArray();
- return artists.GroupBy(e => e.Name).ToDictionary(e => e.Key!, e => e.Select(f => DeserializeBaseItem(f)).Cast<MusicArtist>().ToArray());
+ var lookup = artists
+ .GroupBy(e => e.Name!)
+ .ToDictionary(
+ g => g.Key,
+ g => g.Select(f => DeserializeBaseItem(f)).Where(dto => dto is not null).Cast<MusicArtist>().ToArray());
+
+ var result = new Dictionary<string, MusicArtist[]>(artistNames.Count);
+ foreach (var name in artistNames)
+ {
+ if (lookup.TryGetValue(name, out var artistArray))
+ {
+ result[name] = artistArray;
+ }
+ }
+
+ return result;
}
}
diff --git a/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs b/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs
index 7eb13b740..64874ccad 100644
--- a/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs
+++ b/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs
@@ -158,6 +158,12 @@ public class MediaStreamRepository : IMediaStreamRepository
dto.LocalizedDefault = _localization.GetLocalizedString("Default");
dto.LocalizedExternal = _localization.GetLocalizedString("External");
+ if (!string.IsNullOrEmpty(dto.Language))
+ {
+ var culture = _localization.FindLanguageInfo(dto.Language);
+ dto.LocalizedLanguage = culture?.DisplayName;
+ }
+
if (dto.Type is MediaStreamType.Subtitle)
{
dto.LocalizedUndefined = _localization.GetLocalizedString("Undefined");
diff --git a/Jellyfin.Server.Implementations/Item/OrderMapper.cs b/Jellyfin.Server.Implementations/Item/OrderMapper.cs
index a0c127031..1ae7cc6c4 100644
--- a/Jellyfin.Server.Implementations/Item/OrderMapper.cs
+++ b/Jellyfin.Server.Implementations/Item/OrderMapper.cs
@@ -1,8 +1,12 @@
+#pragma warning disable RS0030 // Do not use banned APIs
+
using System;
using System.Linq;
using System.Linq.Expressions;
using Jellyfin.Data.Enums;
+using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Extensions;
using MediaBrowser.Controller.Entities;
using Microsoft.EntityFrameworkCore;
@@ -18,40 +22,77 @@ public static class OrderMapper
/// </summary>
/// <param name="sortBy">Item property to sort by.</param>
/// <param name="query">Context Query.</param>
+ /// <param name="jellyfinDbContext">Context.</param>
/// <returns>Func to be executed later for sorting query.</returns>
- public static Expression<Func<BaseItemEntity, object?>> MapOrderByField(ItemSortBy sortBy, InternalItemsQuery query)
+ public static Expression<Func<BaseItemEntity, object?>> MapOrderByField(ItemSortBy sortBy, InternalItemsQuery query, JellyfinDbContext jellyfinDbContext)
{
- return sortBy switch
+ return (sortBy, query.User) switch
{
- ItemSortBy.AirTime => e => e.SortName, // TODO
- ItemSortBy.Runtime => e => e.RunTimeTicks,
- ItemSortBy.Random => e => EF.Functions.Random(),
- ItemSortBy.DatePlayed => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.LastPlayedDate,
- ItemSortBy.PlayCount => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.PlayCount,
- ItemSortBy.IsFavoriteOrLiked => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.IsFavorite,
- ItemSortBy.IsFolder => e => e.IsFolder,
- ItemSortBy.IsPlayed => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.Played,
- ItemSortBy.IsUnplayed => e => !e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.Played,
- ItemSortBy.DateLastContentAdded => e => e.DateLastMediaAdded,
- ItemSortBy.Artist => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.Artist).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
- ItemSortBy.AlbumArtist => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.AlbumArtist).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
- ItemSortBy.Studio => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.Studios).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
- ItemSortBy.OfficialRating => e => e.InheritedParentalRatingValue,
- // ItemSortBy.SeriesDatePlayed => "(Select MAX(LastPlayedDate) from TypedBaseItems B" + GetJoinUserDataText(query) + " where Played=1 and B.SeriesPresentationUniqueKey=A.PresentationUniqueKey)",
- ItemSortBy.SeriesSortName => e => e.SeriesName,
+ (ItemSortBy.AirTime, _) => e => e.SortName, // TODO
+ (ItemSortBy.Runtime, _) => e => e.RunTimeTicks,
+ (ItemSortBy.Random, _) => e => EF.Functions.Random(),
+ (ItemSortBy.DatePlayed, _) => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.LastPlayedDate,
+ (ItemSortBy.PlayCount, _) => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.PlayCount,
+ (ItemSortBy.IsFavoriteOrLiked, _) => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.IsFavorite,
+ (ItemSortBy.IsFolder, _) => e => e.IsFolder,
+ (ItemSortBy.IsPlayed, _) => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.Played,
+ (ItemSortBy.IsUnplayed, _) => e => !e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.Played,
+ (ItemSortBy.DateLastContentAdded, _) => e => e.DateLastMediaAdded,
+ (ItemSortBy.Artist, _) => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.Artist).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
+ (ItemSortBy.AlbumArtist, _) => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.AlbumArtist).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
+ (ItemSortBy.Studio, _) => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.Studios).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
+ (ItemSortBy.OfficialRating, _) => e => e.InheritedParentalRatingValue,
+ (ItemSortBy.SeriesSortName, _) => e => e.SeriesName,
+ (ItemSortBy.Album, _) => e => e.Album,
+ (ItemSortBy.DateCreated, _) => e => e.DateCreated,
+ (ItemSortBy.PremiereDate, _) => e => (e.PremiereDate ?? (e.ProductionYear.HasValue ? DateTime.MinValue.AddYears(e.ProductionYear.Value - 1) : null)),
+ (ItemSortBy.StartDate, _) => e => e.StartDate,
+ (ItemSortBy.Name, _) => e => e.CleanName,
+ (ItemSortBy.CommunityRating, _) => e => e.CommunityRating,
+ (ItemSortBy.ProductionYear, _) => e => e.ProductionYear,
+ (ItemSortBy.CriticRating, _) => e => e.CriticRating,
+ (ItemSortBy.VideoBitRate, _) => e => e.TotalBitrate,
+ (ItemSortBy.ParentIndexNumber, _) => e => e.ParentIndexNumber,
+ (ItemSortBy.IndexNumber, _) => e => e.IndexNumber,
+ (ItemSortBy.SeriesDatePlayed, not null) => e =>
+ jellyfinDbContext.BaseItems
+ .Where(w => w.SeriesPresentationUniqueKey == e.PresentationUniqueKey)
+ .Join(jellyfinDbContext.UserData.Where(w => w.UserId == query.User.Id && w.Played), f => f.Id, f => f.ItemId, (item, userData) => userData.LastPlayedDate)
+ .Max(f => f),
+ (ItemSortBy.SeriesDatePlayed, null) => e => jellyfinDbContext.BaseItems.Where(w => w.SeriesPresentationUniqueKey == e.PresentationUniqueKey)
+ .Join(jellyfinDbContext.UserData.Where(w => w.Played), f => f.Id, f => f.ItemId, (item, userData) => userData.LastPlayedDate)
+ .Max(f => f),
+ // ItemSortBy.SeriesDatePlayed => e => jellyfinDbContext.UserData
+ // .Where(u => u.Item!.SeriesPresentationUniqueKey == e.PresentationUniqueKey && u.Played)
+ // .Max(f => f.LastPlayedDate),
// ItemSortBy.AiredEpisodeOrder => "AiredEpisodeOrder",
- ItemSortBy.Album => e => e.Album,
- ItemSortBy.DateCreated => e => e.DateCreated,
- ItemSortBy.PremiereDate => e => (e.PremiereDate ?? (e.ProductionYear.HasValue ? DateTime.MinValue.AddYears(e.ProductionYear.Value - 1) : null)),
- ItemSortBy.StartDate => e => e.StartDate,
- ItemSortBy.Name => e => e.CleanName,
- ItemSortBy.CommunityRating => e => e.CommunityRating,
- ItemSortBy.ProductionYear => e => e.ProductionYear,
- ItemSortBy.CriticRating => e => e.CriticRating,
- ItemSortBy.VideoBitRate => e => e.TotalBitrate,
- ItemSortBy.ParentIndexNumber => e => e.ParentIndexNumber,
- ItemSortBy.IndexNumber => e => e.IndexNumber,
_ => e => e.SortName
};
}
+
+ /// <summary>
+ /// Creates an expression to order search results by match quality.
+ /// Prioritizes: exact match (0) > prefix match with word boundary (1) > prefix match (2) > contains (3).
+ /// </summary>
+ /// <param name="searchTerm">The search term to match against.</param>
+ /// <returns>An expression that returns an integer representing match quality (lower is better).</returns>
+ public static Expression<Func<BaseItemEntity, int>> MapSearchRelevanceOrder(string searchTerm)
+ {
+ var cleanSearchTerm = GetCleanValue(searchTerm);
+ var searchPrefix = cleanSearchTerm + " ";
+ return e =>
+ e.CleanName == cleanSearchTerm ? 0 :
+ e.CleanName!.StartsWith(searchPrefix) ? 1 :
+ e.CleanName!.StartsWith(cleanSearchTerm) ? 2 : 3;
+ }
+
+ private static string GetCleanValue(string value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return value;
+ }
+
+ return value.RemoveDiacritics().ToLowerInvariant();
+ }
}
diff --git a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
index 355ed6479..e2569241d 100644
--- a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
+++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
@@ -74,9 +74,10 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
/// <inheritdoc />
public void UpdatePeople(Guid itemId, IReadOnlyList<PersonInfo> people)
{
- foreach (var item in people.Where(e => e.Role is null))
+ foreach (var person in people)
{
- item.Role = string.Empty;
+ person.Name = person.Name.Trim();
+ person.Role = person.Role?.Trim() ?? string.Empty;
}
// multiple metadata providers can provide the _same_ person
diff --git a/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj b/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj
index 6693ab8db..4f0c37722 100644
--- a/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj
+++ b/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
- <TargetFramework>net9.0</TargetFramework>
+ <TargetFramework>net10.0</TargetFramework>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
@@ -27,7 +27,6 @@
<ItemGroup>
<PackageReference Include="AsyncKeyedLock" />
- <PackageReference Include="System.Linq.Async" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
</ItemGroup>
diff --git a/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs b/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs
index b2f54be7e..ce628a04d 100644
--- a/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs
+++ b/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs
@@ -13,8 +13,7 @@ namespace Jellyfin.Server.Implementations.StorageHelpers;
public static class StorageHelper
{
private const long TwoGigabyte = 2_147_483_647L;
- private const long FiveHundredAndTwelveMegaByte = 536_870_911L;
- private static readonly string[] _byteHumanizedSuffixes = ["B", "KB", "MB", "GB", "TB", "PB", "EB"];
+ private static readonly string[] _byteHumanizedSuffixes = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"];
/// <summary>
/// Tests the available storage capacity on the jellyfin paths with estimated minimum values.
@@ -24,10 +23,8 @@ public static class StorageHelper
public static void TestCommonPathsForStorageCapacity(IApplicationPaths applicationPaths, ILogger logger)
{
TestDataDirectorySize(applicationPaths.DataPath, logger, TwoGigabyte);
- TestDataDirectorySize(applicationPaths.LogDirectoryPath, logger, FiveHundredAndTwelveMegaByte);
TestDataDirectorySize(applicationPaths.CachePath, logger, TwoGigabyte);
TestDataDirectorySize(applicationPaths.ProgramDataPath, logger, TwoGigabyte);
- TestDataDirectorySize(applicationPaths.TempDirectory, logger, TwoGigabyte);
}
/// <summary>
@@ -77,7 +74,7 @@ public static class StorageHelper
var drive = new DriveInfo(path);
if (threshold != -1 && drive.AvailableFreeSpace < threshold)
{
- throw new InvalidOperationException($"The path `{path}` has insufficient free space. Required: at least {HumanizeStorageSize(threshold)}.");
+ throw new InvalidOperationException($"The path `{path}` has insufficient free space. Available: {HumanizeStorageSize(drive.AvailableFreeSpace)}, Required: {HumanizeStorageSize(threshold)}.");
}
logger.LogInformation(
diff --git a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs
index 6f2d2a107..4505a377c 100644
--- a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs
+++ b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs
@@ -254,10 +254,10 @@ public class TrickplayManager : ITrickplayManager
}
// We support video backdrops, but we should not generate trickplay images for them
- var parentDirectory = Directory.GetParent(mediaPath);
+ var parentDirectory = Directory.GetParent(video.Path);
if (parentDirectory is not null && string.Equals(parentDirectory.Name, "backdrops", StringComparison.OrdinalIgnoreCase))
{
- _logger.LogDebug("Ignoring backdrop media found at {Path} for item {ItemID}", mediaPath, video.Id);
+ _logger.LogDebug("Ignoring backdrop media found at {Path} for item {ItemID}", video.Path, video.Id);
return;
}
diff --git a/Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs b/Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs
index 35c43b176..446849b6f 100644
--- a/Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs
+++ b/Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs
@@ -59,7 +59,7 @@ namespace Jellyfin.Server.Implementations.Users
}
// As long as jellyfin supports password-less users, we need this little block here to accommodate
- if (!HasPassword(resolvedUser) && string.IsNullOrEmpty(password))
+ if (string.IsNullOrEmpty(resolvedUser.Password) && string.IsNullOrEmpty(password))
{
return Task.FromResult(new ProviderAuthenticationResult
{
@@ -94,10 +94,6 @@ namespace Jellyfin.Server.Implementations.Users
}
/// <inheritdoc />
- public bool HasPassword(User user)
- => !string.IsNullOrEmpty(user?.Password);
-
- /// <inheritdoc />
public Task ChangePassword(User user, string newPassword)
{
if (string.IsNullOrEmpty(newPassword))
diff --git a/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs b/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs
index f20fb2d92..49a9fda94 100644
--- a/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs
+++ b/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Globalization;
using System.IO;
using System.Security.Cryptography;
using System.Text.Json;
@@ -92,33 +93,38 @@ namespace Jellyfin.Server.Implementations.Users
}
/// <inheritdoc />
- public async Task<ForgotPasswordResult> StartForgotPasswordProcess(User user, bool isInNetwork)
+ public async Task<ForgotPasswordResult> StartForgotPasswordProcess(User? user, string enteredUsername, bool isInNetwork)
{
- byte[] bytes = new byte[4];
- RandomNumberGenerator.Fill(bytes);
- string pin = BitConverter.ToString(bytes);
-
DateTime expireTime = DateTime.UtcNow.AddMinutes(30);
- string filePath = _passwordResetFileBase + user.Id + ".json";
- SerializablePasswordReset spr = new SerializablePasswordReset
- {
- ExpirationDate = expireTime,
- Pin = pin,
- PinFile = filePath,
- UserName = user.Username
- };
+ var usernameHash = enteredUsername.ToUpperInvariant().GetMD5().ToString("N", CultureInfo.InvariantCulture);
+ var pinFile = _passwordResetFileBase + usernameHash + ".json";
- FileStream fileStream = AsyncFile.Create(filePath);
- await using (fileStream.ConfigureAwait(false))
+ if (user is not null && isInNetwork)
{
- await JsonSerializer.SerializeAsync(fileStream, spr).ConfigureAwait(false);
+ byte[] bytes = new byte[4];
+ RandomNumberGenerator.Fill(bytes);
+ string pin = BitConverter.ToString(bytes);
+
+ SerializablePasswordReset spr = new SerializablePasswordReset
+ {
+ ExpirationDate = expireTime,
+ Pin = pin,
+ PinFile = pinFile,
+ UserName = user.Username
+ };
+
+ FileStream fileStream = AsyncFile.Create(pinFile);
+ await using (fileStream.ConfigureAwait(false))
+ {
+ await JsonSerializer.SerializeAsync(fileStream, spr).ConfigureAwait(false);
+ }
}
return new ForgotPasswordResult
{
Action = ForgotPasswordAction.PinCode,
PinExpirationDate = expireTime,
- PinFile = filePath
+ PinFile = pinFile
};
}
diff --git a/Jellyfin.Server.Implementations/Users/InvalidAuthProvider.cs b/Jellyfin.Server.Implementations/Users/InvalidAuthProvider.cs
index caf9d5bd9..56b8a7fc4 100644
--- a/Jellyfin.Server.Implementations/Users/InvalidAuthProvider.cs
+++ b/Jellyfin.Server.Implementations/Users/InvalidAuthProvider.cs
@@ -22,12 +22,6 @@ namespace Jellyfin.Server.Implementations.Users
}
/// <inheritdoc />
- public bool HasPassword(User user)
- {
- return true;
- }
-
- /// <inheritdoc />
public Task ChangePassword(User user, string newPassword)
{
return Task.CompletedTask;
diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs
index d0b41a7f6..501cb4fbe 100644
--- a/Jellyfin.Server.Implementations/Users/UserManager.cs
+++ b/Jellyfin.Server.Implementations/Users/UserManager.cs
@@ -149,7 +149,7 @@ namespace Jellyfin.Server.Implementations.Users
ThrowIfInvalidUsername(newName);
- if (user.Username.Equals(newName, StringComparison.OrdinalIgnoreCase))
+ if (user.Username.Equals(newName, StringComparison.Ordinal))
{
throw new ArgumentException("The new and old names must be different.");
}
@@ -306,15 +306,12 @@ namespace Jellyfin.Server.Implementations.Users
/// <inheritdoc/>
public UserDto GetUserDto(User user, string? remoteEndPoint = null)
{
- var hasPassword = GetAuthenticationProvider(user).HasPassword(user);
var castReceiverApplications = _serverConfigurationManager.Configuration.CastReceiverApplications;
return new UserDto
{
Name = user.Username,
Id = user.Id,
ServerId = _appHost.SystemId,
- HasPassword = hasPassword,
- HasConfiguredPassword = hasPassword,
EnableAutoLogin = user.EnableAutoLogin,
LastLoginDate = user.LastLoginDate,
LastActivityDate = user.LastActivityDate,
@@ -508,23 +505,18 @@ namespace Jellyfin.Server.Implementations.Users
public async Task<ForgotPasswordResult> StartForgotPasswordProcess(string enteredUsername, bool isInNetwork)
{
var user = string.IsNullOrWhiteSpace(enteredUsername) ? null : GetUserByName(enteredUsername);
+ var passwordResetProvider = GetPasswordResetProvider(user);
+
+ var result = await passwordResetProvider
+ .StartForgotPasswordProcess(user, enteredUsername, isInNetwork)
+ .ConfigureAwait(false);
if (user is not null && isInNetwork)
{
- var passwordResetProvider = GetPasswordResetProvider(user);
- var result = await passwordResetProvider
- .StartForgotPasswordProcess(user, isInNetwork)
- .ConfigureAwait(false);
-
await UpdateUserAsync(user).ConfigureAwait(false);
- return result;
}
- return new ForgotPasswordResult
- {
- Action = ForgotPasswordAction.InNetworkRequired,
- PinFile = string.Empty
- };
+ return result;
}
/// <inheritdoc/>
@@ -760,8 +752,13 @@ namespace Jellyfin.Server.Implementations.Users
return GetAuthenticationProviders(user)[0];
}
- private IPasswordResetProvider GetPasswordResetProvider(User user)
+ private IPasswordResetProvider GetPasswordResetProvider(User? user)
{
+ if (user is null)
+ {
+ return _defaultPasswordResetProvider;
+ }
+
return GetPasswordResetProviders(user)[0];
}