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;