Initial commit
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
namespace ErsatzTV.Core.AggregateModels
|
||||
{
|
||||
public record GenericIntegerId(int Id);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace ErsatzTV.Core.AggregateModels
|
||||
{
|
||||
public record MediaCollectionSummary(int Id, string Name, int ItemCount, bool IsSimple);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core
|
||||
{
|
||||
public class BaseError : NewType<BaseError, string>
|
||||
{
|
||||
public BaseError(string value) : base(value)
|
||||
{
|
||||
}
|
||||
|
||||
public static implicit operator BaseError(string str) => New(str);
|
||||
}
|
||||
|
||||
public static class ErrorExtensions
|
||||
{
|
||||
public static BaseError Join(this Seq<BaseError> errors) => string.Join("; ", errors);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class Channel
|
||||
{
|
||||
public Channel(Guid uniqueId) => UniqueId = uniqueId;
|
||||
|
||||
public int Id { get; set; }
|
||||
public Guid UniqueId { get; init; }
|
||||
public int Number { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string Logo { get; set; }
|
||||
public int FFmpegProfileId { get; set; }
|
||||
public FFmpegProfile FFmpegProfile { get; set; }
|
||||
public StreamingMode StreamingMode { get; set; }
|
||||
|
||||
public List<Playout> Playouts { get; set; }
|
||||
// public SourceMode Mode { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class ConfigElement
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Key { get; set; }
|
||||
public string Value { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class ConfigElementKey
|
||||
{
|
||||
private ConfigElementKey(string key) => Key = key;
|
||||
|
||||
public string Key { get; }
|
||||
|
||||
public static ConfigElementKey FFmpegPath => new("ffmpeg.ffmpeg_path");
|
||||
public static ConfigElementKey FFprobePath => new("ffmpeg.ffprobe_path");
|
||||
public static ConfigElementKey FFmpegDefaultProfileId => new("ffmpeg.default_profile_id");
|
||||
public static ConfigElementKey FFmpegDefaultResolutionId => new("ffmpeg.default_resolution_id");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public record FFmpegProfile
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
public int ThreadCount { get; set; }
|
||||
public bool Transcode { get; set; }
|
||||
public int ResolutionId { get; set; }
|
||||
public Resolution Resolution { get; set; }
|
||||
public bool NormalizeResolution { get; set; }
|
||||
public string VideoCodec { get; set; }
|
||||
public bool NormalizeVideoCodec { get; set; }
|
||||
public int VideoBitrate { get; set; }
|
||||
public int VideoBufferSize { get; set; }
|
||||
public string AudioCodec { get; set; }
|
||||
public bool NormalizeAudioCodec { get; set; }
|
||||
public int AudioBitrate { get; set; }
|
||||
public int AudioBufferSize { get; set; }
|
||||
public int AudioVolume { get; set; }
|
||||
public int AudioChannels { get; set; }
|
||||
public int AudioSampleRate { get; set; }
|
||||
public bool NormalizeAudio { get; set; }
|
||||
|
||||
public static FFmpegProfile New(string name, Resolution resolution) =>
|
||||
new()
|
||||
{
|
||||
Name = name,
|
||||
ThreadCount = 4,
|
||||
Transcode = true,
|
||||
ResolutionId = resolution.Id,
|
||||
Resolution = resolution,
|
||||
VideoCodec = "libx264",
|
||||
AudioCodec = "ac3",
|
||||
VideoBitrate = 2000,
|
||||
VideoBufferSize = 2000,
|
||||
AudioBitrate = 192,
|
||||
AudioBufferSize = 50,
|
||||
AudioVolume = 100,
|
||||
AudioChannels = 2,
|
||||
AudioSampleRate = 48,
|
||||
NormalizeResolution = true,
|
||||
NormalizeVideoCodec = true,
|
||||
NormalizeAudioCodec = true,
|
||||
NormalizeAudio = true
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class LocalMediaSource : MediaSource
|
||||
{
|
||||
public LocalMediaSource() => SourceType = MediaSourceType.Local;
|
||||
|
||||
public MediaType MediaType { get; set; }
|
||||
public string Folder { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public abstract class MediaCollection : Record<MediaCollection>
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class MediaCollectionEnumeratorState
|
||||
{
|
||||
public int Seed { get; set; }
|
||||
public int Index { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class MediaItem
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int MediaSourceId { get; set; }
|
||||
public MediaSource Source { get; set; }
|
||||
public string Path { get; set; }
|
||||
public MediaMetadata Metadata { get; set; }
|
||||
public DateTime? LastWriteTime { get; set; }
|
||||
public IList<SimpleMediaCollection> SimpleMediaCollections { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public record MediaMetadata : IDisplaySize
|
||||
{
|
||||
public TimeSpan Duration { get; set; }
|
||||
public string SampleAspectRatio { get; set; }
|
||||
public string DisplayAspectRatio { get; set; }
|
||||
public string VideoCodec { get; set; }
|
||||
public string AudioCodec { get; set; }
|
||||
public MediaType MediaType { get; set; }
|
||||
public string Title { get; set; }
|
||||
public string Subtitle { get; set; }
|
||||
public string Description { get; set; }
|
||||
public int? SeasonNumber { get; set; }
|
||||
public int? EpisodeNumber { get; set; }
|
||||
public string ContentRating { get; set; }
|
||||
public DateTime? Aired { get; set; }
|
||||
public VideoScanType VideoScanType { get; set; }
|
||||
public int Width { get; set; }
|
||||
public int Height { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public abstract class MediaSource
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public MediaSourceType SourceType { get; set; }
|
||||
public string Name { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public enum MediaSourceType
|
||||
{
|
||||
None = 0,
|
||||
Local = 1,
|
||||
Plex = 2,
|
||||
Jellyfin = 3
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public enum MediaType
|
||||
{
|
||||
Other = 0,
|
||||
TvShow = 1,
|
||||
Movie = 2
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public enum PlaybackOrder
|
||||
{
|
||||
Chronological = 1,
|
||||
Random = 2,
|
||||
Shuffle = 3
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class Playout
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int ChannelId { get; set; }
|
||||
public Channel Channel { get; set; }
|
||||
public int ProgramScheduleId { get; set; }
|
||||
public ProgramSchedule ProgramSchedule { get; set; }
|
||||
public ProgramSchedulePlayoutType ProgramSchedulePlayoutType { get; set; }
|
||||
public List<PlayoutItem> Items { get; set; }
|
||||
public PlayoutAnchor Anchor { get; set; }
|
||||
public List<PlayoutProgramScheduleAnchor> ProgramScheduleAnchors { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using System;
|
||||
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class PlayoutAnchor
|
||||
{
|
||||
public int NextScheduleItemId { get; set; }
|
||||
|
||||
public ProgramScheduleItem NextScheduleItem { get; set; }
|
||||
|
||||
public DateTimeOffset NextStart { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System;
|
||||
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class PlayoutItem
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int MediaItemId { get; set; }
|
||||
public MediaItem MediaItem { get; set; }
|
||||
public DateTimeOffset Start { get; set; }
|
||||
public DateTimeOffset Finish { get; set; }
|
||||
public int PlayoutId { get; set; }
|
||||
public Playout Playout { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public enum PlayoutMode
|
||||
{
|
||||
/// <summary>
|
||||
/// Play items one after the other until a fixed start item is encountered
|
||||
/// </summary>
|
||||
Flood = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Play one item from the collection
|
||||
/// </summary>
|
||||
One = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Play a variable number of items from the collection
|
||||
/// </summary>
|
||||
Multiple = 3,
|
||||
|
||||
/// <summary>
|
||||
/// Play however many items will fit in the specified duration
|
||||
/// </summary>
|
||||
Duration = 4
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class PlayoutProgramScheduleAnchor
|
||||
{
|
||||
public int PlayoutId { get; set; }
|
||||
public Playout Playout { get; set; }
|
||||
public int ProgramScheduleId { get; set; }
|
||||
public ProgramSchedule ProgramSchedule { get; set; }
|
||||
public int MediaCollectionId { get; set; }
|
||||
public MediaCollection MediaCollection { get; set; }
|
||||
public MediaCollectionEnumeratorState EnumeratorState { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class PlexMediaSource : MediaSource
|
||||
{
|
||||
public PlexMediaSource() => SourceType = MediaSourceType.Plex;
|
||||
public string ProductVersion { get; set; }
|
||||
|
||||
public string ClientIdentifier { get; set; }
|
||||
|
||||
// public bool IsOwned { get; set; }
|
||||
public List<PlexMediaSourceConnection> Connections { get; set; }
|
||||
public List<PlexMediaSourceLibrary> Libraries { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class PlexMediaSourceConnection
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public string Uri { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class PlexMediaSourceLibrary
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Key { get; init; }
|
||||
public string Name { get; init; }
|
||||
public MediaType MediaType { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class ProgramSchedule
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
public PlaybackOrder MediaCollectionPlaybackOrder { get; set; }
|
||||
public List<ProgramScheduleItem> Items { get; set; }
|
||||
public List<Playout> Playouts { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public abstract class ProgramScheduleItem
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int Index { get; set; }
|
||||
public StartType StartType => StartTime.HasValue ? StartType.Fixed : StartType.Dynamic;
|
||||
public TimeSpan? StartTime { get; set; }
|
||||
public int MediaCollectionId { get; set; }
|
||||
public MediaCollection MediaCollection { get; set; }
|
||||
public int ProgramScheduleId { get; set; }
|
||||
public ProgramSchedule ProgramSchedule { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System;
|
||||
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class ProgramScheduleItemDuration : ProgramScheduleItem
|
||||
{
|
||||
public TimeSpan PlayoutDuration { get; set; }
|
||||
public bool OfflineTail { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class ProgramScheduleItemFlood : ProgramScheduleItem
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class ProgramScheduleItemMultiple : ProgramScheduleItem
|
||||
{
|
||||
public int Count { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class ProgramScheduleItemOne : ProgramScheduleItem
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public enum ProgramSchedulePlayoutType
|
||||
{
|
||||
None = 0,
|
||||
Flood,
|
||||
Daily
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class Resolution : IDisplaySize
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
public int Height { get; set; }
|
||||
public int Width { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public enum ResolutionKey
|
||||
{
|
||||
None = 0,
|
||||
W720H480 = 1,
|
||||
W1280H720 = 2,
|
||||
W1920H1080 = 3,
|
||||
W3840H2160 = 4
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class SimpleMediaCollection : MediaCollection
|
||||
{
|
||||
public IList<MediaItem> Items { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public enum SourceMode
|
||||
{
|
||||
Transcode,
|
||||
DirectPlay,
|
||||
DirectPaths
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public enum StartType
|
||||
{
|
||||
Dynamic = 1,
|
||||
Fixed = 2
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public enum StreamingMode
|
||||
{
|
||||
TransportStream = 1,
|
||||
HttpLiveStreaming = 2
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class TelevisionMediaCollection : MediaCollection
|
||||
{
|
||||
public string ShowTitle { get; set; }
|
||||
public int? SeasonNumber { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public enum VideoScanType
|
||||
{
|
||||
Unknown = 0,
|
||||
Progressive = 1,
|
||||
Interlaced = 2
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="LanguageExt.Core" Version="3.4.15" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="5.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="5.0.0" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
|
||||
<PackageReference Include="Serilog" Version="2.10.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="3.1.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace ErsatzTV.Core.FFmpeg
|
||||
{
|
||||
public record ConcatPlaylist(string Scheme, string Host, int ChannelNumber)
|
||||
{
|
||||
public override string ToString() =>
|
||||
$@"ffconcat version 1.0
|
||||
file {Scheme}://{Host}/ffmpeg/stream/{ChannelNumber}
|
||||
file {Scheme}://{Host}/ffmpeg/stream/{ChannelNumber}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
|
||||
namespace ErsatzTV.Core.FFmpeg
|
||||
{
|
||||
internal record DisplaySize(int Width, int Height) : IDisplaySize;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
|
||||
namespace ErsatzTV.Core.FFmpeg
|
||||
{
|
||||
public static class DisplaySizeExtensions
|
||||
{
|
||||
internal static IDisplaySize PadToEven(this IDisplaySize size) =>
|
||||
new DisplaySize(size.Width + size.Width % 2, size.Height + size.Height % 2);
|
||||
|
||||
internal static bool IsSameSizeAs(this IDisplaySize @this, IDisplaySize that) =>
|
||||
@this.Width == that.Width && @this.Height == that.Height;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Core.FFmpeg
|
||||
{
|
||||
public class FFmpegLocator : IFFmpegLocator
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
|
||||
public FFmpegLocator(IConfigElementRepository configElementRepository) =>
|
||||
_configElementRepository = configElementRepository;
|
||||
|
||||
public async Task<Option<string>> ValidatePath(string executableBase, ConfigElementKey key)
|
||||
{
|
||||
Option<ConfigElement> setting = await _configElementRepository.Get(key);
|
||||
|
||||
return await setting.MatchAsync(
|
||||
async ce =>
|
||||
{
|
||||
if (File.Exists(ce.Value))
|
||||
{
|
||||
return ce.Value;
|
||||
}
|
||||
|
||||
// configured path was incorrect
|
||||
await _configElementRepository.Delete(ce);
|
||||
|
||||
return await LocateExecutableAsync(executableBase, key);
|
||||
},
|
||||
async () => await LocateExecutableAsync(executableBase, key));
|
||||
}
|
||||
|
||||
private async Task<Option<string>> LocateExecutableAsync(string executableBase, ConfigElementKey key)
|
||||
{
|
||||
Option<string> maybePath = await LocateExecutableOnPathAsync(executableBase);
|
||||
return await maybePath.MatchAsync(
|
||||
async path =>
|
||||
{
|
||||
await _configElementRepository.Add(new ConfigElement { Key = key.Key, Value = path });
|
||||
return Some(path);
|
||||
},
|
||||
() => None);
|
||||
}
|
||||
|
||||
private async Task<Option<string>> LocateExecutableOnPathAsync(string executableBase)
|
||||
{
|
||||
string locateCommand = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "where" : "which";
|
||||
string executable = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
|
||||
? $"{executableBase}.exe"
|
||||
: executableBase;
|
||||
using var p = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
UseShellExecute = false,
|
||||
FileName = locateCommand,
|
||||
Arguments = executable,
|
||||
RedirectStandardOutput = true
|
||||
}
|
||||
};
|
||||
p.Start();
|
||||
string path = (await p.StandardOutput.ReadToEndAsync()).Trim();
|
||||
await p.WaitForExitAsync();
|
||||
return p.ExitCode == 0 ? Some(path) : None;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.FFmpeg
|
||||
{
|
||||
public class FFmpegPlaybackSettings
|
||||
{
|
||||
public int ThreadCount { get; set; }
|
||||
public List<string> FormatFlags { get; set; }
|
||||
public bool RealtimeOutput => true;
|
||||
public Option<TimeSpan> StreamSeek { get; set; }
|
||||
public Option<IDisplaySize> ScaledSize { get; set; }
|
||||
public bool PadToDesiredResolution { get; set; }
|
||||
public string ScalingAlgorithm => "fast_bilinear"; // TODO: from config, add tests
|
||||
public string VideoCodec { get; set; }
|
||||
public Option<int> VideoBitrate { get; set; }
|
||||
public Option<int> VideoBufferSize { get; set; }
|
||||
public Option<int> AudioBitrate { get; set; }
|
||||
public Option<int> AudioBufferSize { get; set; }
|
||||
public Option<int> AudioChannels { get; set; }
|
||||
public Option<int> AudioSampleRate { get; set; }
|
||||
public Option<TimeSpan> AudioDuration { get; set; }
|
||||
public string AudioCodec { get; set; }
|
||||
public bool Deinterlace { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
// zlib License
|
||||
//
|
||||
// Copyright (c) 2021 Dan Ferguson, Victor Hugo Soliz Kuncar, Jason Dove
|
||||
//
|
||||
// This software is provided 'as-is', without any express or implied
|
||||
// warranty. In no event will the authors be held liable for any damages
|
||||
// arising from the use of this software.
|
||||
//
|
||||
// Permission is granted to anyone to use this software for any purpose,
|
||||
// including commercial applications, and to alter it and redistribute it
|
||||
// freely, subject to the following restrictions:
|
||||
//
|
||||
// 1. The origin of this software must not be misrepresented; you must not
|
||||
// claim that you wrote the original software. If you use this software
|
||||
// in a product, an acknowledgment in the product documentation would be
|
||||
// appreciated but is not required.
|
||||
// 2. Altered source versions must be plainly marked as such, and must not be
|
||||
// misrepresented as being the original software.
|
||||
// 3. This notice may not be removed or altered from any source distribution.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Core.FFmpeg
|
||||
{
|
||||
public class FFmpegPlaybackSettingsCalculator
|
||||
{
|
||||
private static readonly List<string> CommonFormatFlags = new()
|
||||
{
|
||||
"+genpts",
|
||||
"+discardcorrupt",
|
||||
"+igndts"
|
||||
};
|
||||
|
||||
public FFmpegPlaybackSettings ConcatSettings => new()
|
||||
{
|
||||
ThreadCount = 1,
|
||||
FormatFlags = CommonFormatFlags
|
||||
};
|
||||
|
||||
public FFmpegPlaybackSettings CalculateSettings(
|
||||
StreamingMode streamingMode,
|
||||
FFmpegProfile ffmpegProfile,
|
||||
PlayoutItem playoutItem,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
var result = new FFmpegPlaybackSettings
|
||||
{
|
||||
ThreadCount = ffmpegProfile.ThreadCount,
|
||||
FormatFlags = CommonFormatFlags
|
||||
};
|
||||
|
||||
if (now != playoutItem.Start)
|
||||
{
|
||||
result.StreamSeek = now - playoutItem.Start;
|
||||
}
|
||||
|
||||
switch (streamingMode)
|
||||
{
|
||||
case StreamingMode.HttpLiveStreaming:
|
||||
result.AudioCodec = "copy";
|
||||
result.VideoCodec = "copy";
|
||||
result.Deinterlace = false;
|
||||
break;
|
||||
case StreamingMode.TransportStream:
|
||||
if (NeedToScale(ffmpegProfile, playoutItem.MediaItem.Metadata))
|
||||
{
|
||||
IDisplaySize scaledSize = CalculateScaledSize(ffmpegProfile, playoutItem.MediaItem.Metadata);
|
||||
if (!scaledSize.IsSameSizeAs(playoutItem.MediaItem.Metadata))
|
||||
{
|
||||
result.ScaledSize = Some(
|
||||
CalculateScaledSize(ffmpegProfile, playoutItem.MediaItem.Metadata));
|
||||
}
|
||||
}
|
||||
|
||||
IDisplaySize sizeAfterScaling = result.ScaledSize.IfNone(playoutItem.MediaItem.Metadata);
|
||||
if (!sizeAfterScaling.IsSameSizeAs(ffmpegProfile.Resolution))
|
||||
{
|
||||
result.PadToDesiredResolution = true;
|
||||
}
|
||||
|
||||
if (result.ScaledSize.IsSome || result.PadToDesiredResolution ||
|
||||
NeedToNormalizeVideoCodec(ffmpegProfile, playoutItem.MediaItem.Metadata))
|
||||
{
|
||||
result.VideoCodec = ffmpegProfile.VideoCodec;
|
||||
result.VideoBitrate = ffmpegProfile.VideoBitrate;
|
||||
result.VideoBufferSize = ffmpegProfile.VideoBufferSize;
|
||||
}
|
||||
else
|
||||
{
|
||||
result.VideoCodec = "copy";
|
||||
}
|
||||
|
||||
if (NeedToNormalizeAudioCodec(ffmpegProfile, playoutItem.MediaItem.Metadata))
|
||||
{
|
||||
result.AudioCodec = ffmpegProfile.AudioCodec;
|
||||
result.AudioBitrate = ffmpegProfile.AudioBitrate;
|
||||
result.AudioBufferSize = ffmpegProfile.AudioBufferSize;
|
||||
|
||||
if (ffmpegProfile.NormalizeAudio)
|
||||
{
|
||||
result.AudioChannels = ffmpegProfile.AudioChannels;
|
||||
result.AudioSampleRate = ffmpegProfile.AudioSampleRate;
|
||||
result.AudioDuration = playoutItem.MediaItem.Metadata.Duration;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
result.AudioCodec = "copy";
|
||||
}
|
||||
|
||||
if (playoutItem.MediaItem.Metadata.VideoScanType == VideoScanType.Interlaced)
|
||||
{
|
||||
result.Deinterlace = true;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public FFmpegPlaybackSettings CalculateErrorSettings(FFmpegProfile ffmpegProfile) =>
|
||||
new()
|
||||
{
|
||||
ThreadCount = ffmpegProfile.ThreadCount,
|
||||
FormatFlags = CommonFormatFlags,
|
||||
VideoCodec = ffmpegProfile.VideoCodec,
|
||||
AudioCodec = ffmpegProfile.AudioCodec
|
||||
};
|
||||
|
||||
private static bool NeedToScale(FFmpegProfile ffmpegProfile, MediaMetadata mediaMetadata) =>
|
||||
ffmpegProfile.NormalizeResolution &&
|
||||
IsIncorrectSize(ffmpegProfile.Resolution, mediaMetadata) ||
|
||||
IsTooLarge(ffmpegProfile.Resolution, mediaMetadata) ||
|
||||
IsOddSize(mediaMetadata);
|
||||
|
||||
private static bool IsIncorrectSize(IDisplaySize desiredResolution, MediaMetadata mediaMetadata) =>
|
||||
IsAnamorphic(mediaMetadata) ||
|
||||
mediaMetadata.Width != desiredResolution.Width ||
|
||||
mediaMetadata.Height != desiredResolution.Height;
|
||||
|
||||
private static bool IsTooLarge(IDisplaySize desiredResolution, IDisplaySize mediaSize) =>
|
||||
mediaSize.Height > desiredResolution.Height ||
|
||||
mediaSize.Width > desiredResolution.Width;
|
||||
|
||||
private static bool IsOddSize(IDisplaySize displaySize) =>
|
||||
displaySize.Height % 2 == 1 || displaySize.Width % 2 == 1;
|
||||
|
||||
private static bool NeedToNormalizeVideoCodec(FFmpegProfile ffmpegProfile, MediaMetadata mediaMetadata) =>
|
||||
ffmpegProfile.NormalizeVideoCodec && ffmpegProfile.VideoCodec != mediaMetadata.VideoCodec;
|
||||
|
||||
private static bool NeedToNormalizeAudioCodec(FFmpegProfile ffmpegProfile, MediaMetadata mediaMetadata) =>
|
||||
ffmpegProfile.NormalizeAudioCodec && ffmpegProfile.AudioCodec != mediaMetadata.AudioCodec;
|
||||
|
||||
private static IDisplaySize CalculateScaledSize(FFmpegProfile ffmpegProfile, MediaMetadata mediaMetadata)
|
||||
{
|
||||
IDisplaySize sarSize = SARSize(mediaMetadata);
|
||||
int p = mediaMetadata.Width * sarSize.Width;
|
||||
int q = mediaMetadata.Height * sarSize.Height;
|
||||
int g = Gcd(q, p);
|
||||
p = p / g;
|
||||
q = q / g;
|
||||
IDisplaySize targetSize = ffmpegProfile.Resolution;
|
||||
int hw1 = targetSize.Width;
|
||||
int hh1 = hw1 * q / p;
|
||||
int hh2 = targetSize.Height;
|
||||
int hw2 = targetSize.Height * p / q;
|
||||
if (hh1 <= targetSize.Height)
|
||||
{
|
||||
return new DisplaySize(hw1, hh1);
|
||||
}
|
||||
|
||||
return new DisplaySize(hw2, hh2);
|
||||
}
|
||||
|
||||
private static int Gcd(int a, int b)
|
||||
{
|
||||
while (a != 0 && b != 0)
|
||||
{
|
||||
if (a > b)
|
||||
{
|
||||
a %= b;
|
||||
}
|
||||
else
|
||||
{
|
||||
b %= a;
|
||||
}
|
||||
}
|
||||
|
||||
return a | b;
|
||||
}
|
||||
|
||||
private static bool IsAnamorphic(MediaMetadata mediaMetadata)
|
||||
{
|
||||
if (mediaMetadata.SampleAspectRatio == "1:1")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (mediaMetadata.SampleAspectRatio != "0:1")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (mediaMetadata.DisplayAspectRatio == "0:1")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return mediaMetadata.DisplayAspectRatio != $"{mediaMetadata.Width}:{mediaMetadata.Height}";
|
||||
}
|
||||
|
||||
private static IDisplaySize SARSize(MediaMetadata mediaMetadata)
|
||||
{
|
||||
string[] split = mediaMetadata.SampleAspectRatio.Split(":");
|
||||
return new DisplaySize(int.Parse(split[0]), int.Parse(split[1]));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
// zlib License
|
||||
//
|
||||
// Copyright (c) 2021 Dan Ferguson, Victor Hugo Soliz Kuncar, Jason Dove
|
||||
//
|
||||
// This software is provided 'as-is', without any express or implied
|
||||
// warranty. In no event will the authors be held liable for any damages
|
||||
// arising from the use of this software.
|
||||
//
|
||||
// Permission is granted to anyone to use this software for any purpose,
|
||||
// including commercial applications, and to alter it and redistribute it
|
||||
// freely, subject to the following restrictions:
|
||||
//
|
||||
// 1. The origin of this software must not be misrepresented; you must not
|
||||
// claim that you wrote the original software. If you use this software
|
||||
// in a product, an acknowledgment in the product documentation would be
|
||||
// appreciated but is not required.
|
||||
// 2. Altered source versions must be plainly marked as such, and must not be
|
||||
// misrepresented as being the original software.
|
||||
// 3. This notice may not be removed or altered from any source distribution.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.FFmpeg
|
||||
{
|
||||
internal class FFmpegProcessBuilder
|
||||
{
|
||||
private readonly List<string> _arguments = new();
|
||||
private readonly Queue<string> _audioFilters = new();
|
||||
private readonly string _ffmpegPath;
|
||||
private readonly Queue<string> _videoFilters = new();
|
||||
|
||||
public FFmpegProcessBuilder(string ffmpegPath) => _ffmpegPath = ffmpegPath;
|
||||
|
||||
public FFmpegProcessBuilder WithThreads(int threads)
|
||||
{
|
||||
_arguments.Add("-threads");
|
||||
_arguments.Add($"{threads}");
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithRealtimeOutput(bool realtimeOutput)
|
||||
{
|
||||
if (realtimeOutput)
|
||||
{
|
||||
_arguments.Add("-re");
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithSeek(Option<TimeSpan> maybeStart)
|
||||
{
|
||||
maybeStart.IfSome(
|
||||
start =>
|
||||
{
|
||||
_arguments.Add("-ss");
|
||||
_arguments.Add($"{start:c}");
|
||||
});
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithInfiniteLoop()
|
||||
{
|
||||
_arguments.Add("-stream_loop");
|
||||
_arguments.Add("-1");
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithLoopedImage(string input)
|
||||
{
|
||||
_arguments.Add("-loop");
|
||||
_arguments.Add("1");
|
||||
return WithInput(input);
|
||||
}
|
||||
|
||||
|
||||
public FFmpegProcessBuilder WithPipe()
|
||||
{
|
||||
_arguments.Add("pipe:1");
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithPixfmt(string pixfmt)
|
||||
{
|
||||
_arguments.Add("-pix_fmt");
|
||||
_arguments.Add(pixfmt);
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithLibavfilter()
|
||||
{
|
||||
_arguments.Add("-f");
|
||||
_arguments.Add("lavfi");
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithInput(string input)
|
||||
{
|
||||
_arguments.Add("-i");
|
||||
_arguments.Add($"{input}");
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithFiltergraph(string graph)
|
||||
{
|
||||
_arguments.Add("-vf");
|
||||
_arguments.Add($"{graph}");
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithFilterComplex(string filter, string finalVideo, string finalAudio)
|
||||
{
|
||||
_arguments.Add("-filter_complex");
|
||||
_arguments.Add($"{filter}");
|
||||
_arguments.Add("-map");
|
||||
_arguments.Add(finalVideo);
|
||||
_arguments.Add("-map");
|
||||
_arguments.Add(finalAudio);
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithConcat(string concatPlaylist)
|
||||
{
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"-f", "concat",
|
||||
"-safe", "0",
|
||||
"-protocol_whitelist", "file,http,tcp,https,tcp,tls",
|
||||
"-probesize", "32",
|
||||
"-i", concatPlaylist,
|
||||
"-map", "0:v",
|
||||
"-map", "0:a",
|
||||
"-c", "copy",
|
||||
"-muxdelay", "0",
|
||||
"-muxpreload", "0"
|
||||
};
|
||||
_arguments.AddRange(arguments);
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithMetadata(Channel channel)
|
||||
{
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"-metadata", "service_provider=\"ErsatzTV\"",
|
||||
"-metadata", $"service_name=\"{channel.Name}\""
|
||||
};
|
||||
_arguments.AddRange(arguments);
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithFormatFlags(IEnumerable<string> formatFlags)
|
||||
{
|
||||
_arguments.Add("-fflags");
|
||||
_arguments.Add(string.Join(string.Empty, formatFlags));
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithText(string text)
|
||||
{
|
||||
const string FONT_FILE = "fontfile=Resources/Roboto-Regular.ttf";
|
||||
const string FONT_SIZE = "fontsize=30";
|
||||
const string FONT_COLOR = "fontcolor=white";
|
||||
const string X = "x=(w-text_w)/2";
|
||||
const string Y = "y=(h-text_h)/2";
|
||||
|
||||
return WithFiltergraph($"drawtext={FONT_FILE}:{FONT_SIZE}:{FONT_COLOR}:{X}:{Y}:text='{text}'");
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithDuration(TimeSpan duration) =>
|
||||
// _arguments.Add("-t");
|
||||
// _arguments.Add($"{duration:c}");
|
||||
this;
|
||||
|
||||
public FFmpegProcessBuilder WithFormat(string format)
|
||||
{
|
||||
_arguments.Add("-f");
|
||||
_arguments.Add($"{format}");
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithPlaybackArgs(FFmpegPlaybackSettings playbackSettings)
|
||||
{
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"-c:v", playbackSettings.VideoCodec,
|
||||
"-flags", "cgop",
|
||||
"-sc_threshold", "1000000000"
|
||||
};
|
||||
|
||||
string[] videoBitrateArgs = playbackSettings.VideoBitrate.Match(
|
||||
bitrate =>
|
||||
new[]
|
||||
{
|
||||
"-b:v", $"{bitrate}k",
|
||||
"-maxrate:v", $"{bitrate}k"
|
||||
},
|
||||
Array.Empty<string>());
|
||||
arguments.AddRange(videoBitrateArgs);
|
||||
|
||||
playbackSettings.VideoBufferSize
|
||||
.IfSome(bufferSize => arguments.AddRange(new[] { "-bufsize:v", $"{bufferSize}k" }));
|
||||
|
||||
string[] audioBitrateArgs = playbackSettings.AudioBitrate.Match(
|
||||
bitrate =>
|
||||
new[]
|
||||
{
|
||||
"-b:a", $"{bitrate}k",
|
||||
"-maxrate:a", $"{bitrate}k"
|
||||
},
|
||||
Array.Empty<string>());
|
||||
arguments.AddRange(audioBitrateArgs);
|
||||
|
||||
playbackSettings.AudioBufferSize
|
||||
.IfSome(bufferSize => arguments.AddRange(new[] { "-bufsize:a", $"{bufferSize}k" }));
|
||||
|
||||
playbackSettings.AudioChannels
|
||||
.IfSome(channels => arguments.AddRange(new[] { "-ac", $"{channels}" }));
|
||||
|
||||
playbackSettings.AudioSampleRate
|
||||
.IfSome(sampleRate => arguments.AddRange(new[] { "-ar", $"{sampleRate}k" }));
|
||||
|
||||
arguments.AddRange(
|
||||
new[]
|
||||
{
|
||||
"-c:a", playbackSettings.AudioCodec,
|
||||
"-map_metadata", "-1",
|
||||
"-movflags", "+faststart",
|
||||
"-muxdelay", "0",
|
||||
"-muxpreload", "0"
|
||||
});
|
||||
|
||||
_arguments.AddRange(arguments);
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithScaling(IDisplaySize displaySize, string algorithm)
|
||||
{
|
||||
_videoFilters.Enqueue($"scale={displaySize.Width}:{displaySize.Height}:flags={algorithm}");
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithBlackBars(IDisplaySize displaySize)
|
||||
{
|
||||
_videoFilters.Enqueue($"pad={displaySize.Width}:{displaySize.Height}:(ow-iw)/2:(oh-ih)/2");
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithAlignedAudio(Option<TimeSpan> audioDuration)
|
||||
{
|
||||
audioDuration.IfSome(duration => _audioFilters.Enqueue($"apad=whole_dur={duration.TotalMilliseconds}ms"));
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithDeinterlace(bool deinterlace, string algorithm = "yadif=1")
|
||||
{
|
||||
if (deinterlace)
|
||||
{
|
||||
_videoFilters.Enqueue(algorithm);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithSAR()
|
||||
{
|
||||
// TODO: minsiz?
|
||||
_videoFilters.Enqueue("setsar=1");
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithFilterComplex()
|
||||
{
|
||||
var complexFilter = new StringBuilder();
|
||||
var videoLabel = "0:v";
|
||||
var audioLabel = "0:a";
|
||||
bool hasVideoFilters = _videoFilters.Any();
|
||||
if (hasVideoFilters)
|
||||
{
|
||||
(string filter, string finalLabel) = GenerateFilter(_videoFilters, StreamType.Video);
|
||||
complexFilter.Append(filter);
|
||||
videoLabel = finalLabel;
|
||||
}
|
||||
|
||||
if (_audioFilters.Any())
|
||||
{
|
||||
if (hasVideoFilters)
|
||||
{
|
||||
complexFilter.Append(';');
|
||||
}
|
||||
|
||||
(string filter, string finalLabel) = GenerateFilter(_audioFilters, StreamType.Audio);
|
||||
complexFilter.Append(filter);
|
||||
audioLabel = finalLabel;
|
||||
}
|
||||
|
||||
var complex = complexFilter.ToString();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(complex))
|
||||
{
|
||||
_arguments.Add("-filter_complex");
|
||||
_arguments.Add(complex);
|
||||
}
|
||||
|
||||
_arguments.Add("-map");
|
||||
_arguments.Add(videoLabel);
|
||||
|
||||
_arguments.Add("-map");
|
||||
_arguments.Add(audioLabel);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithQuiet()
|
||||
{
|
||||
_arguments.AddRange(new[] { "-hide_banner", "-loglevel", "panic", "-nostats" });
|
||||
return this;
|
||||
}
|
||||
|
||||
public Process Build()
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = _ffmpegPath,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = false,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
StandardOutputEncoding = Encoding.UTF8
|
||||
};
|
||||
|
||||
foreach (string argument in _arguments)
|
||||
{
|
||||
startInfo.ArgumentList.Add(argument);
|
||||
}
|
||||
|
||||
return new Process
|
||||
{
|
||||
StartInfo = startInfo
|
||||
};
|
||||
}
|
||||
|
||||
private FilterResult GenerateFilter(Queue<string> filterQueue, StreamType streamType)
|
||||
{
|
||||
var filter = new StringBuilder();
|
||||
var index = 0;
|
||||
string nullFilter = streamType switch
|
||||
{
|
||||
StreamType.Audio => "anull",
|
||||
StreamType.Video => "null"
|
||||
};
|
||||
char av = streamType switch
|
||||
{
|
||||
StreamType.Audio => 'a',
|
||||
StreamType.Video => 'v'
|
||||
};
|
||||
filter.Append($"[0:{av}]{nullFilter}[{av}{index}]");
|
||||
while (filterQueue.TryDequeue(out string result))
|
||||
{
|
||||
filter.Append($";[{av}{index}]{result}[{av}{++index}]");
|
||||
}
|
||||
|
||||
return new FilterResult(filter.ToString(), $"[{av}{index}]");
|
||||
}
|
||||
|
||||
private record FilterResult(string Filter, string FinalLabel);
|
||||
|
||||
private enum StreamType
|
||||
{
|
||||
Audio,
|
||||
Video
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
|
||||
namespace ErsatzTV.Core.FFmpeg
|
||||
{
|
||||
public class FFmpegProcessService
|
||||
{
|
||||
private readonly FFmpegPlaybackSettingsCalculator _playbackSettingsCalculator;
|
||||
|
||||
public FFmpegProcessService(FFmpegPlaybackSettingsCalculator ffmpegPlaybackSettingsService) =>
|
||||
_playbackSettingsCalculator = ffmpegPlaybackSettingsService;
|
||||
|
||||
public Process ForPlayoutItem(
|
||||
string ffmpegPath,
|
||||
Channel channel,
|
||||
PlayoutItem item,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.CalculateSettings(
|
||||
channel.StreamingMode,
|
||||
channel.FFmpegProfile,
|
||||
item,
|
||||
now);
|
||||
|
||||
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath)
|
||||
.WithThreads(playbackSettings.ThreadCount)
|
||||
.WithQuiet()
|
||||
.WithFormatFlags(playbackSettings.FormatFlags)
|
||||
.WithRealtimeOutput(playbackSettings.RealtimeOutput)
|
||||
.WithSeek(playbackSettings.StreamSeek)
|
||||
.WithInput(item.MediaItem.Path);
|
||||
|
||||
playbackSettings.ScaledSize.Match(
|
||||
scaledSize =>
|
||||
{
|
||||
builder = builder.WithDeinterlace(playbackSettings.Deinterlace)
|
||||
.WithScaling(scaledSize, playbackSettings.ScalingAlgorithm)
|
||||
.WithSAR();
|
||||
|
||||
scaledSize = scaledSize.PadToEven();
|
||||
if (NeedToPad(channel.FFmpegProfile.Resolution, scaledSize))
|
||||
{
|
||||
builder = builder.WithBlackBars(channel.FFmpegProfile.Resolution);
|
||||
}
|
||||
|
||||
builder = builder
|
||||
.WithAlignedAudio(playbackSettings.AudioDuration).WithFilterComplex();
|
||||
},
|
||||
() =>
|
||||
{
|
||||
if (playbackSettings.PadToDesiredResolution)
|
||||
{
|
||||
builder = builder
|
||||
.WithDeinterlace(playbackSettings.Deinterlace)
|
||||
.WithSAR()
|
||||
.WithBlackBars(channel.FFmpegProfile.Resolution)
|
||||
.WithAlignedAudio(playbackSettings.AudioDuration)
|
||||
.WithFilterComplex();
|
||||
}
|
||||
else if (playbackSettings.Deinterlace)
|
||||
{
|
||||
builder = builder.WithDeinterlace(playbackSettings.Deinterlace)
|
||||
.WithAlignedAudio(playbackSettings.AudioDuration)
|
||||
.WithFilterComplex();
|
||||
}
|
||||
else
|
||||
{
|
||||
builder = builder
|
||||
.WithAlignedAudio(playbackSettings.AudioDuration)
|
||||
.WithFilterComplex();
|
||||
}
|
||||
});
|
||||
|
||||
return builder.WithPlaybackArgs(playbackSettings)
|
||||
.WithMetadata(channel)
|
||||
.WithFormat("mpegts")
|
||||
.WithDuration(item.Start + item.MediaItem.Metadata.Duration - now)
|
||||
.WithPipe()
|
||||
.Build();
|
||||
}
|
||||
|
||||
public Process ForOfflineImage(string ffmpegPath, Channel channel)
|
||||
{
|
||||
FFmpegPlaybackSettings playbackSettings =
|
||||
_playbackSettingsCalculator.CalculateErrorSettings(channel.FFmpegProfile);
|
||||
|
||||
IDisplaySize desiredResolution = channel.FFmpegProfile.Resolution;
|
||||
|
||||
return new FFmpegProcessBuilder(ffmpegPath)
|
||||
.WithThreads(1)
|
||||
.WithQuiet()
|
||||
.WithFormatFlags(playbackSettings.FormatFlags)
|
||||
.WithRealtimeOutput(playbackSettings.RealtimeOutput)
|
||||
.WithLoopedImage("Resources/background.png")
|
||||
.WithLibavfilter()
|
||||
.WithInput("anullsrc")
|
||||
.WithFilterComplex(
|
||||
$"[0:0]scale={desiredResolution.Width}:{desiredResolution.Height}[video]",
|
||||
"[video]",
|
||||
"1:a")
|
||||
.WithPixfmt("yuv420p")
|
||||
.WithPlaybackArgs(playbackSettings)
|
||||
.WithMetadata(channel)
|
||||
.WithFormat("mpegts")
|
||||
.WithDuration(TimeSpan.FromSeconds(10)) // TODO: figure out when we're back online
|
||||
.WithPipe()
|
||||
.Build();
|
||||
}
|
||||
|
||||
public Process ConcatChannel(string ffmpegPath, Channel channel, string scheme, string host)
|
||||
{
|
||||
FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.ConcatSettings;
|
||||
|
||||
return new FFmpegProcessBuilder(ffmpegPath)
|
||||
.WithThreads(1)
|
||||
.WithQuiet()
|
||||
.WithFormatFlags(playbackSettings.FormatFlags)
|
||||
.WithRealtimeOutput(playbackSettings.RealtimeOutput)
|
||||
.WithInfiniteLoop()
|
||||
.WithConcat($"{scheme}://{host}/ffmpeg/concat/{channel.Number}")
|
||||
.WithMetadata(channel)
|
||||
.WithFormat("mpegts")
|
||||
.WithPipe()
|
||||
.Build();
|
||||
}
|
||||
|
||||
private bool NeedToPad(IDisplaySize target, IDisplaySize displaySize) =>
|
||||
displaySize.Width != target.Width || displaySize.Height != target.Height;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace ErsatzTV.Core
|
||||
{
|
||||
public static class FileSystemLayout
|
||||
{
|
||||
public static readonly string AppDataFolder = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"ersatztv");
|
||||
|
||||
public static readonly string DatabasePath = Path.Combine(AppDataFolder, "ersatztv.sqlite3");
|
||||
|
||||
public static readonly string ImageCacheFolder = Path.Combine(AppDataFolder, "cache", "images");
|
||||
|
||||
public static readonly string PlexSecretsPath = Path.Combine(AppDataFolder, "plex-secrets.json");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace ErsatzTV.Core.Hdhr
|
||||
{
|
||||
public record DeviceXml(string Scheme, string Host)
|
||||
{
|
||||
public string ToXml() =>
|
||||
@$"<root xmlns=""urn:schemas-upnp-org:device-1-0"">
|
||||
<URLBase>{Scheme}://{Host}</URLBase>
|
||||
<specVersion>
|
||||
<major>1</major>
|
||||
<minor>0</minor>
|
||||
</specVersion>
|
||||
<device>
|
||||
<deviceType>urn:schemas-upnp-org:device:MediaServer:1</deviceType>
|
||||
<friendlyName>ErsatzTV</friendlyName>
|
||||
<manufacturer>Silicondust</manufacturer>
|
||||
<modelName>HDTC-2US</modelName>
|
||||
<modelNumber>HDTC-2US</modelNumber>
|
||||
<serialNumber/>
|
||||
<UDN>uuid:2020-03-S3LA-BG3LIA:2</UDN>
|
||||
</device>
|
||||
</root>";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace ErsatzTV.Core.Hdhr
|
||||
{
|
||||
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||
public class Discover
|
||||
{
|
||||
private readonly string _host;
|
||||
private readonly string _scheme;
|
||||
|
||||
public Discover(string scheme, string host, int tunerCount)
|
||||
{
|
||||
_scheme = scheme;
|
||||
_host = host;
|
||||
TunerCount = tunerCount;
|
||||
}
|
||||
|
||||
public string DeviceAuth => "";
|
||||
public string DeviceID => "ErsatzTV";
|
||||
public string FirmwareName => "hdhomeruntc_atsc";
|
||||
public string FirmwareVersion => "20190621";
|
||||
public string FriendlyName => "ErsatzTV";
|
||||
public string LineupURL => $"{_scheme}://{_host}/lineup.json";
|
||||
public string Manufacturer => "ErsatzTV - Silicondust";
|
||||
public string ManufacturerURL => "https://github.com/jasongdove/ErsatzTV";
|
||||
public string ModelNumber => "HDTC-2US";
|
||||
public int TunerCount { get; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Hdhr
|
||||
{
|
||||
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||
public class LineupItem
|
||||
{
|
||||
private readonly Channel _channel;
|
||||
private readonly string _host;
|
||||
private readonly string _scheme;
|
||||
|
||||
public LineupItem(string scheme, string host, Channel channel)
|
||||
{
|
||||
_scheme = scheme;
|
||||
_host = host;
|
||||
_channel = channel;
|
||||
}
|
||||
|
||||
public string GuideNumber => _channel.Number.ToString();
|
||||
public string GuideName => _channel.Name;
|
||||
public string URL => $"{_scheme}://{_host}/iptv/channel/{_channel.Number}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ErsatzTV.Core.Hdhr
|
||||
{
|
||||
public class LineupStatus
|
||||
{
|
||||
public int ScanInProgress = 0;
|
||||
public int ScanPossible = 1;
|
||||
public string Source = "Cable";
|
||||
public IEnumerable<string> SourceList = new[] { "Cable" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Core.Interfaces.FFmpeg
|
||||
{
|
||||
public interface IDisplaySize
|
||||
{
|
||||
public int Width { get; }
|
||||
public int Height { get; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.FFmpeg
|
||||
{
|
||||
public interface IFFmpegLocator
|
||||
{
|
||||
public Task<Option<string>> ValidatePath(string executableBase, ConfigElementKey key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Metadata
|
||||
{
|
||||
public interface ILocalMediaScanner
|
||||
{
|
||||
Task<Unit> ScanLocalMediaSource(LocalMediaSource localMediaSource, string ffprobePath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Metadata
|
||||
{
|
||||
public interface ILocalMetadataProvider
|
||||
{
|
||||
Task RefreshMetadata(MediaItem mediaItem);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Metadata
|
||||
{
|
||||
public interface ILocalStatisticsProvider
|
||||
{
|
||||
Task RefreshStatistics(string ffprobePath, MediaItem mediaItem);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Metadata
|
||||
{
|
||||
public interface ISmartCollectionBuilder
|
||||
{
|
||||
Task RefreshSmartCollections(MediaItem mediaItem);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Plex;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Plex
|
||||
{
|
||||
public interface IPlexSecretStore
|
||||
{
|
||||
public Task<string> GetClientIdentifier();
|
||||
public Task<List<PlexUserAuthToken>> GetUserAuthTokens();
|
||||
public Task<Unit> UpsertUserAuthToken(PlexUserAuthToken userAuthToken);
|
||||
public Task<List<PlexServerAuthToken>> GetServerAuthTokens();
|
||||
public Task<Option<PlexServerAuthToken>> GetServerAuthToken(string clientIdentifier);
|
||||
public Task<Unit> UpsertServerAuthToken(PlexServerAuthToken serverAuthToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Plex;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Plex
|
||||
{
|
||||
public interface IPlexServerApiClient
|
||||
{
|
||||
Task<Either<BaseError, List<PlexMediaSourceLibrary>>> GetLibraries(
|
||||
PlexMediaSourceConnection connection,
|
||||
PlexServerAuthToken token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Plex;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Plex
|
||||
{
|
||||
public interface IPlexTvApiClient
|
||||
{
|
||||
Task<Either<BaseError, PlexAuthPin>> StartPinFlow();
|
||||
Task<bool> TryCompletePinFlow(PlexAuthPin authPin);
|
||||
Task<Either<BaseError, List<PlexMediaSource>>> GetServers();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
{
|
||||
public interface IChannelRepository
|
||||
{
|
||||
public Task<Channel> Add(Channel channel);
|
||||
public Task<Option<Channel>> Get(int id);
|
||||
public Task<Option<Channel>> GetByNumber(int number);
|
||||
public Task<List<Channel>> GetAll();
|
||||
public Task<List<Channel>> GetAllForGuide();
|
||||
public Task Update(Channel channel);
|
||||
public Task Delete(int channelId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
{
|
||||
public interface IConfigElementRepository
|
||||
{
|
||||
public Task<ConfigElement> Add(ConfigElement configElement);
|
||||
public Task<Option<ConfigElement>> Get(ConfigElementKey key);
|
||||
public Task<Option<T>> GetValue<T>(ConfigElementKey key);
|
||||
public Task Update(ConfigElement configElement);
|
||||
public Task Delete(ConfigElement configElement);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
{
|
||||
public interface IFFmpegProfileRepository
|
||||
{
|
||||
public Task<FFmpegProfile> Add(FFmpegProfile ffmpegProfile);
|
||||
public Task<Option<FFmpegProfile>> Get(int id);
|
||||
public Task<List<FFmpegProfile>> GetAll();
|
||||
public Task Update(FFmpegProfile ffmpegProfile);
|
||||
public Task Delete(int ffmpegProfileId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.AggregateModels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
{
|
||||
public interface IMediaCollectionRepository
|
||||
{
|
||||
public Task<SimpleMediaCollection> Add(SimpleMediaCollection collection);
|
||||
public Task<Option<MediaCollection>> Get(int id);
|
||||
public Task<Option<SimpleMediaCollection>> GetSimpleMediaCollection(int id);
|
||||
public Task<Option<TelevisionMediaCollection>> GetTelevisionMediaCollection(int id);
|
||||
public Task<List<SimpleMediaCollection>> GetSimpleMediaCollections();
|
||||
public Task<List<MediaCollection>> GetAll();
|
||||
public Task<List<MediaCollectionSummary>> GetSummaries(string searchString);
|
||||
public Task<Option<List<MediaItem>>> GetItems(int id);
|
||||
public Task<Option<List<MediaItem>>> GetSimpleMediaCollectionItems(int id);
|
||||
public Task<Option<List<MediaItem>>> GetTelevisionMediaCollectionItems(int id);
|
||||
public Task Update(SimpleMediaCollection collection);
|
||||
public Task InsertOrIgnore(TelevisionMediaCollection collection);
|
||||
public Task<Unit> ReplaceItems(int collectionId, List<MediaItem> mediaItems);
|
||||
public Task Delete(int mediaCollectionId);
|
||||
public Task DeleteEmptyTelevisionCollections();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
{
|
||||
public interface IMediaItemRepository
|
||||
{
|
||||
public Task<int> Add(MediaItem mediaItem);
|
||||
public Task<Option<MediaItem>> Get(int id);
|
||||
public Task<List<MediaItem>> GetAll();
|
||||
public Task<List<MediaItem>> GetAll(MediaType mediaType);
|
||||
public Task<List<MediaItem>> GetAllByMediaSourceId(int mediaSourceId);
|
||||
public Task Update(MediaItem mediaItem);
|
||||
public Task Delete(int mediaItemId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
{
|
||||
public interface IMediaSourceRepository
|
||||
{
|
||||
public Task<LocalMediaSource> Add(LocalMediaSource localMediaSource);
|
||||
public Task<PlexMediaSource> Add(PlexMediaSource plexMediaSource);
|
||||
public Task<List<MediaSource>> GetAll();
|
||||
public Task<List<PlexMediaSource>> GetAllPlex();
|
||||
public Task<Option<MediaSource>> Get(int id);
|
||||
public Task<Option<PlexMediaSource>> GetPlex(int id);
|
||||
public Task<int> CountMediaItems(int id);
|
||||
public Task Update(PlexMediaSource plexMediaSource);
|
||||
public Task Delete(int id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
{
|
||||
public interface IPlayoutRepository
|
||||
{
|
||||
public Task<Playout> Add(Playout playout);
|
||||
public Task<Option<Playout>> Get(int id);
|
||||
public Task<Option<Playout>> GetFull(int id);
|
||||
public Task<Option<PlayoutItem>> GetPlayoutItem(int channelId, DateTimeOffset now);
|
||||
public Task<List<PlayoutItem>> GetPlayoutItems(int playoutId);
|
||||
public Task<List<int>> GetPlayoutIdsForMediaItems(Seq<MediaItem> mediaItems);
|
||||
public Task<List<Playout>> GetAll();
|
||||
public Task Update(Playout playout);
|
||||
public Task Delete(int playoutId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
{
|
||||
public interface IProgramScheduleRepository
|
||||
{
|
||||
public Task<ProgramSchedule> Add(ProgramSchedule programSchedule);
|
||||
public Task<Option<ProgramSchedule>> Get(int id);
|
||||
public Task<Option<ProgramSchedule>> GetWithPlayouts(int id);
|
||||
public Task<List<ProgramSchedule>> GetAll();
|
||||
public Task Update(ProgramSchedule programSchedule);
|
||||
public Task Delete(int programScheduleId);
|
||||
public Task<Option<List<ProgramScheduleItem>>> GetItems(int programScheduleId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
{
|
||||
public interface IResolutionRepository
|
||||
{
|
||||
public Task<Option<Resolution>> Get(int id);
|
||||
public Task<List<Resolution>> GetAll();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Scheduling
|
||||
{
|
||||
public interface IMediaCollectionEnumerator
|
||||
{
|
||||
MediaCollectionEnumeratorState State { get; }
|
||||
public Option<MediaItem> Current { get; }
|
||||
public Option<MediaItem> Peek { get; }
|
||||
public void MoveNext();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Scheduling
|
||||
{
|
||||
public interface IPlayoutBuilder
|
||||
{
|
||||
public Task<Playout> BuildPlayoutItems(Playout playout, bool rebuild = false);
|
||||
|
||||
public Task<Playout> BuildPlayoutItems(
|
||||
Playout playout,
|
||||
DateTimeOffset start,
|
||||
DateTimeOffset finish,
|
||||
bool rebuild = false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Core.Iptv
|
||||
{
|
||||
public class ChannelGuide
|
||||
{
|
||||
private readonly List<Channel> _channels;
|
||||
private readonly string _host;
|
||||
private readonly string _scheme;
|
||||
|
||||
public ChannelGuide(string scheme, string host, List<Channel> channels)
|
||||
{
|
||||
_scheme = scheme;
|
||||
_host = host;
|
||||
_channels = channels;
|
||||
}
|
||||
|
||||
public string ToXml()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("<?xml version=\"1.0\"?>");
|
||||
sb.AppendLine("<tv generator-info-name=\"ersatztv\">");
|
||||
|
||||
foreach (Channel channel in _channels)
|
||||
{
|
||||
sb.AppendLine($"<channel id=\"{channel.Number}\">");
|
||||
sb.AppendLine($"<display-name lang=\"en\">{channel.Name}</display-name>");
|
||||
sb.AppendLine(
|
||||
!string.IsNullOrWhiteSpace(channel.Logo)
|
||||
? $"<icon src=\"{_scheme}://{_host}/iptv/images/{channel.Logo}\"/>"
|
||||
: $"<icon src=\"{_scheme}://{_host}/images/ersatztv-500.png\"/>");
|
||||
|
||||
sb.AppendLine("</channel>");
|
||||
}
|
||||
|
||||
foreach (Channel channel in _channels)
|
||||
{
|
||||
foreach (PlayoutItem playoutItem in channel.Playouts.Collect(p => p.Items).OrderBy(i => i.Start))
|
||||
{
|
||||
string start = playoutItem.Start.ToString("yyyyMMddHHmmss zzz").Replace(":", string.Empty);
|
||||
string stop = playoutItem.Finish.ToString("yyyyMMddHHmmss zzz").Replace(":", string.Empty);
|
||||
MediaMetadata metadata = Optional(playoutItem.MediaItem.Metadata).IfNone(
|
||||
new MediaMetadata
|
||||
{
|
||||
Title = Path.GetFileName(playoutItem.MediaItem.Path)
|
||||
});
|
||||
|
||||
sb.AppendLine(
|
||||
$"<programme start=\"{start}\" stop=\"{stop}\" channel=\"{channel.Number}\">");
|
||||
sb.AppendLine($"<title lang=\"en\">{metadata.Title}</title>");
|
||||
sb.AppendLine("<previously-shown/>");
|
||||
sb.AppendLine("<sub-title lang=\"en\"></sub-title>");
|
||||
|
||||
int season = Optional(metadata.SeasonNumber).IfNone(0);
|
||||
int episode = Optional(metadata.EpisodeNumber).IfNone(0);
|
||||
if (season > 0 && episode > 0)
|
||||
{
|
||||
sb.AppendLine($"<episode-num system=\"xmltv_ns\">{season - 1}.{episode - 1}.0/1</episode-num>");
|
||||
}
|
||||
|
||||
// sb.AppendLine("<icon src=\"\"/>");
|
||||
sb.AppendLine($"<desc lang=\"en\">{metadata.Description}</desc>");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(metadata.ContentRating))
|
||||
{
|
||||
sb.AppendLine("<rating system=\"MPAA\">");
|
||||
sb.AppendLine($"<value>{metadata.ContentRating}</value>");
|
||||
sb.AppendLine("</rating>");
|
||||
}
|
||||
|
||||
sb.AppendLine("</programme>");
|
||||
}
|
||||
}
|
||||
|
||||
sb.AppendLine("</tv>");
|
||||
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Iptv
|
||||
{
|
||||
public class ChannelPlaylist
|
||||
{
|
||||
private readonly List<Channel> _channels;
|
||||
private readonly string _host;
|
||||
private readonly string _scheme;
|
||||
|
||||
public ChannelPlaylist(string scheme, string host, List<Channel> channels)
|
||||
{
|
||||
_scheme = scheme;
|
||||
_host = host;
|
||||
_channels = channels;
|
||||
}
|
||||
|
||||
public string ToM3U()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
var xmltv = $"{_scheme}://{_host}/iptv/xmltv.xml";
|
||||
sb.AppendLine($"#EXTM3U url-tvg=\"{xmltv}\" x-tvg-url=\"{xmltv}\"");
|
||||
foreach (Channel channel in _channels)
|
||||
{
|
||||
string logo = !string.IsNullOrWhiteSpace(channel.Logo)
|
||||
? $"{_scheme}://{_host}/iptv/images/{channel.Logo}"
|
||||
: $"{_scheme}://{_host}/images/ersatztv-500.png";
|
||||
|
||||
string shortUniqueId = Convert.ToBase64String(channel.UniqueId.ToByteArray())
|
||||
.TrimEnd('=')
|
||||
.Replace("/", "_")
|
||||
.Replace("+", "-");
|
||||
|
||||
string format = channel.StreamingMode switch
|
||||
{
|
||||
StreamingMode.HttpLiveStreaming => "m3u8",
|
||||
_ => "ts"
|
||||
};
|
||||
|
||||
sb.AppendLine(
|
||||
$"#EXTINF:0 tvg-id=\"{channel.Number}\" CUID=\"{shortUniqueId}\" tvg-chno=\"{channel.Number}\" tvg-name=\"{channel.Name}\" tvg-logo=\"{logo}\" group-title=\"ErsatzTV\", {channel.Name}");
|
||||
sb.AppendLine($"{_scheme}://{_host}/iptv/channel/{channel.Number}.{format}");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.Threading.Tasks;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core
|
||||
{
|
||||
public static class LanguageExtensions
|
||||
{
|
||||
public static Either<BaseError, TR> ToEither<TR>(this Validation<BaseError, TR> validation) =>
|
||||
validation.ToEither().MapLeft(errors => errors.Join());
|
||||
|
||||
public static Task<Either<BaseError, TR>> ToEitherAsync<TR>(this Validation<BaseError, Task<TR>> validation) =>
|
||||
validation.ToEither()
|
||||
.MapLeft(errors => errors.Join())
|
||||
.MapAsync<BaseError, Task<TR>, TR>(e => e);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
public class LocalMediaScanner : ILocalMediaScanner
|
||||
{
|
||||
private readonly ILocalMetadataProvider _localMetadataProvider;
|
||||
private readonly ILocalStatisticsProvider _localStatisticsProvider;
|
||||
private readonly ILogger<LocalMediaScanner> _logger;
|
||||
private readonly IMediaItemRepository _mediaItemRepository;
|
||||
private readonly IPlayoutBuilder _playoutBuilder;
|
||||
private readonly IPlayoutRepository _playoutRepository;
|
||||
private readonly ISmartCollectionBuilder _smartCollectionBuilder;
|
||||
|
||||
public LocalMediaScanner(
|
||||
IMediaItemRepository mediaItemRepository,
|
||||
IPlayoutRepository playoutRepository,
|
||||
ILocalStatisticsProvider localStatisticsProvider,
|
||||
ILocalMetadataProvider localMetadataProvider,
|
||||
ISmartCollectionBuilder smartCollectionBuilder,
|
||||
IPlayoutBuilder playoutBuilder,
|
||||
ILogger<LocalMediaScanner> logger)
|
||||
{
|
||||
_mediaItemRepository = mediaItemRepository;
|
||||
_playoutRepository = playoutRepository;
|
||||
_localStatisticsProvider = localStatisticsProvider;
|
||||
_localMetadataProvider = localMetadataProvider;
|
||||
_smartCollectionBuilder = smartCollectionBuilder;
|
||||
_playoutBuilder = playoutBuilder;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Unit> ScanLocalMediaSource(LocalMediaSource localMediaSource, string ffprobePath)
|
||||
{
|
||||
if (!Directory.Exists(localMediaSource.Folder))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Media source folder {Folder} does not exist; skipping scan",
|
||||
localMediaSource.Folder);
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
List<MediaItem> knownMediaItems = await _mediaItemRepository.GetAllByMediaSourceId(localMediaSource.Id);
|
||||
var modifiedPlayoutIds = new List<int>();
|
||||
|
||||
// remove files that no longer exist
|
||||
// add new files
|
||||
// refresh metadata for any files where it is missing
|
||||
var knownExtensions = new List<string>
|
||||
{
|
||||
".mpg", ".mp2", ".mpeg", ".mpe", ".mpv", ".ogg", ".mp4", ".m4p", ".m4v",
|
||||
".avi", ".wmv", ".mov", ".mkv"
|
||||
};
|
||||
|
||||
var allFiles = Directory.GetFiles(localMediaSource.Folder, "*", SearchOption.AllDirectories)
|
||||
.Filter(file => knownExtensions.Contains(Path.GetExtension(file)))
|
||||
.ToSeq();
|
||||
|
||||
// check if the media item exists
|
||||
(Seq<string> newFiles, Seq<MediaItem> existingMediaItems) = allFiles.Map(
|
||||
s => Optional(knownMediaItems.Find(i => i.Path == s)).ToEither(s))
|
||||
.Partition();
|
||||
|
||||
// TODO: flag as missing? delete after some period of time?
|
||||
var removedMediaItems = knownMediaItems.Filter(i => !allFiles.Contains(i.Path)).ToSeq();
|
||||
modifiedPlayoutIds.AddRange(await _playoutRepository.GetPlayoutIdsForMediaItems(removedMediaItems));
|
||||
foreach (MediaItem mediaItem in removedMediaItems)
|
||||
{
|
||||
_logger.LogDebug("Removing missing local media item {MediaItem}", mediaItem.Path);
|
||||
await _mediaItemRepository.Delete(mediaItem.Id);
|
||||
}
|
||||
|
||||
// if exists, check if the file was modified
|
||||
Seq<MediaItem> modifiedMediaItems = existingMediaItems.Filter(
|
||||
mediaItem =>
|
||||
{
|
||||
DateTime lastWrite = File.GetLastWriteTimeUtc(mediaItem.Path);
|
||||
bool modified = lastWrite > mediaItem.LastWriteTime.IfNone(DateTime.MinValue);
|
||||
return modified || mediaItem.Metadata == null;
|
||||
});
|
||||
modifiedPlayoutIds.AddRange(await _playoutRepository.GetPlayoutIdsForMediaItems(modifiedMediaItems));
|
||||
foreach (MediaItem mediaItem in modifiedMediaItems)
|
||||
{
|
||||
_logger.LogDebug("Refreshing metadata for media item {MediaItem}", mediaItem.Path);
|
||||
await RefreshMetadata(mediaItem, ffprobePath);
|
||||
}
|
||||
|
||||
// if new, add and store mtime, refresh metadata
|
||||
var addedMediaItems = new Seq<MediaItem>();
|
||||
foreach (string path in newFiles)
|
||||
{
|
||||
_logger.LogDebug("Adding new media item {MediaItem}", path);
|
||||
var mediaItem = new MediaItem
|
||||
{
|
||||
MediaSourceId = localMediaSource.Id,
|
||||
Path = path,
|
||||
LastWriteTime = File.GetLastWriteTimeUtc(path)
|
||||
};
|
||||
|
||||
await _mediaItemRepository.Add(mediaItem);
|
||||
await RefreshMetadata(mediaItem, ffprobePath);
|
||||
addedMediaItems.Add(mediaItem);
|
||||
}
|
||||
|
||||
modifiedPlayoutIds.AddRange(await _playoutRepository.GetPlayoutIdsForMediaItems(addedMediaItems));
|
||||
|
||||
foreach (int playoutId in modifiedPlayoutIds.Distinct())
|
||||
{
|
||||
Option<Playout> maybePlayout = await _playoutRepository.GetFull(playoutId);
|
||||
await maybePlayout.Match(
|
||||
async playout =>
|
||||
{
|
||||
Playout result = await _playoutBuilder.BuildPlayoutItems(playout, true);
|
||||
await _playoutRepository.Update(result);
|
||||
},
|
||||
Task.CompletedTask);
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task RefreshMetadata(MediaItem mediaItem, string ffprobePath)
|
||||
{
|
||||
await _localStatisticsProvider.RefreshStatistics(ffprobePath, mediaItem);
|
||||
await _localMetadataProvider.RefreshMetadata(mediaItem);
|
||||
await _smartCollectionBuilder.RefreshSmartCollections(mediaItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using System.Xml.Serialization;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
public class LocalMetadataProvider : ILocalMetadataProvider
|
||||
{
|
||||
private readonly IMediaItemRepository _mediaItemRepository;
|
||||
|
||||
public LocalMetadataProvider(IMediaItemRepository mediaItemRepository) =>
|
||||
_mediaItemRepository = mediaItemRepository;
|
||||
|
||||
public async Task RefreshMetadata(MediaItem mediaItem)
|
||||
{
|
||||
Option<MediaMetadata> maybeMetadata = await LoadMetadata(mediaItem);
|
||||
MediaMetadata metadata = maybeMetadata.IfNone(() => GetFallbackMetadata(mediaItem));
|
||||
await ApplyMetadataUpdate(mediaItem, metadata);
|
||||
}
|
||||
|
||||
private async Task ApplyMetadataUpdate(MediaItem mediaItem, MediaMetadata metadata)
|
||||
{
|
||||
if (mediaItem.Metadata == null)
|
||||
{
|
||||
mediaItem.Metadata = new MediaMetadata();
|
||||
}
|
||||
|
||||
mediaItem.Metadata.MediaType = metadata.MediaType;
|
||||
mediaItem.Metadata.Title = metadata.Title;
|
||||
mediaItem.Metadata.Subtitle = metadata.Subtitle;
|
||||
mediaItem.Metadata.Description = metadata.Description;
|
||||
mediaItem.Metadata.EpisodeNumber = metadata.EpisodeNumber;
|
||||
mediaItem.Metadata.SeasonNumber = metadata.SeasonNumber;
|
||||
mediaItem.Metadata.Aired = metadata.Aired;
|
||||
mediaItem.Metadata.ContentRating = metadata.ContentRating;
|
||||
|
||||
await _mediaItemRepository.Update(mediaItem);
|
||||
}
|
||||
|
||||
private async Task<Option<MediaMetadata>> LoadMetadata(MediaItem mediaItem)
|
||||
{
|
||||
string nfoFileName = Path.ChangeExtension(mediaItem.Path, "nfo");
|
||||
if (nfoFileName == null || !File.Exists(nfoFileName))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
var tvShowSerializer = new XmlSerializer(typeof(TvShowEpisodeNfo));
|
||||
var movieSerializer = new XmlSerializer(typeof(MovieNfo));
|
||||
|
||||
TryAsync<object> tvShowAttempt = TryAsync(
|
||||
async () =>
|
||||
{
|
||||
await using FileStream fileStream = File.Open(nfoFileName, FileMode.Open);
|
||||
return tvShowSerializer.Deserialize(fileStream);
|
||||
});
|
||||
TryAsync<object> movieAttempt = TryAsync(
|
||||
async () =>
|
||||
{
|
||||
await using FileStream fileStream = File.Open(nfoFileName, FileMode.Open);
|
||||
return movieSerializer.Deserialize(fileStream);
|
||||
});
|
||||
return await choice(tvShowAttempt, movieAttempt).Match<object, Option<MediaMetadata>>(
|
||||
result =>
|
||||
{
|
||||
switch (result)
|
||||
{
|
||||
case TvShowEpisodeNfo nfo:
|
||||
return new MediaMetadata
|
||||
{
|
||||
MediaType = MediaType.TvShow,
|
||||
Title = nfo.ShowTitle,
|
||||
Subtitle = nfo.Title,
|
||||
Description = nfo.Outline,
|
||||
EpisodeNumber = nfo.Episode,
|
||||
SeasonNumber = nfo.Season,
|
||||
Aired = GetAired(nfo.Aired)
|
||||
};
|
||||
case MovieNfo nfo:
|
||||
return new MediaMetadata
|
||||
{
|
||||
MediaType = MediaType.Movie,
|
||||
Title = nfo.Title,
|
||||
Description = nfo.Outline,
|
||||
ContentRating = nfo.ContentRating,
|
||||
Aired = GetAired(nfo.Premiered)
|
||||
};
|
||||
default:
|
||||
return None;
|
||||
}
|
||||
},
|
||||
None);
|
||||
}
|
||||
|
||||
private MediaMetadata GetFallbackMetadata(MediaItem mediaItem)
|
||||
{
|
||||
string fileName = Path.GetFileName(mediaItem.Path);
|
||||
var metadata = new MediaMetadata { Title = fileName ?? mediaItem.Path };
|
||||
|
||||
if (fileName != null)
|
||||
{
|
||||
const string PATTERN = @"^(.*?)[\s-]+[sS](\d+)[eE](\d+)\.\w+$";
|
||||
Match match = Regex.Match(fileName, PATTERN);
|
||||
if (match.Success)
|
||||
{
|
||||
metadata.MediaType = MediaType.TvShow;
|
||||
metadata.Title = match.Groups[1].Value;
|
||||
metadata.SeasonNumber = int.Parse(match.Groups[2].Value);
|
||||
metadata.EpisodeNumber = int.Parse(match.Groups[3].Value);
|
||||
}
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private static DateTime? GetAired(string aired)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(aired))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (DateTime.TryParse(aired, out DateTime parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
[XmlRoot("movie")]
|
||||
public class MovieNfo
|
||||
{
|
||||
[XmlElement("title")]
|
||||
public string Title { get; set; }
|
||||
|
||||
[XmlElement("outline")]
|
||||
public string Outline { get; set; }
|
||||
|
||||
[XmlElement("mpaa")]
|
||||
public string ContentRating { get; set; }
|
||||
|
||||
[XmlElement("premiered")]
|
||||
public string Premiered { get; set; }
|
||||
}
|
||||
|
||||
[XmlRoot("episodedetails")]
|
||||
public class TvShowEpisodeNfo
|
||||
{
|
||||
[XmlElement("showtitle")]
|
||||
public string ShowTitle { get; set; }
|
||||
|
||||
[XmlElement("title")]
|
||||
public string Title { get; set; }
|
||||
|
||||
[XmlElement("outline")]
|
||||
public string Outline { get; set; }
|
||||
|
||||
[XmlElement("episode")]
|
||||
public int Episode { get; set; }
|
||||
|
||||
[XmlElement("season")]
|
||||
public int Season { get; set; }
|
||||
|
||||
[XmlElement("mpaa")]
|
||||
public string ContentRating { get; set; }
|
||||
|
||||
[XmlElement("aired")]
|
||||
public string Aired { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using Newtonsoft.Json;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
public class LocalStatisticsProvider : ILocalStatisticsProvider
|
||||
{
|
||||
private readonly IMediaItemRepository _mediaItemRepository;
|
||||
|
||||
public LocalStatisticsProvider(IMediaItemRepository mediaItemRepository) =>
|
||||
_mediaItemRepository = mediaItemRepository;
|
||||
|
||||
public async Task RefreshStatistics(string ffprobePath, MediaItem mediaItem)
|
||||
{
|
||||
FFprobe ffprobe = await GetProbeOutput(ffprobePath, mediaItem);
|
||||
MediaMetadata metadata = ProjectToMediaMetadata(ffprobe);
|
||||
await ApplyStatisticsUpdate(mediaItem, metadata);
|
||||
}
|
||||
|
||||
private async Task ApplyStatisticsUpdate(
|
||||
MediaItem mediaItem,
|
||||
MediaMetadata metadata)
|
||||
{
|
||||
if (mediaItem.Metadata == null)
|
||||
{
|
||||
mediaItem.Metadata = new MediaMetadata();
|
||||
}
|
||||
|
||||
mediaItem.Metadata.Duration = metadata.Duration;
|
||||
mediaItem.Metadata.AudioCodec = metadata.AudioCodec;
|
||||
mediaItem.Metadata.SampleAspectRatio = metadata.SampleAspectRatio;
|
||||
mediaItem.Metadata.DisplayAspectRatio = metadata.DisplayAspectRatio;
|
||||
mediaItem.Metadata.Width = metadata.Width;
|
||||
mediaItem.Metadata.Height = metadata.Height;
|
||||
mediaItem.Metadata.VideoCodec = metadata.VideoCodec;
|
||||
mediaItem.Metadata.VideoScanType = metadata.VideoScanType;
|
||||
|
||||
await _mediaItemRepository.Update(mediaItem);
|
||||
}
|
||||
|
||||
private Task<FFprobe> GetProbeOutput(string ffprobePath, MediaItem mediaItem)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = ffprobePath,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false
|
||||
};
|
||||
|
||||
startInfo.ArgumentList.Add("-v");
|
||||
startInfo.ArgumentList.Add("quiet");
|
||||
startInfo.ArgumentList.Add("-print_format");
|
||||
startInfo.ArgumentList.Add("json");
|
||||
startInfo.ArgumentList.Add("-show_format");
|
||||
startInfo.ArgumentList.Add("-show_streams");
|
||||
startInfo.ArgumentList.Add("-i");
|
||||
startInfo.ArgumentList.Add(mediaItem.Path);
|
||||
|
||||
var probe = new Process
|
||||
{
|
||||
StartInfo = startInfo
|
||||
};
|
||||
|
||||
probe.Start();
|
||||
return probe.StandardOutput.ReadToEndAsync().MapAsync(
|
||||
async output =>
|
||||
{
|
||||
await probe.WaitForExitAsync();
|
||||
return JsonConvert.DeserializeObject<FFprobe>(output);
|
||||
});
|
||||
}
|
||||
|
||||
private MediaMetadata ProjectToMediaMetadata(FFprobe probeOutput) =>
|
||||
Optional(probeOutput)
|
||||
.Filter(json => json != null)
|
||||
.ToValidation<BaseError>("Unable to parse ffprobe output")
|
||||
.ToEither<FFprobe>()
|
||||
.Match(
|
||||
json =>
|
||||
{
|
||||
var duration = TimeSpan.FromSeconds(double.Parse(json.format.duration));
|
||||
|
||||
var metadata = new MediaMetadata { Duration = duration };
|
||||
|
||||
FFprobeStream audioStream = json.streams.FirstOrDefault(s => s.codec_type == "audio");
|
||||
if (audioStream != null)
|
||||
{
|
||||
metadata = metadata with
|
||||
{
|
||||
AudioCodec = audioStream.codec_name
|
||||
};
|
||||
}
|
||||
|
||||
FFprobeStream videoStream = json.streams.FirstOrDefault(s => s.codec_type == "video");
|
||||
if (videoStream != null)
|
||||
{
|
||||
metadata = metadata with
|
||||
{
|
||||
SampleAspectRatio = videoStream.sample_aspect_ratio,
|
||||
DisplayAspectRatio = videoStream.display_aspect_ratio,
|
||||
Width = videoStream.width,
|
||||
Height = videoStream.height,
|
||||
VideoCodec = videoStream.codec_name,
|
||||
VideoScanType = ScanTypeFromFieldOrder(videoStream.field_order)
|
||||
};
|
||||
}
|
||||
|
||||
return metadata;
|
||||
},
|
||||
_ => new MediaMetadata());
|
||||
|
||||
private VideoScanType ScanTypeFromFieldOrder(string fieldOrder) =>
|
||||
fieldOrder?.ToLowerInvariant() switch
|
||||
{
|
||||
var x when x == "tt" || x == "bb" || x == "tb" || x == "bt" => VideoScanType.Interlaced,
|
||||
"progressive" => VideoScanType.Progressive,
|
||||
_ => VideoScanType.Unknown
|
||||
};
|
||||
|
||||
// ReSharper disable InconsistentNaming
|
||||
public record FFprobe(FFprobeFormat format, List<FFprobeStream> streams);
|
||||
|
||||
public record FFprobeFormat(string duration);
|
||||
|
||||
public record FFprobeStream(
|
||||
int index,
|
||||
string codec_name,
|
||||
string codec_type,
|
||||
int width,
|
||||
int height,
|
||||
string sample_aspect_ratio,
|
||||
string display_aspect_ratio,
|
||||
string field_order,
|
||||
string r_frame_rate);
|
||||
// ReSharper restore InconsistentNaming
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
public class SmartCollectionBuilder : ISmartCollectionBuilder
|
||||
{
|
||||
private readonly IMediaCollectionRepository _mediaCollectionRepository;
|
||||
|
||||
public SmartCollectionBuilder(IMediaCollectionRepository mediaCollectionRepository) =>
|
||||
_mediaCollectionRepository = mediaCollectionRepository;
|
||||
|
||||
public async Task RefreshSmartCollections(MediaItem mediaItem)
|
||||
{
|
||||
foreach (TelevisionMediaCollection collection in GetTelevisionCollections(mediaItem))
|
||||
{
|
||||
await _mediaCollectionRepository.InsertOrIgnore(collection);
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<TelevisionMediaCollection> GetTelevisionCollections(MediaItem mediaItem)
|
||||
{
|
||||
IList<MediaItem> televisionMediaItems = new[] { mediaItem }
|
||||
.Where(c => c.Metadata.MediaType == MediaType.TvShow)
|
||||
.ToList();
|
||||
|
||||
IEnumerable<TelevisionMediaCollection> televisionShowCollections = televisionMediaItems
|
||||
.Map(c => c.Metadata.Title)
|
||||
.Distinct().Map(
|
||||
t => new TelevisionMediaCollection
|
||||
{
|
||||
Name = $"{t} - All Seasons",
|
||||
ShowTitle = t,
|
||||
SeasonNumber = null
|
||||
});
|
||||
|
||||
IEnumerable<TelevisionMediaCollection> televisionShowSeasonCollections = televisionMediaItems
|
||||
.Map(c => new { c.Metadata.Title, c.Metadata.SeasonNumber }).Distinct()
|
||||
.Map(
|
||||
ts =>
|
||||
{
|
||||
return Optional(ts.SeasonNumber).Map(
|
||||
sn => new TelevisionMediaCollection
|
||||
{
|
||||
Name = $"{ts.Title} - Season {sn:00}",
|
||||
ShowTitle = ts.Title,
|
||||
SeasonNumber = sn
|
||||
});
|
||||
})
|
||||
.Sequence().Flatten();
|
||||
|
||||
return Seq(televisionShowCollections, televisionShowSeasonCollections).Flatten();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace ErsatzTV.Core.Plex
|
||||
{
|
||||
public record PlexAuthPin(int Id, string Code, string ClientIdentifier)
|
||||
{
|
||||
public string Url
|
||||
{
|
||||
get
|
||||
{
|
||||
var clientId = $"clientID={ClientIdentifier}";
|
||||
var code = $"code={Code}";
|
||||
var cdp = "context%5Bdevice%5D%5Bproduct%5D=ErsatzTV";
|
||||
return $"https://app.plex.tv/auth#?{clientId}&{code}&{cdp}";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace ErsatzTV.Core.Plex
|
||||
{
|
||||
public record PlexServerAuthToken(string ClientIdentifier, string AuthToken);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace ErsatzTV.Core.Plex
|
||||
{
|
||||
public record PlexUserAuthToken(string Email, string AuthToken);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
using LanguageExt;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Core.Scheduling
|
||||
{
|
||||
public sealed class ChronologicalMediaCollectionEnumerator : IMediaCollectionEnumerator
|
||||
{
|
||||
private readonly IList<MediaItem> _sortedMediaItems;
|
||||
|
||||
public ChronologicalMediaCollectionEnumerator(
|
||||
IEnumerable<MediaItem> mediaItems,
|
||||
MediaCollectionEnumeratorState state)
|
||||
{
|
||||
_sortedMediaItems = mediaItems.OrderBy(c => c.Metadata.Aired ?? DateTime.MaxValue)
|
||||
.ThenBy(c => c.Metadata.SeasonNumber)
|
||||
.ThenBy(c => c.Metadata.EpisodeNumber)
|
||||
.ToList();
|
||||
|
||||
State = new MediaCollectionEnumeratorState { Seed = state.Seed };
|
||||
while (State.Index < state.Index)
|
||||
{
|
||||
MoveNext();
|
||||
}
|
||||
}
|
||||
|
||||
public MediaCollectionEnumeratorState State { get; }
|
||||
|
||||
public Option<MediaItem> Current => _sortedMediaItems.Any() ? _sortedMediaItems[State.Index] : None;
|
||||
|
||||
public Option<MediaItem> Peek => _sortedMediaItems.Any()
|
||||
? _sortedMediaItems[(State.Index + 1) % _sortedMediaItems.Count]
|
||||
: None;
|
||||
|
||||
public void MoveNext() => State.Index = (State.Index + 1) % _sortedMediaItems.Count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static LanguageExt.Prelude;
|
||||
using Map = LanguageExt.Map;
|
||||
|
||||
namespace ErsatzTV.Core.Scheduling
|
||||
{
|
||||
public class PlayoutBuilder : IPlayoutBuilder
|
||||
{
|
||||
private static readonly Random Random = new();
|
||||
private readonly ILogger<PlayoutBuilder> _logger;
|
||||
private readonly IMediaCollectionRepository _mediaCollectionRepository;
|
||||
|
||||
public PlayoutBuilder(
|
||||
IMediaCollectionRepository mediaCollectionRepository,
|
||||
ILogger<PlayoutBuilder> logger)
|
||||
{
|
||||
_mediaCollectionRepository = mediaCollectionRepository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task<Playout> BuildPlayoutItems(Playout playout, bool rebuild = false)
|
||||
{
|
||||
DateTimeOffset now = DateTimeOffset.Now;
|
||||
return BuildPlayoutItems(playout, now, now.AddDays(2), rebuild);
|
||||
}
|
||||
|
||||
public async Task<Playout> BuildPlayoutItems(
|
||||
Playout playout,
|
||||
DateTimeOffset start,
|
||||
DateTimeOffset finish,
|
||||
bool rebuild = false)
|
||||
{
|
||||
var collections = playout.ProgramSchedule.Items.Map(i => i.MediaCollection).Distinct().ToList();
|
||||
|
||||
IEnumerable<Tuple<MediaCollection, List<MediaItem>>> tuples = await collections.Map(
|
||||
async collection =>
|
||||
{
|
||||
Option<List<MediaItem>> maybeItems = await _mediaCollectionRepository.GetItems(collection.Id);
|
||||
return Tuple(collection, maybeItems.IfNone(new List<MediaItem>()));
|
||||
}).Sequence();
|
||||
|
||||
var collectionMediaItems = Map.createRange(tuples);
|
||||
|
||||
// using IDisposable scope = _logger.BeginScope(new { PlayoutId = playout.Id });
|
||||
_logger.LogDebug(
|
||||
$"{(rebuild ? "Rebuilding" : "Building")} playout {{PlayoutId}} for channel {{ChannelNumber}} - {{ChannelName}}",
|
||||
playout.Id,
|
||||
playout.Channel.Number,
|
||||
playout.Channel.Name);
|
||||
|
||||
playout.Items ??= new List<PlayoutItem>();
|
||||
playout.ProgramScheduleAnchors ??= new List<PlayoutProgramScheduleAnchor>();
|
||||
|
||||
if (rebuild)
|
||||
{
|
||||
playout.Items.Clear();
|
||||
playout.Anchor = null;
|
||||
playout.ProgramScheduleAnchors.Clear();
|
||||
}
|
||||
|
||||
var sortedScheduleItems = playout.ProgramSchedule.Items.OrderBy(i => i.Index).ToList();
|
||||
Map<MediaCollection, IMediaCollectionEnumerator> collectionEnumerators =
|
||||
MapExtensions.Map(collectionMediaItems, (c, i) => GetMediaCollectionEnumerator(playout, c, i));
|
||||
|
||||
// find start anchor
|
||||
PlayoutAnchor startAnchor = FindStartAnchor(playout, start, sortedScheduleItems);
|
||||
|
||||
// start at the previously-decided time
|
||||
DateTimeOffset currentTime = startAnchor.NextStart;
|
||||
_logger.LogDebug(
|
||||
"Starting playout {PlayoutId} for channel {ChannelNumber} - {ChannelName} at {StartTime}",
|
||||
playout.Id,
|
||||
playout.Channel.Number,
|
||||
playout.Channel.Name,
|
||||
currentTime);
|
||||
|
||||
// start with the previously-decided schedule item
|
||||
int index = sortedScheduleItems.IndexOf(startAnchor.NextScheduleItem);
|
||||
|
||||
Option<int> multipleRemaining = None;
|
||||
Option<DateTimeOffset> durationFinish = None;
|
||||
// loop until we're done filling the desired amount of time
|
||||
while (currentTime < finish)
|
||||
{
|
||||
// get the schedule item out of the sorted list
|
||||
ProgramScheduleItem scheduleItem = sortedScheduleItems[index % sortedScheduleItems.Count];
|
||||
|
||||
// find when we should start this item, based on the current time
|
||||
DateTimeOffset startTime = GetStartTimeAfter(
|
||||
scheduleItem,
|
||||
currentTime,
|
||||
multipleRemaining.IsSome,
|
||||
durationFinish.IsSome);
|
||||
_logger.LogDebug(
|
||||
"Schedule item: {ScheduleItemNumber} / {MediaCollectionName} / {StartTime}",
|
||||
scheduleItem.Index,
|
||||
scheduleItem.MediaCollection.Name,
|
||||
startTime);
|
||||
|
||||
IMediaCollectionEnumerator enumerator = collectionEnumerators[scheduleItem.MediaCollection];
|
||||
enumerator.Current.IfSome(
|
||||
mediaItem =>
|
||||
{
|
||||
var playoutItem = new PlayoutItem
|
||||
{
|
||||
MediaItemId = mediaItem.Id,
|
||||
Start = startTime,
|
||||
Finish = startTime + mediaItem.Metadata.Duration
|
||||
};
|
||||
|
||||
currentTime = startTime + mediaItem.Metadata.Duration;
|
||||
enumerator.MoveNext();
|
||||
|
||||
playout.Items.Add(playoutItem);
|
||||
|
||||
switch (scheduleItem)
|
||||
{
|
||||
case ProgramScheduleItemOne:
|
||||
// only play one item from collection, so always advance to the next item
|
||||
_logger.LogDebug(
|
||||
"Advancing to next playout item after playout mode {PlayoutMode}",
|
||||
"One");
|
||||
index++;
|
||||
break;
|
||||
case ProgramScheduleItemMultiple multiple:
|
||||
if (multipleRemaining.IsNone)
|
||||
{
|
||||
multipleRemaining = multiple.Count;
|
||||
}
|
||||
|
||||
multipleRemaining = multipleRemaining.Map(i => i - 1);
|
||||
if (multipleRemaining.IfNone(-1) == 0)
|
||||
{
|
||||
index++;
|
||||
multipleRemaining = None;
|
||||
}
|
||||
|
||||
break;
|
||||
case ProgramScheduleItemFlood:
|
||||
enumerator.Peek.Do(
|
||||
peekMediaItem =>
|
||||
{
|
||||
ProgramScheduleItem peekScheduleItem =
|
||||
sortedScheduleItems[(index + 1) % sortedScheduleItems.Count];
|
||||
DateTimeOffset peekScheduleItemStart =
|
||||
peekScheduleItem.StartType == StartType.Fixed
|
||||
? GetStartTimeAfter(peekScheduleItem, currentTime)
|
||||
: DateTimeOffset.MaxValue;
|
||||
|
||||
// if the current time is before the next schedule item, but the current finish
|
||||
// is after, we need to move on to the next schedule item
|
||||
// eventually, spots probably have to fit in this gap
|
||||
bool willNotFinishInTime = currentTime <= peekScheduleItemStart &&
|
||||
currentTime + peekMediaItem.Metadata.Duration >
|
||||
peekScheduleItemStart;
|
||||
if (willNotFinishInTime)
|
||||
{
|
||||
index++;
|
||||
}
|
||||
});
|
||||
break;
|
||||
case ProgramScheduleItemDuration duration:
|
||||
enumerator.Peek.Do(
|
||||
peekMediaItem =>
|
||||
{
|
||||
if (durationFinish.IsNone)
|
||||
{
|
||||
durationFinish = startTime + duration.PlayoutDuration;
|
||||
}
|
||||
|
||||
DateTimeOffset finish = durationFinish.IfNone(DateTime.MinValue);
|
||||
bool willNotFinishInTime = currentTime <= finish &&
|
||||
currentTime + peekMediaItem.Metadata.Duration >
|
||||
finish;
|
||||
if (willNotFinishInTime)
|
||||
{
|
||||
index++;
|
||||
|
||||
if (duration.OfflineTail)
|
||||
{
|
||||
durationFinish.Do(f => currentTime = f);
|
||||
}
|
||||
|
||||
durationFinish = None;
|
||||
}
|
||||
}
|
||||
);
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// once more to get playout anchor
|
||||
ProgramScheduleItem nextScheduleItem = sortedScheduleItems[index % sortedScheduleItems.Count];
|
||||
playout.Anchor = new PlayoutAnchor
|
||||
{
|
||||
NextScheduleItem = nextScheduleItem,
|
||||
NextScheduleItemId = nextScheduleItem.Id,
|
||||
NextStart = GetStartTimeAfter(nextScheduleItem, currentTime)
|
||||
};
|
||||
|
||||
// build program schedule anchors
|
||||
playout.ProgramScheduleAnchors = BuildProgramScheduleAnchors(playout, collectionEnumerators);
|
||||
|
||||
// remove any items outside the desired range
|
||||
playout.Items.RemoveAll(old => old.Finish < start || old.Start > finish);
|
||||
|
||||
return playout;
|
||||
}
|
||||
|
||||
private static PlayoutAnchor FindStartAnchor(
|
||||
Playout playout,
|
||||
DateTimeOffset start,
|
||||
IReadOnlyCollection<ProgramScheduleItem> sortedScheduleItems) =>
|
||||
Optional(playout.Anchor).IfNone(
|
||||
() =>
|
||||
{
|
||||
ProgramScheduleItem schedule = sortedScheduleItems.Head();
|
||||
switch (schedule.StartType)
|
||||
{
|
||||
case StartType.Fixed:
|
||||
return new PlayoutAnchor
|
||||
{
|
||||
NextScheduleItem = schedule,
|
||||
NextScheduleItemId = schedule.Id,
|
||||
NextStart = start.Date + schedule.StartTime.GetValueOrDefault()
|
||||
};
|
||||
case StartType.Dynamic:
|
||||
default:
|
||||
return new PlayoutAnchor
|
||||
{
|
||||
NextScheduleItem = schedule,
|
||||
NextScheduleItemId = schedule.Id,
|
||||
NextStart = start.Date
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
private static DateTimeOffset GetStartTimeAfter(
|
||||
ProgramScheduleItem item,
|
||||
DateTimeOffset start,
|
||||
bool inMultiple = false,
|
||||
bool inDuration = false)
|
||||
{
|
||||
switch (item.StartType)
|
||||
{
|
||||
case StartType.Fixed:
|
||||
if (item is ProgramScheduleItemMultiple && inMultiple ||
|
||||
item is ProgramScheduleItemDuration && inDuration)
|
||||
{
|
||||
return start;
|
||||
}
|
||||
|
||||
TimeSpan startTime = item.StartTime.GetValueOrDefault();
|
||||
DateTime result = start.Date + startTime;
|
||||
// need to wrap to the next day if appropriate
|
||||
return start.TimeOfDay > startTime ? result.AddDays(1) : result;
|
||||
case StartType.Dynamic:
|
||||
default:
|
||||
return start;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<PlayoutProgramScheduleAnchor> BuildProgramScheduleAnchors(
|
||||
Playout playout,
|
||||
Map<MediaCollection, IMediaCollectionEnumerator> collectionEnumerators)
|
||||
{
|
||||
var result = new List<PlayoutProgramScheduleAnchor>();
|
||||
|
||||
foreach (MediaCollection collection in collectionEnumerators.Keys)
|
||||
{
|
||||
Option<PlayoutProgramScheduleAnchor> maybeExisting = playout.ProgramScheduleAnchors
|
||||
.FirstOrDefault(a => a.MediaCollection == collection);
|
||||
|
||||
var maybeEnumeratorState = collectionEnumerators.GroupBy(e => e.Key, e => e.Value.State)
|
||||
.ToDictionary(mcs => mcs.Key, mcs => mcs.Head());
|
||||
|
||||
PlayoutProgramScheduleAnchor scheduleAnchor = maybeExisting.Match(
|
||||
existing =>
|
||||
{
|
||||
existing.EnumeratorState = maybeEnumeratorState[collection];
|
||||
return existing;
|
||||
},
|
||||
() => new PlayoutProgramScheduleAnchor
|
||||
{
|
||||
Playout = playout,
|
||||
PlayoutId = playout.Id,
|
||||
ProgramSchedule = playout.ProgramSchedule,
|
||||
ProgramScheduleId = playout.ProgramScheduleId,
|
||||
MediaCollection = collection,
|
||||
MediaCollectionId = collection.Id,
|
||||
EnumeratorState = maybeEnumeratorState[collection]
|
||||
});
|
||||
|
||||
result.Add(scheduleAnchor);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static IMediaCollectionEnumerator GetMediaCollectionEnumerator(
|
||||
Playout playout,
|
||||
MediaCollection mediaCollection,
|
||||
List<MediaItem> mediaItems)
|
||||
{
|
||||
Option<PlayoutProgramScheduleAnchor> maybeAnchor = playout.ProgramScheduleAnchors
|
||||
.FirstOrDefault(
|
||||
a => a.ProgramScheduleId == playout.ProgramScheduleId && a.MediaCollectionId == mediaCollection.Id);
|
||||
|
||||
MediaCollectionEnumeratorState state = maybeAnchor.Match(
|
||||
anchor => anchor.EnumeratorState,
|
||||
() => new MediaCollectionEnumeratorState { Seed = Random.Next(), Index = 0 });
|
||||
|
||||
switch (playout.ProgramSchedule.MediaCollectionPlaybackOrder)
|
||||
{
|
||||
case PlaybackOrder.Chronological:
|
||||
return new ChronologicalMediaCollectionEnumerator(mediaItems, state);
|
||||
case PlaybackOrder.Random:
|
||||
return new RandomizedMediaCollectionEnumerator(mediaItems, state);
|
||||
case PlaybackOrder.Shuffle:
|
||||
return new ShuffledMediaCollectionEnumerator(mediaItems, state);
|
||||
default:
|
||||
// TODO: handle this error case differently?
|
||||
return new RandomizedMediaCollectionEnumerator(mediaItems, state);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
using LanguageExt;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Core.Scheduling
|
||||
{
|
||||
public class RandomizedMediaCollectionEnumerator : IMediaCollectionEnumerator
|
||||
{
|
||||
private readonly IList<MediaItem> _mediaItems;
|
||||
private readonly Random _random;
|
||||
private int _index;
|
||||
private Option<int> _peekNext;
|
||||
|
||||
public RandomizedMediaCollectionEnumerator(IList<MediaItem> mediaItems, MediaCollectionEnumeratorState state)
|
||||
{
|
||||
_mediaItems = mediaItems;
|
||||
_random = new Random(state.Seed);
|
||||
_peekNext = None;
|
||||
|
||||
State = new MediaCollectionEnumeratorState { Seed = state.Seed };
|
||||
// we want to move at least once so we start with a random item and not the first
|
||||
// because _index defaults to 0
|
||||
while (State.Index <= state.Index)
|
||||
{
|
||||
MoveNext();
|
||||
}
|
||||
}
|
||||
|
||||
public MediaCollectionEnumeratorState State { get; }
|
||||
|
||||
public Option<MediaItem> Current => _mediaItems.Any() ? _mediaItems[_index] : None;
|
||||
|
||||
public Option<MediaItem> Peek
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_mediaItems.Any())
|
||||
{
|
||||
return _peekNext.Match(
|
||||
peek =>
|
||||
{
|
||||
Debug.WriteLine("returning existing peek");
|
||||
return _mediaItems[peek];
|
||||
},
|
||||
() =>
|
||||
{
|
||||
Debug.WriteLine("setting peek");
|
||||
// gen a random index but save it so we can use it again when
|
||||
// we actually move next
|
||||
int index = _random.Next() % _mediaItems.Count;
|
||||
_peekNext = index;
|
||||
return _mediaItems[index];
|
||||
});
|
||||
}
|
||||
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
public void MoveNext()
|
||||
{
|
||||
// TODO: reset seed at some predictable point so we don't overflow the index
|
||||
Debug.WriteLine("resetting peek");
|
||||
|
||||
_index = _peekNext.IfNone(() => _random.Next() % _mediaItems.Count);
|
||||
_peekNext = None;
|
||||
|
||||
State.Index++;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
using LanguageExt;
|
||||
using LanguageExt.UnsafeValueAccess;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Core.Scheduling
|
||||
{
|
||||
public class ShuffledMediaCollectionEnumerator : IMediaCollectionEnumerator
|
||||
{
|
||||
private readonly IList<MediaItem> _mediaItems;
|
||||
private Option<int> _peekNextSeed;
|
||||
private Random _random;
|
||||
private IList<MediaItem> _shuffled;
|
||||
|
||||
|
||||
public ShuffledMediaCollectionEnumerator(IList<MediaItem> mediaItems, MediaCollectionEnumeratorState state)
|
||||
{
|
||||
_mediaItems = mediaItems;
|
||||
_random = new Random(state.Seed);
|
||||
_shuffled = Shuffle(_mediaItems, _random);
|
||||
|
||||
State = new MediaCollectionEnumeratorState { Seed = state.Seed };
|
||||
while (State.Index < state.Index)
|
||||
{
|
||||
MoveNext();
|
||||
}
|
||||
}
|
||||
|
||||
public MediaCollectionEnumeratorState State { get; }
|
||||
|
||||
public Option<MediaItem> Current => _shuffled.Any() ? _shuffled[State.Index % _mediaItems.Count] : None;
|
||||
|
||||
public Option<MediaItem> Peek
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_shuffled.Any())
|
||||
{
|
||||
// if we aren't peeking past the end of the list, things are simple
|
||||
if (State.Index + 1 < _shuffled.Count)
|
||||
{
|
||||
return _shuffled[State.Index + 1];
|
||||
}
|
||||
|
||||
// if we are peeking past the end of the list...
|
||||
// gen a random seed but save it so we can use it again when we actually move next
|
||||
Random random;
|
||||
if (_peekNextSeed.IsSome)
|
||||
{
|
||||
random = new Random(_peekNextSeed.Value());
|
||||
}
|
||||
else
|
||||
{
|
||||
_peekNextSeed = _random.Next();
|
||||
random = new Random(_peekNextSeed.Value());
|
||||
}
|
||||
|
||||
return Shuffle(_mediaItems, random).Head();
|
||||
}
|
||||
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
public void MoveNext()
|
||||
{
|
||||
State.Index++;
|
||||
if (State.Index % _shuffled.Count == 0)
|
||||
{
|
||||
State.Index = 0;
|
||||
State.Seed = _peekNextSeed.IfNone(_random.Next());
|
||||
_random = new Random(State.Seed);
|
||||
_shuffled = Shuffle(_mediaItems, _random);
|
||||
}
|
||||
|
||||
State.Index %= _shuffled.Count;
|
||||
_peekNextSeed = None;
|
||||
}
|
||||
|
||||
private static IList<T> Shuffle<T>(IEnumerable<T> list, Random random)
|
||||
{
|
||||
T[] copy = list.ToArray();
|
||||
|
||||
int n = copy.Length;
|
||||
while (n > 1)
|
||||
{
|
||||
n--;
|
||||
int k = random.Next(n + 1);
|
||||
T value = copy[k];
|
||||
copy[k] = copy[n];
|
||||
copy[n] = value;
|
||||
}
|
||||
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user