diff --git a/ErsatzTV.Application/ErsatzTV.Application.csproj b/ErsatzTV.Application/ErsatzTV.Application.csproj index cdbbf65b0..00f38e17b 100644 --- a/ErsatzTV.Application/ErsatzTV.Application.csproj +++ b/ErsatzTV.Application/ErsatzTV.Application.csproj @@ -7,6 +7,7 @@ latest-Recommended true Debug;Release;Debug No Sync + true diff --git a/ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs b/ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs index 95c14f813..db445a3e7 100644 --- a/ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs +++ b/ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs @@ -325,6 +325,8 @@ public class BuildPlayoutHandler : IRequestHandler dbContextFactory, + ILogger logger) + : IRequestHandler +{ + [LibraryImport("libc", EntryPoint = "rename", SetLastError = true)] + private static partial int Rename( + [MarshalAs(UnmanagedType.LPUTF8Str)] + string oldpath, + [MarshalAs(UnmanagedType.LPUTF8Str)] + string newpath + ); + + public async Task Handle(SyncNextPlayout request, CancellationToken cancellationToken) + { + // TODO: NEXT: support junctions on Windows + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return; + } + + // gen new folder name + string versionFolderName = DateTimeOffset.Now.ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture); + + string versionFolder = fileSystem.Path.Combine( + FileSystemLayout.NextPlayoutsFolder, + request.ChannelNumber, + versionFolderName); + + logger.LogDebug("versioned playout folder is {Folder}", versionFolder); + + localFileSystem.EnsureFolderExists(versionFolder); + + await WriteAllJsonTo(request.ChannelNumber, versionFolder, cancellationToken); + + string currentFolder = fileSystem.Path.Combine( + FileSystemLayout.NextPlayoutsFolder, + request.ChannelNumber, + "current"); + + // re-point symlink/junction to new folder + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + } + else + { + string tempLink = fileSystem.Path.Combine( + FileSystemLayout.NextPlayoutsFolder, + request.ChannelNumber, + fileSystem.Path.GetRandomFileName()); + + fileSystem.File.CreateSymbolicLink(tempLink, versionFolderName); + _ = Rename(tempLink, currentFolder); + } + + CleanOldVersions( + fileSystem.Path.Combine(FileSystemLayout.NextPlayoutsFolder, request.ChannelNumber), + currentFolder); + } + + private async Task WriteAllJsonTo(string channelNumber, string targetFolder, CancellationToken cancellationToken) + { + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + + List localLibraryIds = await dbContext.LocalLibraries + .AsNoTracking() + .Map(l => l.Id) + .ToListAsync(cancellationToken); + + List playoutItems = await dbContext.PlayoutItems + .AsNoTracking() + .Where(i => i.Playout.Channel.Number == channelNumber) + .Where(i => localLibraryIds.Contains(i.MediaItem.LibraryPath.LibraryId)) + .Include(i => i.MediaItem) + .ThenInclude(i => (i as Episode).MediaVersions) + .ThenInclude(mv => mv.MediaFiles) + .Include(i => i.MediaItem) + .ThenInclude(i => (i as Movie).MediaVersions) + .ThenInclude(mv => mv.MediaFiles) + .Include(i => i.MediaItem) + .ThenInclude(i => (i as OtherVideo).MediaVersions) + .ThenInclude(mv => mv.MediaFiles) + .Include(i => i.MediaItem) + .ThenInclude(i => (i as MusicVideo).MediaVersions) + .ThenInclude(mv => mv.MediaFiles) + .ToListAsync(cancellationToken); + + logger.LogDebug("Located {Count} local playout items", playoutItems.Count); + + foreach (IGrouping group in playoutItems.GroupBy(pi => pi.StartOffset.Date) + .Where(g => g.Any())) + { + var first = group.First(); + var last = group.Last(); + + string fileName = fileSystem.Path.Combine( + targetFolder, + $"{first.StartOffset.ToUnixTimeMilliseconds()}_{last.FinishOffset.ToUnixTimeMilliseconds()}.json"); + + var playout = new Core.Next.Playout { Version = "https://ersatztv.org/playout/version/0.0.1", Items = [] }; + foreach (PlayoutItem playoutItem in group) + { + if (playoutItem.MediaItem is not Episode && playoutItem.MediaItem is not Movie && + playoutItem.MediaItem is not OtherVideo && playoutItem.MediaItem is not MusicVideo) + { + continue; + } + + string path = playoutItem.MediaItem.GetHeadVersion().MediaFiles.Head().Path; + + var nextPlayoutItem = new ItemElement + { + Id = playoutItem.Id.ToString(CultureInfo.InvariantCulture), + Start = playoutItem.StartOffset.ToString("O"), + Finish = playoutItem.FinishOffset.ToString("O"), + Source = new ItemSource + { + SourceType = SourceType.Local, + Path = path, + } + }; + + playout.Items.Add(nextPlayoutItem); + } + + await fileSystem.File.WriteAllTextAsync(fileName, playout.ToJson(), cancellationToken); + } + } + + public void CleanOldVersions( + string playoutRoot, + string currentLinkPath, + int keepVersions = 2, + TimeSpan? gracePeriod = null) + { + gracePeriod ??= TimeSpan.FromMinutes(5); + + string currentResolvedPath = null; + if (Directory.Exists(currentLinkPath)) + { + currentResolvedPath = Path.GetFullPath( + Path.Combine( + Path.GetDirectoryName(currentLinkPath) ?? "", + Directory.ResolveLinkTarget(currentLinkPath, true)?.FullName ?? "" + )); + } + + var directories = Directory.GetDirectories(playoutRoot) + .Select(d => new DirectoryInfo(d)) + .Where(d => long.TryParse(d.Name, out _)) + .OrderByDescending(d => d.Name) + .ToList(); + + int keptCount = 0; + + foreach (var dir in directories) + { + string fullDir = dir.FullName; + + if (fullDir.Equals(currentResolvedPath, StringComparison.OrdinalIgnoreCase)) + { + keptCount++; + continue; + } + + if (keptCount < keepVersions) + { + keptCount++; + continue; + } + + if (DateTime.Now - dir.LastWriteTime < gracePeriod) + { + continue; + } + + try + { + dir.Delete(recursive: true); + logger.LogDebug("Cleaned up old playout version: {Folder}", dir.Name); + } + catch (IOException) + { + // ignore errors; will be cleaned up next time through + logger.LogDebug("Skipping busy folder: {Folder}", dir.Name); + } + } + } +} diff --git a/ErsatzTV.Application/Streaming/Commands/StartFFmpegNextSessionHandler.cs b/ErsatzTV.Application/Streaming/Commands/StartFFmpegNextSessionHandler.cs index e401f6e8f..913fb1c97 100644 --- a/ErsatzTV.Application/Streaming/Commands/StartFFmpegNextSessionHandler.cs +++ b/ErsatzTV.Application/Streaming/Commands/StartFFmpegNextSessionHandler.cs @@ -347,7 +347,7 @@ public class StartFFmpegNextSessionHandler( } }; - string playoutFolder = fileSystem.Path.Combine(FileSystemLayout.NextPlayoutsFolder, channelNumber); + string playoutFolder = fileSystem.Path.Combine(FileSystemLayout.NextPlayoutsFolder, channelNumber, "current"); return new ChannelConfig { diff --git a/ErsatzTV.Core/Next/Playout.cs b/ErsatzTV.Core/Next/Playout.cs index 0642b33f2..44680c975 100644 --- a/ErsatzTV.Core/Next/Playout.cs +++ b/ErsatzTV.Core/Next/Playout.cs @@ -201,6 +201,7 @@ namespace ErsatzTV.Core.Next { public static readonly JsonSerializerSettings Settings = new JsonSerializerSettings { + NullValueHandling = NullValueHandling.Ignore, MetadataPropertyHandling = MetadataPropertyHandling.Ignore, DateParseHandling = DateParseHandling.None, Converters = diff --git a/ErsatzTV/Services/WorkerService.cs b/ErsatzTV/Services/WorkerService.cs index 5e3ebfd00..03d15d26d 100644 --- a/ErsatzTV/Services/WorkerService.cs +++ b/ErsatzTV/Services/WorkerService.cs @@ -81,6 +81,9 @@ public class WorkerService : BackgroundService error.Value)); break; } + case SyncNextPlayout syncNextPlayout: + await mediator.Send(syncNextPlayout, stoppingToken); + break; case CheckForOverlappingPlayoutItems checkForOverlappingPlayoutItems: await mediator.Send(checkForOverlappingPlayoutItems, stoppingToken); break;