Initial minimal draft of multi-part AudioBook support

pull/11517/head
Jacob Weiss 1 month ago
parent 5612cb8178
commit 3ae08b4f06

@ -22,6 +22,7 @@ using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Drawing;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Entities.AudioBooks;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Extensions;
@ -239,7 +240,6 @@ namespace Emby.Server.Implementations.Data
private static readonly BaseItemKind[] _seriesTypes = new[]
{
BaseItemKind.Book,
BaseItemKind.AudioBook,
BaseItemKind.Episode,
BaseItemKind.Season
};
@ -264,6 +264,7 @@ namespace Emby.Server.Implementations.Data
{ BaseItemKind.AggregateFolder, typeof(AggregateFolder).FullName },
{ BaseItemKind.Audio, typeof(Audio).FullName },
{ BaseItemKind.AudioBook, typeof(AudioBook).FullName },
{ BaseItemKind.AudioBookFile, typeof(AudioBookFile).FullName },
{ BaseItemKind.BasePluginFolder, typeof(BasePluginFolder).FullName },
{ BaseItemKind.Book, typeof(Book).FullName },
{ BaseItemKind.BoxSet, typeof(BoxSet).FullName },
@ -1295,6 +1296,7 @@ namespace Emby.Server.Implementations.Data
&& type != typeof(Book)
&& type != typeof(LiveTvProgram)
&& type != typeof(AudioBook)
&& type != typeof(AudioBookFile)
&& type != typeof(Audio)
&& type != typeof(MusicAlbum);
}

@ -0,0 +1,19 @@
#pragma warning disable CS1591
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Drawing;
using MediaBrowser.Controller.Entities.AudioBooks;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.IO;
namespace Emby.Server.Implementations.Images
{
public class AudioBookImageProvider : BaseFolderImageProvider<AudioBook>
{
public AudioBookImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor, ILibraryManager libraryManager)
: base(fileSystem, providerManager, applicationPaths, imageProcessor, libraryManager)
{
}
}
}

@ -45,7 +45,7 @@ namespace Emby.Server.Implementations.Images
includeItemTypes = new[] { BaseItemKind.MusicVideo };
break;
case CollectionType.books:
includeItemTypes = new[] { BaseItemKind.Book, BaseItemKind.AudioBook };
includeItemTypes = new[] { BaseItemKind.Book, BaseItemKind.AudioBook, BaseItemKind.AudioBookFile };
break;
case CollectionType.boxsets:
includeItemTypes = new[] { BaseItemKind.BoxSet };

