Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a13f964200 | ||
|
|
0da9701f9c | ||
|
|
b3f4c22f49 | ||
|
|
50fafbfb98 | ||
|
|
914d128610 | ||
|
|
1a2f36f561 | ||
|
|
96887fbd79 | ||
|
|
c07e2afff4 | ||
|
|
4953617f79 | ||
|
|
1587ac7d62 | ||
|
|
c240169fc9 | ||
|
|
76d6725dd5 | ||
|
|
c016cac8d4 | ||
|
|
e624627ae1 | ||
|
|
46bcf03d9a | ||
|
|
ab9a8493d9 | ||
|
|
b1ecbafb6e | ||
|
|
e3b91e62ae | ||
|
|
54da3a3159 | ||
|
|
d53a2f8bbf | ||
|
|
c2cbb1d5ff | ||
|
|
bd231d57a7 | ||
|
|
77cb2c2270 | ||
|
|
5244d5076a | ||
|
|
9841640128 | ||
|
|
a256095e12 | ||
|
|
ed592bd0a0 |
@@ -80,7 +80,14 @@ jobs:
|
||||
jasongdove/ersatztv:develop
|
||||
jasongdove/ersatztv:${{ github.sha }}
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache,mode=max
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache-new
|
||||
- # Temporary fix
|
||||
# https://github.com/docker/build-push-action/issues/252
|
||||
# https://github.com/moby/buildkit/issues/1896
|
||||
name: Move cache
|
||||
run: |
|
||||
rm -rf /tmp/.buildx-cache
|
||||
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
|
||||
|
||||
- name: Build and push nvidia
|
||||
uses: docker/build-push-action@v2
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using MediatR;
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.FFmpegProfiles.Commands
|
||||
{
|
||||
public record UpdateFFmpegSettings(FFmpegSettingsViewModel Settings) : IRequest;
|
||||
public record UpdateFFmpegSettings(FFmpegSettingsViewModel Settings) : MediatR.IRequest<Either<BaseError, Unit>>;
|
||||
}
|
||||
|
||||
@@ -1,21 +1,74 @@
|
||||
using System.Threading;
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Unit = MediatR.Unit;
|
||||
|
||||
namespace ErsatzTV.Application.FFmpegProfiles.Commands
|
||||
{
|
||||
public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings>
|
||||
public class UpdateFFmpegSettingsHandler : MediatR.IRequestHandler<UpdateFFmpegSettings, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
|
||||
public UpdateFFmpegSettingsHandler(IConfigElementRepository configElementRepository) =>
|
||||
public UpdateFFmpegSettingsHandler(
|
||||
IConfigElementRepository configElementRepository,
|
||||
ILocalFileSystem localFileSystem)
|
||||
{
|
||||
_configElementRepository = configElementRepository;
|
||||
_localFileSystem = localFileSystem;
|
||||
}
|
||||
|
||||
public async Task<Unit> Handle(UpdateFFmpegSettings request, CancellationToken cancellationToken)
|
||||
public Task<Either<BaseError, Unit>> Handle(
|
||||
UpdateFFmpegSettings request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Validate(request)
|
||||
.MapT(_ => ApplyUpdate(request))
|
||||
.Bind(v => v.ToEitherAsync());
|
||||
|
||||
private async Task<Validation<BaseError, Unit>> Validate(UpdateFFmpegSettings request) =>
|
||||
(await FFmpegMustExist(request), await FFprobeMustExist(request))
|
||||
.Apply((_, _) => Unit.Default);
|
||||
|
||||
private Task<Validation<BaseError, Unit>> FFmpegMustExist(UpdateFFmpegSettings request) =>
|
||||
ValidateToolPath(request.Settings.FFmpegPath, "ffmpeg");
|
||||
|
||||
private Task<Validation<BaseError, Unit>> FFprobeMustExist(UpdateFFmpegSettings request) =>
|
||||
ValidateToolPath(request.Settings.FFprobePath, "ffprobe");
|
||||
|
||||
private async Task<Validation<BaseError, Unit>> ValidateToolPath(string path, string name)
|
||||
{
|
||||
if (!_localFileSystem.FileExists(path))
|
||||
{
|
||||
return BaseError.New($"{name} path does not exist");
|
||||
}
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = path,
|
||||
Arguments = "-version",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false
|
||||
};
|
||||
|
||||
var test = new Process
|
||||
{
|
||||
StartInfo = startInfo
|
||||
};
|
||||
|
||||
test.Start();
|
||||
string output = await test.StandardOutput.ReadToEndAsync();
|
||||
await test.WaitForExitAsync();
|
||||
return test.ExitCode == 0 && output.Contains($"{name} version")
|
||||
? Unit.Default
|
||||
: BaseError.New($"Unable to verify {name} version");
|
||||
}
|
||||
|
||||
private async Task<Unit> ApplyUpdate(UpdateFFmpegSettings request)
|
||||
{
|
||||
Option<ConfigElement> ffmpegPath = await _configElementRepository.Get(ConfigElementKey.FFmpegPath);
|
||||
Option<ConfigElement> ffprobePath = await _configElementRepository.Get(ConfigElementKey.FFprobePath);
|
||||
@@ -64,7 +117,7 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
|
||||
_configElementRepository.Add(ce);
|
||||
});
|
||||
|
||||
return Unit.Value;
|
||||
return Unit.Default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -7,5 +7,8 @@ namespace ErsatzTV.Application.MediaCards
|
||||
List<MovieCardViewModel> MovieCards,
|
||||
List<TelevisionShowCardViewModel> ShowCards,
|
||||
List<TelevisionSeasonCardViewModel> SeasonCards,
|
||||
List<TelevisionEpisodeCardViewModel> EpisodeCards);
|
||||
List<TelevisionEpisodeCardViewModel> EpisodeCards)
|
||||
{
|
||||
public bool UseCustomPlaybackOrder { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ namespace ErsatzTV.Application.MediaCards
|
||||
|
||||
internal static TelevisionSeasonCardViewModel ProjectToViewModel(Season season) =>
|
||||
new(
|
||||
season.Show.ShowMetadata.HeadOrNone().Map(m => m.Title).IfNone(string.Empty),
|
||||
season.Show.ShowMetadata.HeadOrNone().Match(m => m.Title ?? string.Empty, () => string.Empty),
|
||||
season.Id,
|
||||
season.SeasonNumber,
|
||||
GetSeasonName(season.SeasonNumber),
|
||||
@@ -32,12 +32,17 @@ namespace ErsatzTV.Application.MediaCards
|
||||
new(
|
||||
episodeMetadata.EpisodeId,
|
||||
episodeMetadata.ReleaseDate ?? DateTime.MinValue,
|
||||
episodeMetadata.Episode.Season.Show.ShowMetadata.HeadOrNone().Map(m => m.Title).IfNone(string.Empty),
|
||||
episodeMetadata.Episode.Season.Show.ShowMetadata.HeadOrNone().Match(
|
||||
m => m.Title ?? string.Empty,
|
||||
() => 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().Match(
|
||||
em => em.Plot ?? string.Empty,
|
||||
() => string.Empty),
|
||||
GetThumbnail(episodeMetadata));
|
||||
|
||||
internal static MovieCardViewModel ProjectToViewModel(MovieMetadata movieMetadata) =>
|
||||
new(
|
||||
@@ -51,11 +56,20 @@ namespace ErsatzTV.Application.MediaCards
|
||||
ProjectToViewModel(Collection collection) =>
|
||||
new(
|
||||
collection.Name,
|
||||
collection.MediaItems.OfType<Movie>().Map(m => ProjectToViewModel(m.MovieMetadata.Head())).ToList(),
|
||||
collection.MediaItems.OfType<Movie>().Map(
|
||||
m => ProjectToViewModel(m.MovieMetadata.Head()) with
|
||||
{
|
||||
CustomIndex = GetCustomIndex(collection, m.Id)
|
||||
}).ToList(),
|
||||
collection.MediaItems.OfType<Show>().Map(s => ProjectToViewModel(s.ShowMetadata.Head())).ToList(),
|
||||
collection.MediaItems.OfType<Season>().Map(ProjectToViewModel).ToList(),
|
||||
collection.MediaItems.OfType<Episode>().Map(e => ProjectToViewModel(e.EpisodeMetadata.Head()))
|
||||
.ToList());
|
||||
.ToList()) { UseCustomPlaybackOrder = collection.UseCustomPlaybackOrder };
|
||||
|
||||
private static int GetCustomIndex(Collection collection, int mediaItemId) =>
|
||||
Optional(collection.CollectionItems.Find(ci => ci.MediaItemId == mediaItemId))
|
||||
.Map(ci => ci.CustomIndex ?? 0)
|
||||
.IfNone(0);
|
||||
|
||||
internal static SearchCardResultsViewModel ProjectToSearchResults(List<MediaItem> items) =>
|
||||
new(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace ErsatzTV.Application.MediaCards
|
||||
{
|
||||
public record MediaCardViewModel(string Title, string Subtitle, string SortTitle, string Poster);
|
||||
public record MediaCardViewModel(int MediaItemId, string Title, string Subtitle, string SortTitle, string Poster);
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
{
|
||||
public record MovieCardViewModel
|
||||
(int MovieId, string Title, string Subtitle, string SortTitle, string Poster) : MediaCardViewModel(
|
||||
MovieId,
|
||||
Title,
|
||||
Subtitle,
|
||||
SortTitle,
|
||||
Poster)
|
||||
{
|
||||
public int CustomIndex { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,16 @@ 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(
|
||||
EpisodeId,
|
||||
Title,
|
||||
Subtitle,
|
||||
SortTitle,
|
||||
$"Episode {Episode}",
|
||||
$"Episode {Episode}",
|
||||
Poster)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
string SortTitle,
|
||||
string Poster,
|
||||
string Placeholder) : MediaCardViewModel(
|
||||
TelevisionSeasonId,
|
||||
Title,
|
||||
Subtitle,
|
||||
SortTitle,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
{
|
||||
public record TelevisionShowCardViewModel
|
||||
(int TelevisionShowId, string Title, string Subtitle, string SortTitle, string Poster) : MediaCardViewModel(
|
||||
TelevisionShowId,
|
||||
Title,
|
||||
Subtitle,
|
||||
SortTitle,
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
{
|
||||
public record UpdateCollection
|
||||
(int CollectionId, string Name) : MediatR.IRequest<Either<BaseError, Unit>>;
|
||||
(int CollectionId, string Name) : MediatR.IRequest<Either<BaseError, Unit>>
|
||||
{
|
||||
public Option<bool> UseCustomPlaybackOrder { get; set; } = None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.Collections.Generic;
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
{
|
||||
public record UpdateCollectionCustomOrder
|
||||
(
|
||||
int CollectionId,
|
||||
List<MediaItemCustomOrder> MediaItemCustomOrders) : MediatR.IRequest<Either<BaseError, Unit>>;
|
||||
|
||||
public record MediaItemCustomOrder(int MediaItemId, int CustomIndex);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
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.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
{
|
||||
public class
|
||||
UpdateCollectionCustomOrderHandler : MediatR.IRequestHandler<UpdateCollectionCustomOrder,
|
||||
Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
|
||||
private readonly IMediaCollectionRepository _mediaCollectionRepository;
|
||||
|
||||
public UpdateCollectionCustomOrderHandler(
|
||||
IMediaCollectionRepository mediaCollectionRepository,
|
||||
ChannelWriter<IBackgroundServiceRequest> channel)
|
||||
{
|
||||
_mediaCollectionRepository = mediaCollectionRepository;
|
||||
_channel = channel;
|
||||
}
|
||||
|
||||
public Task<Either<BaseError, Unit>> Handle(
|
||||
UpdateCollectionCustomOrder request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Validate(request)
|
||||
.MapT(c => ApplyUpdateRequest(c, request))
|
||||
.Bind(v => v.ToEitherAsync());
|
||||
|
||||
private async Task<Unit> ApplyUpdateRequest(Collection c, UpdateCollectionCustomOrder request)
|
||||
{
|
||||
foreach (MediaItemCustomOrder updateItem in request.MediaItemCustomOrders)
|
||||
{
|
||||
Option<CollectionItem> maybeCollectionItem =
|
||||
c.CollectionItems.FirstOrDefault(ci => ci.MediaItemId == updateItem.MediaItemId);
|
||||
|
||||
maybeCollectionItem.IfSome(ci => ci.CustomIndex = updateItem.CustomIndex);
|
||||
}
|
||||
|
||||
if (await _mediaCollectionRepository.Update(c))
|
||||
{
|
||||
// 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 Task<Validation<BaseError, Collection>> Validate(UpdateCollectionCustomOrder request) =>
|
||||
CollectionMustExist(request);
|
||||
|
||||
private Task<Validation<BaseError, Collection>> CollectionMustExist(
|
||||
UpdateCollectionCustomOrder request) =>
|
||||
_mediaCollectionRepository.Get(request.CollectionId)
|
||||
.Map(v => v.ToValidation<BaseError>("Collection does not exist."));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Application.Playouts.Commands;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
@@ -9,10 +11,16 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
{
|
||||
public class UpdateCollectionHandler : MediatR.IRequestHandler<UpdateCollection, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
|
||||
private readonly IMediaCollectionRepository _mediaCollectionRepository;
|
||||
|
||||
public UpdateCollectionHandler(IMediaCollectionRepository mediaCollectionRepository) =>
|
||||
public UpdateCollectionHandler(
|
||||
IMediaCollectionRepository mediaCollectionRepository,
|
||||
ChannelWriter<IBackgroundServiceRequest> channel)
|
||||
{
|
||||
_mediaCollectionRepository = mediaCollectionRepository;
|
||||
_channel = channel;
|
||||
}
|
||||
|
||||
public Task<Either<BaseError, Unit>> Handle(
|
||||
UpdateCollection request,
|
||||
@@ -21,10 +29,21 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
.MapT(c => ApplyUpdateRequest(c, request))
|
||||
.Bind(v => v.ToEitherAsync());
|
||||
|
||||
private async Task<Unit> ApplyUpdateRequest(Collection c, UpdateCollection update)
|
||||
private async Task<Unit> ApplyUpdateRequest(Collection c, UpdateCollection request)
|
||||
{
|
||||
c.Name = update.Name;
|
||||
await _mediaCollectionRepository.Update(c);
|
||||
c.Name = request.Name;
|
||||
request.UseCustomPlaybackOrder.IfSome(
|
||||
useCustomPlaybackOrder => c.UseCustomPlaybackOrder = useCustomPlaybackOrder);
|
||||
if (await _mediaCollectionRepository.Update(c) && request.UseCustomPlaybackOrder.IsSome)
|
||||
{
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace ErsatzTV.Application.MediaCollections
|
||||
{
|
||||
public record MediaCollectionViewModel(int Id, string Name) : MediaCardViewModel(
|
||||
Id,
|
||||
Name,
|
||||
string.Empty,
|
||||
Name,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
+6
-5
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -14,15 +14,22 @@ namespace ErsatzTV.Core.Tests.Fakes
|
||||
private readonly Map<int, List<MediaItem>> _data;
|
||||
|
||||
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();
|
||||
|
||||
public Task<Option<Collection>> GetCollectionWithCollectionItemsUntracked(int id) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<List<Collection>> GetAll() => throw new NotSupportedException();
|
||||
public Task<Option<List<MediaItem>>> GetItems(int id) => Some(_data[id].ToList()).AsTask();
|
||||
Task<bool> IMediaCollectionRepository.Update(Collection collection) => throw new NotSupportedException();
|
||||
public Task Delete(int collectionId) => throw new NotSupportedException();
|
||||
public Task<List<int>> PlayoutIdsUsingCollection(int collectionId) => throw new NotSupportedException();
|
||||
public Task<bool> IsCustomPlaybackOrder(int collectionId) => false.AsTask();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using FluentAssertions;
|
||||
using NUnit.Framework;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Scheduling
|
||||
{
|
||||
public class CustomOrderContentTests
|
||||
{
|
||||
[Test]
|
||||
public void MediaItems_Should_Sort_By_CustomOrder()
|
||||
{
|
||||
Collection collection = CreateCollection(10);
|
||||
List<MediaItem> contents = Episodes(10);
|
||||
var state = new CollectionEnumeratorState();
|
||||
|
||||
var customOrderContent = new CustomOrderCollectionEnumerator(collection, contents, state);
|
||||
|
||||
for (var i = 10; i >= 1; i--)
|
||||
{
|
||||
customOrderContent.Current.IsSome.Should().BeTrue();
|
||||
customOrderContent.Current.Map(x => x.Id).IfNone(-1).Should().Be(i);
|
||||
customOrderContent.MoveNext();
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void State_Index_Should_Increment()
|
||||
{
|
||||
Collection collection = CreateCollection(10);
|
||||
List<MediaItem> contents = Episodes(10);
|
||||
var state = new CollectionEnumeratorState();
|
||||
|
||||
var customOrderContent = new CustomOrderCollectionEnumerator(collection, contents, state);
|
||||
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
customOrderContent.State.Index.Should().Be(i % 10);
|
||||
customOrderContent.MoveNext();
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void State_Should_Impact_Iterator_Start()
|
||||
{
|
||||
Collection collection = CreateCollection(10);
|
||||
List<MediaItem> contents = Episodes(10);
|
||||
var state = new CollectionEnumeratorState { Index = 5 };
|
||||
|
||||
var customOrderContent = new CustomOrderCollectionEnumerator(collection, contents, state);
|
||||
|
||||
for (var i = 5; i >= 1; i--)
|
||||
{
|
||||
customOrderContent.Current.IsSome.Should().BeTrue();
|
||||
customOrderContent.Current.Map(x => x.Id).IfNone(-1).Should().Be(i);
|
||||
customOrderContent.State.Index.Should().Be(5 - i + 5); // 5 through 10
|
||||
customOrderContent.MoveNext();
|
||||
}
|
||||
}
|
||||
|
||||
private static Collection CreateCollection(int episodeCount)
|
||||
{
|
||||
var collection = new Collection { CollectionItems = new List<CollectionItem>() };
|
||||
|
||||
for (var i = 1; i <= episodeCount; i++)
|
||||
{
|
||||
collection.CollectionItems.Add(
|
||||
new CollectionItem
|
||||
{
|
||||
MediaItemId = i,
|
||||
// reverse order
|
||||
CustomIndex = episodeCount - i
|
||||
});
|
||||
}
|
||||
|
||||
return collection;
|
||||
}
|
||||
|
||||
|
||||
private static List<MediaItem> Episodes(int count) =>
|
||||
Range(1, count).Map(
|
||||
i => (MediaItem) new Episode
|
||||
{
|
||||
Id = i,
|
||||
EpisodeMetadata = new List<EpisodeMetadata>
|
||||
{
|
||||
new()
|
||||
{
|
||||
ReleaseDate = new DateTime(2020, 1, i)
|
||||
}
|
||||
}
|
||||
})
|
||||
.Reverse()
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,24 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
_logger = factory.CreateLogger<PlayoutBuilder>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Timeout(2000)]
|
||||
public async Task ZeroDurationItem_Should_Abort()
|
||||
{
|
||||
var mediaItems = new List<MediaItem>
|
||||
{
|
||||
TestMovie(1, TimeSpan.Zero, DateTime.Today)
|
||||
};
|
||||
|
||||
(PlayoutBuilder builder, Playout playout) = TestDataFloodForItems(mediaItems, PlaybackOrder.Random);
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
DateTimeOffset finish = start + TimeSpan.FromHours(6);
|
||||
|
||||
Playout result = await builder.BuildPlayoutItems(playout, start, finish);
|
||||
|
||||
result.Items.Should().BeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task InitialFlood_Should_StartAtMidnight()
|
||||
{
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
public bool UseCustomPlaybackOrder { get; set; }
|
||||
public List<MediaItem> MediaItems { get; set; }
|
||||
public List<CollectionItem> CollectionItems { get; set; }
|
||||
}
|
||||
|
||||
@@ -6,5 +6,6 @@
|
||||
public Collection Collection { get; set; }
|
||||
public int MediaItemId { get; set; }
|
||||
public MediaItem MediaItem { get; set; }
|
||||
public int? CustomIndex { 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; }
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
{
|
||||
Poster = 0,
|
||||
Thumbnail = 1,
|
||||
Logo = 2
|
||||
Logo = 2,
|
||||
FanArt = 3
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class Tag
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Metadata
|
||||
{
|
||||
public interface ILocalStatisticsProvider
|
||||
{
|
||||
Task<bool> RefreshStatistics(string ffprobePath, MediaItem mediaItem);
|
||||
Task<Either<BaseError, Unit>> RefreshStatistics(string ffprobePath, MediaItem mediaItem);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,13 +9,16 @@ 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);
|
||||
Task<Option<Collection>> GetCollectionWithCollectionItemsUntracked(int id);
|
||||
Task<List<Collection>> GetAll();
|
||||
Task<Option<List<MediaItem>>> GetItems(int id);
|
||||
Task<bool> Update(Collection collection);
|
||||
Task Delete(int collectionId);
|
||||
Task<List<int>> PlayoutIdsUsingCollection(int collectionId);
|
||||
Task<bool> IsCustomPlaybackOrder(int collectionId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,6 +4,8 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using System.Xml;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
using LanguageExt.UnsafeValueAccess;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Core.Iptv
|
||||
@@ -30,10 +32,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 +55,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))
|
||||
{
|
||||
@@ -64,11 +66,19 @@ namespace ErsatzTV.Core.Iptv
|
||||
{
|
||||
Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Title ?? string.Empty)
|
||||
.IfNone("[unknown movie]"),
|
||||
Episode e => e.EpisodeMetadata.HeadOrNone().Map(em => em.Title ?? string.Empty)
|
||||
.IfNone("[unknown episode]"),
|
||||
Episode e => e.Season.Show.ShowMetadata.HeadOrNone().Map(em => em.Title ?? string.Empty)
|
||||
.IfNone("[unknown show]"),
|
||||
_ => "[unknown]"
|
||||
};
|
||||
|
||||
string subtitle = playoutItem.MediaItem switch
|
||||
{
|
||||
Episode e => e.EpisodeMetadata.HeadOrNone().Match(
|
||||
em => em.Title ?? string.Empty,
|
||||
() => string.Empty),
|
||||
_ => string.Empty
|
||||
};
|
||||
|
||||
string description = playoutItem.MediaItem switch
|
||||
{
|
||||
Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Plot ?? string.Empty).IfNone(string.Empty),
|
||||
@@ -87,20 +97,59 @@ 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);
|
||||
|
||||
if (playoutItem.MediaItem is Movie movie)
|
||||
{
|
||||
xml.WriteStartElement("category");
|
||||
xml.WriteAttributeString("lang", "en");
|
||||
xml.WriteString("Movie");
|
||||
xml.WriteEndElement(); // category
|
||||
|
||||
Option<MovieMetadata> maybeMetadata = movie.MovieMetadata.HeadOrNone();
|
||||
if (maybeMetadata.IsSome)
|
||||
{
|
||||
MovieMetadata metadata = maybeMetadata.ValueUnsafe();
|
||||
|
||||
if (metadata.Year.HasValue)
|
||||
{
|
||||
xml.WriteStartElement("date");
|
||||
xml.WriteString(metadata.Year.Value.ToString());
|
||||
xml.WriteEndElement(); // date
|
||||
}
|
||||
|
||||
string poster = Optional(metadata.Artwork).Flatten()
|
||||
.Filter(a => a.ArtworkKind == ArtworkKind.Poster)
|
||||
.HeadOrNone()
|
||||
.Match(
|
||||
artwork => $"{_scheme}://{_host}/artwork/posters/{artwork.Path}",
|
||||
() => string.Empty);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(poster))
|
||||
{
|
||||
xml.WriteStartElement("icon");
|
||||
xml.WriteAttributeString("src", poster);
|
||||
xml.WriteEndElement(); // icon
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
xml.WriteStartElement("title");
|
||||
xml.WriteAttributeString("lang", "en");
|
||||
xml.WriteString(title);
|
||||
xml.WriteEndElement(); // title
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(subtitle))
|
||||
{
|
||||
xml.WriteStartElement("sub-title");
|
||||
xml.WriteAttributeString("lang", "en");
|
||||
xml.WriteString(subtitle);
|
||||
xml.WriteEndElement(); // subtitle
|
||||
}
|
||||
|
||||
xml.WriteStartElement("previously-shown");
|
||||
xml.WriteEndElement(); // previously-shown
|
||||
|
||||
xml.WriteStartElement("sub-title");
|
||||
xml.WriteAttributeString("lang", "en");
|
||||
xml.WriteEndElement(); // sub-title
|
||||
|
||||
if (playoutItem.MediaItem is Episode episode)
|
||||
{
|
||||
int s = Optional(episode.Season?.SeasonNumber).IfNone(0);
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +80,15 @@ namespace ErsatzTV.Core.Metadata
|
||||
if (version.DateUpdated < _localFileSystem.GetLastWriteTime(path))
|
||||
{
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", path);
|
||||
await _localStatisticsProvider.RefreshStatistics(ffprobePath, mediaItem);
|
||||
Either<BaseError, Unit> refreshResult =
|
||||
await _localStatisticsProvider.RefreshStatistics(ffprobePath, mediaItem);
|
||||
refreshResult.IfLeft(
|
||||
error =>
|
||||
_logger.LogWarning(
|
||||
"Unable to refresh {Attribute} for media item {Path}. Error: {Error}",
|
||||
"Statistics",
|
||||
path,
|
||||
error.Value));
|
||||
}
|
||||
|
||||
return mediaItem;
|
||||
@@ -97,10 +105,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 +117,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;
|
||||
@@ -47,7 +48,12 @@ namespace ErsatzTV.Core.Metadata
|
||||
maybeMetadata = await LoadTelevisionShowMetadata(nfoFileName);
|
||||
}
|
||||
|
||||
return maybeMetadata.IfNone(
|
||||
return maybeMetadata.Match(
|
||||
metadata =>
|
||||
{
|
||||
metadata.SortTitle = _fallbackMetadataProvider.GetSortTitle(metadata.Title);
|
||||
return metadata;
|
||||
},
|
||||
() =>
|
||||
{
|
||||
ShowMetadata metadata = _fallbackMetadataProvider.GetFallbackMetadataForShow(showFolder);
|
||||
@@ -100,11 +106,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 +136,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 +190,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 +278,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 +335,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 +386,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 +414,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")]
|
||||
|
||||
@@ -29,7 +29,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<bool> RefreshStatistics(string ffprobePath, MediaItem mediaItem)
|
||||
public async Task<Either<BaseError, Unit>> RefreshStatistics(string ffprobePath, MediaItem mediaItem)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -40,14 +40,20 @@ namespace ErsatzTV.Core.Metadata
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
|
||||
};
|
||||
|
||||
FFprobe ffprobe = await GetProbeOutput(ffprobePath, filePath);
|
||||
MediaVersion version = ProjectToMediaVersion(ffprobe);
|
||||
return await ApplyVersionUpdate(mediaItem, version, filePath);
|
||||
Either<BaseError, FFprobe> maybeProbe = await GetProbeOutput(ffprobePath, filePath);
|
||||
return await maybeProbe.Match(
|
||||
async ffprobe =>
|
||||
{
|
||||
MediaVersion version = ProjectToMediaVersion(ffprobe);
|
||||
await ApplyVersionUpdate(mediaItem, version, filePath);
|
||||
return Right<BaseError, Unit>(Unit.Default);
|
||||
},
|
||||
error => Task.FromResult(Left<BaseError, Unit>(error)));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to refresh statistics for media item {Id}", mediaItem.Id);
|
||||
return false;
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,12 +76,13 @@ 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;
|
||||
}
|
||||
|
||||
private Task<FFprobe> GetProbeOutput(string ffprobePath, string filePath)
|
||||
private Task<Either<BaseError, FFprobe>> GetProbeOutput(string ffprobePath, string filePath)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
@@ -100,11 +107,13 @@ namespace ErsatzTV.Core.Metadata
|
||||
};
|
||||
|
||||
probe.Start();
|
||||
return probe.StandardOutput.ReadToEndAsync().MapAsync(
|
||||
return probe.StandardOutput.ReadToEndAsync().MapAsync<string, Either<BaseError, FFprobe>>(
|
||||
async output =>
|
||||
{
|
||||
await probe.WaitForExitAsync();
|
||||
return JsonConvert.DeserializeObject<FFprobe>(output);
|
||||
return probe.ExitCode == 0
|
||||
? JsonConvert.DeserializeObject<FFprobe>(output)
|
||||
: BaseError.New($"FFprobe at {ffprobePath} exited with code {probe.ExitCode}");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -134,6 +143,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 +167,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
public record FFprobeStream(
|
||||
int index,
|
||||
string codec_name,
|
||||
string profile,
|
||||
string codec_type,
|
||||
int width,
|
||||
int height,
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
using LanguageExt;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Core.Scheduling
|
||||
{
|
||||
public class CustomOrderCollectionEnumerator : IMediaCollectionEnumerator
|
||||
{
|
||||
private readonly IList<MediaItem> _sortedMediaItems;
|
||||
|
||||
public CustomOrderCollectionEnumerator(
|
||||
Collection collection,
|
||||
List<MediaItem> mediaItems,
|
||||
CollectionEnumeratorState state)
|
||||
{
|
||||
// TODO: this will break if we allow shows and seasons
|
||||
_sortedMediaItems = collection.CollectionItems
|
||||
.OrderBy(ci => ci.CustomIndex)
|
||||
.Map(ci => mediaItems.First(mi => mi.Id == ci.MediaItemId))
|
||||
.ToList();
|
||||
|
||||
State = new CollectionEnumeratorState { Seed = state.Seed };
|
||||
while (State.Index < state.Index)
|
||||
{
|
||||
MoveNext();
|
||||
}
|
||||
}
|
||||
|
||||
public CollectionEnumeratorState State { get; }
|
||||
|
||||
public Option<MediaItem> Current => _sortedMediaItems.Any() ? _sortedMediaItems[State.Index] : None;
|
||||
|
||||
public void MoveNext() => State.Index = (State.Index + 1) % _sortedMediaItems.Count;
|
||||
}
|
||||
}
|
||||
@@ -78,7 +78,8 @@ namespace ErsatzTV.Core.Scheduling
|
||||
playout.Channel.Number,
|
||||
playout.Channel.Name);
|
||||
|
||||
Option<CollectionKey> emptyCollection = collectionMediaItems.Find(c => !c.Value.Any()).Map(c => c.Key);
|
||||
Option<CollectionKey> emptyCollection =
|
||||
collectionMediaItems.Find(c => !c.Value.Any()).Map(c => c.Key);
|
||||
if (emptyCollection.IsSome)
|
||||
{
|
||||
_logger.LogError(
|
||||
@@ -88,6 +89,25 @@ namespace ErsatzTV.Core.Scheduling
|
||||
return playout;
|
||||
}
|
||||
|
||||
Option<CollectionKey> zeroDurationCollection = collectionMediaItems.Find(
|
||||
c => c.Value.Any(
|
||||
mi => mi switch
|
||||
{
|
||||
Movie m => m.MediaVersions.HeadOrNone().Map(mv => mv.Duration).IfNone(TimeSpan.Zero) ==
|
||||
TimeSpan.Zero,
|
||||
Episode e => e.MediaVersions.HeadOrNone().Map(mv => mv.Duration).IfNone(TimeSpan.Zero) ==
|
||||
TimeSpan.Zero,
|
||||
_ => true
|
||||
})).Map(c => c.Key);
|
||||
if (zeroDurationCollection.IsSome)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Unable to rebuild playout; collection {@CollectionKey} contains items with zero duration!",
|
||||
zeroDurationCollection.ValueUnsafe());
|
||||
|
||||
return playout;
|
||||
}
|
||||
|
||||
playout.Items ??= new List<PlayoutItem>();
|
||||
playout.ProgramScheduleAnchors ??= new List<PlayoutProgramScheduleAnchor>();
|
||||
|
||||
@@ -98,15 +118,21 @@ namespace ErsatzTV.Core.Scheduling
|
||||
playout.ProgramScheduleAnchors.Clear();
|
||||
}
|
||||
|
||||
var sortedScheduleItems = playout.ProgramSchedule.Items.OrderBy(i => i.Index).ToList();
|
||||
Map<CollectionKey, IMediaCollectionEnumerator> collectionEnumerators =
|
||||
MapExtensions.Map(collectionMediaItems, (c, i) => GetMediaCollectionEnumerator(playout, c, i));
|
||||
var sortedScheduleItems =
|
||||
playout.ProgramSchedule.Items.OrderBy(i => i.Index).ToList();
|
||||
var collectionEnumerators = new Dictionary<CollectionKey, IMediaCollectionEnumerator>();
|
||||
foreach ((CollectionKey collectionKey, List<MediaItem> mediaItems) in collectionMediaItems)
|
||||
{
|
||||
IMediaCollectionEnumerator enumerator =
|
||||
await GetMediaCollectionEnumerator(playout, collectionKey, mediaItems);
|
||||
collectionEnumerators.Add(collectionKey, enumerator);
|
||||
}
|
||||
|
||||
// find start anchor
|
||||
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 +303,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 +323,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 +332,7 @@ namespace ErsatzTV.Core.Scheduling
|
||||
{
|
||||
NextScheduleItem = schedule,
|
||||
NextScheduleItemId = schedule.Id,
|
||||
NextStart = start.Date
|
||||
NextStart = (start - start.TimeOfDay).UtcDateTime
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -337,20 +364,20 @@ namespace ErsatzTV.Core.Scheduling
|
||||
|
||||
private static List<PlayoutProgramScheduleAnchor> BuildProgramScheduleAnchors(
|
||||
Playout playout,
|
||||
Map<CollectionKey, IMediaCollectionEnumerator> collectionEnumerators)
|
||||
Dictionary<CollectionKey, IMediaCollectionEnumerator> collectionEnumerators)
|
||||
{
|
||||
var result = new List<PlayoutProgramScheduleAnchor>();
|
||||
|
||||
foreach (CollectionKey collectionKey in collectionEnumerators.Keys)
|
||||
{
|
||||
Option<PlayoutProgramScheduleAnchor> maybeExisting = playout.ProgramScheduleAnchors
|
||||
.FirstOrDefault(
|
||||
a => a.CollectionType == collectionKey.CollectionType
|
||||
&& a.CollectionId == collectionKey.CollectionId
|
||||
&& a.MediaItemId == collectionKey.MediaItemId);
|
||||
Option<PlayoutProgramScheduleAnchor> maybeExisting = playout.ProgramScheduleAnchors.FirstOrDefault(
|
||||
a => a.CollectionType == collectionKey.CollectionType
|
||||
&& a.CollectionId == collectionKey.CollectionId
|
||||
&& a.MediaItemId == collectionKey.MediaItemId);
|
||||
|
||||
var maybeEnumeratorState = collectionEnumerators.GroupBy(e => e.Key, e => e.Value.State)
|
||||
.ToDictionary(mcs => mcs.Key, mcs => mcs.Head());
|
||||
var maybeEnumeratorState = collectionEnumerators.GroupBy(e => e.Key, e => e.Value.State).ToDictionary(
|
||||
mcs => mcs.Key,
|
||||
mcs => mcs.Head());
|
||||
|
||||
PlayoutProgramScheduleAnchor scheduleAnchor = maybeExisting.Match(
|
||||
existing =>
|
||||
@@ -376,22 +403,37 @@ namespace ErsatzTV.Core.Scheduling
|
||||
return result;
|
||||
}
|
||||
|
||||
private static IMediaCollectionEnumerator GetMediaCollectionEnumerator(
|
||||
private async Task<IMediaCollectionEnumerator> GetMediaCollectionEnumerator(
|
||||
Playout playout,
|
||||
CollectionKey collectionKey,
|
||||
List<MediaItem> mediaItems)
|
||||
{
|
||||
Option<PlayoutProgramScheduleAnchor> maybeAnchor = playout.ProgramScheduleAnchors
|
||||
.FirstOrDefault(
|
||||
a => a.ProgramScheduleId == playout.ProgramScheduleId
|
||||
&& a.CollectionType == collectionKey.CollectionType
|
||||
&& a.CollectionId == collectionKey.CollectionId
|
||||
&& a.MediaItemId == collectionKey.MediaItemId);
|
||||
Option<PlayoutProgramScheduleAnchor> maybeAnchor = playout.ProgramScheduleAnchors.FirstOrDefault(
|
||||
a => a.ProgramScheduleId == playout.ProgramScheduleId
|
||||
&& a.CollectionType == collectionKey.CollectionType
|
||||
&& a.CollectionId == collectionKey.CollectionId
|
||||
&& a.MediaItemId == collectionKey.MediaItemId);
|
||||
|
||||
CollectionEnumeratorState state = maybeAnchor.Match(
|
||||
anchor => anchor.EnumeratorState,
|
||||
() => new CollectionEnumeratorState { Seed = Random.Next(), Index = 0 });
|
||||
|
||||
if (await _mediaCollectionRepository.IsCustomPlaybackOrder(collectionKey.CollectionId ?? 0))
|
||||
{
|
||||
Option<Collection> collectionWithItems =
|
||||
await _mediaCollectionRepository.GetCollectionWithCollectionItemsUntracked(
|
||||
collectionKey.CollectionId ?? 0);
|
||||
|
||||
if (collectionKey.CollectionType == ProgramScheduleItemCollectionType.Collection &&
|
||||
collectionWithItems.IsSome)
|
||||
{
|
||||
return new CustomOrderCollectionEnumerator(
|
||||
collectionWithItems.ValueUnsafe(),
|
||||
mediaItems,
|
||||
state);
|
||||
}
|
||||
}
|
||||
|
||||
switch (playout.ProgramSchedule.MediaCollectionPlaybackOrder)
|
||||
{
|
||||
case PlaybackOrder.Chronological:
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations
|
||||
{
|
||||
public class CollectionItemConfiguration : IEntityTypeConfiguration<CollectionItem>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<CollectionItem> builder) => builder.ToTable("CollectionItem");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -52,10 +53,13 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.ThenInclude(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Episode).Season)
|
||||
.ThenInclude(s => s.Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.Include(c => c.Playouts)
|
||||
.ThenInclude(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task Update(Channel channel)
|
||||
|
||||
@@ -62,8 +62,37 @@ 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
|
||||
.Include(c => c.CollectionItems)
|
||||
.OrderBy(c => c.Id)
|
||||
.SingleOrDefaultAsync(c => c.Id == id)
|
||||
.Map(Optional);
|
||||
@@ -92,6 +121,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
public Task<Option<Collection>> GetCollectionWithItemsUntracked(int id) =>
|
||||
_dbContext.Collections
|
||||
.AsNoTracking()
|
||||
.Include(c => c.CollectionItems)
|
||||
.Include(c => c.MediaItems)
|
||||
.ThenInclude(i => i.LibraryPath)
|
||||
.Include(c => c.MediaItems)
|
||||
@@ -117,6 +147,13 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.SingleOrDefaultAsync(c => c.Id == id)
|
||||
.Map(Optional);
|
||||
|
||||
public Task<Option<Collection>> GetCollectionWithCollectionItemsUntracked(int id) =>
|
||||
_dbContext.Collections
|
||||
.Include(c => c.CollectionItems)
|
||||
.OrderBy(c => c.Id)
|
||||
.SingleOrDefaultAsync(c => c.Id == id)
|
||||
.Map(Optional);
|
||||
|
||||
public Task<List<Collection>> GetAll() =>
|
||||
_dbContext.Collections.ToListAsync();
|
||||
|
||||
@@ -146,6 +183,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
new { CollectionId = collectionId })
|
||||
.Map(result => result.ToList());
|
||||
|
||||
public Task<bool> IsCustomPlaybackOrder(int collectionId) =>
|
||||
_dbConnection.QuerySingleAsync<bool>(
|
||||
@"SELECT IFNULL(MIN(UseCustomPlaybackOrder), 0) FROM Collection WHERE Id = @CollectionId",
|
||||
new { CollectionId = collectionId });
|
||||
|
||||
private async Task<List<MediaItem>> GetItemsForCollection(Collection collection)
|
||||
{
|
||||
var result = new List<MediaItem>();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -34,6 +34,7 @@ namespace ErsatzTV.Infrastructure.Data
|
||||
public DbSet<EpisodeMetadata> EpisodeMetadata { get; set; }
|
||||
public DbSet<PlexMovie> PlexMovies { get; set; }
|
||||
public DbSet<Collection> Collections { get; set; }
|
||||
public DbSet<CollectionItem> CollectionItems { get; set; }
|
||||
public DbSet<ProgramSchedule> ProgramSchedules { get; set; }
|
||||
public DbSet<Playout> Playouts { get; set; }
|
||||
public DbSet<PlayoutItem> PlayoutItems { get; set; }
|
||||
|
||||
@@ -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);
|
||||
|
||||
Generated
+1497
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");
|
||||
}
|
||||
}
|
||||
Generated
+1497
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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
+1497
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);
|
||||
}
|
||||
}
|
||||
Generated
+1497
File diff suppressed because it is too large
Load Diff
+19
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
+1560
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");
|
||||
}
|
||||
}
|
||||
Generated
+1560
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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+1560
File diff suppressed because it is too large
Load Diff
+18
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
+1623
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user