+
- {{^IF isInReportingMode}}
-
Jellyfin Server {{version.ToString(2)}} still starting. Please wait.
- {{#ELSE}}
-
Jellyfin Server {{version.ToString(2)}} has encountered an error and was not able to start.
- {{/ELSE}}
- {{/IF}}
-
- {{#IF localNetworkRequest}}
-
You can download the current log file here.
- {{/IF}}
-
+ {{^IF isInReportingMode}}
+
+
+
Jellyfin is still starting. Please wait… {{currentActivity}}
+
+ {{#ELSE}}
+
+
Jellyfin has encountered an error and was not able to start.
+
+ {{/ELSE}}
+ {{/IF}}
{{#DECLARE LogEntry |--}}
{{#LET children = Children}}
@@ -192,7 +472,7 @@
{{DateOfCreation}} - {{Content}}
- {{--| #EACH children.Reverse() |-}}
+ {{--| #EACH children |-}}
{{#IMPORT 'LogEntry'}}
{{--| /EACH |-}}
@@ -205,31 +485,175 @@
{{--| /DECLARE}}
{{#IF localNetworkRequest}}
-
-
- {{#FOREACH log IN logs.Reverse()}}
- {{#IMPORT 'LogEntry' #WITH log}}
- {{/FOREACH}}
-
+
- {{#ELSE}}
- {{#IF networkManagerReady}}
-
Please visit this page from your local network to view detailed startup logs.
- {{#ELSE}}
-
Initializing network settings. Please wait.
- {{/ELSE}}
- {{/IF}}
- {{/ELSE}}
{{/IF}}
+
-{{^IF isInReportingMode}}
-
-{{/IF}}
-
diff --git a/MediaBrowser.Controller/Channels/ChannelItemResult.cs b/MediaBrowser.Controller/Channels/ChannelItemResult.cs
index ca7721991d..9557c91964 100644
--- a/MediaBrowser.Controller/Channels/ChannelItemResult.cs
+++ b/MediaBrowser.Controller/Channels/ChannelItemResult.cs
@@ -1,19 +1,29 @@
-#pragma warning disable CS1591
-
using System;
using System.Collections.Generic;
namespace MediaBrowser.Controller.Channels
{
+ ///
+ /// The result of a channel item query.
+ ///
public class ChannelItemResult
{
+ ///
+ /// Initializes a new instance of the class.
+ ///
public ChannelItemResult()
{
Items = Array.Empty();
}
+ ///
+ /// Gets or sets the items.
+ ///
public IReadOnlyList Items { get; set; }
+ ///
+ /// Gets or sets the total record count.
+ ///
public int? TotalRecordCount { get; set; }
}
}
diff --git a/MediaBrowser.Controller/Channels/ChannelItemType.cs b/MediaBrowser.Controller/Channels/ChannelItemType.cs
index 3ce920e236..2608cb4c88 100644
--- a/MediaBrowser.Controller/Channels/ChannelItemType.cs
+++ b/MediaBrowser.Controller/Channels/ChannelItemType.cs
@@ -1,11 +1,18 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Controller.Channels
{
+ ///
+ /// The type of a channel item.
+ ///
public enum ChannelItemType
{
+ ///
+ /// The item is a media item.
+ ///
Media = 0,
+ ///
+ /// The item is a folder.
+ ///
Folder = 1
}
}
diff --git a/MediaBrowser.Controller/Channels/ChannelLatestMediaSearch.cs b/MediaBrowser.Controller/Channels/ChannelLatestMediaSearch.cs
index ebbe13763b..c6530814b9 100644
--- a/MediaBrowser.Controller/Channels/ChannelLatestMediaSearch.cs
+++ b/MediaBrowser.Controller/Channels/ChannelLatestMediaSearch.cs
@@ -1,11 +1,15 @@
#nullable disable
-#pragma warning disable CS1591
-
namespace MediaBrowser.Controller.Channels
{
+ ///
+ /// The request for a latest media search in a channel.
+ ///
public class ChannelLatestMediaSearch
{
+ ///
+ /// Gets or sets the user id.
+ ///
public string UserId { get; set; }
}
}
diff --git a/MediaBrowser.Controller/Channels/ChannelParentalRating.cs b/MediaBrowser.Controller/Channels/ChannelParentalRating.cs
index f77d81c166..a5a1ba5bf6 100644
--- a/MediaBrowser.Controller/Channels/ChannelParentalRating.cs
+++ b/MediaBrowser.Controller/Channels/ChannelParentalRating.cs
@@ -1,17 +1,33 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Controller.Channels
{
+ ///
+ /// The parental rating of a channel.
+ ///
public enum ChannelParentalRating
{
+ ///
+ /// Suitable for a general audience.
+ ///
GeneralAudience = 0,
+ ///
+ /// Parental guidance suggested (US PG).
+ ///
UsPG = 1,
+ ///
+ /// Parents strongly cautioned (US PG-13).
+ ///
UsPG13 = 2,
+ ///
+ /// Restricted (US R).
+ ///
UsR = 3,
+ ///
+ /// Suitable for adults only.
+ ///
Adult = 4
}
}
diff --git a/MediaBrowser.Controller/Channels/ChannelSearchInfo.cs b/MediaBrowser.Controller/Channels/ChannelSearchInfo.cs
index 990b025bcb..d172b98b25 100644
--- a/MediaBrowser.Controller/Channels/ChannelSearchInfo.cs
+++ b/MediaBrowser.Controller/Channels/ChannelSearchInfo.cs
@@ -1,13 +1,20 @@
#nullable disable
-#pragma warning disable CS1591
-
namespace MediaBrowser.Controller.Channels
{
+ ///
+ /// The request for a search in a channel.
+ ///
public class ChannelSearchInfo
{
+ ///
+ /// Gets or sets the search term.
+ ///
public string SearchTerm { get; set; }
+ ///
+ /// Gets or sets the user id.
+ ///
public string UserId { get; set; }
}
}
diff --git a/MediaBrowser.Controller/Channels/IHasCacheKey.cs b/MediaBrowser.Controller/Channels/IHasCacheKey.cs
index 7d5207c34a..4cdda38bd9 100644
--- a/MediaBrowser.Controller/Channels/IHasCacheKey.cs
+++ b/MediaBrowser.Controller/Channels/IHasCacheKey.cs
@@ -1,14 +1,15 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Controller.Channels
{
+ ///
+ /// Interface for channels that provide a cache key.
+ ///
public interface IHasCacheKey
{
///
/// Gets the cache key.
///
/// The user identifier.
- /// System.String.
+ /// The cache key.
string? GetCacheKey(string? userId);
}
}
diff --git a/MediaBrowser.Controller/Channels/ISupportsDelete.cs b/MediaBrowser.Controller/Channels/ISupportsDelete.cs
index 0110bfa7a3..194654ca9e 100644
--- a/MediaBrowser.Controller/Channels/ISupportsDelete.cs
+++ b/MediaBrowser.Controller/Channels/ISupportsDelete.cs
@@ -1,15 +1,27 @@
-#pragma warning disable CS1591
-
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Entities;
namespace MediaBrowser.Controller.Channels
{
+ ///
+ /// Interface for channels that support deleting items.
+ ///
public interface ISupportsDelete
{
+ ///
+ /// Gets a value indicating whether the item can be deleted.
+ ///
+ /// The item.
+ /// true if the item can be deleted, false otherwise.
bool CanDelete(BaseItem item);
+ ///
+ /// Deletes the item with the provided id.
+ ///
+ /// The item id.
+ /// The cancellation token.
+ /// A task representing the deletion of the item.
Task DeleteItem(string id, CancellationToken cancellationToken);
}
}
diff --git a/MediaBrowser.Controller/Channels/ISupportsLatestMedia.cs b/MediaBrowser.Controller/Channels/ISupportsLatestMedia.cs
index 1935ec0f5f..82ca45d3ad 100644
--- a/MediaBrowser.Controller/Channels/ISupportsLatestMedia.cs
+++ b/MediaBrowser.Controller/Channels/ISupportsLatestMedia.cs
@@ -1,11 +1,12 @@
-#pragma warning disable CS1591
-
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace MediaBrowser.Controller.Channels
{
+ ///
+ /// Interface for channels that support retrieving the latest media.
+ ///
public interface ISupportsLatestMedia
{
///
diff --git a/MediaBrowser.Controller/ClientEvent/ClientEventLogger.cs b/MediaBrowser.Controller/ClientEvent/ClientEventLogger.cs
index 14dc64dabd..36f0d2195c 100644
--- a/MediaBrowser.Controller/ClientEvent/ClientEventLogger.cs
+++ b/MediaBrowser.Controller/ClientEvent/ClientEventLogger.cs
@@ -1,6 +1,7 @@
using System;
using System.IO;
using System.Threading.Tasks;
+using Jellyfin.Extensions;
namespace MediaBrowser.Controller.ClientEvent
{
@@ -21,8 +22,15 @@ namespace MediaBrowser.Controller.ClientEvent
///
public async Task WriteDocumentAsync(string clientName, string clientVersion, Stream fileContents)
{
- var fileName = $"upload_{clientName}_{clientVersion}_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}.log";
+ var safeClientName = PathHelper.GetSafeLeafFileName(clientName) ?? "unknown-client";
+ var safeClientVersion = PathHelper.GetSafeLeafFileName(clientVersion) ?? "unknown-version";
+ var fileName = $"upload_{safeClientName}_{safeClientVersion}_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}.log";
var logFilePath = Path.Combine(_applicationPaths.LogDirectoryPath, fileName);
+ if (!PathHelper.IsContainedIn(_applicationPaths.LogDirectoryPath, logFilePath))
+ {
+ throw new ArgumentException("Path resolved to filename not in log directory");
+ }
+
var fileStream = new FileStream(logFilePath, FileMode.CreateNew, FileAccess.Write, FileShare.None);
await using (fileStream.ConfigureAwait(false))
{
diff --git a/MediaBrowser.Controller/Dto/DtoOptions.cs b/MediaBrowser.Controller/Dto/DtoOptions.cs
index d319feb6b2..052626355f 100644
--- a/MediaBrowser.Controller/Dto/DtoOptions.cs
+++ b/MediaBrowser.Controller/Dto/DtoOptions.cs
@@ -81,13 +81,6 @@ namespace MediaBrowser.Controller.Dto
///
public bool AddCurrentProgram { get; set; }
- ///
- /// Gets or sets a value indicating whether an episode's portrait poster (its season's primary
- /// image, falling back to the series') should replace the episode's own (16:9) primary image.
- /// Used by views that render episodes as poster cards, e.g. "Latest".
- ///
- public bool PreferEpisodeParentPoster { get; set; }
-
///
/// Gets a value indicating whether the specified field is populated.
///
diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs
index 21304768bd..21a726aaec 100644
--- a/MediaBrowser.Controller/Entities/BaseItem.cs
+++ b/MediaBrowser.Controller/Entities/BaseItem.cs
@@ -27,6 +27,7 @@ using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaSegments;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Globalization;
@@ -87,6 +88,8 @@ namespace MediaBrowser.Controller.Entities
Model.Entities.ExtraType.Short
};
+ private static readonly char[] VersionDelimiters = ['-', '_', '.'];
+
private string _sortName;
private string _forcedSortName;
@@ -538,8 +541,8 @@ namespace MediaBrowser.Controller.Entities
{
if (!string.IsNullOrEmpty(ForcedSortName))
{
- // Need the ToLower because that's what CreateSortName does
- _sortName = ModifySortChunks(ForcedSortName).ToLowerInvariant();
+ // Run the forced sort name through the same cleaning as auto-generated sort names.
+ _sortName = GetSortName(ForcedSortName, EnableAlphaNumericSorting, ConfigurationManager.Configuration);
}
else
{
@@ -924,19 +927,31 @@ namespace MediaBrowser.Controller.Entities
/// System.String.
protected virtual string CreateSortName()
{
- if (Name is null)
+ return GetSortName(Name, EnableAlphaNumericSorting, ConfigurationManager.Configuration);
+ }
+
+ ///
+ /// Cleans a raw name into its sortable form by applying the configured sort rules.
+ ///
+ /// The raw name to clean.
+ /// Whether alphanumeric sorting rules should be applied.
+ /// The server configuration providing the sort rules.
+ /// The cleaned, sortable name, or null if is null.
+ public static string GetSortName(string name, bool enableAlphaNumericSorting, ServerConfiguration configuration)
+ {
+ if (name is null)
{
return null; // some items may not have name filled in properly
}
- if (!EnableAlphaNumericSorting)
+ if (!enableAlphaNumericSorting)
{
- return Name.TrimStart();
+ return name.TrimStart();
}
- var sortable = Name.Trim().ToLowerInvariant();
+ var sortable = name.Trim().ToLowerInvariant();
- foreach (var search in ConfigurationManager.Configuration.SortRemoveWords)
+ foreach (var search in configuration.SortRemoveWords)
{
// Remove from beginning if a space follows
if (sortable.StartsWith(search + " ", StringComparison.Ordinal))
@@ -954,12 +969,12 @@ namespace MediaBrowser.Controller.Entities
}
}
- foreach (var removeChar in ConfigurationManager.Configuration.SortRemoveCharacters)
+ foreach (var removeChar in configuration.SortRemoveCharacters)
{
sortable = sortable.Replace(removeChar, string.Empty, StringComparison.Ordinal);
}
- foreach (var replaceChar in ConfigurationManager.Configuration.SortReplaceCharacters)
+ foreach (var replaceChar in configuration.SortReplaceCharacters)
{
sortable = sortable.Replace(replaceChar, " ", StringComparison.Ordinal);
}
@@ -1099,8 +1114,9 @@ namespace MediaBrowser.Controller.Entities
}
}
- var list = GetAllItemsForMediaSources();
- var result = list.Select(i => GetVersionInfo(enablePathSubstitution, i.Item, i.MediaSourceType)).ToList();
+ var list = GetAllItemsForMediaSources().ToList();
+ var commonPrefix = GetCommonNamePrefix(list);
+ var result = list.Select(i => GetVersionInfo(enablePathSubstitution, i.Item, i.MediaSourceType, commonPrefix)).ToList();
if (IsActiveRecording())
{
@@ -1110,17 +1126,15 @@ namespace MediaBrowser.Controller.Entities
}
}
- return result.OrderBy(i =>
- {
- if (i.VideoType == VideoType.VideoFile)
- {
- return 0;
- }
+ // The source belonging to the item being queried sorts first so it is the default the client plays.
+ var selfId = Id.ToString("N", CultureInfo.InvariantCulture);
- return 1;
- }).ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0)
- .ThenByDescending(i => i, new MediaSourceWidthComparator())
- .ToArray();
+ return result
+ .OrderByDescending(i => string.Equals(i.Id, selfId, StringComparison.OrdinalIgnoreCase))
+ .ThenBy(i => i.VideoType == VideoType.VideoFile ? 0 : 1)
+ .ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0)
+ .ThenByDescending(i => i, new MediaSourceWidthComparator())
+ .ToArray();
}
protected virtual IEnumerable<(BaseItem Item, MediaSourceType MediaSourceType)> GetAllItemsForMediaSources()
@@ -1128,7 +1142,7 @@ namespace MediaBrowser.Controller.Entities
return Enumerable.Empty<(BaseItem, MediaSourceType)>();
}
- private MediaSourceInfo GetVersionInfo(bool enablePathSubstitution, BaseItem item, MediaSourceType type)
+ private MediaSourceInfo GetVersionInfo(bool enablePathSubstitution, BaseItem item, MediaSourceType type, string commonPrefix = null)
{
ArgumentNullException.ThrowIfNull(item);
@@ -1141,7 +1155,7 @@ namespace MediaBrowser.Controller.Entities
Protocol = protocol ?? MediaProtocol.File,
MediaStreams = MediaSourceManager.GetMediaStreams(item.Id),
MediaAttachments = MediaSourceManager.GetMediaAttachments(item.Id),
- Name = GetMediaSourceName(item),
+ Name = GetMediaSourceName(item, commonPrefix),
Path = enablePathSubstitution ? GetMappedPath(item, itemPath, protocol) : itemPath,
RunTimeTicks = item.RunTimeTicks,
Container = item.Container,
@@ -1220,7 +1234,7 @@ namespace MediaBrowser.Controller.Entities
return info;
}
- internal string GetMediaSourceName(BaseItem item)
+ internal string GetMediaSourceName(BaseItem item, string commonPrefix = null)
{
var terms = new List();
@@ -1228,12 +1242,31 @@ namespace MediaBrowser.Controller.Entities
if (item.IsFileProtocol && !string.IsNullOrEmpty(path))
{
var displayName = System.IO.Path.GetFileNameWithoutExtension(path);
- if (HasLocalAlternateVersions)
+
+ // Prefer the suffix that differs from the other versions: strip the prefix shared by
+ // all sibling files. This works regardless of folder layout, so it also labels episode
+ // versions that share a season folder (e.g. "Greyscale" instead of the full
+ // "Show - S01E02 - Title - Greyscale"). The prefix is already retreated to a delimiter
+ // boundary (see GetCommonVersionPrefix).
+ if (!string.IsNullOrEmpty(commonPrefix)
+ && displayName.Length > commonPrefix.Length
+ && displayName.StartsWith(commonPrefix, StringComparison.OrdinalIgnoreCase))
+ {
+ var name = displayName.AsSpan(commonPrefix.Length).TrimStart([' ', .. VersionDelimiters]);
+ if (!name.IsWhiteSpace())
+ {
+ terms.Add(name.ToString());
+ }
+ }
+
+ // Fall back to the containing folder name (the common layout for movie versions, and
+ // the path taken when no common prefix could be derived).
+ if (terms.Count == 0 && HasLocalAlternateVersions)
{
var containingFolderName = System.IO.Path.GetFileName(ContainingFolderPath);
if (displayName.Length > containingFolderName.Length && displayName.StartsWith(containingFolderName, StringComparison.OrdinalIgnoreCase))
{
- var name = displayName.AsSpan(containingFolderName.Length).TrimStart([' ', '-']);
+ var name = displayName.AsSpan(containingFolderName.Length).TrimStart([' ', .. VersionDelimiters]);
if (!name.IsWhiteSpace())
{
terms.Add(name.ToString());
@@ -1290,6 +1323,98 @@ namespace MediaBrowser.Controller.Entities
return string.Join('/', terms);
}
+ ///
+ /// Derives the prefix shared by the supplied media source items' file names, used to strip the
+ /// common part and surface a short version label per source. Returns null when there are fewer
+ /// than two file-based sources, since there is nothing to differentiate.
+ ///
+ /// The media source items.
+ /// The shared prefix, or null when no useful prefix exists.
+ private static string GetCommonNamePrefix(IReadOnlyList<(BaseItem Item, MediaSourceType MediaSourceType)> items)
+ {
+ var fileNames = new List();
+ foreach (var (item, _) in items)
+ {
+ if (item.IsFileProtocol && !string.IsNullOrEmpty(item.Path))
+ {
+ fileNames.Add(System.IO.Path.GetFileNameWithoutExtension(item.Path));
+ }
+ }
+
+ if (fileNames.Count < 2)
+ {
+ return null;
+ }
+
+ var prefix = GetCommonVersionPrefix(fileNames);
+ return string.IsNullOrEmpty(prefix) ? null : prefix;
+ }
+
+ ///
+ /// Computes the case-insensitive longest common prefix of the supplied version file names,
+ /// retreated to the last delimiter boundary. Retreating keeps the differing suffix intact:
+ /// it avoids slicing through a word every version shares (e.g. "Grey" in "Greyscale" and
+ /// "Greyish") while still trimming the common part when every version is suffixed (e.g.
+ /// "- Greyscale" / "- Colorized"). It prefers a structural delimiter ('-', '_', '.') so a
+ /// token shared by the descriptors but separated only by spaces (e.g. a common "2160p ") is
+ /// kept in the label, falling back to a space only when no structural delimiter is shared. The
+ /// separators mirror the version delimiters recognised by the naming layer (Emby.Naming
+ /// VideoFlagDelimiters).
+ ///
+ /// The version file names without extension; must contain at least one entry.
+ /// The shared prefix retreated to a separator boundary, or an empty string when none is shared.
+ internal static string GetCommonVersionPrefix(IReadOnlyList fileNames)
+ {
+ var prefix = fileNames[0];
+ for (var i = 1; i < fileNames.Count && prefix.Length > 0; i++)
+ {
+ var name = fileNames[i];
+ var length = Math.Min(prefix.Length, name.Length);
+ var common = 0;
+ while (common < length && char.ToUpperInvariant(prefix[common]) == char.ToUpperInvariant(name[common]))
+ {
+ common++;
+ }
+
+ prefix = prefix[..common];
+ }
+
+ // If the common prefix is itself a whole file name then one version is unlabelled (the
+ // base name); the boundary already sits at the end of that name, so don't retreat into it.
+ var prefixIsWholeName = false;
+ for (var i = 0; i < fileNames.Count; i++)
+ {
+ if (fileNames[i].Length == prefix.Length)
+ {
+ prefixIsWholeName = true;
+ break;
+ }
+ }
+
+ if (!prefixIsWholeName)
+ {
+ // Retreat to the last structural delimiter ('-', '_', '.').
+ var cut = prefix.Length;
+ while (cut > 0 && Array.IndexOf(VersionDelimiters, prefix[cut - 1]) < 0)
+ {
+ cut--;
+ }
+
+ if (cut == 0)
+ {
+ cut = prefix.Length;
+ while (cut > 0 && prefix[cut - 1] != ' ')
+ {
+ cut--;
+ }
+ }
+
+ prefix = prefix[..cut];
+ }
+
+ return prefix;
+ }
+
public Task RefreshMetadata(CancellationToken cancellationToken)
{
return RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(FileSystem)), cancellationToken);
@@ -2011,12 +2136,23 @@ namespace MediaBrowser.Controller.Entities
// I think it is okay to do this here.
// if this is only called when a user is manually forcing something to un-played
// then it probably is what we want to do...
+ ResetPlayedState(data);
+
+ UserDataManager.SaveUserData(user, this, data, UserDataSaveReason.TogglePlayed, CancellationToken.None);
+ }
+
+ ///
+ /// Clears the played state on the supplied user data.
+ ///
+ /// The user data to reset.
+ protected static void ResetPlayedState(UserItemData data)
+ {
+ ArgumentNullException.ThrowIfNull(data);
+
data.PlayCount = 0;
data.PlaybackPositionTicks = 0;
data.LastPlayedDate = null;
data.Played = false;
-
- UserDataManager.SaveUserData(user, this, data, UserDataSaveReason.TogglePlayed, CancellationToken.None);
}
///
@@ -2731,6 +2867,15 @@ namespace MediaBrowser.Controller.Entities
return LibraryManager.Sort(GetExtras(user).Where(e => e.ExtraType == Model.Entities.ExtraType.ThemeVideo), user, orderBy).ToArray();
}
+ ///
+ /// Gets the ids of the items whose owned extras belong to this item.
+ ///
+ /// An array containing the owner ids.
+ protected virtual Guid[] GetExtraOwnerIds()
+ {
+ return [Id];
+ }
+
///
/// Get all extras associated with this item, sorted by .
///
@@ -2740,7 +2885,7 @@ namespace MediaBrowser.Controller.Entities
{
return LibraryManager.GetItemList(new InternalItemsQuery(user)
{
- OwnerIds = [Id],
+ OwnerIds = GetExtraOwnerIds(),
OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending)]
});
}
@@ -2755,7 +2900,7 @@ namespace MediaBrowser.Controller.Entities
{
return LibraryManager.GetItemList(new InternalItemsQuery(user)
{
- OwnerIds = [Id],
+ OwnerIds = GetExtraOwnerIds(),
ExtraTypes = extraTypes.ToArray(),
OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending)]
});
diff --git a/MediaBrowser.Controller/Entities/Book.cs b/MediaBrowser.Controller/Entities/Book.cs
index 5187669373..8559681bdc 100644
--- a/MediaBrowser.Controller/Entities/Book.cs
+++ b/MediaBrowser.Controller/Entities/Book.cs
@@ -13,11 +13,6 @@ namespace MediaBrowser.Controller.Entities
[Common.RequiresSourceSerialisation]
public class Book : BaseItem, IHasLookupInfo, IHasSeries
{
- public Book()
- {
- this.RunTimeTicks = TimeSpan.TicksPerSecond;
- }
-
[JsonIgnore]
public override MediaType MediaType => MediaType.Book;
diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs
index 25cbcedc5f..b1f7f29bad 100644
--- a/MediaBrowser.Controller/Entities/Folder.cs
+++ b/MediaBrowser.Controller/Entities/Folder.cs
@@ -384,6 +384,7 @@ namespace MediaBrowser.Controller.Entities
cancellationToken.ThrowIfCancellationRequested();
var validChildren = new List();
+ var accessibleChildren = new List();
var validChildrenNeedGeneration = false;
if (IsFileProtocol)
@@ -438,12 +439,19 @@ namespace MediaBrowser.Controller.Entities
{
if (!IsLibraryFolderAccessible(directoryService, child, allowRemoveRoot))
{
+ // Preserve inaccessible items so they aren't treated as removed.
+ if (currentChildren.TryGetValue(child.Id, out var childrenToKeep))
+ {
+ validChildren.Add(childrenToKeep);
+ }
+
continue;
}
if (currentChildren.TryGetValue(child.Id, out BaseItem currentChild))
{
validChildren.Add(currentChild);
+ accessibleChildren.Add(currentChild);
if (currentChild.UpdateFromResolvedItem(child) > ItemUpdateType.None)
{
@@ -480,11 +488,12 @@ namespace MediaBrowser.Controller.Entities
child.SetParent(this);
newItems.Add(child);
validChildren.Add(child);
+ accessibleChildren.Add(child);
}
// That's all the new and changed ones - now see if any have been removed and need cleanup
var itemsRemoved = currentChildren.Values.Except(validChildren).ToList();
- var shouldRemove = !IsRoot || allowRemoveRoot;
+
// If it's an AggregateFolder, don't remove
// Collect replaced primaries for deferred deletion (after CreateItems)
var replacedPrimaries = new List<(Video OldPrimary, Video NewPrimary)>();
@@ -497,7 +506,7 @@ namespace MediaBrowser.Controller.Entities
.Where(p => !string.IsNullOrEmpty(p))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
- if (shouldRemove && itemsRemoved.Count > 0)
+ if (itemsRemoved.Count > 0)
{
foreach (var item in itemsRemoved)
{
@@ -703,7 +712,7 @@ namespace MediaBrowser.Controller.Entities
validChildrenNeedGeneration = false;
}
- await ValidateSubFolders(validChildren.OfType().ToList(), directoryService, innerProgress, cancellationToken).ConfigureAwait(false);
+ await ValidateSubFolders(accessibleChildren.OfType().ToList(), directoryService, innerProgress, cancellationToken).ConfigureAwait(false);
}
if (refreshChildMetadata)
@@ -742,7 +751,7 @@ namespace MediaBrowser.Controller.Entities
validChildren = Children.ToList();
}
- await RefreshMetadataRecursive(validChildren, refreshOptions, recursive, innerProgress, cancellationToken).ConfigureAwait(false);
+ await RefreshMetadataRecursive(accessibleChildren, refreshOptions, recursive, innerProgress, cancellationToken).ConfigureAwait(false);
}
}
}
diff --git a/MediaBrowser.Controller/Entities/IHasSpecialFeatures.cs b/MediaBrowser.Controller/Entities/IHasSpecialFeatures.cs
index f47d2162f7..0cdc8bce03 100644
--- a/MediaBrowser.Controller/Entities/IHasSpecialFeatures.cs
+++ b/MediaBrowser.Controller/Entities/IHasSpecialFeatures.cs
@@ -1,12 +1,13 @@
#nullable disable
-#pragma warning disable CS1591
-
using System;
using System.Collections.Generic;
namespace MediaBrowser.Controller.Entities
{
+ ///
+ /// Interface for items that have special features.
+ ///
public interface IHasSpecialFeatures
{
///
diff --git a/MediaBrowser.Controller/Entities/IHasStartDate.cs b/MediaBrowser.Controller/Entities/IHasStartDate.cs
index dab15eb018..47df09d1ce 100644
--- a/MediaBrowser.Controller/Entities/IHasStartDate.cs
+++ b/MediaBrowser.Controller/Entities/IHasStartDate.cs
@@ -1,11 +1,15 @@
-#pragma warning disable CS1591
-
using System;
namespace MediaBrowser.Controller.Entities
{
+ ///
+ /// Interface for items that have a start date.
+ ///
public interface IHasStartDate
{
+ ///
+ /// Gets or sets the start date.
+ ///
DateTime StartDate { get; set; }
}
}
diff --git a/MediaBrowser.Controller/Entities/IItemByName.cs b/MediaBrowser.Controller/Entities/IItemByName.cs
index 4928bda7a2..756dbecb98 100644
--- a/MediaBrowser.Controller/Entities/IItemByName.cs
+++ b/MediaBrowser.Controller/Entities/IItemByName.cs
@@ -1,19 +1,28 @@
-#pragma warning disable CS1591
-
using System.Collections.Generic;
namespace MediaBrowser.Controller.Entities
{
///
- /// Marker interface.
+ /// Marker interface for items that represent a name, like a genre or a studio.
///
public interface IItemByName
{
+ ///
+ /// Gets the items tagged with this name.
+ ///
+ /// The query.
+ /// The tagged items.
IReadOnlyList GetTaggedItems(InternalItemsQuery query);
}
+ ///
+ /// Interface for by-name items that can also be accessed as a regular library item.
+ ///
public interface IHasDualAccess : IItemByName
{
+ ///
+ /// Gets a value indicating whether the item is accessed by name.
+ ///
bool IsAccessedByName { get; }
}
}
diff --git a/MediaBrowser.Controller/Entities/ISupportsPlaceHolders.cs b/MediaBrowser.Controller/Entities/ISupportsPlaceHolders.cs
index cdda8ea399..0f8904df5c 100644
--- a/MediaBrowser.Controller/Entities/ISupportsPlaceHolders.cs
+++ b/MediaBrowser.Controller/Entities/ISupportsPlaceHolders.cs
@@ -1,7 +1,8 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Controller.Entities
{
+ ///
+ /// Interface for items that can be placeholders.
+ ///
public interface ISupportsPlaceHolders
{
///
diff --git a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
index 422c40ce5d..3b1f6a961f 100644
--- a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
+++ b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
@@ -72,6 +72,102 @@ namespace MediaBrowser.Controller.Entities
}
}
+ ///
+ /// Gets a value indicating whether the query carries any criteria that narrows the
+ /// result set, as opposed to user context, pagination, sorting or DTO options.
+ ///
+ public bool HasFilters =>
+ IncludeItemTypes.Length > 0
+ || ExcludeItemTypes.Length > 0
+ || Genres.Count > 0
+ || GenreIds.Count > 0
+ || Years.Length > 0
+ || Tags.Length > 0
+ || ExcludeTags.Length > 0
+ || OfficialRatings.Length > 0
+ || StudioIds.Length > 0
+ || ArtistIds.Length > 0
+ || AlbumArtistIds.Length > 0
+ || ContributingArtistIds.Length > 0
+ || ExcludeArtistIds.Length > 0
+ || AlbumIds.Length > 0
+ || PersonIds.Length > 0
+ || PersonTypes.Length > 0
+ || MediaTypes.Length > 0
+ || VideoTypes.Length > 0
+ || ImageTypes.Length > 0
+ || SeriesStatuses.Length > 0
+ || ItemIds.Length > 0
+ || ExcludeItemIds.Length > 0
+ || AudioLanguages.Count > 0
+ || SubtitleLanguages.Count > 0
+ || LinkedChildAncestorIds.Length > 0
+ || AncestorIds.Length > 0
+ || IsFavorite.HasValue
+ || IsFavoriteOrLiked.HasValue
+ || IsLiked.HasValue
+ || IsPlayed.HasValue
+ || IsResumable.HasValue
+ || IsFolder.HasValue
+ || IsMissing.HasValue
+ || IsUnaired.HasValue
+ || IsSpecialSeason.HasValue
+ || Is3D.HasValue
+ || IsHD.HasValue
+ || Is4K.HasValue
+ || IsLocked.HasValue
+ || IsPlaceHolder.HasValue
+ || IsMovie.HasValue
+ || IsSports.HasValue
+ || IsKids.HasValue
+ || IsNews.HasValue
+ || IsSeries.HasValue
+ || IsAiring.HasValue
+ || IsVirtualItem.HasValue
+ || HasImdbId.HasValue
+ || HasTmdbId.HasValue
+ || HasTvdbId.HasValue
+ || HasOverview.HasValue
+ || HasOfficialRating.HasValue
+ || HasParentalRating.HasValue
+ || HasThemeSong.HasValue
+ || HasThemeVideo.HasValue
+ || HasSubtitles.HasValue
+ || HasSpecialFeature.HasValue
+ || HasTrailer.HasValue
+ || HasChapterImages.HasValue
+ || MinCriticRating.HasValue
+ || MinCommunityRating.HasValue
+ || MinParentalRating is not null
+ || MinIndexNumber.HasValue
+ || MinParentAndIndexNumber.HasValue
+ || IndexNumber.HasValue
+ || ParentIndexNumber.HasValue
+ || AiredDuringSeason.HasValue
+ || MinWidth.HasValue
+ || MinHeight.HasValue
+ || MaxWidth.HasValue
+ || MaxHeight.HasValue
+ || MinPremiereDate.HasValue
+ || MaxPremiereDate.HasValue
+ || MinStartDate.HasValue
+ || MaxStartDate.HasValue
+ || MinEndDate.HasValue
+ || MaxEndDate.HasValue
+ || MinDateCreated.HasValue
+ || MinDateLastSaved.HasValue
+ || MinDateLastSavedForUser.HasValue
+ || AdjacentTo.HasValue
+ || !string.IsNullOrEmpty(NameStartsWith)
+ || !string.IsNullOrEmpty(NameStartsWithOrGreater)
+ || !string.IsNullOrEmpty(NameLessThan)
+ || !string.IsNullOrEmpty(NameContains)
+ || !string.IsNullOrEmpty(MinSortName)
+ || !string.IsNullOrEmpty(Name)
+ || !string.IsNullOrEmpty(Person)
+ || !string.IsNullOrEmpty(SearchTerm)
+ || !string.IsNullOrEmpty(Path);
+
public bool Recursive { get; set; }
public int? StartIndex { get; set; }
diff --git a/MediaBrowser.Controller/Entities/Person.cs b/MediaBrowser.Controller/Entities/Person.cs
index 5cc4d322f7..14325d971a 100644
--- a/MediaBrowser.Controller/Entities/Person.cs
+++ b/MediaBrowser.Controller/Entities/Person.cs
@@ -5,6 +5,7 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
+using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Extensions;
using MediaBrowser.Controller.Providers;
using Microsoft.Extensions.Logging;
@@ -75,6 +76,16 @@ namespace MediaBrowser.Controller.Entities
return false;
}
+ ///
+ ///
+ /// People don't carry the tags of the media they appear in, so the allowed tags check
+ /// is skipped for them; otherwise no person would be visible to users with allowed tags configured.
+ ///
+ public override bool IsVisible(User user, bool skipAllowedTagsCheck = false)
+ {
+ return base.IsVisible(user, true);
+ }
+
public override bool IsSaveLocalMetadataEnabled()
{
return true;
diff --git a/MediaBrowser.Controller/Entities/SourceType.cs b/MediaBrowser.Controller/Entities/SourceType.cs
index be19e1bdae..97aa22dc04 100644
--- a/MediaBrowser.Controller/Entities/SourceType.cs
+++ b/MediaBrowser.Controller/Entities/SourceType.cs
@@ -1,11 +1,23 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Controller.Entities
{
+ ///
+ /// The source of an item.
+ ///
public enum SourceType
{
+ ///
+ /// The item comes from a library.
+ ///
Library = 0,
+
+ ///
+ /// The item comes from a channel.
+ ///
Channel = 1,
+
+ ///
+ /// The item comes from live TV.
+ ///
LiveTV = 2
}
}
diff --git a/MediaBrowser.Controller/Entities/UserRootFolder.cs b/MediaBrowser.Controller/Entities/UserRootFolder.cs
index deed3631b8..d5be997b84 100644
--- a/MediaBrowser.Controller/Entities/UserRootFolder.cs
+++ b/MediaBrowser.Controller/Entities/UserRootFolder.cs
@@ -69,8 +69,14 @@ namespace MediaBrowser.Controller.Entities
protected override QueryResult GetItemsInternal(InternalItemsQuery query)
{
- if (query.Recursive)
+ // The user root holds no items of its own - a plain listing returns the user's
+ // views. But a request carrying any filter is a search across the libraries, so
+ // resolve it through the recursive query path even when Recursive wasn't set;
+ // otherwise the filters would be silently dropped. Recursive is set so the
+ // downstream query (ancestor/top-parent scoping) treats it as a recursive search.
+ if (query.Recursive || query.HasFilters)
{
+ query.Recursive = true;
return QueryRecursive(query);
}
diff --git a/MediaBrowser.Controller/Entities/UserViewBuilder.cs b/MediaBrowser.Controller/Entities/UserViewBuilder.cs
index cb05056601..c57ed2faf8 100644
--- a/MediaBrowser.Controller/Entities/UserViewBuilder.cs
+++ b/MediaBrowser.Controller/Entities/UserViewBuilder.cs
@@ -61,6 +61,9 @@ namespace MediaBrowser.Controller.Entities
case CollectionType.folders:
return GetResult(_libraryManager.GetUserRootFolder().GetChildren(user, true), query);
+ case CollectionType.books:
+ return GetBooks(queryParent, user, query);
+
case CollectionType.tvshows:
return GetTvView(queryParent, user, query);
@@ -190,6 +193,17 @@ namespace MediaBrowser.Controller.Entities
return _libraryManager.GetItemsResult(query);
}
+ private QueryResult GetBooks(Folder parent, User user, InternalItemsQuery query)
+ {
+ query.Recursive = true;
+ query.Parent = parent;
+ query.SetUser(user);
+
+ query.IncludeItemTypes = new[] { BaseItemKind.Book, BaseItemKind.AudioBook };
+
+ return _libraryManager.GetItemsResult(query);
+ }
+
private QueryResult GetMovieMovies(Folder parent, User user, InternalItemsQuery query)
{
query.Recursive = true;
diff --git a/MediaBrowser.Controller/Entities/Video.cs b/MediaBrowser.Controller/Entities/Video.cs
index e7a5672ebd..0606fe1870 100644
--- a/MediaBrowser.Controller/Entities/Video.cs
+++ b/MediaBrowser.Controller/Entities/Video.cs
@@ -34,11 +34,11 @@ namespace MediaBrowser.Controller.Entities
{
public Video()
{
- AdditionalParts = Array.Empty();
- LocalAlternateVersions = Array.Empty();
- SubtitleFiles = Array.Empty();
- AudioFiles = Array.Empty();
- LinkedAlternateVersions = Array.Empty();
+ AdditionalParts = [];
+ LocalAlternateVersions = [];
+ SubtitleFiles = [];
+ AudioFiles = [];
+ LinkedAlternateVersions = [];
}
[JsonIgnore]
@@ -254,7 +254,7 @@ namespace MediaBrowser.Controller.Entities
private int GetMediaSourceCount(HashSet callstack = null)
{
- callstack ??= new();
+ callstack ??= [];
if (PrimaryVersionId.HasValue)
{
var item = LibraryManager.GetItemById(PrimaryVersionId.Value);
@@ -335,6 +335,102 @@ namespace MediaBrowser.Controller.Entities
PresentationUniqueKey = CreatePresentationUniqueKey();
}
+ ///
+ /// Marks the played status of this video and propagates it to its alternate versions.
+ ///
+ /// The user.
+ /// The date played.
+ /// if set to true [reset position].
+ public override void MarkPlayed(User user, DateTime? datePlayed, bool resetPosition)
+ {
+ base.MarkPlayed(user, datePlayed, resetPosition);
+ PropagatePlayedState(user, true, resetPosition);
+ }
+
+ ///
+ /// Marks this video unplayed and propagates the change to its alternate versions.
+ ///
+ /// The user.
+ public override void MarkUnplayed(User user)
+ {
+ base.MarkUnplayed(user);
+
+ // MarkUnplayed always clears the position on this video, so reset the versions too.
+ PropagatePlayedState(user, false, true);
+ }
+
+ ///
+ /// Propagates the played status to every alternate version of this video.
+ ///
+ /// The user.
+ /// The played status to apply to the alternate versions.
+ /// When marking played, controls whether each version's resume point
+ /// is also reset (true) or left untouched (false). Ignored when marking unplayed,
+ /// which always fully resets every version.
+ public void PropagatePlayedState(User user, bool played, bool resetPosition = true)
+ {
+ ArgumentNullException.ThrowIfNull(user);
+
+ if (!PrimaryVersionId.HasValue && LinkedAlternateVersions.Length == 0 && !HasLocalAlternateVersions)
+ {
+ return;
+ }
+
+ foreach (var (item, _) in GetAllItemsForMediaSources())
+ {
+ if (item.Id.Equals(Id) || item is not Video)
+ {
+ continue;
+ }
+
+ if (played)
+ {
+ var dto = new UpdateUserItemDataDto { Played = true };
+ if (resetPosition)
+ {
+ dto.PlaybackPositionTicks = 0;
+ }
+
+ // SaveUserData only writes the fields set on the DTO, so play count and other state are preserved.
+ UserDataManager.SaveUserData(user, item, dto, UserDataSaveReason.TogglePlayed);
+ }
+ else
+ {
+ var data = UserDataManager.GetUserData(user, item);
+ if (data is null)
+ {
+ continue;
+ }
+
+ ResetPlayedState(data);
+ UserDataManager.SaveUserData(user, item, data, UserDataSaveReason.TogglePlayed, CancellationToken.None);
+ }
+ }
+ }
+
+ ///
+ /// Gets this video together with all of its alternate versions (local and linked and, when this
+ /// is itself an alternate, the primary and the primary's other versions), deduplicated.
+ ///
+ /// This video and every alternate version of it.
+ public IReadOnlyList
[SupportedOSPlatform("macos")]
-public static class ApplePlatformHelper
+public static partial class ApplePlatformHelper
{
private static readonly string[] _av1DecodeBlacklistedCpuClass = ["M1", "M2"];
- private static string GetSysctlValue(ReadOnlySpan name)
+ internal static string GetSysctlValue(string name)
{
- IntPtr length = IntPtr.Zero;
+ nuint length = 0;
// Get length of the value
- int osStatus = SysctlByName(name, IntPtr.Zero, ref length, IntPtr.Zero, 0);
-
- if (osStatus != 0)
+ int osStatus = sysctlbyname(name, Span.Empty, ref length, IntPtr.Zero, 0);
+ if (osStatus != 0 || length == 0)
{
- throw new NotSupportedException($"Failed to get sysctl value for {System.Text.Encoding.UTF8.GetString(name)} with error {osStatus}");
+ throw new NotSupportedException($"Failed to get sysctl value for {name} with error {osStatus}");
}
- IntPtr buffer = Marshal.AllocHGlobal(length.ToInt32());
+ byte[] buffer = ArrayPool.Shared.Rent((int)length);
try
{
- osStatus = SysctlByName(name, buffer, ref length, IntPtr.Zero, 0);
+ osStatus = sysctlbyname(name, buffer.AsSpan()[..(int)length], ref length, IntPtr.Zero, 0);
if (osStatus != 0)
{
- throw new NotSupportedException($"Failed to get sysctl value for {System.Text.Encoding.UTF8.GetString(name)} with error {osStatus}");
+ throw new NotSupportedException($"Failed to get sysctl value for {name} with error {osStatus}");
}
- return Marshal.PtrToStringAnsi(buffer) ?? string.Empty;
+ if (length < 1)
+ {
+ return string.Empty;
+ }
+
+ ReadOnlySpan data = buffer.AsSpan()[..(int)(length - 1)];
+ return Encoding.UTF8.GetString(data);
}
finally
{
- Marshal.FreeHGlobal(buffer);
+ ArrayPool.Shared.Return(buffer);
}
}
- private static int SysctlByName(ReadOnlySpan name, IntPtr oldp, ref IntPtr oldlenp, IntPtr newp, uint newlen)
- {
- return NativeMethods.SysctlByName(name.ToArray(), oldp, ref oldlenp, newp, newlen);
- }
-
///
/// Check if the current system has hardware acceleration for AV1 decoding.
///
@@ -63,7 +65,7 @@ public static class ApplePlatformHelper
try
{
- string cpuBrandString = GetSysctlValue("machdep.cpu.brand_string"u8);
+ string cpuBrandString = GetSysctlValue("machdep.cpu.brand_string");
return !_av1DecodeBlacklistedCpuClass.Any(blacklistedCpuClass => cpuBrandString.Contains(blacklistedCpuClass, StringComparison.OrdinalIgnoreCase));
}
catch (NotSupportedException e)
@@ -78,10 +80,7 @@ public static class ApplePlatformHelper
return false;
}
- private static class NativeMethods
- {
- [DllImport("libc", EntryPoint = "sysctlbyname", SetLastError = true)]
- [DefaultDllImportSearchPaths(DllImportSearchPath.SafeDirectories)]
- internal static extern int SysctlByName(byte[] name, IntPtr oldp, ref IntPtr oldlenp, IntPtr newp, uint newlen);
- }
+ [LibraryImport("libc", EntryPoint = "sysctlbyname", SetLastError = true)]
+ [DefaultDllImportSearchPaths(DllImportSearchPath.SafeDirectories)]
+ internal static partial int sysctlbyname([MarshalAs(UnmanagedType.LPStr)] string name, Span oldp, ref nuint oldlenp, IntPtr newp, nuint newlen);
}
diff --git a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs
index 68d6d215b2..91d0c3d5a6 100644
--- a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs
+++ b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs
@@ -6,7 +6,9 @@ using System.Diagnostics;
using System.Globalization;
using System.Linq;
using System.Runtime.Versioning;
+using System.Text;
using System.Text.RegularExpressions;
+using System.Threading.Tasks;
using MediaBrowser.Controller.MediaEncoding;
using Microsoft.Extensions.Logging;
@@ -184,8 +186,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
{ "libavdevice", new Version(58, 13) },
{ "libavfilter", new Version(7, 110) },
{ "libswscale", new Version(5, 9) },
- { "libswresample", new Version(3, 9) },
- { "libpostproc", new Version(55, 9) }
+ { "libswresample", new Version(3, 9) }
};
private readonly ILogger _logger;
@@ -645,7 +646,9 @@ namespace MediaBrowser.MediaEncoding.Encoder
WindowStyle = ProcessWindowStyle.Hidden,
ErrorDialog = false,
RedirectStandardInput = redirectStandardIn,
+ StandardOutputEncoding = Encoding.UTF8,
RedirectStandardOutput = true,
+ StandardErrorEncoding = Encoding.UTF8,
RedirectStandardError = true
}
})
@@ -660,8 +663,15 @@ namespace MediaBrowser.MediaEncoding.Encoder
writer.Write(testKey);
}
- using var reader = readStdErr ? process.StandardError : process.StandardOutput;
- return reader.ReadToEnd();
+ // Drain both streams concurrently to prevent pipe hanging, see #17429
+ using var standardOutput = process.StandardOutput;
+ using var standardError = process.StandardError;
+ var standardOutputTask = standardOutput.ReadToEndAsync();
+ var standardErrorTask = standardError.ReadToEndAsync();
+ process.WaitForExit();
+ Task.WaitAll(standardOutputTask, standardErrorTask);
+
+ return (readStdErr ? standardErrorTask : standardOutputTask).GetAwaiter().GetResult();
}
}
diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
index 66bf6ebd24..0ddd378352 100644
--- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
+++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
@@ -7,6 +7,7 @@ using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
+using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
@@ -528,6 +529,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
UseShellExecute = false,
// Must consume both or ffmpeg may hang due to deadlocks.
+ StandardOutputEncoding = Encoding.UTF8,
RedirectStandardOutput = true,
FileName = _ffprobePath,
@@ -926,6 +928,25 @@ namespace MediaBrowser.MediaEncoding.Encoder
throw new InvalidOperationException("EncodingHelper returned empty or invalid filter parameters.");
}
+ // Normalize invalid PTS from containers for non keyframe only mode
+ if (!enableKeyFrameOnlyExtraction)
+ {
+ var fpsFilterIndex = filterParam.IndexOf("fps=", StringComparison.Ordinal);
+ if (fpsFilterIndex >= 0)
+ {
+ var inputFrameRate = (imageStream.ReferenceFrameRate.HasValue && imageStream.ReferenceFrameRate > 0)
+ ? imageStream.ReferenceFrameRate.Value : 30;
+
+ var setPtsFilter = string.Create(CultureInfo.InvariantCulture, $"setpts=N/{inputFrameRate:F3}/TB,");
+
+ filterParam = filterParam.Insert(fpsFilterIndex, setPtsFilter);
+ }
+ else
+ {
+ throw new InvalidOperationException("EncodingHelper returned invalid filter parameters.");
+ }
+ }
+
try
{
return await ExtractVideoImagesOnIntervalInternal(
diff --git a/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj b/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj
index fc11047a7f..288cd3e189 100644
--- a/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj
+++ b/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj
@@ -9,6 +9,7 @@
net10.0
false
true
+ true
diff --git a/MediaBrowser.MediaEncoding/Probing/FFProbeHelpers.cs b/MediaBrowser.MediaEncoding/Probing/FFProbeHelpers.cs
index 975c2b8161..fa2085ca6f 100644
--- a/MediaBrowser.MediaEncoding/Probing/FFProbeHelpers.cs
+++ b/MediaBrowser.MediaEncoding/Probing/FFProbeHelpers.cs
@@ -76,7 +76,13 @@ namespace MediaBrowser.MediaEncoding.Probing
/// Dictionary{System.StringSystem.String}.
private static Dictionary ConvertDictionaryToCaseInsensitive(IReadOnlyDictionary dict)
{
- return new Dictionary(dict, StringComparer.OrdinalIgnoreCase);
+ var result = new Dictionary(dict.Count, StringComparer.OrdinalIgnoreCase);
+ foreach (var (key, value) in dict)
+ {
+ result.TryAdd(key, value);
+ }
+
+ return result;
}
}
}
diff --git a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs
index 06060988e2..b6acfdbf3b 100644
--- a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs
+++ b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs
@@ -254,16 +254,38 @@ namespace MediaBrowser.MediaEncoding.Probing
{
if (mediaStream.Type == MediaStreamType.Audio && !mediaStream.BitRate.HasValue)
{
- mediaStream.BitRate = GetEstimatedAudioBitrate(mediaStream.Codec, mediaStream.Channels);
+ mediaStream.BitRate = GetEstimatedAudioBitrate(mediaStream.Codec, mediaStream.Profile, mediaStream.Channels);
}
}
- var videoStreamsBitrate = info.MediaStreams.Where(i => i.Type == MediaStreamType.Video).Select(i => i.BitRate ?? 0).Sum();
- // If ffprobe reported the container bitrate as being the same as the video stream bitrate, then it's wrong
- if (videoStreamsBitrate == (info.Bitrate ?? 0))
+ // ffprobe frequently omits the per-stream video bitrate (common in MP4/MKV containers).
+ // Estimate the missing video bitrate as the container bitrate minus the combined stream bitrates.
+ var videoStreams = info.MediaStreams.Where(i => i.Type == MediaStreamType.Video).ToList();
+ if (info.Bitrate.HasValue
+ && videoStreams.Count == 1
+ && !videoStreams[0].BitRate.HasValue)
{
- info.InferTotalBitrate(true);
+ var otherStreams = info.MediaStreams
+ .Where(i => i.Type != MediaStreamType.Video && !i.IsExternal)
+ .ToList();
+
+ // Only attribute the leftover bitrate to the video stream if every audio stream's bitrate is known.
+ var audioBitratesKnown = otherStreams
+ .Where(i => i.Type == MediaStreamType.Audio)
+ .All(i => i.BitRate.HasValue);
+
+ if (audioBitratesKnown)
+ {
+ var estimatedVideoBitrate = info.Bitrate.Value - otherStreams.Sum(i => i.BitRate ?? 0);
+ if (estimatedVideoBitrate > 0)
+ {
+ videoStreams[0].BitRate = estimatedVideoBitrate;
+ }
+ }
}
+
+ // If the container bitrate is still unknown, infer it from the sum of the streams.
+ info.InferTotalBitrate();
}
return info;
@@ -316,54 +338,34 @@ namespace MediaBrowser.MediaEncoding.Probing
return string.Join(',', splitFormat.Where(s => !string.IsNullOrEmpty(s)));
}
- private static int? GetEstimatedAudioBitrate(string codec, int? channels)
+ internal static int? GetEstimatedAudioBitrate(string codec, string profile, int? channels)
{
- if (!channels.HasValue)
+ if (!channels.HasValue || channels.Value < 1 || string.IsNullOrEmpty(codec))
{
return null;
}
- var channelsValue = channels.Value;
+ // Rough typical bitrates used only as a fallback when ffprobe doesn't report a stream bitrate.
+ var channelCount = channels.Value;
+ var isMultichannel = channelCount > 2;
- if (string.Equals(codec, "aac", StringComparison.OrdinalIgnoreCase)
- || string.Equals(codec, "mp3", StringComparison.OrdinalIgnoreCase))
+ return codec.ToLowerInvariant() switch
{
- switch (channelsValue)
- {
- case <= 2:
- return 192000;
- case >= 5:
- return 320000;
- }
- }
-
- if (string.Equals(codec, "ac3", StringComparison.OrdinalIgnoreCase)
- || string.Equals(codec, "eac3", StringComparison.OrdinalIgnoreCase))
- {
- switch (channelsValue)
- {
- case <= 2:
- return 192000;
- case >= 5:
- return 640000;
- }
- }
-
- if (string.Equals(codec, "flac", StringComparison.OrdinalIgnoreCase)
- || string.Equals(codec, "alac", StringComparison.OrdinalIgnoreCase))
- {
- switch (channelsValue)
- {
- case <= 2:
- return 960000;
- case >= 5:
- return 2880000;
- }
- }
-
- return null;
+ "aac" or "mp3" or "mp2" => isMultichannel ? 320000 : 192000,
+ "ac3" or "eac3" => isMultichannel ? 640000 : 192000,
+ "dts" or "dca" => IsDtsLossless(profile) ? channelCount * 700000 : (isMultichannel ? 1509000 : 768000),
+ "opus" => isMultichannel ? 256000 : 128000,
+ "vorbis" => isMultichannel ? 320000 : 160000,
+ "wmav1" or "wmav2" or "wmapro" => isMultichannel ? 384000 : 192000,
+ "flac" or "alac" => channelCount * 480000,
+ "truehd" or "mlp" => channelCount * 700000,
+ _ => null
+ };
}
+ private static bool IsDtsLossless(string profile)
+ => profile is not null && profile.Contains("HD MA", StringComparison.OrdinalIgnoreCase);
+
private void FetchFromItunesInfo(string xml, MediaInfo info)
{
// Make things simpler and strip out the dtd
@@ -730,9 +732,10 @@ namespace MediaBrowser.MediaEncoding.Probing
stream.LocalizedDefault = _localization.GetLocalizedString("Default");
stream.LocalizedExternal = _localization.GetLocalizedString("External");
stream.LocalizedOriginal = _localization.GetLocalizedString("Original");
- stream.LocalizedLanguage = string.IsNullOrEmpty(stream.Language)
- ? null
- : _localization.FindLanguageInfo(stream.Language)?.DisplayName;
+ if (!string.IsNullOrEmpty(stream.Language))
+ {
+ stream.LocalizedLanguage = _localization.GetLanguageDisplayName(stream.Language);
+ }
stream.Channels = streamInfo.Channels;
@@ -754,11 +757,17 @@ namespace MediaBrowser.MediaEncoding.Probing
if (string.IsNullOrEmpty(stream.Title))
{
- // mp4 missing track title workaround: fall back to handler_name if populated and not the default "SoundHandler"
- string handlerName = GetDictionaryValue(streamInfo.Tags, "handler_name");
- if (!string.IsNullOrEmpty(handlerName) && !string.Equals(handlerName, "SoundHandler", StringComparison.OrdinalIgnoreCase))
+ // FFprobe exposes MP4 track names via the name tag rather than title
+ stream.Title = GetDictionaryValue(streamInfo.Tags, "name");
+
+ if (string.IsNullOrEmpty(stream.Title))
{
- stream.Title = handlerName;
+ // fall back to handler_name if populated and not the default "SoundHandler"
+ string handlerName = GetDictionaryValue(streamInfo.Tags, "handler_name");
+ if (!string.IsNullOrEmpty(handlerName) && !string.Equals(handlerName, "SoundHandler", StringComparison.OrdinalIgnoreCase))
+ {
+ stream.Title = handlerName;
+ }
}
}
}
@@ -771,17 +780,24 @@ namespace MediaBrowser.MediaEncoding.Probing
stream.LocalizedForced = _localization.GetLocalizedString("Forced");
stream.LocalizedExternal = _localization.GetLocalizedString("External");
stream.LocalizedHearingImpaired = _localization.GetLocalizedString("HearingImpaired");
- stream.LocalizedLanguage = string.IsNullOrEmpty(stream.Language)
- ? null
- : _localization.FindLanguageInfo(stream.Language)?.DisplayName;
+ if (!string.IsNullOrEmpty(stream.Language))
+ {
+ stream.LocalizedLanguage = _localization.GetLanguageDisplayName(stream.Language);
+ }
if (string.IsNullOrEmpty(stream.Title))
{
- // mp4 missing track title workaround: fall back to handler_name if populated and not the default "SubtitleHandler"
- string handlerName = GetDictionaryValue(streamInfo.Tags, "handler_name");
- if (!string.IsNullOrEmpty(handlerName) && !string.Equals(handlerName, "SubtitleHandler", StringComparison.OrdinalIgnoreCase))
+ // FFprobe exposes MP4 track names via the name tag rather than title
+ stream.Title = GetDictionaryValue(streamInfo.Tags, "name");
+
+ if (string.IsNullOrEmpty(stream.Title))
{
- stream.Title = handlerName;
+ // fall back to handler_name if populated and not the default "SubtitleHandler"
+ string handlerName = GetDictionaryValue(streamInfo.Tags, "handler_name");
+ if (!string.IsNullOrEmpty(handlerName) && !string.Equals(handlerName, "SubtitleHandler", StringComparison.OrdinalIgnoreCase))
+ {
+ stream.Title = handlerName;
+ }
}
}
}
@@ -972,10 +988,12 @@ namespace MediaBrowser.MediaEncoding.Probing
bitrate = value;
}
- // The bitrate info of FLAC musics and some videos is included in formatInfo.
+ // The bitrate info of FLAC audio is included in formatInfo.
+ // Don't do this for video streams: formatInfo.BitRate is the overall container
+ // bitrate (video + audio + subtitles + overhead), not the video bitrate.
if (bitrate == 0
&& formatInfo is not null
- && (stream.Type == MediaStreamType.Video || (isAudio && stream.Type == MediaStreamType.Audio)))
+ && isAudio && stream.Type == MediaStreamType.Audio)
{
// If the stream info doesn't have a bitrate get the value from the media format info
if (int.TryParse(formatInfo.BitRate, CultureInfo.InvariantCulture, out value))
@@ -1260,9 +1278,16 @@ namespace MediaBrowser.MediaEncoding.Probing
}
var duration = GetDictionaryValue(streamInfo.Tags, "DURATION-eng") ?? GetDictionaryValue(streamInfo.Tags, "DURATION");
- if (TimeSpan.TryParse(duration, out var parsedDuration))
+ if (!string.IsNullOrEmpty(duration))
{
- return parsedDuration.TotalSeconds;
+ // Matroska DURATION tags use nanosecond precision (e.g. "00:00:05.023000000"), but
+ // TimeSpan only supports up to 7 fractional digits (ticks). Trim the surplus digits so
+ // these durations parse instead of being silently dropped.
+ duration = DurationOverPrecisionRegex().Replace(duration, "$1");
+ if (TimeSpan.TryParse(duration, CultureInfo.InvariantCulture, out var parsedDuration))
+ {
+ return parsedDuration.TotalSeconds;
+ }
}
return null;
@@ -1630,7 +1655,7 @@ namespace MediaBrowser.MediaEncoding.Probing
// Credit to MCEBuddy: https://mcebuddy2x.codeplex.com/
// DateTime is reported along with timezone info (typically Z i.e. UTC hence assume None)
- if (tags.TryGetValue("WM/MediaOriginalBroadcastDateTime", out var premiereDateString) && DateTime.TryParse(year, null, DateTimeStyles.AdjustToUniversal, out var parsedDate))
+ if (tags.TryGetValue("WM/MediaOriginalBroadcastDateTime", out var premiereDateString) && DateTime.TryParse(year, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out var parsedDate))
{
video.PremiereDate = parsedDate;
}
@@ -1764,5 +1789,8 @@ namespace MediaBrowser.MediaEncoding.Probing
[GeneratedRegex("(?.*) \\((?.*)\\)")]
private static partial Regex PerformerRegex();
+
+ [GeneratedRegex(@"(\.\d{7})\d+")]
+ private static partial Regex DurationOverPrecisionRegex();
}
}
diff --git a/MediaBrowser.MediaEncoding/Subtitles/ISubtitleParser.cs b/MediaBrowser.MediaEncoding/Subtitles/ISubtitleParser.cs
index bd13437fb6..7566616f70 100644
--- a/MediaBrowser.MediaEncoding/Subtitles/ISubtitleParser.cs
+++ b/MediaBrowser.MediaEncoding/Subtitles/ISubtitleParser.cs
@@ -1,7 +1,7 @@
#pragma warning disable CS1591
using System.IO;
-using MediaBrowser.Model.MediaInfo;
+using Nikse.SubtitleEdit.Core.Common;
namespace MediaBrowser.MediaEncoding.Subtitles
{
@@ -12,8 +12,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles
///
/// The stream.
/// The file extension.
- /// SubtitleTrackInfo.
- SubtitleTrackInfo Parse(Stream stream, string fileExtension);
+ /// The parsed subtitle.
+ Subtitle Parse(Stream stream, string fileExtension);
///
/// Determines whether the file extension is supported by the parser.
diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEditParser.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEditParser.cs
index d060b247da..d75eea5904 100644
--- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEditParser.cs
+++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEditParser.cs
@@ -1,10 +1,8 @@
using System;
using System.Collections.Generic;
-using System.Globalization;
using System.IO;
using System.Linq;
using Jellyfin.Extensions;
-using MediaBrowser.Model.MediaInfo;
using Microsoft.Extensions.Logging;
using Nikse.SubtitleEdit.Core.Common;
using SubtitleFormat = Nikse.SubtitleEdit.Core.SubtitleFormats.SubtitleFormat;
@@ -30,7 +28,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
}
///
- public SubtitleTrackInfo Parse(Stream stream, string fileExtension)
+ public Subtitle Parse(Stream stream, string fileExtension)
{
var subtitle = new Subtitle();
var lines = stream.ReadAllLines().ToList();
@@ -76,21 +74,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
throw new ArgumentException("Unsupported format: " + fileExtension);
}
- var trackInfo = new SubtitleTrackInfo();
- int len = subtitle.Paragraphs.Count;
- var trackEvents = new SubtitleTrackEvent[len];
- for (int i = 0; i < len; i++)
- {
- var p = subtitle.Paragraphs[i];
- trackEvents[i] = new SubtitleTrackEvent(p.Number.ToString(CultureInfo.InvariantCulture), p.Text)
- {
- StartPositionTicks = p.StartTime.TimeSpan.Ticks,
- EndPositionTicks = p.EndTime.TimeSpan.Ticks
- };
- }
-
- trackInfo.TrackEvents = trackEvents;
- return trackInfo;
+ return subtitle;
}
///
diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
index 67e323177b..bd516f0a9f 100644
--- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
+++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
@@ -73,7 +73,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
_serverConfigurationManager = serverConfigurationManager;
}
- private MemoryStream ConvertSubtitles(
+ internal MemoryStream ConvertSubtitles(
Stream stream,
SubtitleInfo inputInfo,
string outputFormat,
@@ -81,7 +81,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
long endTimeTicks,
bool preserveOriginalTimestamps)
{
- var subtitle = Subtitle.Parse(stream, Path.GetExtension(inputInfo.Path));
+ var subtitle = _subtitleParser.Parse(stream, inputInfo.Format);
FilterEvents(subtitle, startTimeTicks, endTimeTicks, preserveOriginalTimestamps);
@@ -163,28 +163,36 @@ namespace MediaBrowser.MediaEncoding.Subtitles
return (stream, fileInfo);
}
- private async Task GetSubtitleStream(SubtitleInfo fileInfo, CancellationToken cancellationToken)
+ internal async Task GetSubtitleStream(SubtitleInfo fileInfo, CancellationToken cancellationToken)
{
- if (fileInfo.Protocol == MediaProtocol.Http)
+ if (fileInfo.IsExternal && MediaStream.IsTextFormat(fileInfo.Format))
{
- var result = await DetectCharset(fileInfo.Path, fileInfo.Protocol, cancellationToken).ConfigureAwait(false);
+ var result = await DetectCharset(fileInfo.Path, cancellationToken).ConfigureAwait(false);
var detected = result.Detected;
- if (detected is not null)
- {
- _logger.LogDebug("charset {CharSet} detected for {Path}", detected.EncodingName, fileInfo.Path);
-
- using var stream = await _httpClientFactory.CreateClient(NamedClient.Default)
+ var stream = fileInfo.Protocol == MediaProtocol.Http
+ ? await _httpClientFactory.CreateClient(NamedClient.Default)
.GetStreamAsync(new Uri(fileInfo.Path), cancellationToken)
- .ConfigureAwait(false);
+ .ConfigureAwait(false)
+ : AsyncFile.OpenRead(fileInfo.Path);
- await using (stream.ConfigureAwait(false))
- {
- using var reader = new StreamReader(stream, detected.Encoding);
- var text = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
+ // Short-circuit when the file is already UTF-8/ASCII.
+ if (detected is null
+ || string.Equals(detected.EncodingName, "utf-8", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(detected.EncodingName, "ascii", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(detected.EncodingName, "us-ascii", StringComparison.OrdinalIgnoreCase))
+ {
+ return stream;
+ }
- return new MemoryStream(Encoding.UTF8.GetBytes(text));
- }
+ _logger.LogDebug("charset {CharSet} detected for {Path}", detected.EncodingName, fileInfo.Path);
+
+ await using (stream.ConfigureAwait(false))
+ {
+ using var reader = new StreamReader(stream, detected.Encoding);
+ var text = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
+
+ return new MemoryStream(Encoding.UTF8.GetBytes(text));
}
}
@@ -445,98 +453,15 @@ namespace MediaBrowser.MediaEncoding.Subtitles
encodingParam = " -sub_charenc " + encodingParam;
}
- int exitCode;
+ var args = string.Format(CultureInfo.InvariantCulture, "-y {0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, inputPath, outputPath);
- using (var process = new Process
- {
- StartInfo = new ProcessStartInfo
- {
- CreateNoWindow = true,
- UseShellExecute = false,
- FileName = _mediaEncoder.EncoderPath,
- Arguments = string.Format(CultureInfo.InvariantCulture, "-y {0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, inputPath, outputPath),
- WindowStyle = ProcessWindowStyle.Hidden,
- ErrorDialog = false
- },
- EnableRaisingEvents = true
- })
- {
- _logger.LogInformation("{0} {1}", process.StartInfo.FileName, process.StartInfo.Arguments);
-
- try
- {
- process.Start();
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error starting ffmpeg");
-
- throw;
- }
-
- try
- {
- var timeoutMinutes = _serverConfigurationManager.GetEncodingOptions().SubtitleExtractionTimeoutMinutes;
- await process.WaitForExitAsync(TimeSpan.FromMinutes(timeoutMinutes)).ConfigureAwait(false);
- exitCode = process.ExitCode;
- }
- catch (OperationCanceledException)
- {
- process.Kill(true);
- exitCode = -1;
- }
- }
-
- var failed = false;
-
- if (exitCode == -1)
- {
- failed = true;
-
- if (File.Exists(outputPath))
- {
- try
- {
- _logger.LogInformation("Deleting converted subtitle due to failure: {Path}", outputPath);
- _fileSystem.DeleteFile(outputPath);
- }
- catch (IOException ex)
- {
- _logger.LogError(ex, "Error deleting converted subtitle {Path}", outputPath);
- }
- }
- }
- else if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0)
- {
- failed = true;
-
- try
- {
- _logger.LogWarning("Deleting converted subtitle due to failure: {Path}", outputPath);
- _fileSystem.DeleteFile(outputPath);
- }
- catch (FileNotFoundException)
- {
- }
- catch (IOException ex)
- {
- _logger.LogError(ex, "Error deleting converted subtitle {Path}", outputPath);
- }
- }
-
- if (failed)
- {
- _logger.LogError("ffmpeg subtitle conversion failed for {Path}", inputPath);
-
- throw new FfmpegException(
- string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle conversion failed for {0}", inputPath));
- }
-
- await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false);
+ await ExtractSubtitlesForFile(
+ inputPath,
+ args,
+ [outputPath],
+ cancellationToken).ConfigureAwait(false);
WriteCacheMeta(outputPath, inputPath);
-
- _logger.LogInformation("ffmpeg subtitle conversion succeeded for {Path}", inputPath);
}
private string GetExtractableSubtitleFormat(MediaStream subtitleStream)
@@ -727,7 +652,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
var outputPaths = new List();
var args = string.Format(
CultureInfo.InvariantCulture,
- "-i {0}",
+ "-y -i {0}",
inputPath);
foreach (var subtitleStream in subtitleStreams)
@@ -781,50 +706,10 @@ namespace MediaBrowser.MediaEncoding.Subtitles
private async Task ExtractSubtitlesForFile(
string inputPath,
string args,
- List outputPaths,
+ IReadOnlyList outputPaths,
CancellationToken cancellationToken)
{
- int exitCode;
-
- using (var process = new Process
- {
- StartInfo = new ProcessStartInfo
- {
- CreateNoWindow = true,
- UseShellExecute = false,
- FileName = _mediaEncoder.EncoderPath,
- Arguments = args,
- WindowStyle = ProcessWindowStyle.Hidden,
- ErrorDialog = false
- },
- EnableRaisingEvents = true
- })
- {
- _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
-
- try
- {
- process.Start();
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error starting ffmpeg");
-
- throw;
- }
-
- try
- {
- var timeoutMinutes = _serverConfigurationManager.GetEncodingOptions().SubtitleExtractionTimeoutMinutes;
- await process.WaitForExitAsync(TimeSpan.FromMinutes(timeoutMinutes)).ConfigureAwait(false);
- exitCode = process.ExitCode;
- }
- catch (OperationCanceledException)
- {
- process.Kill(true);
- exitCode = -1;
- }
- }
+ var (exitCode, ffmpegError) = await RunSubtitleExtractionProcess(args, cancellationToken).ConfigureAwait(false);
var failed = false;
@@ -884,6 +769,13 @@ namespace MediaBrowser.MediaEncoding.Subtitles
if (failed)
{
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (!string.IsNullOrWhiteSpace(ffmpegError))
+ {
+ _logger.LogError("ffmpeg subtitle extraction failed for {InputPath}: {FfmpegOutput}", inputPath, ffmpegError);
+ }
+
throw new FfmpegException(
string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle extraction failed for {0}", inputPath));
}
@@ -941,16 +833,38 @@ namespace MediaBrowser.MediaEncoding.Subtitles
ArgumentException.ThrowIfNullOrEmpty(outputPath);
Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath)));
-
var processArgs = string.Format(
CultureInfo.InvariantCulture,
- "-i {0} -copyts -map 0:{1} -an -vn -c:s {2} \"{3}\"",
+ "-y -i {0} -copyts -map 0:{1} -an -vn -c:s {2} \"{3}\"",
inputPath,
subtitleStreamIndex,
outputCodec,
outputPath);
+ await ExtractSubtitlesForFile(
+ inputPath,
+ processArgs,
+ [outputPath],
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ /// Runs ffmpeg to extract or convert subtitles, capturing its exit code and stderr output.
+ ///
+ ///
+ /// stdin is redirected and closed, and -nostdin is prepended to the arguments, so ffmpeg can never
+ /// block reading an inherited stdin handle (which happens when Jellyfin runs as a service, e.g. under NSSM,
+ /// and stalls subtitle extraction until the timeout). stderr is redirected and drained so a full pipe buffer
+ /// cannot deadlock ffmpeg and so its output can be surfaced on failure; stdout is left un-redirected as it is
+ /// unused for subtitle extraction.
+ ///
+ /// The ffmpeg command line arguments.
+ /// The cancellation token.
+ /// The ffmpeg exit code (-1 on timeout) and its captured stderr output.
+ private async Task<(int ExitCode, string StandardError)> RunSubtitleExtractionProcess(string arguments, CancellationToken cancellationToken)
+ {
int exitCode;
+ var standardError = string.Empty;
using (var process = new Process
{
@@ -958,8 +872,10 @@ namespace MediaBrowser.MediaEncoding.Subtitles
{
CreateNoWindow = true,
UseShellExecute = false,
+ RedirectStandardInput = true,
+ RedirectStandardError = true,
FileName = _mediaEncoder.EncoderPath,
- Arguments = processArgs,
+ Arguments = "-nostdin " + arguments,
WindowStyle = ProcessWindowStyle.Hidden,
ErrorDialog = false
},
@@ -975,14 +891,21 @@ namespace MediaBrowser.MediaEncoding.Subtitles
catch (Exception ex)
{
_logger.LogError(ex, "Error starting ffmpeg");
-
throw;
}
+ // Close stdin so ffmpeg observes EOF instead of blocking on an inherited handle.
+ process.StandardInput.Close();
+
+ // Begin draining stderr before waiting for exit; a full stderr pipe buffer would otherwise deadlock ffmpeg.
+ var standardErrorTask = process.StandardError.ReadToEndAsync(CancellationToken.None);
+ var timeoutMinutes = _serverConfigurationManager.GetEncodingOptions().SubtitleExtractionTimeoutMinutes;
+ using var waitSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ waitSource.CancelAfter(TimeSpan.FromMinutes(timeoutMinutes));
+
try
{
- var timeoutMinutes = _serverConfigurationManager.GetEncodingOptions().SubtitleExtractionTimeoutMinutes;
- await process.WaitForExitAsync(TimeSpan.FromMinutes(timeoutMinutes)).ConfigureAwait(false);
+ await process.WaitForExitAsync(waitSource.Token).ConfigureAwait(false);
exitCode = process.ExitCode;
}
catch (OperationCanceledException)
@@ -990,59 +913,18 @@ namespace MediaBrowser.MediaEncoding.Subtitles
process.Kill(true);
exitCode = -1;
}
- }
-
- var failed = false;
-
- if (exitCode == -1)
- {
- failed = true;
try
{
- _logger.LogWarning("Deleting extracted subtitle due to failure: {Path}", outputPath);
- _fileSystem.DeleteFile(outputPath);
+ standardError = await standardErrorTask.ConfigureAwait(false);
}
- catch (FileNotFoundException)
+ catch (OperationCanceledException)
{
- }
- catch (IOException ex)
- {
- _logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath);
- }
- }
- else if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0)
- {
- failed = true;
-
- try
- {
- _logger.LogWarning("Deleting extracted subtitle due to failure: {Path}", outputPath);
- _fileSystem.DeleteFile(outputPath);
- }
- catch (FileNotFoundException)
- {
- }
- catch (IOException ex)
- {
- _logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath);
+ // Reading ffmpeg output was cancelled; nothing more to capture.
}
}
- if (failed)
- {
- _logger.LogError("ffmpeg subtitle extraction failed for {InputPath} to {OutputPath}", inputPath, outputPath);
-
- throw new FfmpegException(
- string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle extraction failed for {0} to {1}", inputPath, outputPath));
- }
-
- _logger.LogInformation("ffmpeg subtitle extraction completed for {InputPath} to {OutputPath}", inputPath, outputPath);
-
- if (string.Equals(outputCodec, "ass", StringComparison.OrdinalIgnoreCase))
- {
- await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false);
- }
+ return (exitCode, standardError);
}
///
@@ -1104,7 +986,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
}
}
- var result = await DetectCharset(path, mediaSource.Protocol, cancellationToken).ConfigureAwait(false);
+ var result = await DetectCharset(path, cancellationToken).ConfigureAwait(false);
var charset = result.Detected?.EncodingName ?? string.Empty;
// UTF16 is automatically converted to UTF8 by FFmpeg, do not specify a character encoding
@@ -1120,8 +1002,9 @@ namespace MediaBrowser.MediaEncoding.Subtitles
return charset;
}
- private async Task DetectCharset(string path, MediaProtocol protocol, CancellationToken cancellationToken)
+ private async Task DetectCharset(string path, CancellationToken cancellationToken)
{
+ var protocol = _mediaSourceManager.GetPathProtocol(path);
switch (protocol)
{
case MediaProtocol.Http:
@@ -1141,7 +1024,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
}
default:
- throw new ArgumentOutOfRangeException(nameof(protocol), protocol, "Unsupported protocol");
+ throw new NotSupportedException($"Unsupported protocol: {protocol}");
}
}
diff --git a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs
index defd855ec0..78bb881ec2 100644
--- a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs
+++ b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs
@@ -424,6 +424,7 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable
// Must consume both stdout and stderr or deadlocks may occur
// RedirectStandardOutput = true,
+ StandardErrorEncoding = Encoding.UTF8,
RedirectStandardError = true,
RedirectStandardInput = true,
FileName = _mediaEncoder.EncoderPath,
diff --git a/MediaBrowser.Model/Configuration/ImageSavingConvention.cs b/MediaBrowser.Model/Configuration/ImageSavingConvention.cs
index 485a4d2f80..c67f379fde 100644
--- a/MediaBrowser.Model/Configuration/ImageSavingConvention.cs
+++ b/MediaBrowser.Model/Configuration/ImageSavingConvention.cs
@@ -1,10 +1,18 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Model.Configuration
{
+ ///
+ /// The convention used for naming saved images.
+ ///
public enum ImageSavingConvention
{
+ ///
+ /// The legacy naming convention.
+ ///
Legacy,
+
+ ///
+ /// The naming convention compatible with other media servers and metadata managers.
+ ///
Compatible
}
}
diff --git a/MediaBrowser.Model/Dlna/CodecType.cs b/MediaBrowser.Model/Dlna/CodecType.cs
index c9f090e4cc..12730a76fa 100644
--- a/MediaBrowser.Model/Dlna/CodecType.cs
+++ b/MediaBrowser.Model/Dlna/CodecType.cs
@@ -1,11 +1,23 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Model.Dlna
{
+ ///
+ /// The codec type of a codec profile.
+ ///
public enum CodecType
{
+ ///
+ /// The profile applies to a video codec.
+ ///
Video = 0,
+
+ ///
+ /// The profile applies to the audio codec of a video stream.
+ ///
VideoAudio = 1,
+
+ ///
+ /// The profile applies to an audio codec.
+ ///
Audio = 2
}
}
diff --git a/MediaBrowser.Model/Dlna/EncodingContext.cs b/MediaBrowser.Model/Dlna/EncodingContext.cs
index 79ca6366d7..1408333d2e 100644
--- a/MediaBrowser.Model/Dlna/EncodingContext.cs
+++ b/MediaBrowser.Model/Dlna/EncodingContext.cs
@@ -1,10 +1,18 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Model.Dlna
{
+ ///
+ /// The encoding context.
+ ///
public enum EncodingContext
{
+ ///
+ /// The media is transcoded on the fly and delivered as a stream.
+ ///
Streaming = 0,
+
+ ///
+ /// The media is transcoded to a static file.
+ ///
Static = 1
}
}
diff --git a/MediaBrowser.Model/Dlna/PlaybackErrorCode.cs b/MediaBrowser.Model/Dlna/PlaybackErrorCode.cs
index 300fab5c50..a28f422a2b 100644
--- a/MediaBrowser.Model/Dlna/PlaybackErrorCode.cs
+++ b/MediaBrowser.Model/Dlna/PlaybackErrorCode.cs
@@ -1,11 +1,23 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Model.Dlna
{
+ ///
+ /// The playback error code.
+ ///
public enum PlaybackErrorCode
{
+ ///
+ /// Playback of the item is not allowed.
+ ///
NotAllowed = 0,
+
+ ///
+ /// No stream compatible with the device profile was found.
+ ///
NoCompatibleStream = 1,
+
+ ///
+ /// The rate limit has been exceeded.
+ ///
RateLimitExceeded = 2
}
}
diff --git a/MediaBrowser.Model/Dlna/ResolutionOptions.cs b/MediaBrowser.Model/Dlna/ResolutionOptions.cs
index 774592abc7..b161b4a1e4 100644
--- a/MediaBrowser.Model/Dlna/ResolutionOptions.cs
+++ b/MediaBrowser.Model/Dlna/ResolutionOptions.cs
@@ -1,11 +1,18 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Model.Dlna
{
+ ///
+ /// The resolution constraints.
+ ///
public class ResolutionOptions
{
+ ///
+ /// Gets or sets the maximum width.
+ ///
public int? MaxWidth { get; set; }
+ ///
+ /// Gets or sets the maximum height.
+ ///
public int? MaxHeight { get; set; }
}
}
diff --git a/MediaBrowser.Model/Dlna/StreamBuilder.cs b/MediaBrowser.Model/Dlna/StreamBuilder.cs
index d875bbe8ed..a9ab7d6db0 100644
--- a/MediaBrowser.Model/Dlna/StreamBuilder.cs
+++ b/MediaBrowser.Model/Dlna/StreamBuilder.cs
@@ -576,11 +576,8 @@ namespace MediaBrowser.Model.Dlna
foreach (var profile in subtitleProfiles)
{
if (profile.Method == SubtitleDeliveryMethod.External
- && (string.Equals(profile.Format, stream.Codec, StringComparison.OrdinalIgnoreCase)
- // FFmpeg cannot mux VobSub back into an .idx/.sub pair, so extracted VobSub streams are exposed as .mks.
- || (string.Equals(profile.Format, "mks", StringComparison.OrdinalIgnoreCase)
- && stream.IsVobSubSubtitleStream
- && (!stream.IsExternal || stream.Path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase)))))
+ && (IsVobSubMksProfile(profile, stream)
+ || (!IsVobSubMksDeliveryProfile(profile) && string.Equals(profile.Format, stream.Codec, StringComparison.OrdinalIgnoreCase))))
{
return stream.Index;
}
@@ -951,6 +948,10 @@ namespace MediaBrowser.Model.Dlna
}
playlistItem.VideoCodecs = videoCodecs;
+ if (videoStream is not null && !ContainerHelper.ContainsContainer(videoCodecs, false, videoStream.Codec))
+ {
+ playlistItem.TranscodeReasons |= TranscodeReason.VideoCodecNotSupported;
+ }
// Copy video codec options as a starting point, this applies to transcode and direct-stream
playlistItem.MaxFramerate = videoStream?.ReferenceFrameRate;
@@ -999,6 +1000,10 @@ namespace MediaBrowser.Model.Dlna
var directAudioFailures = audioStreamWithSupportedCodec is null ? default : GetCompatibilityAudioCodec(options, item, container ?? string.Empty, audioStreamWithSupportedCodec, null, true, false);
playlistItem.TranscodeReasons |= directAudioFailures;
+ if (audioStream is not null && audioStreamWithSupportedCodec is null)
+ {
+ playlistItem.TranscodeReasons |= TranscodeReason.AudioCodecNotSupported;
+ }
var directAudioStreamSatisfied = audioStreamWithSupportedCodec is not null && !channelsExceedsLimit
&& directAudioFailures == 0;
@@ -1582,13 +1587,11 @@ namespace MediaBrowser.Model.Dlna
continue;
}
- // FFmpeg cannot mux VobSub back into an .idx/.sub pair, so extracted VobSub streams are matched against external .mks delivery profiles.
- bool isVobSubMksProfile = string.Equals(profile.Format, "mks", StringComparison.OrdinalIgnoreCase)
- && subtitleStream.IsVobSubSubtitleStream
- && (!subtitleStream.IsExternal || subtitleStream.Path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase));
+ bool isVobSubMksProfile = IsVobSubMksProfile(profile, subtitleStream);
if ((profile.Method == SubtitleDeliveryMethod.External
- && (isVobSubMksProfile || subtitleStream.IsTextSubtitleStream == MediaStream.IsTextFormat(profile.Format))) ||
+ && (isVobSubMksProfile
+ || (!IsVobSubMksDeliveryProfile(profile) && subtitleStream.IsTextSubtitleStream == MediaStream.IsTextFormat(profile.Format)))) ||
(profile.Method == SubtitleDeliveryMethod.Hls && subtitleStream.IsTextSubtitleStream))
{
bool requiresConversion = !isVobSubMksProfile
@@ -1620,6 +1623,21 @@ namespace MediaBrowser.Model.Dlna
return null;
}
+ private static bool IsVobSubMksDeliveryProfile(SubtitleProfile profile)
+ {
+ return MediaStream.IsVobSubFormat(profile.Format)
+ && !string.IsNullOrWhiteSpace(profile.Container)
+ && ContainerHelper.ContainsContainer(profile.Container, "mks");
+ }
+
+ private static bool IsVobSubMksProfile(SubtitleProfile profile, MediaStream subtitleStream)
+ {
+ // FFmpeg cannot mux VobSub back into an .idx/.sub pair, so extracted VobSub streams are exposed as .mks.
+ return IsVobSubMksDeliveryProfile(profile)
+ && subtitleStream.IsVobSubSubtitleStream
+ && (!subtitleStream.IsExternal || subtitleStream.Path?.EndsWith(".mks", StringComparison.OrdinalIgnoreCase) == true);
+ }
+
private bool IsBitrateLimitExceeded(MediaSourceInfo item, long maxBitrate)
{
// Don't restrict bitrate if item is remote.
diff --git a/MediaBrowser.Model/Dlna/TranscodeSeekInfo.cs b/MediaBrowser.Model/Dlna/TranscodeSeekInfo.cs
index cc0c6069bf..1563ffd17a 100644
--- a/MediaBrowser.Model/Dlna/TranscodeSeekInfo.cs
+++ b/MediaBrowser.Model/Dlna/TranscodeSeekInfo.cs
@@ -1,10 +1,18 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Model.Dlna
{
+ ///
+ /// The transcode seek info.
+ ///
public enum TranscodeSeekInfo
{
+ ///
+ /// The seek method is chosen automatically.
+ ///
Auto = 0,
+
+ ///
+ /// Seeking is performed by byte position.
+ ///
Bytes = 1
}
}
diff --git a/MediaBrowser.Model/Dto/IHasServerId.cs b/MediaBrowser.Model/Dto/IHasServerId.cs
index c754d276c5..49452d736a 100644
--- a/MediaBrowser.Model/Dto/IHasServerId.cs
+++ b/MediaBrowser.Model/Dto/IHasServerId.cs
@@ -1,10 +1,15 @@
#nullable disable
-#pragma warning disable CS1591
namespace MediaBrowser.Model.Dto
{
+ ///
+ /// Interface for DTOs that reference the id of the server they originate from.
+ ///
public interface IHasServerId
{
+ ///
+ /// Gets the server id.
+ ///
string ServerId { get; }
}
}
diff --git a/MediaBrowser.Model/Dto/MediaSourceType.cs b/MediaBrowser.Model/Dto/MediaSourceType.cs
index 42314d5198..ca6649e64b 100644
--- a/MediaBrowser.Model/Dto/MediaSourceType.cs
+++ b/MediaBrowser.Model/Dto/MediaSourceType.cs
@@ -1,11 +1,23 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Model.Dto
{
+ ///
+ /// The type of a media source.
+ ///
public enum MediaSourceType
{
+ ///
+ /// A default media source.
+ ///
Default = 0,
+
+ ///
+ /// A grouping of media sources.
+ ///
Grouping = 1,
+
+ ///
+ /// A placeholder media source, for example a disc that has to be inserted.
+ ///
Placeholder = 2
}
}
diff --git a/MediaBrowser.Model/Dto/RatingType.cs b/MediaBrowser.Model/Dto/RatingType.cs
index 033776f9c6..2c2b7b705d 100644
--- a/MediaBrowser.Model/Dto/RatingType.cs
+++ b/MediaBrowser.Model/Dto/RatingType.cs
@@ -1,10 +1,18 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Model.Dto
{
+ ///
+ /// The type of a community rating.
+ ///
public enum RatingType
{
+ ///
+ /// The rating is a numeric score.
+ ///
Score,
+
+ ///
+ /// The rating is based on likes.
+ ///
Likes
}
}
diff --git a/MediaBrowser.Model/Globalization/ILocalizationManager.cs b/MediaBrowser.Model/Globalization/ILocalizationManager.cs
index 7ad240abfb..0fff70c4e0 100644
--- a/MediaBrowser.Model/Globalization/ILocalizationManager.cs
+++ b/MediaBrowser.Model/Globalization/ILocalizationManager.cs
@@ -72,6 +72,14 @@ public interface ILocalizationManager
/// The correct for the given language.
CultureDto? FindLanguageInfo(string language);
+ ///
+ /// Gets a human-readable display name for the given language code.
+ /// Truncates at the first semicolon or comma to avoid cluttered ISO-639-2 names.
+ ///
+ /// An ISO language code.
+ /// The display name, or null if not found.
+ string? GetLanguageDisplayName(string language);
+
///
/// Returns the language in ISO 639-2/T when the input is ISO 639-2/B.
///
diff --git a/MediaBrowser.Model/Library/PlayAccess.cs b/MediaBrowser.Model/Library/PlayAccess.cs
index a2f263ce54..22daaf7254 100644
--- a/MediaBrowser.Model/Library/PlayAccess.cs
+++ b/MediaBrowser.Model/Library/PlayAccess.cs
@@ -1,10 +1,18 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Model.Library
{
+ ///
+ /// The play access of an item.
+ ///
public enum PlayAccess
{
+ ///
+ /// The item can be played.
+ ///
Full = 0,
+
+ ///
+ /// The item cannot be played.
+ ///
None = 1
}
}
diff --git a/MediaBrowser.Model/LiveTv/DayPattern.cs b/MediaBrowser.Model/LiveTv/DayPattern.cs
index 17efe39088..dab69e8974 100644
--- a/MediaBrowser.Model/LiveTv/DayPattern.cs
+++ b/MediaBrowser.Model/LiveTv/DayPattern.cs
@@ -1,11 +1,23 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Model.LiveTv
{
+ ///
+ /// The day pattern of a recurring timer.
+ ///
public enum DayPattern
{
+ ///
+ /// Every day.
+ ///
Daily,
+
+ ///
+ /// Monday through Friday.
+ ///
Weekdays,
+
+ ///
+ /// Saturday and Sunday.
+ ///
Weekends
}
}
diff --git a/MediaBrowser.Model/LiveTv/LiveTvServiceStatus.cs b/MediaBrowser.Model/LiveTv/LiveTvServiceStatus.cs
index 72a0e2d7bf..a3df1dc411 100644
--- a/MediaBrowser.Model/LiveTv/LiveTvServiceStatus.cs
+++ b/MediaBrowser.Model/LiveTv/LiveTvServiceStatus.cs
@@ -1,10 +1,18 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Model.LiveTv
{
+ ///
+ /// The status of a live TV service.
+ ///
public enum LiveTvServiceStatus
{
+ ///
+ /// The service is available.
+ ///
Ok = 0,
+
+ ///
+ /// The service is unavailable.
+ ///
Unavailable = 1
}
}
diff --git a/MediaBrowser.Model/MediaInfo/TransportStreamTimestamp.cs b/MediaBrowser.Model/MediaInfo/TransportStreamTimestamp.cs
index b7ee5747ab..1988dd8078 100644
--- a/MediaBrowser.Model/MediaInfo/TransportStreamTimestamp.cs
+++ b/MediaBrowser.Model/MediaInfo/TransportStreamTimestamp.cs
@@ -1,11 +1,23 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Model.MediaInfo
{
+ ///
+ /// The type of timestamps used in a transport stream.
+ ///
public enum TransportStreamTimestamp
{
+ ///
+ /// The stream contains no timestamps.
+ ///
None,
+
+ ///
+ /// The stream contains zero-value timestamps.
+ ///
Zero,
+
+ ///
+ /// The stream contains valid timestamps.
+ ///
Valid
}
}
diff --git a/MediaBrowser.Model/Session/MessageCommand.cs b/MediaBrowser.Model/Session/MessageCommand.cs
index cc9db8e6c5..e041a9cccd 100644
--- a/MediaBrowser.Model/Session/MessageCommand.cs
+++ b/MediaBrowser.Model/Session/MessageCommand.cs
@@ -1,17 +1,28 @@
#nullable disable
-#pragma warning disable CS1591
using System.ComponentModel.DataAnnotations;
namespace MediaBrowser.Model.Session
{
+ ///
+ /// A command to display a message on a client.
+ ///
public class MessageCommand
{
+ ///
+ /// Gets or sets the message header.
+ ///
public string Header { get; set; }
+ ///
+ /// Gets or sets the message text.
+ ///
[Required(AllowEmptyStrings = false)]
public string Text { get; set; }
+ ///
+ /// Gets or sets the timeout in milliseconds after which the message should be dismissed.
+ ///
public long? TimeoutMs { get; set; }
}
}
diff --git a/MediaBrowser.Model/Session/PlayMethod.cs b/MediaBrowser.Model/Session/PlayMethod.cs
index 8067627843..2bd11cc91a 100644
--- a/MediaBrowser.Model/Session/PlayMethod.cs
+++ b/MediaBrowser.Model/Session/PlayMethod.cs
@@ -1,11 +1,23 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Model.Session
{
+ ///
+ /// The play method.
+ ///
public enum PlayMethod
{
+ ///
+ /// The media is transcoded before it is sent to the client.
+ ///
Transcode = 0,
+
+ ///
+ /// The media is remuxed into a compatible container but the streams are not re-encoded.
+ ///
DirectStream = 1,
+
+ ///
+ /// The media is sent to the client as-is.
+ ///
DirectPlay = 2
}
}
diff --git a/MediaBrowser.Model/Session/PlaystateRequest.cs b/MediaBrowser.Model/Session/PlaystateRequest.cs
index ba2c024b76..040affa144 100644
--- a/MediaBrowser.Model/Session/PlaystateRequest.cs
+++ b/MediaBrowser.Model/Session/PlaystateRequest.cs
@@ -1,11 +1,18 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Model.Session
{
+ ///
+ /// A request to change the playstate of a session.
+ ///
public class PlaystateRequest
{
+ ///
+ /// Gets or sets the playstate command.
+ ///
public PlaystateCommand Command { get; set; }
+ ///
+ /// Gets or sets the seek position in ticks.
+ ///
public long? SeekPositionTicks { get; set; }
///
diff --git a/MediaBrowser.Model/Session/QueueItem.cs b/MediaBrowser.Model/Session/QueueItem.cs
index 43920a8464..b9f3181da0 100644
--- a/MediaBrowser.Model/Session/QueueItem.cs
+++ b/MediaBrowser.Model/Session/QueueItem.cs
@@ -1,13 +1,21 @@
#nullable disable
-#pragma warning disable CS1591
using System;
namespace MediaBrowser.Model.Session;
+///
+/// An item in a play queue.
+///
public record QueueItem
{
+ ///
+ /// Gets or sets the item id.
+ ///
public Guid Id { get; set; }
+ ///
+ /// Gets or sets the playlist item id.
+ ///
public string PlaylistItemId { get; set; }
}
diff --git a/MediaBrowser.Model/Session/RepeatMode.cs b/MediaBrowser.Model/Session/RepeatMode.cs
index c6e173d6b8..c6c657d220 100644
--- a/MediaBrowser.Model/Session/RepeatMode.cs
+++ b/MediaBrowser.Model/Session/RepeatMode.cs
@@ -1,11 +1,23 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Model.Session
{
+ ///
+ /// The repeat mode of a play queue.
+ ///
public enum RepeatMode
{
+ ///
+ /// Nothing is repeated.
+ ///
RepeatNone = 0,
+
+ ///
+ /// The whole queue is repeated.
+ ///
RepeatAll = 1,
+
+ ///
+ /// The current item is repeated.
+ ///
RepeatOne = 2
}
}
diff --git a/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs b/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs
new file mode 100644
index 0000000000..2bd2676ceb
--- /dev/null
+++ b/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs
@@ -0,0 +1,238 @@
+using System;
+using System.Globalization;
+using System.IO;
+using System.IO.Compression;
+using System.Linq;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Data.Enums;
+using Jellyfin.Extensions.Json;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Providers.Books.ComicBookInfo.Models;
+using Microsoft.Extensions.Logging;
+
+namespace MediaBrowser.Providers.Books.ComicBookInfo;
+
+///
+/// ComicBookInfo provider.
+///
+public class ComicBookInfoProvider : IComicProvider
+{
+ private readonly ILogger _logger;
+ private readonly IFileSystem _fileSystem;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Instance of the interface.
+ /// Instance of the interface.
+ public ComicBookInfoProvider(IFileSystem fileSystem, ILogger logger)
+ {
+ _fileSystem = fileSystem;
+ _logger = logger;
+ }
+
+ ///
+ public async ValueTask> ReadMetadata(ItemInfo info, IDirectoryService directoryService, CancellationToken cancellationToken)
+ {
+ var path = GetComicBookFile(info.Path)?.FullName;
+
+ if (path is null)
+ {
+ _logger.LogDebug("could not load comic: {Path}", info.Path);
+ return new MetadataResult { HasMetadata = false };
+ }
+
+ try
+ {
+ Stream stream = AsyncFile.OpenRead(path);
+ await using (stream.ConfigureAwait(false))
+ {
+ var archive = await ZipArchive.CreateAsync(stream, ZipArchiveMode.Read, false, null, cancellationToken).ConfigureAwait(false);
+ await using (archive.ConfigureAwait(false))
+ {
+ if (archive.Comment is null)
+ {
+ _logger.LogInformation("missing ComicBookInfo in archive comment: {Path}", info.Path);
+ return new MetadataResult { HasMetadata = false };
+ }
+
+ var comicBookMetadata = JsonSerializer.Deserialize(archive.Comment, JsonDefaults.Options);
+ if (comicBookMetadata is null)
+ {
+ _logger.LogError("ComicBookInfo deserialization failure: {Path}", info.Path);
+ return new MetadataResult { HasMetadata = false };
+ }
+
+ return SaveMetadata(comicBookMetadata);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "failed to load ComicBookInfo metadata: {Path}", info.Path);
+ return new MetadataResult { HasMetadata = false };
+ }
+ }
+
+ ///
+ public bool HasItemChanged(BaseItem item)
+ {
+ var file = GetComicBookFile(item.Path);
+
+ if (file is null)
+ {
+ return false;
+ }
+
+ return file.Exists && _fileSystem.GetLastWriteTimeUtc(file) > item.DateLastSaved;
+ }
+
+ private MetadataResult SaveMetadata(ComicBookInfoFormat comic)
+ {
+ if (comic.Metadata is null)
+ {
+ return new MetadataResult { HasMetadata = false };
+ }
+
+ var book = ReadComicBookMetadata(comic.Metadata);
+
+ if (book is null)
+ {
+ return new MetadataResult { HasMetadata = false };
+ }
+
+ var metadataResult = new MetadataResult { Item = book, HasMetadata = true };
+
+ if (comic.Metadata.Language is not null)
+ {
+ metadataResult.ResultLanguage = ReadCultureInfoInto(comic.Metadata.Language);
+ }
+
+ if (comic.Metadata.Credits.Count > 0)
+ {
+ ReadPeopleMetadata(comic.Metadata, metadataResult);
+ }
+
+ return metadataResult;
+ }
+
+ private FileSystemMetadata? GetComicBookFile(string path)
+ {
+ var fileInfo = _fileSystem.GetFileSystemInfo(path);
+
+ if (fileInfo.IsDirectory)
+ {
+ return null;
+ }
+
+ // only parse files that are known to have ComicBookInfo metadata
+ return fileInfo.Extension.Equals(".cbz", StringComparison.OrdinalIgnoreCase) ? fileInfo : null;
+ }
+
+ private static Book? ReadComicBookMetadata(ComicBookInfoMetadata comic)
+ {
+ var book = new Book();
+ var hasFoundMetadata = false;
+
+ hasFoundMetadata |= ReadStringInto(comic.Title, title => book.Name = title);
+ hasFoundMetadata |= ReadStringInto(comic.Series, series => book.SeriesName = series);
+ hasFoundMetadata |= ReadStringInto(comic.Genre, genre => book.AddGenre(genre));
+ hasFoundMetadata |= ReadStringInto(comic.Comments, overview => book.Overview = overview);
+ hasFoundMetadata |= ReadStringInto(comic.Publisher, publisher => book.SetStudios([publisher]));
+
+ if (comic.PublicationYear is not null)
+ {
+ book.ProductionYear = comic.PublicationYear;
+ hasFoundMetadata = true;
+ }
+
+ if (comic.Issue is not null)
+ {
+ book.IndexNumber = comic.Issue;
+ hasFoundMetadata = true;
+ }
+
+ if (comic.Tags.Count > 0)
+ {
+ book.Tags = comic.Tags.ToArray();
+ hasFoundMetadata = true;
+ }
+
+ if (comic.PublicationYear is not null && comic.PublicationMonth is not null)
+ {
+ book.PremiereDate = ReadTwoPartDateInto(comic.PublicationYear.Value, comic.PublicationMonth.Value);
+ hasFoundMetadata = true;
+ }
+
+ return hasFoundMetadata ? book : null;
+ }
+
+ private static void ReadPeopleMetadata(ComicBookInfoMetadata comic, MetadataResult metadataResult)
+ {
+ foreach (var person in comic.Credits)
+ {
+ if (person.Person is null || person.Role is null)
+ {
+ continue;
+ }
+
+ if (person.Person.Contains(',', StringComparison.InvariantCultureIgnoreCase))
+ {
+ var name = person.Person.Split(',');
+ person.Person = name[1].Trim(' ') + " " + name[0].Trim(' ');
+ }
+
+ if (!Enum.TryParse(person.Role, out PersonKind personKind))
+ {
+ personKind = PersonKind.Unknown;
+ }
+
+ if (string.Equals("Colorer", person.Role, StringComparison.OrdinalIgnoreCase))
+ {
+ personKind = PersonKind.Colorist;
+ }
+
+ metadataResult.AddPerson(new PersonInfo { Name = person.Person, Type = personKind });
+ }
+ }
+
+ private static string? ReadCultureInfoInto(string language)
+ {
+ try
+ {
+ return CultureInfo.GetCultureInfo(language).DisplayName;
+ }
+ catch (CultureNotFoundException)
+ {
+ return null;
+ }
+ }
+
+ private static bool ReadStringInto(string? data, Action commitResult)
+ {
+ if (!string.IsNullOrWhiteSpace(data))
+ {
+ commitResult(data);
+ return true;
+ }
+
+ return false;
+ }
+
+ private static DateTime? ReadTwoPartDateInto(int year, int month)
+ {
+ try
+ {
+ // use first day of the month because this format doesn't include a day
+ return new DateTime(year, month, 1, 0, 0, 0, DateTimeKind.Unspecified);
+ }
+ catch (ArgumentOutOfRangeException)
+ {
+ return null;
+ }
+ }
+}
diff --git a/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoCredit.cs b/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoCredit.cs
new file mode 100644
index 0000000000..fe7aa40456
--- /dev/null
+++ b/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoCredit.cs
@@ -0,0 +1,21 @@
+using System.Text.Json.Serialization;
+
+namespace MediaBrowser.Providers.Books.ComicBookInfo.Models;
+
+///
+/// ComicBookInfo credit.
+///
+public class ComicBookInfoCredit
+{
+ ///
+ /// Gets or sets the person name.
+ ///
+ [JsonPropertyName("person")]
+ public string? Person { get; set; }
+
+ ///
+ /// Gets or sets the role.
+ ///
+ [JsonPropertyName("role")]
+ public string? Role { get; set; }
+}
diff --git a/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoFormat.cs b/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoFormat.cs
new file mode 100644
index 0000000000..5c4e3d948f
--- /dev/null
+++ b/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoFormat.cs
@@ -0,0 +1,27 @@
+using System.Text.Json.Serialization;
+
+namespace MediaBrowser.Providers.Books.ComicBookInfo.Models;
+
+///
+/// ComicBookInfo format.
+///
+public class ComicBookInfoFormat
+{
+ ///
+ /// Gets or sets the app ID.
+ ///
+ [JsonPropertyName("appID")]
+ public string? AppId { get; set; }
+
+ ///
+ /// Gets or sets the last modified timestamp.
+ ///
+ [JsonPropertyName("lastModified")]
+ public string? LastModified { get; set; }
+
+ ///
+ /// Gets or sets the metadata.
+ ///
+ [JsonPropertyName("ComicBookInfo/1.0")]
+ public ComicBookInfoMetadata? Metadata { get; set; }
+}
diff --git a/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoMetadata.cs b/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoMetadata.cs
new file mode 100644
index 0000000000..42e1b3d4f6
--- /dev/null
+++ b/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoMetadata.cs
@@ -0,0 +1,107 @@
+using System;
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+namespace MediaBrowser.Providers.Books.ComicBookInfo.Models;
+
+///
+/// ComicBookInfo metadata.
+///
+public class ComicBookInfoMetadata
+{
+ ///
+ /// Gets or sets the series.
+ ///
+ [JsonPropertyName("series")]
+ public string? Series { get; set; }
+
+ ///
+ /// Gets or sets the title.
+ ///
+ [JsonPropertyName("title")]
+ public string? Title { get; set; }
+
+ ///
+ /// Gets or sets the publisher.
+ ///
+ [JsonPropertyName("publisher")]
+ public string? Publisher { get; set; }
+
+ ///
+ /// Gets or sets the publication month.
+ ///
+ [JsonPropertyName("publicationMonth")]
+ public int? PublicationMonth { get; set; }
+
+ ///
+ /// Gets or sets the publication year.
+ ///
+ [JsonPropertyName("publicationYear")]
+ public int? PublicationYear { get; set; }
+
+ ///
+ /// Gets or sets the issue number.
+ ///
+ [JsonPropertyName("issue")]
+ public int? Issue { get; set; }
+
+ ///
+ /// Gets or sets the number of issues.
+ ///
+ [JsonPropertyName("numberOfIssues")]
+ public int? NumberOfIssues { get; set; }
+
+ ///
+ /// Gets or sets the volume number.
+ ///
+ [JsonPropertyName("volume")]
+ public int? Volume { get; set; }
+
+ ///
+ /// Gets or sets the number of volumes.
+ ///
+ [JsonPropertyName("numberOfVolumes")]
+ public int? NumberOfVolumes { get; set; }
+
+ ///
+ /// Gets or sets the rating.
+ ///
+ [JsonPropertyName("rating")]
+ public int? Rating { get; set; }
+
+ ///
+ /// Gets or sets the genre.
+ ///
+ [JsonPropertyName("genre")]
+ public string? Genre { get; set; }
+
+ ///
+ /// Gets or sets the language.
+ ///
+ [JsonPropertyName("language")]
+ public string? Language { get; set; }
+
+ ///
+ /// Gets or sets the country.
+ ///
+ [JsonPropertyName("country")]
+ public string? Country { get; set; }
+
+ ///
+ /// Gets or sets the list of credits.
+ ///
+ [JsonPropertyName("credits")]
+ public IReadOnlyList Credits { get; set; } = Array.Empty();
+
+ ///
+ /// Gets or sets the list of tags.
+ ///
+ [JsonPropertyName("tags")]
+ public IReadOnlyList Tags { get; set; } = Array.Empty();
+
+ ///
+ /// Gets or sets the comments.
+ ///
+ [JsonPropertyName("comments")]
+ public string? Comments { get; set; }
+}
diff --git a/MediaBrowser.Providers/Books/ComicImageProvider.cs b/MediaBrowser.Providers/Books/ComicImageProvider.cs
new file mode 100644
index 0000000000..34936cff13
--- /dev/null
+++ b/MediaBrowser.Providers/Books/ComicImageProvider.cs
@@ -0,0 +1,158 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Extensions;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Drawing;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.IO;
+using Microsoft.Extensions.Logging;
+using SharpCompress.Archives;
+
+namespace MediaBrowser.Providers.Books;
+
+///
+/// The ComicImageProvider tries to find either an image named "cover" or, in case that
+/// fails, just takes the first image inside the archive, hoping that it is the cover.
+///
+public class ComicImageProvider : IDynamicImageProvider
+{
+ private readonly string[] _comicBookExtensions = [".cb7", ".cbr", ".cbt", ".cbz"];
+ private readonly string[] _coverExtensions = [".png", ".jpeg", ".jpg", ".webp", ".bmp", ".gif"];
+
+ private readonly ILogger _logger;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Instance of the interface.
+ public ComicImageProvider(ILogger logger)
+ {
+ _logger = logger;
+ }
+
+ ///
+ public string Name => "Comic Book Archive Cover Extractor";
+
+ ///
+ public async Task GetImage(BaseItem item, ImageType type, CancellationToken cancellationToken)
+ {
+ var extension = Path.GetExtension(item.Path);
+
+ if (_comicBookExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase))
+ {
+ return await LoadCoverAsync(item, cancellationToken).ConfigureAwait(false);
+ }
+
+ return new DynamicImageResponse { HasImage = false };
+ }
+
+ ///
+ public IEnumerable GetSupportedImages(BaseItem item)
+ {
+ yield return ImageType.Primary;
+ }
+
+ ///
+ public bool Supports(BaseItem item)
+ {
+ return item is Book;
+ }
+
+ ///
+ /// Tries to load a cover from the CBZ archive. Returns a response
+ /// with no image if nothing is found.
+ ///
+ /// Item to check for covers.
+ /// The cancellation token.
+ private async Task LoadCoverAsync(BaseItem item, CancellationToken cancellationToken)
+ {
+ var memoryStream = new MemoryStream();
+
+ try
+ {
+ ImageFormat imageFormat;
+
+ using (Stream stream = AsyncFile.OpenRead(item.Path))
+ {
+ var archive = await ArchiveFactory.OpenAsyncArchive(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
+ await using (archive.ConfigureAwait(false))
+ {
+ // throw exception to log results if no cover is found
+ (var cover, imageFormat) = await FindCoverEntryInArchiveAsync(archive).ConfigureAwait(false)
+ ?? throw new InvalidOperationException("no supported cover found");
+
+ // copy the cover to memory stream
+ var coverStream = await cover.OpenEntryStreamAsync(cancellationToken).ConfigureAwait(false);
+ await using (coverStream.ConfigureAwait(false))
+ {
+ await coverStream.CopyToAsync(memoryStream, cancellationToken).ConfigureAwait(false);
+ }
+ }
+ }
+
+ // reset stream position after copying
+ memoryStream.Position = 0;
+
+ return new DynamicImageResponse { HasImage = true, Stream = memoryStream, Format = imageFormat };
+ }
+ catch (Exception e)
+ {
+ _logger.LogError(e, "failed to load cover from {Path}", item.Path);
+ return new DynamicImageResponse { HasImage = false };
+ }
+ }
+
+ ///
+ /// Tries to find the entry containing the cover.
+ ///
+ /// The archive to search.
+ /// The search result.
+ private async ValueTask<(IArchiveEntry CoverEntry, ImageFormat ImageFormat)?> FindCoverEntryInArchiveAsync(IAsyncArchive archive)
+ {
+ IArchiveEntry? cover;
+
+ // only some comics will explicitly name their cover file
+ // in many cases the cover will simply be the first image in the archive
+ foreach (var extension in _coverExtensions)
+ {
+ cover = await archive.EntriesAsync.FirstOrDefaultAsync(e => e.Key == "cover" + extension).ConfigureAwait(false);
+
+ if (cover is not null)
+ {
+ var imageFormat = GetImageFormat(extension);
+
+ return (cover, imageFormat);
+ }
+ }
+
+ cover = await archive.EntriesAsync.OrderBy(x => x.Key)
+ .FirstOrDefaultAsync(x => _coverExtensions.Contains(Path.GetExtension(x.Key), StringComparison.OrdinalIgnoreCase))
+ .ConfigureAwait(false);
+
+ if (cover is not null)
+ {
+ var imageFormat = GetImageFormat(Path.GetExtension(cover.Key ?? string.Empty));
+
+ return (cover, imageFormat);
+ }
+
+ return null;
+ }
+
+ private static ImageFormat GetImageFormat(string extension) => extension.ToLowerInvariant() switch
+ {
+ ".jpg" => ImageFormat.Jpg,
+ ".jpeg" => ImageFormat.Jpg,
+ ".png" => ImageFormat.Png,
+ ".webp" => ImageFormat.Webp,
+ ".bmp" => ImageFormat.Bmp,
+ ".gif" => ImageFormat.Gif,
+ ".svg" => ImageFormat.Svg,
+ _ => throw new ArgumentException($"unsupported extension: {extension}"),
+ };
+}
diff --git a/MediaBrowser.Providers/Books/ComicInfo/ComicInfoReader.cs b/MediaBrowser.Providers/Books/ComicInfo/ComicInfoReader.cs
new file mode 100644
index 0000000000..4e8dc405ec
--- /dev/null
+++ b/MediaBrowser.Providers/Books/ComicInfo/ComicInfoReader.cs
@@ -0,0 +1,235 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Xml.Linq;
+using System.Xml.XPath;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Providers;
+
+namespace MediaBrowser.Providers.Books.ComicInfo;
+
+///
+/// ComicInfo reader.
+///
+public static class ComicInfoReader
+{
+ ///
+ /// Filename to check for comic metadata either next to the comic file or inside the archive.
+ ///
+ public const string ComicRackMetaFile = "ComicInfo.xml";
+
+ ///
+ /// Read comic book metadata.
+ ///
+ /// The XDocument to read for comic metadata.
+ /// The resulting book.
+ public static Book? ReadComicBookMetadata(XDocument xml)
+ {
+ var book = new Book();
+ var hasFoundMetadata = false;
+
+ // this value is only used internally since Jellyfin has no manga flag
+ var isManga = false;
+
+ hasFoundMetadata |= ReadStringInto(xml, "ComicInfo/Title", title => book.Name = title);
+ hasFoundMetadata |= ReadStringInto(xml, "ComicInfo/Manga", manga => isManga = manga.Equals("Yes", StringComparison.OrdinalIgnoreCase));
+ hasFoundMetadata |= ReadStringInto(xml, "ComicInfo/Series", series => book.SeriesName = series);
+ hasFoundMetadata |= ReadIntInto(xml, "ComicInfo/Number", issue => book.IndexNumber = issue);
+ hasFoundMetadata |= ReadStringInto(xml, "ComicInfo/Summary", summary => book.Overview = summary);
+ hasFoundMetadata |= ReadIntInto(xml, "ComicInfo/Year", year => book.ProductionYear = year);
+ hasFoundMetadata |= ReadThreePartDateInto(xml, "ComicInfo/Year", "ComicInfo/Month", "ComicInfo/Day", dateTime => book.PremiereDate = dateTime);
+ hasFoundMetadata |= ReadCommaSeparatedStringsInto(xml, "ComicInfo/Genre", genres =>
+ {
+ foreach (var genre in genres)
+ {
+ book.AddGenre(genre);
+ }
+ });
+ hasFoundMetadata |= ReadStringInto(xml, "ComicInfo/Publisher", publisher => book.SetStudios([publisher]));
+
+ hasFoundMetadata |= ReadStringInto(xml, "ComicInfo/AlternateSeries", title =>
+ {
+ if (isManga)
+ {
+ // Software like ComicTagger (https://github.com/comictagger/comictagger) will use
+ // this field for the series name in the original language when tagging manga.
+ book.OriginalTitle = title;
+ }
+ else
+ {
+ // Some US comics can be part of cross-over story arcs. This field is then used to
+ // specify an alternate series.
+ }
+ });
+
+ return hasFoundMetadata ? book : null;
+ }
+
+ ///
+ /// Read people metadata.
+ ///
+ /// The XDocument to read for people metadata.
+ /// The metadata result to update.
+ public static void ReadPeopleMetadata(XDocument xml, MetadataResult metadataResult)
+ {
+ ReadCommaSeparatedStringsInto(xml, "ComicInfo/Writer", authors =>
+ {
+ foreach (var p in authors)
+ {
+ metadataResult.AddPerson(new PersonInfo { Name = p, Type = PersonKind.Author });
+ }
+ });
+
+ ReadCommaSeparatedStringsInto(xml, "ComicInfo/Penciller", pencillers =>
+ {
+ foreach (var p in pencillers)
+ {
+ metadataResult.AddPerson(new PersonInfo { Name = p, Type = PersonKind.Penciller });
+ }
+ });
+
+ ReadCommaSeparatedStringsInto(xml, "ComicInfo/Inker", inkers =>
+ {
+ foreach (var p in inkers)
+ {
+ metadataResult.AddPerson(new PersonInfo { Name = p, Type = PersonKind.Inker });
+ }
+ });
+
+ ReadCommaSeparatedStringsInto(xml, "ComicInfo/Letterer", letterers =>
+ {
+ foreach (var p in letterers)
+ {
+ metadataResult.AddPerson(new PersonInfo { Name = p, Type = PersonKind.Letterer });
+ }
+ });
+
+ ReadCommaSeparatedStringsInto(xml, "ComicInfo/CoverArtist", artists =>
+ {
+ foreach (var p in artists)
+ {
+ metadataResult.AddPerson(new PersonInfo { Name = p, Type = PersonKind.CoverArtist });
+ }
+ });
+
+ ReadCommaSeparatedStringsInto(xml, "ComicInfo/Colourist", colorists =>
+ {
+ foreach (var p in colorists)
+ {
+ metadataResult.AddPerson(new PersonInfo { Name = p, Type = PersonKind.Colorist });
+ }
+ });
+ }
+
+ ///
+ /// Read culture information.
+ ///
+ /// the XDocument to read for metadata.
+ /// The path to search.
+ /// The action to take after parsing all metadata.
+ public static void ReadCultureInfoInto(XDocument xml, string xPath, Action commitResult)
+ {
+ string? culture = null;
+
+ if (!ReadStringInto(xml, xPath, value => culture = value))
+ {
+ return;
+ }
+
+ // culture cannot be null here as the method would have returned earlier
+ commitResult(new CultureInfo(culture!));
+ }
+
+ private static bool ReadStringInto(XDocument xml, string xPath, Action commitResult)
+ {
+ var resultElement = xml.XPathSelectElement(xPath);
+
+ if (resultElement is not null && !string.IsNullOrWhiteSpace(resultElement.Value))
+ {
+ commitResult(resultElement.Value);
+ return true;
+ }
+
+ return false;
+ }
+
+ private static bool ReadCommaSeparatedStringsInto(XDocument xml, string xPath, Action> commitResult)
+ {
+ var resultElement = xml.XPathSelectElement(xPath);
+
+ if (resultElement is null || string.IsNullOrWhiteSpace(resultElement.Value))
+ {
+ return false;
+ }
+
+ try
+ {
+ var splits = resultElement.Value.Split(",").Select(p => p.Trim()).ToArray();
+ if (splits.Length < 1)
+ {
+ return false;
+ }
+
+ commitResult(splits);
+ return true;
+ }
+ catch (ArgumentNullException)
+ {
+ return false;
+ }
+ }
+
+ private static bool ReadIntInto(XDocument xml, string xPath, Action commitResult)
+ {
+ var resultElement = xml.XPathSelectElement(xPath);
+
+ if (resultElement is not null && !string.IsNullOrWhiteSpace(resultElement.Value))
+ {
+ return ParseInt(resultElement.Value, commitResult);
+ }
+
+ return false;
+ }
+
+ private static bool ReadThreePartDateInto(XDocument xml, string yearXPath, string monthXPath, string dayXPath, Action commitResult)
+ {
+ int year = 0;
+ int month = 0;
+ int day = 0;
+ var parsed = false;
+
+ parsed |= ReadIntInto(xml, yearXPath, num => year = num);
+ parsed |= ReadIntInto(xml, monthXPath, num => month = num);
+ parsed |= ReadIntInto(xml, dayXPath, num => day = num);
+
+ if (!parsed)
+ {
+ return false;
+ }
+
+ try
+ {
+ var dateTime = new DateTime(year, month, day, 0, 0, 0, DateTimeKind.Unspecified);
+
+ commitResult(dateTime);
+ return true;
+ }
+ catch (ArgumentOutOfRangeException)
+ {
+ return false;
+ }
+ }
+
+ private static bool ParseInt(string input, Action commitResult)
+ {
+ if (int.TryParse(input, out var parsed))
+ {
+ commitResult(parsed);
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs b/MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs
new file mode 100644
index 0000000000..cfd22a850e
--- /dev/null
+++ b/MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs
@@ -0,0 +1,98 @@
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Xml;
+using System.Xml.Linq;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.IO;
+using Microsoft.Extensions.Logging;
+
+namespace MediaBrowser.Providers.Books.ComicInfo;
+
+///
+/// Handles metadata for comics which is saved as an XML document. This XML document is not part
+/// of the comic itself but an external file.
+///
+public class ExternalComicInfoProvider : IComicProvider
+{
+ private readonly IFileSystem _fileSystem;
+ private readonly ILogger _logger;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Instance of the interface.
+ /// Instance of the interface.
+ public ExternalComicInfoProvider(IFileSystem fileSystem, ILogger logger)
+ {
+ _logger = logger;
+ _fileSystem = fileSystem;
+ }
+
+ ///
+ public async ValueTask> ReadMetadata(ItemInfo info, IDirectoryService directoryService, CancellationToken cancellationToken)
+ {
+ var comicInfoXml = await LoadXml(info, cancellationToken).ConfigureAwait(false);
+
+ if (comicInfoXml is null)
+ {
+ _logger.LogDebug("No external ComicInfo metadata found for {Path}.", info.Path);
+ return new MetadataResult { HasMetadata = false };
+ }
+
+ var book = ComicInfoReader.ReadComicBookMetadata(comicInfoXml);
+
+ if (book is null)
+ {
+ return new MetadataResult { HasMetadata = false };
+ }
+
+ var metadataResult = new MetadataResult { Item = book, HasMetadata = true };
+
+ ComicInfoReader.ReadPeopleMetadata(comicInfoXml, metadataResult);
+ ComicInfoReader.ReadCultureInfoInto(comicInfoXml, "ComicInfo/LanguageISO", cultureInfo => metadataResult.ResultLanguage = cultureInfo.ThreeLetterISOLanguageName);
+
+ return metadataResult;
+ }
+
+ ///
+ public bool HasItemChanged(BaseItem item)
+ {
+ var file = GetXmlFilePath(item.Path);
+
+ return file.Exists && _fileSystem.GetLastWriteTimeUtc(file) > item.DateLastSaved;
+ }
+
+ private async Task LoadXml(ItemInfo info, CancellationToken cancellationToken)
+ {
+ var file = GetXmlFilePath(info.Path);
+ if (!file.Exists)
+ {
+ return null;
+ }
+
+ try
+ {
+ using var reader = XmlReader.Create(file.FullName, new XmlReaderSettings { Async = true });
+ var comicInfoXml = XDocument.LoadAsync(reader, LoadOptions.None, cancellationToken);
+
+ return await comicInfoXml.ConfigureAwait(false);
+ }
+ catch (Exception e)
+ {
+ _logger.LogWarning(e, "Could not load external ComicInfo XML from {Path}.", file.FullName);
+ return null;
+ }
+ }
+
+ private FileSystemMetadata GetXmlFilePath(string path)
+ {
+ var fileInfo = _fileSystem.GetFileSystemInfo(path);
+ var directoryInfo = fileInfo.IsDirectory ? fileInfo : _fileSystem.GetDirectoryInfo(Path.GetDirectoryName(path)!);
+ var file = _fileSystem.GetFileInfo(Path.Combine(directoryInfo.FullName, Path.GetFileNameWithoutExtension(path) + ".xml"));
+
+ return file.Exists ? file : _fileSystem.GetFileInfo(Path.Combine(directoryInfo.FullName, ComicInfoReader.ComicRackMetaFile));
+ }
+}
diff --git a/MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs b/MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs
new file mode 100644
index 0000000000..19062452b9
--- /dev/null
+++ b/MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs
@@ -0,0 +1,120 @@
+using System;
+using System.IO.Compression;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Xml.Linq;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.IO;
+using Microsoft.Extensions.Logging;
+
+namespace MediaBrowser.Providers.Books.ComicInfo;
+
+///
+/// Handles metadata for comics which is saved as an XML document inside the comic itself.
+///
+public class InternalComicInfoProvider : IComicProvider
+{
+ private readonly IFileSystem _fileSystem;
+ private readonly ILogger _logger;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Instance of the interface.
+ /// Instance of the interface.
+ public InternalComicInfoProvider(IFileSystem fileSystem, ILogger logger)
+ {
+ _logger = logger;
+ _fileSystem = fileSystem;
+ }
+
+ ///
+ public async ValueTask> ReadMetadata(ItemInfo info, IDirectoryService directoryService, CancellationToken cancellationToken)
+ {
+ var comicInfoXml = await LoadXml(info, cancellationToken).ConfigureAwait(false);
+
+ if (comicInfoXml is null)
+ {
+ _logger.LogDebug("Could not load ComicInfo metadata for {Path} from XML file. No internal XML in comic archive.", info.Path);
+ return new MetadataResult { HasMetadata = false };
+ }
+
+ var book = ComicInfoReader.ReadComicBookMetadata(comicInfoXml);
+
+ if (book is null)
+ {
+ return new MetadataResult { HasMetadata = false };
+ }
+
+ var metadataResult = new MetadataResult { Item = book, HasMetadata = true };
+
+ ComicInfoReader.ReadPeopleMetadata(comicInfoXml, metadataResult);
+ ComicInfoReader.ReadCultureInfoInto(comicInfoXml, "ComicInfo/LanguageISO", cultureInfo => metadataResult.ResultLanguage = cultureInfo.ThreeLetterISOLanguageName);
+
+ return metadataResult;
+ }
+
+ ///
+ public bool HasItemChanged(BaseItem item)
+ {
+ var file = GetComicBookFile(item.Path);
+
+ if (file is null)
+ {
+ return false;
+ }
+
+ return file.Exists && _fileSystem.GetLastWriteTimeUtc(file) > item.DateLastSaved;
+ }
+
+ private async Task LoadXml(ItemInfo info, CancellationToken cancellationToken)
+ {
+ var path = GetComicBookFile(info.Path)?.FullName;
+
+ if (path is null)
+ {
+ return null;
+ }
+
+ try
+ {
+ // open the comic archive and try to get the ComicInfo.xml entry
+ using var comicBookFile = await ZipFile.OpenReadAsync(path, cancellationToken).ConfigureAwait(false);
+ var container = comicBookFile.GetEntry(ComicInfoReader.ComicRackMetaFile);
+
+ if (container is null)
+ {
+ return null;
+ }
+
+ using var containerStream = await container.OpenAsync(cancellationToken).ConfigureAwait(false);
+ var comicInfoXml = XDocument.LoadAsync(containerStream, LoadOptions.None, cancellationToken);
+
+ return await comicInfoXml.ConfigureAwait(false);
+ }
+ catch (Exception e)
+ {
+ _logger.LogError(e, "could not load internal XML from {Path}", path);
+ return null;
+ }
+ }
+
+ private FileSystemMetadata? GetComicBookFile(string path)
+ {
+ var fileInfo = _fileSystem.GetFileSystemInfo(path);
+
+ if (fileInfo.IsDirectory)
+ {
+ return null;
+ }
+
+ // only parse files that are known to have internal metadata
+ if (!string.Equals(fileInfo.Extension, ".cbz", StringComparison.OrdinalIgnoreCase))
+ {
+ return null;
+ }
+
+ return fileInfo;
+ }
+}
diff --git a/MediaBrowser.Providers/Books/ComicProvider.cs b/MediaBrowser.Providers/Books/ComicProvider.cs
new file mode 100644
index 0000000000..d59c58c330
--- /dev/null
+++ b/MediaBrowser.Providers/Books/ComicProvider.cs
@@ -0,0 +1,59 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Providers;
+
+namespace MediaBrowser.Providers.Books;
+
+///
+/// Comic provider.
+///
+public class ComicProvider : ILocalMetadataProvider, IHasItemChangeMonitor
+{
+ private readonly IEnumerable _comicProviders;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The list of comic providers.
+ public ComicProvider(IEnumerable comicProviders)
+ {
+ _comicProviders = comicProviders;
+ }
+
+ ///
+ public string Name => "Comic Provider";
+
+ ///
+ public async Task> GetMetadata(ItemInfo info, IDirectoryService directoryService, CancellationToken cancellationToken)
+ {
+ foreach (IComicProvider comicProvider in _comicProviders)
+ {
+ var metadata = await comicProvider.ReadMetadata(info, directoryService, cancellationToken).ConfigureAwait(false);
+
+ if (metadata.HasMetadata)
+ {
+ return metadata;
+ }
+ }
+
+ return new MetadataResult { HasMetadata = false };
+ }
+
+ ///
+ public bool HasChanged(BaseItem item, IDirectoryService directoryService)
+ {
+ foreach (IComicProvider iComicFileProvider in _comicProviders)
+ {
+ var fileChanged = iComicFileProvider.HasItemChanged(item);
+
+ if (fileChanged)
+ {
+ return fileChanged;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/MediaBrowser.Providers/Books/IComicProvider.cs b/MediaBrowser.Providers/Books/IComicProvider.cs
new file mode 100644
index 0000000000..06c8bd1136
--- /dev/null
+++ b/MediaBrowser.Providers/Books/IComicProvider.cs
@@ -0,0 +1,28 @@
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Providers;
+
+namespace MediaBrowser.Providers.Books;
+
+///
+/// Comic provider interface.
+///
+public interface IComicProvider
+{
+ ///
+ /// Read the item metadata.
+ ///
+ /// The item information.
+ /// Instance of the interface.
+ /// The cancellation token.
+ /// The metadata result.
+ ValueTask> ReadMetadata(ItemInfo info, IDirectoryService directoryService, CancellationToken cancellationToken);
+
+ ///
+ /// Determine whether the item has changed.
+ ///
+ /// The item.
+ /// Item change status.
+ bool HasItemChanged(BaseItem item);
+}
diff --git a/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs b/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs
index 15ea2ce5ab..6266413dfc 100644
--- a/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs
+++ b/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs
@@ -2,6 +2,7 @@ using System;
using System.Globalization;
using System.IO;
using System.Linq;
+using System.Text.RegularExpressions;
using System.Threading;
using System.Xml;
using Jellyfin.Data.Enums;
@@ -17,7 +18,7 @@ namespace MediaBrowser.Providers.Books.OpenPackagingFormat
/// Methods used to pull metadata and other information from Open Packaging Format in XML objects.
///
/// The type of category.
- public class OpfReader
+ public partial class OpfReader
{
private const string DcNamespace = @"http://purl.org/dc/elements/1.1/";
private const string OpfNamespace = @"http://www.idpf.org/2007/opf";
@@ -42,6 +43,9 @@ namespace MediaBrowser.Providers.Books.OpenPackagingFormat
_namespaceManager.AddNamespace("opf", OpfNamespace);
}
+ [GeneratedRegex(@"(?<=\p{L})\.(?!\s|$)")]
+ private static partial Regex InitialsRegex();
+
///
/// Checks for the existence of a cover image.
///
@@ -125,7 +129,7 @@ namespace MediaBrowser.Providers.Books.OpenPackagingFormat
ReadStringInto("//dc:date", date =>
{
- if (DateTime.TryParse(date, out var dateValue))
+ if (DateTime.TryParse(date, CultureInfo.InvariantCulture, out var dateValue))
{
book.PremiereDate = dateValue.Date;
book.ProductionYear = dateValue.Date.Year;
@@ -229,11 +233,23 @@ namespace MediaBrowser.Providers.Books.OpenPackagingFormat
{
foreach (XmlElement creator in resultElement)
{
- var creatorName = creator.InnerText;
var role = creator.GetAttribute("opf:role");
- var person = new PersonInfo { Name = creatorName, Type = GetRole(role) };
+ var normalizedCreators = creator.InnerText
+ .Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .Select(fullName =>
+ {
+ if (fullName.Split(',', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) is [var lastName, var firstName])
+ {
+ fullName = $"{firstName} {lastName}";
+ }
- book.AddPerson(person);
+ return InitialsRegex().Replace(fullName, ". ");
+ });
+
+ foreach (var fullName in normalizedCreators)
+ {
+ book.AddPerson(new PersonInfo { Name = fullName, Type = GetRole(role) });
+ }
}
}
}
diff --git a/MediaBrowser.Providers/Lyric/LyricManager.cs b/MediaBrowser.Providers/Lyric/LyricManager.cs
index 913a104a0d..af31e373ef 100644
--- a/MediaBrowser.Providers/Lyric/LyricManager.cs
+++ b/MediaBrowser.Providers/Lyric/LyricManager.cs
@@ -398,7 +398,7 @@ public class LyricManager : ILyricManager
{
var mediaFolderPath = Path.GetFullPath(Path.Combine(audio.ContainingFolderPath, saveFileName));
// TODO: Add some error handling to the API user: return BadRequest("Could not save lyric, bad path.");
- if (mediaFolderPath.StartsWith(audio.ContainingFolderPath, StringComparison.Ordinal))
+ if (PathHelper.IsContainedIn(audio.ContainingFolderPath, mediaFolderPath))
{
savePaths.Add(mediaFolderPath);
}
@@ -407,7 +407,7 @@ public class LyricManager : ILyricManager
var internalPath = Path.GetFullPath(Path.Combine(audio.GetInternalMetadataPath(), saveFileName));
// TODO: Add some error to the user: return BadRequest("Could not save lyric, bad path.");
- if (internalPath.StartsWith(audio.GetInternalMetadataPath(), StringComparison.Ordinal))
+ if (PathHelper.IsContainedIn(audio.GetInternalMetadataPath(), internalPath))
{
savePaths.Add(internalPath);
}
diff --git a/MediaBrowser.Providers/Manager/ImageSaver.cs b/MediaBrowser.Providers/Manager/ImageSaver.cs
index d9a8c044b9..aa4ca5afd8 100644
--- a/MediaBrowser.Providers/Manager/ImageSaver.cs
+++ b/MediaBrowser.Providers/Manager/ImageSaver.cs
@@ -90,7 +90,7 @@ namespace MediaBrowser.Providers.Manager
{
ArgumentException.ThrowIfNullOrEmpty(mimeType);
- var saveLocally = item.SupportsLocalMetadata && item.IsSaveLocalMetadataEnabled() && !item.ExtraType.HasValue && item is not Audio;
+ var saveLocally = item.SupportsLocalMetadata && item.IsSaveLocalMetadataEnabled() && !item.ExtraType.HasValue && (item is AudioBook || item is not Audio);
if (type != ImageType.Primary && item is Episode)
{
diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs
index c2e523cfaf..a438a94c40 100644
--- a/MediaBrowser.Providers/Manager/MetadataService.cs
+++ b/MediaBrowser.Providers/Manager/MetadataService.cs
@@ -680,11 +680,18 @@ namespace MediaBrowser.Providers.Manager
return providers;
}
- protected virtual IEnumerable GetNonLocalImageProviders(BaseItem item, IEnumerable allImageProviders, ImageRefreshOptions options)
+ protected virtual IEnumerable GetNonLocalImageProviders(BaseItem item, IEnumerable allImageProviders, MetadataRefreshOptions options)
{
// Get providers to refresh
var providers = allImageProviders.Where(i => i is not ILocalImageProvider);
+ // When identifying, run the provider the user picked first so the correct image is used.
+ if (!string.IsNullOrEmpty(options.SearchResult?.SearchProviderName))
+ {
+ providers = providers
+ .OrderBy(i => string.Equals(i.Name, options.SearchResult.SearchProviderName, StringComparison.OrdinalIgnoreCase) ? 0 : 1);
+ }
+
var dateLastImageRefresh = item.DateLastRefreshed;
// Run all if either of these flags are true
@@ -831,8 +838,16 @@ namespace MediaBrowser.Providers.Manager
var isLocalLocked = temp.Item.IsLocked;
if (!isLocalLocked && (options.ReplaceAllMetadata || options.MetadataRefreshMode > MetadataRefreshMode.ValidationOnly))
{
- var remoteResult = await ExecuteRemoteProviders(temp, logName, false, id, providers.OfType>(), cancellationToken)
- .ConfigureAwait(false);
+ var remoteProviders = providers.OfType>();
+
+ // When identifying, run the provider the user picked first so the correct IDs are used.
+ if (!string.IsNullOrEmpty(options.SearchResult?.SearchProviderName))
+ {
+ remoteProviders = remoteProviders
+ .OrderBy(i => string.Equals(i.Name, options.SearchResult.SearchProviderName, StringComparison.OrdinalIgnoreCase) ? 0 : 1);
+ }
+
+ var remoteResult = await ExecuteRemoteProviders(temp, logName, false, id, remoteProviders, cancellationToken).ConfigureAwait(false);
refreshResult.UpdateType |= remoteResult.UpdateType;
refreshResult.ErrorMessage = remoteResult.ErrorMessage;
@@ -1108,7 +1123,7 @@ namespace MediaBrowser.Providers.Manager
{
if (replaceData || !target.RunTimeTicks.HasValue)
{
- if (target is not Audio && target is not Video)
+ if (target is not Audio && target is not Video && target is not Book)
{
target.RunTimeTicks = source.RunTimeTicks;
}
diff --git a/MediaBrowser.Providers/MediaBrowser.Providers.csproj b/MediaBrowser.Providers/MediaBrowser.Providers.csproj
index 1032582900..2b0f480b1c 100644
--- a/MediaBrowser.Providers/MediaBrowser.Providers.csproj
+++ b/MediaBrowser.Providers/MediaBrowser.Providers.csproj
@@ -21,7 +21,9 @@
+
+
diff --git a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs
index 0ecbb6f068..b70cba5b3b 100644
--- a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs
+++ b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs
@@ -549,7 +549,7 @@ namespace MediaBrowser.Providers.MediaInfo
var candidateUnsynchronizedLyric = supportedLyrics.FirstOrDefault(l => l.Format is LyricsInfo.LyricsFormat.UNSYNCHRONIZED or LyricsInfo.LyricsFormat.OTHER && l.UnsynchronizedLyrics is not null);
var lyrics = candidateSynchronizedLyric is not null ? candidateSynchronizedLyric.FormatSynch() : candidateUnsynchronizedLyric?.UnsynchronizedLyrics;
if (!string.IsNullOrWhiteSpace(lyrics)
- && tryExtractEmbeddedLyrics)
+ && (tryExtractEmbeddedLyrics || options.ReplaceAllMetadata))
{
await _lyricManager.SaveLyricAsync(audio, "lrc", lyrics).ConfigureAwait(false);
}
diff --git a/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs b/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs
index 789df8f061..221c6bff5e 100644
--- a/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs
+++ b/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs
@@ -24,6 +24,8 @@ using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.MediaInfo;
using Microsoft.Extensions.Logging;
+using PDFtoImage;
+using SharpCompress.Archives;
namespace MediaBrowser.Providers.MediaInfo
{
@@ -37,6 +39,7 @@ namespace MediaBrowser.Providers.MediaInfo
ICustomMetadataProvider,
ICustomMetadataProvider