@ -472,7 +472,9 @@ namespace Emby.Server.Implementations.Library
}
/// <summary>
/// Resolves the item.
/// Iterate over list of resolvers attempting to resolve for file system entity
/// described by args, using the first result of the resolver that is not null or the default
/// (folder?) if all results are null.
/// </summary>
/// <param name="args">The args.</param>
/// <param name="resolvers">The resolvers.</param>
@ -1058,7 +1060,7 @@ namespace Emby.Server.Implementations.Library
private async Task PerformLibraryValidation(IProgress<double> progress, CancellationToken cancellationToken)
{
_logger.LogInformation("Validating media library");
_logger.LogInformation("PerformLibraryValidation");
await ValidateTopLibraryFolders(cancellationToken).ConfigureAwait(false);

@ -5,9 +5,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Emby.Naming.Audio;
using Emby.Naming.AudioBook;
using Emby.Naming.Common;
using Emby.Naming.Video;
using Jellyfin.Data.Enums;
@ -43,29 +41,6 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio
CollectionType? collectionType,
IDirectoryService directoryService)
{
var result = ResolveMultipleInternal(parent, files, collectionType);
if (result is not null)
{
foreach (var item in result.Items)
{
SetInitialItemValues((MediaBrowser.Controller.Entities.Audio.Audio)item, null);
}
}
return result;
}
private MultiItemResolverResult ResolveMultipleInternal(
Folder parent,
List<FileSystemMetadata> files,
CollectionType? collectionType)
{
if (collectionType == CollectionType.books)
{
return ResolveMultipleAudio(parent, files, true);
}
return null;
}
@ -80,16 +55,9 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio
var collectionType = args.GetCollectionType();
var isBooksCollectionType = collectionType == CollectionType.books;
if (args.IsDirectory)
{
if (!isBooksCollectionType)
{
return null;
}
return FindAudioBook(args, false);
return null;
}
if (AudioFileParser.IsAudioFile(args.Path, _namingOptions))
@ -121,10 +89,6 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio
{
item = new MediaBrowser.Controller.Entities.Audio.Audio();
}
else if (isBooksCollectionType)
{
item = new AudioBook();
}
if (item is not null)
{
@ -138,103 +102,5 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio
return null;
}
private AudioBook FindAudioBook(ItemResolveArgs args, bool parseName)
{
// TODO: Allow GetMultiDiscMovie in here
var result = ResolveMultipleAudio(args.Parent, args.GetActualFileSystemChildren(), parseName);
if (result is null || result.Items.Count != 1 || result.Items[0] is not AudioBook item)
{
return null;
}
// If we were supporting this we'd be checking filesFromOtherItems
item.IsInMixedFolder = false;
item.Name = Path.GetFileName(item.ContainingFolderPath);
return item;
}
private MultiItemResolverResult ResolveMultipleAudio(Folder parent, IEnumerable<FileSystemMetadata> fileSystemEntries, bool parseName)
{
var files = new List<FileSystemMetadata>();
var leftOver = new List<FileSystemMetadata>();
// Loop through each child file/folder and see if we find a video
foreach (var child in fileSystemEntries)
{
if (child.IsDirectory)
{
leftOver.Add(child);
}
else
{
files.Add(child);
}
}
var resolver = new AudioBookListResolver(_namingOptions);
var resolverResult = resolver.Resolve(files).ToList();
var result = new MultiItemResolverResult
{
ExtraFiles = leftOver,
Items = new List<BaseItem>()
};
var isInMixedFolder = resolverResult.Count > 1 || (parent is not null && parent.IsTopParent);
foreach (var resolvedItem in resolverResult)
{
if (resolvedItem.Files.Count > 1)
{
// For now, until we sort out naming for multi-part books
continue;
}
// Until multi-part books are handled letting files stack hides them from browsing in the client
if (resolvedItem.Files.Count == 0 || resolvedItem.Extras.Count > 0 || resolvedItem.AlternateVersions.Count > 0)
{
continue;
}
var firstMedia = resolvedItem.Files[0];
var libraryItem = new AudioBook
{
Path = firstMedia.Path,
IsInMixedFolder = isInMixedFolder,
ProductionYear = resolvedItem.Year,
Name = parseName ?
resolvedItem.Name :
Path.GetFileNameWithoutExtension(firstMedia.Path),
// AdditionalParts = resolvedItem.Files.Skip(1).Select(i => i.Path).ToArray(),
// LocalAlternateVersions = resolvedItem.AlternateVersions.Select(i => i.Path).ToArray()
};
result.Items.Add(libraryItem);
}
result.ExtraFiles.AddRange(files.Where(i => !ContainsFile(resolverResult, i)));
return result;
}
private static bool ContainsFile(IEnumerable<AudioBookInfo> result, FileSystemMetadata file)
{
return result.Any(i => ContainsFile(i, file));
}
private static bool ContainsFile(AudioBookInfo result, FileSystemMetadata file)
{
return result.Files.Any(i => ContainsFile(i, file)) ||
result.AlternateVersions.Any(i => ContainsFile(i, file)) ||
result.Extras.Any(i => ContainsFile(i, file));
}
private static bool ContainsFile(AudioBookFileInfo result, FileSystemMetadata file)
{
return string.Equals(result.Path, file.FullName, StringComparison.OrdinalIgnoreCase);
}
}
}

@ -0,0 +1,99 @@
#nullable disable
#pragma warning disable SA1642
using System;
using System.IO;
using System.Linq;
using Emby.Naming.Common;
using Jellyfin.Extensions;
using MediaBrowser.Controller.Entities.AudioBooks;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Controller.Resolvers;
using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.Library.Resolvers.AudioBooks
{
/// <summary>
/// Class AudioBookFileResolver.
/// NOTE: Should these files be moved to MediaBrowser.Controller.Resolvers.(AudioBooks?) directory?.
/// </summary>
public class AudioBookFileResolver : BaseAudioBookResolver<AudioBookFile>
{
private readonly ILogger<AudioBookFileResolver> _logger;
private readonly NamingOptions _namingOptions;
/// <summary>
/// Initializes a new instance of the AudioBookFileResolver class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="namingOptions">Options for naming.</param>
/// <param name="directoryService">Some other thing.</param>
public AudioBookFileResolver(ILogger<AudioBookFileResolver> logger, NamingOptions namingOptions, IDirectoryService directoryService)
: base(logger, namingOptions, directoryService)
{
_logger = logger;
_namingOptions = namingOptions;
}
/// <summary>
/// Gets the priority.
/// </summary>
/// <value>The priority.</value>
public override ResolverPriority Priority => ResolverPriority.Fourth;
/// <summary>
/// Resolves the specified args.
/// </summary>
/// <param name="args">The args.</param>
/// <returns>Episode.</returns>
protected override AudioBookFile Resolve(ItemResolveArgs args)
{
if (!IsValid(args))
{
return null;
}
return ResolveAudioBookFile<AudioBookFile>(args, true);
}
private bool IsValid(ItemResolveArgs args)
{
if (args is null)
{
return false;
}
var parent = args.Parent;
if (parent is null || args.IsDirectory)
{
return false;
}
if (parent.GetType() != typeof(AudioBook))
{
return false;
}
if (args.CollectionType != Jellyfin.Data.Enums.CollectionType.books)
{
return false;
}
var path = args.Path;
if (path.Length == 0 || Path.GetFileNameWithoutExtension(path).Length == 0)
{
return false;
}
if (!_namingOptions.AudioFileExtensions.Contains(Path.GetExtension(args.Path), StringComparison.OrdinalIgnoreCase))
{
return false;
}
return true;
}
}
}

