diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md
index 09a7198afe..d70ffddfd7 100644
--- a/CONTRIBUTORS.md
+++ b/CONTRIBUTORS.md
@@ -114,6 +114,7 @@
- [oddstr13](https://github.com/oddstr13)
- [olsh](https://github.com/olsh)
- [orryverducci](https://github.com/orryverducci)
+ - [PCEWLKR](https://github.com/PCEWLKR)
- [petermcneil](https://github.com/petermcneil)
- [Phlogi](https://github.com/Phlogi)
- [pjeanjean](https://github.com/pjeanjean)
diff --git a/Directory.Packages.props b/Directory.Packages.props
index d0df007071..7c70b5a9e9 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -74,8 +74,8 @@
-
-
+
+
diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs
index cc85f09d23..a826db090f 100644
--- a/Emby.Server.Implementations/Library/LibraryManager.cs
+++ b/Emby.Server.Implementations/Library/LibraryManager.cs
@@ -3395,9 +3395,9 @@ namespace Emby.Server.Implementations.Library
}
///
- public IReadOnlyList GetPeopleNamesByItems(IReadOnlyList itemIds, IReadOnlyList personTypes, int limit)
+ public IReadOnlyDictionary> GetPeopleNamesByItems(IReadOnlyList itemIds, IReadOnlyList personTypes)
{
- return _peopleRepository.GetPeopleNamesByItems(itemIds, personTypes, limit);
+ return _peopleRepository.GetPeopleNamesByItems(itemIds, personTypes);
}
public void UpdatePeople(BaseItem item, List people)
diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs
index 66614c6725..0caf66555a 100644
--- a/Emby.Server.Implementations/Library/MediaSourceManager.cs
+++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs
@@ -127,6 +127,11 @@ namespace Emby.Server.Implementations.Library
return true;
}
+ if (stream.IsVobSubSubtitleStream)
+ {
+ return true;
+ }
+
return false;
}
diff --git a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs
index 358c170db2..d923cff07e 100644
--- a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs
+++ b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs
@@ -125,6 +125,7 @@ public class SimilarItemsManager : ISimilarItemsManager
var allResults = new List<(BaseItem Item, float Score)>();
var excludeIds = new HashSet { item.Id };
+ var excludeKeys = new HashSet(StringComparer.OrdinalIgnoreCase) { item.GetPresentationUniqueKey() };
foreach (var (providerOrder, provider) in orderedProviders.Index())
{
if (allResults.Count >= requestedLimit || cancellationToken.IsCancellationRequested)
@@ -149,7 +150,9 @@ public class SimilarItemsManager : ISimilarItemsManager
foreach (var (position, resultItem) in items.Index())
{
- if (excludeIds.Add(resultItem.Id))
+ var isNewId = excludeIds.Add(resultItem.Id);
+ var isNewKey = excludeKeys.Add(resultItem.GetPresentationUniqueKey());
+ if (isNewId && isNewKey)
{
var score = CalculateScore(null, providerOrder, position);
allResults.Add((resultItem, score));
@@ -163,7 +166,7 @@ public class SimilarItemsManager : ISimilarItemsManager
var cachedReferences = await TryReadSimilarItemsCacheAsync(cachePath, cancellationToken).ConfigureAwait(false);
if (cachedReferences is not null)
{
- var resolvedItems = ResolveRemoteReferences(cachedReferences, providerOrder, user, dtoOptions, itemKind, excludeIds);
+ var resolvedItems = ResolveRemoteReferences(cachedReferences, providerOrder, user, dtoOptions, itemKind, excludeIds, excludeKeys);
allResults.AddRange(resolvedItems);
continue;
}
@@ -191,7 +194,7 @@ public class SimilarItemsManager : ISimilarItemsManager
if (pendingBatch.Count >= BatchSize)
{
- var resolvedItems = ResolveRemoteReferences(pendingBatch, providerOrder, user, dtoOptions, itemKind, excludeIds);
+ var resolvedItems = ResolveRemoteReferences(pendingBatch, providerOrder, user, dtoOptions, itemKind, excludeIds, excludeKeys);
allResults.AddRange(resolvedItems);
remaining -= resolvedItems.Count;
pendingBatch.Clear();
@@ -206,7 +209,7 @@ public class SimilarItemsManager : ISimilarItemsManager
// Resolve any remaining references in the last partial batch
if (pendingBatch.Count > 0)
{
- var resolvedItems = ResolveRemoteReferences(pendingBatch, providerOrder, user, dtoOptions, itemKind, excludeIds);
+ var resolvedItems = ResolveRemoteReferences(pendingBatch, providerOrder, user, dtoOptions, itemKind, excludeIds, excludeKeys);
allResults.AddRange(resolvedItems);
}
@@ -435,7 +438,11 @@ public class SimilarItemsManager : ISimilarItemsManager
private IReadOnlyList GetPeopleNames(IReadOnlyList items, IReadOnlyList personTypes)
{
var itemIds = items.Select(i => i.Id).ToArray();
- return _libraryManager.GetPeopleNamesByItems(itemIds, personTypes, limit: 0);
+ return _libraryManager.GetPeopleNamesByItems(itemIds, personTypes)
+ .Values
+ .SelectMany(names => names)
+ .Distinct()
+ .ToArray();
}
private List<(BaseItem Item, float Score)> ResolveRemoteReferences(
@@ -444,14 +451,15 @@ public class SimilarItemsManager : ISimilarItemsManager
User? user,
DtoOptions dtoOptions,
BaseItemKind itemKind,
- HashSet excludeIds)
+ HashSet excludeIds,
+ HashSet excludeKeys)
{
if (references.Count == 0)
{
return [];
}
- var resolvedById = new Dictionary();
+ var resolvedByKey = new Dictionary(StringComparer.OrdinalIgnoreCase);
var providerLookup = new Dictionary<(string ProviderName, string ProviderId), (float? Score, int Position)>(StringTupleComparer.Instance);
foreach (var (position, match) in references.Index())
@@ -482,7 +490,13 @@ public class SimilarItemsManager : ISimilarItemsManager
foreach (var item in items)
{
- if (excludeIds.Contains(item.Id) || resolvedById.ContainsKey(item.Id))
+ if (excludeIds.Contains(item.Id))
+ {
+ continue;
+ }
+
+ var presentationKey = item.GetPresentationUniqueKey();
+ if (excludeKeys.Contains(presentationKey))
{
continue;
}
@@ -492,10 +506,9 @@ public class SimilarItemsManager : ISimilarItemsManager
if (item.TryGetProviderId(providerName, out var itemProviderId) && providerLookup.TryGetValue((providerName, itemProviderId), out var matchInfo))
{
var score = CalculateScore(matchInfo.Score, providerOrder, matchInfo.Position);
- if (!resolvedById.TryGetValue(item.Id, out var existing) || existing.Score < score)
+ if (!resolvedByKey.TryGetValue(presentationKey, out var existing) || existing.Score < score)
{
- excludeIds.Add(item.Id);
- resolvedById[item.Id] = (item, score);
+ resolvedByKey[presentationKey] = (item, score);
}
break;
@@ -503,7 +516,13 @@ public class SimilarItemsManager : ISimilarItemsManager
}
}
- return [.. resolvedById.Values];
+ foreach (var (key, entry) in resolvedByKey)
+ {
+ excludeIds.Add(entry.Item.Id);
+ excludeKeys.Add(key);
+ }
+
+ return [.. resolvedByKey.Values];
}
private static float CalculateScore(float? matchScore, int providerOrder, int position)
diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs
index 5148b62655..18811ef3a9 100644
--- a/Emby.Server.Implementations/Session/SessionManager.cs
+++ b/Emby.Server.Implementations/Session/SessionManager.cs
@@ -453,18 +453,6 @@ namespace Emby.Server.Implementations.Session
session.PlayState.RepeatMode = info.RepeatMode;
session.PlayState.PlaybackOrder = info.PlaybackOrder;
session.PlaylistItemId = info.PlaylistItemId;
-
- var nowPlayingQueue = info.NowPlayingQueue;
-
- if (nowPlayingQueue?.Length > 0 && !nowPlayingQueue.SequenceEqual(session.NowPlayingQueue))
- {
- session.NowPlayingQueue = nowPlayingQueue;
-
- var itemIds = Array.ConvertAll(nowPlayingQueue, queue => queue.Id);
- session.NowPlayingQueueFullItems = _dtoService.GetBaseItemDtos(
- _libraryManager.GetItemList(new InternalItemsQuery { ItemIds = itemIds }),
- new DtoOptions(true));
- }
}
///
@@ -1217,7 +1205,6 @@ namespace Emby.Server.Implementations.Session
SupportsMediaControl = sessionInfo.SupportsMediaControl,
SupportsRemoteControl = sessionInfo.SupportsRemoteControl,
NowPlayingQueue = sessionInfo.NowPlayingQueue,
- NowPlayingQueueFullItems = sessionInfo.NowPlayingQueueFullItems,
HasCustomDeviceName = sessionInfo.HasCustomDeviceName,
PlaylistItemId = sessionInfo.PlaylistItemId,
ServerId = sessionInfo.ServerId,
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs
index 7c64d9854d..c5b5fbf6d8 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs
@@ -5,7 +5,6 @@ using System.Collections.Generic;
using System.Linq;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Entities;
-using Jellyfin.Extensions;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Querying;
@@ -133,15 +132,21 @@ public sealed partial class BaseItemRepository
IsSeries = filter.IsSeries
});
- // Use a correlated EXISTS rather than `IN (SELECT DISTINCT CleanValue ...)`. The
- // IN-form would force materialization of the full set of artist CleanValues across the
- // entire library before filtering.
+ // Keep this as an IQueryable sub-select. Materializing to a list would inline one
+ // bound parameter per CleanValue and hit SQLite's variable cap on libraries with
+ // high-cardinality value types (e.g. tens of thousands of artists).
+ var matchingCleanValues = context.ItemValuesMap
+ .Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type))
+ .Join(
+ innerQueryFilter,
+ ivm => ivm.ItemId,
+ g => g.Id,
+ (ivm, g) => ivm.ItemValue.CleanValue)
+ .Distinct();
+
var innerQuery = PrepareItemQuery(context, filter)
.Where(e => e.Type == returnType)
- .Where(e => context.ItemValuesMap.Any(ivm =>
- itemValueTypes.Contains(ivm.ItemValue.Type)
- && ivm.ItemValue.CleanValue == e.CleanName
- && innerQueryFilter.Any(g => g.Id == ivm.ItemId)));
+ .Where(e => matchingCleanValues.Contains(e.CleanName!));
var outerQueryFilter = new InternalItemsQuery(filter.User)
{
@@ -164,35 +169,46 @@ public sealed partial class BaseItemRepository
ExcludeItemIds = filter.ExcludeItemIds
};
- // Build the master query and collapse rows that share a PresentationUniqueKey
- // (e.g. alternate versions) by picking the lowest Id per group.
+ // Collapse rows that share a PresentationUniqueKey (e.g. alternate versions) by picking
+ // the lowest Id per group. For MusicArtist, prefer the entity from a library the user
+ // can actually access,since the same artist can have a folder in multiple libraries.
+ // Keep as an IQueryable sub-select so paging is applied AFTER
+ // ApplyOrder runs the caller's actual sort.
var masterQuery = TranslateQuery(innerQuery, context, outerQueryFilter);
- var orderedMasterQuery = BuildOrderedMasterQuery(masterQuery, filter.SearchTerm);
+ var isMusicArtist = returnType == _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist];
+ var representativeIds = isMusicArtist
+ ? masterQuery
+ .GroupBy(e => e.PresentationUniqueKey)
+ .Select(g => g
+ .OrderBy(e => filter.TopParentIds.Contains(e.TopParentId ?? Guid.Empty) ? 0 : 1)
+ .ThenBy(e => e.Id)
+ .First().Id)
+ : masterQuery
+ .GroupBy(e => e.PresentationUniqueKey)
+ .Select(g => g.Min(e => e.Id));
var result = new QueryResult<(BaseItemDto, ItemCounts?)>();
if (filter.EnableTotalRecordCount)
{
- result.TotalRecordCount = orderedMasterQuery.Count();
+ result.TotalRecordCount = representativeIds.Count();
}
+ var query = ApplyNavigations(
+ context.BaseItems.AsNoTracking().AsSingleQuery().Where(e => representativeIds.Contains(e.Id)),
+ filter);
+
+ query = ApplyOrder(query, filter, context);
+
if (filter.StartIndex.HasValue && filter.StartIndex.Value > 0)
{
- orderedMasterQuery = orderedMasterQuery.Skip(filter.StartIndex.Value);
+ query = query.Skip(filter.StartIndex.Value);
}
if (filter.Limit.HasValue)
{
- orderedMasterQuery = orderedMasterQuery.Take(filter.Limit.Value);
+ query = query.Take(filter.Limit.Value);
}
- var masterIds = orderedMasterQuery.ToList();
-
- var query = ApplyNavigations(
- context.BaseItems.AsNoTracking().AsSingleQuery().Where(e => masterIds.Contains(e.Id)),
- filter);
-
- query = ApplyOrder(query, filter, context);
-
result.StartIndex = filter.StartIndex ?? 0;
if (filter.IncludeItemTypes.Length > 0)
{
@@ -228,43 +244,6 @@ public sealed partial class BaseItemRepository
return result;
}
- private static IQueryable BuildOrderedMasterQuery(IQueryable masterQuery, string? searchTerm)
- {
- if (string.IsNullOrEmpty(searchTerm))
- {
- return masterQuery
- .GroupBy(e => e.PresentationUniqueKey)
- .Select(g => new { Id = g.Min(e => e.Id), SortName = g.Min(e => e.SortName) })
- .OrderBy(x => x.SortName)
- .Select(x => x.Id);
- }
-
- var cleanSearchTerm = searchTerm.GetCleanValue();
- var cleanSearchPrefix = cleanSearchTerm + " ";
-
- return masterQuery
- .Select(e => new
- {
- e.Id,
- e.PresentationUniqueKey,
- e.SortName,
- Score = (e.CleanName == cleanSearchTerm) ? 0
- : e.CleanName!.StartsWith(cleanSearchTerm) ? 1
- : e.CleanName!.Contains(cleanSearchPrefix) ? 2
- : 3
- })
- .GroupBy(x => x.PresentationUniqueKey)
- .Select(g => new
- {
- Id = g.Min(x => x.Id),
- Score = g.Min(x => x.Score),
- SortName = g.Min(x => x.SortName)
- })
- .OrderBy(x => x.Score)
- .ThenBy(x => x.SortName)
- .Select(x => x.Id);
- }
-
private Dictionary BuildItemCountsByCleanName(
Database.Implementations.JellyfinDbContext context,
InternalItemsQuery filter,
diff --git a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
index 6062aaca2f..eb87b525fe 100644
--- a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
+++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
@@ -166,7 +166,7 @@ public class PeopleRepository(IDbContextFactory dbProvider, I
}
///
- public IReadOnlyList GetPeopleNamesByItems(IReadOnlyList itemIds, IReadOnlyList personTypes, int limit)
+ public IReadOnlyDictionary> GetPeopleNamesByItems(IReadOnlyList itemIds, IReadOnlyList personTypes)
{
using var context = _dbProvider.CreateDbContext();
var query = context.PeopleBaseItemMap
@@ -178,16 +178,27 @@ public class PeopleRepository(IDbContextFactory dbProvider, I
query = query.Where(m => personTypes.Contains(m.People.PersonType));
}
- var names = query
- .Select(m => m.People.Name)
- .Distinct();
+ var rows = query
+ .OrderBy(m => m.ListOrder)
+ .Select(m => new { m.ItemId, m.People.Name })
+ .ToList();
- if (limit > 0)
+ var result = new Dictionary>();
+ foreach (var group in rows.GroupBy(r => r.ItemId))
{
- names = names.Take(limit);
+ var names = group
+ .Select(r => r.Name)
+ .Where(name => !string.IsNullOrEmpty(name))
+ .Distinct()
+ .ToArray();
+
+ if (names.Length > 0)
+ {
+ result[group.Key] = names;
+ }
}
- return names.ToArray();
+ return result;
}
private PersonInfo Map(People people)
diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs
index c23eba75ef..0b64da291c 100644
--- a/MediaBrowser.Controller/Library/ILibraryManager.cs
+++ b/MediaBrowser.Controller/Library/ILibraryManager.cs
@@ -598,13 +598,12 @@ namespace MediaBrowser.Controller.Library
IReadOnlyList GetPeopleNames(InternalPeopleQuery query);
///
- /// Gets distinct people names for multiple items.
+ /// Gets the distinct people names per item for multiple items.
///
/// The item IDs.
/// The person types to include.
- /// Maximum number of names.
- /// The distinct people names.
- IReadOnlyList GetPeopleNamesByItems(IReadOnlyList itemIds, IReadOnlyList personTypes, int limit);
+ /// A dictionary mapping each item ID to its distinct people names. Items with no matching people are omitted.
+ IReadOnlyDictionary> GetPeopleNamesByItems(IReadOnlyList itemIds, IReadOnlyList personTypes);
///
/// Queries the items.
diff --git a/MediaBrowser.Controller/Persistence/IPeopleRepository.cs b/MediaBrowser.Controller/Persistence/IPeopleRepository.cs
index 7474130ec4..e2833dc722 100644
--- a/MediaBrowser.Controller/Persistence/IPeopleRepository.cs
+++ b/MediaBrowser.Controller/Persistence/IPeopleRepository.cs
@@ -34,11 +34,10 @@ public interface IPeopleRepository
IReadOnlyList GetPeopleNames(InternalPeopleQuery filter);
///
- /// Gets distinct people names for multiple items efficiently by querying from the mapping table.
+ /// Gets the distinct people names per item for multiple items efficiently by querying from the mapping table.
///
/// The item IDs to get people for.
/// The person types to include (e.g. "Actor", "Director").
- /// Maximum number of names to return.
- /// The distinct people names.
- IReadOnlyList GetPeopleNamesByItems(IReadOnlyList itemIds, IReadOnlyList personTypes, int limit);
+ /// A dictionary mapping each item ID to its distinct people names, ordered by cast list order. Items with no matching people are omitted.
+ IReadOnlyDictionary> GetPeopleNamesByItems(IReadOnlyList itemIds, IReadOnlyList personTypes);
}
diff --git a/MediaBrowser.Controller/Session/SessionInfo.cs b/MediaBrowser.Controller/Session/SessionInfo.cs
index 96783f6073..fb68bfb770 100644
--- a/MediaBrowser.Controller/Session/SessionInfo.cs
+++ b/MediaBrowser.Controller/Session/SessionInfo.cs
@@ -45,7 +45,6 @@ namespace MediaBrowser.Controller.Session
PlayState = new PlayerStateInfo();
SessionControllers = [];
NowPlayingQueue = [];
- NowPlayingQueueFullItems = [];
}
///
@@ -271,16 +270,10 @@ namespace MediaBrowser.Controller.Session
/// The now playing queue.
public IReadOnlyList NowPlayingQueue { get; set; }
- ///
- /// Gets or sets the now playing queue full items.
- ///
- /// The now playing queue full items.
- public IReadOnlyList NowPlayingQueueFullItems { get; set; }
-
///
/// Gets or sets a value indicating whether the session has a custom device name.
///
- /// true if this session has a custom device name; otherwise, false.
+ /// true if the session has a custom device name; otherwise, false.
public bool HasCustomDeviceName { get; set; }
///
diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
index e0c5f3ad39..8d237473a3 100644
--- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
+++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
@@ -220,12 +220,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles
Path = outputPath,
Protocol = MediaProtocol.File,
Format = outputFormat,
- IsExternal = false
+ IsExternal = MediaStream.IsVobSubFormat(outputFormat)
};
}
- var currentFormat = subtitleStream.Codec ?? Path.GetExtension(subtitleStream.Path)
- .TrimStart('.');
+ var currentFormat = subtitleStream.Codec ?? Path.GetExtension(subtitleStream.Path).TrimStart('.');
// Handle PGS subtitles as raw streams for the client to render
if (MediaStream.IsPgsFormat(currentFormat))
@@ -475,6 +474,10 @@ namespace MediaBrowser.MediaEncoding.Subtitles
{
return subtitleStream.Codec;
}
+ else if (MediaStream.IsVobSubFormat(subtitleStream.Codec))
+ {
+ return "mks";
+ }
else
{
return "srt";
@@ -488,6 +491,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles
{
return "sup";
}
+ else if (MediaStream.IsVobSubFormat(subtitleStream.Codec))
+ {
+ // FFmpeg cannot mux VobSub subtitle streams back into the .idx/.sub pair, so we use .mks container instead.
+ return "mks";
+ }
else
{
return GetExtractableSubtitleFormat(subtitleStream);
@@ -500,7 +508,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|| string.Equals(codec, "ssa", StringComparison.OrdinalIgnoreCase)
|| string.Equals(codec, "srt", StringComparison.OrdinalIgnoreCase)
|| string.Equals(codec, "subrip", StringComparison.OrdinalIgnoreCase)
- || string.Equals(codec, "pgssub", StringComparison.OrdinalIgnoreCase);
+ || string.Equals(codec, "pgssub", StringComparison.OrdinalIgnoreCase)
+ || MediaStream.IsVobSubFormat(codec);
}
///
@@ -516,7 +525,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles
foreach (var subtitleStream in subtitleStreams)
{
- if (subtitleStream.IsExternal && !subtitleStream.Path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))
+ if (subtitleStream.IsExternal
+ && !subtitleStream.Path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))
{
continue;
}
@@ -603,6 +613,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles
}
var outputCodec = IsCodecCopyable(subtitleStream.Codec) ? "copy" : "srt";
+ // FFmpeg does not provide an .idx/.sub muxer, so VobSub streams must be written as MKS files.
+ var outputFormatOption = MediaStream.IsVobSubFormat(subtitleStream.Codec) ? " -f matroska" : string.Empty;
var streamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream);
if (streamIndex == -1)
@@ -616,9 +628,10 @@ namespace MediaBrowser.MediaEncoding.Subtitles
outputPaths.Add(outputPath);
args += string.Format(
CultureInfo.InvariantCulture,
- " -map 0:{0} -an -vn -c:s {1} -flush_packets 1 \"{2}\"",
+ " -map 0:{0} -an -vn -c:s {1}{2} -flush_packets 1 \"{3}\"",
streamIndex,
outputCodec,
+ outputFormatOption,
outputPath);
}
@@ -653,6 +666,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles
}
var outputCodec = IsCodecCopyable(subtitleStream.Codec) ? "copy" : "srt";
+ // FFmpeg does not provide an .idx/.sub muxer, so VobSub streams must be written as MKS files.
+ var outputFormatOption = MediaStream.IsVobSubFormat(subtitleStream.Codec) ? " -f matroska" : string.Empty;
var streamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream);
if (streamIndex == -1)
@@ -666,18 +681,17 @@ namespace MediaBrowser.MediaEncoding.Subtitles
outputPaths.Add(outputPath);
args += string.Format(
CultureInfo.InvariantCulture,
- " -map 0:{0} -an -vn -c:s {1} -flush_packets 1 \"{2}\"",
+ " -map 0:{0} -an -vn -c:s {1}{2} -flush_packets 1 \"{3}\"",
streamIndex,
outputCodec,
+ outputFormatOption,
outputPath);
}
- if (outputPaths.Count == 0)
+ if (outputPaths.Count > 0)
{
- return;
+ await ExtractSubtitlesForFile(inputPath, args, outputPaths, cancellationToken).ConfigureAwait(false);
}
-
- await ExtractSubtitlesForFile(inputPath, args, outputPaths, cancellationToken).ConfigureAwait(false);
}
private async Task ExtractSubtitlesForFile(
diff --git a/MediaBrowser.Model/Dlna/StreamBuilder.cs b/MediaBrowser.Model/Dlna/StreamBuilder.cs
index 2ccd2a6c28..d875bbe8ed 100644
--- a/MediaBrowser.Model/Dlna/StreamBuilder.cs
+++ b/MediaBrowser.Model/Dlna/StreamBuilder.cs
@@ -575,7 +575,12 @@ namespace MediaBrowser.Model.Dlna
{
foreach (var profile in subtitleProfiles)
{
- if (profile.Method == SubtitleDeliveryMethod.External && string.Equals(profile.Format, stream.Codec, StringComparison.OrdinalIgnoreCase))
+ 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)))))
{
return stream.Index;
}
@@ -1577,10 +1582,17 @@ namespace MediaBrowser.Model.Dlna
continue;
}
- if ((profile.Method == SubtitleDeliveryMethod.External && subtitleStream.IsTextSubtitleStream == MediaStream.IsTextFormat(profile.Format)) ||
+ // 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));
+
+ if ((profile.Method == SubtitleDeliveryMethod.External
+ && (isVobSubMksProfile || subtitleStream.IsTextSubtitleStream == MediaStream.IsTextFormat(profile.Format))) ||
(profile.Method == SubtitleDeliveryMethod.Hls && subtitleStream.IsTextSubtitleStream))
{
- bool requiresConversion = !string.Equals(subtitleStream.Codec, profile.Format, StringComparison.OrdinalIgnoreCase);
+ bool requiresConversion = !isVobSubMksProfile
+ && !string.Equals(subtitleStream.Codec, profile.Format, StringComparison.OrdinalIgnoreCase);
if (!requiresConversion)
{
diff --git a/MediaBrowser.Model/Dto/SessionInfoDto.cs b/MediaBrowser.Model/Dto/SessionInfoDto.cs
index d727cd8741..16b201de9d 100644
--- a/MediaBrowser.Model/Dto/SessionInfoDto.cs
+++ b/MediaBrowser.Model/Dto/SessionInfoDto.cs
@@ -149,13 +149,7 @@ public class SessionInfoDto
public IReadOnlyList? NowPlayingQueue { get; set; }
///
- /// Gets or sets the now playing queue full items.
- ///
- /// The now playing queue full items.
- public IReadOnlyList? NowPlayingQueueFullItems { get; set; }
-
- ///
- /// Gets or sets a value indicating whether the session has a custom device name.
+ /// Gets or sets a value indicating whether this session has a custom device name.
///
/// true if this session has a custom device name; otherwise, false.
public bool HasCustomDeviceName { get; set; }
diff --git a/MediaBrowser.Model/Entities/MediaStream.cs b/MediaBrowser.Model/Entities/MediaStream.cs
index dad4a6e149..f057714bea 100644
--- a/MediaBrowser.Model/Entities/MediaStream.cs
+++ b/MediaBrowser.Model/Entities/MediaStream.cs
@@ -644,13 +644,32 @@ namespace MediaBrowser.Model.Entities
}
}
+ [JsonIgnore]
+ public bool IsVobSubSubtitleStream
+ {
+ get
+ {
+ if (Type != MediaStreamType.Subtitle)
+ {
+ return false;
+ }
+
+ if (string.IsNullOrEmpty(Codec) && !IsExternal)
+ {
+ return false;
+ }
+
+ return IsVobSubFormat(Codec);
+ }
+ }
+
///
/// Gets a value indicating whether this is a subtitle steam that is extractable by ffmpeg.
/// All text-based and pgs subtitles can be extracted.
///
/// true if this is a extractable subtitle steam otherwise, false.
[JsonIgnore]
- public bool IsExtractableSubtitleStream => IsTextSubtitleStream || IsPgsSubtitleStream;
+ public bool IsExtractableSubtitleStream => IsTextSubtitleStream || IsPgsSubtitleStream || IsVobSubSubtitleStream;
///
/// Gets or sets a value indicating whether [supports external stream].
@@ -728,6 +747,7 @@ namespace MediaBrowser.Model.Entities
return codec.Contains("microdvd", StringComparison.OrdinalIgnoreCase)
|| (!codec.Contains("pgs", StringComparison.OrdinalIgnoreCase)
&& !codec.Contains("dvdsub", StringComparison.OrdinalIgnoreCase)
+ && !codec.Contains("vobsub", StringComparison.OrdinalIgnoreCase)
&& !codec.Contains("dvbsub", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(codec, "sup", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(codec, "sub", StringComparison.OrdinalIgnoreCase));
@@ -741,6 +761,14 @@ namespace MediaBrowser.Model.Entities
|| string.Equals(codec, "sup", StringComparison.OrdinalIgnoreCase);
}
+ public static bool IsVobSubFormat(string format)
+ {
+ string codec = format ?? string.Empty;
+
+ return codec.Contains("dvdsub", StringComparison.OrdinalIgnoreCase)
+ || codec.Contains("vobsub", StringComparison.OrdinalIgnoreCase);
+ }
+
public bool SupportsSubtitleConversionTo(string toCodec)
{
if (!IsTextSubtitleStream)
diff --git a/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs b/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs
index ed32e6c76a..78907a5e68 100644
--- a/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs
+++ b/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs
@@ -198,15 +198,23 @@ namespace MediaBrowser.XbmcMetadata.Savers
cancellationToken.ThrowIfCancellationRequested();
- await SaveToFileAsync(memoryStream, path).ConfigureAwait(false);
+ await SaveToFileAsync(memoryStream, path, cancellationToken).ConfigureAwait(false);
}
}
- private async Task SaveToFileAsync(Stream stream, string path)
+ private async Task SaveToFileAsync(Stream stream, string path, CancellationToken cancellationToken)
{
var directory = Path.GetDirectoryName(path) ?? throw new ArgumentException($"Provided path ({path}) is not valid.", nameof(path));
Directory.CreateDirectory(directory);
+ // Compare byte-for-byte before proceeding.
+ if (File.Exists(path) && await stream.IsFileIdenticalAsync(path, cancellationToken).ConfigureAwait(false))
+ {
+ return; // Don't save since .nfo is unchanged.
+ }
+
+ stream.Position = 0;
+
// On Windows, saving the file will fail if the file is hidden or readonly
FileSystem.SetAttributes(path, false, false);
@@ -222,7 +230,7 @@ namespace MediaBrowser.XbmcMetadata.Savers
var filestream = new FileStream(path, fileStreamOptions);
await using (filestream.ConfigureAwait(false))
{
- await stream.CopyToAsync(filestream).ConfigureAwait(false);
+ await stream.CopyToAsync(filestream, cancellationToken).ConfigureAwait(false);
}
if (ConfigurationManager.Configuration.SaveMetadataHidden)
diff --git a/src/Jellyfin.Extensions/StreamExtensions.cs b/src/Jellyfin.Extensions/StreamExtensions.cs
index 0cfac384e3..36361c58e8 100644
--- a/src/Jellyfin.Extensions/StreamExtensions.cs
+++ b/src/Jellyfin.Extensions/StreamExtensions.cs
@@ -1,17 +1,22 @@
+using System;
+using System.Buffers;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading;
+using System.Threading.Tasks;
namespace Jellyfin.Extensions
{
///
- /// Class BaseExtensions.
+ /// Extension methods for the class.
///
public static class StreamExtensions
{
+ private const int StreamComparisonBufferSize = 81920;
+
///
/// Reads all lines in the .
///
@@ -60,5 +65,172 @@ namespace Jellyfin.Extensions
yield return line;
}
}
+
+ ///
+ /// Determines whether a stream is identical to a file on disk.
+ ///
+ /// The stream to compare.
+ /// The file path to compare against.
+ /// The token to monitor for cancellation requests.
+ /// True if the stream and file are identical; otherwise false.
+ /// does not support seeking.
+ ///
+ /// The entire stream is compared against the file from the beginning (the position is reset to 0 on entry)
+ /// and restored to its original value after the call.
+ ///
+ public static async Task IsFileIdenticalAsync(this Stream stream, string path, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(stream);
+ ArgumentException.ThrowIfNullOrEmpty(path);
+
+ if (!stream.CanSeek)
+ {
+ throw new ArgumentException("Stream must support seeking.", nameof(stream));
+ }
+
+ var originalPosition = stream.Position;
+ try
+ {
+ stream.Position = 0;
+
+ var existingFileStream = new FileStream(
+ path,
+ FileMode.Open,
+ FileAccess.Read,
+ FileShare.Read,
+ bufferSize: StreamComparisonBufferSize,
+ FileOptions.Asynchronous | FileOptions.SequentialScan);
+ await using (existingFileStream.ConfigureAwait(false))
+ {
+ return await stream.IsStreamIdenticalAsync(existingFileStream, cancellationToken).ConfigureAwait(false);
+ }
+ }
+ finally
+ {
+ stream.Position = originalPosition;
+ }
+ }
+
+ ///
+ /// Determines whether two streams are identical.
+ ///
+ /// The first stream to compare.
+ /// The second stream to compare.
+ /// The token to monitor for cancellation requests.
+ /// True if the streams are identical; otherwise false.
+ ///
+ /// Seekable streams are compared from the beginning (their position is reset to 0 on entry).
+ /// Non-seekable streams are compared from their current read position. Stream positions are not
+ /// restored after the call.
+ ///
+ public static async Task IsStreamIdenticalAsync(this Stream a, Stream b, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(a);
+ ArgumentNullException.ThrowIfNull(b);
+
+ if (ReferenceEquals(a, b))
+ {
+ return true;
+ }
+
+ if (a.CanSeek is var aCanSeek && aCanSeek)
+ {
+ a.Position = 0;
+ }
+
+ if (b.CanSeek is var bCanSeek && bCanSeek)
+ {
+ b.Position = 0;
+ }
+
+ if (aCanSeek && bCanSeek && b.Length != a.Length)
+ {
+ return false;
+ }
+
+ // MemoryStreams only unlock a fast path if their underlying buffer is exposed via TryGetBuffer.
+ var segmentA = a is MemoryStream streamA && streamA.TryGetBuffer(out var bufA) ? bufA : default;
+ var segmentB = b is MemoryStream streamB && streamB.TryGetBuffer(out var bufB) ? bufB : default;
+
+ // Fast path A: both streams expose buffers, compare segments directly
+ if (segmentA.Array is not null && segmentB.Array is not null)
+ {
+ return segmentA.AsSpan().SequenceEqual(segmentB.AsSpan());
+ }
+
+ if (segmentB.Array is not null) // && segmentA.Array is null guaranteed by previous check
+ {
+ // swap so that segmentA is the non-null one, compared to b we need only one fast path B
+ (segmentA, b) = (segmentB, a);
+ }
+
+ if (segmentA.Array is not null) // either a was non-null, or b was non-null and was swapped there
+ {
+ // Fast path B: only one stream exposed a buffer, compare against the other chunk-by-chunk
+ var bufferB = ArrayPool.Shared.Rent(StreamComparisonBufferSize);
+ try
+ {
+ var memoryB = bufferB.AsMemory();
+ int offset = 0;
+ int bytesRead;
+ while ((bytesRead = await b.ReadAtLeastAsync(memoryB, memoryB.Length, throwOnEndOfStream: false, cancellationToken).ConfigureAwait(false)) > 0)
+ {
+ if (offset + bytesRead > segmentA.Count || !segmentA.AsSpan(offset, bytesRead).SequenceEqual(memoryB.Span[..bytesRead]))
+ {
+ return false;
+ }
+
+ offset += bytesRead;
+ }
+
+ return offset == segmentA.Count;
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(bufferB);
+ }
+ }
+ else
+ {
+ var bufferA = ArrayPool.Shared.Rent(StreamComparisonBufferSize);
+ var bufferB = ArrayPool.Shared.Rent(StreamComparisonBufferSize);
+ try
+ {
+ var memoryA = bufferA.AsMemory();
+ var memoryB = bufferB.AsMemory();
+ while (true)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var taskA = a.ReadAtLeastAsync(memoryA, memoryA.Length, throwOnEndOfStream: false, cancellationToken).AsTask();
+ var taskB = b.ReadAtLeastAsync(memoryB, memoryB.Length, throwOnEndOfStream: false, cancellationToken).AsTask();
+ await Task.WhenAll(taskA, taskB).ConfigureAwait(false);
+
+ var bytesReadA = await taskA.ConfigureAwait(false);
+ var bytesReadB = await taskB.ConfigureAwait(false);
+
+ if (bytesReadA != bytesReadB)
+ {
+ return false;
+ }
+
+ if (bytesReadA == 0)
+ {
+ return true;
+ }
+
+ if (!memoryA.Span[..bytesReadA].SequenceEqual(memoryB.Span[..bytesReadB]))
+ {
+ return false;
+ }
+ }
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(bufferA);
+ ArrayPool.Shared.Return(bufferB);
+ }
+ }
+ }
}
}
diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs
index 3aa0f0408b..c1ccb24bf4 100644
--- a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs
+++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs
@@ -684,27 +684,37 @@ namespace Jellyfin.LiveTv.Listings
sdCode?.ToString() ?? "N/A",
responseBody);
- if (sdCode is SdErrorCode.InvalidUser or SdErrorCode.InvalidHash or SdErrorCode.AccountLocked or SdErrorCode.AccountExpired or SdErrorCode.PasswordRequired)
+ if (sdCode is SdErrorCode.AccountExpired or SdErrorCode.InvalidHash or SdErrorCode.InvalidUser or SdErrorCode.AccountLocked or SdErrorCode.AppLocked or SdErrorCode.AccountInactive)
{
// Permanent account errors — disable SD for this server lifetime.
- _logger.LogError("Schedules Direct account error (code {SdCode}). Disabling SD until server restart", sdCode);
+ _logger.LogError("Schedules Direct account error (code {SdCode}). Disabling SD until server restart.", sdCode);
_tokens.Clear();
_accountError = true;
}
- else if (sdCode is SdErrorCode.MaxLoginAttempts or SdErrorCode.TemporaryLockout)
+ else if (sdCode is SdErrorCode.ServiceOffline or SdErrorCode.ServiceBusy or SdErrorCode.AccountTempLock)
{
// Transient login errors — back off for 30 minutes, then allow retry.
+ _logger.LogError("Schedules Direct transient error (code {SdCode}). Backing off for 30 minutes.", sdCode);
_tokens.Clear();
Interlocked.Exchange(ref _lastErrorResponseTicks, DateTime.UtcNow.Ticks);
}
- else if (sdCode is SdErrorCode.MaxImageDownloads)
+ else if (sdCode is SdErrorCode.MaxLoginAttempts or SdErrorCode.MaxIPAttempts)
+ {
+ // 24 hour bans - stop image and metadata requests until SD reset at 00:00 UTC.
+ _logger.LogError("Schedules Direct service limit error (code {SdCode}). Disabling until SD reset.", sdCode);
+ SetImageLimitHit();
+ SetMetadataLimitHit();
+ }
+ else if (sdCode is SdErrorCode.MaxImageDownloads or SdErrorCode.MaxImageDownloadsTrial)
{
// Max image downloads — stop image requests until SD resets at 00:00 UTC.
+ _logger.LogError("Schedules Direct image download limit hit (code {SdCode}). Disabling image acquisition until SD reset.", sdCode);
SetImageLimitHit();
}
else if (sdCode is SdErrorCode.MaxScheduleRequests)
{
// Max schedule/metadata requests — stop metadata requests until SD resets at 00:00 UTC.
+ _logger.LogError("Schedules Direct metadata download limit hit (code {SdCode}). Disabling metadata acquisition until SD reset.", sdCode);
SetMetadataLimitHit();
}
else if (enableRetry
diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/SdErrorCode.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/SdErrorCode.cs
index ec6c6c475b..fffbfb9a58 100644
--- a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/SdErrorCode.cs
+++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/SdErrorCode.cs
@@ -3,39 +3,59 @@
namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos;
///
-/// Schedules Direct API error codes.
+/// Schedules Direct API error codes. See https://github.com/SchedulesDirect/JSON-Service/wiki/API-20141201#error-response for details.
///
public enum SdErrorCode
{
///
- /// Invalid user.
+ /// Schedules Direct unavailable/out of service.
///
- InvalidUser = 4001,
+ ServiceOffline = 3000,
///
- /// Invalid password hash.
+ /// Schedules Direct busy.
///
- InvalidHash = 4003,
-
- ///
- /// Account locked or disabled.
- ///
- AccountLocked = 4004,
+ ServiceBusy = 3001,
///
/// Account expired.
///
- AccountExpired = 4005,
+ AccountExpired = 4001,
///
- /// Token has expired.
+ /// Invalid password hash.
+ ///
+ InvalidHash = 4002,
+
+ ///
+ /// Invalid user or password.
+ ///
+ InvalidUser = 4003,
+
+ ///
+ /// Account temporarily locked due to login failures.
+ ///
+ AccountTempLock = 4004,
+
+ ///
+ /// Account permanently locked due to abuse.
+ ///
+ AccountLocked = 4005,
+
+ ///
+ /// Token has expired. Request a new one.
///
TokenExpired = 4006,
///
- /// Password is required.
+ /// Application locked out.
///
- PasswordRequired = 4008,
+ AppLocked = 4007,
+
+ ///
+ /// Account not active.
+ ///
+ AccountInactive = 4008,
///
/// Maximum login attempts exceeded.
@@ -43,17 +63,32 @@ public enum SdErrorCode
MaxLoginAttempts = 4009,
///
- /// Temporary lockout.
+ /// Maximum unique IP attempts reached.
///
- TemporaryLockout = 4010,
+ MaxIPAttempts = 4010,
+
+ ///
+ /// Lineup change maximum reached.
+ ///
+ MaxScheduleRequests = 4100,
+
+ ///
+ /// Requested image not found.
+ ///
+ ImageNotFound = 5000,
///
/// Maximum image downloads reached for the day.
///
MaxImageDownloads = 5002,
+ ///
+ /// Trial specific maximum image downloads reached for the day.
+ ///
+ MaxImageDownloadsTrial = 5003,
+
///
/// Maximum schedule/metadata requests reached for the day.
///
- MaxScheduleRequests = 5003
+ MaxInvalidImages = 5004
}
diff --git a/tests/Jellyfin.Extensions.Tests/StreamExtensionsTests.cs b/tests/Jellyfin.Extensions.Tests/StreamExtensionsTests.cs
new file mode 100644
index 0000000000..cdbf2f8b1d
--- /dev/null
+++ b/tests/Jellyfin.Extensions.Tests/StreamExtensionsTests.cs
@@ -0,0 +1,397 @@
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace Jellyfin.Extensions.Tests;
+
+public class StreamExtensionsTests
+{
+ [Fact]
+ public async Task IsStreamIdenticalAsync_SeekableDifferentLengths_ReturnsFalse()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ await using var a = new MemoryStream(new byte[] { 1, 2, 3 });
+ await using var b = new MemoryStream(new byte[] { 1, 2, 3, 4 });
+
+ var result = await a.IsStreamIdenticalAsync(b, cancellationToken);
+
+ Assert.False(result);
+ }
+
+ [Fact]
+ public async Task IsStreamIdenticalAsync_NonSeekableIdenticalStreams_ReturnsTrue()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ await using var a = new NonSeekableReadStream(new byte[] { 1, 2, 3, 4 });
+ await using var b = new NonSeekableReadStream(new byte[] { 1, 2, 3, 4 });
+
+ var result = await a.IsStreamIdenticalAsync(b, cancellationToken);
+
+ Assert.True(result);
+ }
+
+ [Fact]
+ public async Task IsStreamIdenticalAsync_NonSeekableDifferentStreams_ReturnsFalse()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ await using var a = new NonSeekableReadStream(new byte[] { 1, 2, 3, 4 });
+ await using var b = new NonSeekableReadStream(new byte[] { 1, 2, 9, 4 });
+
+ var result = await a.IsStreamIdenticalAsync(b, cancellationToken);
+
+ Assert.False(result);
+ }
+
+ [Fact]
+ public async Task IsFileIdenticalAsync_NonSeekableStream_ThrowsArgumentException()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var path = Path.Join(Path.GetTempPath(), Path.GetRandomFileName());
+ await File.WriteAllBytesAsync(path, new byte[] { 1, 2, 3, 4 }, cancellationToken);
+
+ try
+ {
+ await using var stream = new NonSeekableReadStream(new byte[] { 1, 2, 3, 4 });
+
+ await Assert.ThrowsAsync(async () =>
+ await stream.IsFileIdenticalAsync(path, cancellationToken));
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ // Both publiclyVisible values are exercised so the test runs once under the fast path
+ // (TryGetBuffer succeeds) and once under the slow path (TryGetBuffer returns false).
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task IsFileIdenticalAsync_UsesStartOfStreamAndRestoresPosition_OnMatch(bool publiclyVisible)
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var path = Path.Join(Path.GetTempPath(), Path.GetRandomFileName());
+ var bytes = new byte[] { 10, 20, 30, 40, 50 };
+ await File.WriteAllBytesAsync(path, bytes, cancellationToken);
+
+ try
+ {
+ await using var stream = CreateMemoryStream(bytes, publiclyVisible);
+ stream.Position = 3;
+
+ var result = await stream.IsFileIdenticalAsync(path, cancellationToken);
+
+ Assert.True(result);
+ Assert.Equal(3, stream.Position);
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task IsFileIdenticalAsync_RestoresPosition_OnMismatch(bool publiclyVisible)
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var path = Path.Join(Path.GetTempPath(), Path.GetRandomFileName());
+ await File.WriteAllBytesAsync(path, new byte[] { 10, 20, 30, 40, 99 }, cancellationToken);
+
+ try
+ {
+ await using var stream = CreateMemoryStream(new byte[] { 10, 20, 30, 40, 50 }, publiclyVisible);
+ stream.Position = 2;
+
+ var result = await stream.IsFileIdenticalAsync(path, cancellationToken);
+
+ Assert.False(result);
+ Assert.Equal(2, stream.Position);
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task IsStreamIdenticalAsync_BothMemoryStreams_NonZeroPositions_SeeksToStart(bool publiclyVisible)
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ await using var a = CreateMemoryStream(new byte[] { 1, 2, 3, 4, 5 }, publiclyVisible);
+ await using var b = CreateMemoryStream(new byte[] { 1, 2, 3, 4, 5 }, publiclyVisible);
+ a.Position = 3;
+ b.Position = 1;
+
+ var result = await a.IsStreamIdenticalAsync(b, cancellationToken);
+
+ Assert.True(result);
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task IsStreamIdenticalAsync_MemoryStreamPairedWithSeekableNonMemoryStream_NonZeroPositions_SeeksToStart(bool publiclyVisible)
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ await using var a = CreateMemoryStream(new byte[] { 1, 2, 3, 4 }, publiclyVisible);
+ await using var b = new SeekableNonMemoryStream(new byte[] { 1, 2, 3, 4 });
+ a.Position = 2;
+ b.Position = 3;
+
+ var result = await a.IsStreamIdenticalAsync(b, cancellationToken);
+
+ Assert.True(result);
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task IsStreamIdenticalAsync_NonMemoryStreamPairedWithMemoryStream_Swaps_ReturnsTrue(bool publiclyVisible)
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ await using var a = new SeekableNonMemoryStream(new byte[] { 1, 2, 3, 4 });
+ await using var b = CreateMemoryStream(new byte[] { 1, 2, 3, 4 }, publiclyVisible);
+
+ var result = await a.IsStreamIdenticalAsync(b, cancellationToken);
+
+ Assert.True(result);
+ }
+
+ [Fact]
+ public async Task IsStreamIdenticalAsync_BothSeekableNonMemoryStreams_NonZeroPositions_SeeksToStart()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ await using var a = new SeekableNonMemoryStream(new byte[] { 1, 2, 3, 4 });
+ await using var b = new SeekableNonMemoryStream(new byte[] { 1, 2, 3, 4 });
+ a.Position = 1;
+ b.Position = 2;
+
+ var result = await a.IsStreamIdenticalAsync(b, cancellationToken);
+
+ Assert.True(result);
+ }
+
+ [Fact]
+ public async Task IsStreamIdenticalAsync_NonSeekableShortReads_Identical_ReturnsTrue()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var data = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
+ await using var a = new ShortReadingNonSeekableStream(data, maxReadSize: 3);
+ await using var b = new ShortReadingNonSeekableStream(data, maxReadSize: 5);
+
+ var result = await a.IsStreamIdenticalAsync(b, cancellationToken);
+
+ Assert.True(result);
+ }
+
+ [Fact]
+ public async Task IsStreamIdenticalAsync_NonSeekableShortReads_DifferentLengths_ReturnsFalse()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ await using var a = new ShortReadingNonSeekableStream(new byte[] { 1, 2, 3, 4 }, maxReadSize: 3);
+ await using var b = new ShortReadingNonSeekableStream(new byte[] { 1, 2, 3, 4, 5 }, maxReadSize: 5);
+
+ var result = await a.IsStreamIdenticalAsync(b, cancellationToken);
+
+ Assert.False(result);
+ }
+
+ private static MemoryStream CreateMemoryStream(byte[] data, bool publiclyVisible)
+ => publiclyVisible
+ ? new MemoryStream(data, 0, data.Length, writable: false, publiclyVisible: true)
+ : new MemoryStream(data);
+
+ private sealed class NonSeekableReadStream : Stream
+ {
+ private readonly Stream _inner;
+
+ public NonSeekableReadStream(byte[] data)
+ {
+ _inner = new MemoryStream(data, writable: false);
+ }
+
+ public override bool CanRead => true;
+
+ public override bool CanSeek => false;
+
+ public override bool CanWrite => false;
+
+ public override long Length => throw new NotSupportedException();
+
+ public override long Position
+ {
+ get => throw new NotSupportedException();
+ set => throw new NotSupportedException();
+ }
+
+ public override void Flush()
+ {
+ }
+
+ public override int Read(byte[] buffer, int offset, int count)
+ => _inner.Read(buffer, offset, count);
+
+ public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default)
+ => _inner.ReadAsync(buffer, cancellationToken);
+
+ public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
+ => _inner.ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask();
+
+ public override long Seek(long offset, SeekOrigin origin)
+ => throw new NotSupportedException();
+
+ public override void SetLength(long value)
+ => throw new NotSupportedException();
+
+ public override void Write(byte[] buffer, int offset, int count)
+ => throw new NotSupportedException();
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ _inner.Dispose();
+ }
+
+ base.Dispose(disposing);
+ }
+
+ public override async ValueTask DisposeAsync()
+ {
+ await _inner.DisposeAsync();
+ await base.DisposeAsync();
+ }
+ }
+
+ private sealed class SeekableNonMemoryStream : Stream
+ {
+ private readonly MemoryStream _inner;
+
+ public SeekableNonMemoryStream(byte[] data)
+ {
+ _inner = new MemoryStream(data, writable: false);
+ }
+
+ public override bool CanRead => true;
+
+ public override bool CanSeek => true;
+
+ public override bool CanWrite => false;
+
+ public override long Length => _inner.Length;
+
+ public override long Position
+ {
+ get => _inner.Position;
+ set => _inner.Position = value;
+ }
+
+ public override void Flush()
+ {
+ }
+
+ public override int Read(byte[] buffer, int offset, int count)
+ => _inner.Read(buffer, offset, count);
+
+ public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default)
+ => _inner.ReadAsync(buffer, cancellationToken);
+
+ public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
+ => _inner.ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask();
+
+ public override long Seek(long offset, SeekOrigin origin)
+ => _inner.Seek(offset, origin);
+
+ public override void SetLength(long value)
+ => throw new NotSupportedException();
+
+ public override void Write(byte[] buffer, int offset, int count)
+ => throw new NotSupportedException();
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ _inner.Dispose();
+ }
+
+ base.Dispose(disposing);
+ }
+
+ public override async ValueTask DisposeAsync()
+ {
+ await _inner.DisposeAsync();
+ await base.DisposeAsync();
+ }
+ }
+
+ private sealed class ShortReadingNonSeekableStream : Stream
+ {
+ private readonly Stream _inner;
+ private readonly int _maxReadSize;
+
+ public ShortReadingNonSeekableStream(byte[] data, int maxReadSize)
+ {
+ _inner = new MemoryStream(data, writable: false);
+ _maxReadSize = maxReadSize;
+ }
+
+ public override bool CanRead => true;
+
+ public override bool CanSeek => false;
+
+ public override bool CanWrite => false;
+
+ public override long Length => throw new NotSupportedException();
+
+ public override long Position
+ {
+ get => throw new NotSupportedException();
+ set => throw new NotSupportedException();
+ }
+
+ public override void Flush()
+ {
+ }
+
+ public override int Read(byte[] buffer, int offset, int count)
+ => _inner.Read(buffer, offset, Math.Min(count, _maxReadSize));
+
+ public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default)
+ => _inner.ReadAsync(buffer[..Math.Min(buffer.Length, _maxReadSize)], cancellationToken);
+
+ public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
+ => _inner.ReadAsync(buffer.AsMemory(offset, Math.Min(count, _maxReadSize)), cancellationToken).AsTask();
+
+ public override long Seek(long offset, SeekOrigin origin)
+ => throw new NotSupportedException();
+
+ public override void SetLength(long value)
+ => throw new NotSupportedException();
+
+ public override void Write(byte[] buffer, int offset, int count)
+ => throw new NotSupportedException();
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ _inner.Dispose();
+ }
+
+ base.Dispose(disposing);
+ }
+
+ public override async ValueTask DisposeAsync()
+ {
+ await _inner.DisposeAsync();
+ await base.DisposeAsync();
+ }
+ }
+}