Merge remote-tracking branch 'upstream/master' into search-rebased

This commit is contained in:
Shadowghost
2026-05-31 18:24:26 +02:00
20 changed files with 821 additions and 158 deletions

View File

@@ -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)

View File

@@ -74,8 +74,8 @@
<PackageVersion Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" />
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
<PackageVersion Include="Svg.Skia" Version="3.7.0" />
<PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="10.1.7" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.1.7" />
<PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="10.2.0" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.0" />
<PackageVersion Include="System.Text.Json" Version="10.0.8" />
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
<PackageVersion Include="z440.atl.core" Version="7.14.0" />

View File

@@ -3395,9 +3395,9 @@ namespace Emby.Server.Implementations.Library
}
/// <inheritdoc/>
public IReadOnlyList<string> GetPeopleNamesByItems(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes, int limit)
public IReadOnlyDictionary<Guid, IReadOnlyList<string>> GetPeopleNamesByItems(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes)
{
return _peopleRepository.GetPeopleNamesByItems(itemIds, personTypes, limit);
return _peopleRepository.GetPeopleNamesByItems(itemIds, personTypes);
}
public void UpdatePeople(BaseItem item, List<PersonInfo> people)

View File

@@ -127,6 +127,11 @@ namespace Emby.Server.Implementations.Library
return true;
}
if (stream.IsVobSubSubtitleStream)
{
return true;
}
return false;
}

View File

@@ -125,6 +125,7 @@ public class SimilarItemsManager : ISimilarItemsManager
var allResults = new List<(BaseItem Item, float Score)>();
var excludeIds = new HashSet<Guid> { item.Id };
var excludeKeys = new HashSet<string>(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<string> GetPeopleNames(IReadOnlyList<BaseItem> items, IReadOnlyList<string> 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<Guid> excludeIds)
HashSet<Guid> excludeIds,
HashSet<string> excludeKeys)
{
if (references.Count == 0)
{
return [];
}
var resolvedById = new Dictionary<Guid, (BaseItem Item, float Score)>();
var resolvedByKey = new Dictionary<string, (BaseItem Item, float Score)>(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)

View File

@@ -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));
}
}
/// <summary>
@@ -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,

View File

@@ -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<Guid> BuildOrderedMasterQuery(IQueryable<BaseItemEntity> 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<string, ItemCounts> BuildItemCountsByCleanName(
Database.Implementations.JellyfinDbContext context,
InternalItemsQuery filter,

View File

@@ -166,7 +166,7 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
}
/// <inheritdoc/>
public IReadOnlyList<string> GetPeopleNamesByItems(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes, int limit)
public IReadOnlyDictionary<Guid, IReadOnlyList<string>> GetPeopleNamesByItems(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes)
{
using var context = _dbProvider.CreateDbContext();
var query = context.PeopleBaseItemMap
@@ -178,16 +178,27 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> 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<Guid, IReadOnlyList<string>>();
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)

View File

@@ -598,13 +598,12 @@ namespace MediaBrowser.Controller.Library
IReadOnlyList<string> GetPeopleNames(InternalPeopleQuery query);
/// <summary>
/// Gets distinct people names for multiple items.
/// Gets the distinct people names per item for multiple items.
/// </summary>
/// <param name="itemIds">The item IDs.</param>
/// <param name="personTypes">The person types to include.</param>
/// <param name="limit">Maximum number of names.</param>
/// <returns>The distinct people names.</returns>
IReadOnlyList<string> GetPeopleNamesByItems(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes, int limit);
/// <returns>A dictionary mapping each item ID to its distinct people names. Items with no matching people are omitted.</returns>
IReadOnlyDictionary<Guid, IReadOnlyList<string>> GetPeopleNamesByItems(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes);
/// <summary>
/// Queries the items.

View File

@@ -34,11 +34,10 @@ public interface IPeopleRepository
IReadOnlyList<string> GetPeopleNames(InternalPeopleQuery filter);
/// <summary>
/// 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.
/// </summary>
/// <param name="itemIds">The item IDs to get people for.</param>
/// <param name="personTypes">The person types to include (e.g. "Actor", "Director").</param>
/// <param name="limit">Maximum number of names to return.</param>
/// <returns>The distinct people names.</returns>
IReadOnlyList<string> GetPeopleNamesByItems(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes, int limit);
/// <returns>A dictionary mapping each item ID to its distinct people names, ordered by cast list order. Items with no matching people are omitted.</returns>
IReadOnlyDictionary<Guid, IReadOnlyList<string>> GetPeopleNamesByItems(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes);
}

View File

@@ -45,7 +45,6 @@ namespace MediaBrowser.Controller.Session
PlayState = new PlayerStateInfo();
SessionControllers = [];
NowPlayingQueue = [];
NowPlayingQueueFullItems = [];
}
/// <summary>
@@ -271,16 +270,10 @@ namespace MediaBrowser.Controller.Session
/// <value>The now playing queue.</value>
public IReadOnlyList<QueueItem> NowPlayingQueue { get; set; }
/// <summary>
/// Gets or sets the now playing queue full items.
/// </summary>
/// <value>The now playing queue full items.</value>
public IReadOnlyList<BaseItemDto> NowPlayingQueueFullItems { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the session has a custom device name.
/// </summary>
/// <value><c>true</c> if this session has a custom device name; otherwise, <c>false</c>.</value>
/// <value><c>true</c> if the session has a custom device name; otherwise, <c>false</c>.</value>
public bool HasCustomDeviceName { get; set; }
/// <summary>

View File

@@ -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);
}
/// <inheritdoc />
@@ -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(

View File

@@ -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)
{

View File

@@ -149,13 +149,7 @@ public class SessionInfoDto
public IReadOnlyList<QueueItem>? NowPlayingQueue { get; set; }
/// <summary>
/// Gets or sets the now playing queue full items.
/// </summary>
/// <value>The now playing queue full items.</value>
public IReadOnlyList<BaseItemDto>? NowPlayingQueueFullItems { get; set; }
/// <summary>
/// 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.
/// </summary>
/// <value><c>true</c> if this session has a custom device name; otherwise, <c>false</c>.</value>
public bool HasCustomDeviceName { get; set; }

View File

@@ -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);
}
}
/// <summary>
/// Gets a value indicating whether this is a subtitle steam that is extractable by ffmpeg.
/// All text-based and pgs subtitles can be extracted.
/// </summary>
/// <value><c>true</c> if this is a extractable subtitle steam otherwise, <c>false</c>.</value>
[JsonIgnore]
public bool IsExtractableSubtitleStream => IsTextSubtitleStream || IsPgsSubtitleStream;
public bool IsExtractableSubtitleStream => IsTextSubtitleStream || IsPgsSubtitleStream || IsVobSubSubtitleStream;
/// <summary>
/// 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)