@ -0,0 +1,152 @@
#nullable disable
#pragma warning disable CS1591
using System.IO;
using System.Text.RegularExpressions;
using Emby.Naming.Audio;
using Emby.Naming.Common;
using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Entities.AudioBooks;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.Library.Resolvers.AudioBooks
{
/// <summary>
/// Resolve an AudioBook instance from a file system path.
/// Resolve returns a partially initialized AudioBook instance with name (title), year, and path.
/// Rely on audio file metadata for majority of other information.
/// NOTE: Should these files be moved to MediaBrowser.Controller.Resolvers.(AudioBooks?) directory?.
/// </summary>
public class AudioBookResolver : GenericFolderResolver<AudioBook>
{
private readonly ILogger<AudioBookResolver> _logger;
private readonly NamingOptions _namingOptions;
private readonly IDirectoryService _directoryService;
public AudioBookResolver(ILogger<AudioBookResolver> logger, NamingOptions namingOptions, IDirectoryService directoryService)
{
_logger = logger;
_namingOptions = namingOptions;
_directoryService = directoryService;
}
// <summary>
// Gets the priority.
// </summary>
// <value>The priority.</value>
// public override ResolverPriority Priority => ResolverPriority.Fourth;
/// <summary>
/// Attempt to resolve a single audio book from args.Path.
/// Expected cases:
/// Path is an AudioBook directory: Resolve path as AudioBook and children as AudioBookFiles.
/// Path is single-file AudioBook: Resolve path as AudioBook w/ AudioBookFile of path.
/// </summary>
/// <param name="args">The args.</param>
/// <returns>Entities.AudioBook.</returns>
protected override AudioBook Resolve(ItemResolveArgs args)
{
// We only care about audiobooks
// TODO: Change this to new CollectionType.audiobooks?
if (args.GetCollectionType() != CollectionType.books)
{
return null;
}
// TODO: I don't know if there's a valid case where parent is null and CollectionType is books
if (args.Parent != null && args.Parent.GetType() != typeof(MediaBrowser.Controller.Entities.Folder))
{
return null;
}
var audioBook = new AudioBook();
// TODO: Get title from path...
var bookFileName = Path.GetFileNameWithoutExtension(args.Path);
var name = MatchFor(bookFileName, "name");
if (name == null)
{
name = bookFileName;
}
var year = MatchFor(bookFileName, "year");
// Process directory to resolve single audio book
if (args.IsDirectory)
{
// Majority children must be audio files
int audioFiles = 0;
int nonAudioFiles = 0;
// TODO: What's the fancy C# way to do this?
for (var i = 0; i < args.FileSystemChildren.Length; i++)
{
var child = args.FileSystemChildren[i];
if (AudioFileParser.IsAudioFile(child.FullName, _namingOptions))
{
audioFiles += 1;
}
else
{
nonAudioFiles += 1;
}
}
if (audioFiles == 0 || nonAudioFiles / audioFiles > 1)
{
_logger.LogDebug("Less than half of the files in {0} were audio files, probably not an AudioBook directory", args.Path);
return null;
}
// TODO: Must not contain sub directories
return audioBook;
}
// Resolve a single-file AudioBook
if (AudioFileParser.IsAudioFile(args.Path, _namingOptions))
{
// Create AudioBookFile
var audioBookFile = new AudioBookFile
{
Path = args.Path
};
// Set as child of audioBook
// TODO: Is making both calls redundant?
audioBookFile.SetParent(audioBook);
audioBook.AddChild(audioBookFile);
var extension = Path.GetExtension(args.Path);
audioBookFile.Container = extension.TrimStart('.');
audioBookFile.Chapter = 0;
// audioBookFile.path = args.path; ?
return audioBook;
}
return null;
}
// TODO: Put this somewhere better where both AudioBook classes can access
private string MatchFor(string name, string matchGroup)
{
foreach (var expression in _namingOptions.AudioBookNamesExpressions)
{
var match = Regex.Match(name, expression, RegexOptions.IgnoreCase);
if (match.Success)
{
var value = match.Groups[matchGroup];
if (value.Success)
{
return value.Value;
}
}
}
return null;
}
}
}

