From 37983c943a7f65850cc2bd4854f22dbe2ae92c28 Mon Sep 17 00:00:00 2001 From: Piotr Niełacny Date: Fri, 13 Mar 2026 16:34:44 +0100 Subject: Respect EnableSubtitleExtraction setting in subtitle delivery Wire up EnableSubtitleExtraction config to MediaEncoder.CanExtractSubtitles so the setting is actually respected. Gate subtitle extraction check behind PlayMethod.Transcode since DirectPlay has no competing ffmpeg process. Add parameterized tests for StreamBuilder.GetSubtitleProfile covering text and graphical codecs, profile format matching, and extraction setting behavior. Remove misplaced SubtitleEncoder extraction test. --- MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) (limited to 'MediaBrowser.MediaEncoding/Encoder') diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs index 73c5b88c8b..770965cab3 100644 --- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs +++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs @@ -1331,8 +1331,7 @@ namespace MediaBrowser.MediaEncoding.Encoder public bool CanExtractSubtitles(string codec) { - // TODO is there ever a case when a subtitle can't be extracted?? - return true; + return _configurationManager.GetEncodingOptions().EnableSubtitleExtraction; } private sealed class ProcessWrapper : IDisposable -- cgit v1.2.3 From f5f75ed2e1b10dc1f4e55d5cdd9dd7fd69ea8f2b Mon Sep 17 00:00:00 2001 From: Seven Rats <79296037+sevenrats@users.noreply.github.com> Date: Sun, 3 May 2026 06:18:20 -0400 Subject: feat/audiobook_chapters (#16518) feat/audiobook_chapters --- .../Chapters/ChapterManager.cs | 21 ++- Emby.Server.Implementations/Dto/DtoService.cs | 10 +- .../Library/Resolvers/Books/BookResolver.cs | 2 +- Jellyfin.Api/Controllers/LibraryController.cs | 2 +- Jellyfin.Data/Enums/PersonKind.cs | 7 +- .../Chapters/IChapterManager.cs | 11 +- MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs | 2 +- .../Probing/ProbeResultNormalizer.cs | 10 +- .../Books/OpenPackagingFormat/OpfReader.cs | 2 + .../MediaInfo/AudioFileProber.cs | 144 +++++++++++++++++---- MediaBrowser.Providers/MediaInfo/ProbeProvider.cs | 3 +- 11 files changed, 167 insertions(+), 47 deletions(-) (limited to 'MediaBrowser.MediaEncoding/Encoder') diff --git a/Emby.Server.Implementations/Chapters/ChapterManager.cs b/Emby.Server.Implementations/Chapters/ChapterManager.cs index d09ed30ae3..79ab29b87c 100644 --- a/Emby.Server.Implementations/Chapters/ChapterManager.cs +++ b/Emby.Server.Implementations/Chapters/ChapterManager.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using Jellyfin.Extensions; using MediaBrowser.Controller.Chapters; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; @@ -232,12 +233,22 @@ public class ChapterManager : IChapterManager } /// - public void SaveChapters(Video video, IReadOnlyList chapters) + public bool Supports(BaseItem item) + => item is Video or Audio; + + /// + public void SaveChapters(BaseItem item, IReadOnlyList chapters) { - // Remove any chapters that are outside of the runtime of the video - var validChapters = chapters.Where(c => c.StartPositionTicks < video.RunTimeTicks).ToList(); - _chapterRepository.SaveChapters(video.Id, validChapters); - } + if (!Supports(item)) + { + _logger.LogWarning("Attempted to save chapters for unsupported item type {Type}: {Name} ({Id})", item.GetType().Name, item.Name, item.Id); + return; + } + + // Remove any chapters that are outside of the runtime of the item + var validChapters = chapters.Where(c => c.StartPositionTicks < item.RunTimeTicks).ToList(); + _chapterRepository.SaveChapters(item.Id, validChapters); +} /// public ChapterInfo? GetChapter(Guid baseItemId, int index) diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index 08ced387b8..9f62ad5a91 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -1132,11 +1132,6 @@ namespace Emby.Server.Implementations.Dto } } - if (options.ContainsField(ItemFields.Chapters)) - { - dto.Chapters = _chapterManager.GetChapters(item.Id).ToList(); - } - if (options.ContainsField(ItemFields.Trickplay)) { var trickplay = _trickplayManager.GetTrickplayManifest(item).GetAwaiter().GetResult(); @@ -1150,6 +1145,11 @@ namespace Emby.Server.Implementations.Dto dto.ExtraType = video.ExtraType; } + if (options.ContainsField(ItemFields.Chapters)) + { + dto.Chapters = _chapterManager.GetChapters(item.Id).ToList(); + } + if (options.ContainsField(ItemFields.MediaStreams)) { // Add VideoInfo diff --git a/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs index 3ee1c757f2..1e885aad6e 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs @@ -16,7 +16,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Books { public class BookResolver : ItemResolver { - private readonly string[] _validExtensions = { ".azw", ".azw3", ".cb7", ".cbr", ".cbt", ".cbz", ".epub", ".mobi", ".pdf", ".m4b", ".m4a", ".aac", ".flac", ".mp3", ".opus" }; + private readonly string[] _validExtensions = { ".azw", ".azw3", ".cb7", ".cbr", ".cbt", ".cbz", ".epub", ".mobi", ".pdf" }; protected override Book Resolve(ItemResolveArgs args) { diff --git a/Jellyfin.Api/Controllers/LibraryController.cs b/Jellyfin.Api/Controllers/LibraryController.cs index 6ef40a1898..3e483d09df 100644 --- a/Jellyfin.Api/Controllers/LibraryController.cs +++ b/Jellyfin.Api/Controllers/LibraryController.cs @@ -976,7 +976,7 @@ public class LibraryController : BaseJellyfinApiController CollectionType.playlists => new[] { "Playlist" }, CollectionType.movies => new[] { "Movie" }, CollectionType.tvshows => new[] { "Series", "Season", "Episode" }, - CollectionType.books => new[] { "Book" }, + CollectionType.books => new[] { "Book", "AudioBook" }, CollectionType.music => new[] { "MusicArtist", "MusicAlbum", "Audio", "MusicVideo" }, CollectionType.homevideos => new[] { "Video", "Photo" }, CollectionType.photos => new[] { "Video", "Photo" }, diff --git a/Jellyfin.Data/Enums/PersonKind.cs b/Jellyfin.Data/Enums/PersonKind.cs index 29308789a0..54eac5ff3b 100644 --- a/Jellyfin.Data/Enums/PersonKind.cs +++ b/Jellyfin.Data/Enums/PersonKind.cs @@ -129,5 +129,10 @@ public enum PersonKind /// /// A person who renders a text from one language into another. /// - Translator + Translator, + + /// + /// A person who narrates a book or other work. + /// + Narrator } diff --git a/MediaBrowser.Controller/Chapters/IChapterManager.cs b/MediaBrowser.Controller/Chapters/IChapterManager.cs index 25656fd625..edc20205aa 100644 --- a/MediaBrowser.Controller/Chapters/IChapterManager.cs +++ b/MediaBrowser.Controller/Chapters/IChapterManager.cs @@ -13,12 +13,19 @@ namespace MediaBrowser.Controller.Chapters; /// public interface IChapterManager { + /// + /// Gets a value indicating whether the specified item type is supported for chapter operations. + /// + /// The item to check. + /// true if the item type supports chapters; otherwise, false. + bool Supports(BaseItem item); + /// /// Saves the chapters. /// - /// The video. + /// The item. /// The set of chapters. - void SaveChapters(Video video, IReadOnlyList chapters); + void SaveChapters(BaseItem item, IReadOnlyList chapters); /// /// Gets a single chapter of a BaseItem on a specific index. diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs index 770965cab3..f34e911a05 100644 --- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs +++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs @@ -414,7 +414,7 @@ namespace MediaBrowser.MediaEncoding.Encoder /// public Task GetMediaInfo(MediaInfoRequest request, CancellationToken cancellationToken) { - var extractChapters = request.MediaType == DlnaProfileType.Video && request.ExtractChapters; + var extractChapters = request.ExtractChapters; var extraArgs = GetExtraArguments(request); return GetMediaInfoInternal( diff --git a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs index 3c6a03713f..a4d17e4f9d 100644 --- a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs +++ b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs @@ -194,6 +194,11 @@ namespace MediaBrowser.MediaEncoding.Probing info.ProductionYear = info.PremiereDate.Value.Year; } + if (data.Chapters is not null) + { + info.Chapters = data.Chapters.Select(GetChapterInfo).ToArray(); + } + // Set mediaType-specific metadata if (isAudio) { @@ -238,11 +243,6 @@ namespace MediaBrowser.MediaEncoding.Probing FetchWtvInfo(info, data); - if (data.Chapters is not null) - { - info.Chapters = data.Chapters.Select(GetChapterInfo).ToArray(); - } - ExtractTimestamp(info); if (tags.TryGetValue("stereo_mode", out var stereoMode) && string.Equals(stereoMode, "left_right", StringComparison.OrdinalIgnoreCase)) 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/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; /// /// Initializes a new instance of the class. @@ -49,6 +51,7 @@ namespace MediaBrowser.Providers.MediaInfo /// Instance of the interface. /// Instance of the interface. /// Instance of the . + /// Instance of the interface. public AudioFileProber( ILogger 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); + } } /// @@ -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(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/ProbeProvider.cs b/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs index c3ff26202f..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); } /// -- cgit v1.2.3