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: [PATCH] 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 +-
.../Encoder/MediaEncoder.cs | 2 +-
.../Probing/ProbeResultNormalizer.cs | 10 +-
.../Books/OpenPackagingFormat/OpfReader.cs | 2 +
.../MediaInfo/AudioFileProber.cs | 152 ++++++++++++++----
.../MediaInfo/ProbeProvider.cs | 3 +-
11 files changed, 171 insertions(+), 51 deletions(-)
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,32 +243,100 @@ namespace MediaBrowser.Providers.MediaInfo
performers = performers.SelectMany(p => SplitWithCustomDelimiter(p, libraryOptions.GetCustomTagDelimiters(), libraryOptions.DelimiterWhitelist)).ToArray();
}
- foreach (var performer in performers)
- {
- if (!string.IsNullOrWhiteSpace(performer))
- {
- PeopleHelper.AddPerson(people, new PersonInfo
- {
- Name = performer,
- Type = PersonKind.Artist
- });
- }
- }
+ var isAudioBook = audio is AudioBook;
- if (!string.IsNullOrWhiteSpace(trackComposer))
+ if (isAudioBook)
{
- foreach (var composer in trackComposer.Split(InternalValueSeparator))
+ // 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)
{
- if (!string.IsNullOrWhiteSpace(composer))
+ if (!string.IsNullOrWhiteSpace(author))
+ {
+ 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
+ });
+ }
+ }
+ }
+
+ // 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 = composer,
- Type = PersonKind.Composer
+ Name = performer.Trim(),
+ Type = PersonKind.Actor
});
}
}
}
+ else
+ {
+ // 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(performer))
+ {
+ PeopleHelper.AddPerson(people, new PersonInfo
+ {
+ 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);
}
///