diff options
Diffstat (limited to 'MediaBrowser.Providers')
26 files changed, 609 insertions, 154 deletions
diff --git a/MediaBrowser.Providers/Books/Isbn/ISBNExternalId.cs b/MediaBrowser.Providers/Books/Isbn/ISBNExternalId.cs new file mode 100644 index 0000000000..a86275d5ae --- /dev/null +++ b/MediaBrowser.Providers/Books/Isbn/ISBNExternalId.cs @@ -0,0 +1,23 @@ +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; + +namespace MediaBrowser.Providers.Books.Isbn +{ + /// <inheritdoc /> + public class IsbnExternalId : IExternalId + { + /// <inheritdoc /> + public string ProviderName => "ISBN"; + + /// <inheritdoc /> + public string Key => "ISBN"; + + /// <inheritdoc /> + public ExternalIdMediaType? Type => null; + + /// <inheritdoc /> + public bool Supports(IHasProviderIds item) => item is Book; + } +} diff --git a/MediaBrowser.Providers/Books/Isbn/ISBNExternalUrlProvider.cs b/MediaBrowser.Providers/Books/Isbn/ISBNExternalUrlProvider.cs new file mode 100644 index 0000000000..9d7b1ff208 --- /dev/null +++ b/MediaBrowser.Providers/Books/Isbn/ISBNExternalUrlProvider.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; + +namespace MediaBrowser.Providers.Books.Isbn; + +/// <inheritdoc/> +public class IsbnExternalUrlProvider : IExternalUrlProvider +{ + /// <inheritdoc/> + public string Name => "ISBN"; + + /// <inheritdoc /> + public IEnumerable<string> GetExternalUrls(BaseItem item) + { + if (item.TryGetProviderId("ISBN", out var externalId)) + { + if (item is Book) + { + yield return $"https://search.worldcat.org/search?q=bn:{externalId}"; + } + } + } +} diff --git a/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs b/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs index 5d202c59e1..15ea2ce5ab 100644 --- a/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs +++ b/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs @@ -260,6 +260,8 @@ namespace MediaBrowser.Providers.Books.OpenPackagingFormat return PersonKind.Lyricist; case "mus": return PersonKind.AlbumArtist; + case "nrt": + return PersonKind.Narrator; case "oth": return PersonKind.Unknown; case "trl": diff --git a/MediaBrowser.Providers/BoxSets/BoxSetMetadataService.cs b/MediaBrowser.Providers/BoxSets/BoxSetMetadataService.cs index eccf8a606d..5f80151dd3 100644 --- a/MediaBrowser.Providers/BoxSets/BoxSetMetadataService.cs +++ b/MediaBrowser.Providers/BoxSets/BoxSetMetadataService.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using MediaBrowser.Controller.Configuration; @@ -69,8 +70,18 @@ public class BoxSetMetadataService : MetadataService<BoxSet, BoxSetInfo> if (mergeMetadataSettings) { - // TODO: Change to only replace when currently empty or requested. This is currently not done because the metadata service is not handling attaching collection items based on the provider responses - targetItem.LinkedChildren = sourceItem.LinkedChildren.Concat(targetItem.LinkedChildren).DistinctBy(i => i.Path).ToArray(); + // Only merge LinkedChildren from metadata for external collections (not managed by Jellyfin). + // For internal collections, the database LinkedChildren table is the source of truth. + var targetPath = targetItem.Path; + if (!string.IsNullOrEmpty(targetPath) + && !FileSystem.ContainsSubPath(ServerConfigurationManager.ApplicationPaths.DataPath, targetPath)) + { +#pragma warning disable CS0618 // Type or member is obsolete - fallback for legacy path-based dedup + targetItem.LinkedChildren = sourceItem.LinkedChildren.Concat(targetItem.LinkedChildren) + .DistinctBy(i => i.ItemId.HasValue && !i.ItemId.Value.Equals(Guid.Empty) ? i.ItemId.Value.ToString() : i.Path ?? string.Empty) + .ToArray(); +#pragma warning restore CS0618 + } } } diff --git a/MediaBrowser.Providers/Manager/ItemImageProvider.cs b/MediaBrowser.Providers/Manager/ItemImageProvider.cs index e0354dbdfa..727f481b65 100644 --- a/MediaBrowser.Providers/Manager/ItemImageProvider.cs +++ b/MediaBrowser.Providers/Manager/ItemImageProvider.cs @@ -255,7 +255,7 @@ namespace MediaBrowser.Providers.Manager catch (Exception ex) { result.ErrorMessage = ex.Message; - _logger.LogError(ex, "Error in {Provider}", provider.Name); + _logger.LogError(ex, "Error in {Provider} for {Item}", provider.Name, item.Path ?? item.Name); } } @@ -339,7 +339,7 @@ namespace MediaBrowser.Providers.Manager catch (Exception ex) { result.ErrorMessage = ex.Message; - _logger.LogError(ex, "Error in {Provider}", provider.Name); + _logger.LogError(ex, "Error in {Provider} for {Item}", provider.Name, item.Path ?? item.Name); } } diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs index e9cb46eab5..abdfb1e3b7 100644 --- a/MediaBrowser.Providers/Manager/MetadataService.cs +++ b/MediaBrowser.Providers/Manager/MetadataService.cs @@ -820,7 +820,7 @@ namespace MediaBrowser.Providers.Manager } catch (Exception ex) { - Logger.LogError(ex, "Error in {Provider}", provider.Name); + Logger.LogError(ex, "Error in {Provider} for {Item}", provider.Name, logName); // If a local provider fails, consider that a failure refreshResult.ErrorMessage = ex.Message; @@ -886,7 +886,7 @@ namespace MediaBrowser.Providers.Manager catch (Exception ex) { refreshResult.ErrorMessage = ex.Message; - Logger.LogError(ex, "Error in {Provider}", provider.Name); + Logger.LogError(ex, "Error in {Provider} for {Item}", provider.Name, logName); } } @@ -935,7 +935,7 @@ namespace MediaBrowser.Providers.Manager { refreshResult.Failures++; refreshResult.ErrorMessage = ex.Message; - Logger.LogError(ex, "Error in {Provider}", provider.Name); + Logger.LogError(ex, "Error in {Provider} for {Item}", provider.Name, logName); } } diff --git a/MediaBrowser.Providers/Manager/ProviderManager.cs b/MediaBrowser.Providers/Manager/ProviderManager.cs index f8e2aece1f..65edcb2a92 100644 --- a/MediaBrowser.Providers/Manager/ProviderManager.cs +++ b/MediaBrowser.Providers/Manager/ProviderManager.cs @@ -31,6 +31,7 @@ using MediaBrowser.Model.Extensions; using MediaBrowser.Model.IO; using MediaBrowser.Model.Net; using MediaBrowser.Model.Providers; +using MediaBrowser.Model.Querying; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Book = MediaBrowser.Controller.Entities.Book; @@ -69,6 +70,13 @@ namespace MediaBrowser.Providers.Manager o.PoolInitialFill = 1; }); + /// <summary> + /// Cache for ordered metadata providers per library/item type combination. + /// Key: (LibraryPath, ItemTypeName, IncludeDisabled, ForceEnableInternetMetadata). + /// Value: Array of ordered metadata providers (before per-item filtering). + /// </summary> + private readonly ConcurrentDictionary<MetadataProviderCacheKey, IMetadataProvider[]> _metadataProviderCache = new(); + private IImageProvider[] _imageProviders = []; private IMetadataService[] _metadataServices = []; private IMetadataProvider[] _metadataProviders = []; @@ -119,6 +127,8 @@ namespace MediaBrowser.Providers.Manager _lyricManager = lyricManager; _memoryCache = memoryCache; _mediaSegmentManager = mediaSegmentManager; + + CollectionFolder.LibraryOptionsUpdated += OnLibraryOptionsUpdated; } /// <inheritdoc/> @@ -427,8 +437,37 @@ namespace MediaBrowser.Providers.Manager where T : BaseItem { var globalMetadataOptions = GetMetadataOptions(item); + var libraryPath = GetLibraryPathForItem(item); + + return GetMetadataProvidersInternal<T>(item, libraryOptions, globalMetadataOptions, false, false, libraryPath); + } + + /// <summary> + /// Gets metadata providers for the specified item. + /// </summary> + /// <typeparam name="T">The item type.</typeparam> + /// <param name="item">The item.</param> + /// <param name="libraryOptions">The library options.</param> + /// <param name="includeDisabled">Whether to include disabled providers.</param> + /// <returns>The metadata providers.</returns> + public IEnumerable<IMetadataProvider<T>> GetMetadataProviders<T>(BaseItem item, LibraryOptions libraryOptions, bool includeDisabled) + where T : BaseItem + { + var globalMetadataOptions = GetMetadataOptions(item); + var libraryPath = GetLibraryPathForItem(item); + + return GetMetadataProvidersInternal<T>(item, libraryOptions, globalMetadataOptions, includeDisabled, false, libraryPath); + } + + private static string GetLibraryPathForItem(BaseItem item) + { + if (item is CollectionFolder collectionFolder) + { + return collectionFolder.Path ?? string.Empty; + } - return GetMetadataProvidersInternal<T>(item, libraryOptions, globalMetadataOptions, false, false); + var topParent = item.GetTopParent(); + return topParent?.Path ?? string.Empty; } /// <inheritdoc /> @@ -437,15 +476,37 @@ namespace MediaBrowser.Providers.Manager return _savers.Where(i => IsSaverEnabledForItem(i, item, libraryOptions, ItemUpdateType.MetadataEdit, false)); } - private IEnumerable<IMetadataProvider<T>> GetMetadataProvidersInternal<T>(BaseItem item, LibraryOptions libraryOptions, MetadataOptions globalMetadataOptions, bool includeDisabled, bool forceEnableInternetMetadata) + private IEnumerable<IMetadataProvider<T>> GetMetadataProvidersInternal<T>(BaseItem item, LibraryOptions libraryOptions, MetadataOptions globalMetadataOptions, bool includeDisabled, bool forceEnableInternetMetadata, string libraryPath) where T : BaseItem { - var localMetadataReaderOrder = libraryOptions.LocalMetadataReaderOrder ?? globalMetadataOptions.LocalMetadataReaderOrder; var typeOptions = libraryOptions.GetTypeOptions(item.GetType().Name); + + var orderedProviders = GetOrCreateOrderedProviders<T>(item.GetType().Name, libraryOptions, globalMetadataOptions, includeDisabled, forceEnableInternetMetadata, libraryPath); + + return orderedProviders.Where(i => CanRefreshMetadata(i, item, typeOptions, includeDisabled, forceEnableInternetMetadata)); + } + + private IMetadataProvider<T>[] GetOrCreateOrderedProviders<T>( + string itemTypeName, + LibraryOptions libraryOptions, + MetadataOptions globalMetadataOptions, + bool includeDisabled, + bool forceEnableInternetMetadata, + string libraryPath) + where T : BaseItem + { + var cacheKey = new MetadataProviderCacheKey(libraryPath, itemTypeName, includeDisabled, forceEnableInternetMetadata); + if (_metadataProviderCache.TryGetValue(cacheKey, out var cachedProviders)) + { + return cachedProviders.OfType<IMetadataProvider<T>>().ToArray(); + } + + var localMetadataReaderOrder = libraryOptions.LocalMetadataReaderOrder ?? globalMetadataOptions.LocalMetadataReaderOrder; + var typeOptions = libraryOptions.GetTypeOptions(itemTypeName); var metadataFetcherOrder = typeOptions?.MetadataFetcherOrder ?? globalMetadataOptions.MetadataFetcherOrder; - return _metadataProviders.OfType<IMetadataProvider<T>>() - .Where(i => CanRefreshMetadata(i, item, typeOptions, includeDisabled, forceEnableInternetMetadata)) + var orderedProviders = _metadataProviders.OfType<IMetadataProvider<T>>() + .Where(i => CanRefreshMetadataForCache(i, typeOptions, includeDisabled, forceEnableInternetMetadata)) .OrderBy(i => // local and remote providers will be interleaved in the final order // only relative order within a type matters: consumers of the list filter to one or the other @@ -456,7 +517,36 @@ namespace MediaBrowser.Providers.Manager // Default to end _ => int.MaxValue }) - .ThenBy(GetDefaultOrder); + .ThenBy(GetDefaultOrder) + .ToArray(); + + _metadataProviderCache.TryAdd(cacheKey, orderedProviders.Cast<IMetadataProvider>().ToArray()); + + return orderedProviders; + } + + private static bool CanRefreshMetadataForCache( + IMetadataProvider provider, + TypeOptions? libraryTypeOptions, + bool includeDisabled, + bool forceEnableInternetMetadata) + { + if (includeDisabled) + { + return true; + } + + if (forceEnableInternetMetadata || provider is not IRemoteMetadataProvider) + { + return true; + } + + if (libraryTypeOptions?.MetadataFetchers is { Length: > 0 } metadataFetchers) + { + return metadataFetchers.Contains(provider.Name, StringComparer.OrdinalIgnoreCase); + } + + return true; } private bool CanRefreshMetadata( @@ -487,6 +577,13 @@ namespace MediaBrowser.Providers.Manager return true; } + // Artists without a folder structure that are derived from metadata have no real path in the library, + // so GetLibraryOptions returns null. Allow all providers through rather than blocking them. + if (item is MusicArtist && libraryTypeOptions is null) + { + return true; + } + return _baseItemManager.IsMetadataFetcherEnabled(item, libraryTypeOptions, provider.Name); } @@ -607,7 +704,8 @@ namespace MediaBrowser.Providers.Manager private void AddMetadataPlugins<T>(List<MetadataPlugin> list, T item, LibraryOptions libraryOptions, MetadataOptions options) where T : BaseItem { - var providers = GetMetadataProvidersInternal<T>(item, libraryOptions, options, true, true).ToList(); + var libraryPath = GetLibraryPathForItem(item); + var providers = GetMetadataProvidersInternal<T>(item, libraryOptions, options, true, true, libraryPath).ToList(); // Locals list.AddRange(providers.Where(i => i is ILocalMetadataProvider).Select(i => new MetadataPlugin @@ -824,8 +922,8 @@ namespace MediaBrowser.Providers.Manager } var options = GetMetadataOptions(referenceItem); - - var providers = GetMetadataProvidersInternal<TItemType>(referenceItem, libraryOptions, options, searchInfo.IncludeDisabledProviders, false) + var libraryPath = GetLibraryPathForItem(referenceItem); + var providers = GetMetadataProvidersInternal<TItemType>(referenceItem, libraryOptions, options, searchInfo.IncludeDisabledProviders, false, libraryPath) .OfType<IRemoteSearchProvider<TLookupType>>(); if (!string.IsNullOrEmpty(searchInfo.SearchProviderName)) @@ -1035,6 +1133,7 @@ namespace MediaBrowser.Providers.Manager var cancellationToken = _disposeCancellationTokenSource.Token; + libraryManager.ClearIgnoreRuleCache(); while (_refreshQueue.TryDequeue(out var refreshItem, out _)) { if (_disposed) @@ -1069,6 +1168,7 @@ namespace MediaBrowser.Providers.Manager lock (_refreshQueueLock) { _isProcessingRefreshQueue = false; + libraryManager.ClearIgnoreRuleCache(); } } @@ -1157,6 +1257,8 @@ namespace MediaBrowser.Providers.Manager if (disposing) { + CollectionFolder.LibraryOptionsUpdated -= OnLibraryOptionsUpdated; + if (!_disposeCancellationTokenSource.IsCancellationRequested) { _disposeCancellationTokenSource.Cancel(); @@ -1168,5 +1270,38 @@ namespace MediaBrowser.Providers.Manager _disposed = true; } + + private void OnLibraryOptionsUpdated(object? sender, LibraryOptionsUpdatedEventArgs e) + { + var keysToRemove = _metadataProviderCache.Keys + .Where(k => string.Equals(k.LibraryPath, e.LibraryPath, StringComparison.Ordinal)) + .ToList(); + + foreach (var key in keysToRemove) + { + _metadataProviderCache.TryRemove(key, out _); + } + + _logger.LogDebug("Invalidated metadata provider cache for library: {LibraryPath}", e.LibraryPath); + } + + internal void ClearMetadataProviderCache() + { + _metadataProviderCache.Clear(); + _logger.LogDebug("Cleared entire metadata provider cache"); + } + + /// <summary> + /// Cache key for metadata provider lookups. + /// </summary> + /// <param name="LibraryPath">The library path for the collection folder.</param> + /// <param name="ItemTypeName">The item type name.</param> + /// <param name="IncludeDisabled">Whether to include disabled providers.</param> + /// <param name="ForceEnableInternetMetadata">Whether internet metadata is force-enabled.</param> + private readonly record struct MetadataProviderCacheKey( + string LibraryPath, + string ItemTypeName, + bool IncludeDisabled, + bool ForceEnableInternetMetadata); } } diff --git a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs index 869e3f292e..0ecbb6f068 100644 --- a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs +++ b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using ATL; using Jellyfin.Data.Enums; using Jellyfin.Extensions; +using MediaBrowser.Controller.Chapters; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Library; @@ -38,6 +39,7 @@ namespace MediaBrowser.Providers.MediaInfo private readonly LyricResolver _lyricResolver; private readonly ILyricManager _lyricManager; private readonly IMediaStreamRepository _mediaStreamRepository; + private readonly IChapterManager _chapterManager; /// <summary> /// Initializes a new instance of the <see cref="AudioFileProber"/> class. @@ -49,6 +51,7 @@ namespace MediaBrowser.Providers.MediaInfo /// <param name="lyricResolver">Instance of the <see cref="LyricResolver"/> interface.</param> /// <param name="lyricManager">Instance of the <see cref="ILyricManager"/> interface.</param> /// <param name="mediaStreamRepository">Instance of the <see cref="IMediaStreamRepository"/>.</param> + /// <param name="chapterManager">Instance of the <see cref="IChapterManager"/> interface.</param> public AudioFileProber( ILogger<AudioFileProber> logger, IMediaSourceManager mediaSourceManager, @@ -56,7 +59,8 @@ namespace MediaBrowser.Providers.MediaInfo ILibraryManager libraryManager, LyricResolver lyricResolver, ILyricManager lyricManager, - IMediaStreamRepository mediaStreamRepository) + IMediaStreamRepository mediaStreamRepository, + IChapterManager chapterManager) { _mediaEncoder = mediaEncoder; _libraryManager = libraryManager; @@ -65,6 +69,7 @@ namespace MediaBrowser.Providers.MediaInfo _lyricResolver = lyricResolver; _lyricManager = lyricManager; _mediaStreamRepository = mediaStreamRepository; + _chapterManager = chapterManager; ATL.Settings.DisplayValueSeparator = InternalValueSeparator; ATL.Settings.UseFileNameWhenNoTitle = false; ATL.Settings.ID3v2_separatev2v3Values = false; @@ -99,6 +104,7 @@ namespace MediaBrowser.Providers.MediaInfo new MediaInfoRequest { MediaType = DlnaProfileType.Audio, + ExtractChapters = item is AudioBook, MediaSource = new MediaSourceInfo { Path = path, @@ -151,6 +157,11 @@ namespace MediaBrowser.Providers.MediaInfo audio.HasLyrics = mediaStreams.Any(s => s.Type == MediaStreamType.Lyric); _mediaStreamRepository.SaveMediaStreams(audio.Id, mediaStreams, cancellationToken); + + if (audio is AudioBook && mediaInfo.Chapters is { Length: > 0 }) + { + _chapterManager.SaveChapters(audio, mediaInfo.Chapters); + } } /// <summary> @@ -212,18 +223,6 @@ namespace MediaBrowser.Providers.MediaInfo albumArtists = albumArtists.SelectMany(a => SplitWithCustomDelimiter(a, libraryOptions.GetCustomTagDelimiters(), libraryOptions.DelimiterWhitelist)).ToArray(); } - foreach (var albumArtist in albumArtists) - { - if (!string.IsNullOrWhiteSpace(albumArtist)) - { - PeopleHelper.AddPerson(people, new PersonInfo - { - Name = albumArtist, - Type = PersonKind.AlbumArtist - }); - } - } - string[]? performers = null; if (libraryOptions.PreferNonstandardArtistsTag) { @@ -244,31 +243,99 @@ namespace MediaBrowser.Providers.MediaInfo performers = performers.SelectMany(p => SplitWithCustomDelimiter(p, libraryOptions.GetCustomTagDelimiters(), libraryOptions.DelimiterWhitelist)).ToArray(); } - foreach (var performer in performers) + var isAudioBook = audio is AudioBook; + + if (isAudioBook) { - if (!string.IsNullOrWhiteSpace(performer)) + // For audiobooks: AlbumArtists/Performers = Author, NARRATOR tag = Narrator, + // ILLUSTRATOR tag = Illustrator, Composer = fallback Narrator, other performers = Cast. + // If album_artist is missing, fall back to artist/performers for the author role. + var authorSource = albumArtists.Length > 0 ? albumArtists : performers; + var authorNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase); + + foreach (var author in authorSource) { - PeopleHelper.AddPerson(people, new PersonInfo + if (!string.IsNullOrWhiteSpace(author)) { - Name = performer, - Type = PersonKind.Artist - }); + authorNames.Add(author.Trim()); + PeopleHelper.AddPerson(people, new PersonInfo + { + Name = author.Trim(), + Type = PersonKind.Author + }); + } + } + + // Composer tag = Narrator (Audiobookshelf and other tools use Composer for narrator) + if (!string.IsNullOrWhiteSpace(trackComposer)) + { + foreach (var composer in trackComposer.Split(InternalValueSeparator)) + { + if (!string.IsNullOrWhiteSpace(composer)) + { + PeopleHelper.AddPerson(people, new PersonInfo + { + Name = composer.Trim(), + Type = PersonKind.Narrator + }); + } + } } - } - if (!string.IsNullOrWhiteSpace(trackComposer)) + // Any performers not already listed as authors get added as cast + foreach (var performer in performers) + { + if (!string.IsNullOrWhiteSpace(performer) && !authorNames.Contains(performer.Trim())) + { + PeopleHelper.AddPerson(people, new PersonInfo + { + Name = performer.Trim(), + Type = PersonKind.Actor + }); + } + } + } + else { - foreach (var composer in trackComposer.Split(InternalValueSeparator)) + // Standard music track handling + foreach (var albumArtist in albumArtists) + { + if (!string.IsNullOrWhiteSpace(albumArtist)) + { + PeopleHelper.AddPerson(people, new PersonInfo + { + Name = albumArtist, + Type = PersonKind.AlbumArtist + }); + } + } + + foreach (var performer in performers) { - if (!string.IsNullOrWhiteSpace(composer)) + if (!string.IsNullOrWhiteSpace(performer)) { PeopleHelper.AddPerson(people, new PersonInfo { - Name = composer, - Type = PersonKind.Composer + Name = performer, + Type = PersonKind.Artist }); } } + + if (!string.IsNullOrWhiteSpace(trackComposer)) + { + foreach (var composer in trackComposer.Split(InternalValueSeparator)) + { + if (!string.IsNullOrWhiteSpace(composer)) + { + PeopleHelper.AddPerson(people, new PersonInfo + { + Name = composer, + Type = PersonKind.Composer + }); + } + } + } } _libraryManager.UpdatePeople(audio, people); @@ -359,6 +426,33 @@ namespace MediaBrowser.Providers.MediaInfo } } + // Audiobook-specific metadata: Overview, Publisher, Series + if (audio is AudioBook audioBook) + { + if (!audio.LockedFields.Contains(MetadataField.Overview)) + { + var trackDescription = GetSanitizedStringTag(track.Description, audio.Path); + var trackComment = GetSanitizedStringTag(track.Comment, audio.Path); + var overview = !string.IsNullOrWhiteSpace(trackDescription) ? trackDescription : trackComment; + + if (!string.IsNullOrWhiteSpace(overview)) + { + if (options.ReplaceAllMetadata || string.IsNullOrEmpty(audio.Overview)) + { + audio.Overview = overview; + } + } + } + + // Publisher → Studio + var trackPublisher = GetSanitizedStringTag(track.Publisher, audio.Path); + if (!string.IsNullOrWhiteSpace(trackPublisher) + && (options.ReplaceAllMetadata || audio.Studios is null || audio.Studios.Length == 0)) + { + audio.SetStudios(new[] { trackPublisher! }); + } + } + TryGetSanitizedAdditionalFields(track, "REPLAYGAIN_TRACK_GAIN", out var trackGainTag); if (trackGainTag is not null) diff --git a/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs b/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs index bde23e842f..f9d8883dff 100644 --- a/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs +++ b/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs @@ -8,7 +8,6 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; using Jellyfin.Extensions; -using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Chapters; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; @@ -25,7 +24,6 @@ using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; using MediaBrowser.Model.MediaInfo; -using MediaBrowser.Model.Providers; using Microsoft.Extensions.Logging; namespace MediaBrowser.Providers.MediaInfo @@ -74,7 +72,6 @@ namespace MediaBrowser.Providers.MediaInfo _subtitleResolver = subtitleResolver; _mediaAttachmentRepository = mediaAttachmentRepository; _mediaStreamRepository = mediaStreamRepository; - _mediaStreamRepository = mediaStreamRepository; } public async Task<ItemUpdateType> ProbeVideo<T>( @@ -197,20 +194,11 @@ namespace MediaBrowser.Providers.MediaInfo IReadOnlyList<MediaAttachment> mediaAttachments; ChapterInfo[] chapters; - // Add external streams before adding the streams from the file to preserve stream IDs on remote videos - await AddExternalSubtitlesAsync(video, mediaStreams, options, cancellationToken).ConfigureAwait(false); - await AddExternalAudioAsync(video, mediaStreams, options, cancellationToken).ConfigureAwait(false); - var startIndex = mediaStreams.Count == 0 ? 0 : (mediaStreams.Max(i => i.Index) + 1); - if (mediaInfo is not null) { - foreach (var mediaStream in mediaInfo.MediaStreams) - { - mediaStream.Index = startIndex++; - mediaStreams.Add(mediaStream); - } + mediaStreams.AddRange(mediaInfo.MediaStreams); mediaAttachments = mediaInfo.MediaAttachments; video.TotalBitrate = mediaInfo.Bitrate; @@ -234,7 +222,6 @@ namespace MediaBrowser.Providers.MediaInfo { if (!mediaStream.IsExternal) { - mediaStream.Index = startIndex++; mediaStreams.Add(mediaStream); } } @@ -243,6 +230,14 @@ namespace MediaBrowser.Providers.MediaInfo chapters = []; } + // Download and insert external streams before the streams from the file to preserve stream IDs on remote videos + await AddExternalSubtitlesAsync(video, mediaStreams, options, cancellationToken).ConfigureAwait(false); + + for (var i = 0; i < mediaStreams.Count; i++) + { + mediaStreams[i].Index = i; + } + var libraryOptions = _libraryManager.GetLibraryOptions(video); if (mediaInfo is not null) @@ -281,7 +276,7 @@ namespace MediaBrowser.Providers.MediaInfo if (options.MetadataRefreshMode == MetadataRefreshMode.FullRefresh || options.MetadataRefreshMode == MetadataRefreshMode.Default) { - if (_config.Configuration.DummyChapterDuration > 0 && chapters.Length == 0 && mediaStreams.Any(i => i.Type == MediaStreamType.Video)) + if (_config.Configuration.DummyChapterDuration > 0 && chapters.Length <= 1 && mediaStreams.Any(i => i.Type == MediaStreamType.Video)) { chapters = CreateDummyChapters(video); } @@ -366,6 +361,8 @@ namespace MediaBrowser.Providers.MediaInfo blurayVideoStream.ColorSpace = ffmpegVideoStream.ColorSpace; blurayVideoStream.ColorTransfer = ffmpegVideoStream.ColorTransfer; blurayVideoStream.ColorPrimaries = ffmpegVideoStream.ColorPrimaries; + blurayVideoStream.BitDepth = ffmpegVideoStream.BitDepth; + blurayVideoStream.PixelFormat = ffmpegVideoStream.PixelFormat; } } @@ -543,53 +540,24 @@ namespace MediaBrowser.Providers.MediaInfo MetadataRefreshOptions options, CancellationToken cancellationToken) { - var startIndex = currentStreams.Count == 0 ? 0 : (currentStreams.Select(i => i.Index).Max() + 1); - var externalSubtitleStreams = await _subtitleResolver.GetExternalStreamsAsync(video, startIndex, options.DirectoryService, false, cancellationToken).ConfigureAwait(false); + var externalSubtitleStreams = await _subtitleResolver.GetExternalStreamsAsync(video, 0, options.DirectoryService, false, cancellationToken).ConfigureAwait(false); var enableSubtitleDownloading = options.MetadataRefreshMode == MetadataRefreshMode.Default || options.MetadataRefreshMode == MetadataRefreshMode.FullRefresh; - var subtitleOptions = _config.GetConfiguration<SubtitleOptions>("subtitles"); - var libraryOptions = _libraryManager.GetLibraryOptions(video); - string[] subtitleDownloadLanguages; - bool skipIfEmbeddedSubtitlesPresent; - bool skipIfAudioTrackMatches; - bool requirePerfectMatch; - bool enabled; - - if (libraryOptions.SubtitleDownloadLanguages is null) - { - subtitleDownloadLanguages = subtitleOptions.DownloadLanguages; - skipIfEmbeddedSubtitlesPresent = subtitleOptions.SkipIfEmbeddedSubtitlesPresent; - skipIfAudioTrackMatches = subtitleOptions.SkipIfAudioTrackMatches; - requirePerfectMatch = subtitleOptions.RequirePerfectMatch; - enabled = (subtitleOptions.DownloadEpisodeSubtitles && - video is Episode) || - (subtitleOptions.DownloadMovieSubtitles && - video is Movie); - } - else - { - subtitleDownloadLanguages = libraryOptions.SubtitleDownloadLanguages; - skipIfEmbeddedSubtitlesPresent = libraryOptions.SkipSubtitlesIfEmbeddedSubtitlesPresent; - skipIfAudioTrackMatches = libraryOptions.SkipSubtitlesIfAudioTrackMatches; - requirePerfectMatch = libraryOptions.RequirePerfectSubtitleMatch; - enabled = true; - } - - if (enableSubtitleDownloading && enabled) + if (enableSubtitleDownloading && libraryOptions.SubtitleDownloadLanguages is not null) { var downloadedLanguages = await new SubtitleDownloader( _logger, _subtitleManager).DownloadSubtitles( video, currentStreams.Concat(externalSubtitleStreams).ToList(), - skipIfEmbeddedSubtitlesPresent, - skipIfAudioTrackMatches, - requirePerfectMatch, - subtitleDownloadLanguages, + libraryOptions.SkipSubtitlesIfEmbeddedSubtitlesPresent, + libraryOptions.SkipSubtitlesIfAudioTrackMatches, + libraryOptions.RequirePerfectSubtitleMatch, + libraryOptions.SubtitleDownloadLanguages, libraryOptions.DisabledSubtitleFetchers, libraryOptions.SubtitleFetcherOrder, true, @@ -598,13 +566,13 @@ namespace MediaBrowser.Providers.MediaInfo // Rescan if (downloadedLanguages.Count > 0) { - externalSubtitleStreams = await _subtitleResolver.GetExternalStreamsAsync(video, startIndex, options.DirectoryService, true, cancellationToken).ConfigureAwait(false); + externalSubtitleStreams = await _subtitleResolver.GetExternalStreamsAsync(video, 0, options.DirectoryService, true, cancellationToken).ConfigureAwait(false); } } video.SubtitleFiles = externalSubtitleStreams.Select(i => i.Path).Distinct().ToArray(); - currentStreams.AddRange(externalSubtitleStreams); + currentStreams.InsertRange(0, externalSubtitleStreams); } /// <summary> @@ -620,8 +588,7 @@ namespace MediaBrowser.Providers.MediaInfo MetadataRefreshOptions options, CancellationToken cancellationToken) { - var startIndex = currentStreams.Count == 0 ? 0 : currentStreams.Max(i => i.Index) + 1; - var externalAudioStreams = await _audioResolver.GetExternalStreamsAsync(video, startIndex, options.DirectoryService, false, cancellationToken).ConfigureAwait(false); + var externalAudioStreams = await _audioResolver.GetExternalStreamsAsync(video, 0, options.DirectoryService, false, cancellationToken).ConfigureAwait(false); video.AudioFiles = externalAudioStreams.Select(i => i.Path).Distinct().ToArray(); @@ -649,12 +616,13 @@ namespace MediaBrowser.Providers.MediaInfo } long dummyChapterDuration = TimeSpan.FromSeconds(_config.Configuration.DummyChapterDuration).Ticks; - if (runtime <= dummyChapterDuration) + + if (runtime <= 0) { return []; } - int chapterCount = (int)(runtime / dummyChapterDuration); + int chapterCount = Math.Max(1, (int)(runtime / dummyChapterDuration)); var chapters = new ChapterInfo[chapterCount]; long currentChapterTicks = 0; diff --git a/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs b/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs index 0716cdfa01..6f9d5f19da 100644 --- a/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs +++ b/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs @@ -218,12 +218,12 @@ namespace MediaBrowser.Providers.MediaInfo return Array.Empty<ExternalPathParserResult>(); } - var files = directoryService.GetFilePaths(folder, clearCache, true).ToList(); + var files = directoryService.GetFilePaths(folder, clearCache).ToList(); files.Remove(video.Path); var internalMetadataPath = video.GetInternalMetadataPath(); if (_fileSystem.DirectoryExists(internalMetadataPath)) { - files.AddRange(directoryService.GetFilePaths(internalMetadataPath, clearCache, true)); + files.AddRange(directoryService.GetFilePaths(internalMetadataPath, clearCache)); } if (files.Count == 0) @@ -270,12 +270,12 @@ namespace MediaBrowser.Providers.MediaInfo } string folder = audio.ContainingFolderPath; - var files = directoryService.GetFilePaths(folder, clearCache, true).ToList(); + var files = directoryService.GetFilePaths(folder, clearCache).ToList(); files.Remove(audio.Path); var internalMetadataPath = audio.GetInternalMetadataPath(); if (_fileSystem.DirectoryExists(internalMetadataPath)) { - files.AddRange(directoryService.GetFilePaths(internalMetadataPath, clearCache, true)); + files.AddRange(directoryService.GetFilePaths(internalMetadataPath, clearCache)); } if (files.Count == 0) diff --git a/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs b/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs index 9f5463b82c..789df8f061 100644 --- a/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs +++ b/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs @@ -110,7 +110,8 @@ namespace MediaBrowser.Providers.MediaInfo libraryManager, _lyricResolver, lyricManager, - mediaStreamRepository); + mediaStreamRepository, + chapterManager); } /// <inheritdoc /> @@ -262,9 +263,28 @@ namespace MediaBrowser.Providers.MediaInfo private void FetchShortcutInfo(BaseItem item) { - item.ShortcutPath = File.ReadAllLines(item.Path) + var shortcutPath = File.ReadAllLines(item.Path) .Select(NormalizeStrmLine) .FirstOrDefault(i => !string.IsNullOrWhiteSpace(i) && !i.StartsWith('#')); + + if (string.IsNullOrWhiteSpace(shortcutPath)) + { + return; + } + + // Only allow remote URLs in .strm files to prevent local file access + if (Uri.TryCreate(shortcutPath, UriKind.Absolute, out var uri) + && (string.Equals(uri.Scheme, "http", StringComparison.OrdinalIgnoreCase) + || string.Equals(uri.Scheme, "https", StringComparison.OrdinalIgnoreCase) + || string.Equals(uri.Scheme, "rtsp", StringComparison.OrdinalIgnoreCase) + || string.Equals(uri.Scheme, "rtp", StringComparison.OrdinalIgnoreCase))) + { + item.ShortcutPath = shortcutPath; + } + else + { + _logger.LogWarning("Ignoring invalid or non-remote .strm path in {File}: {Path}", item.Path, shortcutPath); + } } /// <summary> diff --git a/MediaBrowser.Providers/MediaInfo/SubtitleScheduledTask.cs b/MediaBrowser.Providers/MediaInfo/SubtitleScheduledTask.cs index 1134baf92d..f1582febf2 100644 --- a/MediaBrowser.Providers/MediaInfo/SubtitleScheduledTask.cs +++ b/MediaBrowser.Providers/MediaInfo/SubtitleScheduledTask.cs @@ -8,14 +8,13 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; -using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Subtitles; using MediaBrowser.Model.Globalization; -using MediaBrowser.Model.Providers; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; @@ -28,19 +27,24 @@ namespace MediaBrowser.Providers.MediaInfo private readonly ISubtitleManager _subtitleManager; private readonly ILogger<SubtitleScheduledTask> _logger; private readonly ILocalizationManager _localization; + private readonly ISubtitleProvider[] _subtitleProviders; public SubtitleScheduledTask( ILibraryManager libraryManager, IServerConfigurationManager config, ISubtitleManager subtitleManager, ILogger<SubtitleScheduledTask> logger, - ILocalizationManager localization) + ILocalizationManager localization, + IEnumerable<ISubtitleProvider> subtitleProviders) { _libraryManager = libraryManager; _config = config; _subtitleManager = subtitleManager; _logger = logger; _localization = localization; + _subtitleProviders = subtitleProviders + .OrderBy(i => i is IHasOrder hasOrder ? hasOrder.Order : 0) + .ToArray(); } public string Name => _localization.GetLocalizedString("TaskDownloadMissingSubtitles"); @@ -57,16 +61,9 @@ namespace MediaBrowser.Providers.MediaInfo public bool IsLogged => true; - private SubtitleOptions GetOptions() - { - return _config.GetConfiguration<SubtitleOptions>("subtitles"); - } - /// <inheritdoc /> public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { - var options = GetOptions(); - var types = new[] { BaseItemKind.Episode, BaseItemKind.Movie }; var dict = new Dictionary<Guid, BaseItem>(); @@ -81,17 +78,20 @@ namespace MediaBrowser.Providers.MediaInfo if (libraryOptions.SubtitleDownloadLanguages is null) { - subtitleDownloadLanguages = options.DownloadLanguages; - skipIfEmbeddedSubtitlesPresent = options.SkipIfEmbeddedSubtitlesPresent; - skipIfAudioTrackMatches = options.SkipIfAudioTrackMatches; + // Skip this library if subtitle download languages are not configured + continue; } - else + + if (_subtitleProviders.All(provider => libraryOptions.DisabledSubtitleFetchers.Contains(provider.Name, StringComparer.OrdinalIgnoreCase))) { - subtitleDownloadLanguages = libraryOptions.SubtitleDownloadLanguages; - skipIfEmbeddedSubtitlesPresent = libraryOptions.SkipSubtitlesIfEmbeddedSubtitlesPresent; - skipIfAudioTrackMatches = libraryOptions.SkipSubtitlesIfAudioTrackMatches; + // Skip this library if all subtitle providers are disabled + continue; } + subtitleDownloadLanguages = libraryOptions.SubtitleDownloadLanguages; + skipIfEmbeddedSubtitlesPresent = libraryOptions.SkipSubtitlesIfEmbeddedSubtitlesPresent; + skipIfAudioTrackMatches = libraryOptions.SkipSubtitlesIfAudioTrackMatches; + foreach (var lang in subtitleDownloadLanguages) { var query = new InternalItemsQuery @@ -144,7 +144,7 @@ namespace MediaBrowser.Providers.MediaInfo try { - await DownloadSubtitles(video as Video, options, cancellationToken).ConfigureAwait(false); + await DownloadSubtitles(video as Video, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { @@ -160,7 +160,7 @@ namespace MediaBrowser.Providers.MediaInfo } } - private async Task<bool> DownloadSubtitles(Video video, SubtitleOptions options, CancellationToken cancellationToken) + private async Task<bool> DownloadSubtitles(Video video, CancellationToken cancellationToken) { var mediaStreams = video.GetMediaStreams(); @@ -173,19 +173,15 @@ namespace MediaBrowser.Providers.MediaInfo if (libraryOptions.SubtitleDownloadLanguages is null) { - subtitleDownloadLanguages = options.DownloadLanguages; - skipIfEmbeddedSubtitlesPresent = options.SkipIfEmbeddedSubtitlesPresent; - skipIfAudioTrackMatches = options.SkipIfAudioTrackMatches; - requirePerfectMatch = options.RequirePerfectMatch; - } - else - { - subtitleDownloadLanguages = libraryOptions.SubtitleDownloadLanguages; - skipIfEmbeddedSubtitlesPresent = libraryOptions.SkipSubtitlesIfEmbeddedSubtitlesPresent; - skipIfAudioTrackMatches = libraryOptions.SkipSubtitlesIfAudioTrackMatches; - requirePerfectMatch = libraryOptions.RequirePerfectSubtitleMatch; + // Subtitle downloading is not configured for this library + return true; } + subtitleDownloadLanguages = libraryOptions.SubtitleDownloadLanguages; + skipIfEmbeddedSubtitlesPresent = libraryOptions.SkipSubtitlesIfEmbeddedSubtitlesPresent; + skipIfAudioTrackMatches = libraryOptions.SkipSubtitlesIfAudioTrackMatches; + requirePerfectMatch = libraryOptions.RequirePerfectSubtitleMatch; + var downloadedLanguages = await new SubtitleDownloader( _logger, _subtitleManager).DownloadSubtitles( diff --git a/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs b/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs index 4c10fe3f1a..924fde4808 100644 --- a/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs +++ b/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs @@ -175,11 +175,11 @@ public class PlaylistItemsProvider : ILocalMetadataProvider<Playlist>, private LinkedChild GetLinkedChild(string itemPath, string playlistPath, List<string> libraryRoots) { - if (TryGetPlaylistItemPath(itemPath, playlistPath, libraryRoots, out var parsedPath)) + if (TryResolvePlaylistItem(itemPath, playlistPath, libraryRoots, out var item)) { return new LinkedChild { - Path = parsedPath, + ItemId = item.Id, Type = LinkedChildType.Manual }; } @@ -187,9 +187,9 @@ public class PlaylistItemsProvider : ILocalMetadataProvider<Playlist>, return null; } - private bool TryGetPlaylistItemPath(string itemPath, string playlistPath, List<string> libraryPaths, out string path) + private bool TryResolvePlaylistItem(string itemPath, string playlistPath, List<string> libraryPaths, out BaseItem item) { - path = null; + item = null; string pathToCheck = _fileSystem.MakeAbsolutePath(Path.GetDirectoryName(playlistPath), itemPath); if (!File.Exists(pathToCheck)) { @@ -200,8 +200,8 @@ public class PlaylistItemsProvider : ILocalMetadataProvider<Playlist>, { if (pathToCheck.StartsWith(libraryPath, StringComparison.OrdinalIgnoreCase)) { - path = pathToCheck; - return true; + item = _libraryManager.FindByPath(pathToCheck, null); + return item is not null; } } diff --git a/MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs b/MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs index e0a4c4f320..0438bc7c95 100644 --- a/MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs +++ b/MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using MediaBrowser.Controller.Configuration; @@ -66,13 +67,24 @@ public class PlaylistMetadataService : MetadataService<Playlist, ItemLookupInfo> { targetItem.PlaylistMediaType = sourceItem.PlaylistMediaType; - if (replaceData || targetItem.LinkedChildren.Length == 0) + // Only merge LinkedChildren from metadata for external playlists (not managed by Jellyfin). + // For internal playlists, the database LinkedChildren table is the source of truth. + var targetPath = targetItem.Path; + if (!string.IsNullOrEmpty(targetPath) + && !FileSystem.ContainsSubPath(ServerConfigurationManager.ApplicationPaths.DataPath, targetPath)) { - targetItem.LinkedChildren = sourceItem.LinkedChildren; - } - else - { - targetItem.LinkedChildren = sourceItem.LinkedChildren.Concat(targetItem.LinkedChildren).DistinctBy(i => i.Path).ToArray(); + if (replaceData || targetItem.LinkedChildren.Length == 0) + { + targetItem.LinkedChildren = sourceItem.LinkedChildren; + } + else + { +#pragma warning disable CS0618 // Type or member is obsolete - fallback for legacy path-based dedup + targetItem.LinkedChildren = sourceItem.LinkedChildren.Concat(targetItem.LinkedChildren) + .DistinctBy(i => i.ItemId.HasValue && !i.ItemId.Value.Equals(Guid.Empty) ? i.ItemId.Value.ToString() : i.Path ?? string.Empty) + .ToArray(); +#pragma warning restore CS0618 + } } if (replaceData || targetItem.Shares.Count == 0) diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs index 00bd96282c..d8cb6b4b24 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs @@ -125,7 +125,9 @@ namespace MediaBrowser.Providers.Plugins.AudioDb if (string.IsNullOrWhiteSpace(overview)) { - overview = result.strBiographyEN; + overview = string.IsNullOrWhiteSpace(result.strBiographyEN) + ? result.strBiography + : result.strBiographyEN; } item.Overview = (overview ?? string.Empty).StripHtml(); @@ -224,6 +226,8 @@ namespace MediaBrowser.Providers.Plugins.AudioDb public string strTwitter { get; set; } + public string strBiography { get; set; } + public string strBiographyEN { get; set; } public string strBiographyDE { get; set; } diff --git a/MediaBrowser.Providers/Plugins/ComicVine/ComicVineExternalId.cs b/MediaBrowser.Providers/Plugins/ComicVine/ComicVineExternalId.cs new file mode 100644 index 0000000000..8cbd1f89a7 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/ComicVine/ComicVineExternalId.cs @@ -0,0 +1,23 @@ +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; + +namespace MediaBrowser.Providers.Plugins.ComicVine +{ + /// <inheritdoc /> + public class ComicVineExternalId : IExternalId + { + /// <inheritdoc /> + public string ProviderName => "Comic Vine"; + + /// <inheritdoc /> + public string Key => "ComicVine"; + + /// <inheritdoc /> + public ExternalIdMediaType? Type => null; + + /// <inheritdoc /> + public bool Supports(IHasProviderIds item) => item is Book; + } +} diff --git a/MediaBrowser.Providers/Plugins/ComicVine/ComicVineExternalUrlProvider.cs b/MediaBrowser.Providers/Plugins/ComicVine/ComicVineExternalUrlProvider.cs new file mode 100644 index 0000000000..9122399179 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/ComicVine/ComicVineExternalUrlProvider.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; + +namespace MediaBrowser.Providers.Plugins.ComicVine; + +/// <inheritdoc/> +public class ComicVineExternalUrlProvider : IExternalUrlProvider +{ + /// <inheritdoc/> + public string Name => "Comic Vine"; + + /// <inheritdoc /> + public IEnumerable<string> GetExternalUrls(BaseItem item) + { + if (item.TryGetProviderId("ComicVine", out var externalId)) + { + switch (item) + { + case Person: + case Book: + yield return $"https://comicvine.gamespot.com/{externalId}"; + break; + } + } + } +} diff --git a/MediaBrowser.Providers/Plugins/ComicVine/ComicVinePersonExternalId.cs b/MediaBrowser.Providers/Plugins/ComicVine/ComicVinePersonExternalId.cs new file mode 100644 index 0000000000..26b8e11380 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/ComicVine/ComicVinePersonExternalId.cs @@ -0,0 +1,23 @@ +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; + +namespace MediaBrowser.Providers.Plugins.ComicVine +{ + /// <inheritdoc /> + public class ComicVinePersonExternalId : IExternalId + { + /// <inheritdoc /> + public string ProviderName => "Comic Vine"; + + /// <inheritdoc /> + public string Key => "ComicVine"; + + /// <inheritdoc /> + public ExternalIdMediaType? Type => ExternalIdMediaType.Person; + + /// <inheritdoc /> + public bool Supports(IHasProviderIds item) => item is Person; + } +} diff --git a/MediaBrowser.Providers/Plugins/GoogleBooks/GoogleBooksExternalId.cs b/MediaBrowser.Providers/Plugins/GoogleBooks/GoogleBooksExternalId.cs new file mode 100644 index 0000000000..02d3b36974 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/GoogleBooks/GoogleBooksExternalId.cs @@ -0,0 +1,23 @@ +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; + +namespace MediaBrowser.Providers.Plugins.GoogleBooks +{ + /// <inheritdoc /> + public class GoogleBooksExternalId : IExternalId + { + /// <inheritdoc /> + public string ProviderName => "Google Books"; + + /// <inheritdoc /> + public string Key => "GoogleBooks"; + + /// <inheritdoc /> + public ExternalIdMediaType? Type => null; + + /// <inheritdoc /> + public bool Supports(IHasProviderIds item) => item is Book; + } +} diff --git a/MediaBrowser.Providers/Plugins/GoogleBooks/GoogleBooksExternalUrlProvider.cs b/MediaBrowser.Providers/Plugins/GoogleBooks/GoogleBooksExternalUrlProvider.cs new file mode 100644 index 0000000000..95047ee83e --- /dev/null +++ b/MediaBrowser.Providers/Plugins/GoogleBooks/GoogleBooksExternalUrlProvider.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; + +namespace MediaBrowser.Providers.Plugins.GoogleBooks; + +/// <inheritdoc/> +public class GoogleBooksExternalUrlProvider : IExternalUrlProvider +{ + /// <inheritdoc /> + public string Name => "Google Books"; + + /// <inheritdoc /> + public IEnumerable<string> GetExternalUrls(BaseItem item) + { + if (item.TryGetProviderId("GoogleBooks", out var externalId)) + { + if (item is Book) + { + yield return $"https://books.google.com/books?id={externalId}"; + } + } + } +} diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs index 1323d2604a..9df21596c5 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs @@ -22,7 +22,7 @@ namespace MediaBrowser.Providers.Plugins.MusicBrainz; /// <summary> /// MusicBrainz artist provider. /// </summary> -public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, ArtistInfo>, IDisposable +public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, ArtistInfo>, IDisposable, IHasOrder { private readonly ILogger<MusicBrainzArtistProvider> _logger; private Query _musicBrainzQuery; @@ -42,6 +42,10 @@ public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, Ar /// <inheritdoc /> public string Name => "MusicBrainz"; + /// <inheritdoc /> + /// Runs first to populate the MusicBrainz artist ID used by downstream providers. + public int Order => 0; + private void ReloadConfig(object? sender, BasePluginConfiguration e) { var configuration = (PluginConfiguration)e; diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Api/TmdbController.cs b/MediaBrowser.Providers/Plugins/Tmdb/Api/TmdbController.cs index 3eacc4f0f0..590cf795de 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Api/TmdbController.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Api/TmdbController.cs @@ -14,6 +14,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Api [Authorize] [Route("[controller]")] [Produces(MediaTypeNames.Application.Json)] + [ApiExplorerSettings(IgnoreApi = true)] public class TmdbController : ControllerBase { private readonly TmdbClientManager _tmdbClientManager; diff --git a/MediaBrowser.Providers/Subtitles/SubtitleManager.cs b/MediaBrowser.Providers/Subtitles/SubtitleManager.cs index ae5e1090ad..a78ec995cf 100644 --- a/MediaBrowser.Providers/Subtitles/SubtitleManager.cs +++ b/MediaBrowser.Providers/Subtitles/SubtitleManager.cs @@ -7,6 +7,7 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Emby.Naming.Common; using Jellyfin.Extensions; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Entities; @@ -32,6 +33,7 @@ namespace MediaBrowser.Providers.Subtitles private readonly ILibraryMonitor _monitor; private readonly IMediaSourceManager _mediaSourceManager; private readonly ILocalizationManager _localization; + private readonly HashSet<string> _allowedSubtitleFormats; private readonly ISubtitleProvider[] _subtitleProviders; @@ -41,7 +43,8 @@ namespace MediaBrowser.Providers.Subtitles ILibraryMonitor monitor, IMediaSourceManager mediaSourceManager, ILocalizationManager localizationManager, - IEnumerable<ISubtitleProvider> subtitleProviders) + IEnumerable<ISubtitleProvider> subtitleProviders, + NamingOptions namingOptions) { _logger = logger; _fileSystem = fileSystem; @@ -51,6 +54,9 @@ namespace MediaBrowser.Providers.Subtitles _subtitleProviders = subtitleProviders .OrderBy(i => i is IHasOrder hasOrder ? hasOrder.Order : 0) .ToArray(); + _allowedSubtitleFormats = new HashSet<string>( + namingOptions.SubtitleFileExtensions.Select(e => e.TrimStart('.')), + StringComparer.OrdinalIgnoreCase); } /// <inheritdoc /> @@ -171,6 +177,12 @@ namespace MediaBrowser.Providers.Subtitles /// <inheritdoc /> public Task UploadSubtitle(Video video, SubtitleResponse response) { + var format = response.Format; + if (string.IsNullOrEmpty(format) || !_allowedSubtitleFormats.Contains(format)) + { + throw new ArgumentException($"Unsupported subtitle format: '{format}'"); + } + var libraryOptions = BaseItem.LibraryManager.GetLibraryOptions(video); return TrySaveSubtitle(video, libraryOptions, response); } @@ -193,7 +205,13 @@ namespace MediaBrowser.Providers.Subtitles } var savePaths = new List<string>(); - var saveFileName = Path.GetFileNameWithoutExtension(video.Path) + "." + response.Language.ToLowerInvariant(); + var language = response.Language.ToLowerInvariant(); + if (language.AsSpan().IndexOfAny(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) >= 0) + { + throw new ArgumentException("Language contains invalid characters."); + } + + var saveFileName = Path.GetFileNameWithoutExtension(video.Path) + "." + language; if (response.IsForced) { @@ -221,15 +239,22 @@ namespace MediaBrowser.Providers.Subtitles private async Task TrySaveToFiles(Stream stream, List<string> savePaths, Video video, string extension) { + if (!_allowedSubtitleFormats.Contains(extension, StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException($"Invalid subtitle format: {extension}"); + } + List<Exception>? exs = null; foreach (var savePath in savePaths) { - var path = savePath + "." + extension; + var path = Path.GetFullPath(savePath + "." + extension); try { - if (path.StartsWith(video.ContainingFolderPath, StringComparison.Ordinal) - || path.StartsWith(video.GetInternalMetadataPath(), StringComparison.Ordinal)) + var containingFolder = video.ContainingFolderPath + Path.DirectorySeparatorChar; + var metadataFolder = video.GetInternalMetadataPath() + Path.DirectorySeparatorChar; + if (path.StartsWith(containingFolder, StringComparison.Ordinal) + || path.StartsWith(metadataFolder, StringComparison.Ordinal)) { var fileExists = File.Exists(path); var counter = 0; diff --git a/MediaBrowser.Providers/TV/EpisodeMetadataService.cs b/MediaBrowser.Providers/TV/EpisodeMetadataService.cs index 31f0687114..596ca8d201 100644 --- a/MediaBrowser.Providers/TV/EpisodeMetadataService.cs +++ b/MediaBrowser.Providers/TV/EpisodeMetadataService.cs @@ -109,5 +109,15 @@ public class EpisodeMetadataService : MetadataService<Episode, EpisodeInfo> { targetItem.IndexNumberEnd = sourceItem.IndexNumberEnd; } + + // Episode season numbers can be set from path parsing before local metadata is merged. + // When a provider supplies an explicit season, prefer it during provider->temp and temp->item merges, + // but avoid clobbering provider data when existing metadata is backfilled into temp. + if (mergeMetadataSettings + && sourceItem.ParentIndexNumber.HasValue + && targetItem.ParentIndexNumber != sourceItem.ParentIndexNumber) + { + targetItem.ParentIndexNumber = sourceItem.ParentIndexNumber; + } } } diff --git a/MediaBrowser.Providers/Trickplay/TrickplayImagesTask.cs b/MediaBrowser.Providers/Trickplay/TrickplayImagesTask.cs index 81dcbf893e..6ea05c6471 100644 --- a/MediaBrowser.Providers/Trickplay/TrickplayImagesTask.cs +++ b/MediaBrowser.Providers/Trickplay/TrickplayImagesTask.cs @@ -79,6 +79,7 @@ public class TrickplayImagesTask : IScheduledTask IsVirtualItem = false, IsFolder = false, Recursive = true, + IncludeOwnedItems = true, Limit = QueryPageLimit }; diff --git a/MediaBrowser.Providers/Trickplay/TrickplayMoveImagesTask.cs b/MediaBrowser.Providers/Trickplay/TrickplayMoveImagesTask.cs index c0b8a8c75c..03bb5ff397 100644 --- a/MediaBrowser.Providers/Trickplay/TrickplayMoveImagesTask.cs +++ b/MediaBrowser.Providers/Trickplay/TrickplayMoveImagesTask.cs @@ -70,7 +70,8 @@ public class TrickplayMoveImagesTask : IScheduledTask SourceTypes = [SourceType.Library], IsVirtualItem = false, IsFolder = false, - Recursive = true + Recursive = true, + IncludeOwnedItems = true }); var trickplayQuery = new InternalItemsQuery @@ -78,7 +79,8 @@ public class TrickplayMoveImagesTask : IScheduledTask MediaTypes = [MediaType.Video], SourceTypes = [SourceType.Library], IsVirtualItem = false, - IsFolder = false + IsFolder = false, + IncludeOwnedItems = true }; do |