@ -0,0 +1,101 @@
#nullable disable
#pragma warning disable CS1591
using System;
using System.Globalization;
using System.IO;
using System.Text.RegularExpressions;
using Emby.Naming.Common;
using MediaBrowser.Controller.Entities.AudioBooks;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.Library.Resolvers.AudioBooks
{
/// <summary>
/// Resolves a Path into a Video or Video subclass.
/// </summary>
/// <typeparam name="T">The type of item to resolve.</typeparam>
public abstract class BaseAudioBookResolver<T> : MediaBrowser.Controller.Resolvers.ItemResolver<T>
where T : AudioBookFile, new()
{
private readonly ILogger _logger;
protected BaseAudioBookResolver(ILogger logger, NamingOptions namingOptions, IDirectoryService directoryService)
{
_logger = logger;
NamingOptions = namingOptions;
DirectoryService = directoryService;
}
protected NamingOptions NamingOptions { get; }
protected IDirectoryService DirectoryService { get; }
/// <summary>
/// Resolves the specified args.
/// </summary>
/// <param name="args">The args.</param>
/// <returns>`0.</returns>
protected override T Resolve(ItemResolveArgs args)
{
return ResolveAudioBookFile<T>(args, false);
}
/// <summary>
/// Resolves the video.
/// </summary>
/// <typeparam name="TAudioBookType">The type of the T video type.</typeparam>
/// <param name="args">The args.</param>
/// <param name="parseName">if set to <c>true</c> [parse name].</param>
/// <returns>``0.</returns>
protected virtual TAudioBookType ResolveAudioBookFile<TAudioBookType>(ItemResolveArgs args, bool parseName)
where TAudioBookType : AudioBookFile, new()
{
var audioBookFile = new TAudioBookType
{
Path = args.Path
};
// Get AudioBookFileInfo
ResolveFileInfo(audioBookFile);
return audioBookFile;
}
private int ParseChapter(string path)
{
var fileName = Path.GetFileNameWithoutExtension(path);
foreach (var expression in NamingOptions.AudioBookPartsExpressions)
{
var match = Regex.Match(fileName, expression, RegexOptions.IgnoreCase);
if (match.Success)
{
var value = match.Groups["chapter"];
if (value.Success)
{
if (int.TryParse(value.ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intValue))
{
return intValue;
}
}
}
}
return 0;
}
private void ResolveFileInfo(AudioBookFile audioBookFile)
{
var path = audioBookFile.Path;
var extension = Path.GetExtension(path);
audioBookFile.Container = extension.TrimStart('.');
audioBookFile.Chapter = ParseChapter(path);
}
}
}

