Initial commit

This commit is contained in:
Jason Dove
2021-02-08 21:13:53 -06:00
commit 4d52e115b5
493 changed files with 31399 additions and 0 deletions
@@ -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);
}
+18
View File
@@ -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);
}
}
+22
View File
@@ -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; }
}
}
+9
View File
@@ -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; }
}
}
+14
View File
@@ -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");
}
}
+48
View File
@@ -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
};
}
}
+10
View File
@@ -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; }
}
}
+10
View File
@@ -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; }
}
}
+16
View File
@@ -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; }
}
}
+25
View File
@@ -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; }
}
}
+9
View File
@@ -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; }
}
}
+10
View File
@@ -0,0 +1,10 @@
namespace ErsatzTV.Core.Domain
{
public enum MediaSourceType
{
None = 0,
Local = 1,
Plex = 2,
Jellyfin = 3
}
}
+9
View File
@@ -0,0 +1,9 @@
namespace ErsatzTV.Core.Domain
{
public enum MediaType
{
Other = 0,
TvShow = 1,
Movie = 2
}
}
+9
View File
@@ -0,0 +1,9 @@
namespace ErsatzTV.Core.Domain
{
public enum PlaybackOrder
{
Chronological = 1,
Random = 2,
Shuffle = 3
}
}
+17
View File
@@ -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; }
}
}
+13
View File
@@ -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; }
}
}
+15
View File
@@ -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; }
}
}
+25
View File
@@ -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; }
}
}
+16
View File
@@ -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; }
}
}
+13
View File
@@ -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
}
}
+12
View File
@@ -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; }
}
}
+11
View File
@@ -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; }
}
}
+9
View File
@@ -0,0 +1,9 @@
namespace ErsatzTV.Core.Domain
{
public enum SourceMode
{
Transcode,
DirectPlay,
DirectPaths
}
}
+8
View File
@@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Domain
{
public enum StartType
{
Dynamic = 1,
Fixed = 2
}
}
+8
View File
@@ -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; }
}
}
+9
View File
@@ -0,0 +1,9 @@
namespace ErsatzTV.Core.Domain
{
public enum VideoScanType
{
Unknown = 0,
Progressive = 1,
Interlaced = 2
}
}
+16
View File
@@ -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>
+10
View File
@@ -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}";
}
}
+6
View File
@@ -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;
}
}
+74
View File
@@ -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;
}
}
+18
View File
@@ -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");
}
}
+23
View File
@@ -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>";
}
}
+29
View File
@@ -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; }
}
}
+24
View File
@@ -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}";
}
}
+12
View File
@@ -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);
}
}
+86
View File
@@ -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();
}
}
}
+52
View File
@@ -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();
}
}
}
+16
View File
@@ -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);
}
}
+140
View File
@@ -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();
}
}
}
+16
View File
@@ -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);
}
+4
View File
@@ -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;
}
}
+336
View File
@@ -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;
}
}
}