View File

@@ -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)

View File

@@ -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
{
/// <summary>
/// Class BaseExtensions.
/// Extension methods for the <see cref="Stream"/> class.
/// </summary>
public static class StreamExtensions
{
private const int StreamComparisonBufferSize = 81920;
/// <summary>
/// Reads all lines in the <see cref="Stream" />.
/// </summary>
@@ -60,5 +65,172 @@ namespace Jellyfin.Extensions
yield return line;
}
}
/// <summary>
/// Determines whether a stream is identical to a file on disk.
/// </summary>
/// <param name="stream">The stream to compare.</param>
/// <param name="path">The file path to compare against.</param>
/// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
/// <returns>True if the stream and file are identical; otherwise false.</returns>
/// <exception cref="ArgumentException"><paramref name="stream"/> does not support seeking.</exception>
/// <remarks>
/// 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.
/// </remarks>
public static async Task<bool> 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;
}
}
/// <summary>
/// Determines whether two streams are identical.
/// </summary>
/// <param name="a">The first stream to compare.</param>
/// <param name="b">The second stream to compare.</param>
/// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
/// <returns>True if the streams are identical; otherwise false.</returns>
/// <remarks>
/// 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.
/// </remarks>
public static async Task<bool> 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<byte>.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<byte>.Shared.Return(bufferB);
}
}
else
{
var bufferA = ArrayPool<byte>.Shared.Rent(StreamComparisonBufferSize);
var bufferB = ArrayPool<byte>.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<byte>.Shared.Return(bufferA);
ArrayPool<byte>.Shared.Return(bufferB);
}
}
}
}
}

View File

@@ -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

View File

@@ -3,39 +3,59 @@
namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos;
/// <summary>
/// Schedules Direct API error codes.
/// Schedules Direct API error codes. See https://github.com/SchedulesDirect/JSON-Service/wiki/API-20141201#error-response for details.
/// </summary>
public enum SdErrorCode
{
/// <summary>
/// Invalid user.
/// Schedules Direct unavailable/out of service.
/// </summary>
InvalidUser = 4001,
ServiceOffline = 3000,
/// <summary>
/// Invalid password hash.
/// Schedules Direct busy.
/// </summary>
InvalidHash = 4003,
/// <summary>
/// Account locked or disabled.
/// </summary>
AccountLocked = 4004,
ServiceBusy = 3001,
/// <summary>
/// Account expired.
/// </summary>
AccountExpired = 4005,
AccountExpired = 4001,
/// <summary>
/// Token has expired.
/// Invalid password hash.
/// </summary>
InvalidHash = 4002,
/// <summary>
/// Invalid user or password.
/// </summary>
InvalidUser = 4003,
/// <summary>
/// Account temporarily locked due to login failures.
/// </summary>
AccountTempLock = 4004,
/// <summary>
/// Account permanently locked due to abuse.
/// </summary>
AccountLocked = 4005,
/// <summary>
/// Token has expired. Request a new one.
/// </summary>
TokenExpired = 4006,
/// <summary>
/// Password is required.
/// Application locked out.
/// </summary>
PasswordRequired = 4008,
AppLocked = 4007,
/// <summary>
/// Account not active.
/// </summary>
AccountInactive = 4008,
/// <summary>
/// Maximum login attempts exceeded.
@@ -43,17 +63,32 @@ public enum SdErrorCode
MaxLoginAttempts = 4009,
/// <summary>
/// Temporary lockout.
/// Maximum unique IP attempts reached.
/// </summary>
TemporaryLockout = 4010,
MaxIPAttempts = 4010,
/// <summary>
/// Lineup change maximum reached.
/// </summary>
MaxScheduleRequests = 4100,
/// <summary>
/// Requested image not found.
/// </summary>
ImageNotFound = 5000,
/// <summary>
/// Maximum image downloads reached for the day.
/// </summary>
MaxImageDownloads = 5002,
/// <summary>
/// Trial specific maximum image downloads reached for the day.
/// </summary>
MaxImageDownloadsTrial = 5003,
/// <summary>
/// Maximum schedule/metadata requests reached for the day.
/// </summary>
MaxScheduleRequests = 5003
MaxInvalidImages = 5004
}

View File

@@ -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<ArgumentException>(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<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
=> _inner.ReadAsync(buffer, cancellationToken);
public override Task<int> 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<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
=> _inner.ReadAsync(buffer, cancellationToken);
public override Task<int> 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<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
=> _inner.ReadAsync(buffer[..Math.Min(buffer.Length, _maxReadSize)], cancellationToken);
public override Task<int> 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();
}
}
}