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>
+53 -50
View File
@@ -10,55 +10,57 @@
@inject ISnackbar Snackbar
@inject IMediator Mediator
<div style="max-width: 400px;">
<MudText Typo="Typo.h4" Class="mb-4">@(IsEdit ? "Edit Channel" : "Add Channel")</MudText>
<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">
<FluentValidator/>
<MudCard>
<MudCardContent>
<MudTextField Label="Number" @bind-Value="_model.Number" For="@(() => _model.Number)" Immediate="true"/>
<MudTextField Class="mt-3" Label="Name" @bind-Value="_model.Name" For="@(() => _model.Name)"/>
<MudSelect Class="mt-3" Label="Streaming Mode" @bind-Value="_model.StreamingMode" For="@(() => _model.StreamingMode)">
@foreach (StreamingMode streamingMode in Enum.GetValues<StreamingMode>())
{
<MudSelectItem Value="@streamingMode">@streamingMode</MudSelectItem>
}
</MudSelect>
<MudSelect Class="mt-3" Label="FFmpeg Profile" @bind-Value="_model.FFmpegProfileId" For="@(() => _model.FFmpegProfileId)"
Disabled="@(_model.StreamingMode != StreamingMode.TransportStream)">
@foreach (FFmpegProfileViewModel profile in _ffmpegProfiles)
{
<MudSelectItem Value="@profile.Id">@profile.Name</MudSelectItem>
}
</MudSelect>
<MudGrid Class="mt-3" Style="align-items: center" Justify="Justify.Center">
<MudItem xs="6">
<InputFile id="fileInput" OnChange="UploadLogo" hidden/>
@if (!string.IsNullOrWhiteSpace(_model.Logo))
<EditForm EditContext="_editContext" OnSubmit="@HandleSubmitAsync">
<FluentValidator/>
<MudCard>
<MudCardContent>
<MudTextField Label="Number" @bind-Value="_model.Number" For="@(() => _model.Number)" Immediate="true"/>
<MudTextField Class="mt-3" Label="Name" @bind-Value="_model.Name" For="@(() => _model.Name)"/>
<MudSelect Class="mt-3" Label="Streaming Mode" @bind-Value="_model.StreamingMode" For="@(() => _model.StreamingMode)">
@foreach (StreamingMode streamingMode in Enum.GetValues<StreamingMode>())
{
<MudElement HtmlTag="img" src="@($"iptv/logos/{_model.Logo}")" Style="max-height: 50px"/>
<MudSelectItem Value="@streamingMode">@streamingMode</MudSelectItem>
}
</MudItem>
<MudItem xs="6">
<MudButton Class="ml-auto" HtmlTag="label"
Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.CloudUpload"
for="fileInput">
Upload Logo
</MudButton>
</MudItem>
</MudGrid>
</MudCardContent>
<MudCardActions>
<MudButton ButtonType="ButtonType.Submit" Variant="Variant.Filled" Color="Color.Primary">
@(IsEdit ? "Save Changes" : "Add Channel")
</MudButton>
</MudCardActions>
</MudCard>
</EditForm>
</div>
</MudSelect>
<MudSelect Class="mt-3" Label="FFmpeg Profile" @bind-Value="_model.FFmpegProfileId" For="@(() => _model.FFmpegProfileId)"
Disabled="@(_model.StreamingMode != StreamingMode.TransportStream)">
@foreach (FFmpegProfileViewModel profile in _ffmpegProfiles)
{
<MudSelectItem Value="@profile.Id">@profile.Name</MudSelectItem>
}
</MudSelect>
<MudGrid Class="mt-3" Style="align-items: center" Justify="Justify.Center">
<MudItem xs="6">
<InputFile id="fileInput" OnChange="UploadLogo" hidden/>
@if (!string.IsNullOrWhiteSpace(_model.Logo))
{
<MudElement HtmlTag="img" src="@($"iptv/logos/{_model.Logo}")" Style="max-height: 50px"/>
}
</MudItem>
<MudItem xs="6">
<MudButton Class="ml-auto" HtmlTag="label"
Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.CloudUpload"
for="fileInput">
Upload Logo
</MudButton>
</MudItem>
</MudGrid>
</MudCardContent>
<MudCardActions>
<MudButton ButtonType="ButtonType.Submit" Variant="Variant.Filled" Color="Color.Primary">
@(IsEdit ? "Save Changes" : "Add Channel")
</MudButton>
</MudCardActions>
</MudCard>
</EditForm>
</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());
}
+18 -16
View File
@@ -8,23 +8,25 @@
@inject ISnackbar Snackbar
@inject IMediator Mediator
<div style="max-width: 400px;">
<MudText Typo="Typo.h4" Class="mb-4">@(IsEdit ? "Edit Collection" : "Add Collection")</MudText>
<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">
<FluentValidator/>
<MudCard>
<MudCardContent>
<MudTextField Class="mt-3" Label="Name" @bind-Value="_model.Name" For="@(() => _model.Name)"/>
</MudCardContent>
<MudCardActions>
<MudButton ButtonType="ButtonType.Submit" Variant="Variant.Filled" Color="Color.Primary" Class="ml-auto">
@(IsEdit ? "Save Changes" : "Add Collection")
</MudButton>
</MudCardActions>
</MudCard>
</EditForm>
</div>
<EditForm EditContext="_editContext" OnSubmit="@HandleSubmitAsync">
<FluentValidator/>
<MudCard>
<MudCardContent>
<MudTextField Class="mt-3" Label="Name" @bind-Value="_model.Name" For="@(() => _model.Name)"/>
</MudCardContent>
<MudCardActions>
<MudButton ButtonType="ButtonType.Submit" Variant="Variant.Filled" Color="Color.Primary" Class="ml-auto">
@(IsEdit ? "Save Changes" : "Add Collection")
</MudButton>
</MudCardActions>
</MudCard>
</EditForm>
</div>
</MudContainer>
@code {
+63 -61
View File
@@ -9,75 +9,77 @@
@inject IDialogService Dialog
@inject ChannelWriter<IBackgroundServiceRequest> Channel
<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>
<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>
@if (_data.MovieCards.Any())
{
<MudText GutterBottom="true" Typo="Typo.h4">Movies</MudText>
@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)
{
<MediaCard Data="@card"
Link="@($"/media/movies/{card.MovieId}")"
DeleteClicked="@RemoveMovieFromCollection"/>
}
</MudContainer>
}
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@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())
{
<MudText GutterBottom="true" Typo="Typo.h4">Television Shows</MudText>
@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)
{
<MediaCard Data="@card"
Link="@($"/media/tv/shows/{card.TelevisionShowId}")"
DeleteClicked="@RemoveShowFromCollection"/>
}
</MudContainer>
}
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@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())
{
<MudText GutterBottom="true" Typo="Typo.h4">Television Seasons</MudText>
@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)
{
<MediaCard Data="@card"
Link="@($"/media/tv/seasons/{card.TelevisionSeasonId}")"
Title="@card.ShowTitle"
Subtitle="@card.Title"
DeleteClicked="@RemoveSeasonFromCollection"/>
}
</MudContainer>
}
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (TelevisionSeasonCardViewModel card in _data.SeasonCards.OrderBy(m => m.SortTitle))
{
<MediaCard Data="@card"
Link="@($"/media/tv/seasons/{card.TelevisionSeasonId}")"
Title="@card.ShowTitle"
Subtitle="@card.Title"
DeleteClicked="@RemoveSeasonFromCollection"/>
}
</MudContainer>
}
@if (_data.EpisodeCards.Any())
{
<MudText GutterBottom="true" Typo="Typo.h4">Television Episodes</MudText>
@if (_data.EpisodeCards.Any())
{
<MudText GutterBottom="true" Typo="Typo.h4">Television Episodes</MudText>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (TelevisionEpisodeCardViewModel card in _data.EpisodeCards.OrderBy(e => e.Aired))
{
<MediaCard Data="@card"
Link="@($"/media/tv/episodes/{card.EpisodeId}")"
Title="@card.ShowTitle"
Subtitle="@card.Title"
ContainerClass="media-card-episode-container mx-2"
CardClass="media-card-episode"
DeleteClicked="@RemoveEpisodeFromCollection"
ArtworkKind="@ArtworkKind.Thumbnail"/>
}
</MudContainer>
}
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (TelevisionEpisodeCardViewModel card in _data.EpisodeCards.OrderBy(e => e.Aired))
{
<MediaCard Data="@card"
Link="@($"/media/tv/episodes/{card.EpisodeId}")"
Title="@card.ShowTitle"
Subtitle="@card.Title"
ContainerClass="media-card-episode-container mx-2"
CardClass="media-card-episode"
DeleteClicked="@RemoveEpisodeFromCollection"
ArtworkKind="@ArtworkKind.Thumbnail"/>
}
</MudContainer>
}
</MudContainer>
@code {
+16 -14
View File
@@ -6,21 +6,23 @@
@inject IDialogService Dialog
@inject IMediator Mediator
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (MediaCollectionViewModel card in _data)
{
<MediaCard Data="@card"
Link="@($"/media/collections/{card.Id}")"
ContainerClass="media-card-episode-container mr-4"
CardClass="media-card-episode"
DeleteClicked="@DeleteMediaCollection"/>
}
</MudContainer>
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (MediaCollectionViewModel card in _data)
{
<MediaCard Data="@card"
Link="@($"/media/collections/{card.Id}")"
ContainerClass="media-card-episode-container mr-4"
CardClass="media-card-episode"
DeleteClicked="@DeleteMediaCollection"/>
}
</MudContainer>
<MudContainer MaxWidth="MaxWidth.False">
<MudButton Variant="Variant.Filled" Color="Color.Primary" Link="/media/collections/add" Class="mt-4">
Add Collection
</MudButton>
<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>
+93 -92
View File
@@ -10,98 +10,99 @@
@inject ISnackbar Snackbar
@inject IMediator Mediator
<EditForm EditContext="_editContext" OnSubmit="@HandleSubmitAsync">
<FluentValidator/>
<MudCard>
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h5">@(IsEdit ? "Edit FFmpeg Profile" : "Add FFmpeg Profile")</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudGrid>
<MudItem xs="12">
<MudGrid Spacing="4" Justify="Justify.Center">
<MudItem>
<MudText Typo="Typo.h6">General</MudText>
<MudTextField Label="Name" @bind-Value="_model.Name" For="@(() => _model.Name)"/>
<MudElement HtmlTag="div" Class="mt-3">
<MudTextField Label="Thread Count" @bind-Value="@_model.ThreadCount" For="@(() => _model.ThreadCount)"/>
</MudElement>
<MudElement HtmlTag="div" Class="mt-3">
<MudSelect Disabled="@(!_model.Transcode)" Label="Preferred Resolution" @bind-Value="_model.Resolution" For="@(() => _model.Resolution)">
@foreach (ResolutionViewModel resolution in _resolutions)
{
<MudSelectItem Value="@resolution">@resolution.Name</MudSelectItem>
}
</MudSelect>
</MudElement>
<MudElement HtmlTag="div" Class="mt-3">
<MudCheckBox Label="Transcode" @bind-Checked="@_model.Transcode" For="@(() => _model.Transcode)"/>
</MudElement>
</MudItem>
<MudItem>
<MudText Typo="Typo.h6">Video</MudText>
<MudTextField Disabled="@(!_model.Transcode)" Label="Codec" @bind-Value="_model.VideoCodec" For="@(() => _model.VideoCodec)"/>
<MudElement HtmlTag="div" Class="mt-3">
<MudTextField Disabled="@(!_model.Transcode)" Label="Bitrate" @bind-Value="_model.VideoBitrate" For="@(() => _model.VideoBitrate)" Adornment="Adornment.End" AdornmentText="kBit/s"/>
</MudElement>
<MudElement HtmlTag="div" Class="mt-3">
<MudTextField Disabled="@(!_model.Transcode)" Label="Buffer Size" @bind-Value="_model.VideoBufferSize" For="@(() => _model.VideoBufferSize)" Adornment="Adornment.End" AdornmentText="kBit"/>
</MudElement>
<MudElement HtmlTag="div" Class="mt-3">
<MudSelect Disabled="@(!_model.Transcode)" Label="Hardware Acceleration" @bind-Value="_model.HardwareAcceleration" For="@(() => _model.HardwareAcceleration)">
@foreach (HardwareAccelerationKind hwAccel in Enum.GetValues<HardwareAccelerationKind>())
{
<MudSelectItem Value="@hwAccel">@hwAccel</MudSelectItem>
}
</MudSelect>
</MudElement>
</MudItem>
<MudItem>
<MudText Typo="Typo.h6">Audio</MudText>
<MudTextField Disabled="@(!_model.Transcode)" Label="Codec" @bind-Value="_model.AudioCodec" For="@(() => _model.AudioCodec)"/>
<MudElement HtmlTag="div" Class="mt-3">
<MudTextField Disabled="@(!_model.Transcode)" Label="Bitrate" @bind-Value="_model.AudioBitrate" For="@(() => _model.AudioBitrate)" Adornment="Adornment.End" AdornmentText="kBit/s"/>
</MudElement>
<MudElement HtmlTag="div" Class="mt-3">
<MudTextField Disabled="@(!_model.Transcode)" Label="Buffer Size" @bind-Value="_model.AudioBufferSize" For="@(() => _model.AudioBufferSize)" Adornment="Adornment.End" AdornmentText="kBit"/>
</MudElement>
<MudElement HtmlTag="div" Class="mt-3">
<MudTextField Disabled="@(!_model.Transcode)" Label="Volume" @bind-Value="_model.AudioVolume" For="@(() => _model.AudioVolume)" Adornment="Adornment.End" AdornmentText="%"/>
</MudElement>
<MudElement HtmlTag="div" Class="mt-3">
<MudTextField Disabled="@(!_model.Transcode)" Label="Channels" @bind-Value="_model.AudioChannels" For="@(() => _model.AudioChannels)"/>
</MudElement>
<MudElement HtmlTag="div" Class="mt-3">
<MudTextField Disabled="@(!_model.Transcode)" Label="Sample Rate" @bind-Value="_model.AudioSampleRate" For="@(() => _model.AudioSampleRate)" Adornment="Adornment.End" AdornmentText="kHz"/>
</MudElement>
</MudItem>
<MudItem>
<MudText Typo="Typo.h6">Normalization</MudText>
<MudCheckBox Disabled="@(!_model.Transcode)" Label="Normalize Resolution" @bind-Checked="@_model.NormalizeResolution" For="@(() => _model.NormalizeResolution)"/>
<MudElement HtmlTag="div" Class="mt-3">
<MudCheckBox Disabled="@(!_model.Transcode)" Label="Normalize Video Codec" @bind-Checked="@_model.NormalizeVideoCodec" For="@(() => _model.NormalizeVideoCodec)"/>
</MudElement>
<MudElement HtmlTag="div" Class="mt-3">
<MudCheckBox Disabled="@(!_model.Transcode)" Label="Normalize Audio Codec" @bind-Checked="@_model.NormalizeAudioCodec" For="@(() => _model.NormalizeAudioCodec)"/>
</MudElement>
<MudElement HtmlTag="div" Class="mt-3">
<MudCheckBox Disabled="@(!_model.Transcode)" Label="Normalize Audio" @bind-Checked="@_model.NormalizeAudio" For="@(() => _model.NormalizeAudio)"/>
</MudElement>
</MudItem>
</MudGrid>
</MudItem>
</MudGrid>
</MudCardContent>
<MudCardActions>
<MudButton ButtonType="ButtonType.Submit" Variant="Variant.Filled" Color="Color.Primary">
@(IsEdit ? "Save Changes" : "Add Profile")
</MudButton>
</MudCardActions>
</MudCard>
</EditForm>
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<EditForm EditContext="_editContext" OnSubmit="@HandleSubmitAsync">
<FluentValidator/>
<MudCard>
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h5">@(IsEdit ? "Edit FFmpeg Profile" : "Add FFmpeg Profile")</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudGrid>
<MudItem xs="12">
<MudGrid Spacing="4" Justify="Justify.Center">
<MudItem>
<MudText Typo="Typo.h6">General</MudText>
<MudTextField Label="Name" @bind-Value="_model.Name" For="@(() => _model.Name)"/>
<MudElement HtmlTag="div" Class="mt-3">
<MudTextField Label="Thread Count" @bind-Value="@_model.ThreadCount" For="@(() => _model.ThreadCount)"/>
</MudElement>
<MudElement HtmlTag="div" Class="mt-3">
<MudSelect Disabled="@(!_model.Transcode)" Label="Preferred Resolution" @bind-Value="_model.Resolution" For="@(() => _model.Resolution)">
@foreach (ResolutionViewModel resolution in _resolutions)
{
<MudSelectItem Value="@resolution">@resolution.Name</MudSelectItem>
}
</MudSelect>
</MudElement>
<MudElement HtmlTag="div" Class="mt-3">
<MudCheckBox Label="Transcode" @bind-Checked="@_model.Transcode" For="@(() => _model.Transcode)"/>
</MudElement>
</MudItem>
<MudItem>
<MudText Typo="Typo.h6">Video</MudText>
<MudTextField Disabled="@(!_model.Transcode)" Label="Codec" @bind-Value="_model.VideoCodec" For="@(() => _model.VideoCodec)"/>
<MudElement HtmlTag="div" Class="mt-3">
<MudTextField Disabled="@(!_model.Transcode)" Label="Bitrate" @bind-Value="_model.VideoBitrate" For="@(() => _model.VideoBitrate)" Adornment="Adornment.End" AdornmentText="kBit/s"/>
</MudElement>
<MudElement HtmlTag="div" Class="mt-3">
<MudTextField Disabled="@(!_model.Transcode)" Label="Buffer Size" @bind-Value="_model.VideoBufferSize" For="@(() => _model.VideoBufferSize)" Adornment="Adornment.End" AdornmentText="kBit"/>
</MudElement>
<MudElement HtmlTag="div" Class="mt-3">
<MudSelect Disabled="@(!_model.Transcode)" Label="Hardware Acceleration" @bind-Value="_model.HardwareAcceleration" For="@(() => _model.HardwareAcceleration)">
@foreach (HardwareAccelerationKind hwAccel in Enum.GetValues<HardwareAccelerationKind>())
{
<MudSelectItem Value="@hwAccel">@hwAccel</MudSelectItem>
}
</MudSelect>
</MudElement>
</MudItem>
<MudItem>
<MudText Typo="Typo.h6">Audio</MudText>
<MudTextField Disabled="@(!_model.Transcode)" Label="Codec" @bind-Value="_model.AudioCodec" For="@(() => _model.AudioCodec)"/>
<MudElement HtmlTag="div" Class="mt-3">
<MudTextField Disabled="@(!_model.Transcode)" Label="Bitrate" @bind-Value="_model.AudioBitrate" For="@(() => _model.AudioBitrate)" Adornment="Adornment.End" AdornmentText="kBit/s"/>
</MudElement>
<MudElement HtmlTag="div" Class="mt-3">
<MudTextField Disabled="@(!_model.Transcode)" Label="Buffer Size" @bind-Value="_model.AudioBufferSize" For="@(() => _model.AudioBufferSize)" Adornment="Adornment.End" AdornmentText="kBit"/>
</MudElement>
<MudElement HtmlTag="div" Class="mt-3">
<MudTextField Disabled="@(!_model.Transcode)" Label="Volume" @bind-Value="_model.AudioVolume" For="@(() => _model.AudioVolume)" Adornment="Adornment.End" AdornmentText="%"/>
</MudElement>
<MudElement HtmlTag="div" Class="mt-3">
<MudTextField Disabled="@(!_model.Transcode)" Label="Channels" @bind-Value="_model.AudioChannels" For="@(() => _model.AudioChannels)"/>
</MudElement>
<MudElement HtmlTag="div" Class="mt-3">
<MudTextField Disabled="@(!_model.Transcode)" Label="Sample Rate" @bind-Value="_model.AudioSampleRate" For="@(() => _model.AudioSampleRate)" Adornment="Adornment.End" AdornmentText="kHz"/>
</MudElement>
</MudItem>
<MudItem>
<MudText Typo="Typo.h6">Normalization</MudText>
<MudCheckBox Disabled="@(!_model.Transcode)" Label="Normalize Resolution" @bind-Checked="@_model.NormalizeResolution" For="@(() => _model.NormalizeResolution)"/>
<MudElement HtmlTag="div" Class="mt-3">
<MudCheckBox Disabled="@(!_model.Transcode)" Label="Normalize Video Codec" @bind-Checked="@_model.NormalizeVideoCodec" For="@(() => _model.NormalizeVideoCodec)"/>
</MudElement>
<MudElement HtmlTag="div" Class="mt-3">
<MudCheckBox Disabled="@(!_model.Transcode)" Label="Normalize Audio Codec" @bind-Checked="@_model.NormalizeAudioCodec" For="@(() => _model.NormalizeAudioCodec)"/>
</MudElement>
<MudElement HtmlTag="div" Class="mt-3">
<MudCheckBox Disabled="@(!_model.Transcode)" Label="Normalize Audio" @bind-Checked="@_model.NormalizeAudio" For="@(() => _model.NormalizeAudio)"/>
</MudElement>
</MudItem>
</MudGrid>
</MudItem>
</MudGrid>
</MudCardContent>
<MudCardActions>
<MudButton ButtonType="ButtonType.Submit" Variant="Variant.Filled" Color="Color.Primary">
@(IsEdit ? "Save Changes" : "Add Profile")
</MudButton>
</MudCardActions>
</MudCard>
</EditForm>
</MudContainer>
@code {
+70 -68
View File
@@ -1,70 +1,72 @@
@page "/"
<MudCard>
<MudCardContent>
<MudText Typo="Typo.h3">Welcome to ErsatzTV!</MudText>
<MudElement HtmlTag="div" Class="mt-6">
<MudText Typo="Typo.h4" GutterBottom="true">Channels</MudText>
<MudText>
<MudLink Href="/channels">Channels</MudLink> are not directly associated with any media. Channels have a <b>number</b>, a <b>name</b>, and a <b>streaming mode</b> that indicates how the channel will play media.
</MudText>
<MudText Class="mt-3">
In <b>TransportStream</b> mode, the channel will also require an <b>FFmpeg profile</b> to configure transcoding and normalization.
In <b>HttpLiveStreaming</b> mode, the channel will attempt to serve the channel's media without transcoding or normalization beyond the container format.
</MudText>
</MudElement>
<MudElement HtmlTag="div" Class="mt-6">
<MudText Typo="Typo.h4" GutterBottom="true">FFmpeg Profiles</MudText>
<MudText>
<MudLink Href="/ffmpeg">FFmpeg Profiles</MudLink> are collections of FFmpeg settings that are applied at the channel level.
All content on a given channel will use the same FFmpeg settings. This also means the same content on different channels can use different settings.
</MudText>
</MudElement>
<MudElement HtmlTag="div" Class="mt-6">
<MudText Typo="Typo.h4" GutterBottom="true">Libraries</MudText>
<MudText>
Two local <MudLink Href="/media/libraries">libraries</MudLink> are available, one for each <b>media kind</b>: Shows and Movies. Libraries contain <b>paths</b> (folders) to regularly scan for media items.
Support for Plex libraries is under active development; Jellyfin and Emby library support is planned.
</MudText>
</MudElement>
<MudElement HtmlTag="div" Class="mt-6">
<MudText Typo="Typo.h4" GutterBottom="true">Collections</MudText>
<MudText>
<MudLink Href="/media/collections">Collections</MudLink> have a <b>name</b> and contain a logical grouping of media items.
Collections may contain shows, seasons, episodes or movies.
Collections containing shows and seasons are automatically updated as media is added or removed from the linked shows and seasons.
</MudText>
</MudElement>
<MudElement HtmlTag="div" Class="mt-6">
<MudText Typo="Typo.h4" GutterBottom="true">Schedules</MudText>
<MudText>
<MudLink Href="/schedules">Schedules</MudLink> have a <b>name</b>, a <b>collection playback order</b> and <b>items</b> to continually loop through.
</MudText>
<MudText Class="mt-3 mb-2">Three <b>collection playback orders</b> are supported:</MudText>
<ul class="mud-typography-body1">
<li><b>Random</b> - to randomly play collection items; repeating is allowed before all collection items have been played.</li>
<li><b>Shuffle</b> - to randomly play collection items; repeating is <i>not</i> allowed until all collection items have been played.</li>
<li><b>Chronological</b> - to play collection items sorted by air date and then by season and episode number (for when multiple episodes aired on a single day).</li>
</ul>
<MudText Class="mt-3">
Schedule items have a <b>start type</b>, a <b>start time</b>, a <b>collection</b> and a <b>playout mode</b>.
</MudText>
<MudText Class="mt-3">
A <b>fixed</b> start type requires a <b>start time</b>, while a <b>dynamic</b> start type means the schedule item will start immediately after the preceding schedule item.
</MudText>
<MudText Class="mt-3 mb-2">Four <b>playout modes</b> are supported:</MudText>
<ul class="mud-typography-body1">
<li><b>One</b> - to play one media item from the collection before advancing to the next schedule item.</li>
<li><b>Multiple</b> - to play a specified <b>count</b> of media items from the collection before advancing to the next schedule item.</li>
<li><b>Duration</b> - to play the maximum number of complete media items that will fit in the specified <b>playout duration</b>, before either going offline for the remainder of the <b>playout duration</b> (an <b>offline tail</b>), or immediately advancing to the next schedule item.</li>
<li><b>Flood</b> - to play media items from the collection forever, or until the next schedule item's <b>start time</b> if one exists.</li>
</ul>
</MudElement>
<MudElement HtmlTag="div" Class="mt-6">
<MudText Typo="Typo.h4" GutterBottom="true">Playouts</MudText>
<MudText>
<MudLink Href="/playouts">Playouts</MudLink> assign a <b>schedule</b> to a <b>channel</b> and individually track the ordered playback of collection items.
</MudText>
</MudElement>
</MudCardContent>
</MudCard>
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudCard>
<MudCardContent>
<MudText Typo="Typo.h3">Welcome to ErsatzTV!</MudText>
<MudElement HtmlTag="div" Class="mt-6">
<MudText Typo="Typo.h4" GutterBottom="true">Channels</MudText>
<MudText>
<MudLink Href="/channels">Channels</MudLink> are not directly associated with any media. Channels have a <b>number</b>, a <b>name</b>, and a <b>streaming mode</b> that indicates how the channel will play media.
</MudText>
<MudText Class="mt-3">
In <b>TransportStream</b> mode, the channel will also require an <b>FFmpeg profile</b> to configure transcoding and normalization.
In <b>HttpLiveStreaming</b> mode, the channel will attempt to serve the channel's media without transcoding or normalization beyond the container format.
</MudText>
</MudElement>
<MudElement HtmlTag="div" Class="mt-6">
<MudText Typo="Typo.h4" GutterBottom="true">FFmpeg Profiles</MudText>
<MudText>
<MudLink Href="/ffmpeg">FFmpeg Profiles</MudLink> are collections of FFmpeg settings that are applied at the channel level.
All content on a given channel will use the same FFmpeg settings. This also means the same content on different channels can use different settings.
</MudText>
</MudElement>
<MudElement HtmlTag="div" Class="mt-6">
<MudText Typo="Typo.h4" GutterBottom="true">Libraries</MudText>
<MudText>
Two local <MudLink Href="/media/libraries">libraries</MudLink> are available, one for each <b>media kind</b>: Shows and Movies. Libraries contain <b>paths</b> (folders) to regularly scan for media items.
Support for Plex libraries is under active development; Jellyfin and Emby library support is planned.
</MudText>
</MudElement>
<MudElement HtmlTag="div" Class="mt-6">
<MudText Typo="Typo.h4" GutterBottom="true">Collections</MudText>
<MudText>
<MudLink Href="/media/collections">Collections</MudLink> have a <b>name</b> and contain a logical grouping of media items.
Collections may contain shows, seasons, episodes or movies.
Collections containing shows and seasons are automatically updated as media is added or removed from the linked shows and seasons.
</MudText>
</MudElement>
<MudElement HtmlTag="div" Class="mt-6">
<MudText Typo="Typo.h4" GutterBottom="true">Schedules</MudText>
<MudText>
<MudLink Href="/schedules">Schedules</MudLink> have a <b>name</b>, a <b>collection playback order</b> and <b>items</b> to continually loop through.
</MudText>
<MudText Class="mt-3 mb-2">Three <b>collection playback orders</b> are supported:</MudText>
<ul class="mud-typography-body1">
<li><b>Random</b> - to randomly play collection items; repeating is allowed before all collection items have been played.</li>
<li><b>Shuffle</b> - to randomly play collection items; repeating is <i>not</i> allowed until all collection items have been played.</li>
<li><b>Chronological</b> - to play collection items sorted by air date and then by season and episode number (for when multiple episodes aired on a single day).</li>
</ul>
<MudText Class="mt-3">
Schedule items have a <b>start type</b>, a <b>start time</b>, a <b>collection</b> and a <b>playout mode</b>.
</MudText>
<MudText Class="mt-3">
A <b>fixed</b> start type requires a <b>start time</b>, while a <b>dynamic</b> start type means the schedule item will start immediately after the preceding schedule item.
</MudText>
<MudText Class="mt-3 mb-2">Four <b>playout modes</b> are supported:</MudText>
<ul class="mud-typography-body1">
<li><b>One</b> - to play one media item from the collection before advancing to the next schedule item.</li>
<li><b>Multiple</b> - to play a specified <b>count</b> of media items from the collection before advancing to the next schedule item.</li>
<li><b>Duration</b> - to play the maximum number of complete media items that will fit in the specified <b>playout duration</b>, before either going offline for the remainder of the <b>playout duration</b> (an <b>offline tail</b>), or immediately advancing to the next schedule item.</li>
<li><b>Flood</b> - to play media items from the collection forever, or until the next schedule item's <b>start time</b> if one exists.</li>
</ul>
</MudElement>
<MudElement HtmlTag="div" Class="mt-6">
<MudText Typo="Typo.h4" GutterBottom="true">Playouts</MudText>
<MudText>
<MudLink Href="/playouts">Playouts</MudLink> assign a <b>schedule</b> to a <b>channel</b> and individually track the ordered playback of collection items.
</MudText>
</MudElement>
</MudCardContent>
</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>
+33 -24
View File
@@ -7,33 +7,42 @@
@inject IDialogService Dialog
@inject NavigationManager NavigationManager
<MudContainer MaxWidth="MaxWidth.Large">
<MudBreadcrumbs Items="_breadcrumbs" Class="mb-6"></MudBreadcrumbs>
<MudCard Class="mb-6">
<div style="display: flex; flex-direction: row;">
@if (!string.IsNullOrWhiteSpace(_movie.Poster))
<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))
{
<img class="mud-elevation-2 mr-6"
style="border-radius: 4px; max-height: 440px"
src="@($"/artwork/posters/{_movie.Poster}")" alt="movie poster"/>
}
<div style="display: flex; flex-direction: column; height: 100%">
<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))
{
<MudPaper Style="flex-shrink: 0;">
<MudCardMedia Image="@($"/artwork/posters/{_movie.Poster}")" Style="height: 440px; width: 304px;"/>
</MudPaper>
<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>
}
<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 Style="flex-grow: 1">@_movie.Plot</MudText>
<div>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
OnClick="@AddToCollection">
Add To Collection
</MudButton>
</div>
</div>
</MudCardContent>
<div>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
OnClick="@AddToCollection">
Add To Collection
</MudButton>
</div>
</div>
</MudCard>
</div>
</MudContainer>
@code {
+24 -22
View File
@@ -12,29 +12,31 @@
@inject NavigationManager NavigationManager
@inject ChannelWriter<IBackgroundServiceRequest> Channel
<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"
Disabled="@(PageNumber <= 1)">
</MudIconButton>
<MudText Style="flex-grow: 1"
Align="Align.Center">
@Math.Min((PageNumber - 1) * PageSize + 1, _data.Count)-@Math.Min(_data.Count, PageNumber * PageSize) of @_data.Count
</MudText>
<MudIconButton Icon="@Icons.Material.Outlined.ChevronRight"
OnClick="@NextPage" Disabled="@(PageNumber * PageSize >= _data.Count)">
</MudIconButton>
</MudPaper>
</MudContainer>
<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"
Disabled="@(PageNumber <= 1)">
</MudIconButton>
<MudText Style="flex-grow: 1"
Align="Align.Center">
@Math.Min((PageNumber - 1) * PageSize + 1, _data.Count)-@Math.Min(_data.Count, PageNumber * PageSize) of @_data.Count
</MudText>
<MudIconButton Icon="@Icons.Material.Outlined.ChevronRight"
OnClick="@NextPage" Disabled="@(PageNumber * PageSize >= _data.Count)">
</MudIconButton>
</MudPaper>
</MudContainer>
<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 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 {
+19 -17
View File
@@ -8,24 +8,26 @@
@inject ISnackbar Snackbar
@inject IMediator Mediator
<div style="max-width: 400px;">
<MudText Typo="Typo.h4" Class="mb-4">Add Playout</MudText>
<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">
<FluentValidator/>
<MudCard>
<MudCardContent>
<MudAutocomplete T="ChannelViewModel" Label="Channel" @bind-value="_model.Channel" SearchFunc="@SearchChannels" ToStringFunc="@(c => c is null ? null : $"{c.Number} - {c.Name}")"/>
<MudAutocomplete Class="mt-3" T="ProgramScheduleViewModel" Label="Schedule" @bind-value="_model.ProgramSchedule" SearchFunc="@SearchProgramSchedules" ToStringFunc="@(s => s?.Name)"/>
</MudCardContent>
<MudCardActions>
<MudButton ButtonType="ButtonType.Submit" Variant="Variant.Filled" Color="Color.Primary">
Add Playout
</MudButton>
</MudCardActions>
</MudCard>
</EditForm>
</div>
<EditForm EditContext="_editContext" OnSubmit="@HandleSubmitAsync">
<FluentValidator/>
<MudCard>
<MudCardContent>
<MudAutocomplete T="ChannelViewModel" Label="Channel" @bind-value="_model.Channel" SearchFunc="@SearchChannels" ToStringFunc="@(c => c is null ? null : $"{c.Number} - {c.Name}")"/>
<MudAutocomplete Class="mt-3" T="ProgramScheduleViewModel" Label="Schedule" @bind-value="_model.ProgramSchedule" SearchFunc="@SearchProgramSchedules" ToStringFunc="@(s => s?.Name)"/>
</MudCardContent>
<MudCardActions>
<MudButton ButtonType="ButtonType.Submit" Variant="Variant.Filled" Color="Color.Primary">
Add Playout
</MudButton>
</MudCardActions>
</MudCard>
</EditForm>
</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>
+25 -23
View File
@@ -7,29 +7,31 @@
@inject ISnackbar Snackbar
@inject IMediator Mediator
<div style="max-width: 400px;">
<MudText Typo="Typo.h4" Class="mb-4">@(IsEdit ? "Edit Schedule" : "Add Schedule")</MudText>
<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">
<FluentValidator/>
<MudCard>
<MudCardContent>
<MudTextField Label="Name" @bind-Value="_model.Name" For="@(() => _model.Name)"/>
<MudSelect Class="mt-3" Label="Collection Playback Order" @bind-Value="_model.MediaCollectionPlaybackOrder" For="@(() => _model.MediaCollectionPlaybackOrder)">
@foreach (PlaybackOrder playbackOrder in Enum.GetValues<PlaybackOrder>())
{
<MudSelectItem Value="@playbackOrder">@playbackOrder</MudSelectItem>
}
</MudSelect>
</MudCardContent>
<MudCardActions>
<MudButton ButtonType="ButtonType.Submit" Variant="Variant.Filled" Color="Color.Primary">
@(IsEdit ? "Save Changes" : "Add Schedule")
</MudButton>
</MudCardActions>
</MudCard>
</EditForm>
</div>
<EditForm EditContext="_editContext" OnSubmit="@HandleSubmitAsync">
<FluentValidator/>
<MudCard>
<MudCardContent>
<MudTextField Label="Name" @bind-Value="_model.Name" For="@(() => _model.Name)"/>
<MudSelect Class="mt-3" Label="Collection Playback Order" @bind-Value="_model.MediaCollectionPlaybackOrder" For="@(() => _model.MediaCollectionPlaybackOrder)">
@foreach (PlaybackOrder playbackOrder in Enum.GetValues<PlaybackOrder>())
{
<MudSelectItem Value="@playbackOrder">@playbackOrder</MudSelectItem>
}
</MudSelect>
</MudCardContent>
<MudCardActions>
<MudButton ButtonType="ButtonType.Submit" Variant="Variant.Filled" Color="Color.Primary">
@(IsEdit ? "Save Changes" : "Add Schedule")
</MudButton>
</MudCardActions>
</MudCard>
</EditForm>
</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"));
+127 -125
View File
@@ -11,138 +11,140 @@
@inject ISnackbar Snackbar
@inject IMediator Mediator
<MudTable Hover="true" Items="_schedule.Items.OrderBy(i => i.Index)" Dense="true" Class="mt-8" @bind-SelectedItem="_selectedItem">
<ToolBarContent>
<MudText Typo="Typo.h6">@_schedule.Name Items</MudText>
</ToolBarContent>
<ColGroup>
<col/>
<col/>
<col/>
<col style="width: 60px;"/>
<col style="width: 60px;"/>
<col style="width: 60px;"/>
</ColGroup>
<HeaderContent>
<MudTh>Start Time</MudTh>
<MudTh>Collection</MudTh>
<MudTh>Playout Mode</MudTh>
<MudTh/>
<MudTh/>
<MudTh/>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Start Time">
<MudText Typo="@(context == _selectedItem ? Typo.subtitle2 : Typo.body2)">
@(context.StartType == StartType.Fixed ? context.StartTime?.ToString(@"hh\:mm") ?? string.Empty : "Dynamic")
</MudText>
</MudTd>
<MudTd DataLabel="Collection">
<MudText Typo="@(context == _selectedItem ? Typo.subtitle2 : Typo.body2)">
@context.CollectionName
</MudText>
</MudTd>
<MudTd DataLabel="Playout Mode">
<MudText Typo="@(context == _selectedItem ? Typo.subtitle2 : Typo.body2)">
@context.PlayoutMode
@if (context.PlayoutMode == PlayoutMode.Multiple && context.MultipleCount.HasValue)
{
@($" ({context.MultipleCount})")
}
</MudText>
</MudTd>
<MudTd>
<MudIconButton Icon="@Icons.Material.Filled.ArrowUpward"
OnClick="@(_ => MoveItemUp(context))"
Disabled="@(_schedule.Items.All(x => x.Index >= context.Index))">
</MudIconButton>
</MudTd>
<MudTd>
<MudIconButton Icon="@Icons.Material.Filled.ArrowDownward"
OnClick="@(_ => MoveItemDown(context))"
Disabled="@(_schedule.Items.All(x => x.Index <= context.Index))">
</MudIconButton>
</MudTd>
<MudTd>
<MudIconButton Icon="@Icons.Material.Filled.Delete"
OnClick="@(_ => RemoveScheduleItem(context))">
</MudIconButton>
</MudTd>
</RowTemplate>
<PagerContent>
<MudTablePager/>
</PagerContent>
</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">
Save Changes
</MudButton>
<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>
<ColGroup>
<col/>
<col/>
<col/>
<col style="width: 60px;"/>
<col style="width: 60px;"/>
<col style="width: 60px;"/>
</ColGroup>
<HeaderContent>
<MudTh>Start Time</MudTh>
<MudTh>Collection</MudTh>
<MudTh>Playout Mode</MudTh>
<MudTh/>
<MudTh/>
<MudTh/>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Start Time">
<MudText Typo="@(context == _selectedItem ? Typo.subtitle2 : Typo.body2)">
@(context.StartType == StartType.Fixed ? context.StartTime?.ToString(@"hh\:mm") ?? string.Empty : "Dynamic")
</MudText>
</MudTd>
<MudTd DataLabel="Collection">
<MudText Typo="@(context == _selectedItem ? Typo.subtitle2 : Typo.body2)">
@context.CollectionName
</MudText>
</MudTd>
<MudTd DataLabel="Playout Mode">
<MudText Typo="@(context == _selectedItem ? Typo.subtitle2 : Typo.body2)">
@context.PlayoutMode
@if (context.PlayoutMode == PlayoutMode.Multiple && context.MultipleCount.HasValue)
{
@($" ({context.MultipleCount})")
}
</MudText>
</MudTd>
<MudTd>
<MudIconButton Icon="@Icons.Material.Filled.ArrowUpward"
OnClick="@(_ => MoveItemUp(context))"
Disabled="@(_schedule.Items.All(x => x.Index >= context.Index))">
</MudIconButton>
</MudTd>
<MudTd>
<MudIconButton Icon="@Icons.Material.Filled.ArrowDownward"
OnClick="@(_ => MoveItemDown(context))"
Disabled="@(_schedule.Items.All(x => x.Index <= context.Index))">
</MudIconButton>
</MudTd>
<MudTd>
<MudIconButton Icon="@Icons.Material.Filled.Delete"
OnClick="@(_ => RemoveScheduleItem(context))">
</MudIconButton>
</MudTd>
</RowTemplate>
<PagerContent>
<MudTablePager/>
</PagerContent>
</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">
Save Changes
</MudButton>
@if (_selectedItem is not null)
{
<div style="max-width: 400px;">
<EditForm Model="_selectedItem">
<FluentValidator/>
<MudCard Class="mt-6">
<MudCardContent>
<MudSelect Label="Start Type" @bind-Value="_selectedItem.StartType" For="@(() => _selectedItem.StartType)">
@foreach (StartType startType in Enum.GetValues<StartType>())
@if (_selectedItem is not null)
{
<div style="max-width: 400px;">
<EditForm Model="_selectedItem">
<FluentValidator/>
<MudCard Class="mt-6">
<MudCardContent>
<MudSelect Label="Start Type" @bind-Value="_selectedItem.StartType" For="@(() => _selectedItem.StartType)">
@foreach (StartType startType in Enum.GetValues<StartType>())
{
<MudSelectItem Value="@startType">@startType</MudSelectItem>
}
</MudSelect>
<MudTimePicker Class="mt-3" Label="Start Time" @bind-Time="@_selectedItem.StartTime" For="@(() => _selectedItem.StartTime)" Disabled="@(_selectedItem.StartType == StartType.Dynamic)"/>
<MudSelect Label="Collection Type" @bind-Value="_selectedItem.CollectionType" For="@(() => _selectedItem.CollectionType)">
@foreach (ProgramScheduleItemCollectionType collectionType in Enum.GetValues<ProgramScheduleItemCollectionType>())
{
<MudSelectItem Value="@collectionType">@collectionType</MudSelectItem>
}
</MudSelect>
@if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.Collection)
{
<MudSelectItem Value="@startType">@startType</MudSelectItem>
<MudAutocomplete Class="mt-3"
T="MediaCollectionViewModel"
Label="Collection"
@bind-value="_selectedItem.Collection"
SearchFunc="@SearchMediaCollections"
ToStringFunc="@(c => c?.Name)"/>
}
</MudSelect>
<MudTimePicker Class="mt-3" Label="Start Time" @bind-Time="@_selectedItem.StartTime" For="@(() => _selectedItem.StartTime)" Disabled="@(_selectedItem.StartType == StartType.Dynamic)"/>
<MudSelect Label="Collection Type" @bind-Value="_selectedItem.CollectionType" For="@(() => _selectedItem.CollectionType)">
@foreach (ProgramScheduleItemCollectionType collectionType in Enum.GetValues<ProgramScheduleItemCollectionType>())
@if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.TelevisionShow)
{
<MudSelectItem Value="@collectionType">@collectionType</MudSelectItem>
<MudAutocomplete Class="mt-3"
T="NamedMediaItemViewModel"
Label="Television Show"
@bind-value="_selectedItem.MediaItem"
SearchFunc="@SearchTelevisionShows"
ToStringFunc="@(s => s?.Name)"/>
}
</MudSelect>
@if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.Collection)
{
<MudAutocomplete Class="mt-3"
T="MediaCollectionViewModel"
Label="Collection"
@bind-value="_selectedItem.Collection"
SearchFunc="@SearchMediaCollections"
ToStringFunc="@(c => c?.Name)"/>
}
@if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.TelevisionShow)
{
<MudAutocomplete Class="mt-3"
T="NamedMediaItemViewModel"
Label="Television Show"
@bind-value="_selectedItem.MediaItem"
SearchFunc="@SearchTelevisionShows"
ToStringFunc="@(s => s?.Name)"/>
}
@if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.TelevisionSeason)
{
<MudAutocomplete Class="mt-3"
T="NamedMediaItemViewModel"
Label="Television Season"
@bind-value="_selectedItem.MediaItem"
SearchFunc="@SearchTelevisionSeasons"
ToStringFunc="@(s => s?.Name)"/>
}
<MudSelect Class="mt-3" Label="Playout Mode" @bind-Value="@_selectedItem.PlayoutMode" For="@(() => _selectedItem.PlayoutMode)">
@foreach (PlayoutMode playoutMode in Enum.GetValues<PlayoutMode>())
@if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.TelevisionSeason)
{
<MudSelectItem Value="@playoutMode">@playoutMode</MudSelectItem>
<MudAutocomplete Class="mt-3"
T="NamedMediaItemViewModel"
Label="Television Season"
@bind-value="_selectedItem.MediaItem"
SearchFunc="@SearchTelevisionSeasons"
ToStringFunc="@(s => s?.Name)"/>
}
</MudSelect>
<MudTextField Class="mt-3" Label="Multiple Count" @bind-Value="@_selectedItem.MultipleCount" For="@(() => _selectedItem.MultipleCount)" Disabled="@(_selectedItem.PlayoutMode != PlayoutMode.Multiple)"/>
<MudTimePicker Class="mt-3" Label="Playout Duration" @bind-Time="@_selectedItem.PlayoutDuration" For="@(() => _selectedItem.PlayoutDuration)" Disabled="@(_selectedItem.PlayoutMode != PlayoutMode.Duration)"/>
<MudElement HtmlTag="div" Class="mt-3">
<MudSwitch Label="Offline Tail" @bind-Checked="@_selectedItem.OfflineTail" For="@(() => _selectedItem.OfflineTail)" Disabled="@(_selectedItem.PlayoutMode != PlayoutMode.Duration)"/>
</MudElement>
</MudCardContent>
</MudCard>
</EditForm>
</div>
}
<MudSelect Class="mt-3" Label="Playout Mode" @bind-Value="@_selectedItem.PlayoutMode" For="@(() => _selectedItem.PlayoutMode)">
@foreach (PlayoutMode playoutMode in Enum.GetValues<PlayoutMode>())
{
<MudSelectItem Value="@playoutMode">@playoutMode</MudSelectItem>
}
</MudSelect>
<MudTextField Class="mt-3" Label="Multiple Count" @bind-Value="@_selectedItem.MultipleCount" For="@(() => _selectedItem.MultipleCount)" Disabled="@(_selectedItem.PlayoutMode != PlayoutMode.Multiple)"/>
<MudTimePicker Class="mt-3" Label="Playout Duration" @bind-Time="@_selectedItem.PlayoutDuration" For="@(() => _selectedItem.PlayoutDuration)" Disabled="@(_selectedItem.PlayoutMode != PlayoutMode.Duration)"/>
<MudElement HtmlTag="div" Class="mt-3">
<MudSwitch Label="Offline Tail" @bind-Checked="@_selectedItem.OfflineTail" For="@(() => _selectedItem.OfflineTail)" Disabled="@(_selectedItem.PlayoutMode != PlayoutMode.Duration)"/>
</MudElement>
</MudCardContent>
</MudCard>
</EditForm>
</div>
}
</MudContainer>
@code {
+25 -24
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,31 +50,32 @@
<MudButton Variant="Variant.Filled" Color="Color.Primary" Link="/schedules/add" Class="mt-4">
Add Schedule
</MudButton>
@if (_selectedScheduleItems != null)
{
<MudTable Hover="true" Items="_selectedScheduleItems.OrderBy(i => i.Index)" Class="mt-8">
<ToolBarContent>
<MudText Typo="Typo.h6">@_selectedSchedule.Name Items</MudText>
</ToolBarContent>
<HeaderContent>
<MudTh>Start Time</MudTh>
<MudTh>Collection</MudTh>
<MudTh>Playout Mode</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Start Time">
@(context.StartType == StartType.Fixed ? context.StartTime?.ToString(@"hh\:mm") ?? string.Empty : "Dynamic")
</MudTd>
<MudTd DataLabel="Collection">@context.Name</MudTd>
<MudTd DataLabel="Playout Mode">@context.PlayoutMode</MudTd>
</RowTemplate>
<PagerContent>
<MudTablePager/>
</PagerContent>
</MudTable>
}
</MudContainer>
@if (_selectedScheduleItems != null)
{
<MudTable Hover="true" Items="_selectedScheduleItems.OrderBy(i => i.Index)" Class="mt-8">
<ToolBarContent>
<MudText Typo="Typo.h6">@_selectedSchedule.Name Items</MudText>
</ToolBarContent>
<HeaderContent>
<MudTh>Start Time</MudTh>
<MudTh>Collection</MudTh>
<MudTh>Playout Mode</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Start Time">
@(context.StartType == StartType.Fixed ? context.StartTime?.ToString(@"hh\:mm") ?? string.Empty : "Dynamic")
</MudTd>
<MudTd DataLabel="Collection">@context.Name</MudTd>
<MudTd DataLabel="Playout Mode">@context.PlayoutMode</MudTd>
</RowTemplate>
<PagerContent>
<MudTablePager/>
</PagerContent>
</MudTable>
}
@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>
+28 -26
View File
@@ -7,33 +7,35 @@
@inject IDialogService Dialog
@inject NavigationManager NavigationManager
<MudContainer MaxWidth="MaxWidth.Large">
<MudBreadcrumbs Items="_breadcrumbs" Class="mb-6"></MudBreadcrumbs>
<MudCard Class="mb-6">
<div style="display: flex; flex-direction: row;">
@if (!string.IsNullOrWhiteSpace(_episode.Poster))
{
<MudPaper style="display: flex; flex-direction: column">
<MudCardMedia Image="@($"/artwork/thumbnails/{_episode.Poster}")" Style="flex-grow: 1; height: 220px; width: 392px;"/>
</MudPaper>
}
<MudCardContent Class="ml-3">
<div style="display: flex; flex-direction: column; height: 100%">
<MudText Typo="Typo.h4">@_episode.Title</MudText>
<MudText Typo="Typo.subtitle1" Class="mb-6 mud-text-secondary">@_season.Plot</MudText>
<MudText Style="flex-grow: 1">@_episode.Plot</MudText>
<div class="mt-6">
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
OnClick="@AddToCollection">
Add To Collection
</MudButton>
<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;">
@if (!string.IsNullOrWhiteSpace(_episode.Poster))
{
<MudPaper style="display: flex; flex-direction: column">
<MudCardMedia Image="@($"/artwork/thumbnails/{_episode.Poster}")" Style="flex-grow: 1; height: 220px; width: 392px;"/>
</MudPaper>
}
<MudCardContent Class="ml-3">
<div style="display: flex; flex-direction: column; height: 100%">
<MudText Typo="Typo.h4">@_episode.Title</MudText>
<MudText Typo="Typo.subtitle1" Class="mb-6 mud-text-secondary">@_season.Plot</MudText>
<MudText Style="flex-grow: 1">@_episode.Plot</MudText>
<div class="mt-6">
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
OnClick="@AddToCollection">
Add To Collection
</MudButton>
</div>
</div>
</div>
</MudCardContent>
</div>
</MudCard>
</MudCardContent>
</div>
</MudCard>
</MudContainer>
</MudContainer>
@code {
+47 -45
View File
@@ -15,53 +15,55 @@
@inject NavigationManager NavigationManager
@inject ChannelWriter<IBackgroundServiceRequest> Channel
<MudContainer MaxWidth="MaxWidth.Large">
<MudBreadcrumbs Items="_breadcrumbs" Class="mb-6"></MudBreadcrumbs>
<MudCard Class="mb-6">
<div style="display: flex; flex-direction: row;">
@if (!string.IsNullOrWhiteSpace(_season.Poster))
{
<MudPaper Style="flex-shrink: 0;">
<MudCardMedia Image="@($"/artwork/posters/{_season.Poster}")" Style="height: 440px; width: 304px;"/>
</MudPaper>
}
<MudCardContent Class="mx-3 my-3">
<div style="display: flex; flex-direction: column; height: 100%">
<MudText Typo="Typo.h3">@_season.Title</MudText>
<MudText Typo="Typo.subtitle1" Class="mb-6 mud-text-secondary">@_season.Year</MudText>
<MudText Style="flex-grow: 1">@_season.Plot</MudText>
<div class="mt-6">
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
OnClick="@AddToCollection">
Add To Collection
</MudButton>
<MudButton Class="ml-3"
Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Schedule"
OnClick="@AddToSchedule">
Add To Schedule
</MudButton>
<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;">
@if (!string.IsNullOrWhiteSpace(_season.Poster))
{
<MudPaper Style="flex-shrink: 0;">
<MudCardMedia Image="@($"/artwork/posters/{_season.Poster}")" Style="height: 440px; width: 304px;"/>
</MudPaper>
}
<MudCardContent Class="mx-3 my-3">
<div style="display: flex; flex-direction: column; height: 100%">
<MudText Typo="Typo.h3">@_season.Title</MudText>
<MudText Typo="Typo.subtitle1" Class="mb-6 mud-text-secondary">@_season.Year</MudText>
<MudText Style="flex-grow: 1">@_season.Plot</MudText>
<div class="mt-6">
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
OnClick="@AddToCollection">
Add To Collection
</MudButton>
<MudButton Class="ml-3"
Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Schedule"
OnClick="@AddToSchedule">
Add To Schedule
</MudButton>
</div>
</div>
</div>
</MudCardContent>
</div>
</MudCard>
</MudContainer>
</MudCardContent>
</div>
</MudCard>
</MudContainer>
<MudContainer MaxWidth="MaxWidth.Large" Class="media-card-grid">
@foreach (TelevisionEpisodeCardViewModel card in _data.Cards)
{
<MediaCard Data="@card"
Placeholder="@card.Placeholder"
Link="@($"/media/tv/episodes/{card.EpisodeId}")"
AddToCollectionClicked="@AddEpisodeToCollection"
ContainerClass="media-card-episode-container mx-2"
CardClass="media-card-episode"
ArtworkKind="@ArtworkKind.Thumbnail"/>
}
<MudContainer MaxWidth="MaxWidth.Large" Class="media-card-grid">
@foreach (TelevisionEpisodeCardViewModel card in _data.Cards)
{
<MediaCard Data="@card"
Placeholder="@card.Placeholder"
Link="@($"/media/tv/episodes/{card.EpisodeId}")"
AddToCollectionClicked="@AddEpisodeToCollection"
ContainerClass="media-card-episode-container mx-2"
CardClass="media-card-episode"
ArtworkKind="@ArtworkKind.Thumbnail"/>
}
</MudContainer>
</MudContainer>
@code {
+43 -41
View File
@@ -15,49 +15,51 @@
@inject NavigationManager NavigationManager
@inject ChannelWriter<IBackgroundServiceRequest> Channel
<MudContainer MaxWidth="MaxWidth.Large">
<MudBreadcrumbs Items="_breadcrumbs" Class="mb-6"></MudBreadcrumbs>
<MudCard Class="mb-6">
<div style="display: flex; flex-direction: row;">
@if (!string.IsNullOrWhiteSpace(_show.Poster))
{
<MudPaper Style="flex-shrink: 0;">
<MudCardMedia Image="@($"/artwork/posters/{_show.Poster}")" Style="height: 440px; width: 304px;"/>
</MudPaper>
}
<MudCardContent Class="mx-3 my-3">
<div style="display: flex; flex-direction: column; height: 100%">
<MudText Typo="Typo.h3">@_show.Title</MudText>
<MudText Typo="Typo.subtitle1" Class="mb-6 mud-text-secondary">@_show.Year</MudText>
<MudText Style="flex-grow: 1">@_show.Plot</MudText>
<div>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
OnClick="@AddToCollection">
Add To Collection
</MudButton>
<MudButton Class="ml-3"
Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Schedule"
OnClick="@AddToSchedule">
Add To Schedule
</MudButton>
<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;">
@if (!string.IsNullOrWhiteSpace(_show.Poster))
{
<MudPaper Style="flex-shrink: 0;">
<MudCardMedia Image="@($"/artwork/posters/{_show.Poster}")" Style="height: 440px; width: 304px;"/>
</MudPaper>
}
<MudCardContent Class="mx-3 my-3">
<div style="display: flex; flex-direction: column; height: 100%">
<MudText Typo="Typo.h3">@_show.Title</MudText>
<MudText Typo="Typo.subtitle1" Class="mb-6 mud-text-secondary">@_show.Year</MudText>
<MudText Style="flex-grow: 1">@_show.Plot</MudText>
<div>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
OnClick="@AddToCollection">
Add To Collection
</MudButton>
<MudButton Class="ml-3"
Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Schedule"
OnClick="@AddToSchedule">
Add To Schedule
</MudButton>
</div>
</div>
</div>
</MudCardContent>
</div>
</MudCard>
</MudContainer>
</MudCardContent>
</div>
</MudCard>
</MudContainer>
<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 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 {
+24 -22
View File
@@ -10,29 +10,31 @@
@inject IDialogService Dialog
@inject ChannelWriter<IBackgroundServiceRequest> Channel
<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())"
Disabled="@(_pageNumber <= 1)">
</MudIconButton>
<MudText Style="flex-grow: 1"
Align="Align.Center">
@Math.Min((_pageNumber - 1) * _pageSize + 1, _data.Count)-@Math.Min(_data.Count, _pageNumber * _pageSize) of @_data.Count
</MudText>
<MudIconButton Icon="@Icons.Material.Outlined.ChevronRight"
OnClick="@(() => NextPage())" Disabled="@(_pageNumber * _pageSize >= _data.Count)">
</MudIconButton>
</MudPaper>
</MudContainer>
<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())"
Disabled="@(_pageNumber <= 1)">
</MudIconButton>
<MudText Style="flex-grow: 1"
Align="Align.Center">
@Math.Min((_pageNumber - 1) * _pageSize + 1, _data.Count)-@Math.Min(_data.Count, _pageNumber * _pageSize) of @_data.Count
</MudText>
<MudIconButton Icon="@Icons.Material.Outlined.ChevronRight"
OnClick="@(() => NextPage())" Disabled="@(_pageNumber * _pageSize >= _data.Count)">
</MudIconButton>
</MudPaper>
</MudContainer>
<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 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 {
+25 -18
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>
@Body
</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;
@@ -68,4 +70,38 @@
.search-bar .mud-input.mud-input-outlined .mud-input-outlined-border {
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