@ -11,11 +11,11 @@ using Jellyfin.Data.Entities;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.AudioBooks;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using AudioBook = MediaBrowser.Controller.Entities.AudioBook;
using Book = MediaBrowser.Controller.Entities.Book;
namespace Emby.Server.Implementations.Library
@ -261,7 +261,7 @@ namespace Emby.Server.Implementations.Library
var hasRuntime = runtimeTicks > 0;
// If a position has been reported, and if we know the duration
if (positionTicks > 0 && hasRuntime && item is not AudioBook && item is not Book)
if (positionTicks > 0 && hasRuntime && item is not AudioBook && item is not AudioBookFile && item is not Book)
{
var pctIn = decimal.Divide(positionTicks, runtimeTicks) * 100;
@ -287,7 +287,7 @@ namespace Emby.Server.Implementations.Library
}
}
}
else if (positionTicks > 0 && hasRuntime && item is AudioBook)
else if (positionTicks > 0 && hasRuntime && item is AudioBookFile)
{
var playbackPositionInMinutes = TimeSpan.FromTicks(positionTicks).TotalMinutes;
var remainingTimeInMinutes = TimeSpan.FromTicks(runtimeTicks - positionTicks).TotalMinutes;

@ -933,7 +933,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", "AudioBookFile" },
CollectionType.music => new[] { "MusicArtist", "MusicAlbum", "Audio", "MusicVideo" },
CollectionType.homevideos => new[] { "Video", "Photo" },
CollectionType.photos => new[] { "Video", "Photo" },

@ -23,6 +23,11 @@
/// </summary>
AudioBook,
/// <summary>
/// Item is an audio file component of an AudioBook.
/// </summary>
AudioBookFile,
/// <summary>
/// Item is base plugin folder.
/// </summary>

@ -86,7 +86,7 @@ namespace MediaBrowser.Common
void NotifyPendingRestart();
/// <summary>
/// Gets the exports.
/// For given type T, find every class which implements T (usually T is an interface), create or retrieve an instance and return a collection.
/// </summary>
/// <typeparam name="T">The type.</typeparam>
/// <param name="manageLifetime">If set to <c>true</c> [manage lifetime].</param>
@ -103,7 +103,8 @@ namespace MediaBrowser.Common
IReadOnlyCollection<T> GetExports<T>(CreationDelegateFactory defaultFunc, bool manageLifetime = true);
/// <summary>
/// Gets the export types.
/// For the given type T, iterate over all concrete types available to the server and yield all types
/// which are sub-types of given type T.
/// </summary>
/// <typeparam name="T">The type.</typeparam>
/// <returns>IEnumerable{Type}.</returns>

@ -1,64 +0,0 @@
#nullable disable
#pragma warning disable CA1724, CS1591
using System;
using System.Text.Json.Serialization;
using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Providers;
namespace MediaBrowser.Controller.Entities
{
public class AudioBook : Audio.Audio, IHasSeries, IHasLookupInfo<SongInfo>
{
[JsonIgnore]
public override bool SupportsPositionTicksResume => true;
[JsonIgnore]
public override bool SupportsPlayedStatus => true;
[JsonIgnore]
public string SeriesPresentationUniqueKey { get; set; }
[JsonIgnore]
public string SeriesName { get; set; }
[JsonIgnore]
public Guid SeriesId { get; set; }
public string FindSeriesSortName()
{
return SeriesName;
}
public string FindSeriesName()
{
return SeriesName;
}
public string FindSeriesPresentationUniqueKey()
{
return SeriesPresentationUniqueKey;
}
public override double GetDefaultPrimaryImageAspectRatio()
{
return 0;
}
public Guid FindSeriesId()
{
return SeriesId;
}
public override bool CanDownload()
{
return IsFileProtocol;
}
public override UnratedItem GetBlockUnratedType()
{
return UnratedItem.Book;
}
}
}

@ -0,0 +1,185 @@
#nullable disable
#pragma warning disable CS1591
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Entities;
using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.IO;
namespace MediaBrowser.Controller.Entities.AudioBooks
{
/// <summary>
/// Class Season.
/// </summary>
public class AudioBook : Folder, IHasLookupInfo<AudioBookFolderInfo>, IMetadataContainer, IHasMediaSources
{
public AudioBook()
{
Authors = Array.Empty<string>();
}
[JsonIgnore]
public override MediaType MediaType => MediaType.Audio;
public IReadOnlyList<string> Authors { get; set; }
[JsonIgnore]
public override bool SupportsAddingToPlaylist => true;
[JsonIgnore]
public override bool IsPreSorted => true;
[JsonIgnore]
public override bool SupportsDateLastMediaAdded => false;
[JsonIgnore]
public override bool SupportsPeople => true;
[JsonIgnore]
public override bool SupportsInheritedParentImages => true;
[JsonIgnore]
public string SeriesPresentationUniqueKey { get; set; }
[JsonIgnore]
public override bool SupportsPositionTicksResume => true;
/// <summary>
/// Gets the tracks.
/// </summary>
/// <value>The tracks.</value>
[JsonIgnore]
public IEnumerable<Audio.Audio> Tracks => GetRecursiveChildren(i => i is AudioBookFile).Cast<Audio.Audio>();
[JsonIgnore]
public IEnumerable<AudioBookFile> Chapters => GetRecursiveChildren(i => i is AudioBookFile).Cast<AudioBookFile>();
public override int GetChildCount(User user)
{
var result = GetChildren(user, true).Count;
return result;
}
protected override IEnumerable<BaseItem> GetEligibleChildrenForRecursiveChildren(User user)
{
return Tracks;
}
/// <summary>
/// Creates the name of the sort.
/// </summary>
/// <returns>System.String.</returns>
protected override string CreateSortName()
{
return IndexNumber is not null ? IndexNumber.Value.ToString("0000", CultureInfo.InvariantCulture) : Name;
}
/// <summary>
/// Gets the lookup information.
/// </summary>
/// <returns>SeasonInfo.</returns>
public AudioBookFolderInfo GetLookupInfo()
{
var id = GetItemLookupInfo<AudioBookFolderInfo>();
return id;
}
public override double GetDefaultPrimaryImageAspectRatio()
{
return 0;
}
public override bool CanDownload()
{
return IsFileProtocol;
}
public async Task RefreshAllMetadata(MetadataRefreshOptions refreshOptions, IProgress<double> progress, CancellationToken cancellationToken)
{
var items = GetRecursiveChildren();
var totalItems = items.Count;
var numComplete = 0;
var childUpdateType = ItemUpdateType.None;
// Refresh AudioBook files/chapters only
foreach (var item in items.OfType<AudioBookFile>())
{
cancellationToken.ThrowIfCancellationRequested();
var updateType = await item.RefreshMetadata(refreshOptions, cancellationToken).ConfigureAwait(false);
childUpdateType = childUpdateType | updateType;
numComplete++;
double percent = numComplete;
percent /= totalItems;
progress.Report(percent * 95);
}
// get album LUFS
LUFS = items.OfType<AudioBookFile>().Max(item => item.LUFS);
var parentRefreshOptions = refreshOptions;
if (childUpdateType > ItemUpdateType.None)
{
parentRefreshOptions = new MetadataRefreshOptions(refreshOptions)
{
MetadataRefreshMode = MetadataRefreshMode.FullRefresh
};
}
// Refresh current item
await RefreshMetadata(parentRefreshOptions, cancellationToken).ConfigureAwait(false);
// if (!refreshOptions.IsAutomated)
// {
// await RefreshArtists(refreshOptions, cancellationToken).ConfigureAwait(false);
// }
}
// private async Task RefreshArtists(MetadataRefreshOptions refreshOptions, CancellationToken cancellationToken)
// {
// foreach (var i in this.GetAllArtists())
// {
// This should not be necessary but we're seeing some cases of it
// if (string.IsNullOrEmpty(i))
// {
// continue;
// }
// var artist = LibraryManager.GetArtist(i);
// if (!artist.IsAccessedByName)
// {
// continue;
// }
// await artist.RefreshMetadata(refreshOptions, cancellationToken).ConfigureAwait(false);
// }
// }
protected override IEnumerable<(BaseItem Item, MediaSourceType MediaSourceType)> GetAllItemsForMediaSources()
{
// var tracks = Enumerable.Empty<(BaseItem Item, MediaSourceType MediaSourceType)>;
var tracks = new List<(BaseItem Item, MediaSourceType MediaSourceType)>();
foreach (var track in Tracks)
{
tracks.Add((track, MediaSourceType.Default));
}
return tracks;
}
}
}

@ -0,0 +1,176 @@
#nullable disable
#pragma warning disable CS1591
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.Json.Serialization;
using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.Providers;
// TODO: Use ChapterInfo?.
namespace MediaBrowser.Controller.Entities.AudioBooks
{
/// <summary>
/// Class AudioBookFile.
/// </summary>
public class AudioBookFile : Audio.Audio, IHasLookupInfo<AudioBookFileInfo>
{
[JsonIgnore]
public override bool SupportsPositionTicksResume => true;
[JsonIgnore]
public override MediaType MediaType => MediaType.Audio;
public string BookTitle { get; set; }
public int Chapter { get; set; }
public IReadOnlyList<string> Authors { get; set; }
[JsonIgnore]
public override bool SupportsPlayedStatus => true;
[JsonIgnore]
public override bool SupportsPeople => true;
[JsonIgnore]
public override bool SupportsAddingToPlaylist => true;
[JsonIgnore]
public override bool SupportsInheritedParentImages => true;
[JsonIgnore]
protected override bool SupportsOwnedItems => false;
[JsonIgnore]
public override Folder LatestItemsIndexContainer => AudioBookEntity;
[JsonIgnore]
public AudioBook AudioBookEntity => FindParent<AudioBook>();
[JsonIgnore]
public string SeriesName { get; set; }
[JsonIgnore]
public string SeasonName { get; set; }
[JsonIgnore]
public override bool SupportsRemoteImageDownloading => true;
public IEnumerable<AudioBookFile> GetNextChapters()
{
var chapters = AudioBookEntity.Chapters;
var nextChapters = new List<AudioBookFile>();
foreach (var chapter in chapters)
{
if (chapter.Chapter > Chapter)
{
nextChapters.Add(chapter);
}
}
return nextChapters;
}
public override double GetDefaultPrimaryImageAspectRatio()
{
return 0;
}
/// <summary>
/// Get user data keys.
/// </summary>
/// <returns>User data keys associated with this item.</returns>
public override List<string> GetUserDataKeys()
{
var list = base.GetUserDataKeys();
return list;
}
public string FindSeriesPresentationUniqueKey()
{
return AudioBookEntity?.PresentationUniqueKey;
}
public string FindAudioBookName()
{
return AudioBookEntity is null ? "AudioBook Unknown" : AudioBookEntity.Name;
}
public Guid FindAudioBookId()
{
return AudioBookEntity is null ? Guid.Empty : AudioBookEntity.Id;
}
/// <summary>
/// Creates the name of the sort.
/// </summary>
/// <returns>System.String.</returns>
protected override string CreateSortName()
{
return (ParentIndexNumber is not null ? ParentIndexNumber.Value.ToString("000 - ", CultureInfo.InvariantCulture) : string.Empty)
+ (IndexNumber is not null ? IndexNumber.Value.ToString("0000 - ", CultureInfo.InvariantCulture) : string.Empty) + Name;
}
public override IEnumerable<FileSystemMetadata> GetDeletePaths()
{
return new[]
{
new FileSystemMetadata
{
FullName = Path,
IsDirectory = IsFolder
}
}.Concat(GetLocalMetadataFilesToDelete());
}
public new AudioBookFileInfo GetLookupInfo()
{
var id = GetItemLookupInfo<AudioBookFileInfo>();
id.BookTitle = BookTitle;
id.Authors = Authors;
id.Container = Container;
return id;
}
public override bool BeforeMetadataRefresh(bool replaceAllMetadata)
{
var hasChanges = base.BeforeMetadataRefresh(replaceAllMetadata);
return hasChanges;
}
public override List<ExternalUrl> GetRelatedUrls()
{
var list = base.GetRelatedUrls();
// TODO: Is there one of these for books and are those changes worth it?
return list;
}
protected override IEnumerable<(BaseItem Item, MediaSourceType MediaSourceType)> GetAllItemsForMediaSources()
{
var chapters = AudioBookEntity.Chapters;
var nextChapters = new List<(BaseItem Item, MediaSourceType MediaSourceType)>();
foreach (var chapter in chapters)
{
if (chapter.Chapter >= this.Chapter)
{
nextChapters.Add((chapter, MediaSourceType.Default));
}
}
return nextChapters;
}
}
}

@ -0,0 +1,25 @@
#nullable disable
#pragma warning disable CS1591
using System;
using System.Collections.Generic;
namespace MediaBrowser.Controller.Providers
{
public class AudioBookFileInfo : ItemLookupInfo
{
public AudioBookFileInfo()
{
Authors = Array.Empty<string>();
}
public string BookTitle { get; set; }
public int Chapter { get; set; }
public string Container { get; set; }
public IReadOnlyList<string> Authors { get; set; }
}
}

@ -0,0 +1,25 @@
#nullable disable
#pragma warning disable CS1591
using System;
using System.Collections.Generic;
namespace MediaBrowser.Controller.Providers
{
public class AudioBookFolderInfo : ItemLookupInfo
{
public AudioBookFolderInfo()
{
Authors = Array.Empty<string>();
}
public string BookTitle { get; set; }
public string Container { get; set; }
public int Chapters { get; set; }
public IReadOnlyList<string> Authors { get; set; }
}
}

@ -0,0 +1,72 @@
using System;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities.AudioBooks;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Providers.Manager;
using Microsoft.Extensions.Logging;
namespace MediaBrowser.Providers.Books
{
/// <summary>
/// The audio metadata service.
/// </summary>
public class AudioBookFileMetadataService : MetadataService<AudioBookFile, AudioBookFileInfo>
{
/// <summary>
/// Initializes a new instance of the <see cref="AudioBookFileMetadataService"/> class.
/// </summary>
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/>.</param>
/// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
/// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
public AudioBookFileMetadataService(
IServerConfigurationManager serverConfigurationManager,
ILogger<AudioBookFileMetadataService> logger,
IProviderManager providerManager,
IFileSystem fileSystem,
ILibraryManager libraryManager)
: base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager)
{
}
private void SetProviderId(AudioBookFile sourceItem, AudioBookFile targetItem, bool replaceData, MetadataProvider provider)
{
var target = targetItem.GetProviderId(provider);
if (replaceData || string.IsNullOrEmpty(target))
{
var source = sourceItem.GetProviderId(provider);
if (!string.IsNullOrEmpty(source)
&& (string.IsNullOrEmpty(target)
|| !target.Equals(source, StringComparison.Ordinal)))
{
targetItem.SetProviderId(provider, source);
}
}
}
/// <inheritdoc />
protected override void MergeData(MetadataResult<AudioBookFile> source, MetadataResult<AudioBookFile> target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings)
{
base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings);
var sourceItem = source.Item;
var targetItem = target.Item;
if (replaceData || targetItem.Authors.Count == 0)
{
targetItem.Authors = sourceItem.Authors;
}
if (replaceData || string.IsNullOrEmpty(targetItem.Album))
{
targetItem.Album = sourceItem.Album;
}
// TODO: Create and register provider specific to book information and AudioBook information
}
}
}

