aboutsummaryrefslogtreecommitdiff
path: root/MediaBrowser.Providers
diff options
context:
space:
mode:
Diffstat (limited to 'MediaBrowser.Providers')
-rw-r--r--MediaBrowser.Providers/Books/Isbn/ISBNExternalId.cs23
-rw-r--r--MediaBrowser.Providers/Books/Isbn/ISBNExternalUrlProvider.cs25
-rw-r--r--MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs2
-rw-r--r--MediaBrowser.Providers/BoxSets/BoxSetMetadataService.cs15
-rw-r--r--MediaBrowser.Providers/Manager/ItemImageProvider.cs4
-rw-r--r--MediaBrowser.Providers/Manager/MetadataService.cs6
-rw-r--r--MediaBrowser.Providers/Manager/ProviderManager.cs153
-rw-r--r--MediaBrowser.Providers/MediaInfo/AudioFileProber.cs144
-rw-r--r--MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs80
-rw-r--r--MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs8
-rw-r--r--MediaBrowser.Providers/MediaInfo/ProbeProvider.cs24
-rw-r--r--MediaBrowser.Providers/MediaInfo/SubtitleScheduledTask.cs56
-rw-r--r--MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs12
-rw-r--r--MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs24
-rw-r--r--MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs6
-rw-r--r--MediaBrowser.Providers/Plugins/ComicVine/ComicVineExternalId.cs23
-rw-r--r--MediaBrowser.Providers/Plugins/ComicVine/ComicVineExternalUrlProvider.cs28
-rw-r--r--MediaBrowser.Providers/Plugins/ComicVine/ComicVinePersonExternalId.cs23
-rw-r--r--MediaBrowser.Providers/Plugins/GoogleBooks/GoogleBooksExternalId.cs23
-rw-r--r--MediaBrowser.Providers/Plugins/GoogleBooks/GoogleBooksExternalUrlProvider.cs25
-rw-r--r--MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs6
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/Api/TmdbController.cs1
-rw-r--r--MediaBrowser.Providers/Subtitles/SubtitleManager.cs35
-rw-r--r--MediaBrowser.Providers/TV/EpisodeMetadataService.cs10
-rw-r--r--MediaBrowser.Providers/Trickplay/TrickplayImagesTask.cs1
-rw-r--r--MediaBrowser.Providers/Trickplay/TrickplayMoveImagesTask.cs6
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