Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a13f964200 | ||
|
|
0da9701f9c | ||
|
|
b3f4c22f49 | ||
|
|
50fafbfb98 | ||
|
|
914d128610 | ||
|
|
1a2f36f561 | ||
|
|
96887fbd79 | ||
|
|
c07e2afff4 | ||
|
|
4953617f79 | ||
|
|
1587ac7d62 | ||
|
|
c240169fc9 | ||
|
|
76d6725dd5 | ||
|
|
c016cac8d4 | ||
|
|
e624627ae1 | ||
|
|
46bcf03d9a | ||
|
|
ab9a8493d9 | ||
|
|
b1ecbafb6e |
@@ -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
|
||||
|
||||
@@ -74,7 +74,7 @@ namespace ErsatzTV.Application.Channels.Commands
|
||||
_ => BaseError.New("Channel number must be unique"),
|
||||
() =>
|
||||
{
|
||||
if (Regex.IsMatch(createChannel.Number, @"^[0-9]+(\.[0-9])?$"))
|
||||
if (Regex.IsMatch(createChannel.Number, Channel.NumberValidator))
|
||||
{
|
||||
return createChannel.Number;
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ namespace ErsatzTV.Application.Channels.Commands
|
||||
int matchId = match.Map(c => c.Id).IfNone(updateChannel.ChannelId);
|
||||
if (matchId == updateChannel.ChannelId)
|
||||
{
|
||||
if (Regex.IsMatch(updateChannel.Number, @"^[0-9](\.[0-9])?$"))
|
||||
if (Regex.IsMatch(updateChannel.Number, Channel.NumberValidator))
|
||||
{
|
||||
return updateChannel.Number;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -14,7 +14,9 @@ namespace ErsatzTV.Application.Movies
|
||||
metadata.Year?.ToString(),
|
||||
metadata.Plot,
|
||||
Artwork(metadata, ArtworkKind.Poster),
|
||||
Artwork(metadata, ArtworkKind.FanArt));
|
||||
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) =>
|
||||
|
||||
@@ -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, string FanArt);
|
||||
public record MovieViewModel(
|
||||
string Title,
|
||||
string Year,
|
||||
string Plot,
|
||||
string Poster,
|
||||
string FanArt,
|
||||
List<string> Genres,
|
||||
List<string> Tags);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,6 +5,8 @@ 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; }
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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,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);
|
||||
|
||||
@@ -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
|
||||
@@ -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),
|
||||
@@ -89,18 +99,57 @@ namespace ErsatzTV.Core.Iptv
|
||||
xml.WriteAttributeString("stop", stop);
|
||||
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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +82,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
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
|
||||
{
|
||||
@@ -101,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}");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -80,8 +80,8 @@ namespace ErsatzTV.Core.Metadata
|
||||
.GetOrAdd(libraryPath, file)
|
||||
.BindT(movie => UpdateStatistics(movie, ffprobePath).MapT(_ => movie))
|
||||
.BindT(UpdateMetadata)
|
||||
.BindT(UpdatePoster)
|
||||
.BindT(UpdateFanArt);
|
||||
.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));
|
||||
@@ -136,37 +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 LocateArtwork(movie, ArtworkKind.Poster).IfSomeAsync(
|
||||
await LocateArtwork(movie, artworkKind).IfSomeAsync(
|
||||
async posterFile =>
|
||||
{
|
||||
MovieMetadata metadata = movie.MovieMetadata.Head();
|
||||
if (RefreshArtwork(posterFile, metadata, ArtworkKind.Poster))
|
||||
{
|
||||
await _movieRepository.Update(movie);
|
||||
}
|
||||
});
|
||||
|
||||
return movie;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Movie>> UpdateFanArt(Movie movie)
|
||||
{
|
||||
try
|
||||
{
|
||||
await LocateArtwork(movie, ArtworkKind.FanArt).IfSomeAsync(
|
||||
async posterFile =>
|
||||
{
|
||||
MovieMetadata metadata = movie.MovieMetadata.Head();
|
||||
if (RefreshArtwork(posterFile, metadata, ArtworkKind.FanArt))
|
||||
if (RefreshArtwork(posterFile, metadata, artworkKind))
|
||||
{
|
||||
await _movieRepository.Update(movie);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,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)
|
||||
|
||||
@@ -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; }
|
||||
|
||||
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
@@ -0,0 +1,75 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Add_MetadataTags : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
"Tag",
|
||||
table => new
|
||||
{
|
||||
Id = table.Column<int>("INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Name = table.Column<string>("TEXT", nullable: true),
|
||||
EpisodeMetadataId = table.Column<int>("INTEGER", nullable: true),
|
||||
MovieMetadataId = table.Column<int>("INTEGER", nullable: true),
|
||||
SeasonMetadataId = table.Column<int>("INTEGER", nullable: true),
|
||||
ShowMetadataId = table.Column<int>("INTEGER", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Tag", x => x.Id);
|
||||
table.ForeignKey(
|
||||
"FK_Tag_EpisodeMetadata_EpisodeMetadataId",
|
||||
x => x.EpisodeMetadataId,
|
||||
"EpisodeMetadata",
|
||||
"Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
"FK_Tag_MovieMetadata_MovieMetadataId",
|
||||
x => x.MovieMetadataId,
|
||||
"MovieMetadata",
|
||||
"Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
"FK_Tag_SeasonMetadata_SeasonMetadataId",
|
||||
x => x.SeasonMetadataId,
|
||||
"SeasonMetadata",
|
||||
"Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
"FK_Tag_ShowMetadata_ShowMetadataId",
|
||||
x => x.ShowMetadataId,
|
||||
"ShowMetadata",
|
||||
"Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
"IX_Tag_EpisodeMetadataId",
|
||||
"Tag",
|
||||
"EpisodeMetadataId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
"IX_Tag_MovieMetadataId",
|
||||
"Tag",
|
||||
"MovieMetadataId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
"IX_Tag_SeasonMetadataId",
|
||||
"Tag",
|
||||
"SeasonMetadataId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
"IX_Tag_ShowMetadataId",
|
||||
"Tag",
|
||||
"ShowMetadataId");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder) =>
|
||||
migrationBuilder.DropTable(
|
||||
"Tag");
|
||||
}
|
||||
}
|
||||
Generated
+1623
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Reset_MetadataDateUpdated_Tags : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(@"UPDATE MovieMetadata SET DateUpdated = '0001-01-01 00:00:00'");
|
||||
migrationBuilder.Sql(@"UPDATE ShowMetadata SET DateUpdated = '0001-01-01 00:00:00'");
|
||||
migrationBuilder.Sql(@"UPDATE Library SET LastScan = '0001-01-01 00:00:00'");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+1626
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_CollectionItem_CustomIndex : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder) =>
|
||||
migrationBuilder.AddColumn<int>(
|
||||
"CustomIndex",
|
||||
"CollectionItem",
|
||||
"INTEGER",
|
||||
nullable: true);
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder) =>
|
||||
migrationBuilder.DropColumn(
|
||||
"CustomIndex",
|
||||
"CollectionItem");
|
||||
}
|
||||
}
|
||||
Generated
+1629
File diff suppressed because it is too large
Load Diff
+20
@@ -0,0 +1,20 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Add_Collection_UseCustomPlaybackOrder : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder) =>
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
"UseCustomPlaybackOrder",
|
||||
"Collection",
|
||||
"INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder) =>
|
||||
migrationBuilder.DropColumn(
|
||||
"UseCustomPlaybackOrder",
|
||||
"Collection");
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,9 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("UseCustomPlaybackOrder")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Collection");
|
||||
@@ -125,6 +128,9 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
b.Property<int>("MediaItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("CustomIndex")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("CollectionId", "MediaItemId");
|
||||
|
||||
b.HasIndex("MediaItemId");
|
||||
@@ -274,6 +280,42 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
b.ToTable("FFmpegProfile");
|
||||
});
|
||||
|
||||
modelBuilder.Entity(
|
||||
"ErsatzTV.Core.Domain.Genre",
|
||||
b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("EpisodeMetadataId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("MovieMetadataId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("SeasonMetadataId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("ShowMetadataId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EpisodeMetadataId");
|
||||
|
||||
b.HasIndex("MovieMetadataId");
|
||||
|
||||
b.HasIndex("SeasonMetadataId");
|
||||
|
||||
b.HasIndex("ShowMetadataId");
|
||||
|
||||
b.ToTable("Genre");
|
||||
});
|
||||
|
||||
modelBuilder.Entity(
|
||||
"ErsatzTV.Core.Domain.Library",
|
||||
b =>
|
||||
@@ -803,6 +845,42 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
b.ToTable("ShowMetadata");
|
||||
});
|
||||
|
||||
modelBuilder.Entity(
|
||||
"ErsatzTV.Core.Domain.Tag",
|
||||
b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("EpisodeMetadataId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("MovieMetadataId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("SeasonMetadataId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("ShowMetadataId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EpisodeMetadataId");
|
||||
|
||||
b.HasIndex("MovieMetadataId");
|
||||
|
||||
b.HasIndex("SeasonMetadataId");
|
||||
|
||||
b.HasIndex("ShowMetadataId");
|
||||
|
||||
b.ToTable("Tag");
|
||||
});
|
||||
|
||||
modelBuilder.Entity(
|
||||
"ErsatzTV.Core.Domain.LocalLibrary",
|
||||
b =>
|
||||
@@ -1068,6 +1146,29 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
b.Navigation("Resolution");
|
||||
});
|
||||
|
||||
modelBuilder.Entity(
|
||||
"ErsatzTV.Core.Domain.Genre",
|
||||
b =>
|
||||
{
|
||||
b.HasOne("ErsatzTV.Core.Domain.EpisodeMetadata", null)
|
||||
.WithMany("Genres")
|
||||
.HasForeignKey("EpisodeMetadataId");
|
||||
|
||||
b.HasOne("ErsatzTV.Core.Domain.MovieMetadata", null)
|
||||
.WithMany("Genres")
|
||||
.HasForeignKey("MovieMetadataId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
|
||||
.WithMany("Genres")
|
||||
.HasForeignKey("SeasonMetadataId");
|
||||
|
||||
b.HasOne("ErsatzTV.Core.Domain.ShowMetadata", null)
|
||||
.WithMany("Genres")
|
||||
.HasForeignKey("ShowMetadataId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity(
|
||||
"ErsatzTV.Core.Domain.Library",
|
||||
b =>
|
||||
@@ -1362,6 +1463,29 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
b.Navigation("Show");
|
||||
});
|
||||
|
||||
modelBuilder.Entity(
|
||||
"ErsatzTV.Core.Domain.Tag",
|
||||
b =>
|
||||
{
|
||||
b.HasOne("ErsatzTV.Core.Domain.EpisodeMetadata", null)
|
||||
.WithMany("Tags")
|
||||
.HasForeignKey("EpisodeMetadataId");
|
||||
|
||||
b.HasOne("ErsatzTV.Core.Domain.MovieMetadata", null)
|
||||
.WithMany("Tags")
|
||||
.HasForeignKey("MovieMetadataId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
|
||||
.WithMany("Tags")
|
||||
.HasForeignKey("SeasonMetadataId");
|
||||
|
||||
b.HasOne("ErsatzTV.Core.Domain.ShowMetadata", null)
|
||||
.WithMany("Tags")
|
||||
.HasForeignKey("ShowMetadataId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity(
|
||||
"ErsatzTV.Core.Domain.LocalLibrary",
|
||||
b =>
|
||||
@@ -1543,7 +1667,16 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.Collection", b => { b.Navigation("CollectionItems"); });
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.EpisodeMetadata", b => { b.Navigation("Artwork"); });
|
||||
modelBuilder.Entity(
|
||||
"ErsatzTV.Core.Domain.EpisodeMetadata",
|
||||
b =>
|
||||
{
|
||||
b.Navigation("Artwork");
|
||||
|
||||
b.Navigation("Genres");
|
||||
|
||||
b.Navigation("Tags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.Library", b => { b.Navigation("Paths"); });
|
||||
|
||||
@@ -1555,7 +1688,16 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.MediaVersion", b => { b.Navigation("MediaFiles"); });
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.MovieMetadata", b => { b.Navigation("Artwork"); });
|
||||
modelBuilder.Entity(
|
||||
"ErsatzTV.Core.Domain.MovieMetadata",
|
||||
b =>
|
||||
{
|
||||
b.Navigation("Artwork");
|
||||
|
||||
b.Navigation("Genres");
|
||||
|
||||
b.Navigation("Tags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity(
|
||||
"ErsatzTV.Core.Domain.Playout",
|
||||
@@ -1575,9 +1717,27 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
b.Navigation("Playouts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.SeasonMetadata", b => { b.Navigation("Artwork"); });
|
||||
modelBuilder.Entity(
|
||||
"ErsatzTV.Core.Domain.SeasonMetadata",
|
||||
b =>
|
||||
{
|
||||
b.Navigation("Artwork");
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.ShowMetadata", b => { b.Navigation("Artwork"); });
|
||||
b.Navigation("Genres");
|
||||
|
||||
b.Navigation("Tags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity(
|
||||
"ErsatzTV.Core.Domain.ShowMetadata",
|
||||
b =>
|
||||
{
|
||||
b.Navigation("Artwork");
|
||||
|
||||
b.Navigation("Genres");
|
||||
|
||||
b.Navigation("Tags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity(
|
||||
"ErsatzTV.Core.Domain.Episode",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Application.MediaCollections.Commands;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ErsatzTV.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
public class SortController : ControllerBase
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
|
||||
public SortController(IMediator mediator) => _mediator = mediator;
|
||||
|
||||
[HttpPost("media/collections/{collectionId}/items")]
|
||||
public Task SortCollectionItems(
|
||||
int collectionId,
|
||||
[FromForm]
|
||||
SortedMediaItemIds sortedMediaItemIds)
|
||||
{
|
||||
var ids = sortedMediaItemIds.Item.Map(int.Parse).ToList();
|
||||
|
||||
var request = new UpdateCollectionCustomOrder(
|
||||
collectionId,
|
||||
ids.Map(i => new MediaItemCustomOrder(i, ids.IndexOf(i))).ToList());
|
||||
|
||||
return _mediator.Send(request);
|
||||
}
|
||||
}
|
||||
|
||||
public record SortedMediaItemIds(List<string> Item);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace ErsatzTV.Extensions
|
||||
{
|
||||
public static class NavigationManagerExtensions
|
||||
{
|
||||
public static ValueTask NavigateToFragmentAsync(this NavigationManager navigationManager, IJSRuntime jSRuntime)
|
||||
{
|
||||
Uri uri = navigationManager.ToAbsoluteUri(navigationManager.Uri);
|
||||
|
||||
if (uri.Fragment.Length == 0)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
return jSRuntime.InvokeVoidAsync("blazorHelpers.scrollToFragment", uri.Fragment.Substring(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,52 +2,125 @@
|
||||
@using ErsatzTV.Application.MediaCards
|
||||
@using ErsatzTV.Application.MediaCards.Queries
|
||||
@using ErsatzTV.Application.MediaCollections.Commands
|
||||
@inherits MultiSelectBase<CollectionItems>
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IMediator Mediator
|
||||
@inject ILogger<CollectionItems> Logger
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService Dialog
|
||||
@inject ChannelWriter<IBackgroundServiceRequest> Channel
|
||||
@inject IJSRuntime JsRuntime
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
||||
<div class="mb-6" style="display: flex; flex-direction: row;">
|
||||
<MudText GutterBottom="true" Typo="Typo.h2">@_data.Name</MudText>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||
Link="@($"/media/collections/{Id}/edit")"
|
||||
Style="margin-bottom: auto; margin-top: auto;"/>
|
||||
<MudPaper Square="true" Style="display: flex; height: 64px; left: 240px; padding: 0; position: fixed; right: 0; z-index: 100;">
|
||||
<div style="align-items: center; display: flex; flex-direction: row; margin-bottom: auto; margin-top: auto; width: 100%;" class="ml-6 mr-6">
|
||||
@if (IsSelectMode())
|
||||
{
|
||||
<MudText Typo="Typo.h6" Color="Color.Primary">@SelectionLabel()</MudText>
|
||||
<div style="margin-left: auto">
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Error"
|
||||
StartIcon="@Icons.Material.Filled.Remove"
|
||||
OnClick="@(_ => RemoveSelectionFromCollection(Id))">
|
||||
Remove From Collection
|
||||
</MudButton>
|
||||
<MudButton Class="ml-3"
|
||||
Variant="Variant.Filled"
|
||||
Color="Color.Secondary"
|
||||
StartIcon="@Icons.Material.Filled.Check"
|
||||
OnClick="@(_ => ClearSelection())">
|
||||
Clear Selection
|
||||
</MudButton>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div style="display: flex; flex-direction: row;">
|
||||
<MudText Typo="Typo.h4">@_data.Name</MudText>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||
Link="@($"/media/collections/{Id}/edit")"
|
||||
Style="margin-bottom: auto; margin-top: auto;"/>
|
||||
</div>
|
||||
@if (_data.MovieCards.Any())
|
||||
{
|
||||
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#movies")">@_data.MovieCards.Count Movies</MudLink>
|
||||
}
|
||||
@if (_data.ShowCards.Any())
|
||||
{
|
||||
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#shows")">@_data.ShowCards.Count Shows</MudLink>
|
||||
}
|
||||
@if (_data.SeasonCards.Any())
|
||||
{
|
||||
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#seasons")">@_data.SeasonCards.Count Seasons</MudLink>
|
||||
}
|
||||
@if (_data.EpisodeCards.Any())
|
||||
{
|
||||
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#episodes")">@_data.EpisodeCards.Count Episodes</MudLink>
|
||||
}
|
||||
@if (SupportsCustomOrdering())
|
||||
{
|
||||
<div style="margin-left: auto">
|
||||
<MudSwitch T="bool"
|
||||
Checked="@_data.UseCustomPlaybackOrder"
|
||||
Color="Color.Primary"
|
||||
CheckedChanged="@OnUseCustomOrderChanged"
|
||||
Label="Use Custom Playback Order"/>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</MudPaper>
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8" Style="margin-top: 64px">
|
||||
|
||||
@if (_data.MovieCards.Any())
|
||||
{
|
||||
<MudText GutterBottom="true" Typo="Typo.h4">Movies</MudText>
|
||||
<MudText GutterBottom="true"
|
||||
Typo="Typo.h4"
|
||||
Style="scroll-margin-top: 160px"
|
||||
UserAttributes="@(new Dictionary<string, object> { { "id", "movies" } })">
|
||||
Movies
|
||||
</MudText>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
|
||||
@foreach (MovieCardViewModel card in _data.MovieCards.OrderBy(m => m.SortTitle))
|
||||
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid" UserAttributes="@(new Dictionary<string, object> { { "id", "sortable-collection" } })">
|
||||
@foreach (MovieCardViewModel card in OrderMovies(_data.MovieCards))
|
||||
{
|
||||
<MediaCard Data="@card"
|
||||
Link="@($"/media/movies/{card.MovieId}")"
|
||||
DeleteClicked="@RemoveMovieFromCollection"/>
|
||||
DeleteClicked="@RemoveMovieFromCollection"
|
||||
SelectColor="@Color.Error"
|
||||
SelectClicked="@(e => SelectClicked(card, e))"
|
||||
IsSelected="@IsSelected(card)"
|
||||
IsSelectMode="@IsSelectMode()"/>
|
||||
}
|
||||
</MudContainer>
|
||||
}
|
||||
|
||||
@if (_data.ShowCards.Any())
|
||||
{
|
||||
<MudText GutterBottom="true" Typo="Typo.h4">Television Shows</MudText>
|
||||
<MudText GutterBottom="true"
|
||||
Typo="Typo.h4"
|
||||
Style="scroll-margin-top: 160px"
|
||||
UserAttributes="@(new Dictionary<string, object> { { "id", "shows" } })">
|
||||
Television Shows
|
||||
</MudText>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
|
||||
@foreach (TelevisionShowCardViewModel card in _data.ShowCards.OrderBy(m => m.SortTitle))
|
||||
{
|
||||
<MediaCard Data="@card"
|
||||
Link="@($"/media/tv/shows/{card.TelevisionShowId}")"
|
||||
DeleteClicked="@RemoveShowFromCollection"/>
|
||||
DeleteClicked="@RemoveShowFromCollection"
|
||||
SelectColor="@Color.Error"
|
||||
SelectClicked="@(e => SelectClicked(card, e))"
|
||||
IsSelected="@IsSelected(card)"
|
||||
IsSelectMode="@IsSelectMode()"/>
|
||||
}
|
||||
</MudContainer>
|
||||
}
|
||||
|
||||
@if (_data.SeasonCards.Any())
|
||||
{
|
||||
<MudText GutterBottom="true" Typo="Typo.h4">Television Seasons</MudText>
|
||||
<MudText GutterBottom="true"
|
||||
Typo="Typo.h4"
|
||||
Style="scroll-margin-top: 160px"
|
||||
UserAttributes="@(new Dictionary<string, object> { { "id", "seasons" } })">
|
||||
Television Seasons
|
||||
</MudText>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
|
||||
@foreach (TelevisionSeasonCardViewModel card in _data.SeasonCards.OrderBy(m => m.SortTitle))
|
||||
@@ -56,26 +129,39 @@
|
||||
Link="@($"/media/tv/seasons/{card.TelevisionSeasonId}")"
|
||||
Title="@card.ShowTitle"
|
||||
Subtitle="@card.Title"
|
||||
DeleteClicked="@RemoveSeasonFromCollection"/>
|
||||
DeleteClicked="@RemoveSeasonFromCollection"
|
||||
SelectColor="@Color.Error"
|
||||
SelectClicked="@(e => SelectClicked(card, e))"
|
||||
IsSelected="@IsSelected(card)"
|
||||
IsSelectMode="@IsSelectMode()"/>
|
||||
}
|
||||
</MudContainer>
|
||||
}
|
||||
|
||||
@if (_data.EpisodeCards.Any())
|
||||
{
|
||||
<MudText GutterBottom="true" Typo="Typo.h4">Television Episodes</MudText>
|
||||
<MudText GutterBottom="true"
|
||||
Typo="Typo.h4"
|
||||
Style="scroll-margin-top: 160px"
|
||||
UserAttributes="@(new Dictionary<string, object> { { "id", "episodes" } })">
|
||||
Television Episodes
|
||||
</MudText>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
|
||||
@foreach (TelevisionEpisodeCardViewModel card in _data.EpisodeCards.OrderBy(e => e.Aired))
|
||||
{
|
||||
<MediaCard Data="@card"
|
||||
Link="@($"/media/tv/episodes/{card.EpisodeId}")"
|
||||
Link="@($"/media/tv/seasons/{card.SeasonId}#episode-{card.EpisodeId}")"
|
||||
Title="@card.ShowTitle"
|
||||
Subtitle="@card.Title"
|
||||
ContainerClass="media-card-episode-container mx-2"
|
||||
CardClass="media-card-episode"
|
||||
DeleteClicked="@RemoveEpisodeFromCollection"
|
||||
ArtworkKind="@ArtworkKind.Thumbnail"/>
|
||||
DeleteClicked="@(_ => RemoveEpisodeFromCollection(card))"
|
||||
ArtworkKind="@ArtworkKind.Thumbnail"
|
||||
SelectColor="@Color.Error"
|
||||
SelectClicked="@(e => SelectClicked(card, e))"
|
||||
IsSelected="@IsSelected(card)"
|
||||
IsSelectMode="@IsSelectMode()"/>
|
||||
}
|
||||
</MudContainer>
|
||||
}
|
||||
@@ -88,9 +174,12 @@
|
||||
|
||||
private CollectionCardResultsViewModel _data;
|
||||
|
||||
private bool SupportsCustomOrdering() =>
|
||||
_data.MovieCards.Any() && !_data.ShowCards.Any() && !_data.SeasonCards.Any() && !_data.EpisodeCards.Any();
|
||||
|
||||
protected override async Task OnParametersSetAsync() => await RefreshData();
|
||||
|
||||
private async Task RefreshData()
|
||||
protected override async Task RefreshData()
|
||||
{
|
||||
Either<BaseError, CollectionCardResultsViewModel> maybeResult =
|
||||
await Mediator.Send(new GetCollectionCards(Id));
|
||||
@@ -100,6 +189,44 @@
|
||||
error => NavigationManager.NavigateTo("404"));
|
||||
}
|
||||
|
||||
private IOrderedEnumerable<MovieCardViewModel> OrderMovies(List<MovieCardViewModel> movies)
|
||||
{
|
||||
if (_data.UseCustomPlaybackOrder)
|
||||
{
|
||||
return movies.OrderBy(m => m.CustomIndex);
|
||||
}
|
||||
|
||||
return movies.OrderBy(m => m.SortTitle);
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
await JsRuntime.InvokeVoidAsync("sortableCollection", Id);
|
||||
if (_data.UseCustomPlaybackOrder)
|
||||
{
|
||||
await JsRuntime.InvokeVoidAsync("enableSorting");
|
||||
}
|
||||
else
|
||||
{
|
||||
await JsRuntime.InvokeVoidAsync("disableSorting");
|
||||
}
|
||||
await base.OnAfterRenderAsync(firstRender);
|
||||
}
|
||||
|
||||
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
|
||||
{
|
||||
List<MediaCardViewModel> GetSortedItems()
|
||||
{
|
||||
return _data.MovieCards.OrderBy(m => m.SortTitle)
|
||||
.Append<MediaCardViewModel>(_data.ShowCards.OrderBy(s => s.SortTitle))
|
||||
.Append(_data.SeasonCards.OrderBy(s => s.SortTitle))
|
||||
.Append(_data.EpisodeCards.OrderBy(ep => ep.Aired))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
SelectClicked(GetSortedItems, card, e);
|
||||
}
|
||||
|
||||
private async Task RemoveMovieFromCollection(MediaCardViewModel vm)
|
||||
{
|
||||
if (vm is MovieCardViewModel movie)
|
||||
@@ -139,17 +266,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RemoveEpisodeFromCollection(MediaCardViewModel vm)
|
||||
private async Task RemoveEpisodeFromCollection(TelevisionEpisodeCardViewModel episode)
|
||||
{
|
||||
if (vm is TelevisionEpisodeCardViewModel episode)
|
||||
var request = new RemoveItemsFromCollection(Id)
|
||||
{
|
||||
var request = new RemoveItemsFromCollection(Id)
|
||||
{
|
||||
MediaItemIds = new List<int> { episode.EpisodeId }
|
||||
};
|
||||
MediaItemIds = new List<int> { episode.EpisodeId }
|
||||
};
|
||||
|
||||
await RemoveItemsWithConfirmation("episode", $"{episode.ShowTitle} - {episode.Title}", request);
|
||||
}
|
||||
await RemoveItemsWithConfirmation("episode", $"{episode.ShowTitle} - {episode.Title}", request);
|
||||
}
|
||||
|
||||
private async Task RemoveItemsWithConfirmation(
|
||||
@@ -169,4 +293,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnUseCustomOrderChanged()
|
||||
{
|
||||
_data.UseCustomPlaybackOrder = !_data.UseCustomPlaybackOrder;
|
||||
var request = new UpdateCollection(Id, _data.Name) { UseCustomPlaybackOrder = _data.UseCustomPlaybackOrder };
|
||||
await Mediator.Send(request);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -2,8 +2,11 @@
|
||||
@using ErsatzTV.Application.FFmpegProfiles
|
||||
@using ErsatzTV.Application.FFmpegProfiles.Commands
|
||||
@using ErsatzTV.Application.FFmpegProfiles.Queries
|
||||
@using Unit = LanguageExt.Unit
|
||||
@inject IDialogService Dialog
|
||||
@inject IMediator Mediator
|
||||
@inject ILogger<FFmpeg> Logger
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
||||
<MudCard>
|
||||
@@ -110,7 +113,17 @@
|
||||
await LoadFFmpegProfilesAsync();
|
||||
}
|
||||
|
||||
private Task SaveSettings() => Mediator.Send(new UpdateFFmpegSettings(_ffmpegSettings));
|
||||
private async Task SaveSettings()
|
||||
{
|
||||
Either<BaseError, Unit> result = await Mediator.Send(new UpdateFFmpegSettings(_ffmpegSettings));
|
||||
result.Match(
|
||||
Left: error =>
|
||||
{
|
||||
Snackbar.Add(error.Value, Severity.Error);
|
||||
Logger.LogError("Unexpected error saving FFmpeg settings: {Error}", error.Value);
|
||||
},
|
||||
Right: _ => Snackbar.Add("Successfully saved FFmpeg settings", Severity.Success));
|
||||
}
|
||||
|
||||
private static string ValidatePathExists(string path) => !File.Exists(path) ? "Path does not exist" : null;
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Extensions;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Routing;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace ErsatzTV.Pages
|
||||
{
|
||||
public class FragmentNavigationBase : ComponentBase, IDisposable
|
||||
{
|
||||
[Inject]
|
||||
private NavigationManager NavManager { get; set; }
|
||||
|
||||
[Inject]
|
||||
private IJSRuntime JsRuntime { get; set; }
|
||||
|
||||
public void Dispose() => NavManager.LocationChanged -= TryFragmentNavigation;
|
||||
|
||||
protected override void OnInitialized() => NavManager.LocationChanged += TryFragmentNavigation;
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
await NavManager.NavigateToFragmentAsync(JsRuntime);
|
||||
}
|
||||
}
|
||||
|
||||
private async void TryFragmentNavigation(object sender, LocationChangedEventArgs args) =>
|
||||
await NavManager.NavigateToFragmentAsync(JsRuntime);
|
||||
}
|
||||
}
|
||||
+23
-14
@@ -15,7 +15,7 @@
|
||||
}
|
||||
</MudContainer>
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Style="margin-top: 200px">
|
||||
<div style="display: flex; flex-direction: row;">
|
||||
<div style="display: flex; flex-direction: row;" class="mb-6">
|
||||
@if (!string.IsNullOrWhiteSpace(_movie.Poster))
|
||||
{
|
||||
<img class="mud-elevation-2 mr-6"
|
||||
@@ -43,6 +43,26 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@if (_movie.Genres.Any())
|
||||
{
|
||||
<MudText GutterBottom="true">Genres</MudText>
|
||||
<div class="mb-2">
|
||||
@foreach (string genre in _movie.Genres.OrderBy(g => g))
|
||||
{
|
||||
<MudFab Color="Color.Info" Size="Size.Small" Label="@genre" Class="mr-2 mb-2" Link="@($"/search?query=genre%3a{genre}")"/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (_movie.Tags.Any())
|
||||
{
|
||||
<MudText GutterBottom="true">Tags</MudText>
|
||||
<div>
|
||||
@foreach (string tag in _movie.Tags.OrderBy(t => t))
|
||||
{
|
||||
<MudFab Color="Color.Info" Size="Size.Small" Label="@tag" Class="mr-2 mb-2" Link="@($"/search?query=tag%3a{tag}")"/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
@@ -52,21 +72,10 @@
|
||||
|
||||
private MovieViewModel _movie;
|
||||
|
||||
private List<BreadcrumbItem> _breadcrumbs;
|
||||
|
||||
protected override Task OnParametersSetAsync() => RefreshData();
|
||||
|
||||
private async Task RefreshData()
|
||||
{
|
||||
await Mediator.Send(new GetMovieById(MovieId))
|
||||
.IfSomeAsync(vm => _movie = vm);
|
||||
|
||||
_breadcrumbs = new List<BreadcrumbItem>
|
||||
{
|
||||
new("Movies", "/media/movies"),
|
||||
new($"{_movie.Title} ({_movie.Year})", null, true)
|
||||
};
|
||||
}
|
||||
private Task RefreshData() =>
|
||||
Mediator.Send(new GetMovieById(MovieId)).IfSomeAsync(vm => _movie = vm);
|
||||
|
||||
private async Task AddToCollection()
|
||||
{
|
||||
|
||||
@@ -5,36 +5,61 @@
|
||||
@using ErsatzTV.Application.MediaCollections
|
||||
@using ErsatzTV.Application.MediaCollections.Commands
|
||||
@using Unit = LanguageExt.Unit
|
||||
@inject ILogger<MovieList> Logger
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IMediator Mediator
|
||||
@inject IDialogService Dialog
|
||||
@inherits MultiSelectBase<MovieList>
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject ChannelWriter<IBackgroundServiceRequest> Channel
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
||||
<MudContainer MaxWidth="MaxWidth.Small" Class="mb-6" Style="max-width: 300px">
|
||||
<MudPaper Style="align-items: center; display: flex; justify-content: center;">
|
||||
<MudIconButton Icon="@Icons.Material.Outlined.ChevronLeft"
|
||||
OnClick="@PrevPage"
|
||||
Disabled="@(PageNumber <= 1)">
|
||||
</MudIconButton>
|
||||
<MudText Style="flex-grow: 1"
|
||||
Align="Align.Center">
|
||||
@Math.Min((PageNumber - 1) * PageSize + 1, _data.Count)-@Math.Min(_data.Count, PageNumber * PageSize) of @_data.Count
|
||||
</MudText>
|
||||
<MudIconButton Icon="@Icons.Material.Outlined.ChevronRight"
|
||||
OnClick="@NextPage" Disabled="@(PageNumber * PageSize >= _data.Count)">
|
||||
</MudIconButton>
|
||||
</MudPaper>
|
||||
</MudContainer>
|
||||
|
||||
<MudPaper Square="true" Style="display: flex; height: 64px; left: 240px; padding: 0; position: fixed; right: 0; z-index: 100;">
|
||||
<div style="display: flex; flex-direction: row; margin-bottom: auto; margin-top: auto; width: 100%" class="ml-6 mr-6">
|
||||
@if (IsSelectMode())
|
||||
{
|
||||
<MudText Typo="Typo.h6" Color="Color.Primary">@SelectionLabel()</MudText>
|
||||
<div style="margin-left: auto">
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Add"
|
||||
OnClick="@(_ => AddSelectionToCollection())">
|
||||
Add To Collection
|
||||
</MudButton>
|
||||
<MudButton Class="ml-3"
|
||||
Variant="Variant.Filled"
|
||||
Color="Color.Secondary"
|
||||
StartIcon="@Icons.Material.Filled.Check"
|
||||
OnClick="@(_ => ClearSelection())">
|
||||
Clear Selection
|
||||
</MudButton>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div style="margin-left: auto; margin-right: auto; max-width: 300px;">
|
||||
<MudPaper Style="align-items: center; display: flex; justify-content: center;">
|
||||
<MudIconButton Icon="@Icons.Material.Outlined.ChevronLeft"
|
||||
OnClick="@PrevPage"
|
||||
Disabled="@(PageNumber <= 1)">
|
||||
</MudIconButton>
|
||||
<MudText Style="flex-grow: 1"
|
||||
Align="Align.Center">
|
||||
@Math.Min((PageNumber - 1) * PageSize + 1, _data.Count)-@Math.Min(_data.Count, PageNumber * PageSize) of @_data.Count
|
||||
</MudText>
|
||||
<MudIconButton Icon="@Icons.Material.Outlined.ChevronRight"
|
||||
OnClick="@NextPage" Disabled="@(PageNumber * PageSize >= _data.Count)">
|
||||
</MudIconButton>
|
||||
</MudPaper>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</MudPaper>
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8" Style="margin-top: 64px">
|
||||
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
|
||||
@foreach (MovieCardViewModel card in _data.Cards.Where(d => !string.IsNullOrWhiteSpace(d.Title)))
|
||||
@foreach (MovieCardViewModel card in _data.Cards.Where(m => !string.IsNullOrWhiteSpace(m.Title)).OrderBy(m => m.SortTitle))
|
||||
{
|
||||
<MediaCard Data="@card"
|
||||
Link="@($"/media/movies/{card.MovieId}")"
|
||||
AddToCollectionClicked="@AddToCollection"/>
|
||||
AddToCollectionClicked="@AddToCollection"
|
||||
SelectClicked="@(e => SelectClicked(card, e))"
|
||||
IsSelected="@IsSelected(card)"
|
||||
IsSelectMode="@IsSelectMode()"/>
|
||||
}
|
||||
</MudContainer>
|
||||
</MudContainer>
|
||||
@@ -57,13 +82,23 @@
|
||||
return RefreshData();
|
||||
}
|
||||
|
||||
private async Task RefreshData() =>
|
||||
protected override async Task RefreshData() =>
|
||||
_data = await Mediator.Send(new GetMovieCards(PageNumber, PageSize));
|
||||
|
||||
private void PrevPage() => NavigationManager.NavigateTo($"/media/movies/page/{PageNumber - 1}");
|
||||
|
||||
private void NextPage() => NavigationManager.NavigateTo($"/media/movies/page/{PageNumber + 1}");
|
||||
|
||||
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
|
||||
{
|
||||
List<MediaCardViewModel> GetSortedItems()
|
||||
{
|
||||
return _data.Cards.OrderBy(m => m.SortTitle).ToList<MediaCardViewModel>();
|
||||
}
|
||||
|
||||
SelectClicked(GetSortedItems, card, e);
|
||||
}
|
||||
|
||||
private async Task AddToCollection(MediaCardViewModel card)
|
||||
{
|
||||
if (card is MovieCardViewModel movie)
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Application.MediaCollections.Commands;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Shared;
|
||||
using LanguageExt;
|
||||
using LanguageExt.UnsafeValueAccess;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Web;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MudBlazor;
|
||||
using static LanguageExt.Prelude;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Pages
|
||||
{
|
||||
public class MultiSelectBase<T> : FragmentNavigationBase
|
||||
{
|
||||
private readonly System.Collections.Generic.HashSet<MediaCardViewModel> _selectedItems;
|
||||
private Option<MediaCardViewModel> _recentlySelected;
|
||||
|
||||
public MultiSelectBase()
|
||||
{
|
||||
_recentlySelected = None;
|
||||
_selectedItems = new System.Collections.Generic.HashSet<MediaCardViewModel>();
|
||||
}
|
||||
|
||||
[Inject]
|
||||
protected IDialogService Dialog { get; set; }
|
||||
|
||||
[Inject]
|
||||
protected ISnackbar Snackbar { get; set; }
|
||||
|
||||
[Inject]
|
||||
protected ILogger<T> Logger { get; set; }
|
||||
|
||||
[Inject]
|
||||
protected IMediator Mediator { get; set; }
|
||||
|
||||
protected bool IsSelected(MediaCardViewModel card) =>
|
||||
_selectedItems.Contains(card);
|
||||
|
||||
protected bool IsSelectMode() =>
|
||||
_selectedItems.Any();
|
||||
|
||||
protected string SelectionLabel() =>
|
||||
$"{_selectedItems.Count} {(_selectedItems.Count == 1 ? "Item" : "Items")} Selected";
|
||||
|
||||
protected void ClearSelection()
|
||||
{
|
||||
_selectedItems.Clear();
|
||||
_recentlySelected = None;
|
||||
}
|
||||
|
||||
protected virtual Task RefreshData() => Task.CompletedTask;
|
||||
|
||||
protected void SelectClicked(
|
||||
Func<List<MediaCardViewModel>> getSortedItems,
|
||||
MediaCardViewModel card,
|
||||
MouseEventArgs e)
|
||||
{
|
||||
if (_selectedItems.Contains(card))
|
||||
{
|
||||
_selectedItems.Remove(card);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (e.ShiftKey && _recentlySelected.IsSome)
|
||||
{
|
||||
List<MediaCardViewModel> sorted = getSortedItems();
|
||||
|
||||
int start = sorted.IndexOf(_recentlySelected.ValueUnsafe());
|
||||
int finish = sorted.IndexOf(card);
|
||||
if (start > finish)
|
||||
{
|
||||
int temp = start;
|
||||
start = finish;
|
||||
finish = temp;
|
||||
}
|
||||
|
||||
for (int i = start; i < finish; i++)
|
||||
{
|
||||
_selectedItems.Add(sorted[i]);
|
||||
}
|
||||
}
|
||||
|
||||
_recentlySelected = card;
|
||||
_selectedItems.Add(card);
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task AddSelectionToCollection()
|
||||
{
|
||||
var parameters = new DialogParameters
|
||||
{ { "EntityType", _selectedItems.Count.ToString() }, { "EntityName", "selected items" } };
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
|
||||
|
||||
IDialogReference dialog = Dialog.Show<AddToCollectionDialog>("Add To Collection", parameters, options);
|
||||
DialogResult result = await dialog.Result;
|
||||
if (!result.Cancelled && result.Data is MediaCollectionViewModel collection)
|
||||
{
|
||||
var request = new AddItemsToCollection(
|
||||
collection.Id,
|
||||
_selectedItems.OfType<MovieCardViewModel>().Map(m => m.MovieId).ToList(),
|
||||
_selectedItems.OfType<TelevisionShowCardViewModel>().Map(s => s.TelevisionShowId).ToList());
|
||||
|
||||
Either<BaseError, Unit> addResult = await Mediator.Send(request);
|
||||
addResult.Match(
|
||||
Left: error =>
|
||||
{
|
||||
Snackbar.Add($"Unexpected error adding items to collection: {error.Value}");
|
||||
Logger.LogError("Unexpected error adding items to collection: {Error}", error.Value);
|
||||
},
|
||||
Right: _ =>
|
||||
{
|
||||
Snackbar.Add(
|
||||
$"Added {_selectedItems.Count} items to collection {collection.Name}",
|
||||
Severity.Success);
|
||||
ClearSelection();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task RemoveSelectionFromCollection(int collectionId)
|
||||
{
|
||||
var parameters = new DialogParameters
|
||||
{ { "EntityType", _selectedItems.Count.ToString() }, { "EntityName", "selected items" } };
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
|
||||
|
||||
IDialogReference dialog = Dialog.Show<RemoveFromCollectionDialog>(
|
||||
"Remove From Collection",
|
||||
parameters,
|
||||
options);
|
||||
DialogResult result = await dialog.Result;
|
||||
if (!result.Cancelled)
|
||||
{
|
||||
var itemIds = new List<int>();
|
||||
itemIds.AddRange(_selectedItems.OfType<MovieCardViewModel>().Map(m => m.MovieId));
|
||||
itemIds.AddRange(_selectedItems.OfType<TelevisionShowCardViewModel>().Map(s => s.TelevisionShowId));
|
||||
itemIds.AddRange(_selectedItems.OfType<TelevisionSeasonCardViewModel>().Map(s => s.TelevisionSeasonId));
|
||||
itemIds.AddRange(_selectedItems.OfType<TelevisionEpisodeCardViewModel>().Map(e => e.EpisodeId));
|
||||
|
||||
await Mediator.Send(
|
||||
new RemoveItemsFromCollection(collectionId)
|
||||
{
|
||||
MediaItemIds = itemIds
|
||||
});
|
||||
|
||||
await RefreshData();
|
||||
ClearSelection();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+64
-14
@@ -6,42 +6,80 @@
|
||||
@using Microsoft.AspNetCore.WebUtilities
|
||||
@using Microsoft.Extensions.Primitives
|
||||
@using Unit = LanguageExt.Unit
|
||||
@inherits MultiSelectBase<Search>
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IMediator Mediator
|
||||
@inject ILogger<Search> Logger
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService Dialog
|
||||
@inject ChannelWriter<IBackgroundServiceRequest> Channel
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
||||
<div class="mb-6" style="display: flex; flex-direction: row;">
|
||||
<MudText GutterBottom="true" Typo="Typo.h4">Search Results: "@_query"</MudText>
|
||||
<MudPaper Square="true" Style="display: flex; height: 64px; left: 240px; padding: 0; position: fixed; right: 0; z-index: 100;">
|
||||
<div style="display: flex; flex-direction: row; margin-bottom: auto; margin-top: auto; width: 100%" class="ml-6 mr-6">
|
||||
@if (IsSelectMode())
|
||||
{
|
||||
<MudText Typo="Typo.h6" Color="Color.Primary">@SelectionLabel()</MudText>
|
||||
<div style="margin-left: auto">
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Add"
|
||||
OnClick="@(_ => AddSelectionToCollection())">
|
||||
Add To Collection
|
||||
</MudButton>
|
||||
<MudButton Class="ml-3"
|
||||
Variant="Variant.Filled"
|
||||
Color="Color.Secondary"
|
||||
StartIcon="@Icons.Material.Filled.Check"
|
||||
OnClick="@(_ => ClearSelection())">
|
||||
Clear Selection
|
||||
</MudButton>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText>@_query</MudText>
|
||||
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#movies")">@_data.MovieCards.Count Movies</MudLink>
|
||||
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#shows")">@_data.ShowCards.Count Shows</MudLink>
|
||||
}
|
||||
</div>
|
||||
|
||||
</MudPaper>
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Style="margin-top: 96px">
|
||||
@if (_data?.MovieCards.Any() == true)
|
||||
{
|
||||
<MudText GutterBottom="true" Typo="Typo.h4">Movies</MudText>
|
||||
<MudText GutterBottom="true"
|
||||
Typo="Typo.h4"
|
||||
Style="scroll-margin-top: 160px"
|
||||
UserAttributes="@(new Dictionary<string, object> { { "id", "movies" } })">
|
||||
Movies
|
||||
</MudText>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
|
||||
@foreach (MovieCardViewModel card in _data.MovieCards)
|
||||
@foreach (MovieCardViewModel card in _data.MovieCards.OrderBy(m => m.SortTitle))
|
||||
{
|
||||
<MediaCard Data="@card"
|
||||
Link="@($"/media/movies/{card.MovieId}")"
|
||||
AddToCollectionClicked="@AddToCollection"/>
|
||||
AddToCollectionClicked="@AddToCollection"
|
||||
SelectClicked="@(e => SelectClicked(card, e))"
|
||||
IsSelected="@IsSelected(card)"
|
||||
IsSelectMode="@IsSelectMode()"/>
|
||||
}
|
||||
</MudContainer>
|
||||
}
|
||||
|
||||
@if (_data?.ShowCards.Any() == true)
|
||||
{
|
||||
<MudText GutterBottom="true" Typo="Typo.h4">Television Shows</MudText>
|
||||
<MudText GutterBottom="true"
|
||||
Typo="Typo.h4"
|
||||
Style="scroll-margin-top: 160px"
|
||||
UserAttributes="@(new Dictionary<string, object> { { "id", "shows" } })">
|
||||
Shows
|
||||
</MudText>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
|
||||
@foreach (TelevisionShowCardViewModel card in _data.ShowCards)
|
||||
@foreach (TelevisionShowCardViewModel card in _data.ShowCards.OrderBy(s => s.SortTitle))
|
||||
{
|
||||
<MediaCard Data="@card"
|
||||
Link="@($"/media/tv/shows/{card.TelevisionShowId}")"
|
||||
AddToCollectionClicked="@AddToCollection"/>
|
||||
AddToCollectionClicked="@AddToCollection"
|
||||
SelectClicked="@(e => SelectClicked(card, e))"
|
||||
IsSelected="@IsSelected(card)"
|
||||
IsSelectMode="@IsSelectMode()"/>
|
||||
}
|
||||
</MudContainer>
|
||||
}
|
||||
@@ -63,6 +101,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
|
||||
{
|
||||
List<MediaCardViewModel> GetSortedItems()
|
||||
{
|
||||
return _data.MovieCards.OrderBy(m => m.SortTitle)
|
||||
.Append<MediaCardViewModel>(_data.ShowCards.OrderBy(s => s.SortTitle))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
SelectClicked(GetSortedItems, card, e);
|
||||
}
|
||||
|
||||
private async Task AddToCollection(MediaCardViewModel card)
|
||||
{
|
||||
if (card is MovieCardViewModel movie)
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
@page "/media/tv/episodes/{EpisodeId:int}"
|
||||
@using ErsatzTV.Application.Television
|
||||
@using ErsatzTV.Application.Television.Queries
|
||||
@using ErsatzTV.Application.MediaCollections
|
||||
@using ErsatzTV.Application.MediaCollections.Commands
|
||||
@inject IMediator Mediator
|
||||
@inject IDialogService Dialog
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
||||
<MudContainer MaxWidth="MaxWidth.Large">
|
||||
<MudBreadcrumbs Items="_breadcrumbs" Class="mb-6"></MudBreadcrumbs>
|
||||
<MudCard Class="mb-6">
|
||||
<div style="display: flex; flex-direction: row;">
|
||||
@if (!string.IsNullOrWhiteSpace(_episode.Poster))
|
||||
{
|
||||
<MudPaper style="display: flex; flex-direction: column">
|
||||
<MudCardMedia Image="@($"/artwork/thumbnails/{_episode.Poster}")" Style="flex-grow: 1; height: 220px; width: 392px;"/>
|
||||
</MudPaper>
|
||||
}
|
||||
<MudCardContent Class="ml-3">
|
||||
<div style="display: flex; flex-direction: column; height: 100%">
|
||||
<MudText Typo="Typo.h4">@_episode.Title</MudText>
|
||||
<MudText Typo="Typo.subtitle1" Class="mb-6 mud-text-secondary">@_season.Plot</MudText>
|
||||
<MudText Style="flex-grow: 1">@_episode.Plot</MudText>
|
||||
<div class="mt-6">
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Add"
|
||||
OnClick="@AddToCollection">
|
||||
Add To Collection
|
||||
</MudButton>
|
||||
</div>
|
||||
</div>
|
||||
</MudCardContent>
|
||||
</div>
|
||||
</MudCard>
|
||||
</MudContainer>
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
|
||||
[Parameter]
|
||||
public int EpisodeId { get; set; }
|
||||
|
||||
private TelevisionEpisodeViewModel _episode;
|
||||
private TelevisionSeasonViewModel _season;
|
||||
|
||||
private List<BreadcrumbItem> _breadcrumbs;
|
||||
|
||||
protected override Task OnParametersSetAsync() => RefreshData();
|
||||
|
||||
private async Task RefreshData()
|
||||
{
|
||||
await Mediator.Send(new GetTelevisionEpisodeById(EpisodeId))
|
||||
.IfSomeAsync(vm => _episode = vm);
|
||||
|
||||
await Mediator.Send(new GetTelevisionSeasonById(_episode.SeasonId))
|
||||
.IfSomeAsync(vm => _season = vm);
|
||||
|
||||
_breadcrumbs = new List<BreadcrumbItem>
|
||||
{
|
||||
new("TV Shows", "/media/tv/shows"),
|
||||
new($"{_season.Title} ({_season.Year})", $"/media/tv/shows/{_season.ShowId}"),
|
||||
new(_season.Plot, $"/media/tv/seasons/{_episode.SeasonId}"),
|
||||
new($"Episode {_episode.Episode}", null, true)
|
||||
};
|
||||
}
|
||||
|
||||
private async Task AddToCollection()
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "episode" }, { "EntityName", _episode.Title } };
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
|
||||
|
||||
IDialogReference dialog = Dialog.Show<AddToCollectionDialog>("Add To Collection", parameters, options);
|
||||
DialogResult result = await dialog.Result;
|
||||
if (!result.Cancelled && result.Data is MediaCollectionViewModel collection)
|
||||
{
|
||||
await Mediator.Send(new AddEpisodeToCollection(collection.Id, EpisodeId));
|
||||
NavigationManager.NavigateTo($"/media/collections/{collection.Id}");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
@page "/media/tv/seasons/{SeasonId:int}"
|
||||
@using ErsatzTV.Extensions
|
||||
@using ErsatzTV.Application.Television
|
||||
@using ErsatzTV.Application.Television.Queries
|
||||
@using ErsatzTV.Application.MediaCards
|
||||
@@ -14,56 +15,74 @@
|
||||
@inject IDialogService Dialog
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject ChannelWriter<IBackgroundServiceRequest> Channel
|
||||
@inject IJSRuntime JsRuntime
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
||||
<MudContainer MaxWidth="MaxWidth.Large">
|
||||
<MudBreadcrumbs Items="_breadcrumbs" Class="mb-6"></MudBreadcrumbs>
|
||||
<MudContainer MaxWidth="MaxWidth.False" Style="padding: 0" Class="fanart-container">
|
||||
<div class="fanart-tint"></div>
|
||||
@if (!string.IsNullOrWhiteSpace(_season.FanArt))
|
||||
{
|
||||
<img src="@($"/artwork/fanart/{_season.FanArt}")" alt="fan art"/>
|
||||
}
|
||||
</MudContainer>
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Style="margin-top: 200px">
|
||||
<div style="display: flex; flex-direction: row;">
|
||||
@if (!string.IsNullOrWhiteSpace(_season.Poster))
|
||||
{
|
||||
<img class="mud-elevation-2 mr-6"
|
||||
style="border-radius: 4px; max-height: 440px"
|
||||
src="@($"/artwork/posters/{_season.Poster}")" alt="show poster"/>
|
||||
}
|
||||
<div style="display: flex; flex-direction: column; height: 100%">
|
||||
<MudLink Href="@($"/media/tv/shows/{_season.ShowId}")">
|
||||
<MudText Typo="Typo.h2" Class="media-item-title">@_season.Title</MudText>
|
||||
</MudLink>
|
||||
<MudText Typo="Typo.subtitle1" Class="media-item-subtitle mb-6 mud-text-secondary">@_season.Name</MudText>
|
||||
<div>
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Add"
|
||||
OnClick="@AddToCollection">
|
||||
Add To Collection
|
||||
</MudButton>
|
||||
<MudButton Class="ml-3"
|
||||
Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Schedule"
|
||||
OnClick="@AddToSchedule">
|
||||
Add To Schedule
|
||||
</MudButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MudContainer>
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
||||
@foreach (TelevisionEpisodeCardViewModel episode in _data.Cards)
|
||||
{
|
||||
<MudCard Class="mb-6">
|
||||
<div style="display: flex; flex-direction: row;">
|
||||
@if (!string.IsNullOrWhiteSpace(_season.Poster))
|
||||
<div id="@($"episode-{episode.EpisodeId}")" style="display: flex; flex-direction: row; scroll-margin-top: 85px">
|
||||
@if (!string.IsNullOrWhiteSpace(episode.Poster))
|
||||
{
|
||||
<MudPaper Style="flex-shrink: 0;">
|
||||
<MudCardMedia Image="@($"/artwork/posters/{_season.Poster}")" Style="height: 440px; width: 304px;"/>
|
||||
<MudPaper style="display: flex; flex-direction: column">
|
||||
<MudCardMedia Image="@($"/artwork/thumbnails/{episode.Poster}")" Style="flex-grow: 1; height: 220px; width: 392px;"/>
|
||||
</MudPaper>
|
||||
}
|
||||
<MudCardContent Class="mx-3 my-3">
|
||||
<MudCardContent Class="ml-3">
|
||||
<div style="display: flex; flex-direction: column; height: 100%">
|
||||
<MudText Typo="Typo.h3">@_season.Title</MudText>
|
||||
<MudText Typo="Typo.subtitle1" Class="mb-6 mud-text-secondary">@_season.Year</MudText>
|
||||
<MudText Style="flex-grow: 1">@_season.Plot</MudText>
|
||||
<MudText Typo="Typo.h4">@episode.Episode. @episode.Title</MudText>
|
||||
<MudText Style="flex-grow: 1">@episode.Plot</MudText>
|
||||
<div class="mt-6">
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Add"
|
||||
OnClick="@AddToCollection">
|
||||
OnClick="@(_ => AddEpisodeToCollection(episode))">
|
||||
Add To Collection
|
||||
</MudButton>
|
||||
<MudButton Class="ml-3"
|
||||
Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Schedule"
|
||||
OnClick="@AddToSchedule">
|
||||
Add To Schedule
|
||||
</MudButton>
|
||||
</div>
|
||||
</div>
|
||||
</MudCardContent>
|
||||
</div>
|
||||
</MudCard>
|
||||
</MudContainer>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="media-card-grid">
|
||||
@foreach (TelevisionEpisodeCardViewModel card in _data.Cards)
|
||||
{
|
||||
<MediaCard Data="@card"
|
||||
Placeholder="@card.Placeholder"
|
||||
Link="@($"/media/tv/episodes/{card.EpisodeId}")"
|
||||
AddToCollectionClicked="@AddEpisodeToCollection"
|
||||
ContainerClass="media-card-episode-container mx-2"
|
||||
CardClass="media-card-episode"
|
||||
ArtworkKind="@ArtworkKind.Thumbnail"/>
|
||||
}
|
||||
</MudContainer>
|
||||
}
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
@@ -78,7 +97,13 @@
|
||||
|
||||
private TelevisionEpisodeCardResultsViewModel _data;
|
||||
|
||||
private List<BreadcrumbItem> _breadcrumbs;
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
await NavigationManager.NavigateToFragmentAsync(JsRuntime);
|
||||
}
|
||||
}
|
||||
|
||||
protected override Task OnParametersSetAsync() => RefreshData();
|
||||
|
||||
@@ -88,18 +113,11 @@
|
||||
.IfSomeAsync(vm => _season = vm);
|
||||
|
||||
_data = await Mediator.Send(new GetTelevisionEpisodeCards(SeasonId, _pageNumber, _pageSize));
|
||||
|
||||
_breadcrumbs = new List<BreadcrumbItem>
|
||||
{
|
||||
new("TV Shows", "/media/tv/shows"),
|
||||
new($"{_season.Title} ({_season.Year})", $"/media/tv/shows/{_season.ShowId}"),
|
||||
new(_season.Plot, null, true)
|
||||
};
|
||||
}
|
||||
|
||||
private async Task AddToCollection()
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "season" }, { "EntityName", $"{_season.Title} - {_season.Plot}" } };
|
||||
var parameters = new DialogParameters { { "EntityType", "season" }, { "EntityName", $"{_season.Title} - {_season.Name}" } };
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
|
||||
|
||||
IDialogReference dialog = Dialog.Show<AddToCollectionDialog>("Add To Collection", parameters, options);
|
||||
@@ -114,7 +132,7 @@
|
||||
|
||||
private async Task AddToSchedule()
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "season" }, { "EntityName", $"{_season.Title} - {_season.Plot}" } };
|
||||
var parameters = new DialogParameters { { "EntityType", "season" }, { "EntityName", $"{_season.Title} - {_season.Name}" } };
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
|
||||
|
||||
IDialogReference dialog = Dialog.Show<AddToScheduleDialog>("Add To Schedule", parameters, options);
|
||||
@@ -126,27 +144,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddEpisodeToCollection(MediaCardViewModel card)
|
||||
private async Task AddEpisodeToCollection(TelevisionEpisodeCardViewModel episode)
|
||||
{
|
||||
if (card is TelevisionEpisodeCardViewModel episode)
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "episode" }, { "EntityName", episode.Title } };
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
|
||||
var parameters = new DialogParameters { { "EntityType", "episode" }, { "EntityName", episode.Title } };
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
|
||||
|
||||
IDialogReference dialog = Dialog.Show<AddToCollectionDialog>("Add To Collection", parameters, options);
|
||||
DialogResult result = await dialog.Result;
|
||||
if (!result.Cancelled && result.Data is MediaCollectionViewModel collection)
|
||||
{
|
||||
var request = new AddEpisodeToCollection(collection.Id, episode.EpisodeId);
|
||||
Either<BaseError, Unit> addResult = await Mediator.Send(request);
|
||||
addResult.Match(
|
||||
Left: error =>
|
||||
{
|
||||
Snackbar.Add($"Unexpected error adding episode to collection: {error.Value}");
|
||||
Logger.LogError("Unexpected error adding episode to collection: {Error}", error.Value);
|
||||
},
|
||||
Right: _ => Snackbar.Add($"Added {episode.Title} to collection {collection.Name}", Severity.Success));
|
||||
}
|
||||
IDialogReference dialog = Dialog.Show<AddToCollectionDialog>("Add To Collection", parameters, options);
|
||||
DialogResult result = await dialog.Result;
|
||||
if (!result.Cancelled && result.Data is MediaCollectionViewModel collection)
|
||||
{
|
||||
var request = new AddEpisodeToCollection(collection.Id, episode.EpisodeId);
|
||||
Either<BaseError, Unit> addResult = await Mediator.Send(request);
|
||||
addResult.Match(
|
||||
Left: error =>
|
||||
{
|
||||
Snackbar.Add($"Unexpected error adding episode to collection: {error.Value}");
|
||||
Logger.LogError("Unexpected error adding episode to collection: {Error}", error.Value);
|
||||
},
|
||||
Right: _ => Snackbar.Add($"Added {episode.Title} to collection {collection.Name}", Severity.Success));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,51 +15,77 @@
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject ChannelWriter<IBackgroundServiceRequest> Channel
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
||||
<MudContainer MaxWidth="MaxWidth.Large">
|
||||
<MudBreadcrumbs Items="_breadcrumbs" Class="mb-6"></MudBreadcrumbs>
|
||||
<MudCard Class="mb-6">
|
||||
<div style="display: flex; flex-direction: row;">
|
||||
@if (!string.IsNullOrWhiteSpace(_show.Poster))
|
||||
{
|
||||
<MudPaper Style="flex-shrink: 0;">
|
||||
<MudCardMedia Image="@($"/artwork/posters/{_show.Poster}")" Style="height: 440px; width: 304px;"/>
|
||||
</MudPaper>
|
||||
}
|
||||
<MudCardContent Class="mx-3 my-3">
|
||||
<div style="display: flex; flex-direction: column; height: 100%">
|
||||
<MudText Typo="Typo.h3">@_show.Title</MudText>
|
||||
<MudText Typo="Typo.subtitle1" Class="mb-6 mud-text-secondary">@_show.Year</MudText>
|
||||
<MudText Style="flex-grow: 1">@_show.Plot</MudText>
|
||||
<div>
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Add"
|
||||
OnClick="@AddToCollection">
|
||||
Add To Collection
|
||||
</MudButton>
|
||||
<MudButton Class="ml-3"
|
||||
Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Schedule"
|
||||
OnClick="@AddToSchedule">
|
||||
Add To Schedule
|
||||
</MudButton>
|
||||
</div>
|
||||
</div>
|
||||
</MudCardContent>
|
||||
</div>
|
||||
</MudCard>
|
||||
</MudContainer>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="media-card-grid">
|
||||
@foreach (TelevisionSeasonCardViewModel card in _data.Cards)
|
||||
<MudContainer MaxWidth="MaxWidth.False" Style="padding: 0" Class="fanart-container">
|
||||
<div class="fanart-tint"></div>
|
||||
@if (!string.IsNullOrWhiteSpace(_show.FanArt))
|
||||
{
|
||||
<img src="@($"/artwork/fanart/{_show.FanArt}")" alt="fan art"/>
|
||||
}
|
||||
</MudContainer>
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Style="margin-top: 200px">
|
||||
<div style="display: flex; flex-direction: row;" class="mb-6">
|
||||
@if (!string.IsNullOrWhiteSpace(_show.Poster))
|
||||
{
|
||||
<MediaCard Data="@card" Placeholder="@card.Placeholder"
|
||||
Link="@($"/media/tv/seasons/{card.TelevisionSeasonId}")"
|
||||
AddToCollectionClicked="@AddSeasonToCollection"/>
|
||||
<img class="mud-elevation-2 mr-6"
|
||||
style="border-radius: 4px; max-height: 440px"
|
||||
src="@($"/artwork/posters/{_show.Poster}")" alt="show poster"/>
|
||||
}
|
||||
</MudContainer>
|
||||
<div style="display: flex; flex-direction: column; height: 100%">
|
||||
<MudText Typo="Typo.h2" Class="media-item-title">@_show.Title</MudText>
|
||||
<MudText Typo="Typo.subtitle1" Class="media-item-subtitle mb-6 mud-text-secondary">@_show.Year</MudText>
|
||||
@if (!string.IsNullOrWhiteSpace(_show.Plot))
|
||||
{
|
||||
<MudCard Elevation="2" Class="mb-6">
|
||||
<MudCardContent Class="mx-3 my-3" Style="height: 100%">
|
||||
<MudText Style="flex-grow: 1">@_show.Plot</MudText>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
}
|
||||
<div>
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Add"
|
||||
OnClick="@AddToCollection">
|
||||
Add To Collection
|
||||
</MudButton>
|
||||
<MudButton Class="ml-3"
|
||||
Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Schedule"
|
||||
OnClick="@AddToSchedule">
|
||||
Add To Schedule
|
||||
</MudButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@if (_show.Genres.Any())
|
||||
{
|
||||
<MudText GutterBottom="true">Genres</MudText>
|
||||
<div class="mb-2">
|
||||
@foreach (string genre in _show.Genres.OrderBy(g => g))
|
||||
{
|
||||
<MudFab Color="Color.Info" Size="Size.Small" Label="@genre" Class="mr-2 mb-2" Link="@($"/search?query=genre%3a{genre}")"/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (_show.Tags.Any())
|
||||
{
|
||||
<MudText GutterBottom="true">Tags</MudText>
|
||||
<div>
|
||||
@foreach (string tag in _show.Tags.OrderBy(t => t))
|
||||
{
|
||||
<MudFab Color="Color.Info" Size="Size.Small" Label="@tag" Class="mr-2 mb-2" Link="@($"/search?query=tag%3a{tag}")"/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</MudContainer>
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="media-card-grid mt-8">
|
||||
@foreach (TelevisionSeasonCardViewModel card in _data.Cards)
|
||||
{
|
||||
<MediaCard Data="@card" Placeholder="@card.Placeholder"
|
||||
Link="@($"/media/tv/seasons/{card.TelevisionSeasonId}")"
|
||||
AddToCollectionClicked="@AddSeasonToCollection"/>
|
||||
}
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
@@ -74,8 +100,6 @@
|
||||
|
||||
private TelevisionSeasonCardResultsViewModel _data;
|
||||
|
||||
private List<BreadcrumbItem> _breadcrumbs;
|
||||
|
||||
protected override Task OnParametersSetAsync() => RefreshData();
|
||||
|
||||
private async Task RefreshData()
|
||||
@@ -84,12 +108,6 @@
|
||||
.IfSomeAsync(vm => _show = vm);
|
||||
|
||||
_data = await Mediator.Send(new GetTelevisionSeasonCards(ShowId, _pageNumber, _pageSize));
|
||||
|
||||
_breadcrumbs = new List<BreadcrumbItem>
|
||||
{
|
||||
new("TV Shows", "/media/tv/shows"),
|
||||
new($"{_show.Title} ({_show.Year})", null, true)
|
||||
};
|
||||
}
|
||||
|
||||
private async Task AddToCollection()
|
||||
|
||||
@@ -1,63 +1,102 @@
|
||||
@page "/media/tv/shows"
|
||||
@page "/media/tv/shows/page/{PageNumber:int}"
|
||||
@using ErsatzTV.Application.MediaCards
|
||||
@using ErsatzTV.Application.MediaCards.Queries
|
||||
@using ErsatzTV.Application.MediaCollections
|
||||
@using ErsatzTV.Application.MediaCollections.Commands
|
||||
@using Unit = LanguageExt.Unit
|
||||
@inject ILogger<TelevisionShowList> Logger
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IMediator Mediator
|
||||
@inject IDialogService Dialog
|
||||
@inherits MultiSelectBase<TelevisionShowList>
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject ChannelWriter<IBackgroundServiceRequest> Channel
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
||||
<MudContainer MaxWidth="MaxWidth.Small" Class="mb-6" Style="max-width: 300px">
|
||||
<MudPaper Style="align-items: center; display: flex; justify-content: center;">
|
||||
<MudIconButton Icon="@Icons.Material.Outlined.ChevronLeft"
|
||||
OnClick="@(() => PrevPage())"
|
||||
Disabled="@(_pageNumber <= 1)">
|
||||
</MudIconButton>
|
||||
<MudText Style="flex-grow: 1"
|
||||
Align="Align.Center">
|
||||
@Math.Min((_pageNumber - 1) * _pageSize + 1, _data.Count)-@Math.Min(_data.Count, _pageNumber * _pageSize) of @_data.Count
|
||||
</MudText>
|
||||
<MudIconButton Icon="@Icons.Material.Outlined.ChevronRight"
|
||||
OnClick="@(() => NextPage())" Disabled="@(_pageNumber * _pageSize >= _data.Count)">
|
||||
</MudIconButton>
|
||||
</MudPaper>
|
||||
</MudContainer>
|
||||
|
||||
<MudPaper Square="true" Style="display: flex; height: 64px; left: 240px; padding: 0; position: fixed; right: 0; z-index: 100;">
|
||||
<div style="display: flex; flex-direction: row; margin-bottom: auto; margin-top: auto; width: 100%" class="ml-6 mr-6">
|
||||
@if (IsSelectMode())
|
||||
{
|
||||
<MudText Typo="Typo.h6" Color="Color.Primary">@SelectionLabel()</MudText>
|
||||
<div style="margin-left: auto">
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Add"
|
||||
OnClick="@(_ => AddSelectionToCollection())">
|
||||
Add To Collection
|
||||
</MudButton>
|
||||
<MudButton Class="ml-3"
|
||||
Variant="Variant.Filled"
|
||||
Color="Color.Secondary"
|
||||
StartIcon="@Icons.Material.Filled.Check"
|
||||
OnClick="@(_ => ClearSelection())">
|
||||
Clear Selection
|
||||
</MudButton>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div style="margin-left: auto; margin-right: auto; max-width: 300px;">
|
||||
<MudPaper Style="align-items: center; display: flex; justify-content: center;">
|
||||
<MudIconButton Icon="@Icons.Material.Outlined.ChevronLeft"
|
||||
OnClick="@PrevPage"
|
||||
Disabled="@(PageNumber <= 1)">
|
||||
</MudIconButton>
|
||||
<MudText Style="flex-grow: 1"
|
||||
Align="Align.Center">
|
||||
@Math.Min((PageNumber - 1) * PageSize + 1, _data.Count)-@Math.Min(_data.Count, PageNumber * PageSize) of @_data.Count
|
||||
</MudText>
|
||||
<MudIconButton Icon="@Icons.Material.Outlined.ChevronRight"
|
||||
OnClick="@NextPage" Disabled="@(PageNumber * PageSize >= _data.Count)">
|
||||
</MudIconButton>
|
||||
</MudPaper>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</MudPaper>
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8" Style="margin-top: 64px">
|
||||
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
|
||||
@foreach (TelevisionShowCardViewModel card in _data.Cards)
|
||||
@foreach (TelevisionShowCardViewModel card in _data.Cards.OrderBy(s => s.SortTitle))
|
||||
{
|
||||
<MediaCard Data="@card"
|
||||
Link="@($"/media/tv/shows/{card.TelevisionShowId}")"
|
||||
AddToCollectionClicked="@AddToCollection"/>
|
||||
AddToCollectionClicked="@AddToCollection"
|
||||
SelectClicked="@(e => SelectClicked(card, e))"
|
||||
IsSelected="@IsSelected(card)"
|
||||
IsSelectMode="@IsSelectMode()"/>
|
||||
}
|
||||
</MudContainer>
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
private int _pageSize => 100;
|
||||
private int _pageNumber = 1;
|
||||
private static int PageSize => 100;
|
||||
|
||||
[Parameter]
|
||||
public int PageNumber { get; set; }
|
||||
|
||||
private TelevisionShowCardResultsViewModel _data;
|
||||
|
||||
protected override Task OnParametersSetAsync() => RefreshData();
|
||||
|
||||
private async Task RefreshData() =>
|
||||
_data = await Mediator.Send(new GetTelevisionShowCards(_pageNumber, _pageSize));
|
||||
|
||||
private async Task PrevPage()
|
||||
protected override Task OnParametersSetAsync()
|
||||
{
|
||||
_pageNumber -= 1;
|
||||
await RefreshData();
|
||||
if (PageNumber == 0)
|
||||
{
|
||||
PageNumber = 1;
|
||||
}
|
||||
|
||||
return RefreshData();
|
||||
}
|
||||
|
||||
private async Task NextPage()
|
||||
protected override async Task RefreshData() =>
|
||||
_data = await Mediator.Send(new GetTelevisionShowCards(PageNumber, PageSize));
|
||||
|
||||
private void PrevPage() => NavigationManager.NavigateTo($"/media/tv/shows/page/{PageNumber - 1}");
|
||||
|
||||
private void NextPage() => NavigationManager.NavigateTo($"/media/tv/shows/page/{PageNumber + 1}");
|
||||
|
||||
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
|
||||
{
|
||||
_pageNumber += 1;
|
||||
await RefreshData();
|
||||
List<MediaCardViewModel> GetSortedItems()
|
||||
{
|
||||
return _data.Cards.OrderBy(m => m.SortTitle).ToList<MediaCardViewModel>();
|
||||
}
|
||||
|
||||
SelectClicked(GetSortedItems, card, e);
|
||||
}
|
||||
|
||||
private async Task AddToCollection(MediaCardViewModel card)
|
||||
|
||||
@@ -17,7 +17,33 @@
|
||||
<link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet"/>
|
||||
<link href="css/site.css" rel="stylesheet"/>
|
||||
<link href="ErsatzTV.styles.css" rel="stylesheet"/>
|
||||
<link href="//code.jquery.com/ui/1.12.1/themes/base/jquery-ui.css" rel="stylesheet">
|
||||
<script src="https://code.jquery.com/jquery-1.12.4.js"></script>
|
||||
<script src="https://code.jquery.com/ui/1.12.1/jquery-ui.js"></script>
|
||||
@await Html.PartialAsync("../Shared/_Favicons")
|
||||
<script>
|
||||
function sortableCollection(collectionId) {
|
||||
$("#sortable-collection").sortable({
|
||||
update: function(event, ui) {
|
||||
const data = $(this).sortable('serialize');
|
||||
$.ajax({
|
||||
data: data,
|
||||
type: 'POST',
|
||||
url: `/media/collections/${collectionId}/items`
|
||||
});
|
||||
}
|
||||
});
|
||||
$("#sortable-collection").disableSelection();
|
||||
}
|
||||
|
||||
function disableSorting() {
|
||||
$("#sortable-collection").sortable("option", "disabled", true);
|
||||
}
|
||||
|
||||
function enableSorting() {
|
||||
$("#sortable-collection").sortable("option", "disabled", false);
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<component type="typeof(App)" render-mode="ServerPrerendered"/>
|
||||
@@ -35,5 +61,17 @@
|
||||
|
||||
<script src="_framework/blazor.server.js"></script>
|
||||
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
|
||||
<script type="text/javascript">
|
||||
window.blazorHelpers = {
|
||||
scrollToFragment: (elementId) => {
|
||||
const element = document.getElementById(elementId);
|
||||
if (element) {
|
||||
element.scrollIntoView({
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -56,7 +56,6 @@ namespace ErsatzTV.Services
|
||||
await ScanLocalMediaSources(cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
private async Task BuildPlayouts(CancellationToken cancellationToken)
|
||||
{
|
||||
using IServiceScope scope = _serviceScopeFactory.CreateScope();
|
||||
|
||||
@@ -10,10 +10,12 @@
|
||||
<MudLayout>
|
||||
<MudAppBar Elevation="1">
|
||||
<div style="min-width: 240px">
|
||||
<img src="/images/ersatztv.png" alt="ErsatzTV"/>
|
||||
<a href="/">
|
||||
<img src="/images/ersatztv.png" alt="ErsatzTV"/>
|
||||
</a>
|
||||
</div>
|
||||
<MudTextField T="string"
|
||||
@ref=" _textField"
|
||||
@ref="_textField"
|
||||
AdornmentIcon="@Icons.Material.Filled.Search"
|
||||
Adornment="Adornment.Start"
|
||||
Variant="Variant.Outlined"
|
||||
@@ -34,7 +36,6 @@
|
||||
</MudAppBar>
|
||||
<MudDrawer Open="true" Elevation="2" ClipMode="DrawerClipMode.Always">
|
||||
<MudNavMenu>
|
||||
<MudNavLink Href="/" Match="NavLinkMatch.All">Home</MudNavLink>
|
||||
<MudNavLink Href="/channels">Channels</MudNavLink>
|
||||
<MudNavLink Href="/ffmpeg">FFmpeg</MudNavLink>
|
||||
@* <MudNavGroup Title="Media Sources" Expanded="true"> *@
|
||||
@@ -66,32 +67,25 @@
|
||||
|
||||
private MudTextField<string> _textField;
|
||||
|
||||
private MudTheme _ersatzTvTheme
|
||||
private MudTheme _ersatzTvTheme => new()
|
||||
{
|
||||
get
|
||||
Palette = new Palette
|
||||
{
|
||||
var current = new MudTheme();
|
||||
|
||||
return new MudTheme
|
||||
{
|
||||
Palette = new Palette
|
||||
{
|
||||
ActionDefault = "rgba(255,255,255, 0.80)",
|
||||
Primary = "#009000",
|
||||
AppbarBackground = "#121212",
|
||||
Background = "#272727",
|
||||
DrawerBackground = "#1f1f1f",
|
||||
Surface = "#1f1f1f",
|
||||
DrawerText = "rgba(255,255,255, 0.80)",
|
||||
TextPrimary = "rgba(255,255,255, 0.80)",
|
||||
TextSecondary = "rgba(255,255,255, 0.80)",
|
||||
Info = "#00c0c0",
|
||||
Tertiary = "#00c000",
|
||||
White = Colors.Shades.White
|
||||
}
|
||||
};
|
||||
ActionDefault = "rgba(255,255,255, 0.80)",
|
||||
Primary = "#009000",
|
||||
Secondary = "#009090",
|
||||
AppbarBackground = "#121212",
|
||||
Background = "#272727",
|
||||
DrawerBackground = "#1f1f1f",
|
||||
Surface = "#1f1f1f",
|
||||
DrawerText = "rgba(255,255,255, 0.80)",
|
||||
TextPrimary = "rgba(255,255,255, 0.80)",
|
||||
TextSecondary = "rgba(255,255,255, 0.80)",
|
||||
Info = "#00c0c0",
|
||||
Tertiary = "#00c000",
|
||||
White = Colors.Shades.White
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private void OnSearchKeyDown(KeyboardEventArgs args)
|
||||
{
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
@using Unit = LanguageExt.Unit
|
||||
@inject IMediator Mediator
|
||||
|
||||
<div class="@((ContainerClass ?? "media-card-container mr-6") + " pb-3")">
|
||||
<div class="@((ContainerClass ?? "media-card-container mr-6") + " pb-3")" id="@($"item_{Data.MediaItemId}")">
|
||||
@if (!string.IsNullOrWhiteSpace(Link))
|
||||
{
|
||||
<div style="position: relative">
|
||||
<div class="@(IsSelected ? DeleteClicked.HasDelegate ? "media-card-selected-delete" : "media-card-selected" : "")"
|
||||
style="border-radius: 4px; position: relative;">
|
||||
<MudPaper Class="@($"media-card {CardClass}")" Style="@ArtworkForItem()">
|
||||
@if (string.IsNullOrWhiteSpace(Data.Poster))
|
||||
{
|
||||
@@ -14,19 +15,36 @@
|
||||
</MudText>
|
||||
}
|
||||
</MudPaper>
|
||||
@if (IsSelected)
|
||||
{
|
||||
<div style="display: flex; height: 48px; left: 0; position: absolute; top: 0; width: 48px;">
|
||||
<MudIcon Color="@SelectColor"
|
||||
Icon="@Icons.Material.Filled.CheckBox"
|
||||
Style="margin: auto"/>
|
||||
</div>
|
||||
}
|
||||
<div class="media-card-overlay" style="">
|
||||
<MudButton Link="@Link" Style="height: 100%; width: 100%">
|
||||
<MudButton Link="@(IsSelectMode ? null : Link)"
|
||||
Style="height: 100%; width: 100%"
|
||||
OnClick="@(e => IsSelectMode ? SelectClicked.InvokeAsync(e) : Task.CompletedTask)">
|
||||
</MudButton>
|
||||
@if (AddToCollectionClicked.HasDelegate)
|
||||
@if (SelectClicked.HasDelegate)
|
||||
{
|
||||
<MudIconButton Color="@SelectColor"
|
||||
Icon="@(IsSelected ? Icons.Material.Filled.CheckBox : Icons.Material.Filled.CheckBoxOutlineBlank)"
|
||||
Style="left: 0; position: absolute; top: 0;"
|
||||
OnClick="@(e => SelectClicked.InvokeAsync(e))"/>
|
||||
}
|
||||
@if (AddToCollectionClicked.HasDelegate && !IsSelectMode)
|
||||
{
|
||||
<MudIconButton Color="Color.Tertiary"
|
||||
Icon="@Icons.Material.Filled.PlaylistAdd"
|
||||
Style="bottom: 0; left: 0; position: absolute;"
|
||||
OnClick="@(() => AddToCollectionClicked.InvokeAsync(Data))"/>
|
||||
}
|
||||
@if (DeleteClicked.HasDelegate)
|
||||
@if (DeleteClicked.HasDelegate && !IsSelectMode)
|
||||
{
|
||||
<MudIconButton Color="Color.Tertiary"
|
||||
<MudIconButton Color="Color.Error"
|
||||
Icon="@Icons.Material.Filled.Delete"
|
||||
Style="bottom: 0; position: absolute; right: 0;"
|
||||
OnClick="@(() => DeleteClicked.InvokeAsync(Data))"/>
|
||||
@@ -88,6 +106,18 @@
|
||||
[Parameter]
|
||||
public EventCallback<MediaCardViewModel> AddToCollectionClicked { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<MouseEventArgs> SelectClicked { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool IsSelectMode { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool IsSelected { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public Color SelectColor { get; set; } = Color.Tertiary;
|
||||
|
||||
private string GetPlaceholder(string sortTitle)
|
||||
{
|
||||
if (Placeholder != null)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ErsatzTV.ViewModels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.ViewModels;
|
||||
using FluentValidation;
|
||||
|
||||
namespace ErsatzTV.Validators
|
||||
@@ -7,7 +8,7 @@ namespace ErsatzTV.Validators
|
||||
{
|
||||
public ChannelEditViewModelValidator()
|
||||
{
|
||||
RuleFor(x => x.Number).Matches(@"^[0-9]+(\.[0-9])?$")
|
||||
RuleFor(x => x.Number).Matches(Channel.NumberValidator)
|
||||
.WithMessage("Invalid channel number; one decimal is allowed for subchannels");
|
||||
|
||||
RuleFor(x => x.Name).NotEmpty();
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
.mud-breadcrumb-separator > span { color: inherit !important; }
|
||||
|
||||
.media-card-grid {
|
||||
.media-card-grid {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
@@ -20,6 +18,10 @@
|
||||
width: 152px;
|
||||
}
|
||||
|
||||
.media-card-selected { box-shadow: 0 0 0 3px #00c000, 0 0 4px rgba(0, 0, 0, 0.3); }
|
||||
|
||||
.media-card-selected-delete { box-shadow: 0 0 0 3px #f44336, 0 0 4px rgba(0, 0, 0, 0.3); }
|
||||
|
||||
.media-card-episode { width: 392px; }
|
||||
|
||||
.media-card:hover { /*filter: brightness(75%);*/ }
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 217 KiB After Width: | Height: | Size: 220 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 435 KiB |
Reference in New Issue
Block a user