feat/audiobook_chapters (#16518)

feat/audiobook_chapters
This commit is contained in:
Seven Rats
2026-05-03 06:18:20 -04:00
committed by GitHub
parent df6f706c2f
commit f5f75ed2e1
11 changed files with 171 additions and 51 deletions

View File

@@ -7,6 +7,7 @@ using System.Threading.Tasks;
using Jellyfin.Extensions; using Jellyfin.Extensions;
using MediaBrowser.Controller.Chapters; using MediaBrowser.Controller.Chapters;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.IO; using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.MediaEncoding;
@@ -232,12 +233,22 @@ public class ChapterManager : IChapterManager
} }
/// <inheritdoc /> /// <inheritdoc />
public void SaveChapters(Video video, IReadOnlyList<ChapterInfo> chapters) public bool Supports(BaseItem item)
=> item is Video or Audio;
/// <inheritdoc />
public void SaveChapters(BaseItem item, IReadOnlyList<ChapterInfo> chapters)
{ {
// Remove any chapters that are outside of the runtime of the video if (!Supports(item))
var validChapters = chapters.Where(c => c.StartPositionTicks < video.RunTimeTicks).ToList(); {
_chapterRepository.SaveChapters(video.Id, validChapters); _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);
}
/// <inheritdoc /> /// <inheritdoc />
public ChapterInfo? GetChapter(Guid baseItemId, int index) public ChapterInfo? GetChapter(Guid baseItemId, int index)

View File

@@ -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)) if (options.ContainsField(ItemFields.Trickplay))
{ {
var trickplay = _trickplayManager.GetTrickplayManifest(item).GetAwaiter().GetResult(); var trickplay = _trickplayManager.GetTrickplayManifest(item).GetAwaiter().GetResult();
@@ -1150,6 +1145,11 @@ namespace Emby.Server.Implementations.Dto
dto.ExtraType = video.ExtraType; dto.ExtraType = video.ExtraType;
} }
if (options.ContainsField(ItemFields.Chapters))
{
dto.Chapters = _chapterManager.GetChapters(item.Id).ToList();
}
if (options.ContainsField(ItemFields.MediaStreams)) if (options.ContainsField(ItemFields.MediaStreams))
{ {
// Add VideoInfo // Add VideoInfo

View File

@@ -16,7 +16,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Books
{ {
public class BookResolver : ItemResolver<Book> public class BookResolver : ItemResolver<Book>
{ {
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) protected override Book Resolve(ItemResolveArgs args)
{ {

View File

@@ -976,7 +976,7 @@ public class LibraryController : BaseJellyfinApiController
CollectionType.playlists => new[] { "Playlist" }, CollectionType.playlists => new[] { "Playlist" },
CollectionType.movies => new[] { "Movie" }, CollectionType.movies => new[] { "Movie" },
CollectionType.tvshows => new[] { "Series", "Season", "Episode" }, CollectionType.tvshows => new[] { "Series", "Season", "Episode" },
CollectionType.books => new[] { "Book" }, CollectionType.books => new[] { "Book", "AudioBook" },
CollectionType.music => new[] { "MusicArtist", "MusicAlbum", "Audio", "MusicVideo" }, CollectionType.music => new[] { "MusicArtist", "MusicAlbum", "Audio", "MusicVideo" },
CollectionType.homevideos => new[] { "Video", "Photo" }, CollectionType.homevideos => new[] { "Video", "Photo" },
CollectionType.photos => new[] { "Video", "Photo" }, CollectionType.photos => new[] { "Video", "Photo" },

View File

@@ -129,5 +129,10 @@ public enum PersonKind
/// <summary> /// <summary>
/// A person who renders a text from one language into another. /// A person who renders a text from one language into another.
/// </summary> /// </summary>
Translator Translator,
/// <summary>
/// A person who narrates a book or other work.
/// </summary>
Narrator
} }

View File

@@ -13,12 +13,19 @@ namespace MediaBrowser.Controller.Chapters;
/// </summary> /// </summary>
public interface IChapterManager public interface IChapterManager
{ {
/// <summary>
/// Gets a value indicating whether the specified item type is supported for chapter operations.
/// </summary>
/// <param name="item">The item to check.</param>
/// <returns><c>true</c> if the item type supports chapters; otherwise, <c>false</c>.</returns>
bool Supports(BaseItem item);
/// <summary> /// <summary>
/// Saves the chapters. /// Saves the chapters.
/// </summary> /// </summary>
/// <param name="video">The video.</param> /// <param name="item">The item.</param>
/// <param name="chapters">The set of chapters.</param> /// <param name="chapters">The set of chapters.</param>
void SaveChapters(Video video, IReadOnlyList<ChapterInfo> chapters); void SaveChapters(BaseItem item, IReadOnlyList<ChapterInfo> chapters);
/// <summary> /// <summary>
/// Gets a single chapter of a BaseItem on a specific index. /// Gets a single chapter of a BaseItem on a specific index.

View File

@@ -414,7 +414,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
/// <inheritdoc /> /// <inheritdoc />
public Task<MediaInfo> GetMediaInfo(MediaInfoRequest request, CancellationToken cancellationToken) public Task<MediaInfo> GetMediaInfo(MediaInfoRequest request, CancellationToken cancellationToken)
{ {
var extractChapters = request.MediaType == DlnaProfileType.Video && request.ExtractChapters; var extractChapters = request.ExtractChapters;
var extraArgs = GetExtraArguments(request); var extraArgs = GetExtraArguments(request);
return GetMediaInfoInternal( return GetMediaInfoInternal(

View File

@@ -194,6 +194,11 @@ namespace MediaBrowser.MediaEncoding.Probing
info.ProductionYear = info.PremiereDate.Value.Year; info.ProductionYear = info.PremiereDate.Value.Year;
} }
if (data.Chapters is not null)
{
info.Chapters = data.Chapters.Select(GetChapterInfo).ToArray();
}
// Set mediaType-specific metadata // Set mediaType-specific metadata
if (isAudio) if (isAudio)
{ {
@@ -238,11 +243,6 @@ namespace MediaBrowser.MediaEncoding.Probing
FetchWtvInfo(info, data); FetchWtvInfo(info, data);
if (data.Chapters is not null)
{
info.Chapters = data.Chapters.Select(GetChapterInfo).ToArray();
}
ExtractTimestamp(info); ExtractTimestamp(info);
if (tags.TryGetValue("stereo_mode", out var stereoMode) && string.Equals(stereoMode, "left_right", StringComparison.OrdinalIgnoreCase)) if (tags.TryGetValue("stereo_mode", out var stereoMode) && string.Equals(stereoMode, "left_right", StringComparison.OrdinalIgnoreCase))

View File

@@ -260,6 +260,8 @@ namespace MediaBrowser.Providers.Books.OpenPackagingFormat
return PersonKind.Lyricist; return PersonKind.Lyricist;
case "mus": case "mus":
return PersonKind.AlbumArtist; return PersonKind.AlbumArtist;
case "nrt":
return PersonKind.Narrator;
case "oth": case "oth":
return PersonKind.Unknown; return PersonKind.Unknown;
case "trl": case "trl":

View File

@@ -7,6 +7,7 @@ using System.Threading.Tasks;
using ATL; using ATL;
using Jellyfin.Data.Enums; using Jellyfin.Data.Enums;
using Jellyfin.Extensions; using Jellyfin.Extensions;
using MediaBrowser.Controller.Chapters;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
@@ -38,6 +39,7 @@ namespace MediaBrowser.Providers.MediaInfo
private readonly LyricResolver _lyricResolver; private readonly LyricResolver _lyricResolver;
private readonly ILyricManager _lyricManager; private readonly ILyricManager _lyricManager;
private readonly IMediaStreamRepository _mediaStreamRepository; private readonly IMediaStreamRepository _mediaStreamRepository;
private readonly IChapterManager _chapterManager;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="AudioFileProber"/> class. /// 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="lyricResolver">Instance of the <see cref="LyricResolver"/> interface.</param>
/// <param name="lyricManager">Instance of the <see cref="ILyricManager"/> 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="mediaStreamRepository">Instance of the <see cref="IMediaStreamRepository"/>.</param>
/// <param name="chapterManager">Instance of the <see cref="IChapterManager"/> interface.</param>
public AudioFileProber( public AudioFileProber(
ILogger<AudioFileProber> logger, ILogger<AudioFileProber> logger,
IMediaSourceManager mediaSourceManager, IMediaSourceManager mediaSourceManager,
@@ -56,7 +59,8 @@ namespace MediaBrowser.Providers.MediaInfo
ILibraryManager libraryManager, ILibraryManager libraryManager,
LyricResolver lyricResolver, LyricResolver lyricResolver,
ILyricManager lyricManager, ILyricManager lyricManager,
IMediaStreamRepository mediaStreamRepository) IMediaStreamRepository mediaStreamRepository,
IChapterManager chapterManager)
{ {
_mediaEncoder = mediaEncoder; _mediaEncoder = mediaEncoder;
_libraryManager = libraryManager; _libraryManager = libraryManager;
@@ -65,6 +69,7 @@ namespace MediaBrowser.Providers.MediaInfo
_lyricResolver = lyricResolver; _lyricResolver = lyricResolver;
_lyricManager = lyricManager; _lyricManager = lyricManager;
_mediaStreamRepository = mediaStreamRepository; _mediaStreamRepository = mediaStreamRepository;
_chapterManager = chapterManager;
ATL.Settings.DisplayValueSeparator = InternalValueSeparator; ATL.Settings.DisplayValueSeparator = InternalValueSeparator;
ATL.Settings.UseFileNameWhenNoTitle = false; ATL.Settings.UseFileNameWhenNoTitle = false;
ATL.Settings.ID3v2_separatev2v3Values = false; ATL.Settings.ID3v2_separatev2v3Values = false;
@@ -99,6 +104,7 @@ namespace MediaBrowser.Providers.MediaInfo
new MediaInfoRequest new MediaInfoRequest
{ {
MediaType = DlnaProfileType.Audio, MediaType = DlnaProfileType.Audio,
ExtractChapters = item is AudioBook,
MediaSource = new MediaSourceInfo MediaSource = new MediaSourceInfo
{ {
Path = path, Path = path,
@@ -151,6 +157,11 @@ namespace MediaBrowser.Providers.MediaInfo
audio.HasLyrics = mediaStreams.Any(s => s.Type == MediaStreamType.Lyric); audio.HasLyrics = mediaStreams.Any(s => s.Type == MediaStreamType.Lyric);
_mediaStreamRepository.SaveMediaStreams(audio.Id, mediaStreams, cancellationToken); _mediaStreamRepository.SaveMediaStreams(audio.Id, mediaStreams, cancellationToken);
if (audio is AudioBook && mediaInfo.Chapters is { Length: > 0 })
{
_chapterManager.SaveChapters(audio, mediaInfo.Chapters);
}
} }
/// <summary> /// <summary>
@@ -212,18 +223,6 @@ namespace MediaBrowser.Providers.MediaInfo
albumArtists = albumArtists.SelectMany(a => SplitWithCustomDelimiter(a, libraryOptions.GetCustomTagDelimiters(), libraryOptions.DelimiterWhitelist)).ToArray(); 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; string[]? performers = null;
if (libraryOptions.PreferNonstandardArtistsTag) if (libraryOptions.PreferNonstandardArtistsTag)
{ {
@@ -244,32 +243,100 @@ namespace MediaBrowser.Providers.MediaInfo
performers = performers.SelectMany(p => SplitWithCustomDelimiter(p, libraryOptions.GetCustomTagDelimiters(), libraryOptions.DelimiterWhitelist)).ToArray(); performers = performers.SelectMany(p => SplitWithCustomDelimiter(p, libraryOptions.GetCustomTagDelimiters(), libraryOptions.DelimiterWhitelist)).ToArray();
} }
foreach (var performer in performers) var isAudioBook = audio is AudioBook;
{
if (!string.IsNullOrWhiteSpace(performer))
{
PeopleHelper.AddPerson(people, new PersonInfo
{
Name = performer,
Type = PersonKind.Artist
});
}
}
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<string>(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 PeopleHelper.AddPerson(people, new PersonInfo
{ {
Name = composer, Name = performer.Trim(),
Type = PersonKind.Composer 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); _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); TryGetSanitizedAdditionalFields(track, "REPLAYGAIN_TRACK_GAIN", out var trackGainTag);
if (trackGainTag is not null) if (trackGainTag is not null)

View File

@@ -110,7 +110,8 @@ namespace MediaBrowser.Providers.MediaInfo
libraryManager, libraryManager,
_lyricResolver, _lyricResolver,
lyricManager, lyricManager,
mediaStreamRepository); mediaStreamRepository,
chapterManager);
} }
/// <inheritdoc /> /// <inheritdoc />