@ -1,7 +1,10 @@
#pragma warning disable CS1591
using System;
using System.Collections.Generic;
using System.Linq;
using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.AudioBooks;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
@ -11,7 +14,7 @@ using Microsoft.Extensions.Logging;
namespace MediaBrowser.Providers.Books
{
public class AudioBookMetadataService : MetadataService<AudioBook, SongInfo>
public class AudioBookMetadataService : MetadataService<AudioBook, AudioBookFolderInfo>
{
public AudioBookMetadataService(
IServerConfigurationManager serverConfigurationManager,
@ -23,6 +26,100 @@ namespace MediaBrowser.Providers.Books
{
}
/// <inheritdoc />
protected override bool EnableUpdatingPremiereDateFromChildren => true;
/// <inheritdoc />
protected override bool EnableUpdatingGenresFromChildren => true;
/// <inheritdoc />
protected override bool EnableUpdatingStudiosFromChildren => true;
/// <inheritdoc />
protected override IList<Controller.Entities.BaseItem> GetChildrenForMetadataUpdates(AudioBook item)
=> item.GetRecursiveChildren(i => i is AudioBookFile);
/// <inheritdoc />
protected override ItemUpdateType UpdateMetadataFromChildren(AudioBook item, IList<Controller.Entities.BaseItem> children, bool isFullRefresh, ItemUpdateType currentUpdateType)
{
var updateType = base.UpdateMetadataFromChildren(item, children, isFullRefresh, currentUpdateType);
// don't update user-changeable metadata for locked items
if (item.IsLocked)
{
return updateType;
}
if (isFullRefresh || currentUpdateType > ItemUpdateType.None)
{
if (!item.LockedFields.Contains(MetadataField.Name))
{
var name = children.Select(i => i.Album).FirstOrDefault(i => !string.IsNullOrEmpty(i));
if (!string.IsNullOrEmpty(name)
&& !string.Equals(item.Name, name, StringComparison.Ordinal))
{
item.Name = name;
updateType |= ItemUpdateType.MetadataEdit;
}
}
var files = children.Cast<AudioBookFile>().ToArray();
updateType |= SetAuthorsFromFile(item, files);
updateType |= SetPeople(item);
}
return updateType;
}
private ItemUpdateType SetAuthorsFromFile(AudioBook item, IReadOnlyList<AudioBookFile> files)
{
var updateType = ItemUpdateType.None;
// var authors = files
// .SelectMany(i => i.Authors)
// .Where(i => i.Length != 0)
// .GroupBy(i => i)
// .OrderByDescending(g => g.Count())
// .Select(g => g.Key)
// .ToArray();
// if (!item.Authors.SequenceEqual(authors, StringComparer.OrdinalIgnoreCase))
// {
// item.Authors = authors;
// updateType |= ItemUpdateType.MetadataEdit;
// }
return updateType;
}
private ItemUpdateType SetPeople(AudioBook item)
{
var updateType = ItemUpdateType.None;
if (item.Authors.Any())
{
var people = new List<Controller.Entities.PersonInfo>();
foreach (var author in item.Authors)
{
Controller.Entities.PeopleHelper.AddPerson(people, new Controller.Entities.PersonInfo
{
Name = author,
Type = PersonKind.Author
});
}
// TODO: Add narrator
LibraryManager.UpdatePeople(item, people);
updateType |= ItemUpdateType.MetadataEdit;
}
return updateType;
}
/// <inheritdoc />
protected override void MergeData(
MetadataResult<AudioBook> source,
@ -36,14 +133,9 @@ namespace MediaBrowser.Providers.Books
var sourceItem = source.Item;
var targetItem = target.Item;
if (replaceData || targetItem.Artists.Count == 0)
{
targetItem.Artists = sourceItem.Artists;
}
if (replaceData || string.IsNullOrEmpty(targetItem.Album))
if (replaceData || targetItem.Authors.Count == 0)
{
targetItem.Album = sourceItem.Album;
targetItem.Authors = sourceItem.Authors;
}
}
}

@ -14,6 +14,7 @@ using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Entities.AudioBooks;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Entities;
@ -418,6 +419,7 @@ namespace MediaBrowser.Providers.Manager
item is MusicArtist ||
item is PhotoAlbum ||
item is Person ||
item is AudioBook ||
(saveLocally && _config.Configuration.ImageSavingConvention == ImageSavingConvention.Legacy) ?
"folder" :
"poster";
@ -605,7 +607,7 @@ namespace MediaBrowser.Providers.Manager
return new[] { GetSavePathForItemInMixedFolder(item, type, string.Empty, extension) };
}
if (item is MusicAlbum || item is MusicArtist)
if (item is MusicAlbum || item is MusicArtist || item is AudioBook)
{
return new[] { Path.Combine(item.ContainingFolderPath, "folder" + extension) };
}

@ -20,6 +20,7 @@ using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Entities.AudioBooks;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Lyrics;

@ -10,6 +10,7 @@ using MediaBrowser.Controller.Chapters;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Entities.AudioBooks;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
@ -35,7 +36,7 @@ namespace MediaBrowser.Providers.MediaInfo
ICustomMetadataProvider<Trailer>,
ICustomMetadataProvider<Video>,
ICustomMetadataProvider<Audio>,
ICustomMetadataProvider<AudioBook>,
ICustomMetadataProvider<AudioBookFile>,
IHasOrder,
IForcedProvider,
IPreRefreshProvider,
@ -211,7 +212,7 @@ namespace MediaBrowser.Providers.MediaInfo
}
/// <inheritdoc />
public Task<ItemUpdateType> FetchAsync(AudioBook item, MetadataRefreshOptions options, CancellationToken cancellationToken)
public Task<ItemUpdateType> FetchAsync(AudioBookFile item, MetadataRefreshOptions options, CancellationToken cancellationToken)
{
return FetchAudioInfo(item, options, cancellationToken);
}

@ -19,6 +19,7 @@ using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Entities.AudioBooks;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Channels;

Loading…
Cancel
Save