diff options
| author | Shadowghost <Ghost_of_Stone@web.de> | 2026-05-12 22:50:16 +0200 |
|---|---|---|
| committer | Shadowghost <Ghost_of_Stone@web.de> | 2026-05-12 22:50:16 +0200 |
| commit | 8f7c54ee5ef8647bc049499819606ad7946378ec (patch) | |
| tree | 4411b82fd0d0660a426b869a5781782e6dee7500 /Emby.Server.Implementations | |
| parent | 5e82b61bab8c9461624fd2095fc9ccd11e33ce8d (diff) | |
| parent | e9942c385775f33c70dbb4b910085ae2c563e898 (diff) | |
Merge remote-tracking branch 'upstream/master' into search-rebased
Diffstat (limited to 'Emby.Server.Implementations')
33 files changed, 668 insertions, 131 deletions
diff --git a/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs b/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs index de722332a4..56d977bbcb 100644 --- a/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs +++ b/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs @@ -90,6 +90,7 @@ namespace Emby.Server.Implementations.AppBase CreateAndCheckMarker(ProgramDataPath, "data"); CreateAndCheckMarker(CachePath, "cache"); CreateAndCheckMarker(DataPath, "data"); + CreateCacheDirTag(CachePath); } /// <inheritdoc /> @@ -100,6 +101,26 @@ namespace Emby.Server.Implementations.AppBase CheckOrCreateMarker(path, $".jellyfin-{markerName}", recursive); } + /// <summary> + /// Creates a CACHEDIR.TAG file in the specified directory per the Cache Directory Tagging specification. + /// This signals to backup tools (e.g. Restic, Borg) that the directory contains cached data + /// and can be excluded from backups. + /// </summary> + /// <param name="path">The cache directory path.</param> + internal static void CreateCacheDirTag(string path) + { + var tagPath = Path.Combine(path, "CACHEDIR.TAG"); + if (!File.Exists(tagPath)) + { + File.WriteAllText( + tagPath, + "Signature: 8a477f597d28d172789f06886806bc55\n" + + "# This file is a cache directory tag created by Jellyfin.\n" + + "# For information about cache directory tags, see:\n" + + "#\thttps://bford.info/cachedir/\n"); + } + } + private IEnumerable<string> GetMarkers(string path, bool recursive = false) { return Directory.EnumerateFiles(path, ".jellyfin-*", recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly); diff --git a/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs b/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs index 81ef0e5f9a..ef5fa8bef9 100644 --- a/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs +++ b/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs @@ -228,6 +228,7 @@ namespace Emby.Server.Implementations.AppBase Logger.LogInformation("Setting cache path: {Path}", cachePath); ((BaseApplicationPaths)CommonApplicationPaths).CachePath = cachePath; CommonApplicationPaths.CreateAndCheckMarker(((BaseApplicationPaths)CommonApplicationPaths).CachePath, "cache"); + BaseApplicationPaths.CreateCacheDirTag(cachePath); } /// <summary> diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index c97821f094..1a745aa799 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -167,8 +167,6 @@ namespace Emby.Server.Implementations ConfigurationManager.Configuration, ApplicationPaths.PluginsPath, ApplicationVersion); - - _disposableParts.Add(_pluginManager); } /// <summary> @@ -537,6 +535,7 @@ namespace Emby.Server.Implementations serviceCollection.AddSingleton<IMusicManager, MusicManager>(); serviceCollection.AddSingleton<ILibraryMonitor, LibraryMonitor>(); + serviceCollection.AddSingleton<DotIgnoreIgnoreRule>(); serviceCollection.AddSingleton<ISearchManager, SearchManager>(); serviceCollection.AddSingleton<ISearchProvider, SqlSearchProvider>(); @@ -1016,6 +1015,8 @@ namespace Emby.Server.Implementations } _disposableParts.Clear(); + + _pluginManager?.Dispose(); } _disposed = true; diff --git a/Emby.Server.Implementations/Chapters/ChapterManager.cs b/Emby.Server.Implementations/Chapters/ChapterManager.cs index 79ab29b87c..8a4721ce62 100644 --- a/Emby.Server.Implementations/Chapters/ChapterManager.cs +++ b/Emby.Server.Implementations/Chapters/ChapterManager.cs @@ -129,7 +129,7 @@ public class ChapterManager : IChapterManager var averageChapterDuration = GetAverageDurationBetweenChapters(chapters); var threshold = TimeSpan.FromSeconds(1).Ticks; - if (averageChapterDuration < threshold) + if (chapters.Count >= 2 && averageChapterDuration < threshold) { _logger.LogInformation("Skipping chapter image extraction for {Video} as the average chapter duration {AverageDuration} was lower than the minimum threshold {Threshold}", video.Name, averageChapterDuration, threshold); extractImages = false; diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index cc57d183b6..321c7da1c4 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -203,6 +203,39 @@ namespace Emby.Server.Implementations.Dto } } + // Batch-fetch MusicArtist lookups across all items to avoid N+1 queries. + IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null; + var artistNames = new HashSet<string>(StringComparer.Ordinal); + foreach (var item in accessibleItems) + { + if (item is IHasArtist hasArtist) + { + foreach (var name in hasArtist.Artists) + { + if (!string.IsNullOrWhiteSpace(name)) + { + artistNames.Add(name); + } + } + } + + if (item is IHasAlbumArtist hasAlbumArtist) + { + foreach (var name in hasAlbumArtist.AlbumArtists) + { + if (!string.IsNullOrWhiteSpace(name)) + { + artistNames.Add(name); + } + } + } + } + + if (artistNames.Count > 0) + { + artistsBatch = _libraryManager.GetArtists(artistNames.ToArray()); + } + for (int index = 0; index < accessibleItems.Count; index++) { var item = accessibleItems[index]; @@ -214,7 +247,8 @@ namespace Emby.Server.Implementations.Dto userDataBatch?.GetValueOrDefault(item.Id), allCollectionFolders, childCountBatch, - playedCountBatch); + playedCountBatch, + artistsBatch); if (item is LiveTvChannel tvChannel) { @@ -274,7 +308,8 @@ namespace Emby.Server.Implementations.Dto UserItemData? userData = null, List<Folder>? allCollectionFolders = null, Dictionary<Guid, int>? childCountBatch = null, - Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null) + Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null, + IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null) { var dto = new BaseItemDto { @@ -334,7 +369,7 @@ namespace Emby.Server.Implementations.Dto AttachStudios(dto, item); } - AttachBasicFields(dto, item, owner, options); + AttachBasicFields(dto, item, owner, options, artistsBatch); if (options.ContainsField(ItemFields.CanDelete)) { @@ -907,7 +942,8 @@ namespace Emby.Server.Implementations.Dto /// <param name="item">The item.</param> /// <param name="owner">The owner.</param> /// <param name="options">The options.</param> - private void AttachBasicFields(BaseItemDto dto, BaseItem item, BaseItem? owner, DtoOptions options) + /// <param name="artistsBatch">Optional pre-fetched artist lookup shared across a batch of items.</param> + private void AttachBasicFields(BaseItemDto dto, BaseItem item, BaseItem? owner, DtoOptions options, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null) { if (options.ContainsField(ItemFields.DateCreated)) { @@ -1031,6 +1067,8 @@ namespace Emby.Server.Implementations.Dto dto.OriginalTitle = item.OriginalTitle; } + dto.OriginalLanguage = item.OriginalLanguage; + if (options.ContainsField(ItemFields.ParentId)) { dto.ParentId = item.DisplayParentId; @@ -1152,7 +1190,8 @@ namespace Emby.Server.Implementations.Dto // Include artists that are not in the database yet, e.g., just added via metadata editor // var foundArtists = artistItems.Items.Select(i => i.Item1.Name).ToList(); - var artistsLookup = _libraryManager.GetArtists([.. hasArtist.Artists.Where(e => !string.IsNullOrWhiteSpace(e))]); + var artistsLookup = artistsBatch + ?? _libraryManager.GetArtists([.. hasArtist.Artists.Where(e => !string.IsNullOrWhiteSpace(e))]); dto.ArtistItems = hasArtist.Artists .Where(name => !string.IsNullOrWhiteSpace(name)) @@ -1186,7 +1225,8 @@ namespace Emby.Server.Implementations.Dto // }) // .ToList(); - var albumArtistsLookup = _libraryManager.GetArtists([.. hasAlbumArtist.AlbumArtists.Where(e => !string.IsNullOrWhiteSpace(e))]); + var albumArtistsLookup = artistsBatch + ?? _libraryManager.GetArtists([.. hasAlbumArtist.AlbumArtists.Where(e => !string.IsNullOrWhiteSpace(e))]); dto.AlbumArtists = hasAlbumArtist.AlbumArtists .Where(name => !string.IsNullOrWhiteSpace(name)) diff --git a/Emby.Server.Implementations/IO/LibraryMonitor.cs b/Emby.Server.Implementations/IO/LibraryMonitor.cs index 23bd5cf200..1bf0f8c76c 100644 --- a/Emby.Server.Implementations/IO/LibraryMonitor.cs +++ b/Emby.Server.Implementations/IO/LibraryMonitor.cs @@ -21,6 +21,7 @@ namespace Emby.Server.Implementations.IO private readonly ILibraryManager _libraryManager; private readonly IServerConfigurationManager _configurationManager; private readonly IFileSystem _fileSystem; + private readonly DotIgnoreIgnoreRule _dotIgnoreIgnoreRule; /// <summary> /// The file system watchers. @@ -47,17 +48,20 @@ namespace Emby.Server.Implementations.IO /// <param name="configurationManager">The configuration manager.</param> /// <param name="fileSystem">The filesystem.</param> /// <param name="appLifetime">The <see cref="IHostApplicationLifetime"/>.</param> + /// <param name="dotIgnoreIgnoreRule">The .ignore rule handler.</param> public LibraryMonitor( ILogger<LibraryMonitor> logger, ILibraryManager libraryManager, IServerConfigurationManager configurationManager, IFileSystem fileSystem, - IHostApplicationLifetime appLifetime) + IHostApplicationLifetime appLifetime, + DotIgnoreIgnoreRule dotIgnoreIgnoreRule) { _libraryManager = libraryManager; _logger = logger; _configurationManager = configurationManager; _fileSystem = fileSystem; + _dotIgnoreIgnoreRule = dotIgnoreIgnoreRule; appLifetime.ApplicationStarted.Register(Start); appLifetime.ApplicationStopping.Register(Stop); @@ -354,7 +358,7 @@ namespace Emby.Server.Implementations.IO } var fileInfo = _fileSystem.GetFileSystemInfo(path); - if (DotIgnoreIgnoreRule.IsIgnored(fileInfo, null)) + if (_dotIgnoreIgnoreRule.ShouldIgnore(fileInfo, null)) { return; } diff --git a/Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs b/Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs index ef5d24c70f..023c1e8915 100644 --- a/Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs +++ b/Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs @@ -1,6 +1,8 @@ using System; +using System.Collections.Generic; using System.IO; using System.Text.RegularExpressions; +using BitFaster.Caching.Lru; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Resolvers; @@ -15,22 +17,36 @@ public class DotIgnoreIgnoreRule : IResolverIgnoreRule { private static readonly bool IsWindows = OperatingSystem.IsWindows(); - private static FileInfo? FindIgnoreFile(DirectoryInfo directory) - { - for (var current = directory; current is not null; current = current.Parent) - { - var ignorePath = Path.Join(current.FullName, ".ignore"); - if (File.Exists(ignorePath)) - { - return new FileInfo(ignorePath); - } - } + private readonly FastConcurrentLru<string, IgnoreFileCacheEntry> _directoryCache; + private readonly FastConcurrentLru<string, ParsedIgnoreCacheEntry> _rulesCache; - return null; + /// <summary> + /// Initializes a new instance of the <see cref="DotIgnoreIgnoreRule"/> class. + /// </summary> + public DotIgnoreIgnoreRule() + { + var cacheSize = Math.Max(100, Environment.ProcessorCount * 100); + _directoryCache = new FastConcurrentLru<string, IgnoreFileCacheEntry>( + Environment.ProcessorCount, + cacheSize, + StringComparer.Ordinal); + _rulesCache = new FastConcurrentLru<string, ParsedIgnoreCacheEntry>( + Environment.ProcessorCount, + Math.Max(32, cacheSize / 4), + StringComparer.Ordinal); } /// <inheritdoc /> - public bool ShouldIgnore(FileSystemMetadata fileInfo, BaseItem? parent) => IsIgnored(fileInfo, parent); + public bool ShouldIgnore(FileSystemMetadata fileInfo, BaseItem? parent) => IsIgnoredInternal(fileInfo, parent); + + /// <summary> + /// Clears the directory lookup cache. The parsed rules cache is not cleared + /// as it validates file modification time on each access. + /// </summary> + public void ClearDirectoryCache() + { + _directoryCache.Clear(); + } /// <summary> /// Checks whether or not the file is ignored. @@ -38,40 +54,38 @@ public class DotIgnoreIgnoreRule : IResolverIgnoreRule /// <param name="fileInfo">The file information.</param> /// <param name="parent">The parent BaseItem.</param> /// <returns>True if the file should be ignored.</returns> - public static bool IsIgnored(FileSystemMetadata fileInfo, BaseItem? parent) + public bool IsIgnoredInternal(FileSystemMetadata fileInfo, BaseItem? parent) { var searchDirectory = fileInfo.IsDirectory - ? new DirectoryInfo(fileInfo.FullName) - : new DirectoryInfo(Path.GetDirectoryName(fileInfo.FullName) ?? string.Empty); + ? fileInfo.FullName + : Path.GetDirectoryName(fileInfo.FullName); - if (string.IsNullOrEmpty(searchDirectory.FullName)) + if (string.IsNullOrEmpty(searchDirectory)) { return false; } - var ignoreFile = FindIgnoreFile(searchDirectory); + var ignoreFile = FindIgnoreFileCached(searchDirectory); if (ignoreFile is null) { return false; } - // Fast path in case the ignore files isn't a symlink and is empty - if (ignoreFile.LinkTarget is null && ignoreFile.Length == 0) + var parsedEntry = GetParsedRules(ignoreFile); + if (parsedEntry is null) { - // Ignore directory if we just have the file - return true; + // File was deleted after we cached the path - clear the directory cache entry and return false + _directoryCache.TryRemove(searchDirectory, out _); + return false; } - var content = GetFileContent(ignoreFile); - return string.IsNullOrWhiteSpace(content) - || CheckIgnoreRules(fileInfo.FullName, content, fileInfo.IsDirectory); - } + // Empty file means ignore everything + if (parsedEntry.IsEmpty) + { + return true; + } - private static bool CheckIgnoreRules(string path, string ignoreFileContent, bool isDirectory) - { - // If file has content, base ignoring off the content .gitignore-style rules - var rules = ignoreFileContent.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - return CheckIgnoreRules(path, rules, isDirectory); + return parsedEntry.Rules.IsIgnored(GetPathToCheck(fileInfo.FullName, fileInfo.IsDirectory)); } /// <summary> @@ -117,8 +131,8 @@ public class DotIgnoreIgnoreRule : IResolverIgnoreRule return true; } - // Mitigate the problem of the Ignore library not handling Windows paths correctly. - // See https://github.com/jellyfin/jellyfin/issues/15484 + // Mitigate the problem of the Ignore library not handling Windows paths correctly. + // See https://github.com/jellyfin/jellyfin/issues/15484 var pathToCheck = normalizePath ? path.NormalizePath('/') : path; // Add trailing slash for directories to match "folder/" @@ -130,11 +144,196 @@ public class DotIgnoreIgnoreRule : IResolverIgnoreRule return ignore.IsIgnored(pathToCheck); } - private static string GetFileContent(FileInfo ignoreFile) + private FileInfo? FindIgnoreFileCached(string directory) + { + // Check if we have a cached result for this directory + if (_directoryCache.TryGet(directory, out var cached)) + { + return cached.IgnoreFileDirectory is null + ? null + : new FileInfo(Path.Join(cached.IgnoreFileDirectory, ".ignore")); + } + + DirectoryInfo startDir; + try + { + startDir = new DirectoryInfo(directory); + } + catch (ArgumentException) + { + return null; + } + + // Walk up the directory tree to find .ignore file using DirectoryInfo.Parent + var checkedDirs = new List<string> { directory }; + + for (var current = startDir; current is not null; current = current.Parent) + { + var currentPath = current.FullName; + + // Check if this intermediate directory is cached + if (current != startDir && _directoryCache.TryGet(currentPath, out var parentCached)) + { + // Cache the result for all directories we checked + var entry = new IgnoreFileCacheEntry(parentCached.IgnoreFileDirectory); + foreach (var dir in checkedDirs) + { + _directoryCache.AddOrUpdate(dir, entry); + } + + return parentCached.IgnoreFileDirectory is null + ? null + : new FileInfo(Path.Join(parentCached.IgnoreFileDirectory, ".ignore")); + } + + var ignoreFile = new FileInfo(Path.Join(currentPath, ".ignore")); + if (ignoreFile.Exists) + { + // Cache for all directories we checked + var entry = new IgnoreFileCacheEntry(currentPath); + foreach (var dir in checkedDirs) + { + _directoryCache.AddOrUpdate(dir, entry); + } + + return ignoreFile; + } + + if (current != startDir) + { + checkedDirs.Add(currentPath); + } + } + + // No .ignore file found - cache null result for all directories + var nullEntry = new IgnoreFileCacheEntry((string?)null); + foreach (var dir in checkedDirs) + { + _directoryCache.AddOrUpdate(dir, nullEntry); + } + + return null; + } + + private ParsedIgnoreCacheEntry? GetParsedRules(FileInfo ignoreFile) + { + if (!ignoreFile.Exists) + { + _rulesCache.TryRemove(ignoreFile.FullName, out _); + return null; + } + + var lastModified = ignoreFile.LastWriteTimeUtc; + var fileLength = ignoreFile.Length; + var key = ignoreFile.FullName; + + // Check cache + if (_rulesCache.TryGet(key, out var cached)) + { + if (cached.FileLastModified == lastModified && cached.FileLength == fileLength) + { + return cached; + } + + // Stale - need to reparse + _rulesCache.TryRemove(key, out _); + } + + // Parse the file + var parsedEntry = ParseIgnoreFile(ignoreFile, lastModified, fileLength); + _rulesCache.AddOrUpdate(key, parsedEntry); + return parsedEntry; + } + + private static ParsedIgnoreCacheEntry ParseIgnoreFile(FileInfo ignoreFile, DateTime lastModified, long fileLength) { - ignoreFile = FileSystemHelper.ResolveLinkTarget(ignoreFile, returnFinalTarget: true) ?? ignoreFile; - return ignoreFile.Exists - ? File.ReadAllText(ignoreFile.FullName) - : string.Empty; + if (ignoreFile.LinkTarget is null && fileLength == 0) + { + return new ParsedIgnoreCacheEntry + { + Rules = new Ignore.Ignore(), + FileLastModified = lastModified, + FileLength = fileLength, + IsEmpty = true + }; + } + + // Resolve symlinks + var resolvedFile = FileSystemHelper.ResolveLinkTarget(ignoreFile, returnFinalTarget: true) ?? ignoreFile; + if (!resolvedFile.Exists) + { + return new ParsedIgnoreCacheEntry + { + Rules = new Ignore.Ignore(), + FileLastModified = lastModified, + FileLength = fileLength, + IsEmpty = true + }; + } + + var content = File.ReadAllText(resolvedFile.FullName); + if (string.IsNullOrWhiteSpace(content)) + { + return new ParsedIgnoreCacheEntry + { + Rules = new Ignore.Ignore(), + FileLastModified = lastModified, + FileLength = fileLength, + IsEmpty = true + }; + } + + var rules = content.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var ignore = new Ignore.Ignore(); + var validRulesAdded = 0; + + foreach (var rule in rules) + { + try + { + ignore.Add(rule); + validRulesAdded++; + } + catch (RegexParseException) + { + // Ignore invalid patterns + } + } + + // No valid rules means treat as empty (ignore all) + return new ParsedIgnoreCacheEntry + { + Rules = ignore, + FileLastModified = lastModified, + FileLength = fileLength, + IsEmpty = validRulesAdded == 0 + }; + } + + private static string GetPathToCheck(string path, bool isDirectory) + { + // Normalize Windows paths + var pathToCheck = IsWindows ? path.NormalizePath('/') : path; + + // Add trailing slash for directories to match "folder/" + if (isDirectory) + { + pathToCheck = string.Concat(pathToCheck.AsSpan().TrimEnd('/'), "/"); + } + + return pathToCheck; + } + + private readonly record struct IgnoreFileCacheEntry(string? IgnoreFileDirectory); + + private sealed class ParsedIgnoreCacheEntry + { + public required Ignore.Ignore Rules { get; init; } + + public required DateTime FileLastModified { get; init; } + + public required long FileLength { get; init; } + + public required bool IsEmpty { get; init; } } } diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 2bcb10e9e1..11f1496086 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -30,11 +30,13 @@ using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Persistence; +using MediaBrowser.Controller.Playlists; using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Resolvers; using MediaBrowser.Controller.Sorting; @@ -84,6 +86,7 @@ namespace Emby.Server.Implementations.Library private readonly ExtraResolver _extraResolver; private readonly IPathManager _pathManager; private readonly FastConcurrentLru<Guid, BaseItem> _cache; + private readonly DotIgnoreIgnoreRule _dotIgnoreIgnoreRule; /// <summary> /// The _root folder sync lock. @@ -125,6 +128,7 @@ namespace Emby.Server.Implementations.Library /// <param name="directoryService">The directory service.</param> /// <param name="peopleRepository">The people repository.</param> /// <param name="pathManager">The path manager.</param> + /// <param name="dotIgnoreIgnoreRule">The .ignore rule handler.</param> public LibraryManager( IServerApplicationHost appHost, ILoggerFactory loggerFactory, @@ -146,7 +150,8 @@ namespace Emby.Server.Implementations.Library NamingOptions namingOptions, IDirectoryService directoryService, IPeopleRepository peopleRepository, - IPathManager pathManager) + IPathManager pathManager, + DotIgnoreIgnoreRule dotIgnoreIgnoreRule) { _appHost = appHost; _logger = loggerFactory.CreateLogger<LibraryManager>(); @@ -171,6 +176,7 @@ namespace Emby.Server.Implementations.Library _namingOptions = namingOptions; _peopleRepository = peopleRepository; _pathManager = pathManager; + _dotIgnoreIgnoreRule = dotIgnoreIgnoreRule; _extraResolver = new ExtraResolver(loggerFactory.CreateLogger<ExtraResolver>(), namingOptions, directoryService); _configurationManager.ConfigurationUpdated += ConfigurationUpdated; @@ -1303,6 +1309,7 @@ namespace Emby.Server.Implementations.Library public async Task ValidateMediaLibraryInternal(IProgress<double> progress, CancellationToken cancellationToken) { IsScanRunning = true; + ClearIgnoreRuleCache(); LibraryMonitor.Stop(); try @@ -1311,6 +1318,7 @@ namespace Emby.Server.Implementations.Library } finally { + ClearIgnoreRuleCache(); LibraryMonitor.Start(); IsScanRunning = false; } @@ -1318,6 +1326,7 @@ namespace Emby.Server.Implementations.Library public async Task ValidateTopLibraryFolders(CancellationToken cancellationToken, bool removeRoot = false) { + ClearIgnoreRuleCache(); RootFolder.Children = null; await RootFolder.RefreshMetadata(cancellationToken).ConfigureAwait(false); @@ -1360,6 +1369,14 @@ namespace Emby.Server.Implementations.Library { _persistenceService.DeleteItem(toDelete.ToArray()); } + + ClearIgnoreRuleCache(); + } + + /// <inheritdoc /> + public void ClearIgnoreRuleCache() + { + _dotIgnoreIgnoreRule.ClearDirectoryCache(); } private async Task PerformLibraryValidation(IProgress<double> progress, CancellationToken cancellationToken) @@ -1881,6 +1898,25 @@ namespace Emby.Server.Implementations.Library query.TopParentIds = [Guid.NewGuid()]; } } + else if (parents.Count == 1 && parents.First() is Folder folder + && (folder is Playlist || folder is BoxSet) + && folder.LinkedChildren.Length > 0) + { + // Playlists and BoxSets store their contents in LinkedChildren and never + // populate AncestorIds for those items, so a recursive AncestorIds query + // would return zero rows. Resolve to the linked child IDs up front and + // route through the existing indexed ItemIds filter. + query.ItemIds = folder.LinkedChildren + .Where(lc => lc.ItemId.HasValue && !lc.ItemId.Value.IsEmpty()) + .Select(lc => lc.ItemId!.Value) + .ToArray(); + + // Empty linked-children should still return empty rather than scanning everything. + if (query.ItemIds.Length == 0) + { + query.ItemIds = [Guid.NewGuid()]; + } + } else { // We need to be able to query from any arbitrary ancestor up the tree @@ -3161,7 +3197,7 @@ namespace Emby.Server.Implementations.Library public IEnumerable<BaseItem> FindExtras(BaseItem owner, IReadOnlyList<FileSystemMetadata> fileSystemChildren, IDirectoryService directoryService) { // Apply .ignore rules - var filtered = fileSystemChildren.Where(c => !DotIgnoreIgnoreRule.IsIgnored(c, owner)).ToList(); + var filtered = fileSystemChildren.Where(c => !_dotIgnoreIgnoreRule.ShouldIgnore(c, owner)).ToList(); var isFolder = owner.IsFolder || (owner is Video video && (video.VideoType == VideoType.BluRay || video.VideoType == VideoType.Dvd)); var ownerVideoInfo = VideoResolver.Resolve(owner.Path, isFolder, _namingOptions, libraryRoot: owner.ContainingFolderPath); if (ownerVideoInfo is null) @@ -3253,7 +3289,7 @@ namespace Emby.Server.Implementations.Library public IReadOnlyList<PersonInfo> GetPeople(InternalPeopleQuery query) { - return _peopleRepository.GetPeople(query); + return _peopleRepository.GetPeople(query).Items; } public IReadOnlyList<PersonInfo> GetPeople(BaseItem item) @@ -3274,24 +3310,33 @@ namespace Emby.Server.Implementations.Library return []; } - public IReadOnlyList<Person> GetPeopleItems(InternalPeopleQuery query) + public QueryResult<BaseItem> GetPeopleItems(InternalPeopleQuery query) { - return _peopleRepository.GetPeopleNames(query) - .Select(i => - { - try + var queryResult = _peopleRepository.GetPeople(query); + var baseItems = queryResult.Items.Select(i => { - return GetPerson(i); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting person"); - return null; - } - }) - .Where(i => i is not null) - .Where(i => query.User is null || i!.IsVisible(query.User)) - .ToList()!; // null values are filtered out + try + { + return GetPerson(i.Name); + } + catch (Exception ex) + { + _logger.LogError(ex, "error retrieving BaseItem for person: {0}", i.Name); + return null; + } + }) + .Where(i => i is not null) + .Where(i => query.User is null || i!.IsVisible(query.User)) + .OfType<BaseItem>() + .ToList() + .AsReadOnly(); + + return new QueryResult<BaseItem> + { + StartIndex = queryResult.StartIndex, + TotalRecordCount = queryResult.TotalRecordCount, + Items = baseItems, + }; } public IReadOnlyList<string> GetPeopleNames(InternalPeopleQuery query) diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs index c667fb0600..fdb4c7328b 100644 --- a/Emby.Server.Implementations/Library/MediaSourceManager.cs +++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs @@ -23,6 +23,7 @@ using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.MediaEncoding; @@ -423,7 +424,7 @@ namespace Emby.Server.Implementations.Library MediaStreamSelector.SetSubtitleStreamScores(source.MediaStreams, preferredSubs, user.SubtitleMode, audioLanguage); } - private void SetDefaultAudioStreamIndex(MediaSourceInfo source, UserItemData userData, User user, bool allowRememberingSelection) + private void SetDefaultAudioStreamIndex(MediaSourceInfo source, UserItemData userData, User user, bool allowRememberingSelection, string originalLanguage) { if (userData is not null && userData.AudioStreamIndex.HasValue && user.RememberAudioSelections && allowRememberingSelection) { @@ -437,7 +438,42 @@ namespace Emby.Server.Implementations.Library } } - var preferredAudio = NormalizeLanguage(user.AudioLanguagePreference); + if (string.Equals(user.AudioLanguagePreference, "OriginalLanguage", StringComparison.OrdinalIgnoreCase)) + { + originalLanguage = !string.IsNullOrWhiteSpace(originalLanguage) + ? originalLanguage.Split(',').FirstOrDefault() + : null; + + if (user.PlayDefaultAudioTrack) + { + source.DefaultAudioStreamIndex = MediaStreamSelector.GetDefaultAudioStreamIndex( + source.MediaStreams, + NormalizeLanguage(originalLanguage), + user.PlayDefaultAudioTrack); + return; + } + + var originalIndex = source.MediaStreams.FindIndex(i => i.Type == MediaStreamType.Audio && i.IsOriginal); + + if (!string.IsNullOrWhiteSpace(originalLanguage) && originalIndex != -1) + { + var mediaLanguageOriginal = source.MediaStreams[originalIndex].Language; + if (NormalizeLanguage(mediaLanguageOriginal).Contains(NormalizeLanguage(originalLanguage).FirstOrDefault())) + { + source.DefaultAudioStreamIndex = originalIndex; + return; + } + } + else if (originalIndex != -1) + { + source.DefaultAudioStreamIndex = originalIndex; + return; + } + } + + var preferredAudio = string.Equals(user.AudioLanguagePreference, "OriginalLanguage", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(originalLanguage) + ? NormalizeLanguage(originalLanguage) + : NormalizeLanguage(user.AudioLanguagePreference); source.DefaultAudioStreamIndex = MediaStreamSelector.GetDefaultAudioStreamIndex(source.MediaStreams, preferredAudio, user.PlayDefaultAudioTrack); if (user.PlayDefaultAudioTrack) @@ -462,7 +498,19 @@ namespace Emby.Server.Implementations.Library var allowRememberingSelection = item is null || item.EnableRememberingTrackSelections; - SetDefaultAudioStreamIndex(source, userData, user, allowRememberingSelection); + var originalLanguage = item?.OriginalLanguage ?? item switch + { + Episode episode => episode.Series.OriginalLanguage, + Video video => video.GetOwner() switch + { + Episode ownerEpisode => ownerEpisode.OriginalLanguage ?? ownerEpisode.Series.OriginalLanguage, + BaseItem owner => owner.OriginalLanguage, + null => null + }, + _ => null + }; + + SetDefaultAudioStreamIndex(source, userData, user, allowRememberingSelection, originalLanguage); SetDefaultSubtitleStreamIndex(source, userData, user, allowRememberingSelection); } else if (mediaType == MediaType.Audio) diff --git a/Emby.Server.Implementations/Library/PathExtensions.cs b/Emby.Server.Implementations/Library/PathExtensions.cs index fc63251ad0..cfa3e7c31d 100644 --- a/Emby.Server.Implementations/Library/PathExtensions.cs +++ b/Emby.Server.Implementations/Library/PathExtensions.cs @@ -70,6 +70,16 @@ namespace Emby.Server.Implementations.Library return match ? imdbId.ToString() : null; } + // Allow tmdb as an alias for tmdbid + if (attribute.Equals("tmdbid", StringComparison.OrdinalIgnoreCase)) + { + var tmdbValue = str.GetAttributeValue("tmdb"); + if (tmdbValue is not null) + { + return tmdbValue; + } + } + return null; } diff --git a/Emby.Server.Implementations/Library/PathManager.cs b/Emby.Server.Implementations/Library/PathManager.cs index a9b7a1274b..ef5edb9afa 100644 --- a/Emby.Server.Implementations/Library/PathManager.cs +++ b/Emby.Server.Implementations/Library/PathManager.cs @@ -6,6 +6,7 @@ using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.IO; +using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.Library; @@ -14,18 +15,22 @@ namespace Emby.Server.Implementations.Library; /// </summary> public class PathManager : IPathManager { + private readonly ILogger<PathManager> _logger; private readonly IServerConfigurationManager _config; private readonly IApplicationPaths _appPaths; /// <summary> /// Initializes a new instance of the <see cref="PathManager"/> class. /// </summary> + /// <param name="logger">The logger.</param> /// <param name="config">The server configuration manager.</param> /// <param name="appPaths">The application paths.</param> public PathManager( + ILogger<PathManager> logger, IServerConfigurationManager config, IApplicationPaths appPaths) { + _logger = logger; _config = config; _appPaths = appPaths; } @@ -35,31 +40,43 @@ public class PathManager : IPathManager private string AttachmentCachePath => Path.Combine(_appPaths.DataPath, "attachments"); /// <inheritdoc /> - public string GetAttachmentPath(string mediaSourceId, string fileName) + public string? GetAttachmentPath(string mediaSourceId, string fileName) { - return Path.Combine(GetAttachmentFolderPath(mediaSourceId), fileName); + var folder = GetAttachmentFolderPath(mediaSourceId); + return folder is null ? null : Path.Combine(folder, fileName); } /// <inheritdoc /> - public string GetAttachmentFolderPath(string mediaSourceId) + public string? GetAttachmentFolderPath(string mediaSourceId) { - var id = Guid.Parse(mediaSourceId).ToString("D", CultureInfo.InvariantCulture).AsSpan(); + if (!Guid.TryParse(mediaSourceId, out var parsed)) + { + _logger.LogDebug("MediaSource Id '{MediaSourceId}' is not a GUID; no on-disk attachment folder.", mediaSourceId); + return null; + } + var id = parsed.ToString("D", CultureInfo.InvariantCulture).AsSpan(); return Path.Join(AttachmentCachePath, id[..2], id); } /// <inheritdoc /> - public string GetSubtitleFolderPath(string mediaSourceId) + public string? GetSubtitleFolderPath(string mediaSourceId) { - var id = Guid.Parse(mediaSourceId).ToString("D", CultureInfo.InvariantCulture).AsSpan(); + if (!Guid.TryParse(mediaSourceId, out var parsed)) + { + _logger.LogDebug("MediaSource Id '{MediaSourceId}' is not a GUID; no on-disk subtitle folder.", mediaSourceId); + return null; + } + var id = parsed.ToString("D", CultureInfo.InvariantCulture).AsSpan(); return Path.Join(SubtitleCachePath, id[..2], id); } /// <inheritdoc /> - public string GetSubtitlePath(string mediaSourceId, int streamIndex, string extension) + public string? GetSubtitlePath(string mediaSourceId, int streamIndex, string extension) { - return Path.Combine(GetSubtitleFolderPath(mediaSourceId), streamIndex.ToString(CultureInfo.InvariantCulture) + extension); + var folder = GetSubtitleFolderPath(mediaSourceId); + return folder is null ? null : Path.Combine(folder, streamIndex.ToString(CultureInfo.InvariantCulture) + extension); } /// <inheritdoc /> @@ -90,12 +107,23 @@ public class PathManager : IPathManager public IReadOnlyList<string> GetExtractedDataPaths(BaseItem item) { var mediaSourceId = item.Id.ToString("N", CultureInfo.InvariantCulture); - return [ - GetAttachmentFolderPath(mediaSourceId), - GetSubtitleFolderPath(mediaSourceId), - GetTrickplayDirectory(item, false), - GetTrickplayDirectory(item, true), - GetChapterImageFolderPath(item) - ]; + List<string> paths = []; + var attachmentFolder = GetAttachmentFolderPath(mediaSourceId); + if (attachmentFolder is not null) + { + paths.Add(attachmentFolder); + } + + var subtitleFolder = GetSubtitleFolderPath(mediaSourceId); + if (subtitleFolder is not null) + { + paths.Add(subtitleFolder); + } + + paths.Add(GetTrickplayDirectory(item, false)); + paths.Add(GetTrickplayDirectory(item, true)); + paths.Add(GetChapterImageFolderPath(item)); + + return paths; } } diff --git a/Emby.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs index 5fd23c9f50..85bf20cc2a 100644 --- a/Emby.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs @@ -1,8 +1,10 @@ #nullable disable using System; +using System.IO; using System.Linq; using Emby.Naming.Common; +using Emby.Server.Implementations.Library; using Jellyfin.Data.Enums; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; @@ -81,10 +83,34 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV episode.ParentIndexNumber = 1; } + SetProviderIdFromPath(episode, args.Path); + return episode; } return null; } + + /// <summary> + /// Sets provider ids from the episode file name. + /// </summary> + /// <param name="item">The episode.</param> + /// <param name="path">The episode file path.</param> + private static void SetProviderIdFromPath(Episode item, string path) + { + var justName = Path.GetFileNameWithoutExtension(path.AsSpan()); + + var imdbId = justName.GetAttributeValue("imdbid"); + item.TrySetProviderId(MetadataProvider.Imdb, imdbId); + + var tvdbId = justName.GetAttributeValue("tvdbid"); + item.TrySetProviderId(MetadataProvider.Tvdb, tvdbId); + + var tvmazeId = justName.GetAttributeValue("tvmazeid"); + item.TrySetProviderId(MetadataProvider.TvMaze, tvmazeId); + + var tmdbId = justName.GetAttributeValue("tmdbid"); + item.TrySetProviderId(MetadataProvider.Tmdb, tmdbId); + } } } diff --git a/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs index 6cb63a28a2..6e9a38fd34 100644 --- a/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs @@ -1,10 +1,15 @@ #nullable disable +using System; using System.Globalization; +using System.IO; +using System.Linq; using Emby.Naming.Common; using Emby.Naming.TV; +using Emby.Server.Implementations.Library; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; using Microsoft.Extensions.Logging; @@ -77,6 +82,14 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV return null; } + + var hasAnyVideo = Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories) + .Any(file => _namingOptions.VideoFileExtensions.Contains(Path.GetExtension(file))); + + if (!hasAnyVideo) + { + return null; + } } if (season.IndexNumber.HasValue && string.IsNullOrEmpty(season.Name)) @@ -91,10 +104,31 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV args.LibraryOptions.PreferredMetadataLanguage); } + SetProviderIdFromPath(season, path); + return season; } return null; } + + /// <summary> + /// Sets provider ids from the season folder name. + /// </summary> + /// <param name="item">The season.</param> + /// <param name="path">The season folder path.</param> + private static void SetProviderIdFromPath(Season item, string path) + { + var justName = Path.GetFileName(path.AsSpan()); + + var tvdbId = justName.GetAttributeValue("tvdbid"); + item.TrySetProviderId(MetadataProvider.Tvdb, tvdbId); + + var tvmazeId = justName.GetAttributeValue("tvmazeid"); + item.TrySetProviderId(MetadataProvider.TvMaze, tvmazeId); + + var tmdbId = justName.GetAttributeValue("tmdbid"); + item.TrySetProviderId(MetadataProvider.Tmdb, tmdbId); + } } } diff --git a/Emby.Server.Implementations/Localization/Core/ar.json b/Emby.Server.Implementations/Localization/Core/ar.json index b80737d3b9..e48939b4d7 100644 --- a/Emby.Server.Implementations/Localization/Core/ar.json +++ b/Emby.Server.Implementations/Localization/Core/ar.json @@ -135,5 +135,6 @@ "TaskMoveTrickplayImages": "نقل موقع صور معاينات التنقل", "TaskMoveTrickplayImagesDescription": "ينقل ملفات معاينات التنقل الحالية وفقاً لإعدادات المكتبة.", "CleanupUserDataTask": "مهمة تنظيف بيانات المستخدم", - "CleanupUserDataTaskDescription": "ينظف جميع بيانات المستخدم (مثل حالة المشاهدة وحالة المفضلة وغيرها) للمحتوى الذي لم يعد موجوداً لمدة 90 يوماً على الأقل." + "CleanupUserDataTaskDescription": "ينظف جميع بيانات المستخدم (مثل حالة المشاهدة وحالة المفضلة وغيرها) للمحتوى الذي لم يعد موجوداً لمدة 90 يوماً على الأقل.", + "Original": "فريد" } diff --git a/Emby.Server.Implementations/Localization/Core/cs.json b/Emby.Server.Implementations/Localization/Core/cs.json index 8d43839110..3fc1895842 100644 --- a/Emby.Server.Implementations/Localization/Core/cs.json +++ b/Emby.Server.Implementations/Localization/Core/cs.json @@ -135,5 +135,6 @@ "TaskMoveTrickplayImages": "Přesunout úložiště obrázků Trickplay", "TaskMoveTrickplayImagesDescription": "Přesune existující soubory Trickplay podle nastavení knihovny.", "CleanupUserDataTaskDescription": "Odstraní všechna uživatelská data (stav zhlédnutí, oblíbené atd.) z médií, které již neexistují více než 90 dní.", - "CleanupUserDataTask": "Pročistit uživatelská data" + "CleanupUserDataTask": "Pročistit uživatelská data", + "Original": "Originál" } diff --git a/Emby.Server.Implementations/Localization/Core/de.json b/Emby.Server.Implementations/Localization/Core/de.json index ab1a7d2cbd..b628f45ea7 100644 --- a/Emby.Server.Implementations/Localization/Core/de.json +++ b/Emby.Server.Implementations/Localization/Core/de.json @@ -135,5 +135,6 @@ "TaskMoveTrickplayImages": "Verzeichnis für Trickplay-Bilder migrieren", "TaskMoveTrickplayImagesDescription": "Trickplay-Bilder werden entsprechend der Bibliothekseinstellungen verschoben.", "CleanupUserDataTask": "Aufgabe zur Bereinigung von Benutzerdaten", - "CleanupUserDataTaskDescription": "Löscht alle Benutzerdaten (Abspielstatus, Favoritenstatus, usw.) von Medien, die seit mindestens 90 Tagen nicht mehr vorhanden sind." + "CleanupUserDataTaskDescription": "Löscht alle Benutzerdaten (Abspielstatus, Favoritenstatus, usw.) von Medien, die seit mindestens 90 Tagen nicht mehr vorhanden sind.", + "Original": "Original" } diff --git a/Emby.Server.Implementations/Localization/Core/en-US.json b/Emby.Server.Implementations/Localization/Core/en-US.json index 45b1cbb6a0..9b5049c8c7 100644 --- a/Emby.Server.Implementations/Localization/Core/en-US.json +++ b/Emby.Server.Implementations/Localization/Core/en-US.json @@ -64,6 +64,7 @@ "NotificationOptionUserLockedOut": "User locked out", "NotificationOptionVideoPlayback": "Video playback started", "NotificationOptionVideoPlaybackStopped": "Video playback stopped", + "Original": "Original", "Photos": "Photos", "Playlists": "Playlists", "Plugin": "Plugin", diff --git a/Emby.Server.Implementations/Localization/Core/es.json b/Emby.Server.Implementations/Localization/Core/es.json index cf118077c6..4f6a3544e4 100644 --- a/Emby.Server.Implementations/Localization/Core/es.json +++ b/Emby.Server.Implementations/Localization/Core/es.json @@ -135,5 +135,6 @@ "TaskExtractMediaSegmentsDescription": "Extrae u obtiene segmentos de medios de plugins habilitados para MediaSegment.", "TaskMoveTrickplayImages": "Migrar la ubicación de la imagen de Trickplay", "CleanupUserDataTask": "Tarea de limpieza de datos del usuario", - "CleanupUserDataTaskDescription": "Limpia todos los datos del usuario (estado de visualización, favoritos, etc.) de los medios que ya no están disponibles desde hace al menos 90 días." + "CleanupUserDataTaskDescription": "Limpia todos los datos del usuario (estado de visualización, favoritos, etc.) de los medios que ya no están disponibles desde hace al menos 90 días.", + "Original": "Original" } diff --git a/Emby.Server.Implementations/Localization/Core/ga.json b/Emby.Server.Implementations/Localization/Core/ga.json index 5742e6224d..ee6e8b8368 100644 --- a/Emby.Server.Implementations/Localization/Core/ga.json +++ b/Emby.Server.Implementations/Localization/Core/ga.json @@ -135,5 +135,6 @@ "TaskCleanTranscode": "Eolaire Transcode Glan", "TaskDownloadMissingSubtitles": "Íosluchtaigh fotheidil ar iarraidh", "CleanupUserDataTask": "Tasc glantacháin sonraí úsáideora", - "CleanupUserDataTaskDescription": "Glanann sé gach sonraí úsáideora (stádas faire, stádas is fearr leat srl.) ó mheáin nach bhfuil i láthair a thuilleadh ar feadh 90 lá ar a laghad." + "CleanupUserDataTaskDescription": "Glanann sé gach sonraí úsáideora (stádas faire, stádas is fearr leat srl.) ó mheáin nach bhfuil i láthair a thuilleadh ar feadh 90 lá ar a laghad.", + "Original": "Bunaidh" } diff --git a/Emby.Server.Implementations/Localization/Core/hr.json b/Emby.Server.Implementations/Localization/Core/hr.json index e3bea78a3f..5800764587 100644 --- a/Emby.Server.Implementations/Localization/Core/hr.json +++ b/Emby.Server.Implementations/Localization/Core/hr.json @@ -135,5 +135,6 @@ "TaskMoveTrickplayImages": "Premjesti mjesto slika brzog pregledavanja", "TaskMoveTrickplayImagesDescription": "Premješta postojeće datoteke brzog pregledavanja u postavke biblioteke.", "CleanupUserDataTask": "Zadatak čišćenja korisničkih podataka", - "CleanupUserDataTaskDescription": "Briše sve korisničke podatke (stanje gledanja, status favorita itd.) s medija koji više nisu prisutni najmanje 90 dana." + "CleanupUserDataTaskDescription": "Briše sve korisničke podatke (stanje gledanja, status favorita itd.) s medija koji više nisu prisutni najmanje 90 dana.", + "Original": "Original" } diff --git a/Emby.Server.Implementations/Localization/Core/hu.json b/Emby.Server.Implementations/Localization/Core/hu.json index 8d9e5b08ba..31df91693c 100644 --- a/Emby.Server.Implementations/Localization/Core/hu.json +++ b/Emby.Server.Implementations/Localization/Core/hu.json @@ -39,7 +39,7 @@ "MixedContent": "Vegyes tartalom", "Movies": "Filmek", "Music": "Zenék", - "MusicVideos": "Zenei videóklipek", + "MusicVideos": "Zenei videók", "NameInstallFailed": "{0} sikertelen telepítés", "NameSeasonNumber": "{0}. évad", "NameSeasonUnknown": "Ismeretlen évad", @@ -135,5 +135,6 @@ "TaskMoveTrickplayImagesDescription": "A médiatár-beállításoknak megfelelően áthelyezi a meglévő trickplay fájlokat.", "TaskExtractMediaSegmentsDescription": "Kinyeri vagy megszerzi a médiaszegmenseket a MediaSegment támogatással rendelkező bővítményekből.", "CleanupUserDataTaskDescription": "Legalább 90 napja nem elérhető médiákhoz kapcsolódó összes felhasználói adat (pl. megtekintési állapot, kedvencek) törlése.", - "CleanupUserDataTask": "Felhasználói adatok tisztítása feladat" + "CleanupUserDataTask": "Felhasználói adatok tisztítása feladat", + "Original": "Eredeti" } diff --git a/Emby.Server.Implementations/Localization/Core/it.json b/Emby.Server.Implementations/Localization/Core/it.json index 782f5ce53d..41d97442ed 100644 --- a/Emby.Server.Implementations/Localization/Core/it.json +++ b/Emby.Server.Implementations/Localization/Core/it.json @@ -135,5 +135,6 @@ "TaskExtractMediaSegmentsDescription": "Estrae o ottiene segmenti multimediali dai plugin abilitati MediaSegment.", "TaskExtractMediaSegments": "Scansiona Segmento Media", "CleanupUserDataTask": "Task di pulizia dei dati utente", - "CleanupUserDataTaskDescription": "Pulisce tutti i dati utente (stato di visione, status preferiti, ecc.) dai contenuti non più presenti da almeno 90 giorni." + "CleanupUserDataTaskDescription": "Pulisce tutti i dati utente (stato di visione, status preferiti, ecc.) dai contenuti non più presenti da almeno 90 giorni.", + "Original": "Originale" } diff --git a/Emby.Server.Implementations/Localization/Core/lv.json b/Emby.Server.Implementations/Localization/Core/lv.json index 1083e3c299..4bf6ed4752 100644 --- a/Emby.Server.Implementations/Localization/Core/lv.json +++ b/Emby.Server.Implementations/Localization/Core/lv.json @@ -135,5 +135,6 @@ "TaskDownloadMissingLyrics": "Lejupielādēt trūkstošos vārdus", "TaskDownloadMissingLyricsDescription": "Lejupielādēt vārdus dziesmām", "CleanupUserDataTask": "Lietotāju datu tīrīšanas uzdevums", - "CleanupUserDataTaskDescription": "Notīra visus lietotāja datus (skatīšanās stāvokļus, favorītu statusi utt.) no medijiem, kas vairs nav pieejami vismaz 90 dienas." + "CleanupUserDataTaskDescription": "Notīra visus lietotāja datus (skatīšanās stāvokļus, favorītu statusi utt.) no medijiem, kas vairs nav pieejami vismaz 90 dienas.", + "Original": "Oriģināls" } diff --git a/Emby.Server.Implementations/Localization/Core/ne.json b/Emby.Server.Implementations/Localization/Core/ne.json index 7c6b08fb36..0e52e32c1b 100644 --- a/Emby.Server.Implementations/Localization/Core/ne.json +++ b/Emby.Server.Implementations/Localization/Core/ne.json @@ -45,7 +45,7 @@ "Genres": "विधाहरू", "Folders": "फोल्डरहरू", "Favorites": "मनपर्ने", - "FailedLoginAttemptWithUserName": "{0}को लग इन प्रयास असफल", + "FailedLoginAttemptWithUserName": "असफल लग इन प्रयास {0} देखि", "DeviceOnlineWithName": "{0}को साथ जडित", "DeviceOfflineWithName": "{0}बाट विच्छेदन भयो", "Collections": "संग्रह", diff --git a/Emby.Server.Implementations/Localization/Core/nl.json b/Emby.Server.Implementations/Localization/Core/nl.json index bf1cbdacd1..de4c277ce7 100644 --- a/Emby.Server.Implementations/Localization/Core/nl.json +++ b/Emby.Server.Implementations/Localization/Core/nl.json @@ -135,5 +135,6 @@ "CleanupUserDataTaskDescription": "Wist alle gebruikersgegevens (kijkstatus, favorieten, etc.) van media die al minstens 90 dagen niet meer aanwezig zijn.", "CleanupUserDataTask": "Opruimtaak gebruikersdata", "Albums": "Albums", - "Genres": "Genres" + "Genres": "Genres", + "Original": "Oorspronkelijk" } diff --git a/Emby.Server.Implementations/Localization/Core/pl.json b/Emby.Server.Implementations/Localization/Core/pl.json index a741fc14c0..e5af2c7801 100644 --- a/Emby.Server.Implementations/Localization/Core/pl.json +++ b/Emby.Server.Implementations/Localization/Core/pl.json @@ -135,5 +135,6 @@ "TaskExtractMediaSegmentsDescription": "Wyodrębnia lub pobiera segmenty mediów z wtyczek obsługujących MediaSegment.", "TaskMoveTrickplayImagesDescription": "Przenosi istniejące pliki Trickplay zgodnie z ustawieniami biblioteki.", "CleanupUserDataTaskDescription": "Usuwa wszystkie dane użytkownika (stan oglądanych, status ulubionych itp.) z mediów, które nie są dostępne od co najmniej 90 dni.", - "CleanupUserDataTask": "Zadanie czyszczenia danych użytkownika" + "CleanupUserDataTask": "Zadanie czyszczenia danych użytkownika", + "Original": "Oryginalny" } diff --git a/Emby.Server.Implementations/Localization/Core/pt-PT.json b/Emby.Server.Implementations/Localization/Core/pt-PT.json index 1d31efcdc9..93dfa7e7f5 100644 --- a/Emby.Server.Implementations/Localization/Core/pt-PT.json +++ b/Emby.Server.Implementations/Localization/Core/pt-PT.json @@ -135,5 +135,6 @@ "TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos de multimédia a partir de plugins com suporte para MediaSegment.", "TaskMoveTrickplayImagesDescription": "Move os ficheiros trickplay existentes de acordo com as definições da mediateca.", "CleanupUserDataTaskDescription": "Apaga todos os dados de utilizador (estados de reprodução, favoritos, etc) de arquivos média não presentes há 90 dias ou mais.", - "CleanupUserDataTask": "Limpeza de dados de utilizador" + "CleanupUserDataTask": "Limpeza de dados de utilizador", + "Original": "Original" } diff --git a/Emby.Server.Implementations/Localization/Core/pt.json b/Emby.Server.Implementations/Localization/Core/pt.json index 82da1f0aff..ce288223bb 100644 --- a/Emby.Server.Implementations/Localization/Core/pt.json +++ b/Emby.Server.Implementations/Localization/Core/pt.json @@ -135,5 +135,6 @@ "TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos de multimédia a partir de plugins com suporte para MediaSegment.", "TaskMoveTrickplayImages": "Migrar a localização da imagem do Trickplay", "CleanupUserDataTask": "Task de limpeza de dados do usuário", - "CleanupUserDataTaskDescription": "Remove todos os dados do usuário (progresso, favoritos etc) de mídias que não estão presentes há pelo menos 90 dias." + "CleanupUserDataTaskDescription": "Remove todos os dados do usuário (progresso, favoritos etc) de mídias que não estão presentes há pelo menos 90 dias.", + "Original": "Original" } diff --git a/Emby.Server.Implementations/Localization/Core/sv.json b/Emby.Server.Implementations/Localization/Core/sv.json index a47ed248e9..015f59af25 100644 --- a/Emby.Server.Implementations/Localization/Core/sv.json +++ b/Emby.Server.Implementations/Localization/Core/sv.json @@ -135,5 +135,6 @@ "TaskMoveTrickplayImages": "Migrera platsen för Trickplay-bilder", "TaskMoveTrickplayImagesDescription": "Flyttar befintliga trickplay-filer enligt bibliotekets inställningar.", "CleanupUserDataTaskDescription": "Tar bort all användardata (såsom vad du sett, favoriter med mera) för media som inte funnits på enheten på minst 90 dagar.", - "CleanupUserDataTask": "Uppgift för rensning av användardata" + "CleanupUserDataTask": "Uppgift för rensning av användardata", + "Original": "Original" } diff --git a/Emby.Server.Implementations/Localization/LocalizationManager.cs b/Emby.Server.Implementations/Localization/LocalizationManager.cs index 6fca5bc1ba..d8797e612b 100644 --- a/Emby.Server.Implementations/Localization/LocalizationManager.cs +++ b/Emby.Server.Implementations/Localization/LocalizationManager.cs @@ -320,6 +320,14 @@ namespace Emby.Server.Implementations.Localization { return value; } + + if (ratingsDictionary is not null && rating.Length > countryCode.Length + && rating.StartsWith(countryCode, StringComparison.OrdinalIgnoreCase) + && (rating[countryCode.Length] == '-' || rating[countryCode.Length] == ':') + && ratingsDictionary.TryGetValue(rating[(countryCode.Length + 1)..].Trim(), out var normalizedValue)) + { + return normalizedValue; + } } else { @@ -345,33 +353,68 @@ namespace Emby.Server.Implementations.Localization } } - // Try splitting by : to handle "Germany: FSK-18" - if (rating.Contains(':', StringComparison.OrdinalIgnoreCase)) + // Try splitting by country prefix separator to handle "US:PG-13", "Germany: FSK-18", "DE-FSK-18" + if (TryGetRatingScoreBySeparator(rating, ':', out var result) + || TryGetRatingScoreBySeparator(rating, '-', out result)) { - var ratingLevelRightPart = rating.AsSpan().RightPart(':'); - if (ratingLevelRightPart.Length != 0) - { - return GetRatingScore(ratingLevelRightPart.ToString()); - } + return result; } - // Handle prefix country code to handle "DE-18" - if (rating.Contains('-', StringComparison.OrdinalIgnoreCase)) + return null; + } + + private bool TryGetRatingScoreBySeparator(string rating, char separator, out ParentalRatingScore? result) + { + result = null; + + if (rating.IndexOf(separator, StringComparison.Ordinal) < 0) { - var ratingSpan = rating.AsSpan(); + return false; + } - // Extract culture from country prefix - var culture = FindLanguageInfo(ratingSpan.LeftPart('-').ToString()); + var ratingSpan = rating.AsSpan(); + var countryPart = ratingSpan.LeftPart(separator).Trim().ToString(); + var ratingPart = ratingSpan.RightPart(separator).Trim().ToString(); + if (ratingPart.Length == 0) + { + return false; + } - var ratingLevelRightPart = ratingSpan.RightPart('-'); - if (ratingLevelRightPart.Length != 0) + string? resolvedCountryCode = null; + + if (_allParentalRatings.ContainsKey(countryPart)) + { + resolvedCountryCode = countryPart; + } + else + { + var culture = FindLanguageInfo(countryPart); + if (culture is not null) { - // Check rating system of culture - return GetRatingScore(ratingLevelRightPart.ToString(), culture?.TwoLetterISOLanguageName); + resolvedCountryCode = culture.TwoLetterISOLanguageName; } } - return null; + if (resolvedCountryCode is not null + && _allParentalRatings.TryGetValue(resolvedCountryCode, out var countryRatings)) + { + if (countryRatings.TryGetValue(ratingPart, out result)) + { + return true; + } + + _logger.LogWarning( + "Rating '{Rating}' not found in the '{CountryCode}' rating system, treating as unrated", + rating, + resolvedCountryCode); + + return true; + } + + // Country not identified or no rating data available, try recursive lookup + result = GetRatingScore(ratingPart, resolvedCountryCode); + + return true; } /// <inheritdoc /> diff --git a/Emby.Server.Implementations/Localization/Ratings/ca.json b/Emby.Server.Implementations/Localization/Ratings/ca.json index fa43a8f2b7..76550b64c3 100644 --- a/Emby.Server.Implementations/Localization/Ratings/ca.json +++ b/Emby.Server.Implementations/Localization/Ratings/ca.json @@ -3,7 +3,7 @@ "supportsSubScores": true, "ratings": [ { - "ratingStrings": ["E", "G", "TV-Y", "TV-G"], + "ratingStrings": ["C", "E", "G", "TV-Y", "TV-G"], "ratingScore": { "score": 0, "subScore": 0 @@ -24,13 +24,20 @@ } }, { - "ratingStrings": ["PG", "TV-PG"], + "ratingStrings": ["C8"], "ratingScore": { - "score": 9, + "score": 8, "subScore": 0 } }, { + "ratingStrings": ["PG", "TV-PG"], + "ratingScore": { + "score": 8, + "subScore": 1 + } + }, + { "ratingStrings": ["14A"], "ratingScore": { "score": 14, @@ -38,7 +45,7 @@ } }, { - "ratingStrings": ["TV-14"], + "ratingStrings": ["14+", "TV-14"], "ratingScore": { "score": 14, "subScore": 1 diff --git a/Emby.Server.Implementations/Serialization/MyXmlSerializer.cs b/Emby.Server.Implementations/Serialization/MyXmlSerializer.cs index aa5fbbdf73..5c9a94cd36 100644 --- a/Emby.Server.Implementations/Serialization/MyXmlSerializer.cs +++ b/Emby.Server.Implementations/Serialization/MyXmlSerializer.cs @@ -85,9 +85,17 @@ namespace Emby.Server.Implementations.Serialization /// <returns>System.Object.</returns> public object? DeserializeFromFile(Type type, string file) { - using (var stream = File.OpenRead(file)) + try { - return DeserializeFromStream(type, stream); + using (var stream = File.OpenRead(file)) + { + return DeserializeFromStream(type, stream); + } + } + catch (Exception ex) + { + ex.Data.Add("Filename", file); + throw; } } diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs index e2ddf86c7a..1782b53e10 100644 --- a/Emby.Server.Implementations/Session/SessionManager.cs +++ b/Emby.Server.Implementations/Session/SessionManager.cs @@ -973,7 +973,7 @@ namespace Emby.Server.Implementations.Session if (user.RememberAudioSelections) { - if (data.AudioStreamIndex != info.AudioStreamIndex) + if (info.AudioStreamIndex.HasValue && data.AudioStreamIndex != info.AudioStreamIndex) { data.AudioStreamIndex = info.AudioStreamIndex; changed = true; @@ -990,7 +990,7 @@ namespace Emby.Server.Implementations.Session if (user.RememberSubtitleSelections) { - if (data.SubtitleStreamIndex != info.SubtitleStreamIndex) + if (info.SubtitleStreamIndex.HasValue && data.SubtitleStreamIndex != info.SubtitleStreamIndex) { data.SubtitleStreamIndex = info.SubtitleStreamIndex; changed = true; @@ -1021,15 +1021,22 @@ namespace Emby.Server.Implementations.Session ArgumentNullException.ThrowIfNull(info); + var session = GetSession(info.SessionId); + + session.StopAutomaticProgress(); + if (info.PositionTicks.HasValue && info.PositionTicks.Value < 0) { + // Ensure live stream is cleaned up before throwing, to prevent tuner + // resource leaks when stalled clients report a negative PositionTicks. + if (!string.IsNullOrEmpty(info.LiveStreamId)) + { + await CloseLiveStreamIfNeededAsync(info.LiveStreamId, session.Id).ConfigureAwait(false); + } + throw new ArgumentOutOfRangeException(nameof(info), "The PlaybackStopInfo's PositionTicks was negative."); } - var session = GetSession(info.SessionId); - - session.StopAutomaticProgress(); - var libraryItem = info.ItemId.IsEmpty() ? null : GetNowPlayingItem(session, info.ItemId); @@ -2049,7 +2056,7 @@ namespace Emby.Server.Implementations.Session { CheckDisposed(); - var adminUserIds = _userManager.Users + var adminUserIds = _userManager.GetUsers() .Where(i => i.HasPermission(PermissionKind.IsAdministrator)) .Select(i => i.Id) .ToList(); |
