Compare commits

...
Author SHA1 Message Date
Jason DoveandGitHub e3b91e62ae new movie layout, new dark ui (#55)
* include cache header on artwork responses

* rework movie page to include fan art

* full width app bar

* dark mode

* cleanup

* fix placeholder color
2021-03-10 03:26:51 +00:00
Jason DoveandGitHub 54da3a3159 fix channel sorting (#54) 2021-03-09 03:40:13 +00:00
Jason DoveandGitHub d53a2f8bbf add subchannel support (#53) 2021-03-09 02:46:22 +00:00
Jason DoveandGitHub c2cbb1d5ff fix collection item sorting in ui (#52) 2021-03-09 00:38:18 +00:00
Jason DoveandGitHub bd231d57a7 fix vaapi pipeline with mpeg4 content (#51) 2021-03-09 00:24:08 +00:00
Jason DoveandGitHub 77cb2c2270 include tzdata in docker to support TZ env var again (#50) 2021-03-08 13:41:59 +00:00
Jason DoveandGitHub 5244d5076a use output duration flag (#49)
* re-enable output duration flag

* calculate appropriate duration for offline image
2021-03-08 11:16:56 +00:00
Jason DoveandGitHub 9841640128 add m3u codec hints for channels app (#48) 2021-03-08 02:36:27 +00:00
Jason DoveandGitHub a256095e12 enforce unique schedule name (#47) 2021-03-07 21:46:48 +00:00
Jason DoveandGitHub ed592bd0a0 Fix offline stream (#46)
* publish offline stream background image

* add text to offline stream
2021-03-07 21:18:38 +00:00
86 changed files with 5653 additions and 810 deletions
@@ -4,7 +4,7 @@ namespace ErsatzTV.Application.Channels
{
public record ChannelViewModel(
int Id,
int Number,
string Number,
string Name,
int FFmpegProfileId,
string Logo,
@@ -8,7 +8,7 @@ namespace ErsatzTV.Application.Channels.Commands
public record CreateChannel
(
string Name,
int Number,
string Number,
int FFmpegProfileId,
string Logo,
StreamingMode StreamingMode) : IRequest<Either<BaseError, ChannelViewModel>>;
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
@@ -35,7 +36,7 @@ namespace ErsatzTV.Application.Channels.Commands
_channelRepository.Add(c).Map(ProjectToViewModel);
private async Task<Validation<BaseError, Channel>> Validate(CreateChannel request) =>
(ValidateName(request), ValidateNumber(request), await FFmpegProfileMustExist(request))
(ValidateName(request), await ValidateNumber(request), await FFmpegProfileMustExist(request))
.Apply(
(name, number, ffmpegProfileId) =>
{
@@ -66,9 +67,21 @@ namespace ErsatzTV.Application.Channels.Commands
createChannel.NotEmpty(c => c.Name)
.Bind(_ => createChannel.NotLongerThan(50)(c => c.Name));
// TODO: validate number does not exist?
private Validation<BaseError, int> ValidateNumber(CreateChannel createChannel) =>
createChannel.AtLeast(1)(c => c.Number);
private async Task<Validation<BaseError, string>> ValidateNumber(CreateChannel createChannel)
{
Option<Channel> maybeExistingChannel = await _channelRepository.GetByNumber(createChannel.Number);
return maybeExistingChannel.Match<Validation<BaseError, string>>(
_ => BaseError.New("Channel number must be unique"),
() =>
{
if (Regex.IsMatch(createChannel.Number, @"^[0-9]+(\.[0-9])?$"))
{
return createChannel.Number;
}
return BaseError.New("Invalid channel number; one decimal is allowed for subchannels");
});
}
private async Task<Validation<BaseError, int>> FFmpegProfileMustExist(CreateChannel createChannel) =>
(await _ffmpegProfileRepository.Get(createChannel.FFmpegProfileId))
@@ -9,7 +9,7 @@ namespace ErsatzTV.Application.Channels.Commands
(
int ChannelId,
string Name,
int Number,
string Number,
int FFmpegProfileId,
string Logo,
StreamingMode StreamingMode) : IRequest<Either<BaseError, ChannelViewModel>>;
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
@@ -75,13 +76,18 @@ namespace ErsatzTV.Application.Channels.Commands
updateChannel.NotEmpty(c => c.Name)
.Bind(_ => updateChannel.NotLongerThan(50)(c => c.Name));
private async Task<Validation<BaseError, int>> ValidateNumber(UpdateChannel updateChannel)
private async Task<Validation<BaseError, string>> ValidateNumber(UpdateChannel updateChannel)
{
Option<Channel> match = await _channelRepository.GetByNumber(updateChannel.Number);
int matchId = match.Map(c => c.Id).IfNone(updateChannel.ChannelId);
if (matchId == updateChannel.ChannelId)
{
return updateChannel.AtLeast(1)(c => c.Number);
if (Regex.IsMatch(updateChannel.Number, @"^[0-9](\.[0-9])?$"))
{
return updateChannel.Number;
}
return BaseError.New("Invalid channel number; one decimal is allowed for subchannels");
}
return BaseError.New("Channel number must be unique");
@@ -42,6 +42,7 @@ namespace ErsatzTV.Application.Images.Queries
ArtworkKind.Poster => Path.Combine(FileSystemLayout.PosterCacheFolder, subfolder),
ArtworkKind.Thumbnail => Path.Combine(FileSystemLayout.ThumbnailCacheFolder, subfolder),
ArtworkKind.Logo => Path.Combine(FileSystemLayout.LogoCacheFolder, subfolder),
ArtworkKind.FanArt => Path.Combine(FileSystemLayout.FanArtCacheFolder, subfolder),
_ => FileSystemLayout.LegacyImageCacheFolder
};
@@ -16,11 +16,11 @@ namespace ErsatzTV.Application.MediaCards.Queries
public GetCollectionCardsHandler(IMediaCollectionRepository collectionRepository) =>
_collectionRepository = collectionRepository;
public async Task<Either<BaseError, CollectionCardResultsViewModel>> Handle(
public Task<Either<BaseError, CollectionCardResultsViewModel>> Handle(
GetCollectionCards request,
CancellationToken cancellationToken) =>
(await _collectionRepository.GetCollectionWithItemsUntracked(request.Id))
.ToEither(BaseError.New("Unable to load collection"))
.Map(ProjectToViewModel);
_collectionRepository.GetCollectionWithItemsUntracked(request.Id)
.Map(c => c.ToEither(BaseError.New("Unable to load collection")))
.MapT(ProjectToViewModel);
}
}
+6 -2
View File
@@ -13,8 +13,12 @@ namespace ErsatzTV.Application.Movies
metadata.Title,
metadata.Year?.ToString(),
metadata.Plot,
Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster))
.Match(a => a.Path, string.Empty));
Artwork(metadata, ArtworkKind.Poster),
Artwork(metadata, ArtworkKind.FanArt));
}
private static string Artwork(Metadata metadata, ArtworkKind artworkKind) =>
Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind))
.Match(a => a.Path, string.Empty);
}
}
@@ -1,4 +1,4 @@
namespace ErsatzTV.Application.Movies
{
public record MovieViewModel(string Title, string Year, string Plot, string Poster);
public record MovieViewModel(string Title, string Year, string Plot, string Poster, string FanArt);
}
@@ -1,4 +1,4 @@
namespace ErsatzTV.Application.Playouts
{
public record PlayoutChannelViewModel(int Id, int Number, string Name);
public record PlayoutChannelViewModel(int Id, string Number, string Name);
}
@@ -1,4 +1,6 @@
using System.Threading;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
@@ -6,6 +8,7 @@ using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.ProgramSchedules.Mapper;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.ProgramSchedules.Commands
{
@@ -22,22 +25,33 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
CreateProgramSchedule request,
CancellationToken cancellationToken) =>
Validate(request)
.Map(PersistProgramSchedule)
.ToEitherAsync();
.MapT(PersistProgramSchedule)
.Bind(v => v.ToEitherAsync());
private Task<ProgramScheduleViewModel> PersistProgramSchedule(ProgramSchedule c) =>
_programScheduleRepository.Add(c).Map(ProjectToViewModel);
private Validation<BaseError, ProgramSchedule> Validate(CreateProgramSchedule request) =>
private Task<Validation<BaseError, ProgramSchedule>> Validate(CreateProgramSchedule request) =>
ValidateName(request)
.Map(
.MapT(
name => new ProgramSchedule
{
Name = name, MediaCollectionPlaybackOrder = request.MediaCollectionPlaybackOrder
});
private Validation<BaseError, string> ValidateName(CreateProgramSchedule createProgramSchedule) =>
createProgramSchedule.NotEmpty(c => c.Name)
private async Task<Validation<BaseError, string>> ValidateName(CreateProgramSchedule createProgramSchedule)
{
List<string> allNames = await _programScheduleRepository.GetAll()
.Map(list => list.Map(c => c.Name).ToList());
Validation<BaseError, string> result1 = createProgramSchedule.NotEmpty(c => c.Name)
.Bind(_ => createProgramSchedule.NotLongerThan(50)(c => c.Name));
var result2 = Optional(createProgramSchedule.Name)
.Filter(name => !allNames.Contains(name))
.ToValidation<BaseError>("Schedule name must be unique");
return (result1, result2).Apply((_, _) => createProgramSchedule.Name);
}
}
}
@@ -5,5 +5,5 @@ using MediatR;
namespace ErsatzTV.Application.Streaming.Queries
{
public record FFmpegProcessRequest(int ChannelNumber) : IRequest<Either<BaseError, Process>>;
public record FFmpegProcessRequest(string ChannelNumber) : IRequest<Either<BaseError, Process>>;
}
@@ -6,5 +6,5 @@ using MediatR;
namespace ErsatzTV.Application.Streaming.Queries
{
public record GetConcatPlaylistByChannelNumber
(string Scheme, string Host, int ChannelNumber) : IRequest<Either<BaseError, ConcatPlaylist>>;
(string Scheme, string Host, string ChannelNumber) : IRequest<Either<BaseError, ConcatPlaylist>>;
}
@@ -2,7 +2,7 @@
{
public record GetConcatProcessByChannelNumber : FFmpegProcessRequest
{
public GetConcatProcessByChannelNumber(string scheme, string host, int channelNumber) : base(channelNumber)
public GetConcatProcessByChannelNumber(string scheme, string host, string channelNumber) : base(channelNumber)
{
Scheme = scheme;
Host = host;
@@ -5,5 +5,5 @@ using MediatR;
namespace ErsatzTV.Application.Streaming.Queries
{
public record GetHlsPlaylistByChannelNumber
(string Scheme, string Host, int ChannelNumber) : IRequest<Either<BaseError, string>>;
(string Scheme, string Host, string ChannelNumber) : IRequest<Either<BaseError, string>>;
}
@@ -2,7 +2,7 @@
{
public record GetPlayoutItemProcessByChannelNumber : FFmpegProcessRequest
{
public GetPlayoutItemProcessByChannelNumber(int channelNumber) : base(channelNumber)
public GetPlayoutItemProcessByChannelNumber(string channelNumber) : base(channelNumber)
{
}
}
@@ -10,7 +10,6 @@ using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.Streaming.Queries
{
@@ -69,18 +68,20 @@ namespace ErsatzTV.Application.Streaming.Queries
playoutItem.StartOffset,
now);
},
() =>
async () =>
{
if (channel.FFmpegProfile.Transcode)
{
return Right<BaseError, Process>(_ffmpegProcessService.ForOfflineImage(ffmpegPath, channel))
.AsTask();
Option<TimeSpan> maybeDuration = await _playoutRepository.GetNextItemStart(channel.Id, now)
.MapT(nextStart => nextStart - now);
return _ffmpegProcessService.ForOfflineImage(ffmpegPath, channel, maybeDuration);
}
var message =
$"Unable to locate playout item for channel {channel.Number}; offline image is unavailable because transcoding is disabled in ffmpeg profile '{channel.FFmpegProfile.Name}'";
return Left<BaseError, Process>(BaseError.New(message)).AsTask();
return BaseError.New(message);
});
}
@@ -275,44 +275,94 @@ namespace ErsatzTV.Core.Tests.FFmpeg
}
[Test]
[TestCase(true, false, false, "[0:v]deinterlace_vaapi[v]", "[v]")]
[TestCase("h264", true, false, false, "[0:v]deinterlace_vaapi[v]", "[v]")]
[TestCase(
"h264",
true,
true,
false,
"[0:v]deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
"[v]")]
[TestCase(
"h264",
true,
false,
true,
"[0:v]deinterlace_vaapi,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")]
[TestCase(
"h264",
true,
true,
true,
"[0:v]deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")]
[TestCase(
"h264",
false,
true,
false,
"[0:v]scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
"[v]")]
[TestCase(
"h264",
false,
false,
true,
"[0:v]hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")]
[TestCase(
"h264",
false,
true,
true,
"[0:v]scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")]
[TestCase("mpeg4", true, false, false, "[0:v]hwupload,deinterlace_vaapi[v]", "[v]")]
[TestCase(
"mpeg4",
true,
true,
false,
"[0:v]hwupload,deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
"[v]")]
[TestCase(
"mpeg4",
true,
false,
true,
"[0:v]hwupload,deinterlace_vaapi,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")]
[TestCase(
"mpeg4",
true,
true,
true,
"[0:v]hwupload,deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")]
[TestCase(
"mpeg4",
false,
true,
false,
"[0:v]hwupload,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
"[v]")]
[TestCase(
"mpeg4",
false,
false,
true,
"[0:v]setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")]
[TestCase(
"mpeg4",
false,
true,
true,
"[0:v]hwupload,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")]
public void Should_Return_VAAPI_Video_Filter(
string codec,
bool deinterlace,
bool scale,
bool pad,
@@ -321,6 +371,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder()
.WithHardwareAcceleration(HardwareAccelerationKind.Vaapi)
.WithInputCodec(codec)
.WithDeinterlace(deinterlace);
if (scale)
+1 -1
View File
@@ -8,7 +8,7 @@ namespace ErsatzTV.Core.Domain
public Channel(Guid uniqueId) => UniqueId = uniqueId;
public int Id { get; set; }
public Guid UniqueId { get; init; }
public int Number { get; set; }
public string Number { get; set; }
public string Name { get; set; }
public int FFmpegProfileId { get; set; }
public FFmpegProfile FFmpegProfile { get; set; }
@@ -15,6 +15,7 @@ namespace ErsatzTV.Core.Domain
public string SampleAspectRatio { get; set; }
public string DisplayAspectRatio { get; set; }
public string VideoCodec { get; set; }
public string VideoProfile { get; set; }
public string AudioCodec { get; set; }
public VideoScanKind VideoScanKind { get; set; }
public DateTime DateAdded { get; set; }
+2 -1
View File
@@ -4,6 +4,7 @@
{
Poster = 0,
Thumbnail = 1,
Logo = 2
Logo = 2,
FanArt = 3
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
namespace ErsatzTV.Core.FFmpeg
{
public record ConcatPlaylist(string Scheme, string Host, int ChannelNumber)
public record ConcatPlaylist(string Scheme, string Host, string ChannelNumber)
{
public override string ToString() =>
$@"ffconcat version 1.0
@@ -14,6 +14,7 @@ namespace ErsatzTV.Core.FFmpeg
private Option<TimeSpan> _audioDuration = None;
private bool _deinterlace;
private Option<HardwareAccelerationKind> _hardwareAccelerationKind = None;
private string _inputCodec;
private Option<IDisplaySize> _padToSize = None;
private Option<IDisplaySize> _scaleToSize = None;
@@ -47,6 +48,12 @@ namespace ErsatzTV.Core.FFmpeg
return this;
}
public FFmpegComplexFilterBuilder WithInputCodec(string codec)
{
_inputCodec = codec;
return this;
}
public Option<FFmpegComplexFilter> Build()
{
var complexFilter = new StringBuilder();
@@ -55,6 +62,13 @@ namespace ErsatzTV.Core.FFmpeg
var audioLabel = "0:a";
HardwareAccelerationKind acceleration = _hardwareAccelerationKind.IfNone(HardwareAccelerationKind.None);
bool isHardwareDecode = acceleration switch
{
HardwareAccelerationKind.Vaapi => _inputCodec != "mpeg4",
HardwareAccelerationKind.Nvenc => true,
HardwareAccelerationKind.Qsv => true,
_ => false
};
_audioDuration.IfSome(
audioDuration =>
@@ -67,6 +81,13 @@ namespace ErsatzTV.Core.FFmpeg
var filterQueue = new List<string>();
bool usesHardwareFilters = acceleration != HardwareAccelerationKind.None && !isHardwareDecode &&
(_deinterlace || _scaleToSize.IsSome);
if (usesHardwareFilters)
{
filterQueue.Add("hwupload");
}
if (_deinterlace)
{
string filter = acceleration switch
@@ -102,7 +123,7 @@ namespace ErsatzTV.Core.FFmpeg
if (_scaleToSize.IsSome || _padToSize.IsSome)
{
if (acceleration != HardwareAccelerationKind.None)
if (acceleration != HardwareAccelerationKind.None && (isHardwareDecode || usesHardwareFilters))
{
filterQueue.Add("hwdownload");
string format = acceleration switch
+15 -8
View File
@@ -153,6 +153,8 @@ namespace ErsatzTV.Core.FFmpeg
_arguments.Add(qsvCodec);
}
_complexFilterBuilder = _complexFilterBuilder.WithInputCodec(codec);
_arguments.Add("-i");
_arguments.Add($"{input}");
return this;
@@ -213,21 +215,26 @@ namespace ErsatzTV.Core.FFmpeg
return this;
}
public FFmpegProcessBuilder WithText(string text)
public FFmpegProcessBuilder WithErrorText(IDisplaySize desiredResolution, string text)
{
const string FONT_FILE = "fontfile=Resources/Roboto-Regular.ttf";
const string FONT_SIZE = "fontsize=30";
const string FONT_SIZE = "fontsize=60";
const string FONT_COLOR = "fontcolor=white";
const string X = "x=(w-text_w)/2";
const string Y = "y=(h-text_h)/2";
const string Y = "y=(h-text_h)/3*2";
return WithFiltergraph($"drawtext={FONT_FILE}:{FONT_SIZE}:{FONT_COLOR}:{X}:{Y}:text='{text}'");
return WithFilterComplex(
$"[0:0]scale={desiredResolution.Width}:{desiredResolution.Height},drawtext={FONT_FILE}:{FONT_SIZE}:{FONT_COLOR}:{X}:{Y}:text='{text}'[v]",
"[v]",
"1:a");
}
public FFmpegProcessBuilder WithDuration(TimeSpan duration) =>
// _arguments.Add("-t");
// _arguments.Add($"{duration:c}");
this;
public FFmpegProcessBuilder WithDuration(TimeSpan duration)
{
_arguments.Add("-t");
_arguments.Add($"{duration:c}");
return this;
}
public FFmpegProcessBuilder WithFormat(string format)
{
+9 -10
View File
@@ -2,6 +2,7 @@
using System.Diagnostics;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using LanguageExt;
namespace ErsatzTV.Core.FFmpeg
{
@@ -83,14 +84,14 @@ namespace ErsatzTV.Core.FFmpeg
.Build();
}
public Process ForOfflineImage(string ffmpegPath, Channel channel)
public Process ForOfflineImage(string ffmpegPath, Channel channel, Option<TimeSpan> duration)
{
FFmpegPlaybackSettings playbackSettings =
_playbackSettingsCalculator.CalculateErrorSettings(channel.FFmpegProfile);
IDisplaySize desiredResolution = channel.FFmpegProfile.Resolution;
return new FFmpegProcessBuilder(ffmpegPath)
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath)
.WithThreads(1)
.WithQuiet()
.WithFormatFlags(playbackSettings.FormatFlags)
@@ -98,17 +99,15 @@ namespace ErsatzTV.Core.FFmpeg
.WithLoopedImage("Resources/background.png")
.WithLibavfilter()
.WithInput("anullsrc")
.WithFilterComplex(
$"[0:0]scale={desiredResolution.Width}:{desiredResolution.Height}[video]",
"[video]",
"1:a")
.WithErrorText(desiredResolution, "Channel is Offline")
.WithPixfmt("yuv420p")
.WithPlaybackArgs(playbackSettings)
.WithMetadata(channel)
.WithFormat("mpegts")
.WithDuration(TimeSpan.FromSeconds(10)) // TODO: figure out when we're back online
.WithPipe()
.Build();
.WithFormat("mpegts");
duration.IfSome(d => builder = builder.WithDuration(d));
return builder.WithPipe().Build();
}
public Process ConcatChannel(string ffmpegPath, Channel channel, string scheme, string host)
+1
View File
@@ -24,5 +24,6 @@ namespace ErsatzTV.Core
public static readonly string PosterCacheFolder = Path.Combine(ArtworkCacheFolder, "posters");
public static readonly string ThumbnailCacheFolder = Path.Combine(ArtworkCacheFolder, "thumbnails");
public static readonly string LogoCacheFolder = Path.Combine(ArtworkCacheFolder, "logos");
public static readonly string FanArtCacheFolder = Path.Combine(ArtworkCacheFolder, "fanart");
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ namespace ErsatzTV.Core.Hdhr
_channel = channel;
}
public string GuideNumber => _channel.Number.ToString();
public string GuideNumber => _channel.Number;
public string GuideName => _channel.Name;
public string URL => _channel.StreamingMode switch
@@ -9,7 +9,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
{
Task<Channel> Add(Channel channel);
Task<Option<Channel>> Get(int id);
Task<Option<Channel>> GetByNumber(int number);
Task<Option<Channel>> GetByNumber(string number);
Task<List<Channel>> GetAll();
Task<List<Channel>> GetAllForGuide();
Task Update(Channel channel);
@@ -12,6 +12,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<Option<Playout>> Get(int id);
Task<Option<Playout>> GetFull(int id);
Task<Option<PlayoutItem>> GetPlayoutItem(int channelId, DateTimeOffset now);
Task<Option<DateTimeOffset>> GetNextItemStart(int channelId, DateTimeOffset now);
Task<List<PlayoutItem>> GetPlayoutItems(int playoutId);
Task<List<Playout>> GetAll();
Task Update(Playout playout);
+4 -4
View File
@@ -30,10 +30,10 @@ namespace ErsatzTV.Core.Iptv
xml.WriteStartElement("tv");
xml.WriteAttributeString("generator-info-name", "ersatztv");
foreach (Channel channel in _channels)
foreach (Channel channel in _channels.OrderBy(c => c.Number))
{
xml.WriteStartElement("channel");
xml.WriteAttributeString("id", channel.Number.ToString());
xml.WriteAttributeString("id", channel.Number);
xml.WriteStartElement("display-name");
xml.WriteAttributeString("lang", "en");
@@ -53,7 +53,7 @@ namespace ErsatzTV.Core.Iptv
xml.WriteEndElement(); // channel
}
foreach (Channel channel in _channels)
foreach (Channel channel in _channels.OrderBy(c => c.Number))
{
foreach (PlayoutItem playoutItem in channel.Playouts.Collect(p => p.Items).OrderBy(i => i.Start))
{
@@ -87,7 +87,7 @@ namespace ErsatzTV.Core.Iptv
xml.WriteStartElement("programme");
xml.WriteAttributeString("start", start);
xml.WriteAttributeString("stop", stop);
xml.WriteAttributeString("channel", channel.Number.ToString());
xml.WriteAttributeString("channel", channel.Number);
xml.WriteStartElement("title");
xml.WriteAttributeString("lang", "en");
+6 -2
View File
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using ErsatzTV.Core.Domain;
using static LanguageExt.Prelude;
@@ -25,7 +26,7 @@ namespace ErsatzTV.Core.Iptv
var xmltv = $"{_scheme}://{_host}/iptv/xmltv.xml";
sb.AppendLine($"#EXTM3U url-tvg=\"{xmltv}\" x-tvg-url=\"{xmltv}\"");
foreach (Channel channel in _channels)
foreach (Channel channel in _channels.OrderBy(c => c.Number))
{
string logo = Optional(channel.Artwork).Flatten()
.Filter(a => a.ArtworkKind == ArtworkKind.Logo)
@@ -45,8 +46,11 @@ namespace ErsatzTV.Core.Iptv
_ => "ts"
};
string vcodec = channel.FFmpegProfile.VideoCodec.Split("_").Head();
string acodec = channel.FFmpegProfile.AudioCodec;
sb.AppendLine(
$"#EXTINF:0 tvg-id=\"{channel.Number}\" channel-id=\"{shortUniqueId}\" channel-number=\"{channel.Number}\" CUID=\"{shortUniqueId}\" tvg-chno=\"{channel.Number}\" tvg-name=\"{channel.Name}\" tvg-logo=\"{logo}\" group-title=\"ErsatzTV\", {channel.Name}");
$"#EXTINF:0 tvg-id=\"{channel.Number}\" channel-id=\"{shortUniqueId}\" channel-number=\"{channel.Number}\" CUID=\"{shortUniqueId}\" tvg-chno=\"{channel.Number}\" tvg-name=\"{channel.Name}\" tvg-logo=\"{logo}\" group-title=\"ErsatzTV\" tvc-stream-vcodec=\"{vcodec}\" tvc-stream-acodec=\"{acodec}\", {channel.Name}");
sb.AppendLine($"{_scheme}://{_host}/iptv/channel/{channel.Number}.{format}");
}
+3 -3
View File
@@ -97,10 +97,10 @@ namespace ErsatzTV.Core.Metadata
metadata.Artwork ??= new List<Artwork>();
Option<Artwork> maybePoster =
Option<Artwork> maybeArtwork =
Optional(metadata.Artwork).Flatten().FirstOrDefault(a => a.ArtworkKind == artworkKind);
bool shouldRefresh = maybePoster.Match(
bool shouldRefresh = maybeArtwork.Match(
artwork => artwork.DateUpdated < lastWriteTime,
true);
@@ -109,7 +109,7 @@ namespace ErsatzTV.Core.Metadata
_logger.LogDebug("Refreshing {Attribute} from {Path}", artworkKind, artworkFile);
string cacheName = _imageCache.CopyArtworkToCache(artworkFile, artworkKind);
maybePoster.Match(
maybeArtwork.Match(
artwork =>
{
artwork.Path = cacheName;
@@ -70,6 +70,7 @@ namespace ErsatzTV.Core.Metadata
mediaItemVersion.Width = version.Width;
mediaItemVersion.Height = version.Height;
mediaItemVersion.VideoCodec = version.VideoCodec;
mediaItemVersion.VideoProfile = version.VideoProfile;
mediaItemVersion.VideoScanKind = version.VideoScanKind;
return await _mediaItemRepository.Update(mediaItem) && durationChange;
@@ -134,6 +135,7 @@ namespace ErsatzTV.Core.Metadata
version.Width = videoStream.width;
version.Height = videoStream.height;
version.VideoCodec = videoStream.codec_name;
version.VideoProfile = (videoStream.profile ?? string.Empty).ToLowerInvariant();
version.VideoScanKind = ScanKindFromFieldOrder(videoStream.field_order);
}
@@ -157,6 +159,7 @@ namespace ErsatzTV.Core.Metadata
public record FFprobeStream(
int index,
string codec_name,
string profile,
string codec_type,
int width,
int height,
+34 -4
View File
@@ -80,7 +80,8 @@ namespace ErsatzTV.Core.Metadata
.GetOrAdd(libraryPath, file)
.BindT(movie => UpdateStatistics(movie, ffprobePath).MapT(_ => movie))
.BindT(UpdateMetadata)
.BindT(UpdatePoster);
.BindT(UpdatePoster)
.BindT(UpdateFanArt);
maybeMovie.IfLeft(
error => _logger.LogWarning("Error processing movie at {Path}: {Error}", file, error.Value));
@@ -139,7 +140,7 @@ namespace ErsatzTV.Core.Metadata
{
try
{
await LocatePoster(movie).IfSomeAsync(
await LocateArtwork(movie, ArtworkKind.Poster).IfSomeAsync(
async posterFile =>
{
MovieMetadata metadata = movie.MovieMetadata.Head();
@@ -157,6 +158,28 @@ namespace ErsatzTV.Core.Metadata
}
}
private async Task<Either<BaseError, Movie>> UpdateFanArt(Movie movie)
{
try
{
await LocateArtwork(movie, ArtworkKind.FanArt).IfSomeAsync(
async posterFile =>
{
MovieMetadata metadata = movie.MovieMetadata.Head();
if (RefreshArtwork(posterFile, metadata, ArtworkKind.FanArt))
{
await _movieRepository.Update(movie);
}
});
return movie;
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
private Option<string> LocateNfoFile(Movie movie)
{
string path = movie.MediaVersions.Head().MediaFiles.Head().Path;
@@ -167,12 +190,19 @@ namespace ErsatzTV.Core.Metadata
.HeadOrNone();
}
private Option<string> LocatePoster(Movie movie)
private Option<string> LocateArtwork(Movie movie, ArtworkKind artworkKind)
{
string segment = artworkKind switch
{
ArtworkKind.Poster => "poster",
ArtworkKind.FanArt => "fanart",
_ => throw new ArgumentOutOfRangeException(nameof(artworkKind))
};
string path = movie.MediaVersions.Head().MediaFiles.Head().Path;
string folder = Path.GetDirectoryName(path) ?? string.Empty;
IEnumerable<string> possibleMoviePosters = ImageFileExtensions.Collect(
ext => new[] { $"poster.{ext}", Path.GetFileNameWithoutExtension(path) + $"-poster.{ext}" })
ext => new[] { $"{segment}.{ext}", Path.GetFileNameWithoutExtension(path) + $"-{segment}.{ext}" })
.Map(f => Path.Combine(folder, f));
Option<string> result = possibleMoviePosters.Filter(p => _localFileSystem.FileExists(p)).HeadOrNone();
return result;
@@ -47,7 +47,7 @@ namespace ErsatzTV.Infrastructure.Data
var defaultChannel = new Channel(Guid.NewGuid())
{
Number = 1,
Number = "1",
Name = "ErsatzTV",
FFmpegProfile = defaultProfile,
StreamingMode = StreamingMode.TransportStream
@@ -29,7 +29,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.SingleOrDefaultAsync(c => c.Id == id)
.Map(Optional);
public Task<Option<Channel>> GetByNumber(int number) =>
public Task<Option<Channel>> GetByNumber(string number) =>
_dbContext.Channels
.Include(c => c.FFmpegProfile)
.ThenInclude(p => p.Resolution)
@@ -39,6 +39,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public Task<List<Channel>> GetAll() =>
_dbContext.Channels
.Include(c => c.FFmpegProfile)
.Include(c => c.Artwork)
.ToListAsync();
@@ -44,8 +44,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.OrderBy(p => p.Id) // https://github.com/dotnet/efcore/issues/22579#issuecomment-694772289
.SingleOrDefaultAsync(p => p.Id == id);
public async Task<Option<PlayoutItem>> GetPlayoutItem(int channelId, DateTimeOffset now) =>
await _dbContext.PlayoutItems
public Task<Option<PlayoutItem>> GetPlayoutItem(int channelId, DateTimeOffset now) =>
_dbContext.PlayoutItems
.Where(pi => pi.Playout.ChannelId == channelId)
.Where(pi => pi.Start <= now.UtcDateTime && pi.Finish > now.UtcDateTime)
.Include(i => i.MediaItem)
@@ -55,7 +55,17 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(mi => (mi as Movie).MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.AsNoTracking()
.SingleOrDefaultAsync();
.SingleOrDefaultAsync()
.Map(Optional);
public Task<Option<DateTimeOffset>> GetNextItemStart(int channelId, DateTimeOffset now) =>
_dbContext.PlayoutItems
.Where(pi => pi.Playout.ChannelId == channelId)
.Where(pi => pi.Finish > now.UtcDateTime)
.OrderBy(pi => pi.Finish)
.FirstOrDefaultAsync()
.Map(Optional)
.MapT(pi => pi.StartOffset);
public Task<List<PlayoutItem>> GetPlayoutItems(int playoutId) =>
_dbContext.PlayoutItems
@@ -56,6 +56,7 @@ namespace ErsatzTV.Infrastructure.Images
ArtworkKind.Poster => Path.Combine(FileSystemLayout.PosterCacheFolder, subfolder),
ArtworkKind.Thumbnail => Path.Combine(FileSystemLayout.ThumbnailCacheFolder, subfolder),
ArtworkKind.Logo => Path.Combine(FileSystemLayout.LogoCacheFolder, subfolder),
ArtworkKind.FanArt => Path.Combine(FileSystemLayout.FanArtCacheFolder, subfolder),
_ => FileSystemLayout.LegacyImageCacheFolder
};
string target = Path.Combine(baseFolder, hex);
@@ -85,6 +86,7 @@ namespace ErsatzTV.Infrastructure.Images
ArtworkKind.Poster => Path.Combine(FileSystemLayout.PosterCacheFolder, subfolder),
ArtworkKind.Thumbnail => Path.Combine(FileSystemLayout.ThumbnailCacheFolder, subfolder),
ArtworkKind.Logo => Path.Combine(FileSystemLayout.LogoCacheFolder, subfolder),
ArtworkKind.FanArt => Path.Combine(FileSystemLayout.FanArtCacheFolder, subfolder),
_ => FileSystemLayout.LegacyImageCacheFolder
};
string target = Path.Combine(baseFolder, hex);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,19 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_MediaVersionVideoProfile : Migration
{
protected override void Up(MigrationBuilder migrationBuilder) =>
migrationBuilder.AddColumn<string>(
"VideoProfile",
"MediaVersion",
"TEXT",
nullable: true);
protected override void Down(MigrationBuilder migrationBuilder) =>
migrationBuilder.DropColumn(
"VideoProfile",
"MediaVersion");
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,14 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Reset_MediaVersionDateUpdated : Migration
{
protected override void Up(MigrationBuilder migrationBuilder) =>
migrationBuilder.Sql(@"UPDATE MediaVersion SET DateUpdated = '0001-01-01 00:00:00'");
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Update_ChannelNumberType : Migration
{
protected override void Up(MigrationBuilder migrationBuilder) =>
migrationBuilder.AlterColumn<string>(
"Number",
"Channel",
"TEXT",
nullable: true,
oldClrType: typeof(int),
oldType: "INTEGER");
protected override void Down(MigrationBuilder migrationBuilder) =>
migrationBuilder.AlterColumn<int>(
"Number",
"Channel",
"INTEGER",
nullable: false,
defaultValue: 0,
oldClrType: typeof(string),
oldType: "TEXT",
oldNullable: true);
}
}
@@ -80,8 +80,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int>("Number")
.HasColumnType("INTEGER");
b.Property<string>("Number")
.HasColumnType("TEXT");
b.Property<int>("StreamingMode")
.HasColumnType("INTEGER");
@@ -418,6 +418,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<string>("VideoCodec")
.HasColumnType("TEXT");
b.Property<string>("VideoProfile")
.HasColumnType("TEXT");
b.Property<int>("VideoScanKind")
.HasColumnType("INTEGER");
@@ -13,6 +13,7 @@ namespace ErsatzTV.Infrastructure.Plex.Models
public int AudioChannels { get; set; }
public string AudioCodec { get; set; }
public string VideoCodec { get; set; }
public string VideoProfile { get; set; }
public string Container { get; set; }
public string VideoFrameRate { get; set; }
public List<PlexPartResponse> Part { get; set; }
@@ -125,6 +125,7 @@ namespace ErsatzTV.Infrastructure.Plex
Height = media.Height,
AudioCodec = media.AudioCodec,
VideoCodec = media.VideoCodec,
VideoProfile = media.VideoProfile,
SampleAspectRatio = ConvertToSAR(media.AspectRatio),
MediaFiles = new List<MediaFile>
{
+1
View File
@@ -14,6 +14,7 @@
<s:Boolean x:Key="/Default/UserDictionary/Words/=drawtext/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=ersatztv/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=etvignore/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=fanart/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=faststart/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=featurette/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=featurettes/@EntryIndexedValue">True</s:Boolean>
+11
View File
@@ -14,6 +14,7 @@ using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers
{
[ResponseCache(Duration = 3600)]
[ApiController]
[ApiExplorerSettings(IgnoreApi = true)]
public class PostersController : ControllerBase
@@ -37,6 +38,16 @@ namespace ErsatzTV.Controllers
Right: r => new FileContentResult(r.Contents, r.MimeType));
}
[HttpGet("/artwork/fanart/{fileName}")]
public async Task<IActionResult> GetFanArt(string fileName)
{
Either<BaseError, ImageViewModel> imageContents =
await _mediator.Send(new GetImageContents(fileName, ArtworkKind.FanArt));
return imageContents.Match<IActionResult>(
Left: _ => new NotFoundResult(),
Right: r => new FileContentResult(r.Contents, r.MimeType));
}
[HttpGet("/artwork/posters/plex/{plexMediaSourceId}/{*path}")]
public async Task<IActionResult> GetPlexPoster(int plexMediaSourceId, string path)
{
+2 -2
View File
@@ -22,12 +22,12 @@ namespace ErsatzTV.Controllers
}
[HttpGet("ffmpeg/concat/{channelNumber}")]
public Task<IActionResult> GetConcatPlaylist(int channelNumber) =>
public Task<IActionResult> GetConcatPlaylist(string channelNumber) =>
_mediator.Send(new GetConcatPlaylistByChannelNumber(Request.Scheme, Request.Host.ToString(), channelNumber))
.ToActionResult();
[HttpGet("ffmpeg/stream/{channelNumber}")]
public Task<IActionResult> GetStream(int channelNumber) =>
public Task<IActionResult> GetStream(string channelNumber) =>
_mediator.Send(new GetPlayoutItemProcessByChannelNumber(channelNumber)).Map(
result =>
result.Match<IActionResult>(
+2 -2
View File
@@ -37,7 +37,7 @@ namespace ErsatzTV.Controllers
.Map<ChannelGuide, IActionResult>(Ok);
[HttpGet("iptv/channel/{channelNumber}.ts")]
public Task<IActionResult> GetTransportStreamVideo(int channelNumber) =>
public Task<IActionResult> GetTransportStreamVideo(string channelNumber) =>
_mediator.Send(new GetConcatProcessByChannelNumber(Request.Scheme, Request.Host.ToString(), channelNumber))
.Map(
result => result.Match<IActionResult>(
@@ -50,7 +50,7 @@ namespace ErsatzTV.Controllers
error => BadRequest(error.Value)));
[HttpGet("iptv/channel/{channelNumber}.m3u8")]
public Task<IActionResult> GetHttpLiveStreamingVideo(int channelNumber) =>
public Task<IActionResult> GetHttpLiveStreamingVideo(string channelNumber) =>
_mediator.Send(new GetHlsPlaylistByChannelNumber(Request.Scheme, Request.Host.ToString(), channelNumber))
.Map(
result => result.Match<IActionResult>(
+9
View File
@@ -36,4 +36,13 @@
<ProjectReference Include="..\ErsatzTV.Infrastructure\ErsatzTV.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="Resources\background.png">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Resources\Roboto-Regular.ttf">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
+9 -6
View File
@@ -10,7 +10,8 @@
@inject ISnackbar Snackbar
@inject IMediator Mediator
<div style="max-width: 400px;">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<div style="max-width: 400px;">
<MudText Typo="Typo.h4" Class="mb-4">@(IsEdit ? "Edit Channel" : "Add Channel")</MudText>
<EditForm EditContext="_editContext" OnSubmit="@HandleSubmitAsync">
@@ -58,7 +59,8 @@
</MudCardActions>
</MudCard>
</EditForm>
</div>
</div>
</MudContainer>
@code {
@@ -93,8 +95,9 @@
else
{
// TODO: command for new channel
int maxNumber = await Mediator.Send(new GetAllChannels()).Map(channels => channels.Max(c => c.Number));
_model.Number = maxNumber + 1;
int maxNumber = await Mediator.Send(new GetAllChannels())
.Map(list => list.Map(c => int.TryParse(c.Number.Split(".").Head(), out int result) ? result : 0).Max());
_model.Number = (maxNumber + 1).ToString();
_model.Name = "New Channel";
_model.FFmpegProfileId = _ffmpegProfiles.Head().Id;
_model.StreamingMode = StreamingMode.TransportStream;
@@ -124,7 +127,7 @@
errorMessage.HeadOrNone().Match(
error =>
{
Snackbar.Add($"Unexpected error saving channel: {error.Value}");
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Unexpected error saving channel: {Error}", error.Value);
},
() => NavigationManager.NavigateTo("/channels"));
@@ -144,7 +147,7 @@
},
error =>
{
Snackbar.Add($"Unexpected error saving channel logo: {error.Value}");
Snackbar.Add($"Unexpected error saving channel logo: {error.Value}", Severity.Error);
Logger.LogError("Unexpected error saving channel logo: {Error}", error.Value);
});
}
+17 -11
View File
@@ -7,7 +7,7 @@
@inject IDialogService Dialog
@inject IMediator Mediator
<MudContainer MaxWidth="MaxWidth.ExtraLarge">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudTable Hover="true" Items="_channels">
<ToolBarContent>
<MudText Typo="Typo.h6">Channels</MudText>
@@ -18,7 +18,7 @@
<col style="width: 20%"/>
<col style="width: 20%"/>
<col style="width: 20%"/>
<col style="width: 60px;"/>
<col style="width: 120px;"/>
</ColGroup>
<HeaderContent>
<MudTh>
@@ -49,14 +49,18 @@
}
</MudTd>
<MudTd>
<MudMenu Icon="@Icons.Material.Filled.MoreVert" Direction="Direction.Left" OffsetX="true">
<MudMenuItem Icon="@Icons.Material.Filled.Edit" Link="@($"/channels/{context.Id}")">
Edit
</MudMenuItem>
<MudMenuItem Icon="@Icons.Material.Filled.Delete" OnClick="@(_ => DeleteChannelAsync(context))">
Delete
</MudMenuItem>
</MudMenu>
<div style="align-items: center; display: flex;">
<MudTooltip Text="Edit Channel">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Link="@($"/channels/{context.Id}")">
</MudIconButton>
</MudTooltip>
<MudTooltip Text="Delete Channel">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
OnClick="@(_ => DeleteChannelAsync(context))">
</MudIconButton>
</MudTooltip>
</div>
</MudTd>
</RowTemplate>
</MudTable>
@@ -89,5 +93,7 @@
}
}
private async Task LoadChannelsAsync() => _channels = await Mediator.Send(new GetAllChannels());
private async Task LoadChannelsAsync() => _channels = await Mediator.Send(new GetAllChannels())
.Map(list => list.OrderBy(c => c.Number).ToList());
}
+4 -2
View File
@@ -8,7 +8,8 @@
@inject ISnackbar Snackbar
@inject IMediator Mediator
<div style="max-width: 400px;">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<div style="max-width: 400px;">
<MudText Typo="Typo.h4" Class="mb-4">@(IsEdit ? "Edit Collection" : "Add Collection")</MudText>
<EditForm EditContext="_editContext" OnSubmit="@HandleSubmitAsync">
@@ -24,7 +25,8 @@
</MudCardActions>
</MudCard>
</EditForm>
</div>
</div>
</MudContainer>
@code {
+19 -17
View File
@@ -9,47 +9,48 @@
@inject IDialogService Dialog
@inject ChannelWriter<IBackgroundServiceRequest> Channel
<div class="mb-6" style="display: flex; flex-direction: row;">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<div class="mb-6" style="display: flex; flex-direction: row;">
<MudText GutterBottom="true" Typo="Typo.h2">@_data.Name</MudText>
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Link="@($"/media/collections/{Id}/edit")"
Style="margin-bottom: auto; margin-top: auto;"/>
</div>
</div>
@if (_data.MovieCards.Any())
{
@if (_data.MovieCards.Any())
{
<MudText GutterBottom="true" Typo="Typo.h4">Movies</MudText>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (MovieCardViewModel card in _data.MovieCards)
@foreach (MovieCardViewModel card in _data.MovieCards.OrderBy(m => m.SortTitle))
{
<MediaCard Data="@card"
Link="@($"/media/movies/{card.MovieId}")"
DeleteClicked="@RemoveMovieFromCollection"/>
}
</MudContainer>
}
}
@if (_data.ShowCards.Any())
{
@if (_data.ShowCards.Any())
{
<MudText GutterBottom="true" Typo="Typo.h4">Television Shows</MudText>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (TelevisionShowCardViewModel card in _data.ShowCards)
@foreach (TelevisionShowCardViewModel card in _data.ShowCards.OrderBy(m => m.SortTitle))
{
<MediaCard Data="@card"
Link="@($"/media/tv/shows/{card.TelevisionShowId}")"
DeleteClicked="@RemoveShowFromCollection"/>
}
</MudContainer>
}
}
@if (_data.SeasonCards.Any())
{
@if (_data.SeasonCards.Any())
{
<MudText GutterBottom="true" Typo="Typo.h4">Television Seasons</MudText>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (TelevisionSeasonCardViewModel card in _data.SeasonCards)
@foreach (TelevisionSeasonCardViewModel card in _data.SeasonCards.OrderBy(m => m.SortTitle))
{
<MediaCard Data="@card"
Link="@($"/media/tv/seasons/{card.TelevisionSeasonId}")"
@@ -58,10 +59,10 @@
DeleteClicked="@RemoveSeasonFromCollection"/>
}
</MudContainer>
}
}
@if (_data.EpisodeCards.Any())
{
@if (_data.EpisodeCards.Any())
{
<MudText GutterBottom="true" Typo="Typo.h4">Television Episodes</MudText>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@@ -77,7 +78,8 @@
ArtworkKind="@ArtworkKind.Thumbnail"/>
}
</MudContainer>
}
}
</MudContainer>
@code {
+5 -3
View File
@@ -6,7 +6,8 @@
@inject IDialogService Dialog
@inject IMediator Mediator
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (MediaCollectionViewModel card in _data)
{
<MediaCard Data="@card"
@@ -15,12 +16,13 @@
CardClass="media-card-episode"
DeleteClicked="@DeleteMediaCollection"/>
}
</MudContainer>
</MudContainer>
<MudContainer MaxWidth="MaxWidth.False">
<MudContainer MaxWidth="MaxWidth.False">
<MudButton Variant="Variant.Filled" Color="Color.Primary" Link="/media/collections/add" Class="mt-4">
Add Collection
</MudButton>
</MudContainer>
</MudContainer>
@code {
+18 -14
View File
@@ -5,7 +5,7 @@
@inject IDialogService Dialog
@inject IMediator Mediator
<MudContainer MaxWidth="MaxWidth.ExtraLarge">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudCard>
<MudCardHeader>
<CardHeaderContent>
@@ -37,7 +37,7 @@
<ToolBarContent>
<MudText Typo="Typo.h6">FFmpeg Profiles</MudText>
<MudToolBarSpacer></MudToolBarSpacer>
<MudText Color="Color.Primary">Colored settings will be normalized</MudText>
<MudText Color="Color.Tertiary">Colored settings will be normalized</MudText>
</ToolBarContent>
<ColGroup>
<col/>
@@ -45,7 +45,7 @@
<col/>
<col/>
<col/>
<col style="width: 60px;"/>
<col style="width: 120px;"/>
</ColGroup>
<HeaderContent>
<MudTh>Name</MudTh>
@@ -61,29 +61,33 @@
@(context.Transcode ? "Yes" : "No")
</MudTd>
<MudTd DataLabel="Resolution">
<MudText Color="@(context.Transcode && context.NormalizeResolution ? Color.Primary : Color.Inherit)">
<MudText Color="@(context.Transcode && context.NormalizeResolution ? Color.Tertiary : Color.Inherit)">
@context.Resolution.Name
</MudText>
</MudTd>
<MudTd DataLabel="Video Codec">
<MudText Color="@(context.Transcode && context.NormalizeVideoCodec ? Color.Primary : Color.Inherit)">
<MudText Color="@(context.Transcode && context.NormalizeVideoCodec ? Color.Tertiary : Color.Inherit)">
@context.VideoCodec
</MudText>
</MudTd>
<MudTd DataLabel="Audio Codec">
<MudText Color="@(context.Transcode && context.NormalizeAudioCodec ? Color.Primary : Color.Inherit)">
<MudText Color="@(context.Transcode && context.NormalizeAudioCodec ? Color.Tertiary : Color.Inherit)">
@context.AudioCodec
</MudText>
</MudTd>
<MudTd>
<MudMenu Icon="@Icons.Material.Filled.MoreVert" Direction="Direction.Left" OffsetX="true">
<MudMenuItem Icon="@Icons.Material.Filled.Edit" Link="@($"/ffmpeg/{context.Id}")">
Edit
</MudMenuItem>
<MudMenuItem Icon="@Icons.Material.Filled.Delete" OnClick="@(_ => DeleteProfileAsync(context))">
Delete
</MudMenuItem>
</MudMenu>
<div style="align-items: center; display: flex;">
<MudTooltip Text="Edit Channel">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Link="@($"/ffmpeg/{context.Id}")">
</MudIconButton>
</MudTooltip>
<MudTooltip Text="Delete Channel">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
OnClick="@(_ => DeleteProfileAsync(context))">
</MudIconButton>
</MudTooltip>
</div>
</MudTd>
</RowTemplate>
</MudTable>
+4 -3
View File
@@ -10,7 +10,8 @@
@inject ISnackbar Snackbar
@inject IMediator Mediator
<EditForm EditContext="_editContext" OnSubmit="@HandleSubmitAsync">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<EditForm EditContext="_editContext" OnSubmit="@HandleSubmitAsync">
<FluentValidator/>
<MudCard>
<MudCardHeader>
@@ -100,8 +101,8 @@
</MudButton>
</MudCardActions>
</MudCard>
</EditForm>
</EditForm>
</MudContainer>
@code {
+4 -2
View File
@@ -1,6 +1,7 @@
@page "/"
<MudCard>
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudCard>
<MudCardContent>
<MudText Typo="Typo.h3">Welcome to ErsatzTV!</MudText>
<MudElement HtmlTag="div" Class="mt-6">
@@ -67,4 +68,5 @@
</MudText>
</MudElement>
</MudCardContent>
</MudCard>
</MudCard>
</MudContainer>
+1 -1
View File
@@ -8,7 +8,7 @@
@inject IEntityLocker Locker
@inject ChannelWriter<IBackgroundServiceRequest> Channel
<MudContainer MaxWidth="MaxWidth.ExtraLarge">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudTable Hover="true" Items="_libraries" Dense="true">
<ToolBarContent>
<MudText Typo="Typo.h6">Libraries</MudText>
+1 -1
View File
@@ -5,7 +5,7 @@
@inject IDialogService Dialog
@inject IMediator Mediator
<MudContainer MaxWidth="MaxWidth.ExtraLarge">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudTable Hover="true" Items="_libraryPaths" Dense="true">
<ToolBarContent>
<MudText Typo="Typo.h6">Library Paths</MudText>
+1 -1
View File
@@ -10,7 +10,7 @@
@inject IEntityLocker Locker
@inject ChannelWriter<IBackgroundServiceRequest> Channel
<MudContainer MaxWidth="MaxWidth.ExtraLarge">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudText Typo="Typo.h4" Class="mb-4">@_library.Name - Add Local Library Path</MudText>
<div style="max-width: 400px;">
<EditForm EditContext="_editContext" OnSubmit="@HandleSubmitAsync">
+1 -1
View File
@@ -3,7 +3,7 @@
@using ErsatzTV.Application.Logs.Queries
@inject IMediator Mediator
<MudContainer MaxWidth="MaxWidth.ExtraLarge">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudTable FixedHeader="true" Dense="true" Items="_logEntries">
<HeaderContent>
<MudTh>Timestamp</MudTh>
+20 -11
View File
@@ -7,21 +7,32 @@
@inject IDialogService Dialog
@inject NavigationManager NavigationManager
<MudContainer MaxWidth="MaxWidth.Large">
<MudBreadcrumbs Items="_breadcrumbs" Class="mb-6"></MudBreadcrumbs>
<MudCard Class="mb-6">
<MudContainer MaxWidth="MaxWidth.False" Style="padding: 0" Class="fanart-container">
<div class="fanart-tint"></div>
@if (!string.IsNullOrWhiteSpace(_movie.FanArt))
{
<img src="@($"/artwork/fanart/{_movie.FanArt}")" alt="fan art"/>
}
</MudContainer>
<MudContainer MaxWidth="MaxWidth.Large" Style="margin-top: 200px">
<div style="display: flex; flex-direction: row;">
@if (!string.IsNullOrWhiteSpace(_movie.Poster))
{
<MudPaper Style="flex-shrink: 0;">
<MudCardMedia Image="@($"/artwork/posters/{_movie.Poster}")" Style="height: 440px; width: 304px;"/>
</MudPaper>
<img class="mud-elevation-2 mr-6"
style="border-radius: 4px; max-height: 440px"
src="@($"/artwork/posters/{_movie.Poster}")" alt="movie poster"/>
}
<MudCardContent Class="mx-3 my-3">
<div style="display: flex; flex-direction: column; height: 100%">
<MudText Typo="Typo.h3">@_movie.Title</MudText>
<MudText Typo="Typo.subtitle1" Class="mb-6 mud-text-secondary">@_movie.Year</MudText>
<MudText Typo="Typo.h2" Class="media-item-title">@_movie.Title</MudText>
<MudText Typo="Typo.subtitle1" Class="media-item-subtitle mb-6 mud-text-secondary">@_movie.Year</MudText>
@if (!string.IsNullOrWhiteSpace(_movie.Plot))
{
<MudCard Elevation="2" Class="mb-6">
<MudCardContent Class="mx-3 my-3" Style="height: 100%">
<MudText Style="flex-grow: 1">@_movie.Plot</MudText>
</MudCardContent>
</MudCard>
}
<div>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
@@ -31,9 +42,7 @@
</MudButton>
</div>
</div>
</MudCardContent>
</div>
</MudCard>
</MudContainer>
@code {
+5 -3
View File
@@ -12,7 +12,8 @@
@inject NavigationManager NavigationManager
@inject ChannelWriter<IBackgroundServiceRequest> Channel
<MudContainer MaxWidth="MaxWidth.Small" Class="mb-6" Style="max-width: 300px">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudContainer MaxWidth="MaxWidth.Small" Class="mb-6" Style="max-width: 300px">
<MudPaper Style="align-items: center; display: flex; justify-content: center;">
<MudIconButton Icon="@Icons.Material.Outlined.ChevronLeft"
OnClick="@PrevPage"
@@ -26,15 +27,16 @@
OnClick="@NextPage" Disabled="@(PageNumber * PageSize >= _data.Count)">
</MudIconButton>
</MudPaper>
</MudContainer>
</MudContainer>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (MovieCardViewModel card in _data.Cards.Where(d => !string.IsNullOrWhiteSpace(d.Title)))
{
<MediaCard Data="@card"
Link="@($"/media/movies/{card.MovieId}")"
AddToCollectionClicked="@AddToCollection"/>
}
</MudContainer>
</MudContainer>
@code {
+4 -2
View File
@@ -8,7 +8,8 @@
@inject ISnackbar Snackbar
@inject IMediator Mediator
<div style="max-width: 400px;">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<div style="max-width: 400px;">
<MudText Typo="Typo.h4" Class="mb-4">Add Playout</MudText>
<EditForm EditContext="_editContext" OnSubmit="@HandleSubmitAsync">
@@ -25,7 +26,8 @@
</MudCardActions>
</MudCard>
</EditForm>
</div>
</div>
</MudContainer>
@code {
+1 -1
View File
@@ -5,7 +5,7 @@
@inject IDialogService Dialog
@inject IMediator Mediator
<MudContainer MaxWidth="MaxWidth.ExtraLarge">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudTable Hover="true" Dense="true" Items="_playouts" SelectedItemChanged="@(async (PlayoutViewModel x) => await PlayoutSelected(x))">
<ToolBarContent>
<MudText Typo="Typo.h6">Playouts</MudText>
+5 -3
View File
@@ -7,7 +7,8 @@
@inject ISnackbar Snackbar
@inject IMediator Mediator
<div style="max-width: 400px;">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<div style="max-width: 400px;">
<MudText Typo="Typo.h4" Class="mb-4">@(IsEdit ? "Edit Schedule" : "Add Schedule")</MudText>
<EditForm EditContext="_editContext" OnSubmit="@HandleSubmitAsync">
@@ -29,7 +30,8 @@
</MudCardActions>
</MudCard>
</EditForm>
</div>
</div>
</MudContainer>
@code {
@@ -81,7 +83,7 @@
errorMessage.HeadOrNone().Match(
error =>
{
Snackbar.Add(error.Value);
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Unexpected error saving schedule: {Error}", error.Value);
},
() => NavigationManager.NavigateTo("/schedules"));
+11 -9
View File
@@ -11,7 +11,8 @@
@inject ISnackbar Snackbar
@inject IMediator Mediator
<MudTable Hover="true" Items="_schedule.Items.OrderBy(i => i.Index)" Dense="true" Class="mt-8" @bind-SelectedItem="_selectedItem">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudTable Hover="true" Items="_schedule.Items.OrderBy(i => i.Index)" Dense="true" @bind-SelectedItem="_selectedItem">
<ToolBarContent>
<MudText Typo="Typo.h6">@_schedule.Name Items</MudText>
</ToolBarContent>
@@ -72,16 +73,16 @@
<PagerContent>
<MudTablePager/>
</PagerContent>
</MudTable>
<MudButton Variant="Variant.Filled" Color="Color.Default" OnClick="@(_ => AddScheduleItem())" Class="mt-4">
</MudTable>
<MudButton Variant="Variant.Filled" Color="Color.Default" OnClick="@(_ => AddScheduleItem())" Class="mt-4">
Add Schedule Item
</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(_ => SaveChanges())" Class="mt-4 ml-4">
</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(_ => SaveChanges())" Class="mt-4 ml-4">
Save Changes
</MudButton>
</MudButton>
@if (_selectedItem is not null)
{
@if (_selectedItem is not null)
{
<div style="max-width: 400px;">
<EditForm Model="_selectedItem">
<FluentValidator/>
@@ -142,7 +143,8 @@
</MudCard>
</EditForm>
</div>
}
}
</MudContainer>
@code {
+6 -5
View File
@@ -5,7 +5,7 @@
@inject IDialogService Dialog
@inject IMediator Mediator
<MudContainer MaxWidth="MaxWidth.ExtraLarge">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudTable Hover="true" Items="_schedules" Dense="true" SelectedItemChanged="@(async (ProgramScheduleViewModel x) => await ScheduleSelected(x))">
<ToolBarContent>
<MudText Typo="Typo.h6">Schedules</MudText>
@@ -50,10 +50,9 @@
<MudButton Variant="Variant.Filled" Color="Color.Primary" Link="/schedules/add" Class="mt-4">
Add Schedule
</MudButton>
</MudContainer>
@if (_selectedScheduleItems != null)
{
@if (_selectedScheduleItems != null)
{
<MudTable Hover="true" Items="_selectedScheduleItems.OrderBy(i => i.Index)" Class="mt-8">
<ToolBarContent>
<MudText Typo="Typo.h6">@_selectedSchedule.Name Items</MudText>
@@ -74,7 +73,9 @@
<MudTablePager/>
</PagerContent>
</MudTable>
}
}
</MudContainer>
@code {
private List<ProgramScheduleViewModel> _schedules;
+1 -1
View File
@@ -13,7 +13,7 @@
@inject IDialogService Dialog
@inject ChannelWriter<IBackgroundServiceRequest> Channel
<MudContainer MaxWidth="MaxWidth.ExtraLarge">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<div class="mb-6" style="display: flex; flex-direction: row;">
<MudText GutterBottom="true" Typo="Typo.h4">Search Results: "@_query"</MudText>
</div>
+3 -1
View File
@@ -7,7 +7,8 @@
@inject IDialogService Dialog
@inject NavigationManager NavigationManager
<MudContainer MaxWidth="MaxWidth.Large">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudContainer MaxWidth="MaxWidth.Large">
<MudBreadcrumbs Items="_breadcrumbs" Class="mb-6"></MudBreadcrumbs>
<MudCard Class="mb-6">
<div style="display: flex; flex-direction: row;">
@@ -34,6 +35,7 @@
</MudCardContent>
</div>
</MudCard>
</MudContainer>
</MudContainer>
@code {
+5 -3
View File
@@ -15,7 +15,8 @@
@inject NavigationManager NavigationManager
@inject ChannelWriter<IBackgroundServiceRequest> Channel
<MudContainer MaxWidth="MaxWidth.Large">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudContainer MaxWidth="MaxWidth.Large">
<MudBreadcrumbs Items="_breadcrumbs" Class="mb-6"></MudBreadcrumbs>
<MudCard Class="mb-6">
<div style="display: flex; flex-direction: row;">
@@ -49,9 +50,9 @@
</MudCardContent>
</div>
</MudCard>
</MudContainer>
</MudContainer>
<MudContainer MaxWidth="MaxWidth.Large" Class="media-card-grid">
<MudContainer MaxWidth="MaxWidth.Large" Class="media-card-grid">
@foreach (TelevisionEpisodeCardViewModel card in _data.Cards)
{
<MediaCard Data="@card"
@@ -62,6 +63,7 @@
CardClass="media-card-episode"
ArtworkKind="@ArtworkKind.Thumbnail"/>
}
</MudContainer>
</MudContainer>
@code {
+5 -3
View File
@@ -15,7 +15,8 @@
@inject NavigationManager NavigationManager
@inject ChannelWriter<IBackgroundServiceRequest> Channel
<MudContainer MaxWidth="MaxWidth.Large">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudContainer MaxWidth="MaxWidth.Large">
<MudBreadcrumbs Items="_breadcrumbs" Class="mb-6"></MudBreadcrumbs>
<MudCard Class="mb-6">
<div style="display: flex; flex-direction: row;">
@@ -49,15 +50,16 @@
</MudCardContent>
</div>
</MudCard>
</MudContainer>
</MudContainer>
<MudContainer MaxWidth="MaxWidth.Large" Class="media-card-grid">
<MudContainer MaxWidth="MaxWidth.Large" Class="media-card-grid">
@foreach (TelevisionSeasonCardViewModel card in _data.Cards)
{
<MediaCard Data="@card" Placeholder="@card.Placeholder"
Link="@($"/media/tv/seasons/{card.TelevisionSeasonId}")"
AddToCollectionClicked="@AddSeasonToCollection"/>
}
</MudContainer>
</MudContainer>
@code {
+5 -3
View File
@@ -10,7 +10,8 @@
@inject IDialogService Dialog
@inject ChannelWriter<IBackgroundServiceRequest> Channel
<MudContainer MaxWidth="MaxWidth.Small" Class="mb-6" Style="max-width: 300px">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudContainer MaxWidth="MaxWidth.Small" Class="mb-6" Style="max-width: 300px">
<MudPaper Style="align-items: center; display: flex; justify-content: center;">
<MudIconButton Icon="@Icons.Material.Outlined.ChevronLeft"
OnClick="@(() => PrevPage())"
@@ -24,15 +25,16 @@
OnClick="@(() => NextPage())" Disabled="@(_pageNumber * _pageSize >= _data.Count)">
</MudIconButton>
</MudPaper>
</MudContainer>
</MudContainer>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (TelevisionShowCardViewModel card in _data.Cards)
{
<MediaCard Data="@card"
Link="@($"/media/tv/shows/{card.TelevisionShowId}")"
AddToCollectionClicked="@AddToCollection"/>
}
</MudContainer>
</MudContainer>
@code {
+24 -17
View File
@@ -9,30 +9,30 @@
<MudLayout>
<MudAppBar Elevation="1">
<div style="min-width: 240px">
<img src="/images/ersatztv.png" alt="ErsatzTV"/>
</div>
<MudTextField T="string"
@ref="_textField"
Placeholder="Search"
@ref=" _textField"
AdornmentIcon="@Icons.Material.Filled.Search"
Adornment="Adornment.Start"
Variant="Variant.Outlined"
Class="search-bar"
OnKeyDown="@OnSearchKeyDown"
Immediate="true">
</MudTextField>
<MudAppBarSpacer/>
<MudLink Style="@($"color:{Colors.Shades.White}")" Color="Color.Inherit" Href="/iptv/channels.m3u" Target="_blank" Underline="Underline.None">M3U</MudLink>
<MudLink Style="@($"color:{Colors.Shades.White}")" Color="Color.Inherit" Href="/iptv/xmltv.xml" Target="_blank" Class="mx-4" Underline="Underline.None">XMLTV</MudLink>
<MudLink Style="@($"color:{Colors.Shades.White}")" Color="Color.Inherit" Href="/swagger" Target="_blank" Class="mr-4" Underline="Underline.None">API</MudLink>
<MudDivider Vertical="true" FlexItem="true" DividerType="DividerType.Middle" Class="mx-4 my-5"/>
<MudLink Color="Color.Info" Href="/iptv/channels.m3u" Target="_blank" Underline="Underline.None">M3U</MudLink>
<MudLink Color="Color.Info" Href="/iptv/xmltv.xml" Target="_blank" Class="mx-4" Underline="Underline.None">XMLTV</MudLink>
<MudLink Color="Color.Info" Href="/swagger" Target="_blank" Class="mr-4" Underline="Underline.None">API</MudLink>
<MudTooltip Text="Discord">
<MudIconButton Icon="fab fa-discord" Color="Color.Inherit" Link="https://discord.gg/hHaJm3yGy6" Target="_blank"/>
<MudIconButton Icon="fab fa-discord" Color="Color.Primary" Link="https://discord.gg/hHaJm3yGy6" Target="_blank"/>
</MudTooltip>
<MudTooltip Text="GitHub">
<MudIconButton Icon="@Icons.Custom.Brands.GitHub" Color="Color.Inherit" Link="https://github.com/jasongdove/ErsatzTV" Target="_blank"/>
<MudIconButton Icon="@Icons.Custom.Brands.GitHub" Color="Color.Primary" Link="https://github.com/jasongdove/ErsatzTV" Target="_blank"/>
</MudTooltip>
</MudAppBar>
<MudDrawer Open="true" Elevation="2">
<MudDrawerHeader>
<MudText Typo="Typo.h6">ErsatzTV</MudText>
</MudDrawerHeader>
<MudDrawer Open="true" Elevation="2" ClipMode="DrawerClipMode.Always">
<MudNavMenu>
<MudNavLink Href="/" Match="NavLinkMatch.All">Home</MudNavLink>
<MudNavLink Href="/channels">Channels</MudNavLink>
@@ -57,9 +57,7 @@
</MudNavMenu>
</MudDrawer>
<MudMainContent>
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
@Body
</MudContainer>
</MudMainContent>
</MudLayout>
@@ -78,9 +76,18 @@
{
Palette = new Palette
{
DrawerBackground = current.Palette.Background,
Background = current.Palette.BackgroundGrey,
Tertiary = Colors.Shades.White
ActionDefault = "rgba(255,255,255, 0.80)",
Primary = "#009000",
AppbarBackground = "#121212",
Background = "#272727",
DrawerBackground = "#1f1f1f",
Surface = "#1f1f1f",
DrawerText = "rgba(255,255,255, 0.80)",
TextPrimary = "rgba(255,255,255, 0.80)",
TextSecondary = "rgba(255,255,255, 0.80)",
Info = "#00c0c0",
Tertiary = "#00c000",
White = Colors.Shades.White
}
};
}
+2 -2
View File
@@ -9,7 +9,7 @@
<MudPaper Class="@($"media-card {CardClass}")" Style="@ArtworkForItem()">
@if (string.IsNullOrWhiteSpace(Data.Poster))
{
<MudText Align="Align.Center" Typo="Typo.h1" Class="media-card-poster-placeholder mud-text-disabled">
<MudText Align="Align.Center" Typo="Typo.h1" Class="media-card-poster-placeholder mud-text-primary">
@GetPlaceholder(Data.SortTitle)
</MudText>
}
@@ -39,7 +39,7 @@
<MudPaper Class="@($"media-card {CardClass}")" Style="@ArtworkForItem()">
@if (string.IsNullOrWhiteSpace(Data.Poster))
{
<MudText Align="Align.Center" Typo="Typo.h1" Class="media-card-poster-placeholder mud-text-disabled">
<MudText Align="Align.Center" Typo="Typo.h1" Class="media-card-poster-placeholder mud-text-primary">
@GetPlaceholder(Data.SortTitle)
</MudText>
}
@@ -7,7 +7,9 @@ namespace ErsatzTV.Validators
{
public ChannelEditViewModelValidator()
{
RuleFor(x => x.Number).GreaterThan(0);
RuleFor(x => x.Number).Matches(@"^[0-9]+(\.[0-9])?$")
.WithMessage("Invalid channel number; one decimal is allowed for subchannels");
RuleFor(x => x.Name).NotEmpty();
RuleFor(x => x.FFmpegProfileId).GreaterThan(0);
}
+1 -1
View File
@@ -7,7 +7,7 @@ namespace ErsatzTV.ViewModels
{
public int Id { get; set; }
public string Name { get; set; }
public int Number { get; set; }
public string Number { get; set; }
public int FFmpegProfileId { get; set; }
public string Logo { get; set; }
public StreamingMode StreamingMode { get; set; }
+37 -1
View File
@@ -1,4 +1,6 @@
.media-card-grid {
.mud-breadcrumb-separator > span { color: inherit !important; }
.media-card-grid {
display: flex;
flex-direction: row;
flex-wrap: wrap;
@@ -69,3 +71,37 @@
border: none;
border-radius: 4px;
}
.fanart-container {
position: relative;
width: 100%;
z-index: -1;
}
.fanart-container img {
-o-object-fit: cover;
height: 400px;
object-fit: cover;
position: absolute;
transition: opacity 5s ease;
width: 100%;
}
.fanart-container > .fanart-tint {
background: linear-gradient(360deg, black, transparent);
height: 400px;
opacity: 0.85;
position: absolute;
width: 100%;
z-index: 1;
}
.media-item-title {
color: #fff;
text-shadow: 1px 1px 5px #000;
}
.media-item-subtitle {
color: #fff;
text-shadow: 1px 1px 5px #000;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

+1 -1
View File
@@ -2,7 +2,7 @@
FROM jrottenberg/ffmpeg:4.3-ubuntu2004 AS runtime-base
COPY --from=dotnet-runtime /usr/share/dotnet /usr/share/dotnet
RUN apt-get update && apt-get install -y libicu-dev
RUN apt-get update && DEBIAN_FRONTEND="noninteractive" apt-get install -y libicu-dev tzdata
# https://hub.docker.com/_/microsoft-dotnet
FROM mcr.microsoft.com/dotnet/sdk:5.0 AS build
+2
View File
@@ -7,6 +7,8 @@ services:
dockerfile: docker/Dockerfile
args:
INFO_VERSION: "docker-compose-develop"
environment:
TZ: America/Chicago
ports:
- "8409:8409"
volumes:
+1 -1
View File
@@ -2,7 +2,7 @@
FROM jrottenberg/ffmpeg:4.3-nvidia1804 AS runtime-base
COPY --from=dotnet-runtime /usr/share/dotnet /usr/share/dotnet
RUN apt-get update && apt-get install -y libicu-dev
RUN apt-get update && DEBIAN_FRONTEND="noninteractive" apt-get install -y libicu-dev tzdata
# https://hub.docker.com/_/microsoft-dotnet
FROM mcr.microsoft.com/dotnet/sdk:5.0 AS build
+1 -1
View File
@@ -2,7 +2,7 @@
FROM jrottenberg/ffmpeg:4.3-vaapi1804 AS runtime-base
COPY --from=dotnet-runtime /usr/share/dotnet /usr/share/dotnet
RUN apt-get update && apt-get install -y libicu-dev
RUN apt-get update && DEBIAN_FRONTEND="noninteractive" apt-get install -y libicu-dev tzdata
# https://hub.docker.com/_/microsoft-dotnet
FROM mcr.microsoft.com/dotnet/sdk:5.0 AS build