mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-06-08 00:39:25 +01:00
Merge remote-tracking branch 'upstream/master' into search-rebased
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -127,6 +127,11 @@ namespace Emby.Server.Implementations.Library
|
||||
return true;
|
||||
}
|
||||
|
||||
if (stream.IsVobSubSubtitleStream)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
397
tests/Jellyfin.Extensions.Tests/StreamExtensionsTests.cs
Normal file
397
tests/Jellyfin.Extensions.Tests/StreamExtensionsTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user