Compare commits

...
Author SHA1 Message Date
Jason DoveandGitHub c240169fc9 add multiselect and movie tags (#62)
* add basic selection behavior to search results

* add search scrolling, selection actions

* include shows in multiselect

* multiselect movies, shows, collection items

* add movie and show tags

* code cleanup

* update show screenshot
2021-03-11 18:49:00 +00:00
Jason DoveandGitHub 76d6725dd5 add movie and show genres (#61)
* load movie genres from sidecar metadata

* search movie and tv show genres

* rebuild all playouts (needed after time zone fix)

* code cleanup

* fix duplicate tv show search results
2021-03-11 02:42:04 +00:00
Jason DoveandGitHub c016cac8d4 fix playout time zone bugs (#60)
* fix time zone bugs with playout building

* more time zone fixes
2021-03-10 22:37:57 +00:00
Jason DoveandGitHub e624627ae1 fix editing channel number (#59) 2021-03-10 19:15:08 +00:00
Jason DoveandGitHub 46bcf03d9a regenerate sort titles for all media items (#58) 2021-03-10 18:06:27 +00:00
Jason DoveandGitHub ab9a8493d9 fix tv show sorting (#57) 2021-03-10 14:14:33 +00:00
Jason DoveandGitHub b1ecbafb6e tv ui update (#56)
* update tv show ui

* update tv season ui

* list episode details on tv season page

* remove episode page

* remove breadcrumbs

* move home link

* code cleanup

* update screenshots
2021-03-10 12:24:18 +00:00
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
130 changed files with 16463 additions and 1029 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, Channel.NumberValidator))
{
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, Channel.NumberValidator))
{
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
};
+5 -4
View File
@@ -33,11 +33,12 @@ namespace ErsatzTV.Application.MediaCards
episodeMetadata.EpisodeId,
episodeMetadata.ReleaseDate ?? DateTime.MinValue,
episodeMetadata.Episode.Season.Show.ShowMetadata.HeadOrNone().Map(m => m.Title).IfNone(string.Empty),
episodeMetadata.Episode.Season.ShowId,
episodeMetadata.Episode.SeasonId,
episodeMetadata.Episode.EpisodeNumber,
episodeMetadata.Title,
$"Episode {episodeMetadata.Episode.EpisodeNumber}",
episodeMetadata.Episode.EpisodeNumber.ToString(),
GetThumbnail(episodeMetadata),
episodeMetadata.Episode.EpisodeNumber.ToString());
episodeMetadata.Episode.EpisodeMetadata.HeadOrNone().Map(em => em.Plot).IfNone(string.Empty),
GetThumbnail(episodeMetadata));
internal static MovieCardViewModel ProjectToViewModel(MovieMetadata movieMetadata) =>
new(
@@ -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);
}
}
@@ -18,7 +18,25 @@ namespace ErsatzTV.Application.MediaCards.Queries
public Task<Either<BaseError, SearchCardResultsViewModel>> Handle(
GetSearchCards request,
CancellationToken cancellationToken) =>
Try(_searchRepository.SearchMediaItems(request.Query)).Sequence()
request.Query.Split(":").Head() switch
{
"genre" => GenreSearch(request.Query.Replace("genre:", string.Empty)),
"tag" => TagSearch(request.Query.Replace("tag:", string.Empty)),
_ => TitleSearch(request.Query)
};
private Task<Either<BaseError, SearchCardResultsViewModel>> TitleSearch(string query) =>
Try(_searchRepository.SearchMediaItemsByTitle(query)).Sequence()
.Map(ProjectToSearchResults)
.Map(t => t.ToEither(ex => BaseError.New($"Failed to search: {ex.Message}")));
private Task<Either<BaseError, SearchCardResultsViewModel>> GenreSearch(string query) =>
Try(_searchRepository.SearchMediaItemsByGenre(query)).Sequence()
.Map(ProjectToSearchResults)
.Map(t => t.ToEither(ex => BaseError.New($"Failed to search: {ex.Message}")));
private Task<Either<BaseError, SearchCardResultsViewModel>> TagSearch(string query) =>
Try(_searchRepository.SearchMediaItemsByTag(query)).Sequence()
.Map(ProjectToSearchResults)
.Map(t => t.ToEither(ex => BaseError.New($"Failed to search: {ex.Message}")));
}
@@ -7,14 +7,15 @@ namespace ErsatzTV.Application.MediaCards
int EpisodeId,
DateTime Aired,
string ShowTitle,
int ShowId,
int SeasonId,
int Episode,
string Title,
string Subtitle,
string SortTitle,
string Poster,
string Placeholder) : MediaCardViewModel(
string Plot,
string Poster) : MediaCardViewModel(
Title,
Subtitle,
SortTitle,
$"Episode {Episode}",
$"Episode {Episode}",
Poster)
{
}
@@ -0,0 +1,9 @@
using System.Collections.Generic;
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record AddItemsToCollection
(int CollectionId, List<int> MovieIds, List<int> ShowIds) : MediatR.IRequest<Either<BaseError, Unit>>;
}
@@ -0,0 +1,80 @@
using System.Linq;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Application.Playouts.Commands;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class
AddItemsToCollectionHandler : MediatR.IRequestHandler<AddItemsToCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly IMovieRepository _movieRepository;
private readonly ITelevisionRepository _televisionRepository;
public AddItemsToCollectionHandler(
IMediaCollectionRepository mediaCollectionRepository,
IMovieRepository movieRepository,
ITelevisionRepository televisionRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
{
_mediaCollectionRepository = mediaCollectionRepository;
_movieRepository = movieRepository;
_televisionRepository = televisionRepository;
_channel = channel;
}
public Task<Either<BaseError, Unit>> Handle(
AddItemsToCollection request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(_ => ApplyAddItemsRequest(request))
.Bind(v => v.ToEitherAsync());
private async Task<Unit> ApplyAddItemsRequest(AddItemsToCollection request)
{
if (await _mediaCollectionRepository.AddMediaItems(
request.CollectionId,
request.MovieIds.Append(request.ShowIds).ToList()))
{
// rebuild all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(request.CollectionId))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, true));
}
}
return Unit.Default;
}
private async Task<Validation<BaseError, Unit>> Validate(AddItemsToCollection request) =>
(await CollectionMustExist(request), await ValidateMovies(request), await ValidateShows(request))
.Apply((_, _, _) => Unit.Default);
private Task<Validation<BaseError, Unit>> CollectionMustExist(AddItemsToCollection request) =>
_mediaCollectionRepository.GetCollectionWithItems(request.CollectionId)
.MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Collection does not exist."));
private Task<Validation<BaseError, Unit>> ValidateMovies(AddItemsToCollection request) =>
_movieRepository.AllMoviesExist(request.MovieIds)
.Map(Optional)
.Filter(v => v == true)
.MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Movie does not exist"));
private Task<Validation<BaseError, Unit>> ValidateShows(AddItemsToCollection request) =>
_televisionRepository.AllShowsExist(request.ShowIds)
.Map(Optional)
.Filter(v => v == true)
.MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Show does not exist"));
}
}
+8 -2
View File
@@ -13,8 +13,14 @@ 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),
metadata.Genres.Map(g => g.Name).ToList(),
metadata.Tags.Map(t => t.Name).ToList());
}
private static string Artwork(Metadata metadata, ArtworkKind artworkKind) =>
Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind))
.Match(a => a.Path, string.Empty);
}
}
+11 -2
View File
@@ -1,4 +1,13 @@
namespace ErsatzTV.Application.Movies
using System.Collections.Generic;
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,
List<string> Genres,
List<string> Tags);
}
@@ -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);
});
}
+15 -8
View File
@@ -1,4 +1,5 @@
using System.Linq;
using System.Collections.Generic;
using System.Linq;
using ErsatzTV.Core.Domain;
using static LanguageExt.Prelude;
@@ -12,7 +13,10 @@ namespace ErsatzTV.Application.Television
show.ShowMetadata.HeadOrNone().Map(m => m.Title ?? string.Empty).IfNone(string.Empty),
show.ShowMetadata.HeadOrNone().Map(m => m.Year?.ToString() ?? string.Empty).IfNone(string.Empty),
show.ShowMetadata.HeadOrNone().Map(m => m.Plot ?? string.Empty).IfNone(string.Empty),
show.ShowMetadata.HeadOrNone().Map(GetPoster).IfNone(string.Empty));
show.ShowMetadata.HeadOrNone().Map(GetPoster).IfNone(string.Empty),
show.ShowMetadata.HeadOrNone().Map(GetFanArt).IfNone(string.Empty),
show.ShowMetadata.HeadOrNone().Map(m => m.Genres.Map(g => g.Name).ToList()).IfNone(new List<string>()),
show.ShowMetadata.HeadOrNone().Map(m => m.Tags.Map(g => g.Name).ToList()).IfNone(new List<string>()));
internal static TelevisionSeasonViewModel ProjectToViewModel(Season season) =>
new(
@@ -21,7 +25,8 @@ namespace ErsatzTV.Application.Television
season.Show.ShowMetadata.HeadOrNone().Map(m => m.Title ?? string.Empty).IfNone(string.Empty),
season.Show.ShowMetadata.HeadOrNone().Map(m => m.Year?.ToString() ?? string.Empty).IfNone(string.Empty),
season.SeasonNumber == 0 ? "Specials" : $"Season {season.SeasonNumber}",
season.SeasonMetadata.HeadOrNone().Map(GetPoster).IfNone(string.Empty));
season.SeasonMetadata.HeadOrNone().Map(GetPoster).IfNone(string.Empty),
season.Show.ShowMetadata.HeadOrNone().Map(GetFanArt).IfNone(string.Empty));
internal static TelevisionEpisodeViewModel ProjectToViewModel(Episode episode) =>
new(
@@ -32,12 +37,14 @@ namespace ErsatzTV.Application.Television
episode.EpisodeMetadata.HeadOrNone().Map(m => m.Plot ?? string.Empty).IfNone(string.Empty),
episode.EpisodeMetadata.HeadOrNone().Map(GetThumbnail).IfNone(string.Empty));
private static string GetPoster(Metadata metadata) =>
Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster))
.Match(a => a.Path, string.Empty);
private static string GetPoster(Metadata metadata) => GetArtwork(metadata, ArtworkKind.Poster);
private static string GetThumbnail(Metadata metadata) =>
Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Thumbnail))
private static string GetFanArt(Metadata metadata) => GetArtwork(metadata, ArtworkKind.FanArt);
private static string GetThumbnail(Metadata metadata) => GetArtwork(metadata, ArtworkKind.Thumbnail);
private static string GetArtwork(Metadata metadata, ArtworkKind artworkKind) =>
Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind))
.Match(a => a.Path, string.Empty);
}
}
@@ -1,4 +1,11 @@
namespace ErsatzTV.Application.Television
{
public record TelevisionSeasonViewModel(int Id, int ShowId, string Title, string Year, string Plot, string Poster);
public record TelevisionSeasonViewModel(
int Id,
int ShowId,
string Title,
string Year,
string Name,
string Poster,
string FanArt);
}
@@ -1,4 +1,14 @@
namespace ErsatzTV.Application.Television
using System.Collections.Generic;
namespace ErsatzTV.Application.Television
{
public record TelevisionShowViewModel(int Id, string Title, string Year, string Plot, string Poster);
public record TelevisionShowViewModel(
int Id,
string Title,
string Year,
string Plot,
string Poster,
string FanArt,
List<string> Genres,
List<string> Tags);
}
@@ -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)
@@ -16,6 +16,8 @@ namespace ErsatzTV.Core.Tests.Fakes
public FakeMediaCollectionRepository(Map<int, List<MediaItem>> data) => _data = data;
public Task<Collection> Add(Collection collection) => throw new NotSupportedException();
public Task<bool> AddMediaItem(int collectionId, int mediaItemId) => throw new NotSupportedException();
public Task<bool> AddMediaItems(int collectionId, List<int> mediaItemIds) => throw new NotSupportedException();
public Task<Option<Collection>> Get(int id) => throw new NotSupportedException();
public Task<Option<Collection>> GetCollectionWithItems(int id) => throw new NotSupportedException();
public Task<Option<Collection>> GetCollectionWithItemsUntracked(int id) => throw new NotSupportedException();
@@ -9,6 +9,8 @@ namespace ErsatzTV.Core.Tests.Fakes
{
public class FakeTelevisionRepository : ITelevisionRepository
{
public Task<bool> AllShowsExist(List<int> showIds) => throw new NotSupportedException();
public Task<bool> Update(Show show) => throw new NotSupportedException();
public Task<bool> Update(Season season) => throw new NotSupportedException();
+3 -1
View File
@@ -5,10 +5,12 @@ namespace ErsatzTV.Core.Domain
{
public class Channel
{
public static string NumberValidator = @"^[0-9]+(\.[0-9])?$";
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
}
}
+8
View File
@@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Domain
{
public class Genre
{
public int Id { get; set; }
public string Name { get; set; }
}
}
@@ -15,5 +15,7 @@ namespace ErsatzTV.Core.Domain
public DateTime DateAdded { get; set; }
public DateTime DateUpdated { get; set; }
public List<Artwork> Artwork { get; set; }
public List<Genre> Genres { get; set; }
public List<Tag> Tags { get; set; }
}
}
+8
View File
@@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Domain
{
public class Tag
{
public int Id { get; set; }
public string Name { get; set; }
}
}
+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);
@@ -9,6 +9,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
{
Task<Collection> Add(Collection collection);
Task<bool> AddMediaItem(int collectionId, int mediaItemId);
Task<bool> AddMediaItems(int collectionId, List<int> mediaItemIds);
Task<Option<Collection>> Get(int id);
Task<Option<Collection>> GetCollectionWithItems(int id);
Task<Option<Collection>> GetCollectionWithItemsUntracked(int id);
@@ -7,6 +7,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
{
public interface IMovieRepository
{
Task<bool> AllMoviesExist(List<int> movieIds);
Task<Option<Movie>> GetMovie(int movieId);
Task<Either<BaseError, Movie>> GetOrAdd(LibraryPath libraryPath, string path);
Task<Either<BaseError, PlexMovie>> GetOrAdd(PlexLibrary library, PlexMovie item);
@@ -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);
@@ -6,6 +6,8 @@ namespace ErsatzTV.Core.Interfaces.Repositories
{
public interface ISearchRepository
{
public Task<List<MediaItem>> SearchMediaItems(string query);
public Task<List<MediaItem>> SearchMediaItemsByTitle(string query);
public Task<List<MediaItem>> SearchMediaItemsByGenre(string genre);
public Task<List<MediaItem>> SearchMediaItemsByTag(string tag);
}
}
@@ -7,6 +7,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
{
public interface ITelevisionRepository
{
Task<bool> AllShowsExist(List<int> showIds);
Task<bool> Update(Show show);
Task<bool> Update(Season season);
Task<bool> Update(Episode episode);
+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;
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using System.Xml.Serialization;
using ErsatzTV.Core.Domain;
@@ -100,11 +101,15 @@ namespace ErsatzTV.Core.Metadata
existing.OriginalTitle = metadata.OriginalTitle;
existing.ReleaseDate = metadata.ReleaseDate;
existing.Year = metadata.Year;
existing.SortTitle = metadata.SortTitle ?? _fallbackMetadataProvider.GetSortTitle(metadata.Title);
existing.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
: metadata.SortTitle;
},
() =>
{
metadata.SortTitle ??= _fallbackMetadataProvider.GetSortTitle(metadata.Title);
metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
: metadata.SortTitle;
episode.EpisodeMetadata = new List<EpisodeMetadata> { metadata };
});
@@ -126,11 +131,39 @@ namespace ErsatzTV.Core.Metadata
existing.OriginalTitle = metadata.OriginalTitle;
existing.ReleaseDate = metadata.ReleaseDate;
existing.Year = metadata.Year;
existing.SortTitle = metadata.SortTitle ?? _fallbackMetadataProvider.GetSortTitle(metadata.Title);
existing.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
: metadata.SortTitle;
foreach (Genre genre in existing.Genres.Filter(g => metadata.Genres.All(g2 => g2.Name != g.Name))
.ToList())
{
existing.Genres.Remove(genre);
}
foreach (Genre genre in metadata.Genres.Filter(g => existing.Genres.All(g2 => g2.Name != g.Name))
.ToList())
{
existing.Genres.Add(genre);
}
foreach (Tag tag in existing.Tags.Filter(t => metadata.Tags.All(t2 => t2.Name != t.Name))
.ToList())
{
existing.Tags.Remove(tag);
}
foreach (Tag tag in metadata.Tags.Filter(t => existing.Tags.All(t2 => t2.Name != t.Name))
.ToList())
{
existing.Tags.Add(tag);
}
},
() =>
{
metadata.SortTitle ??= _fallbackMetadataProvider.GetSortTitle(metadata.Title);
metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
: metadata.SortTitle;
movie.MovieMetadata = new List<MovieMetadata> { metadata };
});
@@ -152,11 +185,39 @@ namespace ErsatzTV.Core.Metadata
existing.OriginalTitle = metadata.OriginalTitle;
existing.ReleaseDate = metadata.ReleaseDate;
existing.Year = metadata.Year;
existing.SortTitle = metadata.SortTitle ?? _fallbackMetadataProvider.GetSortTitle(metadata.Title);
existing.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
: metadata.SortTitle;
foreach (Genre genre in existing.Genres.Filter(g => metadata.Genres.All(g2 => g2.Name != g.Name))
.ToList())
{
existing.Genres.Remove(genre);
}
foreach (Genre genre in metadata.Genres.Filter(g => existing.Genres.All(g2 => g2.Name != g.Name))
.ToList())
{
existing.Genres.Add(genre);
}
foreach (Tag tag in existing.Tags.Filter(t => metadata.Tags.All(t2 => t2.Name != t.Name))
.ToList())
{
existing.Tags.Remove(tag);
}
foreach (Tag tag in metadata.Tags.Filter(t => existing.Tags.All(t2 => t2.Name != t.Name))
.ToList())
{
existing.Tags.Add(tag);
}
},
() =>
{
metadata.SortTitle ??= _fallbackMetadataProvider.GetSortTitle(metadata.Title);
metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
: metadata.SortTitle;
show.ShowMetadata = new List<ShowMetadata> { metadata };
});
@@ -212,7 +273,9 @@ namespace ErsatzTV.Core.Metadata
Outline = nfo.Outline,
Tagline = nfo.Tagline,
Year = nfo.Year,
ReleaseDate = GetAired(nfo.Premiered) ?? new DateTime(nfo.Year, 1, 1)
ReleaseDate = GetAired(nfo.Premiered) ?? new DateTime(nfo.Year, 1, 1),
Genres = nfo.Genres.Map(g => new Genre { Name = g }).ToList(),
Tags = nfo.Tags.Map(t => new Tag { Name = t }).ToList()
},
None);
}
@@ -267,7 +330,9 @@ namespace ErsatzTV.Core.Metadata
ReleaseDate = nfo.Premiered,
Plot = nfo.Plot,
Outline = nfo.Outline,
Tagline = nfo.Tagline
Tagline = nfo.Tagline,
Genres = nfo.Genres.Map(g => new Genre { Name = g }).ToList(),
Tags = nfo.Tags.Map(t => new Tag { Name = t }).ToList()
},
None);
}
@@ -316,6 +381,12 @@ namespace ErsatzTV.Core.Metadata
[XmlElement("tagline")]
public string Tagline { get; set; }
[XmlElement("genre")]
public List<string> Genres { get; set; }
[XmlElement("tag")]
public List<string> Tags { get; set; }
}
[XmlRoot("tvshow")]
@@ -338,6 +409,12 @@ namespace ErsatzTV.Core.Metadata
[XmlElement("premiered")]
public string Premiered { get; set; }
[XmlElement("genre")]
public List<string> Genres { get; set; }
[XmlElement("tag")]
public List<string> Tags { get; set; }
}
[XmlRoot("episodedetails")]
@@ -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,
+14 -6
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(movie => UpdateArtwork(movie, ArtworkKind.Poster))
.BindT(movie => UpdateArtwork(movie, ArtworkKind.FanArt));
maybeMovie.IfLeft(
error => _logger.LogWarning("Error processing movie at {Path}: {Error}", file, error.Value));
@@ -135,15 +136,15 @@ namespace ErsatzTV.Core.Metadata
}
}
private async Task<Either<BaseError, Movie>> UpdatePoster(Movie movie)
private async Task<Either<BaseError, Movie>> UpdateArtwork(Movie movie, ArtworkKind artworkKind)
{
try
{
await LocatePoster(movie).IfSomeAsync(
await LocateArtwork(movie, artworkKind).IfSomeAsync(
async posterFile =>
{
MovieMetadata metadata = movie.MovieMetadata.Head();
if (RefreshArtwork(posterFile, metadata, ArtworkKind.Poster))
if (RefreshArtwork(posterFile, metadata, artworkKind))
{
await _movieRepository.Update(movie);
}
@@ -167,12 +168,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;
@@ -56,7 +56,8 @@ namespace ErsatzTV.Core.Metadata
Either<BaseError, Show> maybeShow =
await FindOrCreateShow(libraryPath.Id, showFolder)
.BindT(show => UpdateMetadataForShow(show, showFolder))
.BindT(show => UpdatePosterForShow(show, showFolder));
.BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.Poster))
.BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.FanArt));
await maybeShow.Match(
show => ScanSeasons(libraryPath, ffprobePath, show, showFolder),
@@ -212,17 +213,18 @@ namespace ErsatzTV.Core.Metadata
}
}
private async Task<Either<BaseError, Show>> UpdatePosterForShow(
private async Task<Either<BaseError, Show>> UpdateArtworkForShow(
Show show,
string showFolder)
string showFolder,
ArtworkKind artworkKind)
{
try
{
await LocatePosterForShow(showFolder).IfSomeAsync(
await LocateArtworkForShow(showFolder, artworkKind).IfSomeAsync(
async posterFile =>
{
ShowMetadata metadata = show.ShowMetadata.Head();
if (RefreshArtwork(posterFile, metadata, ArtworkKind.Poster))
if (RefreshArtwork(posterFile, metadata, artworkKind))
{
await _televisionRepository.Update(show);
}
@@ -298,12 +300,21 @@ namespace ErsatzTV.Core.Metadata
.Filter(s => _localFileSystem.FileExists(s));
}
private Option<string> LocatePosterForShow(string showFolder) =>
ImageFileExtensions
.Map(ext => $"poster.{ext}")
private Option<string> LocateArtworkForShow(string showFolder, ArtworkKind artworkKind)
{
string segment = artworkKind switch
{
ArtworkKind.Poster => "poster",
ArtworkKind.FanArt => "fanart",
_ => throw new ArgumentOutOfRangeException(nameof(artworkKind))
};
return ImageFileExtensions
.Map(ext => $"{segment}.{ext}")
.Map(f => Path.Combine(showFolder, f))
.Filter(s => _localFileSystem.FileExists(s))
.HeadOrNone();
}
private Option<string> LocatePoster(Season season, string seasonFolder)
{
+5 -4
View File
@@ -106,7 +106,7 @@ namespace ErsatzTV.Core.Scheduling
PlayoutAnchor startAnchor = FindStartAnchor(playout, playoutStart, sortedScheduleItems);
// start at the previously-decided time
DateTimeOffset currentTime = startAnchor.NextStart;
DateTimeOffset currentTime = startAnchor.NextStartOffset.ToLocalTime();
_logger.LogDebug(
"Starting playout {PlayoutId} for channel {ChannelNumber} - {ChannelName} at {StartTime}",
playout.Id,
@@ -277,7 +277,7 @@ namespace ErsatzTV.Core.Scheduling
playout.ProgramScheduleAnchors = BuildProgramScheduleAnchors(playout, collectionEnumerators);
// remove any items outside the desired range
playout.Items.RemoveAll(old => old.Finish < playoutStart || old.Start > playoutFinish);
playout.Items.RemoveAll(old => old.FinishOffset < playoutStart || old.StartOffset > playoutFinish);
return playout;
}
@@ -297,7 +297,8 @@ namespace ErsatzTV.Core.Scheduling
{
NextScheduleItem = schedule,
NextScheduleItemId = schedule.Id,
NextStart = start.Date + schedule.StartTime.GetValueOrDefault()
NextStart = (start - start.TimeOfDay).UtcDateTime +
schedule.StartTime.GetValueOrDefault()
};
case StartType.Dynamic:
default:
@@ -305,7 +306,7 @@ namespace ErsatzTV.Core.Scheduling
{
NextScheduleItem = schedule,
NextScheduleItemId = schedule.Id,
NextStart = start.Date
NextStart = (start - start.TimeOfDay).UtcDateTime
};
}
});
@@ -13,6 +13,14 @@ namespace ErsatzTV.Infrastructure.Data.Configurations
builder.HasMany(mm => mm.Artwork)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(mm => mm.Genres)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(mm => mm.Tags)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
}
}
}
@@ -13,6 +13,14 @@ namespace ErsatzTV.Infrastructure.Data.Configurations
builder.HasMany(sm => sm.Artwork)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(mm => mm.Genres)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(mm => mm.Tags)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
}
}
}
@@ -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();
@@ -62,6 +62,34 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
return modified;
}
public async Task<bool> AddMediaItems(int collectionId, List<int> mediaItemIds)
{
var modified = false;
Option<Collection> maybeCollection = await _dbContext.Collections
.Include(c => c.MediaItems)
.OrderBy(c => c.Id)
.SingleOrDefaultAsync(c => c.Id == collectionId)
.Map(Optional);
await maybeCollection.IfSomeAsync(
async collection =>
{
var toAdd = mediaItemIds.Filter(i => collection.MediaItems.All(i2 => i2.Id != i)).ToList();
if (toAdd.Any())
{
List<MediaItem> items = await _dbContext.MediaItems
.Filter(mi => toAdd.Contains(mi.Id))
.ToListAsync();
collection.MediaItems.AddRange(items);
modified = await _dbContext.SaveChangesAsync() > 0;
}
});
return modified;
}
public Task<Option<Collection>> Get(int id) =>
_dbContext.Collections
.OrderBy(c => c.Id)
@@ -29,12 +29,22 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
_dbConnection = dbConnection;
}
public Task<bool> AllMoviesExist(List<int> movieIds) =>
_dbConnection.QuerySingleAsync<int>(
"SELECT COUNT(*) FROM Movie WHERE Id in @MovieIds",
new { MovieIds = movieIds })
.Map(c => c == movieIds.Count);
public async Task<Option<Movie>> GetMovie(int movieId)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.Movies
.Include(m => m.MovieMetadata)
.ThenInclude(m => m.Artwork)
.Include(m => m.MovieMetadata)
.ThenInclude(m => m.Genres)
.Include(m => m.MovieMetadata)
.ThenInclude(m => m.Tags)
.OrderBy(m => m.Id)
.SingleOrDefaultAsync(m => m.Id == movieId)
.Map(Optional);
@@ -45,6 +55,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
Option<Movie> maybeExisting = await _dbContext.Movies
.Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Genres)
.Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Tags)
.Include(i => i.LibraryPath)
.Include(i => i.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
@@ -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
@@ -21,7 +21,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
_dbConnection = dbConnection;
}
public async Task<List<MediaItem>> SearchMediaItems(string query)
public async Task<List<MediaItem>> SearchMediaItemsByTitle(string query)
{
List<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT M.Id FROM Movie M
@@ -30,7 +30,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
UNION
SELECT S.Id FROM Show S
INNER JOIN ShowMetadata SM on S.Id = SM.ShowId
WHERE SM.Title LIKE @Query",
WHERE SM.Title LIKE @Query
GROUP BY SM.Title, SM.Year",
new { Query = $"%{query}%" })
.Map(results => results.ToList());
@@ -44,5 +45,59 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.OfType<MediaItem>()
.ToListAsync();
}
public async Task<List<MediaItem>> SearchMediaItemsByGenre(string genre)
{
List<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT M.Id FROM Movie M
INNER JOIN MovieMetadata MM on M.Id = MM.MovieId
INNER JOIN Genre G on MM.Id = G.MovieMetadataId
WHERE G.Name LIKE @Query
UNION
SELECT S.Id FROM Show S
INNER JOIN ShowMetadata SM on S.Id = SM.ShowId
INNER JOIN Genre G2 on SM.Id = G2.ShowMetadataId
WHERE G2.Name LIKE @Query
GROUP BY SM.Title, SM.Year",
new { Query = genre })
.Map(results => results.ToList());
await using TvContext context = _dbContextFactory.CreateDbContext();
return await context.MediaItems
.Filter(m => ids.Contains(m.Id))
.Include(m => (m as Movie).MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(m => (m as Show).ShowMetadata)
.ThenInclude(mm => mm.Artwork)
.OfType<MediaItem>()
.ToListAsync();
}
public async Task<List<MediaItem>> SearchMediaItemsByTag(string tag)
{
List<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT M.Id FROM Movie M
INNER JOIN MovieMetadata MM on M.Id = MM.MovieId
INNER JOIN Tag T on MM.Id = T.MovieMetadataId
WHERE T.Name LIKE @Query
UNION
SELECT S.Id FROM Show S
INNER JOIN ShowMetadata SM on S.Id = SM.ShowId
INNER JOIN Tag T2 on SM.Id = T2.ShowMetadataId
WHERE T2.Name LIKE @Query
GROUP BY SM.Title, SM.Year",
new { Query = tag })
.Map(results => results.ToList());
await using TvContext context = _dbContextFactory.CreateDbContext();
return await context.MediaItems
.Filter(m => ids.Contains(m.Id))
.Include(m => (m as Movie).MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(m => (m as Show).ShowMetadata)
.ThenInclude(mm => mm.Artwork)
.OfType<MediaItem>()
.ToListAsync();
}
}
}
@@ -24,6 +24,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
_dbConnection = dbConnection;
}
public Task<bool> AllShowsExist(List<int> showIds) =>
_dbConnection.QuerySingleAsync<int>(
"SELECT COUNT(*) FROM Show WHERE Id in @ShowIds",
new { ShowIds = showIds })
.Map(c => c == showIds.Count);
public async Task<bool> Update(Show show)
{
_dbContext.Shows.Update(show);
@@ -55,6 +61,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Filter(s => s.Id == showId)
.Include(s => s.ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(s => s.ShowMetadata)
.ThenInclude(sm => sm.Genres)
.Include(s => s.ShowMetadata)
.ThenInclude(sm => sm.Tags)
.OrderBy(s => s.Id)
.SingleOrDefaultAsync()
.Map(Optional);
@@ -94,6 +104,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(sm => sm.Artwork)
.Include(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.OrderBy(s => s.Id)
.SingleOrDefaultAsync(s => s.Id == seasonId)
.Map(Optional);
@@ -170,6 +181,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
return _dbContext.Shows
.Include(s => s.ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(s => s.ShowMetadata)
.ThenInclude(sm => sm.Genres)
.Include(s => s.ShowMetadata)
.ThenInclude(sm => sm.Tags)
.OrderBy(s => s.Id)
.SingleOrDefaultAsync(s => s.Id == id)
.Map(Optional);
@@ -182,6 +197,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
try
{
metadata.DateAdded = DateTime.UtcNow;
metadata.Genres ??= new List<Genre>();
metadata.Tags ??= new List<Tag>();
var show = new Show
{
LibraryPathId = libraryPathId,
@@ -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);
}
}
@@ -0,0 +1,19 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Reset_MetadataDateUpdated_SortTitle : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"UPDATE MovieMetadata SET DateUpdated = '0001-01-01 00:00:00'");
migrationBuilder.Sql(@"UPDATE ShowMetadata SET DateUpdated = '0001-01-01 00:00:00'");
migrationBuilder.Sql(@"UPDATE SeasonMetadata SET DateUpdated = '0001-01-01 00:00:00'");
migrationBuilder.Sql(@"UPDATE EpisodeMetadata 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,75 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_MetadataGenres : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
"Genre",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>("TEXT", nullable: true),
EpisodeMetadataId = table.Column<int>("INTEGER", nullable: true),
MovieMetadataId = table.Column<int>("INTEGER", nullable: true),
SeasonMetadataId = table.Column<int>("INTEGER", nullable: true),
ShowMetadataId = table.Column<int>("INTEGER", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Genre", x => x.Id);
table.ForeignKey(
"FK_Genre_EpisodeMetadata_EpisodeMetadataId",
x => x.EpisodeMetadataId,
"EpisodeMetadata",
"Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
"FK_Genre_MovieMetadata_MovieMetadataId",
x => x.MovieMetadataId,
"MovieMetadata",
"Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
"FK_Genre_SeasonMetadata_SeasonMetadataId",
x => x.SeasonMetadataId,
"SeasonMetadata",
"Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
"FK_Genre_ShowMetadata_ShowMetadataId",
x => x.ShowMetadataId,
"ShowMetadata",
"Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
"IX_Genre_EpisodeMetadataId",
"Genre",
"EpisodeMetadataId");
migrationBuilder.CreateIndex(
"IX_Genre_MovieMetadataId",
"Genre",
"MovieMetadataId");
migrationBuilder.CreateIndex(
"IX_Genre_SeasonMetadataId",
"Genre",
"SeasonMetadataId");
migrationBuilder.CreateIndex(
"IX_Genre_ShowMetadataId",
"Genre",
"ShowMetadataId");
}
protected override void Down(MigrationBuilder migrationBuilder) =>
migrationBuilder.DropTable(
"Genre");
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,18 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Reset_MetadataDateUpdated_Genres : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"UPDATE MovieMetadata SET DateUpdated = '0001-01-01 00:00:00'");
migrationBuilder.Sql(@"UPDATE ShowMetadata SET DateUpdated = '0001-01-01 00:00:00'");
migrationBuilder.Sql(@"UPDATE Library SET LastScan = '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,18 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class RebuildAllPlayouts_TimeZonesAgain : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"DELETE FROM PlayoutItem");
migrationBuilder.Sql(@"DELETE FROM PlayoutProgramScheduleAnchor");
migrationBuilder.Sql(@"UPDATE Playout SET Anchor_NextStart = null, Anchor_NextScheduleItemId = null");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,75 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_MetadataTags : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
"Tag",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>("TEXT", nullable: true),
EpisodeMetadataId = table.Column<int>("INTEGER", nullable: true),
MovieMetadataId = table.Column<int>("INTEGER", nullable: true),
SeasonMetadataId = table.Column<int>("INTEGER", nullable: true),
ShowMetadataId = table.Column<int>("INTEGER", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Tag", x => x.Id);
table.ForeignKey(
"FK_Tag_EpisodeMetadata_EpisodeMetadataId",
x => x.EpisodeMetadataId,
"EpisodeMetadata",
"Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
"FK_Tag_MovieMetadata_MovieMetadataId",
x => x.MovieMetadataId,
"MovieMetadata",
"Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
"FK_Tag_SeasonMetadata_SeasonMetadataId",
x => x.SeasonMetadataId,
"SeasonMetadata",
"Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
"FK_Tag_ShowMetadata_ShowMetadataId",
x => x.ShowMetadataId,
"ShowMetadata",
"Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
"IX_Tag_EpisodeMetadataId",
"Tag",
"EpisodeMetadataId");
migrationBuilder.CreateIndex(
"IX_Tag_MovieMetadataId",
"Tag",
"MovieMetadataId");
migrationBuilder.CreateIndex(
"IX_Tag_SeasonMetadataId",
"Tag",
"SeasonMetadataId");
migrationBuilder.CreateIndex(
"IX_Tag_ShowMetadataId",
"Tag",
"ShowMetadataId");
}
protected override void Down(MigrationBuilder migrationBuilder) =>
migrationBuilder.DropTable(
"Tag");
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,18 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Reset_MetadataDateUpdated_Tags : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"UPDATE MovieMetadata SET DateUpdated = '0001-01-01 00:00:00'");
migrationBuilder.Sql(@"UPDATE ShowMetadata SET DateUpdated = '0001-01-01 00:00:00'");
migrationBuilder.Sql(@"UPDATE Library SET LastScan = '0001-01-01 00:00:00'");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
@@ -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");
@@ -274,6 +274,42 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("FFmpegProfile");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Genre",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("EpisodeMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("MovieMetadataId")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int?>("SeasonMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("ShowMetadataId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("EpisodeMetadataId");
b.HasIndex("MovieMetadataId");
b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId");
b.ToTable("Genre");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Library",
b =>
@@ -418,6 +454,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<string>("VideoCodec")
.HasColumnType("TEXT");
b.Property<string>("VideoProfile")
.HasColumnType("TEXT");
b.Property<int>("VideoScanKind")
.HasColumnType("INTEGER");
@@ -800,6 +839,42 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("ShowMetadata");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Tag",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("EpisodeMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("MovieMetadataId")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int?>("SeasonMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("ShowMetadataId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("EpisodeMetadataId");
b.HasIndex("MovieMetadataId");
b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId");
b.ToTable("Tag");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.LocalLibrary",
b =>
@@ -1065,6 +1140,29 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Resolution");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Genre",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.EpisodeMetadata", null)
.WithMany("Genres")
.HasForeignKey("EpisodeMetadataId");
b.HasOne("ErsatzTV.Core.Domain.MovieMetadata", null)
.WithMany("Genres")
.HasForeignKey("MovieMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Genres")
.HasForeignKey("SeasonMetadataId");
b.HasOne("ErsatzTV.Core.Domain.ShowMetadata", null)
.WithMany("Genres")
.HasForeignKey("ShowMetadataId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Library",
b =>
@@ -1359,6 +1457,29 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Show");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Tag",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.EpisodeMetadata", null)
.WithMany("Tags")
.HasForeignKey("EpisodeMetadataId");
b.HasOne("ErsatzTV.Core.Domain.MovieMetadata", null)
.WithMany("Tags")
.HasForeignKey("MovieMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Tags")
.HasForeignKey("SeasonMetadataId");
b.HasOne("ErsatzTV.Core.Domain.ShowMetadata", null)
.WithMany("Tags")
.HasForeignKey("ShowMetadataId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.LocalLibrary",
b =>
@@ -1540,7 +1661,16 @@ namespace ErsatzTV.Infrastructure.Migrations
modelBuilder.Entity("ErsatzTV.Core.Domain.Collection", b => { b.Navigation("CollectionItems"); });
modelBuilder.Entity("ErsatzTV.Core.Domain.EpisodeMetadata", b => { b.Navigation("Artwork"); });
modelBuilder.Entity(
"ErsatzTV.Core.Domain.EpisodeMetadata",
b =>
{
b.Navigation("Artwork");
b.Navigation("Genres");
b.Navigation("Tags");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Library", b => { b.Navigation("Paths"); });
@@ -1552,7 +1682,16 @@ namespace ErsatzTV.Infrastructure.Migrations
modelBuilder.Entity("ErsatzTV.Core.Domain.MediaVersion", b => { b.Navigation("MediaFiles"); });
modelBuilder.Entity("ErsatzTV.Core.Domain.MovieMetadata", b => { b.Navigation("Artwork"); });
modelBuilder.Entity(
"ErsatzTV.Core.Domain.MovieMetadata",
b =>
{
b.Navigation("Artwork");
b.Navigation("Genres");
b.Navigation("Tags");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Playout",
@@ -1572,9 +1711,27 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Playouts");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.SeasonMetadata", b => { b.Navigation("Artwork"); });
modelBuilder.Entity(
"ErsatzTV.Core.Domain.SeasonMetadata",
b =>
{
b.Navigation("Artwork");
modelBuilder.Entity("ErsatzTV.Core.Domain.ShowMetadata", b => { b.Navigation("Artwork"); });
b.Navigation("Genres");
b.Navigation("Tags");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.ShowMetadata",
b =>
{
b.Navigation("Artwork");
b.Navigation("Genres");
b.Navigation("Tags");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Episode",
@@ -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>
@@ -0,0 +1,22 @@
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
namespace ErsatzTV.Extensions
{
public static class NavigationManagerExtensions
{
public static ValueTask NavigateToFragmentAsync(this NavigationManager navigationManager, IJSRuntime jSRuntime)
{
Uri uri = navigationManager.ToAbsoluteUri(navigationManager.Uri);
if (uri.Fragment.Length == 0)
{
return default;
}
return jSRuntime.InvokeVoidAsync("blazorHelpers.scrollToFragment", uri.Fragment.Substring(1));
}
}
}
+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 {
+162 -74
View File
@@ -2,82 +2,159 @@
@using ErsatzTV.Application.MediaCards
@using ErsatzTV.Application.MediaCards.Queries
@using ErsatzTV.Application.MediaCollections.Commands
@inherits MultiSelectBase<CollectionItems>
@inject NavigationManager NavigationManager
@inject IMediator Mediator
@inject ILogger<CollectionItems> Logger
@inject ISnackbar Snackbar
@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>
@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)
<MudPaper Square="true" Style="display: flex; height: 64px; left: 240px; padding: 0; position: fixed; right: 0; z-index: 100;">
<div style="align-items: center; display: flex; flex-direction: row; margin-bottom: auto; margin-top: auto; width: 100%;" class="ml-6 mr-6">
@if (IsSelectMode())
{
<MediaCard Data="@card"
Link="@($"/media/movies/{card.MovieId}")"
DeleteClicked="@RemoveMovieFromCollection"/>
<MudText Typo="Typo.h6" Color="Color.Primary">@SelectionLabel()</MudText>
<div style="margin-left: auto">
<MudButton Variant="Variant.Filled"
Color="Color.Error"
StartIcon="@Icons.Material.Filled.Remove"
OnClick="@(_ => RemoveSelectionFromCollection(Id))">
Remove From Collection
</MudButton>
<MudButton Class="ml-3"
Variant="Variant.Filled"
Color="Color.Secondary"
StartIcon="@Icons.Material.Filled.Check"
OnClick="@(_ => ClearSelection())">
Clear Selection
</MudButton>
</div>
}
</MudContainer>
}
@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)
else
{
<MediaCard Data="@card"
Link="@($"/media/tv/shows/{card.TelevisionShowId}")"
DeleteClicked="@RemoveShowFromCollection"/>
<div style="display: flex; flex-direction: row;">
<MudText Typo="Typo.h4">@_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())
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#movies")">@_data.MovieCards.Count Movies</MudLink>
}
@if (_data.ShowCards.Any())
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#shows")">@_data.ShowCards.Count Shows</MudLink>
}
@if (_data.SeasonCards.Any())
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#seasons")">@_data.SeasonCards.Count Seasons</MudLink>
}
@if (_data.EpisodeCards.Any())
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#episodes")">@_data.EpisodeCards.Count Episodes</MudLink>
}
}
</MudContainer>
}
</div>
</MudPaper>
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8" Style="margin-top: 64px">
@if (_data.SeasonCards.Any())
{
<MudText GutterBottom="true" Typo="Typo.h4">Television Seasons</MudText>
@if (_data.MovieCards.Any())
{
<MudText GutterBottom="true"
Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "movies" } })">
Movies
</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 (MovieCardViewModel card in _data.MovieCards.OrderBy(m => m.SortTitle))
{
<MediaCard Data="@card"
Link="@($"/media/movies/{card.MovieId}")"
DeleteClicked="@RemoveMovieFromCollection"
SelectColor="@Color.Error"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudContainer>
}
@if (_data.EpisodeCards.Any())
{
<MudText GutterBottom="true" Typo="Typo.h4">Television Episodes</MudText>
@if (_data.ShowCards.Any())
{
<MudText GutterBottom="true"
Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "shows" } })">
Television Shows
</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 (TelevisionShowCardViewModel card in _data.ShowCards.OrderBy(m => m.SortTitle))
{
<MediaCard Data="@card"
Link="@($"/media/tv/shows/{card.TelevisionShowId}")"
DeleteClicked="@RemoveShowFromCollection"
SelectColor="@Color.Error"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudContainer>
}
@if (_data.SeasonCards.Any())
{
<MudText GutterBottom="true"
Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "seasons" } })">
Television Seasons
</MudText>
<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"
SelectColor="@Color.Error"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudContainer>
}
@if (_data.EpisodeCards.Any())
{
<MudText GutterBottom="true"
Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "episodes" } })">
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/seasons/{card.SeasonId}#episode-{card.EpisodeId}")"
Title="@card.ShowTitle"
Subtitle="@card.Title"
ContainerClass="media-card-episode-container mx-2"
CardClass="media-card-episode"
DeleteClicked="@(_ => RemoveEpisodeFromCollection(card))"
ArtworkKind="@ArtworkKind.Thumbnail"
SelectColor="@Color.Error"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudContainer>
}
</MudContainer>
@code {
@@ -88,7 +165,7 @@
protected override async Task OnParametersSetAsync() => await RefreshData();
private async Task RefreshData()
protected override async Task RefreshData()
{
Either<BaseError, CollectionCardResultsViewModel> maybeResult =
await Mediator.Send(new GetCollectionCards(Id));
@@ -98,6 +175,20 @@
error => NavigationManager.NavigateTo("404"));
}
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
{
List<MediaCardViewModel> GetSortedItems()
{
return _data.MovieCards.OrderBy(m => m.SortTitle)
.Append<MediaCardViewModel>(_data.ShowCards.OrderBy(s => s.SortTitle))
.Append(_data.SeasonCards.OrderBy(s => s.SortTitle))
.Append(_data.EpisodeCards.OrderBy(ep => ep.Aired))
.ToList();
}
SelectClicked(GetSortedItems, card, e);
}
private async Task RemoveMovieFromCollection(MediaCardViewModel vm)
{
if (vm is MovieCardViewModel movie)
@@ -137,17 +228,14 @@
}
}
private async Task RemoveEpisodeFromCollection(MediaCardViewModel vm)
private async Task RemoveEpisodeFromCollection(TelevisionEpisodeCardViewModel episode)
{
if (vm is TelevisionEpisodeCardViewModel episode)
var request = new RemoveItemsFromCollection(Id)
{
var request = new RemoveItemsFromCollection(Id)
{
MediaItemIds = new List<int> { episode.EpisodeId }
};
MediaItemIds = new List<int> { episode.EpisodeId }
};
await RemoveItemsWithConfirmation("episode", $"{episode.ShowTitle} - {episode.Title}", request);
}
await RemoveItemsWithConfirmation("episode", $"{episode.ShowTitle} - {episode.Title}", request);
}
private async Task RemoveItemsWithConfirmation(
+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 {
+33
View File
@@ -0,0 +1,33 @@
using System;
using System.Threading.Tasks;
using ErsatzTV.Extensions;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Routing;
using Microsoft.JSInterop;
namespace ErsatzTV.Pages
{
public class FragmentNavigationBase : ComponentBase, IDisposable
{
[Inject]
private NavigationManager NavManager { get; set; }
[Inject]
private IJSRuntime JsRuntime { get; set; }
public void Dispose() => NavManager.LocationChanged -= TryFragmentNavigation;
protected override void OnInitialized() => NavManager.LocationChanged += TryFragmentNavigation;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await NavManager.NavigateToFragmentAsync(JsRuntime);
}
}
private async void TryFragmentNavigation(object sender, LocationChangedEventArgs args) =>
await NavManager.NavigateToFragmentAsync(JsRuntime);
}
}
+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>

Some files were not shown because too many files have changed in this diff Show More