Merge branch 'master' into fix-code-migration

This commit is contained in:
Cody Robibero
2026-09-06 07:34:30 -04:00
committed by GitHub
33 changed files with 1439 additions and 119 deletions
+1
View File
@@ -240,6 +240,7 @@
- [Florin-Popescu](https://github.com/Florin-Popescu)
- [m0g3r](https://github.com/m0g3r)
- [martin-77](https://github.com/martin-77)
- [Oggeb1](https://github.com/Oggeb1)
# Emby Contributors
@@ -44,7 +44,14 @@ namespace Emby.Naming.ExternalFiles
}
var extension = Path.GetExtension(path.AsSpan());
if (!(_type == DlnaProfileType.Subtitle && _namingOptions.SubtitleFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
// .idx carries VobSub per-track language metadata. Recognize it here rather
// than adding it to NamingOptions.SubtitleFileExtensions, which also gates
// subtitle uploads/saves.
var isVobSubIndex = _type == DlnaProfileType.Subtitle && extension.Equals(".idx", StringComparison.OrdinalIgnoreCase);
if (!isVobSubIndex
&& !(_type == DlnaProfileType.Subtitle && _namingOptions.SubtitleFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
&& !(_type == DlnaProfileType.Audio && _namingOptions.AudioFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
&& !(_type == DlnaProfileType.Lyric && _namingOptions.LyricFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase)))
{
@@ -27,6 +27,11 @@ namespace Emby.Server.Implementations.EntryPoints;
/// </summary>
public sealed class LibraryChangedNotifier : IHostedService, IDisposable
{
// A batch holds a live reference to every item it names, so it has to stay small enough that a
// library scan - which changes items faster than any batch window closes - cannot grow it without
// bound. Reached only by a scan; interactive use closes a batch on the window long before this.
internal const int MaxBatchSize = 2000;
private readonly ILibraryManager _libraryManager;
private readonly IServerConfigurationManager _configurationManager;
private readonly IProviderManager _providerManager;
@@ -35,11 +40,11 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable
private readonly ILogger<LibraryChangedNotifier> _logger;
private readonly Lock _libraryChangedSyncLock = new();
private readonly List<Folder> _foldersAddedTo = new();
private readonly List<Folder> _foldersRemovedFrom = new();
private readonly List<BaseItem> _itemsAdded = new();
private readonly List<BaseItem> _itemsRemoved = new();
private readonly List<BaseItem> _itemsUpdated = new();
private readonly Dictionary<Guid, Folder> _foldersAddedTo = [];
private readonly Dictionary<Guid, Folder> _foldersRemovedFrom = [];
private readonly Dictionary<Guid, BaseItem> _itemsAdded = [];
private readonly Dictionary<Guid, BaseItem> _itemsRemoved = [];
private readonly Dictionary<Guid, BaseItem> _itemsUpdated = [];
private readonly ConcurrentDictionary<Guid, DateTime> _lastProgressMessageTimes = new();
private Timer? _libraryUpdateTimer;
@@ -173,7 +178,7 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable
private void OnLibraryItemRemoved(object? sender, ItemChangeEventArgs e)
=> OnLibraryChange(e.Item, e.Parent, _itemsRemoved, _foldersRemovedFrom);
private void OnLibraryChange(BaseItem item, BaseItem parent, List<BaseItem> itemsList, List<Folder>? foldersList)
private void OnLibraryChange(BaseItem item, BaseItem parent, Dictionary<Guid, BaseItem> itemsList, Dictionary<Guid, Folder>? foldersList)
{
if (!FilterItem(item))
{
@@ -182,23 +187,28 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable
lock (_libraryChangedSyncLock)
{
var updateDuration = TimeSpan.FromSeconds(_configurationManager.Configuration.LibraryUpdateDuration);
// The window runs from the first change of a batch and is never extended. Extending it on
// every change would keep a library scan's batch open for the whole scan, and the batch
// holds the items it names alive, so it would grow to the size of the library.
if (_libraryUpdateTimer is null)
{
var updateDuration = TimeSpan.FromSeconds(_configurationManager.Configuration.LibraryUpdateDuration);
_libraryUpdateTimer = new Timer(LibraryUpdateTimerCallback, null, updateDuration, Timeout.InfiniteTimeSpan);
}
else
{
_libraryUpdateTimer.Change(updateDuration, Timeout.InfiniteTimeSpan);
}
if (foldersList is not null && parent is Folder folder)
{
foldersList.Add(folder);
foldersList[folder.Id] = folder;
}
itemsList.Add(item);
itemsList[item.Id] = item;
// A window long enough to cover a burst still has to give way once the batch is large
// enough to be worth sending on its own.
if (_itemsAdded.Count + _itemsRemoved.Count + _itemsUpdated.Count >= MaxBatchSize)
{
_libraryUpdateTimer.Change(TimeSpan.Zero, Timeout.InfiniteTimeSpan);
}
}
}
@@ -211,22 +221,16 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable
List<BaseItem> itemsRemoved;
lock (_libraryChangedSyncLock)
{
// Remove dupes in case some were saved multiple times
foldersAddedTo = _foldersAddedTo
.DistinctBy(x => x.Id)
.ToList();
foldersRemovedFrom = _foldersRemovedFrom
.DistinctBy(x => x.Id)
.ToList();
foldersAddedTo = _foldersAddedTo.Values.ToList();
foldersRemovedFrom = _foldersRemovedFrom.Values.ToList();
itemsUpdated = _itemsUpdated
.Where(i => !_itemsAdded.Contains(i))
.DistinctBy(x => x.Id)
.Where(e => !_itemsAdded.ContainsKey(e.Key))
.Select(e => e.Value)
.ToList();
itemsAdded = _itemsAdded.ToList();
itemsRemoved = _itemsRemoved.ToList();
itemsAdded = _itemsAdded.Values.ToList();
itemsRemoved = _itemsRemoved.Values.ToList();
if (_libraryUpdateTimer is not null)
{
@@ -241,6 +245,15 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable
_foldersRemovedFrom.Clear();
}
if (itemsAdded.Count == 0
&& itemsUpdated.Count == 0
&& itemsRemoved.Count == 0
&& foldersAddedTo.Count == 0
&& foldersRemovedFrom.Count == 0)
{
return;
}
await SendChangeNotifications(itemsAdded, itemsUpdated, itemsRemoved, foldersAddedTo, foldersRemovedFrom, CancellationToken.None).ConfigureAwait(false);
}
@@ -18,15 +18,17 @@ namespace Emby.Server.Implementations.EntryPoints
public sealed class UserDataChangeNotifier : IHostedService, IDisposable
{
private const int UpdateDuration = 500;
internal const int MaxBatchSize = 2000;
private readonly ISessionManager _sessionManager;
private readonly IUserDataManager _userDataManager;
private readonly IUserManager _userManager;
private readonly Dictionary<Guid, List<BaseItem>> _changedItems = new();
private readonly Dictionary<Guid, Dictionary<Guid, BaseItem>> _changedItems = [];
private readonly Lock _syncLock = new();
private Timer? _updateTimer;
private int _changedItemCount;
/// <summary>
/// Initializes a new instance of the <see cref="UserDataChangeNotifier"/> class.
@@ -69,50 +71,64 @@ namespace Emby.Server.Implementations.EntryPoints
lock (_syncLock)
{
if (_updateTimer is null)
{
_updateTimer = new Timer(
UpdateTimerCallback,
null,
UpdateDuration,
Timeout.Infinite);
}
else
{
_updateTimer.Change(UpdateDuration, Timeout.Infinite);
}
// The window runs from the first change of a batch and is never extended, so a stream
// of changes that never pauses - a library scan - still closes its batches instead of
// holding every item it touched alive until the stream stops.
_updateTimer ??= new Timer(
UpdateTimerCallback,
null,
UpdateDuration,
Timeout.Infinite);
if (!_changedItems.TryGetValue(e.UserId, out List<BaseItem>? keys))
if (!_changedItems.TryGetValue(e.UserId, out Dictionary<Guid, BaseItem>? keys))
{
keys = new List<BaseItem>();
keys = [];
_changedItems[e.UserId] = keys;
}
keys.Add(e.Item);
var baseItem = e.Item;
// Go up one level for indicators
if (baseItem is not null)
{
Track(keys, baseItem);
var parent = baseItem.GetOwner() ?? baseItem.GetParent();
if (parent is not null)
{
keys.Add(parent);
Track(keys, parent);
}
}
// A window long enough to cover a burst still has to give way once the batch is
// large enough to be worth sending on its own.
if (_changedItemCount >= MaxBatchSize)
{
_updateTimer.Change(0, Timeout.Infinite);
}
}
}
private void Track(Dictionary<Guid, BaseItem> keys, BaseItem item)
{
var before = keys.Count;
keys[item.Id] = item;
if (keys.Count != before)
{
_changedItemCount++;
}
}
private async void UpdateTimerCallback(object? state)
{
List<KeyValuePair<Guid, List<BaseItem>>> changes;
List<KeyValuePair<Guid, Dictionary<Guid, BaseItem>>> changes;
lock (_syncLock)
{
// Remove dupes in case some were saved multiple times
changes = _changedItems.ToList();
_changedItems.Clear();
_changedItemCount = 0;
if (_updateTimer is not null)
{
@@ -121,17 +137,22 @@ namespace Emby.Server.Implementations.EntryPoints
}
}
if (changes.Count == 0)
{
return;
}
foreach (var (userId, changedItems) in changes)
{
await _sessionManager.SendMessageToUserSessions(
[userId],
SessionMessageType.UserDataChanged,
() => GetUserDataChangeInfo(userId, changedItems),
() => GetUserDataChangeInfo(userId, changedItems.Values),
default).ConfigureAwait(false);
}
}
private UserDataChangeInfo GetUserDataChangeInfo(Guid userId, List<BaseItem> changedItems)
private UserDataChangeInfo GetUserDataChangeInfo(Guid userId, IEnumerable<BaseItem> changedItems)
{
var user = _userManager.GetUserById(userId)
?? throw new ArgumentException("Invalid user ID", nameof(userId));
@@ -140,7 +161,6 @@ namespace Emby.Server.Implementations.EntryPoints
{
UserId = userId,
UserDataList = changedItems
.DistinctBy(x => x.Id)
.Select(i =>
{
var dto = _userDataManager.GetUserDataDto(i, user);
@@ -11,7 +11,7 @@ using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.ScheduledTasks.Tasks;
/// <summary>
/// Optimizes Jellyfin's database by issuing a VACUUM command.
/// Optimizes Jellyfin's database by issuing VACUUM and ANALYZE commands.
/// </summary>
public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask
{
@@ -82,7 +82,7 @@ public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask
return;
}
_logger.LogInformation("Optimizing and vacuuming jellyfin.db...");
_logger.LogInformation("Vacuuming and analyzing jellyfin.db...");
try
{
+16 -2
View File
@@ -90,6 +90,18 @@ namespace Emby.Server.Implementations.SyncPlay
/// <value>The default ping.</value>
public long DefaultPing { get; } = 500;
/// <summary>
/// Gets the maximum ping, in milliseconds, accepted from a session.
/// </summary>
/// <remarks>
/// Pings are reported by clients and are scaled into the delays used to schedule playback,
/// so an unbounded value lets a single session push the whole group's resume point
/// arbitrarily far out, or overflow the arithmetic entirely. Anything above this is not a
/// usable measurement for synchronisation.
/// </remarks>
/// <value>The maximum ping.</value>
public long MaxPing { get; } = 10000;
/// <summary>
/// Gets the maximum time offset error accepted for dates reported by clients, in milliseconds.
/// </summary>
@@ -438,7 +450,7 @@ namespace Emby.Server.Implementations.SyncPlay
{
if (_participants.TryGetValue(session.Id, out GroupMember value))
{
value.Ping = ping;
value.Ping = Math.Clamp(ping, 0, MaxPing);
}
}
@@ -451,7 +463,9 @@ namespace Emby.Server.Implementations.SyncPlay
max = Math.Max(max, session.Ping);
}
return max;
// A group with no participants has no ping to report. Returning long.MinValue would
// overflow the callers that scale this value into ticks, so fall back to the default.
return max == long.MinValue ? DefaultPing : max;
}
/// <inheritdoc />
@@ -181,8 +181,8 @@ namespace Emby.Server.Implementations.SyncPlay
{
if (existingGroup.GroupId.Equals(request.GroupId))
{
// Restore session.
UpdateSessionsCounter(session.UserId, 1);
// Restore session. The session is already in the group and has already
// been counted, so the counter must not be incremented a second time.
group.SessionJoin(session, request, cancellationToken);
return;
}
@@ -332,8 +332,11 @@ namespace Emby.Server.Implementations.SyncPlay
// Group lock required as Group is not thread-safe.
lock (group)
{
// Make sure that session still belongs to this group.
if (_sessionToGroupMap.TryGetValue(session.Id, out var checkGroup) && !checkGroup.GroupId.Equals(group.GroupId))
// Make sure that session still belongs to this group. The lookup can fail
// outright when the session left while this request was waiting on the group
// lock, which is exactly the case this re-check exists to catch.
if (!_sessionToGroupMap.TryGetValue(session.Id, out var checkGroup)
|| !checkGroup.GroupId.Equals(group.GroupId))
{
// Drop request.
return;
@@ -400,7 +403,7 @@ namespace Emby.Server.Implementations.SyncPlay
// Update sessions counter.
var newSessionsCounter = _activeUsers.AddOrUpdate(
userId,
1,
toAdd,
(_, sessionsCounter) => sessionsCounter + toAdd);
// Should never happen.
@@ -176,14 +176,6 @@ public class ItemPersistenceService : IItemPersistenceService
var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (context.ConfigureAwait(false))
{
if (!await context.BaseItems
.AnyAsync(bi => bi.Id == item.Id, cancellationToken)
.ConfigureAwait(false))
{
_logger.LogWarning("Unable to save ImageInfo for non existing BaseItem");
return;
}
await context.BaseItemImageInfos
.Where(e => e.ItemId == item.Id)
.ExecuteDeleteAsync(cancellationToken)
@@ -193,7 +185,26 @@ public class ItemPersistenceService : IItemPersistenceService
.AddRangeAsync(images, cancellationToken)
.ConfigureAwait(false);
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
try
{
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
catch (DbUpdateException)
{
// Checking that the item exists before writing leaves a gap a scan can delete it
// through, turning the insert into a foreign key violation that fails the whole
// refresh instead of the no-op intended here. Let the insert be the check: it is the
// only point at which the answer cannot go stale. Nothing is orphaned by the delete
// above, because deleting the item cascades to its images anyway.
if (await context.BaseItems
.AnyAsync(bi => bi.Id == item.Id, cancellationToken)
.ConfigureAwait(false))
{
throw;
}
_logger.LogWarning("Unable to save ImageInfo for non existing BaseItem {ItemId}", item.Id);
}
}
}
@@ -183,7 +183,13 @@ internal class JellyfinMigrationService
}
}
public async Task MigrateStepAsync(JellyfinMigrationStageTypes stage, IServiceProvider serviceProvider)
/// <summary>
/// Runs all pending migrations of the requested stage.
/// </summary>
/// <param name="stage">The stage to migrate.</param>
/// <param name="serviceProvider">The service provider handed to the migrations.</param>
/// <returns>A value indicating whether at least one migration has been applied.</returns>
public async Task<bool> MigrateStepAsync(JellyfinMigrationStageTypes stage, IServiceProvider serviceProvider)
{
var logger = _startupLogger.With(_loggerFactory.CreateLogger<JellyfinMigrationService>()).BeginGroup($"Migrate stage {stage}.");
ICollection<CodeMigration> migrationStage = (Migrations.FirstOrDefault(e => e.Stage == stage) as ICollection<CodeMigration>) ?? [];
@@ -297,6 +303,8 @@ internal class JellyfinMigrationService
completedMigrations++;
}
return completedMigrations > 0;
}
}
+34 -8
View File
@@ -61,6 +61,7 @@ namespace Jellyfin.Server
private static ILogger _logger = NullLogger.Instance;
private static bool _restartOnShutdown;
private static IStartupLogger<JellyfinMigrationService>? _migrationLogger;
private static bool _optimizeDatabaseAfterMigration;
private static string? _restoreFromBackup;
/// <summary>
@@ -207,14 +208,15 @@ namespace Jellyfin.Server
await jellyfinMigrationService.PrepareSystemForMigration(_logger).ConfigureAwait(false);
// "Preparing migrations" carries through the DB read; per-migration progress is reported
// as "Running migration X of Y" from inside the step once the pending set is known.
await jellyfinMigrationService.MigrateStepAsync(JellyfinMigrationStageTypes.CoreInitialisation, appHost.ServiceProvider).ConfigureAwait(false);
_optimizeDatabaseAfterMigration |= await jellyfinMigrationService.MigrateStepAsync(JellyfinMigrationStageTypes.CoreInitialisation, appHost.ServiceProvider).ConfigureAwait(false);
SetupServer.ReportActivity(StartupActivity.InitializingServices);
await appHost.InitializeServices(startupConfig).ConfigureAwait(false);
_appHost = appHost;
await jellyfinMigrationService.MigrateStepAsync(JellyfinMigrationStageTypes.AppInitialisation, appHost.ServiceProvider).ConfigureAwait(false);
_optimizeDatabaseAfterMigration |= await jellyfinMigrationService.MigrateStepAsync(JellyfinMigrationStageTypes.AppInitialisation, appHost.ServiceProvider).ConfigureAwait(false);
await jellyfinMigrationService.CleanupSystemAfterMigration(_logger).ConfigureAwait(false);
await OptimizeDatabaseAfterMigrationAsync(appHost.ServiceProvider).ConfigureAwait(false);
try
{
configurationCompleted = true;
@@ -269,12 +271,11 @@ namespace Jellyfin.Server
// Don't throw additional exception if startup failed.
if (appHost.ServiceProvider is not null)
{
_logger.LogInformation("Running query planner optimizations in the database... This might take a while");
_logger.LogInformation("Optimizing the database... This might take a while");
// Deliberately untimed: a truncated optimization leaves the statistics incomplete.
var databaseProvider = appHost.ServiceProvider.GetRequiredService<IJellyfinDatabaseProvider>();
using var shutdownSource = new CancellationTokenSource();
shutdownSource.CancelAfter((int)TimeSpan.FromSeconds(60).TotalMicroseconds);
await databaseProvider.RunShutdownTask(shutdownSource.Token).ConfigureAwait(false);
await databaseProvider.RunShutdownTask(CancellationToken.None).ConfigureAwait(false);
}
_appHost = null;
@@ -311,7 +312,7 @@ namespace Jellyfin.Server
var jellyfinMigrationService = ActivatorUtilities.CreateInstance<JellyfinMigrationService>(startupService);
await jellyfinMigrationService.CheckFirstTimeRunOrMigration(appPaths, startupOptions).ConfigureAwait(false);
await jellyfinMigrationService.MigrateStepAsync(Migrations.Stages.JellyfinMigrationStageTypes.PreInitialisation, startupService).ConfigureAwait(false);
_optimizeDatabaseAfterMigration |= await jellyfinMigrationService.MigrateStepAsync(Migrations.Stages.JellyfinMigrationStageTypes.PreInitialisation, startupService).ConfigureAwait(false);
}
/// <summary>
@@ -326,7 +327,32 @@ namespace Jellyfin.Server
public static async Task ApplyCoreMigrationsAsync(IServiceProvider serviceProvider, Migrations.Stages.JellyfinMigrationStageTypes jellyfinMigrationStage)
{
var jellyfinMigrationService = ActivatorUtilities.CreateInstance<JellyfinMigrationService>(serviceProvider, _migrationLogger!);
await jellyfinMigrationService.MigrateStepAsync(jellyfinMigrationStage, serviceProvider).ConfigureAwait(false);
_optimizeDatabaseAfterMigration |= await jellyfinMigrationService.MigrateStepAsync(jellyfinMigrationStage, serviceProvider).ConfigureAwait(false);
}
private static async Task OptimizeDatabaseAfterMigrationAsync(IServiceProvider serviceProvider)
{
if (!_optimizeDatabaseAfterMigration)
{
return;
}
// Reset first: a restart runs no migrations and must not optimize again.
_optimizeDatabaseAfterMigration = false;
SetupServer.ReportActivity(StartupActivity.OptimizingDatabase);
_logger.LogInformation("Migrations have been applied, optimizing the database... This might take a while");
try
{
// Deliberately untimed: incomplete statistics are worse than a slow start.
var databaseProvider = serviceProvider.GetRequiredService<IJellyfinDatabaseProvider>();
await databaseProvider.RunScheduledOptimisation(CancellationToken.None).ConfigureAwait(false);
}
catch (Exception ex)
{
// A missed optimization only costs performance, so never fail startup over this.
_logger.LogError(ex, "Error while optimizing the database after migration");
}
}
/// <summary>
@@ -27,6 +27,9 @@ public static class StartupActivity
/// <summary>Bringing up core services and plugins.</summary>
public const string InitializingServices = "Initializing services";
/// <summary>Refreshing the database statistics after migrations have run.</summary>
public const string OptimizingDatabase = "Optimizing database";
/// <summary>Running the final startup tasks.</summary>
public const string FinishingStartup = "Finishing startup";
@@ -695,7 +695,11 @@ namespace MediaBrowser.Controller.MediaEncoding
"ogg" or "oga" or "ogv" or "webm" or "webma" => "opus",
"m4a" or "m4b" or "mp4" or "mov" or "mkv" or "mka" => "aac",
"ts" or "avi" or "flv" or "f4v" or "swf" => "mp3",
_ => inferredCodec
// Containers that share their name with the codec they carry.
"aac" or "ac3" or "alac" or "dts" or "eac3" or "flac" or "mp2" or "mp3" or "opus" or "truehd" or "vorbis" => inferredCodec,
// Anything else - manifests such as m3u8/mpd in particular - names a container that
// is not an audio codec. Never hand that name to ffmpeg as an encoder.
_ => "aac"
};
}
@@ -6311,7 +6315,7 @@ namespace MediaBrowser.Controller.MediaEncoding
string.Join(',', overlayFilters));
var mapPrefix = Convert.ToInt32(state.SubtitleStream.IsExternal);
var subtitleStreamIndex = FindIndex(state.MediaSource.MediaStreams, state.SubtitleStream);
var subtitleStreamIndex = GetSubtitleStreamIndexForFfmpeg(state.MediaSource, state.SubtitleStream);
var videoStreamIndex = FindIndex(state.MediaSource.MediaStreams, state.VideoStream);
if (hasSubs)
@@ -7943,6 +7947,24 @@ namespace MediaBrowser.Controller.MediaEncoding
return -1;
}
public static int GetSubtitleStreamIndexForFfmpeg(MediaSourceInfo mediaSource, MediaStream subtitleStream)
{
var index = FindIndex(mediaSource.MediaStreams, subtitleStream);
if (index == -1 || subtitleStream.IsExternal || mediaSource.VideoType != VideoType.BluRay)
{
return index;
}
var hiddenStreamsBefore = mediaSource.MediaStreams.Count(s =>
s.Type == MediaStreamType.Audio
&& !s.IsExternal
&& (string.Equals(s.Codec, "truehd", StringComparison.OrdinalIgnoreCase)
|| string.Equals(s.Codec, "atmos", StringComparison.OrdinalIgnoreCase))
&& s.Index < subtitleStream.Index);
return index + hiddenStreamsBefore;
}
public static bool IsCopyCodec(string codec)
{
return string.Equals(codec, "copy", StringComparison.OrdinalIgnoreCase);
@@ -501,7 +501,7 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates
{
// Client, that was buffering, resumed playback but did not update others in time.
delayTicks = context.GetHighestPing() * 2 * TimeSpan.TicksPerMillisecond;
delayTicks = Math.Max(delayTicks, context.DefaultPing);
delayTicks = Math.Max(delayTicks, TimeSpan.FromMilliseconds(context.DefaultPing).Ticks);
context.LastActivity = currentTime.AddTicks(delayTicks);
@@ -157,7 +157,10 @@ namespace MediaBrowser.Controller.SyncPlay.Queue
/// </summary>
public void RestoreSortedPlaylist()
{
if (PlayingItemIndex != NoPlayingItemIndex)
// The shuffled playlist is only populated while the shuffle mode is active, so there is
// nothing to map back when the playlist is already sorted. Guarding on its contents keeps
// a redundant request for the sorted mode from indexing an empty list.
if (PlayingItemIndex != NoPlayingItemIndex && _shuffledPlaylist.Count > 0)
{
var playingItem = _shuffledPlaylist[PlayingItemIndex];
PlayingItemIndex = _sortedPlaylist.IndexOf(playingItem);
@@ -1152,6 +1152,11 @@ namespace MediaBrowser.MediaEncoding.Encoder
{
process.Process.PriorityClass = ProcessPriorityClass.BelowNormal;
}
catch (InvalidOperationException)
{
// The process finished before its priority could be lowered. That says nothing
// about whether the platform allows it, so keep the capability for the next one.
}
catch (Exception ex)
{
_canSetProcessPriority = false;
@@ -1361,12 +1366,20 @@ namespace MediaBrowser.MediaEncoding.Encoder
return _configurationManager.GetEncodingOptions().EnableSubtitleExtraction;
}
private sealed class ProcessWrapper : IDisposable
internal sealed class ProcessWrapper : IDisposable
{
private readonly MediaEncoder _mediaEncoder;
// The exit event is raised on the thread pool, so it writes the state below while the
// caller that started the process is reading it.
private readonly Lock _exitLock = new();
private bool _disposed = false;
private bool _hasExited;
private int? _exitCode;
public ProcessWrapper(Process process, MediaEncoder mediaEncoder)
{
Process = process;
@@ -1376,49 +1389,84 @@ namespace MediaBrowser.MediaEncoding.Encoder
public Process Process { get; }
public bool HasExited { get; private set; }
// The exit event can lag behind the wait that returned, so ask the process rather than
// report one that has exited as still running.
public bool HasExited => ReadExitState().HasExited;
public int? ExitCode { get; private set; }
// As above: rather than report no exit code for a process that has one.
public int? ExitCode => ReadExitState().ExitCode;
private (bool HasExited, int? ExitCode) ReadExitState()
{
lock (_exitLock)
{
if (!_hasExited && !_disposed)
{
try
{
if (Process.HasExited)
{
_hasExited = true;
_exitCode = Process.ExitCode;
}
}
catch (InvalidOperationException)
{
// No process is associated with this object, or it was disposed from
// under us - ObjectDisposedException derives from this one.
}
}
return (_hasExited, _exitCode);
}
}
private void OnProcessExited(object sender, EventArgs e)
{
var process = (Process)sender;
HasExited = true;
lock (_exitLock)
{
_hasExited = true;
try
{
ExitCode = process.ExitCode;
}
catch
{
try
{
_exitCode = process.ExitCode;
}
catch
{
}
}
DisposeProcess(process);
// Only stop tracking it. The caller that started the process still holds it to read
// its output and its exit code, so disposing it here handed whoever was quickest to
// exit - an ffprobe on a file it rejects outright - an ObjectDisposedException.
Untrack();
}
private void DisposeProcess(Process process)
private void Untrack()
{
lock (_mediaEncoder._runningProcessesLock)
{
_mediaEncoder._runningProcesses.Remove(this);
}
process.Dispose();
}
public void Dispose()
{
if (!_disposed)
lock (_exitLock)
{
if (Process is not null)
if (_disposed)
{
Process.Exited -= OnProcessExited;
DisposeProcess(Process);
return;
}
_disposed = true;
}
_disposed = true;
Process.Exited -= OnProcessExited;
Untrack();
Process.Dispose();
}
}
}
@@ -649,7 +649,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
List<MediaStream> subtitleStreams,
CancellationToken cancellationToken)
{
var inputPath = _mediaEncoder.GetInputArgument(mediaSource.Path, mediaSource);
var inputPath = _mediaEncoder.GetInputPathArgument(mediaSource.Path, mediaSource);
var outputPaths = new List<string>();
var args = string.Format(
CultureInfo.InvariantCulture,
@@ -673,7 +673,7 @@ 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);
var streamIndex = EncodingHelper.GetSubtitleStreamIndexForFfmpeg(mediaSource, subtitleStream);
if (streamIndex == -1)
{
+10
View File
@@ -26,6 +26,8 @@ namespace MediaBrowser.Model.Dlna
internal const TranscodeReason VideoReasons = TranscodeReason.VideoCodecNotSupported | VideoCodecReasons;
internal const TranscodeReason DirectStreamReasons = AudioReasons | TranscodeReason.ContainerNotSupported | TranscodeReason.VideoCodecTagNotSupported;
private const string ManifestContainers = "hls,applehttp,dash";
private readonly ILogger _logger;
private readonly ITranscoderSupport _transcoderSupport;
private static readonly string[] _supportedHlsVideoCodecs = ["h264", "hevc", "vp9", "av1"];
@@ -718,6 +720,14 @@ namespace MediaBrowser.Model.Dlna
isEligibleForDirectPlay = false;
}
// A manifest is not a byte stream, so it cannot be handed to the client as one. The variant
// and segment URIs inside it are relative to the origin and do not resolve against the
// Jellyfin url the client would fetch it from.
if (ContainerHelper.ContainsContainer(ManifestContainers, item.Container))
{
isEligibleForDirectPlay = false;
}
if (bitrateLimitExceeded)
{
transcodeReasons = TranscodeReason.ContainerBitrateExceedsLimit;
@@ -231,10 +231,28 @@ namespace MediaBrowser.Providers.MediaInfo
return Array.Empty<ExternalPathParserResult>();
}
// VobSub .sub payloads only carry per-track language metadata when read via
// their paired .idx file, so probe the .idx instead and skip the .sub. Pairing
// requires the same directory (ffprobe can't resolve a split pair) and an
// ordinal comparison (ffprobe matches the .sub by exact case on case-sensitive
// filesystems, so a looser match could suppress a .sub with no working .idx).
// An .idx file with no paired .sub cannot be probed at all, so it is left out
// entirely rather than surfaced (which would otherwise fail every probe and,
// since the .idx would keep "existing" from Jellyfin's point of view, prevent
// stale subtitle stream metadata from ever being cleared once the .sub is gone).
HashSet<string>? pairedVobSubKeys = _type == DlnaProfileType.Subtitle
? GetPairedVobSubKeys(files)
: null;
var externalPathInfos = new List<ExternalPathParserResult>();
ReadOnlySpan<char> prefix = video.FileNameWithoutExtension;
foreach (var file in files)
{
if (IsSuppressedVobSubFile(file, pairedVobSubKeys))
{
continue;
}
var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(file.AsSpan());
if (fileNameWithoutExtension.Length >= prefix.Length
&& prefix.Equals(fileNameWithoutExtension[..prefix.Length], StringComparison.OrdinalIgnoreCase)
@@ -304,6 +322,77 @@ namespace MediaBrowser.Providers.MediaInfo
return externalPathInfos;
}
/// <summary>
/// Determines whether a candidate file is part of a VobSub .idx/.sub pair that
/// should be resolved to only its .idx file, or an .idx file with no paired .sub
/// that cannot be probed at all.
/// </summary>
/// <param name="file">The full path to the candidate file.</param>
/// <param name="pairedVobSubKeys">The set of pairing keys with both an .idx and .sub present, or null if not applicable.</param>
/// <returns><c>true</c> if the file should be suppressed; otherwise, <c>false</c>.</returns>
private static bool IsSuppressedVobSubFile(string file, HashSet<string>? pairedVobSubKeys)
{
if (pairedVobSubKeys is null)
{
return false;
}
var extension = Path.GetExtension(file.AsSpan());
if (extension.Equals(".sub", StringComparison.OrdinalIgnoreCase))
{
// A paired .idx exists; probe it instead of the .sub payload.
return pairedVobSubKeys.Contains(GetVobSubPairingKey(file));
}
if (extension.Equals(".idx", StringComparison.OrdinalIgnoreCase))
{
// Without its .sub payload, the .idx cannot be probed for any data.
return !pairedVobSubKeys.Contains(GetVobSubPairingKey(file));
}
return false;
}
/// <summary>
/// Builds the set of directory+basename keys that have both an .idx and a .sub
/// file present, in a single pass over the candidate files.
/// </summary>
/// <param name="files">The candidate files to search.</param>
/// <returns>The set of pairing keys with both an .idx and .sub present.</returns>
private static HashSet<string> GetPairedVobSubKeys(IEnumerable<string> files)
{
var idxKeys = new HashSet<string>(StringComparer.Ordinal);
var subKeys = new HashSet<string>(StringComparer.Ordinal);
foreach (var file in files)
{
var extension = Path.GetExtension(file.AsSpan());
if (extension.Equals(".idx", StringComparison.OrdinalIgnoreCase))
{
idxKeys.Add(GetVobSubPairingKey(file));
}
else if (extension.Equals(".sub", StringComparison.OrdinalIgnoreCase))
{
subKeys.Add(GetVobSubPairingKey(file));
}
}
idxKeys.IntersectWith(subKeys);
return idxKeys;
}
/// <summary>
/// Builds a directory+basename key used to pair a VobSub .idx file with its .sub
/// payload only when both live in the same directory.
/// </summary>
/// <param name="file">The full path to the file.</param>
/// <returns>A key combining the containing directory and file name without extension.</returns>
private static string GetVobSubPairingKey(string file)
{
var directory = Path.GetDirectoryName(file) ?? string.Empty;
var baseName = Path.GetFileNameWithoutExtension(file);
return Path.Combine(directory, baseName);
}
/// <summary>
/// Returns the media info of the given file.
/// </summary>
@@ -37,14 +37,16 @@ public interface IJellyfinDatabaseProvider
void ConfigureConventions(ModelConfigurationBuilder configurationBuilder);
/// <summary>
/// If supported this should run any periodic maintaince tasks.
/// If supported this should run any periodic maintaince tasks, reclaiming unused space and refreshing the query
/// planner statistics. Also used after migrations have modified the database.
/// </summary>
/// <param name="cancellationToken">The token to abort the operation.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
Task RunScheduledOptimisation(CancellationToken cancellationToken);
/// <summary>
/// If supported this should perform any actions that are required on stopping the jellyfin server.
/// If supported this should perform any actions that are required on stopping the jellyfin server, including the
/// same maintenance as <see cref="RunScheduledOptimisation(CancellationToken)"/>.
/// </summary>
/// <param name="cancellationToken">The token that will be used to abort the operation.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
@@ -103,17 +103,9 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider
}
/// <inheritdoc/>
public async Task RunScheduledOptimisation(CancellationToken cancellationToken)
public Task RunScheduledOptimisation(CancellationToken cancellationToken)
{
var context = await DbContextFactory!.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (context.ConfigureAwait(false))
{
await context.Database.ExecuteSqlRawAsync("PRAGMA wal_checkpoint(TRUNCATE)", cancellationToken).ConfigureAwait(false);
await context.Database.ExecuteSqlRawAsync("PRAGMA optimize", cancellationToken).ConfigureAwait(false);
await context.Database.ExecuteSqlRawAsync("VACUUM", cancellationToken).ConfigureAwait(false);
await context.Database.ExecuteSqlRawAsync("PRAGMA wal_checkpoint(TRUNCATE)", cancellationToken).ConfigureAwait(false);
_logger.LogInformation("jellyfin.db optimized successfully!");
}
return OptimizeAsync(cancellationToken);
}
/// <inheritdoc/>
@@ -124,20 +116,38 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider
/// <inheritdoc/>
public async Task RunShutdownTask(CancellationToken cancellationToken)
{
// Run before disposing the application
try
{
await OptimizeAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
// A missed optimization only costs performance, so never fail the shutdown over this.
_logger.LogError(ex, "Error while optimizing jellyfin.db");
}
SqliteConnection.ClearAllPools();
}
private async Task OptimizeAsync(CancellationToken cancellationToken)
{
if (DbContextFactory is null)
{
return;
}
// Run before disposing the application
var context = await DbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (context.ConfigureAwait(false))
{
await context.Database.ExecuteSqlRawAsync("PRAGMA optimize", cancellationToken).ConfigureAwait(false);
await context.Database.ExecuteSqlRawAsync("PRAGMA wal_checkpoint(TRUNCATE)", cancellationToken).ConfigureAwait(false);
await context.Database.ExecuteSqlRawAsync("VACUUM", cancellationToken).ConfigureAwait(false);
await context.Database.ExecuteSqlRawAsync("PRAGMA analysis_limit=0", cancellationToken).ConfigureAwait(false);
await context.Database.ExecuteSqlRawAsync("ANALYZE", cancellationToken).ConfigureAwait(false);
await context.Database.ExecuteSqlRawAsync("PRAGMA wal_checkpoint(TRUNCATE)", cancellationToken).ConfigureAwait(false);
_logger.LogInformation("jellyfin.db optimized successfully!");
}
SqliteConnection.ClearAllPools();
}
/// <inheritdoc/>
+25 -1
View File
@@ -32,6 +32,7 @@ namespace Jellyfin.LiveTv.TunerHosts
{
private static readonly string[] _mimeTypesCanShareHttpStream = ["video/MP2T"];
private static readonly string[] _extensionsCanShareHttpStream = [".ts", ".tsv", ".m2t"];
private static readonly string[] _manifestExtensions = [".m3u8", ".m3u", ".mpd"];
private readonly IHttpClientFactory _httpClientFactory;
private readonly IServerApplicationHost _appHost;
@@ -151,11 +152,20 @@ namespace Jellyfin.LiveTv.TunerHosts
var protocol = _mediaSourceManager.GetPathProtocol(path);
var isRemote = true;
if (Uri.TryCreate(path, UriKind.Absolute, out var uri))
Uri.TryCreate(path, UriKind.Absolute, out var uri);
if (uri is not null)
{
isRemote = !_networkManager.IsInLocalNetwork(uri.Host);
}
// A manifest is not a byte stream. Serving one directly hands the client a playlist whose
// variant and segment URIs are relative to the origin, and those do not resolve against the
// Jellyfin url the client fetched it from. Remux or transcode these instead.
if (IsManifest(path, uri))
{
supportsDirectPlay = false;
}
var httpHeaders = new Dictionary<string, string>();
if (protocol == MediaProtocol.Http)
@@ -210,6 +220,20 @@ namespace Jellyfin.LiveTv.TunerHosts
return mediaSource;
}
/// <summary>
/// Determines whether a channel path points at an HLS or DASH manifest rather than at a byte stream.
/// </summary>
/// <param name="path">The channel path.</param>
/// <param name="uri">The channel path parsed as an absolute uri, or <c>null</c> if it is not one.</param>
/// <returns><c>true</c> if the path names a streaming manifest.</returns>
private static bool IsManifest(string path, Uri uri)
{
// Use the uri path when there is one so that a query string does not hide the extension.
var extension = Path.GetExtension(uri is null ? path : uri.AbsolutePath);
return _manifestExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase);
}
public Task<List<TunerHostInfo>> DiscoverDevices(int discoveryDurationMs, CancellationToken cancellationToken)
{
return Task.FromResult(new List<TunerHostInfo>());
@@ -0,0 +1,40 @@
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.MediaEncoding;
using Moq;
using Xunit;
using IConfiguration = Microsoft.Extensions.Configuration.IConfiguration;
namespace Jellyfin.Controller.Tests.MediaEncoding;
public class EncodingHelperInferAudioCodecTests
{
[Theory]
// Manifests and other containers that carry no inferable audio codec.
[InlineData("m3u8", "aac")]
[InlineData("mpd", "aac")]
[InlineData("wtv", "aac")]
[InlineData("", "aac")]
// Containers with a well known audio codec.
[InlineData("mp4", "aac")]
[InlineData("mkv", "aac")]
[InlineData("webm", "opus")]
[InlineData("ts", "mp3")]
// Containers named after the codec they carry.
[InlineData("flac", "flac")]
[InlineData("opus", "opus")]
[InlineData("ac3", "ac3")]
public void InferAudioCodec_ReturnsAnAudioCodec(string container, string expected)
{
Assert.Equal(expected, Create().InferAudioCodec(container));
}
private static EncodingHelper Create()
=> new(
Mock.Of<IApplicationPaths>(),
Mock.Of<IMediaEncoder>(),
Mock.Of<ISubtitleEncoder>(),
Mock.Of<IConfiguration>(),
Mock.Of<IConfigurationManager>(),
Mock.Of<IPathManager>());
}
@@ -0,0 +1,72 @@
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.LiveTv.TunerHosts;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.LiveTv;
using MediaBrowser.Model.MediaInfo;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace Jellyfin.LiveTv.Tests
{
public class M3UTunerHostTests
{
[Theory]
// A manifest is not a byte stream, so it must never be offered for direct play.
[InlineData("http://example.com/live/1234.m3u8", false)]
[InlineData("http://example.com/live/1234.m3u8?token=abc", false)]
[InlineData("http://example.com/live/1234.mpd", false)]
// Byte streams are unaffected.
[InlineData("http://example.com/live/1234.ts", true)]
[InlineData("http://example.com/live/1234", true)]
public async Task GetChannelStreamMediaSources_ManifestPath_DisablesDirectPlay(string path, bool expectDirectPlay)
{
var mediaSourceManager = new Mock<IMediaSourceManager>();
mediaSourceManager.Setup(x => x.GetPathProtocol(It.IsAny<string>())).Returns(MediaProtocol.Http);
var host = new TestableM3UTunerHost(
Mock.Of<IServerConfigurationManager>(),
mediaSourceManager.Object,
Mock.Of<ILogger<M3UTunerHost>>(),
Mock.Of<IFileSystem>(),
Mock.Of<IHttpClientFactory>(),
Mock.Of<IServerApplicationHost>(),
Mock.Of<INetworkManager>(),
Mock.Of<IStreamHelper>());
var sources = await host.GetMediaSources(
new TunerHostInfo { TunerCount = 0, EnableStreamLooping = false },
new ChannelInfo { Path = path });
Assert.Equal(expectDirectPlay, sources[0].SupportsDirectPlay);
}
private sealed class TestableM3UTunerHost : M3UTunerHost
{
public TestableM3UTunerHost(
IServerConfigurationManager config,
IMediaSourceManager mediaSourceManager,
ILogger<M3UTunerHost> logger,
IFileSystem fileSystem,
IHttpClientFactory httpClientFactory,
IServerApplicationHost appHost,
INetworkManager networkManager,
IStreamHelper streamHelper)
: base(config, mediaSourceManager, logger, fileSystem, httpClientFactory, appHost, networkManager, streamHelper)
{
}
public Task<List<MediaSourceInfo>> GetMediaSources(TunerHostInfo tuner, ChannelInfo channel)
=> GetChannelStreamMediaSources(tuner, channel, CancellationToken.None);
}
}
}
@@ -0,0 +1,103 @@
using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.MediaEncoding.Encoder;
using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.MediaInfo;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace Jellyfin.MediaEncoding.Tests.Encoder;
public class ProcessWrapperTests
{
[Fact]
public async Task ExitedProcess_StaysUsableForTheCallerThatStartedIt()
{
using var process = CreateProcess();
using var exitHandled = new ManualResetEventSlim(false);
using (var wrapper = new MediaEncoder.ProcessWrapper(process, CreateEncoder()))
{
// Subscribed after the wrapper, so by the time this is set the wrapper's own handler has
// already run: whatever it does to the process has happened.
process.Exited += (_, _) => exitHandled.Set();
process.Start();
await process.WaitForExitAsync(TestContext.Current.CancellationToken).ConfigureAwait(true);
Assert.True(exitHandled.Wait(TimeSpan.FromSeconds(15), TestContext.Current.CancellationToken), "The process never raised Exited.");
// The caller still owns the process here. Disposing it from the exit handler handed
// whoever exited quickest an ObjectDisposedException out of these three lines.
var output = await process.StandardOutput.ReadToEndAsync(TestContext.Current.CancellationToken).ConfigureAwait(true);
Assert.Equal("jellyfin", output.Trim());
Assert.True(wrapper.HasExited);
Assert.Equal(3, wrapper.ExitCode);
}
}
[Fact]
public async Task ExitState_IsReadableBeforeTheExitEventArrives()
{
using var process = CreateProcess();
using (var wrapper = new MediaEncoder.ProcessWrapper(process, CreateEncoder()))
{
process.Start();
await process.WaitForExitAsync(TestContext.Current.CancellationToken).ConfigureAwait(true);
// The exit event is raised on the thread pool and can lag behind the wait that just
// returned, so neither of these may depend on it having arrived.
Assert.True(wrapper.HasExited);
Assert.Equal(3, wrapper.ExitCode);
}
}
[Fact]
public async Task ExitCode_SurvivesDisposal()
{
using var process = CreateProcess();
var wrapper = new MediaEncoder.ProcessWrapper(process, CreateEncoder());
process.Start();
await process.WaitForExitAsync(TestContext.Current.CancellationToken).ConfigureAwait(true);
var exitCode = wrapper.ExitCode;
wrapper.Dispose();
Assert.Equal(exitCode, wrapper.ExitCode);
Assert.True(wrapper.HasExited);
}
private static MediaEncoder CreateEncoder()
=> new(
Mock.Of<ILogger<MediaEncoder>>(),
Mock.Of<IServerConfigurationManager>(),
Mock.Of<IFileSystem>(),
Mock.Of<IBlurayExaminer>(),
Mock.Of<ILocalizationManager>(),
new ConfigurationBuilder().Build(),
Mock.Of<IServerConfigurationManager>());
// Writes to stdout and exits immediately with a non-zero code, standing in for the ffprobe that
// rejects a file outright - the process that used to win the race against its own caller.
private static Process CreateProcess()
{
var startInfo = OperatingSystem.IsWindows()
? new ProcessStartInfo("cmd.exe", "/c echo jellyfin & exit 3")
: new ProcessStartInfo("/bin/sh", "-c \"printf 'jellyfin\\n'; exit 3\"");
startInfo.CreateNoWindow = true;
startInfo.UseShellExecute = false;
startInfo.RedirectStandardOutput = true;
return new Process { StartInfo = startInfo, EnableRaisingEvents = true };
}
}
@@ -0,0 +1,104 @@
using System;
using Jellyfin.Data.Enums;
using MediaBrowser.Model.Dlna;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.MediaInfo;
using MediaBrowser.Model.Session;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace Jellyfin.Model.Tests.Dlna;
public class StreamBuilderManifestContainerTests
{
[Theory]
// A manifest describes a stream instead of carrying one, so it can never be direct played,
// even when the client claims to support the container.
[InlineData("hls")]
[InlineData("hls,applehttp")]
[InlineData("applehttp")]
[InlineData("dash")]
public void GetOptimalVideoStream_ManifestContainer_DoesNotDirectPlay(string container)
{
var streamInfo = BuildFor(container);
Assert.NotNull(streamInfo);
Assert.Equal(PlayMethod.Transcode, streamInfo.PlayMethod);
}
[Fact]
public void GetOptimalVideoStream_ByteStreamContainer_StillDirectPlays()
{
var streamInfo = BuildFor("mp4");
Assert.NotNull(streamInfo);
Assert.Equal(PlayMethod.DirectPlay, streamInfo.PlayMethod);
}
private static StreamInfo? BuildFor(string container)
{
var mediaSource = new MediaSourceInfo
{
Id = "test-source",
Path = "http://example.com/live/channel",
Protocol = MediaProtocol.Http,
Container = container,
SupportsDirectPlay = true,
SupportsDirectStream = true,
SupportsTranscoding = true,
IsInfiniteStream = true,
IsRemote = true,
MediaStreams =
[
new MediaStream { Type = MediaStreamType.Video, Index = 0, Codec = "h264" },
new MediaStream { Type = MediaStreamType.Audio, Index = 1, Codec = "aac" }
]
};
var profile = new DeviceProfile
{
Name = "Manifest aware client",
DirectPlayProfiles =
[
new DirectPlayProfile
{
Type = DlnaProfileType.Video,
Container = "mp4,hls,applehttp,dash",
VideoCodec = "h264",
AudioCodec = "aac"
}
],
TranscodingProfiles =
[
new TranscodingProfile
{
Type = DlnaProfileType.Video,
Context = EncodingContext.Streaming,
Protocol = MediaStreamProtocol.hls,
Container = "ts",
VideoCodec = "h264",
AudioCodec = "aac"
}
]
};
var options = new MediaOptions
{
ItemId = new Guid("11D229B7-2D48-4B95-9F9B-49F6AB75E613"),
MediaSourceId = mediaSource.Id,
MediaSources = [mediaSource],
DeviceId = "test-deviceId",
Profile = profile,
AllowAudioStreamCopy = true,
AllowVideoStreamCopy = true,
EnableDirectStream = false // This is disabled in server
};
var transcodeSupport = new Mock<ITranscoderSupport>();
return new StreamBuilder(transcodeSupport.Object, new NullLogger<StreamBuilderManifestContainerTests>())
.GetOptimalVideoStream(options);
}
}
@@ -179,6 +179,146 @@ public class MediaInfoResolverTests
Assert.Empty(streams);
}
[Fact]
public void GetExternalFiles_VobSubIdxAndSubPair_OnlyReturnsIdxFile()
{
// VobSub (.sub) payloads only carry per-track language metadata when read
// alongside their paired .idx index file. When both are present, only the
// .idx file should be returned so it (not the raw .sub) gets probed.
BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
var video = new Movie
{
Path = VideoDirectoryPath + "/My.Video.mkv"
};
var directoryService = new Mock<IDirectoryService>(MockBehavior.Strict);
directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny<bool>()))
.Returns(new[] { VideoDirectoryPath + "/My.Video.idx", VideoDirectoryPath + "/My.Video.sub" });
directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny<bool>()))
.Returns(Array.Empty<string>());
var streams = _subtitleResolver.GetExternalFiles(video, directoryService.Object, false).ToList();
var stream = Assert.Single(streams);
Assert.EndsWith(".idx", stream.Path, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void GetExternalFiles_VobSubIdxWithoutMatchingSub_DoesNotReturnIdxFile()
{
// An .idx file with no paired .sub cannot be probed for anything, so it must be
// left out entirely rather than surfaced as a doomed-to-fail probe candidate.
// Surfacing it anyway would also make it "exist" from Jellyfin's perspective
// even after the real .sub is deleted, preventing stale subtitle stream data
// from ever being cleared on a rescan.
BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
var video = new Movie
{
Path = VideoDirectoryPath + "/My.Video.mkv"
};
var directoryService = GetDirectoryServiceForExternalFile("My.Video.idx");
var streams = _subtitleResolver.GetExternalFiles(video, directoryService, false).ToList();
Assert.Empty(streams);
}
[Fact]
public void GetExternalFiles_StandaloneSubWithoutIdx_StillReturnsSubFile()
{
// Guards against the .idx/.sub pairing suppression firing when there is no
// .idx sidecar at all - a lone .sub file must still be returned.
BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
var video = new Movie
{
Path = VideoDirectoryPath + "/My.Video.mkv"
};
var directoryService = GetDirectoryServiceForExternalFile("My.Video.sub");
var streams = _subtitleResolver.GetExternalFiles(video, directoryService, false).ToList();
var stream = Assert.Single(streams);
Assert.EndsWith(".sub", stream.Path, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void GetExternalFiles_VobSubIdxAndSubInDifferentDirectories_DoesNotPair()
{
// A same-named .idx and .sub split across the video folder and the internal
// metadata folder cannot be paired by ffprobe (it only looks next to the .idx),
// so the .sub must still be returned, but the orphaned .idx (no sibling .sub in
// its own directory) must be left out since it cannot be probed.
BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
var video = new Movie
{
Path = VideoDirectoryPath + "/My.Video.mkv"
};
var directoryService = new Mock<IDirectoryService>(MockBehavior.Strict);
directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny<bool>()))
.Returns(new[] { VideoDirectoryPath + "/My.Video.sub" });
directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny<bool>()))
.Returns(new[] { MetadataDirectoryPath + "/My.Video.idx" });
var streams = _subtitleResolver.GetExternalFiles(video, directoryService.Object, false).ToList();
var stream = Assert.Single(streams);
Assert.EndsWith(".sub", stream.Path, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void GetExternalFiles_VobSubIdxAndSubWithMatchingLanguageFlag_SuppressesSub()
{
// A .idx/.sub pair sharing the same filename flags (e.g. a language token) should
// still pair and suppress the .sub, just like an unflagged pair.
BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
var video = new Movie
{
Path = VideoDirectoryPath + "/My.Video.mkv"
};
var directoryService = new Mock<IDirectoryService>(MockBehavior.Strict);
directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny<bool>()))
.Returns(new[] { VideoDirectoryPath + "/My.Video.en.idx", VideoDirectoryPath + "/My.Video.en.sub" });
directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny<bool>()))
.Returns(Array.Empty<string>());
var streams = _subtitleResolver.GetExternalFiles(video, directoryService.Object, false).ToList();
var stream = Assert.Single(streams);
Assert.EndsWith(".idx", stream.Path, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void GetExternalFiles_VobSubIdxAndSubWithMismatchedNames_DoesNotPair()
{
// An .idx and .sub with different basenames (e.g. differing filename flags) are not
// a pair ffprobe would resolve. The .sub must still be returned, but the orphaned
// .idx (no same-named sibling .sub) must be left out since it cannot be probed.
BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
var video = new Movie
{
Path = VideoDirectoryPath + "/My.Video.mkv"
};
var directoryService = new Mock<IDirectoryService>(MockBehavior.Strict);
directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny<bool>()))
.Returns(new[] { VideoDirectoryPath + "/My.Video.idx", VideoDirectoryPath + "/My.Video.en.sub" });
directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny<bool>()))
.Returns(Array.Empty<string>());
var streams = _subtitleResolver.GetExternalFiles(video, directoryService.Object, false).ToList();
var stream = Assert.Single(streams);
Assert.EndsWith(".sub", stream.Path, StringComparison.OrdinalIgnoreCase);
}
[Theory]
[InlineData("https://url.com/My.Video.mkv")]
[InlineData(VideoDirectoryPath)] // valid but no files found for this test
@@ -0,0 +1,123 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Emby.Server.Implementations.EntryPoints;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace Jellyfin.Server.Implementations.Tests.EntryPoints;
public class LibraryChangedNotifierTests
{
// How long a test waits for the notifier's timer callback to run. Generous: the assertions are
// about a batch being sent at all, not about how promptly.
private static readonly TimeSpan _flushTimeout = TimeSpan.FromSeconds(15);
private readonly Mock<ILibraryManager> _libraryManager = new();
private readonly Mock<IServerConfigurationManager> _configurationManager = new();
private readonly Mock<ISessionManager> _sessionManager = new();
private readonly Mock<IUserManager> _userManager = new();
private readonly Mock<IProviderManager> _providerManager = new();
private readonly ServerConfiguration _configuration = new();
private int _flushCount;
public LibraryChangedNotifierTests()
{
_configurationManager.SetupGet(e => e.Configuration).Returns(_configuration);
// Reading the session list is the first thing a flush does, so it stands in for "a batch was
// sent" without having to mock a whole user library behind it.
_sessionManager.SetupGet(e => e.Sessions)
.Returns(() =>
{
Interlocked.Increment(ref _flushCount);
return [];
});
}
[Fact]
public async Task OnLibraryItemUpdated_BatchSizeCapReached_SendsWithoutWaitingForWindow()
{
// Long enough that only the size cap can close the batch.
_configuration.LibraryUpdateDuration = 3600;
var notifier = CreateNotifier();
await notifier.StartAsync(TestContext.Current.CancellationToken);
for (var i = 0; i < LibraryChangedNotifier.MaxBatchSize; i++)
{
RaiseItemUpdated();
}
Assert.True(await WaitForFlushAsync(1), "The batch was not sent once it hit the size cap.");
await notifier.StopAsync(TestContext.Current.CancellationToken);
notifier.Dispose();
}
[Fact]
public async Task OnLibraryItemUpdated_ChangesNeverPause_StillSendsOnTheWindow()
{
// A scan changes items continuously. The window must run from the first change of a batch, or
// the batch never closes and holds every item it named alive for the length of the scan.
_configuration.LibraryUpdateDuration = 1;
var notifier = CreateNotifier();
await notifier.StartAsync(TestContext.Current.CancellationToken);
var stopwatch = Stopwatch.StartNew();
while (stopwatch.Elapsed < _flushTimeout && Volatile.Read(ref _flushCount) == 0)
{
// Well below the window, and well below the size cap over the whole loop.
RaiseItemUpdated();
await Task.Delay(25, TestContext.Current.CancellationToken);
}
Assert.True(Volatile.Read(ref _flushCount) > 0, "The batch was never sent while changes kept arriving.");
await notifier.StopAsync(TestContext.Current.CancellationToken);
notifier.Dispose();
}
private LibraryChangedNotifier CreateNotifier()
=> new(
_libraryManager.Object,
_configurationManager.Object,
_sessionManager.Object,
_userManager.Object,
NullLogger<LibraryChangedNotifier>.Instance,
_providerManager.Object);
// A folder passes the notifier's item filter without needing any of BaseItem's static services.
private void RaiseItemUpdated()
=> _libraryManager.Raise(
e => e.ItemUpdated += null,
_libraryManager.Object,
new ItemChangeEventArgs { Item = new Folder { Id = Guid.NewGuid() } });
private async Task<bool> WaitForFlushAsync(int expected)
{
var stopwatch = Stopwatch.StartNew();
while (stopwatch.Elapsed < _flushTimeout)
{
if (Volatile.Read(ref _flushCount) >= expected)
{
return true;
}
await Task.Delay(25, TestContext.Current.CancellationToken);
}
return false;
}
}
@@ -0,0 +1,78 @@
using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Emby.Server.Implementations.EntryPoints;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Session;
using Moq;
using Xunit;
namespace Jellyfin.Server.Implementations.Tests.EntryPoints;
public class UserDataChangeNotifierTests
{
// How long a test waits for the notifier's timer callback to run. Generous: the assertions are
// about a batch being sent at all, not about how promptly.
private static readonly TimeSpan _flushTimeout = TimeSpan.FromSeconds(15);
private readonly Mock<IUserDataManager> _userDataManager = new();
private readonly Mock<ISessionManager> _sessionManager = new();
private readonly Mock<IUserManager> _userManager = new();
private int _flushCount;
public UserDataChangeNotifierTests()
{
_sessionManager
.Setup(e => e.SendMessageToUserSessions(
It.IsAny<System.Collections.Generic.List<Guid>>(),
SessionMessageType.UserDataChanged,
It.IsAny<Func<UserDataChangeInfo>>(),
It.IsAny<CancellationToken>()))
.Callback(() => Interlocked.Increment(ref _flushCount))
.Returns(Task.CompletedTask);
}
[Fact]
public async Task OnUserDataSaved_ChangesNeverPause_StillSendsOnTheWindow()
{
// A scan changes user data continuously. The window must run from the first change of a batch,
// or the batch never closes and holds every item it named alive for the length of the scan.
var notifier = CreateNotifier();
await notifier.StartAsync(TestContext.Current.CancellationToken);
var userId = Guid.NewGuid();
var stopwatch = Stopwatch.StartNew();
while (stopwatch.Elapsed < _flushTimeout && Volatile.Read(ref _flushCount) == 0)
{
// Well below the window, and well below the size cap over the whole loop.
RaiseUserDataSaved(userId);
await Task.Delay(25, TestContext.Current.CancellationToken);
}
Assert.True(Volatile.Read(ref _flushCount) > 0, "The batch was never sent while changes kept arriving.");
await notifier.StopAsync(TestContext.Current.CancellationToken);
notifier.Dispose();
}
private UserDataChangeNotifier CreateNotifier()
=> new(_userDataManager.Object, _sessionManager.Object, _userManager.Object);
// A folder needs none of BaseItem's static services, and PlaybackProgress is the one reason the
// notifier ignores outright.
private void RaiseUserDataSaved(Guid userId)
=> _userDataManager.Raise(
e => e.UserDataSaved += null,
_userDataManager.Object,
new UserDataSaveEventArgs
{
UserId = userId,
SaveReason = UserDataSaveReason.UpdateUserRating,
Item = new Folder { Id = Guid.NewGuid() }
});
}
@@ -0,0 +1,77 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Server.Implementations.Item;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Model.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace Jellyfin.Server.Implementations.Tests.Item;
public class ItemPersistenceServiceSaveImagesTests : SqliteDbTestFixture
{
private readonly ItemPersistenceService _service;
public ItemPersistenceServiceSaveImagesTests()
{
_service = new ItemPersistenceService(
CreateDbContextFactory(),
Mock.Of<IServerApplicationHost>(),
NullLogger<ItemPersistenceService>.Instance);
}
[Fact]
public async Task SaveImagesAsync_ReplacesThePreviousImages()
{
var itemId = Guid.NewGuid();
Seed(itemId);
await _service.SaveImagesAsync(CreateItem(itemId, "/first.jpg"), TestContext.Current.CancellationToken);
await _service.SaveImagesAsync(CreateItem(itemId, "/second.jpg"), TestContext.Current.CancellationToken);
using var context = CreateDbContext();
var paths = context.BaseItemImageInfos
.Where(e => e.ItemId.Equals(itemId))
.Select(e => e.Path)
.ToList();
Assert.Equal(["/second.jpg"], paths);
}
[Fact]
public async Task SaveImagesAsync_ItemDeletedFromUnderIt_IsANoOp()
{
// A scan can delete the item between the refresh reading it and the images being written. That
// must not fail the whole refresh, and must not leave the images of an item that is gone.
var itemId = Guid.NewGuid();
await _service.SaveImagesAsync(CreateItem(itemId, "/gone.jpg"), TestContext.Current.CancellationToken);
using var context = CreateDbContext();
Assert.Empty(context.BaseItemImageInfos.Where(e => e.ItemId.Equals(itemId)));
}
private static BaseItem CreateItem(Guid itemId, string imagePath)
=> new Folder
{
Id = itemId,
ImageInfos = [new ItemImageInfo { Path = imagePath, Type = ImageType.Primary }]
};
private void Seed(Guid itemId)
{
using var context = CreateDbContext();
context.BaseItems.Add(new BaseItemEntity
{
Id = itemId,
Type = "Folder",
IsFolder = true
});
context.SaveChanges();
}
}
@@ -1,4 +1,5 @@
using System;
using System.Threading;
using Emby.Server.Implementations.Data;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Locking;
@@ -58,6 +59,8 @@ public abstract class SqliteDbTestFixture : IDisposable
{
var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(CreateDbContext);
return factory.Object;
}
@@ -142,6 +142,36 @@ public class PlayQueueManagerTests
Assert.Equal(Guid.Empty, queue.GetPlayingItemPlaylistId());
}
[Fact]
public void SetShuffleMode_SortedWhileAlreadySorted_KeepsPlayingItem()
{
var queue = CreateQueue(3);
queue.SetPlayingItemByIndex(1);
var expectedItemId = queue.GetPlayingItemId();
queue.SetShuffleMode(GroupShuffleMode.Sorted);
Assert.Equal(GroupShuffleMode.Sorted, queue.ShuffleMode);
Assert.Equal(1, queue.PlayingItemIndex);
Assert.Equal(expectedItemId, queue.GetPlayingItemId());
}
[Fact]
public void SetShuffleMode_SortedTwiceAfterShuffle_KeepsPlayingItem()
{
var queue = CreateQueue(5);
queue.SetPlayingItemByIndex(2);
var expectedItemId = queue.GetPlayingItemId();
queue.SetShuffleMode(GroupShuffleMode.Shuffle);
queue.SetShuffleMode(GroupShuffleMode.Sorted);
queue.SetShuffleMode(GroupShuffleMode.Sorted);
Assert.Equal(GroupShuffleMode.Sorted, queue.ShuffleMode);
Assert.Equal(5, queue.GetPlaylist().Count);
Assert.Equal(expectedItemId, queue.GetPlayingItemId());
}
[Fact]
public void SetPlayingItemByIndex_InBounds_SetsPlayingItem()
{
@@ -0,0 +1,95 @@
using System;
using System.Threading;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Session;
using MediaBrowser.Controller.SyncPlay.Requests;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
using SyncPlayManager = Emby.Server.Implementations.SyncPlay.SyncPlayManager;
namespace Jellyfin.Server.Implementations.Tests.SyncPlay;
public class SyncPlayManagerTests
{
[Fact]
public void LeaveGroup_AfterJoiningTheSameGroupTwice_ClearsTheActiveSessionCounter()
{
var harness = new ManagerHarness();
var info = harness.Manager.NewGroup(harness.Session, new NewGroupRequest("group"), CancellationToken.None);
Assert.True(harness.Manager.IsUserActive(harness.User.Id));
// A client that re-sends Join for the group it is already in must not be counted twice.
harness.Manager.JoinGroup(harness.Session, new JoinGroupRequest(info.GroupId), CancellationToken.None);
harness.Manager.LeaveGroup(harness.Session, new LeaveGroupRequest(), CancellationToken.None);
Assert.False(harness.Manager.IsUserActive(harness.User.Id));
}
[Fact]
public void LeaveGroup_AfterASingleJoin_ClearsTheActiveSessionCounter()
{
var harness = new ManagerHarness();
harness.Manager.NewGroup(harness.Session, new NewGroupRequest("group"), CancellationToken.None);
harness.Manager.LeaveGroup(harness.Session, new LeaveGroupRequest(), CancellationToken.None);
Assert.False(harness.Manager.IsUserActive(harness.User.Id));
}
[Fact]
public void IsUserActive_WithTwoSessionsOfTheSameUser_TracksBothSeparately()
{
var harness = new ManagerHarness();
var second = harness.CreateSession("session-2");
var info = harness.Manager.NewGroup(harness.Session, new NewGroupRequest("group"), CancellationToken.None);
harness.Manager.JoinGroup(second, new JoinGroupRequest(info.GroupId), CancellationToken.None);
harness.Manager.LeaveGroup(harness.Session, new LeaveGroupRequest(), CancellationToken.None);
Assert.True(harness.Manager.IsUserActive(harness.User.Id));
harness.Manager.LeaveGroup(second, new LeaveGroupRequest(), CancellationToken.None);
Assert.False(harness.Manager.IsUserActive(harness.User.Id));
}
private sealed class ManagerHarness
{
private readonly Mock<ISessionManager> _sessionManager = new();
public ManagerHarness()
{
var userManager = new Mock<IUserManager>();
var libraryManager = new Mock<ILibraryManager>();
User = new User("tester", "auth-provider", "pwdreset-provider");
userManager.Setup(m => m.GetUserById(It.IsAny<Guid>())).Returns(User);
Manager = new SyncPlayManager(
NullLoggerFactory.Instance,
userManager.Object,
_sessionManager.Object,
libraryManager.Object);
Session = CreateSession("session-1");
}
public SyncPlayManager Manager { get; }
public User User { get; }
public SessionInfo Session { get; }
public SessionInfo CreateSession(string id)
{
return new SessionInfo(_sessionManager.Object, NullLogger.Instance)
{
Id = id,
UserId = User.Id,
UserName = User.Username
};
}
}
}
@@ -0,0 +1,141 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Session;
using MediaBrowser.Controller.SyncPlay.GroupStates;
using MediaBrowser.Controller.SyncPlay.PlaybackRequests;
using MediaBrowser.Controller.SyncPlay.Requests;
using MediaBrowser.Model.SyncPlay;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
using SyncPlayGroup = Emby.Server.Implementations.SyncPlay.Group;
namespace Jellyfin.Server.Implementations.Tests.SyncPlay;
public class WaitingGroupStateTests
{
[Fact]
public void Ready_ClientResumedWithLowPing_AppliesTheDefaultPingFloorInMilliseconds()
{
var harness = new GroupHarness();
var group = harness.Group;
// Both members report a ping well under the default, so the floor is what decides the delay.
group.UpdatePing(harness.First, 10);
group.UpdatePing(harness.Second, 10);
group.PositionTicks = TimeSpan.FromMinutes(5).Ticks;
group.LastActivity = DateTime.UtcNow;
group.SetBuffering(harness.First, true);
group.SetBuffering(harness.Second, false);
var state = new WaitingGroupState(NullLoggerFactory.Instance) { ResumePlaying = true };
var before = DateTime.UtcNow;
state.HandleRequest(
new ReadyGroupRequest(DateTime.UtcNow, group.PositionTicks, true, harness.PlaylistItemId),
group,
GroupStateType.Waiting,
harness.First,
CancellationToken.None);
// DefaultPing is expressed in milliseconds, so the floor must be converted before being
// compared against a tick count. Without the conversion the floor is 500 ticks (0.05 ms)
// and never applies.
var scheduledDelay = group.LastActivity - before;
Assert.True(
scheduledDelay >= TimeSpan.FromMilliseconds(group.DefaultPing),
$"expected a resume delay of at least {group.DefaultPing} ms, got {scheduledDelay.TotalMilliseconds} ms");
}
[Theory]
[InlineData(4_000_000_000L)]
[InlineData(1_000_000_000_000_000L)]
[InlineData(long.MaxValue)]
[InlineData(-1L)]
public void UpdatePing_ClientReportsAnUnusablePing_IsClampedAndCannotStallTheGroup(long reportedPing)
{
var harness = new GroupHarness();
var group = harness.Group;
group.UpdatePing(harness.First, reportedPing);
Assert.InRange(group.GetHighestPing(), 0, group.MaxPing);
// The reported ping is scaled into the group's resume point, so an unclamped value either
// pushes playback months out or overflows the arithmetic outright.
var state = new PlayingGroupState(NullLoggerFactory.Instance);
var before = DateTime.UtcNow;
state.HandleRequest(
new UnpauseGroupRequest(),
group,
GroupStateType.Paused,
harness.First,
CancellationToken.None);
Assert.InRange(group.LastActivity - before, TimeSpan.Zero, TimeSpan.FromMinutes(1));
}
private sealed class GroupHarness
{
public GroupHarness()
{
var userManager = new Mock<IUserManager>();
var sessionManager = new Mock<ISessionManager>();
var libraryManager = new Mock<ILibraryManager>();
var user = new User("tester", "auth-provider", "pwdreset-provider");
userManager.Setup(m => m.GetUserById(It.IsAny<Guid>())).Returns(user);
var item = new Mock<BaseItem>();
item.Setup(i => i.IsVisibleStandalone(It.IsAny<User>())).Returns(true);
item.Object.RunTimeTicks = TimeSpan.FromHours(2).Ticks;
libraryManager.Setup(m => m.GetItemById(It.IsAny<Guid>())).Returns(item.Object);
sessionManager
.Setup(m => m.SendSyncPlayCommand(It.IsAny<string>(), It.IsAny<SendCommand>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
sessionManager
.Setup(m => m.SendSyncPlayGroupUpdate(It.IsAny<string>(), It.IsAny<GroupUpdate<GroupStateUpdate>>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
Group = new SyncPlayGroup(
NullLoggerFactory.Instance,
userManager.Object,
sessionManager.Object,
libraryManager.Object);
First = new SessionInfo(sessionManager.Object, NullLogger.Instance)
{
Id = "first",
UserId = user.Id,
UserName = "first"
};
Second = new SessionInfo(sessionManager.Object, NullLogger.Instance)
{
Id = "second",
UserId = user.Id,
UserName = "second"
};
Group.CreateGroup(First, new NewGroupRequest("group"), CancellationToken.None);
Group.SessionJoin(Second, new JoinGroupRequest(Group.GroupId), CancellationToken.None);
Group.SetPlayQueue(new List<Guid> { Guid.NewGuid() }, 0, 0);
PlaylistItemId = Group.PlayQueue.GetPlayingItemPlaylistId();
}
public SyncPlayGroup Group { get; }
public SessionInfo First { get; }
public SessionInfo Second { get; }
public Guid PlaylistItemId { get; }
}
}