Compare commits

...
Author SHA1 Message Date
Jason DoveandGitHub 633586ddba add music videos library (#125)
* add music videos library

* add music video tables

* first pass at music video library scan

* support music videos in playouts

* display music videos in search results and collections

* fix music video thumbnails

* remove some obsolete fields
2021-04-02 18:28:45 -05:00
Jason DoveandGitHub da3e05b231 normalize video track timescale (#123) 2021-03-31 23:36:20 +00:00
Jason DoveandGitHub 9e6de7e2eb use proper type for plex timestamps (#124) 2021-03-31 21:30:30 +00:00
Jason DoveandGitHub 4097288fed normalize framerate (#122)
* normalize framerate

* simplify audio normalization settings
2021-03-31 09:34:52 +00:00
Jason DoveandGitHub 90f775aab4 ffmpeg tweaks (#121)
* save reports from ffmpeg concat process

* let ffmpeg determine thread count by default

* disable stdin for ffmpeg processes
2021-03-31 01:08:57 +00:00
Jason DoveandGitHub fc33c5cd05 add show title to playout details (#120) 2021-03-30 21:23:02 +00:00
Jason DoveandGitHub 37eee73ab7 clear search query when clicking nav links (#119) 2021-03-30 21:10:56 +00:00
Jason DoveandGitHub e7ebb32a1d navigate to schedule items after creating new schedule (#118) 2021-03-30 11:15:31 +00:00
Jason DoveandGitHub 9ea4459988 cache artwork async (#117) 2021-03-30 11:09:47 +00:00
Jason DoveandGitHub 745b03af73 add custom title option to schedule items (#116) 2021-03-29 21:46:03 +00:00
Jason DoveandGitHub a62c4ecfcf fix playout builds using duration or multiple (#115) 2021-03-29 20:01:46 +00:00
Jason DoveandGitHub c48f0a7d51 don't require preferred language on channels (#114) 2021-03-29 14:43:09 +00:00
Jason DoveandGitHub f2c105174b fix stream selection for non-normalized playback (#113) 2021-03-29 14:42:20 +00:00
116 changed files with 17537 additions and 387 deletions
+3
View File
@@ -40,3 +40,6 @@ msbuild.wrn
core
scripts/generate-api-sdk/swagger.json
docker-compose.override.yml
@@ -73,13 +73,12 @@ namespace ErsatzTV.Application.Channels.Commands
.Bind(_ => createChannel.NotLongerThan(50)(c => c.Name));
private Validation<BaseError, string> ValidatePreferredLanguage(CreateChannel createChannel) =>
Optional(createChannel.PreferredLanguageCode)
Optional(createChannel.PreferredLanguageCode ?? string.Empty)
.Filter(
lc => string.IsNullOrWhiteSpace(lc) || CultureInfo.GetCultures(CultureTypes.NeutralCultures).Any(
ci => string.Equals(ci.ThreeLetterISOLanguageName, lc, StringComparison.OrdinalIgnoreCase)))
.ToValidation<BaseError>("Preferred language code is invalid");
private async Task<Validation<BaseError, string>> ValidateNumber(CreateChannel createChannel)
{
Option<Channel> maybeExistingChannel = await _channelRepository.GetByNumber(createChannel.Number);
@@ -97,7 +97,7 @@ namespace ErsatzTV.Application.Channels.Commands
}
private Validation<BaseError, string> ValidatePreferredLanguage(UpdateChannel updateChannel) =>
Optional(updateChannel.PreferredLanguageCode)
Optional(updateChannel.PreferredLanguageCode ?? string.Empty)
.Filter(
lc => string.IsNullOrWhiteSpace(lc) || CultureInfo.GetCultures(CultureTypes.NeutralCultures).Any(
ci => string.Equals(ci.ThreeLetterISOLanguageName, lc, StringComparison.OrdinalIgnoreCase)))
@@ -11,17 +11,16 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
bool Transcode,
HardwareAccelerationKind HardwareAcceleration,
int ResolutionId,
bool NormalizeResolution,
bool NormalizeVideo,
string VideoCodec,
bool NormalizeVideoCodec,
int VideoBitrate,
int VideoBufferSize,
string AudioCodec,
bool NormalizeAudioCodec,
int AudioBitrate,
int AudioBufferSize,
int AudioVolume,
int AudioChannels,
int AudioSampleRate,
bool NormalizeAudio) : IRequest<Either<BaseError, FFmpegProfileViewModel>>;
bool NormalizeAudio,
string FrameRate) : IRequest<Either<BaseError, FFmpegProfileViewModel>>;
}
@@ -43,19 +43,18 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
Transcode = request.Transcode,
HardwareAcceleration = request.HardwareAcceleration,
ResolutionId = resolutionId,
NormalizeResolution = request.NormalizeResolution,
NormalizeVideo = request.NormalizeVideo,
VideoCodec = request.VideoCodec,
NormalizeVideoCodec = request.NormalizeVideoCodec,
VideoBitrate = request.VideoBitrate,
VideoBufferSize = request.VideoBufferSize,
AudioCodec = request.AudioCodec,
NormalizeAudioCodec = request.NormalizeAudioCodec,
AudioBitrate = request.AudioBitrate,
AudioBufferSize = request.AudioBufferSize,
AudioVolume = request.AudioVolume,
AudioChannels = request.AudioChannels,
AudioSampleRate = request.AudioSampleRate,
NormalizeAudio = request.NormalizeAudio
NormalizeAudio = request.NormalizeAudio,
FrameRate = request.FrameRate
});
private Validation<BaseError, string> ValidateName(CreateFFmpegProfile createFFmpegProfile) =>
@@ -63,7 +62,7 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
.Bind(_ => createFFmpegProfile.NotLongerThan(50)(x => x.Name));
private Validation<BaseError, int> ValidateThreadCount(CreateFFmpegProfile createFFmpegProfile) =>
createFFmpegProfile.AtLeast(1)(p => p.ThreadCount);
createFFmpegProfile.AtLeast(0)(p => p.ThreadCount);
private async Task<Validation<BaseError, int>> ResolutionMustExist(CreateFFmpegProfile createFFmpegProfile) =>
(await _resolutionRepository.Get(createFFmpegProfile.ResolutionId))
@@ -12,17 +12,16 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
bool Transcode,
HardwareAccelerationKind HardwareAcceleration,
int ResolutionId,
bool NormalizeResolution,
bool NormalizeVideo,
string VideoCodec,
bool NormalizeVideoCodec,
int VideoBitrate,
int VideoBufferSize,
string AudioCodec,
bool NormalizeAudioCodec,
int AudioBitrate,
int AudioBufferSize,
int AudioVolume,
int AudioChannels,
int AudioSampleRate,
bool NormalizeAudio) : IRequest<Either<BaseError, FFmpegProfileViewModel>>;
bool NormalizeAudio,
string FrameRate) : IRequest<Either<BaseError, FFmpegProfileViewModel>>;
}
@@ -37,19 +37,18 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
p.Transcode = update.Transcode;
p.HardwareAcceleration = update.HardwareAcceleration;
p.ResolutionId = update.ResolutionId;
p.NormalizeResolution = update.NormalizeResolution;
p.NormalizeVideo = update.NormalizeVideo;
p.VideoCodec = update.VideoCodec;
p.NormalizeVideoCodec = update.NormalizeVideoCodec;
p.VideoBitrate = update.VideoBitrate;
p.VideoBufferSize = update.VideoBufferSize;
p.AudioCodec = update.AudioCodec;
p.NormalizeAudioCodec = update.NormalizeAudioCodec;
p.AudioBitrate = update.AudioBitrate;
p.AudioBufferSize = update.AudioBufferSize;
p.AudioVolume = update.AudioVolume;
p.AudioChannels = update.AudioChannels;
p.AudioSampleRate = update.AudioSampleRate;
p.NormalizeAudio = update.NormalizeAudio;
p.FrameRate = update.FrameRate;
await _ffmpegProfileRepository.Update(p);
return ProjectToViewModel(p);
}
@@ -69,7 +68,7 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
.Bind(_ => updateFFmpegProfile.NotLongerThan(50)(x => x.Name));
private Validation<BaseError, int> ValidateThreadCount(UpdateFFmpegProfile updateFFmpegProfile) =>
updateFFmpegProfile.AtLeast(1)(p => p.ThreadCount);
updateFFmpegProfile.AtLeast(0)(p => p.ThreadCount);
private async Task<Validation<BaseError, int>> ResolutionMustExist(UpdateFFmpegProfile updateFFmpegProfile) =>
(await _resolutionRepository.Get(updateFFmpegProfile.ResolutionId))
@@ -10,17 +10,16 @@ namespace ErsatzTV.Application.FFmpegProfiles
bool Transcode,
HardwareAccelerationKind HardwareAcceleration,
ResolutionViewModel Resolution,
bool NormalizeResolution,
bool NormalizeVideo,
string VideoCodec,
bool NormalizeVideoCodec,
int VideoBitrate,
int VideoBufferSize,
string AudioCodec,
bool NormalizeAudioCodec,
int AudioBitrate,
int AudioBufferSize,
int AudioVolume,
int AudioChannels,
int AudioSampleRate,
bool NormalizeAudio);
bool NormalizeAudio,
string FrameRate);
}
@@ -13,19 +13,18 @@ namespace ErsatzTV.Application.FFmpegProfiles
profile.Transcode,
profile.HardwareAcceleration,
Project(profile.Resolution),
profile.NormalizeResolution,
profile.NormalizeVideo,
profile.VideoCodec,
profile.NormalizeVideoCodec,
profile.VideoBitrate,
profile.VideoBufferSize,
profile.AudioCodec,
profile.NormalizeAudioCodec,
profile.AudioBitrate,
profile.AudioBufferSize,
profile.AudioVolume,
profile.AudioChannels,
profile.AudioSampleRate,
profile.NormalizeAudio);
profile.NormalizeAudio,
profile.FrameRate);
private static ResolutionViewModel Project(Resolution resolution) =>
new(resolution.Id, resolution.Name, resolution.Width, resolution.Height);
@@ -18,7 +18,11 @@ namespace ErsatzTV.Application.Libraries.Queries
public Task<List<LibraryViewModel>> Handle(GetAllLibraries request, CancellationToken cancellationToken) =>
_libraryRepository.GetAll()
.Map(list => list.Filter(ShouldIncludeLibrary).Map(ProjectToViewModel).ToList());
.Map(
list => list.Filter(ShouldIncludeLibrary)
.OrderBy(l => l.MediaSource is LocalMediaSource ? 0 : 1)
.ThenBy(l => l.MediaKind)
.Map(ProjectToViewModel).ToList());
private static bool ShouldIncludeLibrary(Library library) =>
library switch
@@ -7,7 +7,8 @@ namespace ErsatzTV.Application.MediaCards
List<MovieCardViewModel> MovieCards,
List<TelevisionShowCardViewModel> ShowCards,
List<TelevisionSeasonCardViewModel> SeasonCards,
List<TelevisionEpisodeCardViewModel> EpisodeCards)
List<TelevisionEpisodeCardViewModel> EpisodeCards,
List<MusicVideoCardViewModel> MusicVideoCards)
{
public bool UseCustomPlaybackOrder { get; set; }
}
+10
View File
@@ -52,6 +52,14 @@ namespace ErsatzTV.Application.MediaCards
movieMetadata.SortTitle,
GetPoster(movieMetadata));
internal static MusicVideoCardViewModel ProjectToViewModel(MusicVideoMetadata musicVideoMetadata) =>
new(
musicVideoMetadata.MusicVideoId,
$"{musicVideoMetadata.Title} ({musicVideoMetadata.Artist})",
musicVideoMetadata.Year?.ToString(),
musicVideoMetadata.SortTitle,
GetThumbnail(musicVideoMetadata));
internal static CollectionCardResultsViewModel
ProjectToViewModel(Collection collection) =>
new(
@@ -64,6 +72,8 @@ namespace ErsatzTV.Application.MediaCards
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(),
collection.MediaItems.OfType<MusicVideo>().Map(mv => ProjectToViewModel(mv.MusicVideoMetadata.Head()))
.ToList()) { UseCustomPlaybackOrder = collection.UseCustomPlaybackOrder };
private static int GetCustomIndex(Collection collection, int mediaItemId) =>
@@ -0,0 +1,11 @@
using System.Collections.Generic;
using ErsatzTV.Core.Search;
using LanguageExt;
namespace ErsatzTV.Application.MediaCards
{
public record MusicVideoCardResultsViewModel(
int Count,
List<MusicVideoCardViewModel> Cards,
Option<SearchPageMap> PageMap);
}
@@ -0,0 +1,13 @@
namespace ErsatzTV.Application.MediaCards
{
public record MusicVideoCardViewModel
(int MusicVideoId, string Title, string Subtitle, string SortTitle, string Poster) : MediaCardViewModel(
MusicVideoId,
Title,
Subtitle,
SortTitle,
Poster)
{
public int CustomIndex { get; set; }
}
}
@@ -5,5 +5,9 @@ using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record AddItemsToCollection
(int CollectionId, List<int> MovieIds, List<int> ShowIds) : MediatR.IRequest<Either<BaseError, Unit>>;
(
int CollectionId,
List<int> MovieIds,
List<int> ShowIds,
List<int> MusicVideoIds) : MediatR.IRequest<Either<BaseError, Unit>>;
}
@@ -41,7 +41,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
{
if (await _mediaCollectionRepository.AddMediaItems(
request.CollectionId,
request.MovieIds.Append(request.ShowIds).ToList()))
request.MovieIds.Append(request.ShowIds).Append(request.MusicVideoIds).ToList()))
{
// rebuild all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
@@ -0,0 +1,8 @@
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record AddMusicVideoToCollection
(int CollectionId, int MusicVideoId) : MediatR.IRequest<Either<BaseError, Unit>>;
}
@@ -0,0 +1,68 @@
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
AddMusicVideoToCollectionHandler : MediatR.IRequestHandler<AddMusicVideoToCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly IMusicVideoRepository _musicVideoRepository;
public AddMusicVideoToCollectionHandler(
IMediaCollectionRepository mediaCollectionRepository,
IMusicVideoRepository musicVideoRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
{
_mediaCollectionRepository = mediaCollectionRepository;
_musicVideoRepository = musicVideoRepository;
_channel = channel;
}
public Task<Either<BaseError, Unit>> Handle(
AddMusicVideoToCollection request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(_ => ApplyAddMusicVideoRequest(request))
.Bind(v => v.ToEitherAsync());
private async Task<Unit> ApplyAddMusicVideoRequest(AddMusicVideoToCollection request)
{
if (await _mediaCollectionRepository.AddMediaItem(request.CollectionId, request.MusicVideoId))
{
// 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(AddMusicVideoToCollection request) =>
(await CollectionMustExist(request), await ValidateMusicVideo(request))
.Apply((_, _) => Unit.Default);
private Task<Validation<BaseError, Unit>> CollectionMustExist(AddMusicVideoToCollection request) =>
_mediaCollectionRepository.GetCollectionWithItems(request.CollectionId)
.MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Collection does not exist."));
private Task<Validation<BaseError, Unit>> ValidateMusicVideo(AddMusicVideoToCollection request) =>
LoadMusicVideo(request)
.MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Music video does not exist"));
private Task<Option<MusicVideo>> LoadMusicVideo(AddMusicVideoToCollection request) =>
_musicVideoRepository.GetMusicVideo(request.MusicVideoId);
}
}
+1 -55
View File
@@ -1,5 +1,4 @@
using System;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaItems
{
@@ -8,59 +7,6 @@ namespace ErsatzTV.Application.MediaItems
internal static MediaItemViewModel ProjectToViewModel(MediaItem mediaItem) =>
new(mediaItem.Id, mediaItem.LibraryPathId);
internal static MediaItemSearchResultViewModel ProjectToSearchViewModel(MediaItem mediaItem) =>
mediaItem switch
{
Episode e => ProjectToSearchViewModel(e),
Movie m => ProjectToSearchViewModel(m),
_ => throw new ArgumentOutOfRangeException()
};
private static MediaItemSearchResultViewModel ProjectToSearchViewModel(Episode mediaItem) =>
new(
mediaItem.Id,
GetLibraryName(mediaItem),
"TV Show",
GetDisplayTitle(mediaItem),
GetDisplayDuration(mediaItem));
private static MediaItemSearchResultViewModel ProjectToSearchViewModel(Movie mediaItem) =>
new(
mediaItem.Id,
GetLibraryName(mediaItem),
"Movie",
GetDisplayTitle(mediaItem),
GetDisplayDuration(mediaItem));
private static string GetDisplayTitle(MediaItem mediaItem) =>
mediaItem switch
{
Episode e => e.EpisodeMetadata.HeadOrNone()
.Map(em => $"{em.Title} - s{e.Season.SeasonNumber:00}e{e.EpisodeNumber:00}")
.IfNone("[unknown episode]"),
Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Title).IfNone("[unknown movie]"),
_ => string.Empty
};
private static string GetDisplayDuration(MediaItem mediaItem)
{
MediaVersion version = mediaItem switch
{
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
};
return string.Format(
version.Duration.TotalHours >= 1 ? @"{0:h\:mm\:ss}" : @"{0:mm\:ss}",
version.Duration);
}
// TODO: fix this when search is reimplemented
private static string GetLibraryName(MediaItem item) =>
"Library Name";
public static NamedMediaItemViewModel ProjectToViewModel(Show show) =>
new(show.Id, show.ShowMetadata.HeadOrNone().Map(sm => $"{sm?.Title} ({sm?.Year})").IfNone("???"));
@@ -8,15 +8,24 @@ namespace ErsatzTV.Application.MediaSources.Commands
{
int LibraryId { get; }
bool ForceScan { get; }
bool Rescan { get; }
}
public record ScanLocalLibraryIfNeeded(int LibraryId) : IScanLocalLibrary
{
public bool ForceScan => false;
public bool Rescan => false;
}
public record ForceScanLocalLibrary(int LibraryId) : IScanLocalLibrary
{
public bool ForceScan => true;
public bool Rescan => false;
}
public record ForceRescanLocalLibrary(int LibraryId) : IScanLocalLibrary
{
public bool ForceScan => true;
public bool Rescan => true;
}
}
@@ -16,13 +16,15 @@ using Unit = LanguageExt.Unit;
namespace ErsatzTV.Application.MediaSources.Commands
{
public class ScanLocalLibraryHandler : IRequestHandler<ForceScanLocalLibrary, Either<BaseError, string>>,
IRequestHandler<ScanLocalLibraryIfNeeded, Either<BaseError, string>>
IRequestHandler<ScanLocalLibraryIfNeeded, Either<BaseError, string>>,
IRequestHandler<ForceRescanLocalLibrary, Either<BaseError, string>>
{
private readonly IConfigElementRepository _configElementRepository;
private readonly IEntityLocker _entityLocker;
private readonly ILibraryRepository _libraryRepository;
private readonly ILogger<ScanLocalLibraryHandler> _logger;
private readonly IMovieFolderScanner _movieFolderScanner;
private readonly IMusicVideoFolderScanner _musicVideoFolderScanner;
private readonly ITelevisionFolderScanner _televisionFolderScanner;
public ScanLocalLibraryHandler(
@@ -30,6 +32,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
IConfigElementRepository configElementRepository,
IMovieFolderScanner movieFolderScanner,
ITelevisionFolderScanner televisionFolderScanner,
IMusicVideoFolderScanner musicVideoFolderScanner,
IEntityLocker entityLocker,
ILogger<ScanLocalLibraryHandler> logger)
{
@@ -37,10 +40,15 @@ namespace ErsatzTV.Application.MediaSources.Commands
_configElementRepository = configElementRepository;
_movieFolderScanner = movieFolderScanner;
_televisionFolderScanner = televisionFolderScanner;
_musicVideoFolderScanner = musicVideoFolderScanner;
_entityLocker = entityLocker;
_logger = logger;
}
public Task<Either<BaseError, string>> Handle(
ForceRescanLocalLibrary request,
CancellationToken cancellationToken) => Handle(request);
public Task<Either<BaseError, string>> Handle(
ForceScanLocalLibrary request,
CancellationToken cancellationToken) => Handle(request);
@@ -57,7 +65,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
private async Task<Unit> PerformScan(RequestParameters parameters)
{
(LocalLibrary localLibrary, string ffprobePath, bool forceScan) = parameters;
(LocalLibrary localLibrary, string ffprobePath, bool forceScan, bool rescan) = parameters;
var lastScan = new DateTimeOffset(localLibrary.LastScan ?? DateTime.MinValue, TimeSpan.Zero);
if (forceScan || lastScan < DateTimeOffset.Now - TimeSpan.FromHours(6))
@@ -65,15 +73,20 @@ namespace ErsatzTV.Application.MediaSources.Commands
var sw = new Stopwatch();
sw.Start();
DateTimeOffset effectiveLastScan = rescan ? DateTimeOffset.MinValue : lastScan;
foreach (LibraryPath libraryPath in localLibrary.Paths)
{
switch (localLibrary.MediaKind)
{
case LibraryMediaKind.Movies:
await _movieFolderScanner.ScanFolder(libraryPath, ffprobePath, lastScan);
await _movieFolderScanner.ScanFolder(libraryPath, ffprobePath, effectiveLastScan);
break;
case LibraryMediaKind.Shows:
await _televisionFolderScanner.ScanFolder(libraryPath, ffprobePath, lastScan);
await _televisionFolderScanner.ScanFolder(libraryPath, ffprobePath, effectiveLastScan);
break;
case LibraryMediaKind.MusicVideos:
await _musicVideoFolderScanner.ScanFolder(libraryPath, ffprobePath, effectiveLastScan);
break;
}
}
@@ -104,7 +117,8 @@ namespace ErsatzTV.Application.MediaSources.Commands
(library, ffprobePath) => new RequestParameters(
library,
ffprobePath,
request.ForceScan));
request.ForceScan,
request.Rescan));
private Task<Validation<BaseError, LocalLibrary>> LocalLibraryMustExist(
IScanLocalLibrary request) =>
@@ -119,6 +133,6 @@ namespace ErsatzTV.Application.MediaSources.Commands
ffprobePath =>
ffprobePath.ToValidation<BaseError>("FFprobe path does not exist on the file system"));
private record RequestParameters(LocalLibrary LocalLibrary, string FFprobePath, bool ForceScan);
private record RequestParameters(LocalLibrary LocalLibrary, string FFprobePath, bool ForceScan, bool Rescan);
}
}
+19 -8
View File
@@ -24,15 +24,25 @@ namespace ErsatzTV.Application.Playouts
private static PlayoutProgramScheduleViewModel Project(ProgramSchedule programSchedule) =>
new(programSchedule.Id, programSchedule.Name);
private static string GetDisplayTitle(MediaItem mediaItem) =>
mediaItem switch
private static string GetDisplayTitle(MediaItem mediaItem)
{
switch (mediaItem)
{
Episode e => e.EpisodeMetadata.HeadOrNone()
.Map(em => $"{em.Title} - s{e.Season.SeasonNumber:00}e{e.EpisodeNumber:00}")
.IfNone("[unknown episode]"),
Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Title).IfNone("[unknown movie]"),
_ => string.Empty
};
case Episode e:
string showTitle = e.Season.Show.ShowMetadata.HeadOrNone()
.Map(sm => $"{sm.Title} - ").IfNone(string.Empty);
return e.EpisodeMetadata.HeadOrNone()
.Map(em => $"{showTitle}s{e.Season.SeasonNumber:00}e{e.EpisodeNumber:00} - {em.Title}")
.IfNone("[unknown episode]");
case Movie m:
return m.MovieMetadata.HeadOrNone().Map(mm => mm.Title).IfNone("[unknown movie]");
case MusicVideo mv:
return mv.MusicVideoMetadata.HeadOrNone().Map(mvm => $"{mvm.Artist} - {mvm.Title}")
.IfNone("[unknown music video]");
default:
return string.Empty;
}
}
private static string GetDisplayDuration(MediaItem mediaItem)
{
@@ -40,6 +50,7 @@ namespace ErsatzTV.Application.Playouts
{
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
MusicVideo mv => mv.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
};
@@ -16,5 +16,6 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
int? MediaItemId,
int? MultipleCount,
TimeSpan? PlayoutDuration,
bool? OfflineTail) : IRequest<Either<BaseError, ProgramScheduleItemViewModel>>, IProgramScheduleItemRequest;
bool? OfflineTail,
string CustomTitle) : IRequest<Either<BaseError, ProgramScheduleItemViewModel>>, IProgramScheduleItemRequest;
}
@@ -13,5 +13,6 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
int? MultipleCount { get; }
TimeSpan? PlayoutDuration { get; }
bool? OfflineTail { get; }
string CustomTitle { get; }
}
}
@@ -100,7 +100,8 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
StartTime = item.StartTime,
CollectionType = item.CollectionType,
CollectionId = item.CollectionId,
MediaItemId = item.MediaItemId
MediaItemId = item.MediaItemId,
CustomTitle = item.CustomTitle
},
PlayoutMode.One => new ProgramScheduleItemOne
{
@@ -109,7 +110,8 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
StartTime = item.StartTime,
CollectionType = item.CollectionType,
CollectionId = item.CollectionId,
MediaItemId = item.MediaItemId
MediaItemId = item.MediaItemId,
CustomTitle = item.CustomTitle
},
PlayoutMode.Multiple => new ProgramScheduleItemMultiple
{
@@ -119,7 +121,8 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
CollectionType = item.CollectionType,
CollectionId = item.CollectionId,
MediaItemId = item.MediaItemId,
Count = item.MultipleCount.GetValueOrDefault()
Count = item.MultipleCount.GetValueOrDefault(),
CustomTitle = item.CustomTitle
},
PlayoutMode.Duration => new ProgramScheduleItemDuration
{
@@ -130,7 +133,8 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
CollectionId = item.CollectionId,
MediaItemId = item.MediaItemId,
PlayoutDuration = item.PlayoutDuration.GetValueOrDefault(),
OfflineTail = item.OfflineTail.GetValueOrDefault()
OfflineTail = item.OfflineTail.GetValueOrDefault(),
CustomTitle = item.CustomTitle
},
_ => throw new NotSupportedException($"Unsupported playout mode {item.PlayoutMode}")
};
@@ -17,7 +17,8 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
int? MediaItemId,
int? MultipleCount,
TimeSpan? PlayoutDuration,
bool? OfflineTail) : IProgramScheduleItemRequest;
bool? OfflineTail,
string CustomTitle) : IProgramScheduleItemRequest;
public record ReplaceProgramScheduleItems
(int ProgramScheduleId, List<ReplaceProgramScheduleItem> Items) : IRequest<
@@ -28,7 +28,8 @@ namespace ErsatzTV.Application.ProgramSchedules
_ => null
},
duration.PlayoutDuration,
duration.OfflineTail),
duration.OfflineTail,
duration.CustomTitle),
ProgramScheduleItemFlood flood =>
new ProgramScheduleItemFloodViewModel(
flood.Id,
@@ -44,7 +45,8 @@ namespace ErsatzTV.Application.ProgramSchedules
Show show => MediaItems.Mapper.ProjectToViewModel(show),
Season season => MediaItems.Mapper.ProjectToViewModel(season),
_ => null
}),
},
flood.CustomTitle),
ProgramScheduleItemMultiple multiple =>
new ProgramScheduleItemMultipleViewModel(
multiple.Id,
@@ -61,7 +63,8 @@ namespace ErsatzTV.Application.ProgramSchedules
Season season => MediaItems.Mapper.ProjectToViewModel(season),
_ => null
},
multiple.Count),
multiple.Count,
multiple.CustomTitle),
ProgramScheduleItemOne one =>
new ProgramScheduleItemOneViewModel(
one.Id,
@@ -77,7 +80,8 @@ namespace ErsatzTV.Application.ProgramSchedules
Show show => MediaItems.Mapper.ProjectToViewModel(show),
Season season => MediaItems.Mapper.ProjectToViewModel(season),
_ => null
}),
},
one.CustomTitle),
_ => throw new NotSupportedException(
$"Unsupported program schedule item type {programScheduleItem.GetType().Name}")
};
@@ -16,7 +16,8 @@ namespace ErsatzTV.Application.ProgramSchedules
MediaCollectionViewModel collection,
NamedMediaItemViewModel mediaItem,
TimeSpan playoutDuration,
bool offlineTail) : base(
bool offlineTail,
string customTitle) : base(
id,
index,
startType,
@@ -24,7 +25,8 @@ namespace ErsatzTV.Application.ProgramSchedules
PlayoutMode.Duration,
collectionType,
collection,
mediaItem)
mediaItem,
customTitle)
{
PlayoutDuration = playoutDuration;
OfflineTail = offlineTail;
@@ -14,7 +14,8 @@ namespace ErsatzTV.Application.ProgramSchedules
TimeSpan? startTime,
ProgramScheduleItemCollectionType collectionType,
MediaCollectionViewModel collection,
NamedMediaItemViewModel mediaItem) : base(
NamedMediaItemViewModel mediaItem,
string customTitle) : base(
id,
index,
startType,
@@ -22,7 +23,8 @@ namespace ErsatzTV.Application.ProgramSchedules
PlayoutMode.Flood,
collectionType,
collection,
mediaItem)
mediaItem,
customTitle)
{
}
}
@@ -15,7 +15,8 @@ namespace ErsatzTV.Application.ProgramSchedules
ProgramScheduleItemCollectionType collectionType,
MediaCollectionViewModel collection,
NamedMediaItemViewModel mediaItem,
int count) : base(
int count,
string customTitle) : base(
id,
index,
startType,
@@ -23,7 +24,8 @@ namespace ErsatzTV.Application.ProgramSchedules
PlayoutMode.Multiple,
collectionType,
collection,
mediaItem) =>
mediaItem,
customTitle) =>
Count = count;
public int Count { get; }
@@ -14,7 +14,8 @@ namespace ErsatzTV.Application.ProgramSchedules
TimeSpan? startTime,
ProgramScheduleItemCollectionType collectionType,
MediaCollectionViewModel collection,
NamedMediaItemViewModel mediaItem) : base(
NamedMediaItemViewModel mediaItem,
string customTitle) : base(
id,
index,
startType,
@@ -22,7 +23,8 @@ namespace ErsatzTV.Application.ProgramSchedules
PlayoutMode.One,
collectionType,
collection,
mediaItem)
mediaItem,
customTitle)
{
}
}
@@ -13,7 +13,8 @@ namespace ErsatzTV.Application.ProgramSchedules
PlayoutMode PlayoutMode,
ProgramScheduleItemCollectionType CollectionType,
MediaCollectionViewModel Collection,
NamedMediaItemViewModel MediaItem)
NamedMediaItemViewModel MediaItem,
string CustomTitle)
{
public string Name => CollectionType switch
{
@@ -0,0 +1,8 @@
using ErsatzTV.Application.MediaCards;
using MediatR;
namespace ErsatzTV.Application.Search.Queries
{
public record QuerySearchIndexMusicVideos
(string Query, int PageNumber, int PageSize) : IRequest<MusicVideoCardResultsViewModel>;
}
@@ -0,0 +1,44 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.MediaCards.Mapper;
namespace ErsatzTV.Application.Search.Queries
{
public class
QuerySearchIndexMusicVideosHandler : IRequestHandler<QuerySearchIndexMusicVideos, MusicVideoCardResultsViewModel
>
{
private readonly IMusicVideoRepository _musicVideoRepository;
private readonly ISearchIndex _searchIndex;
public QuerySearchIndexMusicVideosHandler(ISearchIndex searchIndex, IMusicVideoRepository musicVideoRepository)
{
_searchIndex = searchIndex;
_musicVideoRepository = musicVideoRepository;
}
public async Task<MusicVideoCardResultsViewModel> Handle(
QuerySearchIndexMusicVideos request,
CancellationToken cancellationToken)
{
SearchResult searchResult = await _searchIndex.Search(
request.Query,
(request.PageNumber - 1) * request.PageSize,
request.PageSize);
List<MusicVideoCardViewModel> items = await _musicVideoRepository
.GetMusicVideosForCards(searchResult.Items.Map(i => i.Id).ToList())
.Map(list => list.Map(ProjectToViewModel).ToList());
return new MusicVideoCardResultsViewModel(searchResult.TotalCount, items, searchResult.PageMap);
}
}
}
@@ -5,30 +5,38 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.Streaming.Queries
{
public class GetConcatProcessByChannelNumberHandler : FFmpegProcessHandler<GetConcatProcessByChannelNumber>
{
private readonly IConfigElementRepository _configElementRepository;
private readonly FFmpegProcessService _ffmpegProcessService;
public GetConcatProcessByChannelNumberHandler(
IChannelRepository channelRepository,
IConfigElementRepository configElementRepository,
FFmpegProcessService ffmpegProcessService)
: base(channelRepository, configElementRepository) =>
: base(channelRepository, configElementRepository)
{
_configElementRepository = configElementRepository;
_ffmpegProcessService = ffmpegProcessService;
}
protected override Task<Either<BaseError, Process>> GetProcess(
protected override async Task<Either<BaseError, Process>> GetProcess(
GetConcatProcessByChannelNumber request,
Channel channel,
string ffmpegPath) =>
Right<BaseError, Process>(
_ffmpegProcessService.ConcatChannel(
ffmpegPath,
channel,
request.Scheme,
request.Host)).AsTask();
string ffmpegPath)
{
bool saveReports = await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
.Map(result => result.IfNone(false));
return _ffmpegProcessService.ConcatChannel(
ffmpegPath,
saveReports,
channel,
request.Scheme,
request.Host);
}
}
}
@@ -56,6 +56,7 @@ namespace ErsatzTV.Application.Streaming.Queries
{
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
MusicVideo mv => mv.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(playoutItemWithPath))
};
@@ -153,6 +154,7 @@ namespace ErsatzTV.Application.Streaming.Queries
{
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
MusicVideo mv => mv.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(playoutItem))
};
@@ -28,7 +28,7 @@ namespace ErsatzTV.CommandLine.Commands
public string Name { get; set; }
[CommandOption("thread-count", Description = "The number of threads")]
public int ThreadCount { get; set; } = 4;
public int ThreadCount { get; set; } = 0;
[CommandOption("transcode", Description = "Whether to transcode all media")]
public bool Transcode { get; set; } = true;
@@ -163,9 +163,9 @@ namespace ErsatzTV.Core.Tests.FFmpeg
}
[Test]
public void ShouldNot_SetScaledSize_When_NotNormalizingResolution_ForTransportStream()
public void ShouldNot_SetScaledSize_When_NotNormalizingVideo_ForTransportStream()
{
FFmpegProfile ffmpegProfile = TestProfile() with { NormalizeResolution = false };
FFmpegProfile ffmpegProfile = TestProfile() with { NormalizeVideo = false };
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
@@ -184,7 +184,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
NormalizeResolution = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 }
};
@@ -208,7 +208,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
NormalizeResolution = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 }
};
@@ -232,7 +232,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
NormalizeResolution = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 }
};
@@ -257,7 +257,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
NormalizeResolution = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 }
};
@@ -282,7 +282,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
NormalizeResolution = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 }
};
@@ -303,11 +303,11 @@ namespace ErsatzTV.Core.Tests.FFmpeg
}
[Test]
public void Should_NotPadToDesiredResolution_When_NotNormalizingResolution()
public void Should_NotPadToDesiredResolution_When_NotNormalizingVideo()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
NormalizeResolution = false,
NormalizeVideo = false,
Resolution = new Resolution { Width = 1920, Height = 1080 }
};
@@ -332,9 +332,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
var ffmpegProfile = new FFmpegProfile
{
NormalizeResolution = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 },
NormalizeVideoCodec = false,
VideoCodec = "testCodec"
};
@@ -357,13 +356,12 @@ namespace ErsatzTV.Core.Tests.FFmpeg
[Test]
public void
Should_SetDesiredVideoCodec_When_ContentIsCorrectSize_And_NormalizingWrongCodec_ForTransportStream()
Should_SetDesiredVideoCodec_When_ContentIsCorrectSize_And_NormalizingVideo_ForTransportStream()
{
var ffmpegProfile = new FFmpegProfile
{
NormalizeResolution = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 },
NormalizeVideoCodec = true,
VideoCodec = "testCodec"
};
@@ -387,13 +385,12 @@ namespace ErsatzTV.Core.Tests.FFmpeg
[Test]
public void
Should_SetCopyVideoCodec_When_ContentIsCorrectSize_And_NormalizingWrongCodec_ForHttpLiveStreaming()
Should_SetCopyVideoCodec_When_ContentIsCorrectSize_And_NormalizingVideo_ForHttpLiveStreaming()
{
var ffmpegProfile = new FFmpegProfile
{
NormalizeResolution = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 },
NormalizeVideoCodec = true,
VideoCodec = "testCodec"
};
@@ -420,9 +417,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
var ffmpegProfile = new FFmpegProfile
{
NormalizeResolution = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 },
NormalizeVideoCodec = true,
VideoCodec = "libx264"
};
@@ -446,13 +442,12 @@ namespace ErsatzTV.Core.Tests.FFmpeg
[Test]
public void
Should_SetCopyVideoCodec_When_ContentIsCorrectSize_And_NotNormalizingWrongCodec_ForTransportStream()
Should_SetCopyVideoCodec_When_ContentIsCorrectSize_And_NotNormalizingVideo_ForTransportStream()
{
var ffmpegProfile = new FFmpegProfile
{
NormalizeResolution = true,
NormalizeVideo = false,
Resolution = new Resolution { Width = 1920, Height = 1080 },
NormalizeVideoCodec = false,
VideoCodec = "libx264"
};
@@ -479,9 +474,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
var ffmpegProfile = new FFmpegProfile
{
NormalizeResolution = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 },
NormalizeVideoCodec = false,
VideoBitrate = 2525
};
@@ -503,13 +497,12 @@ namespace ErsatzTV.Core.Tests.FFmpeg
}
[Test]
public void Should_SetVideoBitrate_When_ContentIsCorrectSize_And_NormalizingWrongCodec_ForTransportStream()
public void Should_SetVideoBitrate_When_ContentIsCorrectSize_And_NormalizingVideo_ForTransportStream()
{
var ffmpegProfile = new FFmpegProfile
{
NormalizeResolution = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 },
NormalizeVideoCodec = true,
VideoBitrate = 2525
};
@@ -536,9 +529,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
var ffmpegProfile = new FFmpegProfile
{
NormalizeResolution = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 },
NormalizeVideoCodec = false,
VideoBufferSize = 2525
};
@@ -561,13 +553,12 @@ namespace ErsatzTV.Core.Tests.FFmpeg
[Test]
public void
Should_SetVideoBufferSize_When_ContentIsCorrectSize_And_NormalizingWrongCodec_ForTransportStream()
Should_SetVideoBufferSize_When_ContentIsCorrectSize_And_NormalizingVideo_ForTransportStream()
{
var ffmpegProfile = new FFmpegProfile
{
NormalizeResolution = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 },
NormalizeVideoCodec = true,
VideoBufferSize = 2525
};
@@ -594,7 +585,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
NormalizeAudioCodec = true,
NormalizeAudio = true,
AudioCodec = "aac"
};
@@ -613,11 +604,11 @@ namespace ErsatzTV.Core.Tests.FFmpeg
}
[Test]
public void Should_SetCopyAudioCodec_When_NotNormalizingWrongCodec_ForTransportStream()
public void Should_SetCopyAudioCodec_When_NotNormalizingVideo_ForTransportStream()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
NormalizeAudioCodec = false,
NormalizeAudio = false,
AudioCodec = "aac"
};
@@ -636,11 +627,11 @@ namespace ErsatzTV.Core.Tests.FFmpeg
}
[Test]
public void Should_SetDesiredAudioCodec_When_NormalizingWrongCodec_ForTransportStream()
public void Should_SetDesiredAudioCodec_When_NormalizingVideo_ForTransportStream()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
NormalizeAudioCodec = true,
NormalizeAudio = true,
AudioCodec = "aac"
};
@@ -659,11 +650,11 @@ namespace ErsatzTV.Core.Tests.FFmpeg
}
[Test]
public void Should_SetCopyAudioCodec_When_NormalizingWrongCodec_ForHttpLiveStreaming()
public void Should_SetCopyAudioCodec_When_NormalizingVideo_ForHttpLiveStreaming()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
NormalizeAudioCodec = true,
NormalizeAudio = true,
AudioCodec = "aac"
};
@@ -682,11 +673,11 @@ namespace ErsatzTV.Core.Tests.FFmpeg
}
[Test]
public void Should_SetAudioBitrate_When_NormalizingWrongCodec_ForTransportStream()
public void Should_SetAudioBitrate_When_NormalizingVideo_ForTransportStream()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
NormalizeAudioCodec = true,
NormalizeAudio = true,
AudioBitrate = 2424
};
@@ -705,11 +696,11 @@ namespace ErsatzTV.Core.Tests.FFmpeg
}
[Test]
public void Should_SetAudioBufferSize_When_NormalizingWrongCodec_ForTransportStream()
public void Should_SetAudioBufferSize_When_NormalizingVideo_ForTransportStream()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
NormalizeAudioCodec = true,
NormalizeAudio = true,
AudioBufferSize = 2424
};
@@ -732,7 +723,6 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
NormalizeAudioCodec = true,
NormalizeAudio = true,
AudioCodec = "ac3",
AudioChannels = 6
@@ -757,7 +747,6 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
NormalizeAudioCodec = true,
NormalizeAudio = true,
AudioCodec = "ac3",
AudioSampleRate = 48
@@ -778,11 +767,10 @@ namespace ErsatzTV.Core.Tests.FFmpeg
}
[Test]
public void Should_SetAudioChannels_When_NormalizingWrongCodecAndAudio_ForTransportStream()
public void Should_SetAudioChannels_When_NormalizingVideoAndAudio_ForTransportStream()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
NormalizeAudioCodec = true,
NormalizeAudio = true,
AudioChannels = 6
};
@@ -802,11 +790,10 @@ namespace ErsatzTV.Core.Tests.FFmpeg
}
[Test]
public void Should_SetAudioSampleRate_When_NormalizingWrongCodecAndAudio_ForTransportStream()
public void Should_SetAudioSampleRate_When_NormalizingVideoAndAudio_ForTransportStream()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
NormalizeAudioCodec = true,
NormalizeAudio = true,
AudioSampleRate = 48
};
@@ -56,8 +56,8 @@ namespace ErsatzTV.Core.Tests.Fakes
public Task<byte[]> ReadAllBytes(string path) => TestBytes.AsTask();
public Unit CopyFile(string source, string destination) =>
Unit.Default;
public Task<Either<BaseError, Unit>> CopyFile(string source, string destination) =>
Task.FromResult(Right<BaseError, Unit>(Unit.Default));
private static List<DirectoryInfo> Split(DirectoryInfo path)
{
@@ -610,6 +610,190 @@ namespace ErsatzTV.Core.Tests.Scheduling
result.Items[5].MediaItemId.Should().Be(4);
}
[Test]
public async Task Alternating_MultipleContent_Should_Maintain_Counts()
{
var collectionOne = new Collection
{
Id = 1,
Name = "Multiple Items 1",
MediaItems = new List<MediaItem>
{
TestMovie(1, TimeSpan.FromHours(1), new DateTime(2020, 1, 1))
}
};
var collectionTwo = new Collection
{
Id = 2,
Name = "Multiple Items 2",
MediaItems = new List<MediaItem>
{
TestMovie(2, TimeSpan.FromHours(1), new DateTime(2020, 1, 1))
}
};
var fakeRepository = new FakeMediaCollectionRepository(
Map(
(collectionOne.Id, collectionOne.MediaItems.ToList()),
(collectionTwo.Id, collectionTwo.MediaItems.ToList())));
var items = new List<ProgramScheduleItem>
{
new ProgramScheduleItemMultiple
{
Id = 1,
Index = 1,
Collection = collectionOne,
CollectionId = collectionOne.Id,
StartTime = null,
Count = 3
},
new ProgramScheduleItemMultiple
{
Id = 2,
Index = 2,
Collection = collectionTwo,
CollectionId = collectionTwo.Id,
StartTime = null,
Count = 3
}
};
var playout = new Playout
{
ProgramSchedule = new ProgramSchedule
{
Items = items,
MediaCollectionPlaybackOrder = PlaybackOrder.Chronological
},
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
Anchor = new PlayoutAnchor
{
NextStart = HoursAfterMidnight(1).UtcDateTime,
NextScheduleItem = items[0],
NextScheduleItemId = 1,
MultipleRemaining = 2
}
};
var televisionRepo = new FakeTelevisionRepository();
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, _logger);
DateTimeOffset start = HoursAfterMidnight(0);
DateTimeOffset finish = start + TimeSpan.FromHours(5);
Playout result = await builder.BuildPlayoutItems(playout, start, finish);
result.Items.Count.Should().Be(4);
result.Items[0].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(1));
result.Items[0].MediaItemId.Should().Be(1);
result.Items[1].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(2));
result.Items[1].MediaItemId.Should().Be(1);
result.Items[2].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(3));
result.Items[2].MediaItemId.Should().Be(2);
result.Items[3].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(4));
result.Items[3].MediaItemId.Should().Be(2);
result.Anchor.NextScheduleItem.Should().Be(items[1]);
result.Anchor.MultipleRemaining.Should().Be(1);
}
[Test]
public async Task Alternating_Duration_Should_Maintain_Duration()
{
var collectionOne = new Collection
{
Id = 1,
Name = "Duration Items 1",
MediaItems = new List<MediaItem>
{
TestMovie(1, TimeSpan.FromHours(1), new DateTime(2020, 1, 1))
}
};
var collectionTwo = new Collection
{
Id = 2,
Name = "Duration Items 2",
MediaItems = new List<MediaItem>
{
TestMovie(2, TimeSpan.FromHours(1), new DateTime(2020, 1, 1))
}
};
var fakeRepository = new FakeMediaCollectionRepository(
Map(
(collectionOne.Id, collectionOne.MediaItems.ToList()),
(collectionTwo.Id, collectionTwo.MediaItems.ToList())));
var items = new List<ProgramScheduleItem>
{
new ProgramScheduleItemDuration
{
Id = 1,
Index = 1,
Collection = collectionOne,
CollectionId = collectionOne.Id,
StartTime = null,
PlayoutDuration = TimeSpan.FromHours(3),
OfflineTail = false
},
new ProgramScheduleItemDuration
{
Id = 2,
Index = 2,
Collection = collectionTwo,
CollectionId = collectionTwo.Id,
StartTime = null,
PlayoutDuration = TimeSpan.FromHours(3),
OfflineTail = false
}
};
var playout = new Playout
{
ProgramSchedule = new ProgramSchedule
{
Items = items,
MediaCollectionPlaybackOrder = PlaybackOrder.Chronological
},
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
Anchor = new PlayoutAnchor
{
NextStart = HoursAfterMidnight(1).UtcDateTime,
NextScheduleItem = items[0],
NextScheduleItemId = 1,
DurationFinish = HoursAfterMidnight(3).UtcDateTime
}
};
var televisionRepo = new FakeTelevisionRepository();
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, _logger);
DateTimeOffset start = HoursAfterMidnight(0);
DateTimeOffset finish = start + TimeSpan.FromHours(5);
Playout result = await builder.BuildPlayoutItems(playout, start, finish);
result.Items.Count.Should().Be(4);
result.Items[0].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(1));
result.Items[0].MediaItemId.Should().Be(1);
result.Items[1].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(2));
result.Items[1].MediaItemId.Should().Be(1);
result.Items[2].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(3));
result.Items[2].MediaItemId.Should().Be(2);
result.Items[3].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(4));
result.Items[3].MediaItemId.Should().Be(2);
result.Anchor.NextScheduleItem.Should().Be(items[1]);
result.Anchor.DurationFinish.Should().Be(HoursAfterMidnight(6).UtcDateTime);
}
private static DateTimeOffset HoursAfterMidnight(int hours)
{
DateTimeOffset now = DateTimeOffset.Now;
+5 -7
View File
@@ -9,13 +9,12 @@
public HardwareAccelerationKind HardwareAcceleration { get; set; }
public int ResolutionId { get; set; }
public Resolution Resolution { get; set; }
public bool NormalizeResolution { get; set; }
public string VideoCodec { get; set; }
public bool NormalizeVideoCodec { get; set; }
public bool NormalizeVideo { get; set; }
public int VideoBitrate { get; set; }
public int VideoBufferSize { get; set; }
public string FrameRate { get; set; }
public string AudioCodec { get; set; }
public bool NormalizeAudioCodec { get; set; }
public int AudioBitrate { get; set; }
public int AudioBufferSize { get; set; }
public int AudioVolume { get; set; }
@@ -27,7 +26,7 @@
new()
{
Name = name,
ThreadCount = 4,
ThreadCount = 0,
Transcode = true,
ResolutionId = resolution.Id,
Resolution = resolution,
@@ -40,9 +39,8 @@
AudioVolume = 100,
AudioChannels = 2,
AudioSampleRate = 48,
NormalizeResolution = true,
NormalizeVideoCodec = true,
NormalizeAudioCodec = true,
NormalizeVideo = true,
FrameRate = "24",
NormalizeAudio = true
};
}
@@ -3,6 +3,7 @@
public enum LibraryMediaKind
{
Movies = 1,
Shows = 2
Shows = 2,
MusicVideos = 3
}
}
@@ -13,16 +13,6 @@ namespace ErsatzTV.Core.Domain
public TimeSpan Duration { get; set; }
public string SampleAspectRatio { get; set; }
public string DisplayAspectRatio { get; set; }
[Obsolete("Use MediaSource instead")]
public string VideoCodec { get; set; }
[Obsolete("Use MediaSource instead")]
public string VideoProfile { get; set; }
[Obsolete("Use MediaSource instead")]
public string AudioCodec { get; set; }
public VideoScanKind VideoScanKind { get; set; }
public DateTime DateAdded { get; set; }
public DateTime DateUpdated { get; set; }
@@ -0,0 +1,10 @@
using System.Collections.Generic;
namespace ErsatzTV.Core.Domain
{
public class MusicVideo : MediaItem
{
public List<MusicVideoMetadata> MusicVideoMetadata { get; set; }
public List<MediaVersion> MediaVersions { get; set; }
}
}
@@ -0,0 +1,11 @@
namespace ErsatzTV.Core.Domain
{
public class MusicVideoMetadata : Metadata
{
public string Album { get; set; }
public string Plot { get; set; }
public string Artist { get; set; }
public int MusicVideoId { get; set; }
public MusicVideo MusicVideo { get; set; }
}
}
+8
View File
@@ -1,4 +1,6 @@
using System;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Domain
{
@@ -9,7 +11,13 @@ namespace ErsatzTV.Core.Domain
public ProgramScheduleItem NextScheduleItem { get; set; }
public DateTime NextStart { get; set; }
public int? MultipleRemaining { get; set; }
public DateTime? DurationFinish { get; set; }
public DateTimeOffset NextStartOffset => new DateTimeOffset(NextStart, TimeSpan.Zero).ToLocalTime();
public Option<DateTimeOffset> DurationFinishOffset =>
Optional(DurationFinish)
.Map(durationFinish => new DateTimeOffset(durationFinish, TimeSpan.Zero).ToLocalTime());
}
}
+2
View File
@@ -9,6 +9,8 @@ namespace ErsatzTV.Core.Domain
public MediaItem MediaItem { get; set; }
public DateTime Start { get; set; }
public DateTime Finish { get; set; }
public string CustomTitle { get; set; }
public bool CustomGroup { get; set; }
public int PlayoutId { get; set; }
public Playout Playout { get; set; }
@@ -9,6 +9,7 @@ namespace ErsatzTV.Core.Domain
public StartType StartType => StartTime.HasValue ? StartType.Fixed : StartType.Dynamic;
public TimeSpan? StartTime { get; set; }
public ProgramScheduleItemCollectionType CollectionType { get; set; }
public string CustomTitle { get; set; }
public int ProgramScheduleId { get; set; }
public ProgramSchedule ProgramSchedule { get; set; }
public int? CollectionId { get; set; }
@@ -13,6 +13,7 @@ namespace ErsatzTV.Core.FFmpeg
{
private Option<TimeSpan> _audioDuration = None;
private bool _deinterlace;
private Option<string> _frameRate = None;
private Option<HardwareAccelerationKind> _hardwareAccelerationKind = None;
private string _inputCodec;
private Option<IDisplaySize> _padToSize = None;
@@ -54,6 +55,12 @@ namespace ErsatzTV.Core.FFmpeg
return this;
}
public FFmpegComplexFilterBuilder WithFrameRate(Option<string> frameRate)
{
_frameRate = frameRate;
return this;
}
public Option<FFmpegComplexFilter> Build(int videoStreamIndex, int audioStreamIndex)
{
var complexFilter = new StringBuilder();
@@ -104,6 +111,8 @@ namespace ErsatzTV.Core.FFmpeg
}
}
_frameRate.IfSome(frameRate => filterQueue.Add($"fps=fps={frameRate}"));
_scaleToSize.IfSome(
size =>
{
@@ -16,7 +16,6 @@ namespace ErsatzTV.Core.FFmpeg
public Option<TimeSpan> StreamSeek { get; set; }
public Option<IDisplaySize> ScaledSize { get; set; }
public bool PadToDesiredResolution { get; set; }
public string ScalingAlgorithm => "fast_bilinear"; // TODO: from config, add tests
public string VideoCodec { get; set; }
public Option<int> VideoBitrate { get; set; }
public Option<int> VideoBufferSize { get; set; }
@@ -27,5 +26,7 @@ namespace ErsatzTV.Core.FFmpeg
public Option<TimeSpan> AudioDuration { get; set; }
public string AudioCodec { get; set; }
public bool Deinterlace { get; set; }
public Option<string> FrameRate { get; set; }
public Option<int> VideoTrackTimeScale { get; set; }
}
}
@@ -81,11 +81,20 @@ namespace ErsatzTV.Core.FFmpeg
}
IDisplaySize sizeAfterScaling = result.ScaledSize.IfNone(version);
if (ffmpegProfile.NormalizeResolution && !sizeAfterScaling.IsSameSizeAs(ffmpegProfile.Resolution))
if (ffmpegProfile.NormalizeVideo && !sizeAfterScaling.IsSameSizeAs(ffmpegProfile.Resolution))
{
result.PadToDesiredResolution = true;
}
if (ffmpegProfile.NormalizeVideo)
{
result.FrameRate = string.IsNullOrWhiteSpace(ffmpegProfile.FrameRate)
? None
: Some(ffmpegProfile.FrameRate);
result.VideoTrackTimeScale = 90000;
}
if (result.ScaledSize.IsSome || result.PadToDesiredResolution ||
NeedToNormalizeVideoCodec(ffmpegProfile, videoStream))
{
@@ -141,7 +150,7 @@ namespace ErsatzTV.Core.FFmpeg
};
private static bool NeedToScale(FFmpegProfile ffmpegProfile, MediaVersion version) =>
ffmpegProfile.NormalizeResolution &&
ffmpegProfile.NormalizeVideo &&
IsIncorrectSize(ffmpegProfile.Resolution, version) ||
IsTooLarge(ffmpegProfile.Resolution, version) ||
IsOddSize(version);
@@ -159,10 +168,10 @@ namespace ErsatzTV.Core.FFmpeg
version.Height % 2 == 1 || version.Width % 2 == 1;
private static bool NeedToNormalizeVideoCodec(FFmpegProfile ffmpegProfile, MediaStream videoStream) =>
ffmpegProfile.NormalizeVideoCodec && ffmpegProfile.VideoCodec != videoStream.Codec;
ffmpegProfile.NormalizeVideo && ffmpegProfile.VideoCodec != videoStream.Codec;
private static bool NeedToNormalizeAudioCodec(FFmpegProfile ffmpegProfile, MediaStream audioStream) =>
ffmpegProfile.NormalizeAudioCodec && ffmpegProfile.AudioCodec != audioStream.Codec;
ffmpegProfile.NormalizeAudio && ffmpegProfile.AudioCodec != audioStream.Codec;
private static IDisplaySize CalculateScaledSize(FFmpegProfile ffmpegProfile, MediaVersion version)
{
+27 -6
View File
@@ -42,6 +42,7 @@ namespace ErsatzTV.Core.FFmpeg
private readonly string _ffmpegPath;
private readonly bool _saveReports;
private FFmpegComplexFilterBuilder _complexFilterBuilder = new();
private bool _isConcat;
public FFmpegProcessBuilder(string ffmpegPath, bool saveReports)
{
@@ -186,6 +187,8 @@ namespace ErsatzTV.Core.FFmpeg
public FFmpegProcessBuilder WithConcat(string concatPlaylist)
{
_isConcat = true;
var arguments = new List<string>
{
"-f", "concat",
@@ -193,8 +196,6 @@ namespace ErsatzTV.Core.FFmpeg
"-protocol_whitelist", "file,http,tcp,https,tcp,tls",
"-probesize", "32",
"-i", concatPlaylist,
"-map", "0:v",
"-map", "0:a",
"-c", "copy",
"-muxdelay", "0",
"-muxpreload", "0"
@@ -228,7 +229,7 @@ namespace ErsatzTV.Core.FFmpeg
const string X = "x=(w-text_w)/2";
const string Y = "y=(h-text_h)/3*2";
string fontSize = text.Length > 60 ? "fontsize=40" : "fontsize=60";
string fontSize = text.Length > 80 ? "fontsize=30" : text.Length > 60 ? "fontsize=40" : "fontsize=60";
return WithFilterComplex(
$"[0:0]scale={desiredResolution.Width}:{desiredResolution.Height},drawtext={FONT_FILE}:{fontSize}:{FONT_COLOR}:{X}:{Y}:text='{text}'[v]",
@@ -323,6 +324,23 @@ namespace ErsatzTV.Core.FFmpeg
return this;
}
public FFmpegProcessBuilder WithFrameRate(Option<string> frameRate)
{
_complexFilterBuilder = _complexFilterBuilder.WithFrameRate(frameRate);
return this;
}
public FFmpegProcessBuilder WithVideoTrackTimeScale(Option<int> videoTrackTimeScale)
{
videoTrackTimeScale.IfSome(
timeScale =>
{
_arguments.Add("-video_track_timescale");
_arguments.Add($"{timeScale}");
});
return this;
}
public FFmpegProcessBuilder WithDeinterlace(bool deinterlace)
{
_complexFilterBuilder = _complexFilterBuilder.WithDeinterlace(deinterlace);
@@ -331,8 +349,8 @@ namespace ErsatzTV.Core.FFmpeg
public FFmpegProcessBuilder WithFilterComplex(int videoStreamIndex, int audioStreamIndex)
{
var videoLabel = $"0:v:{videoStreamIndex}";
var audioLabel = $"0:a:{audioStreamIndex}";
var videoLabel = $"0:{videoStreamIndex}";
var audioLabel = $"0:{audioStreamIndex}";
Option<FFmpegComplexFilter> maybeFilter = _complexFilterBuilder.Build(videoStreamIndex, audioStreamIndex);
maybeFilter.IfSome(
@@ -373,10 +391,13 @@ namespace ErsatzTV.Core.FFmpeg
if (_saveReports)
{
string fileName = Path.Combine(FileSystemLayout.FFmpegReportsFolder, "%p-%t.log");
string fileName = _isConcat
? Path.Combine(FileSystemLayout.FFmpegReportsFolder, "ffmpeg-%t-concat.log")
: Path.Combine(FileSystemLayout.FFmpegReportsFolder, "ffmpeg-%t-transcode.log");
startInfo.EnvironmentVariables.Add("FFREPORT", $"file={fileName}:level=32");
}
startInfo.ArgumentList.Add("-nostdin");
foreach (string argument in _arguments)
{
startInfo.ArgumentList.Add(argument);
+5 -3
View File
@@ -48,7 +48,9 @@ namespace ErsatzTV.Core.FFmpeg
.WithFormatFlags(playbackSettings.FormatFlags)
.WithRealtimeOutput(playbackSettings.RealtimeOutput)
.WithSeek(playbackSettings.StreamSeek)
.WithInputCodec(path, playbackSettings.HardwareAcceleration, videoStream.Codec);
.WithInputCodec(path, playbackSettings.HardwareAcceleration, videoStream.Codec)
.WithFrameRate(playbackSettings.FrameRate)
.WithVideoTrackTimeScale(playbackSettings.VideoTrackTimeScale);
playbackSettings.ScaledSize.Match(
scaledSize =>
@@ -124,11 +126,11 @@ namespace ErsatzTV.Core.FFmpeg
return builder.WithPipe().Build();
}
public Process ConcatChannel(string ffmpegPath, Channel channel, string scheme, string host)
public Process ConcatChannel(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host)
{
FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.ConcatSettings;
return new FFmpegProcessBuilder(ffmpegPath, false)
return new FFmpegProcessBuilder(ffmpegPath, saveReports)
.WithThreads(1)
.WithQuiet()
.WithFormatFlags(playbackSettings.FormatFlags)
@@ -8,6 +8,6 @@ namespace ErsatzTV.Core.Interfaces.Images
{
Task<Either<BaseError, byte[]>> ResizeImage(byte[] imageBuffer, int height);
Task<Either<BaseError, string>> SaveArtworkToCache(byte[] imageBuffer, ArtworkKind artworkKind);
string CopyArtworkToCache(string path, ArtworkKind artworkKind);
Task<Either<BaseError, string>> CopyArtworkToCache(string path, ArtworkKind artworkKind);
}
}
@@ -15,6 +15,6 @@ namespace ErsatzTV.Core.Interfaces.Metadata
IEnumerable<string> ListFiles(string folder);
bool FileExists(string path);
Task<byte[]> ReadAllBytes(string path);
Unit CopyFile(string source, string destination);
Task<Either<BaseError, Unit>> CopyFile(string source, string destination);
}
}
@@ -1,11 +1,13 @@
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Metadata
{
public interface ILocalMetadataProvider
{
Task<ShowMetadata> GetMetadataForShow(string showFolder);
Task<Option<MusicVideoMetadata>> GetMetadataForMusicVideo(string filePath);
Task<bool> RefreshSidecarMetadata(MediaItem mediaItem, string path);
Task<bool> RefreshSidecarMetadata(Show televisionShow, string showFolder);
Task<bool> RefreshFallbackMetadata(MediaItem mediaItem);
@@ -0,0 +1,12 @@
using System;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Metadata
{
public interface IMusicVideoFolderScanner
{
Task<Either<BaseError, Unit>> ScanFolder(LibraryPath libraryPath, string ffprobePath, DateTimeOffset lastScan);
}
}
@@ -0,0 +1,26 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Metadata;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Repositories
{
public interface IMusicVideoRepository
{
Task<Option<MusicVideo>> GetByMetadata(LibraryPath libraryPath, MusicVideoMetadata metadata);
Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> Add(
LibraryPath libraryPath,
string filePath,
MusicVideoMetadata metadata);
Task<IEnumerable<string>> FindMusicVideoPaths(LibraryPath libraryPath);
Task<List<int>> DeleteByPath(LibraryPath libraryPath, string path);
Task<bool> AddGenre(MusicVideoMetadata metadata, Genre genre);
Task<bool> AddTag(MusicVideoMetadata metadata, Tag tag);
Task<bool> AddStudio(MusicVideoMetadata metadata, Studio studio);
Task<List<MusicVideoMetadata>> GetMusicVideosForCards(List<int> ids);
Task<Option<MusicVideo>> GetMusicVideo(int musicVideoId);
}
}
+74 -32
View File
@@ -57,49 +57,36 @@ namespace ErsatzTV.Core.Iptv
foreach (Channel channel in _channels.OrderBy(c => c.Number))
{
foreach (PlayoutItem playoutItem in channel.Playouts.Collect(p => p.Items).OrderBy(i => i.Start))
var sorted = channel.Playouts.Collect(p => p.Items).OrderBy(x => x.Start).ToList();
var i = 0;
while (i < sorted.Count)
{
string start = playoutItem.StartOffset.ToString("yyyyMMddHHmmss zzz").Replace(":", string.Empty);
string stop = playoutItem.FinishOffset.ToString("yyyyMMddHHmmss zzz").Replace(":", string.Empty);
PlayoutItem startItem = sorted[i];
bool hasCustomTitle = !string.IsNullOrWhiteSpace(startItem.CustomTitle);
string title = playoutItem.MediaItem switch
int finishIndex = i;
while (hasCustomTitle && finishIndex + 1 < sorted.Count && sorted[finishIndex + 1].CustomGroup)
{
Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Title ?? string.Empty)
.IfNone("[unknown movie]"),
Episode e => e.Season.Show.ShowMetadata.HeadOrNone().Map(em => em.Title ?? string.Empty)
.IfNone("[unknown show]"),
_ => "[unknown]"
};
finishIndex++;
}
string subtitle = playoutItem.MediaItem switch
{
Episode e => e.EpisodeMetadata.HeadOrNone().Match(
em => em.Title ?? string.Empty,
() => string.Empty),
_ => string.Empty
};
PlayoutItem finishItem = sorted[finishIndex];
i = finishIndex;
string description = playoutItem.MediaItem switch
{
Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Plot ?? string.Empty).IfNone(string.Empty),
Episode e => e.EpisodeMetadata.HeadOrNone().Map(em => em.Plot ?? string.Empty)
.IfNone(string.Empty),
_ => string.Empty
};
string start = startItem.StartOffset.ToString("yyyyMMddHHmmss zzz").Replace(":", string.Empty);
string stop = finishItem.FinishOffset.ToString("yyyyMMddHHmmss zzz").Replace(":", string.Empty);
string contentRating = playoutItem.MediaItem switch
{
// TODO: re-implement content rating
// Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.ContentRating).IfNone(string.Empty),
_ => string.Empty
};
string title = GetTitle(startItem);
string subtitle = GetSubtitle(startItem);
string description = GetDescription(startItem);
string contentRating = string.Empty;
xml.WriteStartElement("programme");
xml.WriteAttributeString("start", start);
xml.WriteAttributeString("stop", stop);
xml.WriteAttributeString("channel", channel.Number);
if (playoutItem.MediaItem is Movie movie)
if (!hasCustomTitle && startItem.MediaItem is Movie movie)
{
xml.WriteStartElement("category");
xml.WriteAttributeString("lang", "en");
@@ -150,7 +137,7 @@ namespace ErsatzTV.Core.Iptv
xml.WriteStartElement("previously-shown");
xml.WriteEndElement(); // previously-shown
if (playoutItem.MediaItem is Episode episode)
if (!hasCustomTitle && startItem.MediaItem is Episode episode)
{
Option<ShowMetadata> maybeMetadata =
Optional(episode.Season?.Show?.ShowMetadata.HeadOrNone()).Flatten();
@@ -209,6 +196,8 @@ namespace ErsatzTV.Core.Iptv
}
xml.WriteEndElement(); // programme
i++;
}
}
@@ -218,5 +207,58 @@ namespace ErsatzTV.Core.Iptv
xml.Flush();
return Encoding.UTF8.GetString(ms.ToArray());
}
private static string GetTitle(PlayoutItem playoutItem)
{
if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle))
{
return playoutItem.CustomTitle;
}
return playoutItem.MediaItem switch
{
Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Title ?? string.Empty)
.IfNone("[unknown movie]"),
Episode e => e.Season.Show.ShowMetadata.HeadOrNone().Map(em => em.Title ?? string.Empty)
.IfNone("[unknown show]"),
MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Map(mvm => $"{mvm.Artist} - {mvm.Title}")
.IfNone("[unknown music video]"),
_ => "[unknown]"
};
}
private static string GetSubtitle(PlayoutItem playoutItem)
{
if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle))
{
return string.Empty;
}
return playoutItem.MediaItem switch
{
Episode e => e.EpisodeMetadata.HeadOrNone().Match(
em => em.Title ?? string.Empty,
() => string.Empty),
_ => string.Empty
};
}
private static string GetDescription(PlayoutItem playoutItem)
{
if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle))
{
return string.Empty;
}
return playoutItem.MediaItem switch
{
Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Plot ?? string.Empty).IfNone(string.Empty),
Episode e => e.EpisodeMetadata.HeadOrNone().Map(em => em.Plot ?? string.Empty)
.IfNone(string.Empty),
MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Map(mvm => mvm.Plot ?? string.Empty)
.IfNone(string.Empty),
_ => string.Empty
};
}
}
}
+17 -8
View File
@@ -36,17 +36,26 @@ namespace ErsatzTV.Core.Metadata
public bool FileExists(string path) => File.Exists(path);
public Task<byte[]> ReadAllBytes(string path) => File.ReadAllBytesAsync(path);
public Unit CopyFile(string source, string destination)
public async Task<Either<BaseError, Unit>> CopyFile(string source, string destination)
{
string directory = Path.GetDirectoryName(destination) ?? string.Empty;
if (!Directory.Exists(directory))
try
{
Directory.CreateDirectory(directory);
string directory = Path.GetDirectoryName(destination) ?? string.Empty;
if (!Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
await using FileStream sourceStream = File.OpenRead(source);
await using FileStream destinationStream = File.Create(destination);
await sourceStream.CopyToAsync(destinationStream);
return Unit.Default;
}
catch (Exception ex)
{
return BaseError.New(ex.ToString());
}
File.Copy(source, destination, true);
return Unit.Default;
}
}
}
+39 -21
View File
@@ -72,6 +72,7 @@ namespace ErsatzTV.Core.Metadata
{
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
MusicVideo mv => mv.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
};
@@ -120,30 +121,47 @@ namespace ErsatzTV.Core.Metadata
if (shouldRefresh)
{
_logger.LogDebug("Refreshing {Attribute} from {Path}", artworkKind, artworkFile);
string cacheName = _imageCache.CopyArtworkToCache(artworkFile, artworkKind);
try
{
_logger.LogDebug("Refreshing {Attribute} from {Path}", artworkKind, artworkFile);
Either<BaseError, string> maybeCacheName =
await _imageCache.CopyArtworkToCache(artworkFile, artworkKind);
await maybeArtwork.Match(
async artwork =>
{
artwork.Path = cacheName;
artwork.DateUpdated = lastWriteTime;
await _metadataRepository.UpdateArtworkPath(artwork);
},
async () =>
{
var artwork = new Artwork
return await maybeCacheName.Match(
async cacheName =>
{
Path = cacheName,
DateAdded = DateTime.UtcNow,
DateUpdated = lastWriteTime,
ArtworkKind = artworkKind
};
metadata.Artwork.Add(artwork);
await _metadataRepository.AddArtwork(metadata, artwork);
});
await maybeArtwork.Match(
async artwork =>
{
artwork.Path = cacheName;
artwork.DateUpdated = lastWriteTime;
await _metadataRepository.UpdateArtworkPath(artwork);
},
async () =>
{
var artwork = new Artwork
{
Path = cacheName,
DateAdded = DateTime.UtcNow,
DateUpdated = lastWriteTime,
ArtworkKind = artworkKind
};
metadata.Artwork.Add(artwork);
await _metadataRepository.AddArtwork(metadata, artwork);
});
return true;
return true;
},
error =>
{
_logger.LogDebug("Failed to cache artwork from {Path}: {Error}", artworkFile, error.Value);
return Task.FromResult(false);
});
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error refreshing artwork");
}
}
return false;
+204 -5
View File
@@ -18,18 +18,21 @@ namespace ErsatzTV.Core.Metadata
private static readonly XmlSerializer MovieSerializer = new(typeof(MovieNfo));
private static readonly XmlSerializer EpisodeSerializer = new(typeof(TvShowEpisodeNfo));
private static readonly XmlSerializer TvShowSerializer = new(typeof(TvShowNfo));
private static readonly XmlSerializer MusicVideoSerializer = new(typeof(MusicVideoNfo));
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<LocalMetadataProvider> _logger;
private readonly IMetadataRepository _metadataRepository;
private readonly IMovieRepository _movieRepository;
private readonly IMusicVideoRepository _musicVideoRepository;
private readonly ITelevisionRepository _televisionRepository;
public LocalMetadataProvider(
IMetadataRepository metadataRepository,
IMovieRepository movieRepository,
ITelevisionRepository televisionRepository,
IMusicVideoRepository musicVideoRepository,
IFallbackMetadataProvider fallbackMetadataProvider,
ILocalFileSystem localFileSystem,
ILogger<LocalMetadataProvider> logger)
@@ -37,6 +40,7 @@ namespace ErsatzTV.Core.Metadata
_metadataRepository = metadataRepository;
_movieRepository = movieRepository;
_televisionRepository = televisionRepository;
_musicVideoRepository = musicVideoRepository;
_fallbackMetadataProvider = fallbackMetadataProvider;
_localFileSystem = localFileSystem;
_logger = logger;
@@ -65,6 +69,23 @@ namespace ErsatzTV.Core.Metadata
});
}
public async Task<Option<MusicVideoMetadata>> GetMetadataForMusicVideo(string filePath)
{
string nfoFileName = Path.ChangeExtension(filePath, "nfo");
Option<MusicVideoMetadata> maybeMetadata = None;
if (_localFileSystem.FileExists(nfoFileName))
{
maybeMetadata = await LoadMusicVideoMetadata(nfoFileName);
}
return maybeMetadata.Map(
metadata =>
{
metadata.SortTitle = _fallbackMetadataProvider.GetSortTitle(metadata.Title);
return metadata;
});
}
public Task<bool> RefreshSidecarMetadata(MediaItem mediaItem, string path) =>
mediaItem switch
{
@@ -78,6 +99,11 @@ namespace ErsatzTV.Core.Metadata
maybeMetadata => maybeMetadata.Match(
metadata => ApplyMetadataUpdate(m, metadata),
() => Task.FromResult(false))),
MusicVideo mv => LoadMetadata(mv, path)
.Bind(
maybeMetadata => maybeMetadata.Match(
metadata => ApplyMetadataUpdate(mv, metadata),
() => Task.FromResult(false))),
_ => Task.FromResult(false)
};
@@ -98,6 +124,37 @@ namespace ErsatzTV.Core.Metadata
public Task<bool> RefreshFallbackMetadata(Show televisionShow, string showFolder) =>
ApplyMetadataUpdate(televisionShow, _fallbackMetadataProvider.GetFallbackMetadataForShow(showFolder));
private async Task<Option<MusicVideoMetadata>> LoadMusicVideoMetadata(string nfoFileName)
{
try
{
await using FileStream fileStream = File.Open(nfoFileName, FileMode.Open, FileAccess.Read);
Option<MusicVideoNfo> maybeNfo = MusicVideoSerializer.Deserialize(fileStream) as MusicVideoNfo;
return maybeNfo.Match<Option<MusicVideoMetadata>>(
nfo => new MusicVideoMetadata
{
MetadataKind = MetadataKind.Sidecar,
DateAdded = DateTime.UtcNow,
DateUpdated = File.GetLastWriteTimeUtc(nfoFileName),
Artist = nfo.Artist,
Album = nfo.Album,
Title = nfo.Title,
Plot = nfo.Plot,
Year = GetYear(nfo.Year, nfo.Premiered),
ReleaseDate = GetAired(nfo.Year, nfo.Premiered),
Genres = nfo.Genres.Map(g => new Genre { Name = g }).ToList(),
Tags = nfo.Tags.Map(t => new Tag { Name = t }).ToList(),
Studios = nfo.Studios.Map(s => new Studio { Name = s }).ToList()
},
None);
}
catch (Exception ex)
{
_logger.LogInformation(ex, "Failed to read music video nfo metadata from {Path}", nfoFileName);
return None;
}
}
private async Task<bool> ApplyMetadataUpdate(Episode episode, Tuple<EpisodeMetadata, int> metadataEpisodeNumber)
{
(EpisodeMetadata metadata, int episodeNumber) = metadataEpisodeNumber;
@@ -113,7 +170,7 @@ namespace ErsatzTV.Core.Metadata
existing.Plot = metadata.Plot;
existing.Tagline = metadata.Tagline;
existing.Title = metadata.Title;
if (existing.DateAdded == DateTime.MinValue)
{
existing.DateAdded = metadata.DateAdded;
@@ -149,17 +206,17 @@ namespace ErsatzTV.Core.Metadata
async existing =>
{
var updated = false;
existing.Outline = metadata.Outline;
existing.Plot = metadata.Plot;
existing.Tagline = metadata.Tagline;
existing.Title = metadata.Title;
if (existing.DateAdded == DateTime.MinValue)
{
existing.DateAdded = metadata.DateAdded;
}
existing.DateUpdated = metadata.DateUpdated;
existing.MetadataKind = metadata.MetadataKind;
existing.OriginalTitle = metadata.OriginalTitle;
@@ -254,7 +311,7 @@ namespace ErsatzTV.Core.Metadata
existing.Plot = metadata.Plot;
existing.Tagline = metadata.Tagline;
existing.Title = metadata.Title;
if (existing.DateAdded == DateTime.MinValue)
{
existing.DateAdded = metadata.DateAdded;
@@ -344,6 +401,106 @@ namespace ErsatzTV.Core.Metadata
return await _metadataRepository.Add(metadata);
});
private Task<bool> ApplyMetadataUpdate(MusicVideo musicVideo, MusicVideoMetadata metadata) =>
Optional(musicVideo.MusicVideoMetadata).Flatten().HeadOrNone().Match(
async existing =>
{
var updated = false;
existing.Artist = metadata.Artist;
existing.Title = metadata.Title;
existing.Year = metadata.Year;
existing.Plot = metadata.Plot;
existing.Album = metadata.Album;
if (existing.DateAdded == DateTime.MinValue)
{
existing.DateAdded = metadata.DateAdded;
}
existing.DateUpdated = metadata.DateUpdated;
existing.MetadataKind = metadata.MetadataKind;
existing.OriginalTitle = metadata.OriginalTitle;
existing.ReleaseDate = metadata.ReleaseDate;
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);
if (await _metadataRepository.RemoveGenre(genre))
{
updated = true;
}
}
foreach (Genre genre in metadata.Genres.Filter(g => existing.Genres.All(g2 => g2.Name != g.Name))
.ToList())
{
existing.Genres.Add(genre);
if (await _musicVideoRepository.AddGenre(existing, genre))
{
updated = true;
}
}
foreach (Tag tag in existing.Tags.Filter(t => metadata.Tags.All(t2 => t2.Name != t.Name))
.ToList())
{
existing.Tags.Remove(tag);
if (await _metadataRepository.RemoveTag(tag))
{
updated = true;
}
}
foreach (Tag tag in metadata.Tags.Filter(t => existing.Tags.All(t2 => t2.Name != t.Name))
.ToList())
{
existing.Tags.Add(tag);
if (await _musicVideoRepository.AddTag(existing, tag))
{
updated = true;
}
}
foreach (Studio studio in existing.Studios
.Filter(s => metadata.Studios.All(s2 => s2.Name != s.Name))
.ToList())
{
existing.Studios.Remove(studio);
if (await _metadataRepository.RemoveStudio(studio))
{
updated = true;
}
}
foreach (Studio studio in metadata.Studios
.Filter(s => existing.Studios.All(s2 => s2.Name != s.Name))
.ToList())
{
existing.Studios.Add(studio);
if (await _musicVideoRepository.AddStudio(existing, studio))
{
updated = true;
}
}
return await _metadataRepository.Update(existing) || updated;
},
async () =>
{
metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
: metadata.SortTitle;
metadata.MusicVideoId = musicVideo.Id;
musicVideo.MusicVideoMetadata = new List<MusicVideoMetadata> { metadata };
return await _metadataRepository.Add(metadata);
});
private async Task<Option<MovieMetadata>> LoadMetadata(Movie mediaItem, string nfoFileName)
{
if (nfoFileName == null || !File.Exists(nfoFileName))
@@ -377,6 +534,17 @@ namespace ErsatzTV.Core.Metadata
return await LoadTelevisionShowMetadata(nfoFileName);
}
private async Task<Option<MusicVideoMetadata>> LoadMetadata(MusicVideo musicVideo, string nfoFileName)
{
if (nfoFileName == null || !File.Exists(nfoFileName))
{
_logger.LogDebug("NFO file does not exist at {Path}", nfoFileName);
return None;
}
return await LoadMusicVideoMetadata(nfoFileName);
}
private async Task<Option<ShowMetadata>> LoadTelevisionShowMetadata(string nfoFileName)
{
try
@@ -589,5 +757,36 @@ namespace ErsatzTV.Core.Metadata
[XmlElement("plot")]
public string Plot { get; set; }
}
[XmlRoot("musicvideo")]
public class MusicVideoNfo
{
[XmlElement("artist")]
public string Artist { get; set; }
[XmlElement("title")]
public string Title { get; set; }
[XmlElement("album")]
public string Album { get; set; }
[XmlElement("plot")]
public string Plot { get; set; }
[XmlElement("premiered")]
public string Premiered { get; set; }
[XmlElement("year")]
public int Year { get; set; }
[XmlElement("genre")]
public List<string> Genres { get; set; }
[XmlElement("tag")]
public List<string> Tags { get; set; }
[XmlElement("studio")]
public List<string> Studios { get; set; }
}
}
}
@@ -37,6 +37,7 @@ namespace ErsatzTV.Core.Metadata
{
Movie m => m.MediaVersions.Head().MediaFiles.Head().Path,
Episode e => e.MediaVersions.Head().MediaFiles.Head().Path,
MusicVideo mv => mv.MediaVersions.Head().MediaFiles.Head().Path,
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
};
@@ -63,6 +64,7 @@ namespace ErsatzTV.Core.Metadata
{
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
MusicVideo mv => mv.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
};
@@ -0,0 +1,225 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using LanguageExt;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Metadata
{
public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScanner
{
private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalMetadataProvider _localMetadataProvider;
private readonly ILogger<MusicVideoFolderScanner> _logger;
private readonly IMusicVideoRepository _musicVideoRepository;
private readonly ISearchIndex _searchIndex;
public MusicVideoFolderScanner(
ILocalFileSystem localFileSystem,
ILocalStatisticsProvider localStatisticsProvider,
ILocalMetadataProvider localMetadataProvider,
IMetadataRepository metadataRepository,
IImageCache imageCache,
ISearchIndex searchIndex,
IMusicVideoRepository musicVideoRepository,
ILogger<MusicVideoFolderScanner> logger) : base(
localFileSystem,
localStatisticsProvider,
metadataRepository,
imageCache,
logger)
{
_localFileSystem = localFileSystem;
_localMetadataProvider = localMetadataProvider;
_searchIndex = searchIndex;
_musicVideoRepository = musicVideoRepository;
_logger = logger;
}
public async Task<Either<BaseError, Unit>> ScanFolder(
LibraryPath libraryPath,
string ffprobePath,
DateTimeOffset lastScan)
{
if (!_localFileSystem.IsLibraryPathAccessible(libraryPath))
{
return new MediaSourceInaccessible();
}
var folderQueue = new Queue<string>();
foreach (string folder in _localFileSystem.ListSubdirectories(libraryPath.Path).OrderBy(identity))
{
folderQueue.Enqueue(folder);
}
while (folderQueue.Count > 0)
{
string movieFolder = folderQueue.Dequeue();
var allFiles = _localFileSystem.ListFiles(movieFolder)
.Filter(f => VideoFileExtensions.Contains(Path.GetExtension(f)))
.Filter(
f => !ExtraFiles.Any(
e => Path.GetFileNameWithoutExtension(f).EndsWith(e, StringComparison.OrdinalIgnoreCase)))
.ToList();
if (allFiles.Count == 0)
{
foreach (string subdirectory in _localFileSystem.ListSubdirectories(movieFolder).OrderBy(identity))
{
folderQueue.Enqueue(subdirectory);
}
continue;
}
if (_localFileSystem.GetLastWriteTime(movieFolder) < lastScan)
{
continue;
}
foreach (string file in allFiles.OrderBy(identity))
{
// TODO: figure out how to rebuild playouts
Either<BaseError, MediaItemScanResult<MusicVideo>> maybeMusicVideo =
await FindOrCreateMusicVideo(libraryPath, file)
.BindT(musicVideo => UpdateStatistics(musicVideo, ffprobePath))
.BindT(UpdateMetadata)
.BindT(UpdateThumbnail);
await maybeMusicVideo.Match(
async result =>
{
if (result.IsAdded)
{
await _searchIndex.AddItems(new List<MediaItem> { result.Item });
}
else if (result.IsUpdated)
{
await _searchIndex.UpdateItems(new List<MediaItem> { result.Item });
}
},
error =>
{
_logger.LogWarning("Error processing music video at {Path}: {Error}", file, error.Value);
return Task.CompletedTask;
});
}
}
foreach (string path in await _musicVideoRepository.FindMusicVideoPaths(libraryPath))
{
if (!_localFileSystem.FileExists(path))
{
_logger.LogInformation("Removing missing music video at {Path}", path);
List<int> ids = await _musicVideoRepository.DeleteByPath(libraryPath, path);
await _searchIndex.RemoveItems(ids);
}
}
return Unit.Default;
}
private async Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> FindOrCreateMusicVideo(
LibraryPath libraryPath,
string filePath)
{
Option<MusicVideoMetadata> maybeMetadata = await _localMetadataProvider.GetMetadataForMusicVideo(filePath);
return await maybeMetadata.Match(
async metadata =>
{
Option<MusicVideo> maybeMusicVideo =
await _musicVideoRepository.GetByMetadata(libraryPath, metadata);
return await maybeMusicVideo.Match(
musicVideo =>
Right<BaseError, MediaItemScanResult<MusicVideo>>(
new MediaItemScanResult<MusicVideo>(musicVideo))
.AsTask(),
async () => await _musicVideoRepository.Add(libraryPath, filePath, metadata));
},
() => Left<BaseError, MediaItemScanResult<MusicVideo>>(
BaseError.New("Unable to locate metadata for music video")).AsTask());
}
private async Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> UpdateMetadata(
MediaItemScanResult<MusicVideo> result)
{
try
{
MusicVideo musicVideo = result.Item;
return await LocateNfoFile(musicVideo).Match<Task<Either<BaseError, MediaItemScanResult<MusicVideo>>>>(
async nfoFile =>
{
bool shouldUpdate = Optional(musicVideo.MusicVideoMetadata).Flatten().HeadOrNone().Match(
m => m.MetadataKind == MetadataKind.Fallback ||
m.DateUpdated < _localFileSystem.GetLastWriteTime(nfoFile),
true);
if (shouldUpdate)
{
_logger.LogDebug("Refreshing {Attribute} from {Path}", "Sidecar Metadata", nfoFile);
if (await _localMetadataProvider.RefreshSidecarMetadata(musicVideo, nfoFile))
{
result.IsUpdated = true;
}
}
return result;
},
() => Left<BaseError, MediaItemScanResult<MusicVideo>>(
BaseError.New("Unable to locate metadata for music video")).AsTask());
}
catch (Exception ex)
{
return BaseError.New(ex.ToString());
}
}
private Option<string> LocateNfoFile(MusicVideo musicVideo)
{
string path = musicVideo.MediaVersions.Head().MediaFiles.Head().Path;
return Optional(Path.ChangeExtension(path, "nfo"))
.Filter(s => _localFileSystem.FileExists(s))
.HeadOrNone();
}
private async Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> UpdateThumbnail(
MediaItemScanResult<MusicVideo> result)
{
try
{
MusicVideo musicVideo = result.Item;
await LocateThumbnail(musicVideo).IfSomeAsync(
async thumbnailFile =>
{
MusicVideoMetadata metadata = musicVideo.MusicVideoMetadata.Head();
await RefreshArtwork(thumbnailFile, metadata, ArtworkKind.Thumbnail);
});
return result;
}
catch (Exception ex)
{
return BaseError.New(ex.ToString());
}
}
private Option<string> LocateThumbnail(MusicVideo musicVideo)
{
string path = musicVideo.MediaVersions.Head().MediaFiles.Head().Path;
return ImageFileExtensions
.Map(ext => Path.ChangeExtension(path, ext))
.Filter(f => _localFileSystem.FileExists(f))
.HeadOrNone();
}
}
}
@@ -80,9 +80,22 @@ namespace ErsatzTV.Core.Metadata
await _searchIndex.UpdateItems(new List<MediaItem> { result.Item });
}
await ScanSeasons(libraryPath, ffprobePath, result.Item, showFolder, lastScan);
await ScanSeasons(
libraryPath,
ffprobePath,
result.Item,
showFolder,
// force scanning all folders if we're adding a new show
result.IsAdded ? DateTimeOffset.MinValue : lastScan);
},
_ => Task.FromResult(Unit.Default));
error =>
{
_logger.LogWarning(
"Error processing show in folder {Folder}: {Error}",
showFolder,
error.Value);
return Task.FromResult(Unit.Default);
});
}
foreach (string path in await _televisionRepository.FindEpisodePaths(libraryPath))
@@ -132,7 +145,14 @@ namespace ErsatzTV.Core.Metadata
await maybeSeason.Match(
season => ScanEpisodes(libraryPath, ffprobePath, season, seasonFolder, lastScan),
_ => Task.FromResult(Unit.Default));
error =>
{
_logger.LogWarning(
"Error processing season in folder {Folder}: {Error}",
seasonFolder,
error.Value);
return Task.FromResult(Unit.Default);
});
});
}
@@ -48,6 +48,9 @@ namespace ErsatzTV.Core.Scheduling
Movie m => m.MovieMetadata.HeadOrNone().Match(
mm => mm.ReleaseDate ?? DateTime.MaxValue,
() => DateTime.MaxValue),
MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Match(
mvm => mvm.ReleaseDate ?? DateTime.MaxValue,
() => DateTime.MaxValue),
_ => DateTime.MaxValue
};
@@ -59,6 +62,9 @@ namespace ErsatzTV.Core.Scheduling
Movie m => m.MovieMetadata.HeadOrNone().Match(
mm => mm.ReleaseDate ?? DateTime.MaxValue,
() => DateTime.MaxValue),
MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Match(
mvm => mvm.ReleaseDate ?? DateTime.MaxValue,
() => DateTime.MaxValue),
_ => DateTime.MaxValue
};
+32 -4
View File
@@ -99,6 +99,8 @@ namespace ErsatzTV.Core.Scheduling
TimeSpan.Zero,
Episode e => e.MediaVersions.HeadOrNone().Map(mv => mv.Duration).IfNone(TimeSpan.Zero) ==
TimeSpan.Zero,
MusicVideo mv => mv.MediaVersions.HeadOrNone().Map(v => v.Duration).IfNone(TimeSpan.Zero) ==
TimeSpan.Zero,
_ => true
})).Map(c => c.Key);
if (zeroDurationCollection.IsSome)
@@ -145,8 +147,12 @@ namespace ErsatzTV.Core.Scheduling
// start with the previously-decided schedule item
int index = sortedScheduleItems.IndexOf(startAnchor.NextScheduleItem);
Option<int> multipleRemaining = None;
Option<DateTimeOffset> durationFinish = None;
// start with the previous multiple/duration states
Option<int> multipleRemaining = Optional(startAnchor.MultipleRemaining);
Option<DateTimeOffset> durationFinish = startAnchor.DurationFinishOffset;
bool customGroup = multipleRemaining.IsSome || durationFinish.IsSome;
// loop until we're done filling the desired amount of time
while (currentTime < playoutFinish)
{
@@ -176,6 +182,7 @@ namespace ErsatzTV.Core.Scheduling
{
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
MusicVideo mv => mv.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
};
@@ -183,9 +190,15 @@ namespace ErsatzTV.Core.Scheduling
{
MediaItemId = mediaItem.Id,
Start = itemStartTime.UtcDateTime,
Finish = itemStartTime.UtcDateTime + version.Duration
Finish = itemStartTime.UtcDateTime + version.Duration,
CustomGroup = customGroup
};
if (!string.IsNullOrWhiteSpace(scheduleItem.CustomTitle))
{
playoutItem.CustomTitle = scheduleItem.CustomTitle;
}
currentTime = itemStartTime + version.Duration;
enumerator.MoveNext();
@@ -199,11 +212,13 @@ namespace ErsatzTV.Core.Scheduling
"Advancing to next schedule item after playout mode {PlayoutMode}",
"One");
index++;
customGroup = false;
break;
case ProgramScheduleItemMultiple multiple:
if (multipleRemaining.IsNone)
{
multipleRemaining = multiple.Count;
customGroup = true;
}
multipleRemaining = multipleRemaining.Map(i => i - 1);
@@ -214,6 +229,7 @@ namespace ErsatzTV.Core.Scheduling
"Multiple");
index++;
multipleRemaining = None;
customGroup = false;
}
break;
@@ -221,10 +237,13 @@ namespace ErsatzTV.Core.Scheduling
enumerator.Current.Do(
peekMediaItem =>
{
customGroup = true;
MediaVersion peekVersion = peekMediaItem switch
{
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
MusicVideo mv => mv.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(peekMediaItem))
};
@@ -247,6 +266,7 @@ namespace ErsatzTV.Core.Scheduling
"Advancing to next schedule item after playout mode {PlayoutMode}",
"Flood");
index++;
customGroup = false;
}
});
break;
@@ -258,6 +278,7 @@ namespace ErsatzTV.Core.Scheduling
{
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
MusicVideo mv => mv.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(peekMediaItem))
};
@@ -265,6 +286,7 @@ namespace ErsatzTV.Core.Scheduling
if (durationFinish.IsNone)
{
durationFinish = itemStartTime + duration.PlayoutDuration;
customGroup = true;
}
bool willNotFinishInTime =
@@ -277,6 +299,7 @@ namespace ErsatzTV.Core.Scheduling
"Advancing to next schedule item after playout mode {PlayoutMode}",
"Duration");
index++;
customGroup = false;
if (duration.OfflineTail)
{
@@ -298,7 +321,9 @@ namespace ErsatzTV.Core.Scheduling
{
NextScheduleItem = nextScheduleItem,
NextScheduleItemId = nextScheduleItem.Id,
NextStart = GetStartTimeAfter(nextScheduleItem, currentTime).UtcDateTime
NextStart = GetStartTimeAfter(nextScheduleItem, currentTime).UtcDateTime,
MultipleRemaining = multipleRemaining.IsSome ? multipleRemaining.ValueUnsafe() : null,
DurationFinish = durationFinish.IsSome ? durationFinish.ValueUnsafe().UtcDateTime : null
};
// build program schedule anchors
@@ -459,6 +484,9 @@ namespace ErsatzTV.Core.Scheduling
Movie m => m.MovieMetadata.HeadOrNone().Match(
mm => mm.Title ?? string.Empty,
() => "[unknown movie]"),
MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Match(
mvm => $"{mvm.Artist} - {mvm.Title}",
() => "[unknown music video]"),
_ => string.Empty
};
@@ -0,0 +1,23 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations
{
public class MusicVideoConfiguration : IEntityTypeConfiguration<MusicVideo>
{
public void Configure(EntityTypeBuilder<MusicVideo> builder)
{
builder.ToTable("MusicVideo");
builder.HasMany(m => m.MusicVideoMetadata)
.WithOne(m => m.MusicVideo)
.HasForeignKey(m => m.MusicVideoId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(m => m.MediaVersions)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
}
}
}
@@ -0,0 +1,30 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations
{
public class MusicVideoMetadataConfiguration : IEntityTypeConfiguration<MusicVideoMetadata>
{
public void Configure(EntityTypeBuilder<MusicVideoMetadata> builder)
{
builder.ToTable("MusicVideoMetadata");
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);
builder.HasMany(mm => mm.Studios)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
}
}
}
@@ -52,7 +52,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public Task<List<Library>> GetAll()
{
using TvContext context = _dbContextFactory.CreateDbContext();
return context.Libraries.ToListAsync();
return context.Libraries
.AsNoTracking()
.Include(l => l.MediaSource)
.ToListAsync();
}
public Task<Unit> UpdateLastScan(Library library) => _dbConnection.ExecuteAsync(
@@ -104,6 +104,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(c => c.MediaItems)
.ThenInclude(i => (i as Movie).MovieMetadata)
.Include(c => c.MediaItems)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.Include(c => c.MediaItems)
.ThenInclude(i => (i as Show).ShowMetadata)
.Include(c => c.MediaItems)
.ThenInclude(i => (i as Season).Show)
@@ -128,6 +130,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(c => c.MediaItems)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Artwork)
.Include(c => c.MediaItems)
.ThenInclude(i => (i as Show).ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(c => c.MediaItems)
@@ -196,6 +201,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
result.AddRange(await GetShowItems(collection));
result.AddRange(await GetSeasonItems(collection));
result.AddRange(await GetEpisodeItems(collection));
result.AddRange(await GetMusicVideoItems(collection));
return result.Distinct().ToList();
}
@@ -215,6 +221,21 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ToListAsync();
}
private async Task<List<MusicVideo>> GetMusicVideoItems(Collection collection)
{
IEnumerable<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT m.Id FROM CollectionItem ci
INNER JOIN MusicVideo m ON m.Id = ci.MediaItemId
WHERE ci.CollectionId = @CollectionId",
new { CollectionId = collection.Id });
return await _dbContext.MusicVideos
.Include(m => m.MusicVideoMetadata)
.Include(m => m.MediaVersions)
.Filter(m => ids.Contains(m.Id))
.ToListAsync();
}
private async Task<List<Episode>> GetShowItems(Collection collection)
{
IEnumerable<int> ids = await _dbConnection.QueryAsync<int>(
@@ -156,6 +156,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path)",
parameters)
.ToUnit(),
MusicVideoMetadata => _dbConnection.ExecuteAsync(
@"INSERT INTO Artwork (ArtworkKind, MusicVideoMetadataId, DateAdded, DateUpdated, Path)
VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path)",
parameters)
.ToUnit(),
_ => Task.FromResult(Unit.Default)
};
}
@@ -0,0 +1,181 @@
using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Threading.Tasks;
using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using static LanguageExt.Prelude;
namespace ErsatzTV.Infrastructure.Data.Repositories
{
public class MusicVideoRepository : IMusicVideoRepository
{
private readonly IDbConnection _dbConnection;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public MusicVideoRepository(IDbContextFactory<TvContext> dbContextFactory, IDbConnection dbConnection)
{
_dbContextFactory = dbContextFactory;
_dbConnection = dbConnection;
}
public async Task<Option<MusicVideo>> GetByMetadata(LibraryPath libraryPath, MusicVideoMetadata metadata)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Option<int> maybeId = await dbContext.MusicVideoMetadata
.Where(s => s.Artist == metadata.Artist && s.Title == metadata.Title && s.Year == metadata.Year)
.Where(s => s.MusicVideo.LibraryPathId == libraryPath.Id)
.SingleOrDefaultAsync()
.Map(Optional)
.MapT(sm => sm.MusicVideoId);
return await maybeId.Match(
id =>
{
return dbContext.MusicVideos
.AsNoTracking()
.Include(mv => mv.MusicVideoMetadata)
.ThenInclude(mvm => mvm.Artwork)
.Include(mv => mv.MusicVideoMetadata)
.ThenInclude(mvm => mvm.Genres)
.Include(mv => mv.MusicVideoMetadata)
.ThenInclude(mvm => mvm.Tags)
.Include(mv => mv.MusicVideoMetadata)
.ThenInclude(mvm => mvm.Studios)
.Include(mv => mv.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(mv => mv.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.OrderBy(mv => mv.Id)
.SingleOrDefaultAsync(mv => mv.Id == id)
.Map(Optional);
},
() => Option<MusicVideo>.None.AsTask());
}
public async Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> Add(
LibraryPath libraryPath,
string filePath,
MusicVideoMetadata metadata)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
try
{
metadata.DateAdded = DateTime.UtcNow;
metadata.Genres ??= new List<Genre>();
metadata.Tags ??= new List<Tag>();
metadata.Studios ??= new List<Studio>();
var musicVideo = new MusicVideo
{
LibraryPathId = libraryPath.Id,
MusicVideoMetadata = new List<MusicVideoMetadata> { metadata },
MediaVersions = new List<MediaVersion>
{
new()
{
MediaFiles = new List<MediaFile>
{
new() { Path = filePath }
},
Streams = new List<MediaStream>()
}
}
};
await dbContext.MusicVideos.AddAsync(musicVideo);
await dbContext.SaveChangesAsync();
await dbContext.Entry(musicVideo).Reference(s => s.LibraryPath).LoadAsync();
await dbContext.Entry(musicVideo.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return new MediaItemScanResult<MusicVideo>(musicVideo) { IsAdded = true };
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
public Task<IEnumerable<string>> FindMusicVideoPaths(LibraryPath libraryPath) =>
_dbConnection.QueryAsync<string>(
@"SELECT MF.Path
FROM MediaFile MF
INNER JOIN MediaVersion MV on MF.MediaVersionId = MV.Id
INNER JOIN MusicVideo M on MV.MusicVideoId = M.Id
INNER JOIN MediaItem MI on M.Id = MI.Id
WHERE MI.LibraryPathId = @LibraryPathId",
new { LibraryPathId = libraryPath.Id });
public async Task<List<int>> DeleteByPath(LibraryPath libraryPath, string path)
{
List<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT M.Id
FROM MusicVideo M
INNER JOIN MediaItem MI on M.Id = MI.Id
INNER JOIN MediaVersion MV on M.Id = MV.EpisodeId
INNER JOIN MediaFile MF on MV.Id = MF.MediaVersionId
WHERE MI.LibraryPathId = @LibraryPathId AND MF.Path = @Path",
new { LibraryPathId = libraryPath.Id, Path = path }).Map(result => result.ToList());
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
foreach (int musicVideoId in ids)
{
MusicVideo musicVideo = await dbContext.MusicVideos.FindAsync(musicVideoId);
dbContext.MusicVideos.Remove(musicVideo);
}
await dbContext.SaveChangesAsync();
return ids;
}
public Task<bool> AddGenre(MusicVideoMetadata metadata, Genre genre) =>
_dbConnection.ExecuteAsync(
"INSERT INTO Genre (Name, MusicVideoMetadataId) VALUES (@Name, @MetadataId)",
new { genre.Name, MetadataId = metadata.Id }).Map(result => result > 0);
public Task<bool> AddTag(MusicVideoMetadata metadata, Tag tag) =>
_dbConnection.ExecuteAsync(
"INSERT INTO Tag (Name, MusicVideoMetadataId) VALUES (@Name, @MetadataId)",
new { tag.Name, MetadataId = metadata.Id }).Map(result => result > 0);
public Task<bool> AddStudio(MusicVideoMetadata metadata, Studio studio) =>
_dbConnection.ExecuteAsync(
"INSERT INTO Studio (Name, MusicVideoMetadataId) VALUES (@Name, @MetadataId)",
new { studio.Name, MetadataId = metadata.Id }).Map(result => result > 0);
public async Task<List<MusicVideoMetadata>> GetMusicVideosForCards(List<int> ids)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.MusicVideoMetadata
.AsNoTracking()
.Filter(mvm => ids.Contains(mvm.MusicVideoId))
.Include(mvm => mvm.Artwork)
.OrderBy(mvm => mvm.SortTitle)
.ToListAsync();
}
public async Task<Option<MusicVideo>> GetMusicVideo(int musicVideoId)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.MusicVideos
.Include(m => m.MusicVideoMetadata)
.ThenInclude(m => m.Artwork)
.Include(m => m.MusicVideoMetadata)
.ThenInclude(m => m.Genres)
.Include(m => m.MusicVideoMetadata)
.ThenInclude(m => m.Tags)
.Include(m => m.MusicVideoMetadata)
.ThenInclude(m => m.Studios)
.OrderBy(m => m.Id)
.SingleOrDefaultAsync(m => m.Id == musicVideoId)
.Map(Optional);
}
}
}
@@ -65,6 +65,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Movie).MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as MusicVideo).MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as MusicVideo).MediaVersions)
.ThenInclude(mv => mv.Streams)
.AsNoTracking()
.SingleOrDefaultAsync()
.Map(Optional);
@@ -89,6 +95,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Movie).MediaVersions)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as MusicVideo).MusicVideoMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as MusicVideo).MediaVersions)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Episode).EpisodeMetadata)
.ThenInclude(em => em.Artwork)
.Include(i => i.MediaItem)
@@ -96,6 +107,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Episode).Season)
.ThenInclude(s => s.SeasonMetadata)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Episode).Season.Show)
.ThenInclude(s => s.ShowMetadata)
.Filter(i => i.PlayoutId == playoutId)
.ToListAsync();
}
@@ -27,6 +27,8 @@ namespace ErsatzTV.Infrastructure.Data
public DbSet<MediaFile> MediaFiles { get; set; }
public DbSet<Movie> Movies { get; set; }
public DbSet<MovieMetadata> MovieMetadata { get; set; }
public DbSet<MusicVideo> MusicVideos { get; set; }
public DbSet<MusicVideoMetadata> MusicVideoMetadata { get; set; }
public DbSet<Show> Shows { get; set; }
public DbSet<ShowMetadata> ShowMetadata { get; set; }
public DbSet<Season> Seasons { get; set; }
+31 -17
View File
@@ -8,6 +8,7 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata;
using LanguageExt;
using Microsoft.Extensions.Logging;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Jpeg;
using SixLabors.ImageSharp.Processing;
@@ -18,10 +19,15 @@ namespace ErsatzTV.Infrastructure.Images
{
private static readonly SHA1CryptoServiceProvider Crypto;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<ImageCache> _logger;
static ImageCache() => Crypto = new SHA1CryptoServiceProvider();
public ImageCache(ILocalFileSystem localFileSystem) => _localFileSystem = localFileSystem;
public ImageCache(ILocalFileSystem localFileSystem, ILogger<ImageCache> logger)
{
_localFileSystem = localFileSystem;
_logger = logger;
}
public async Task<Either<BaseError, byte[]>> ResizeImage(byte[] imageBuffer, int height)
{
@@ -75,24 +81,32 @@ namespace ErsatzTV.Infrastructure.Images
}
}
public string CopyArtworkToCache(string path, ArtworkKind artworkKind)
public async Task<Either<BaseError, string>> CopyArtworkToCache(string path, ArtworkKind artworkKind)
{
var filenameKey = $"{path}:{_localFileSystem.GetLastWriteTime(path).ToFileTimeUtc()}";
byte[] hash = Crypto.ComputeHash(Encoding.UTF8.GetBytes(filenameKey));
string hex = BitConverter.ToString(hash).Replace("-", string.Empty);
string subfolder = hex.Substring(0, 2);
string baseFolder = artworkKind switch
try
{
ArtworkKind.Poster => Path.Combine(FileSystemLayout.PosterCacheFolder, subfolder),
ArtworkKind.Thumbnail => Path.Combine(FileSystemLayout.ThumbnailCacheFolder, subfolder),
ArtworkKind.Logo => Path.Combine(FileSystemLayout.LogoCacheFolder, subfolder),
ArtworkKind.FanArt => Path.Combine(FileSystemLayout.FanArtCacheFolder, subfolder),
_ => FileSystemLayout.LegacyImageCacheFolder
};
string target = Path.Combine(baseFolder, hex);
_localFileSystem.CopyFile(path, target);
return hex;
var filenameKey = $"{path}:{_localFileSystem.GetLastWriteTime(path).ToFileTimeUtc()}";
byte[] hash = Crypto.ComputeHash(Encoding.UTF8.GetBytes(filenameKey));
string hex = BitConverter.ToString(hash).Replace("-", string.Empty);
string subfolder = hex.Substring(0, 2);
string baseFolder = artworkKind switch
{
ArtworkKind.Poster => Path.Combine(FileSystemLayout.PosterCacheFolder, subfolder),
ArtworkKind.Thumbnail => Path.Combine(FileSystemLayout.ThumbnailCacheFolder, subfolder),
ArtworkKind.Logo => Path.Combine(FileSystemLayout.LogoCacheFolder, subfolder),
ArtworkKind.FanArt => Path.Combine(FileSystemLayout.FanArtCacheFolder, subfolder),
_ => FileSystemLayout.LegacyImageCacheFolder
};
string target = Path.Combine(baseFolder, hex);
Either<BaseError, Unit> maybeResult = await _localFileSystem.CopyFile(path, target);
return maybeResult.Match<Either<BaseError, string>>(
_ => hex,
error => error);
}
catch (Exception ex)
{
return BaseError.New(ex.ToString());
}
}
}
}
@@ -0,0 +1,34 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_PlayoutAnchor_DurationMultiple : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
"Anchor_DurationFinish",
"Playout",
"TEXT",
nullable: true);
migrationBuilder.AddColumn<int>(
"Anchor_MultipleRemaining",
"Playout",
"INTEGER",
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
"Anchor_DurationFinish",
"Playout");
migrationBuilder.DropColumn(
"Anchor_MultipleRemaining",
"Playout");
}
}
}
@@ -0,0 +1,44 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_ProgramScheduleItem_CustomTitle : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
"CustomTitle",
"ProgramScheduleItem",
"TEXT",
nullable: true);
migrationBuilder.AddColumn<bool>(
"CustomGroup",
"PlayoutItem",
"INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
"CustomTitle",
"PlayoutItem",
"TEXT",
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
"CustomTitle",
"ProgramScheduleItem");
migrationBuilder.DropColumn(
"CustomGroup",
"PlayoutItem");
migrationBuilder.DropColumn(
"CustomTitle",
"PlayoutItem");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,14 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Update_FFmpegProfile_ThreadCount : Migration
{
protected override void Up(MigrationBuilder migrationBuilder) =>
migrationBuilder.Sql(@"UPDATE FFmpegProfile SET ThreadCount = 0 WHERE ThreadCount = 4");
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,45 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_FFmpegProfile_FrameRate : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
"NormalizeResolution",
"FFmpegProfile");
migrationBuilder.RenameColumn(
"NormalizeVideoCodec",
"FFmpegProfile",
"NormalizeVideo");
migrationBuilder.AddColumn<string>(
"FrameRate",
"FFmpegProfile",
"TEXT",
nullable: true,
defaultValue: "24");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
"FrameRate",
"FFmpegProfile");
migrationBuilder.RenameColumn(
"NormalizeVideo",
"FFmpegProfile",
"NormalizeVideoCodec");
migrationBuilder.AddColumn<bool>(
"NormalizeResolution",
"FFmpegProfile",
"INTEGER",
nullable: false,
defaultValue: false);
}
}
}
@@ -0,0 +1,20 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Remove_FFmpegProfile_NormalizeAudioCodec : Migration
{
protected override void Up(MigrationBuilder migrationBuilder) =>
migrationBuilder.DropColumn(
"NormalizeAudioCodec",
"FFmpegProfile");
protected override void Down(MigrationBuilder migrationBuilder) =>
migrationBuilder.AddColumn<bool>(
"NormalizeAudioCodec",
"FFmpegProfile",
"INTEGER",
nullable: false,
defaultValue: false);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_LocalLibrary_MusicVideos : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
// create local music videos library
migrationBuilder.Sql(
@"INSERT INTO Library (Name, MediaKind, MediaSourceId)
SELECT 'Music Videos', 3, Id FROM
(SELECT LMS.Id FROM LocalMediaSource LMS
INNER JOIN Library L on L.MediaSourceId = LMS.Id
INNER JOIN LocalLibrary LL on L.Id = LL.Id
WHERE L.Name = 'Movies')");
migrationBuilder.Sql("INSERT INTO LocalLibrary (Id) Values (last_insert_rowid())");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,228 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_MusicVideo_MusicVideoMetadata : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
"MusicVideoMetadataId",
"Tag",
"INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
"MusicVideoMetadataId",
"Studio",
"INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
"MusicVideoId",
"MediaVersion",
"INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
"MusicVideoMetadataId",
"Genre",
"INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
"MusicVideoMetadataId",
"Artwork",
"INTEGER",
nullable: true);
migrationBuilder.CreateTable(
"MusicVideo",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true)
},
constraints: table =>
{
table.PrimaryKey("PK_MusicVideo", x => x.Id);
table.ForeignKey(
"FK_MusicVideo_MediaItem_Id",
x => x.Id,
"MediaItem",
"Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
"MusicVideoMetadata",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Album = table.Column<string>("TEXT", nullable: true),
Plot = table.Column<string>("TEXT", nullable: true),
Artist = table.Column<string>("TEXT", nullable: true),
MusicVideoId = table.Column<int>("INTEGER", nullable: false),
MetadataKind = table.Column<int>("INTEGER", nullable: false),
Title = table.Column<string>("TEXT", nullable: true),
OriginalTitle = table.Column<string>("TEXT", nullable: true),
SortTitle = table.Column<string>("TEXT", nullable: true),
Year = table.Column<int>("INTEGER", nullable: true),
ReleaseDate = table.Column<DateTime>("TEXT", nullable: true),
DateAdded = table.Column<DateTime>("TEXT", nullable: false),
DateUpdated = table.Column<DateTime>("TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_MusicVideoMetadata", x => x.Id);
table.ForeignKey(
"FK_MusicVideoMetadata_MusicVideo_MusicVideoId",
x => x.MusicVideoId,
"MusicVideo",
"Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
"IX_Tag_MusicVideoMetadataId",
"Tag",
"MusicVideoMetadataId");
migrationBuilder.CreateIndex(
"IX_Studio_MusicVideoMetadataId",
"Studio",
"MusicVideoMetadataId");
migrationBuilder.CreateIndex(
"IX_MediaVersion_MusicVideoId",
"MediaVersion",
"MusicVideoId");
migrationBuilder.CreateIndex(
"IX_Genre_MusicVideoMetadataId",
"Genre",
"MusicVideoMetadataId");
migrationBuilder.CreateIndex(
"IX_Artwork_MusicVideoMetadataId",
"Artwork",
"MusicVideoMetadataId");
migrationBuilder.CreateIndex(
"IX_MusicVideoMetadata_MusicVideoId",
"MusicVideoMetadata",
"MusicVideoId");
migrationBuilder.AddForeignKey(
"FK_Artwork_MusicVideoMetadata_MusicVideoMetadataId",
"Artwork",
"MusicVideoMetadataId",
"MusicVideoMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
"FK_Genre_MusicVideoMetadata_MusicVideoMetadataId",
"Genre",
"MusicVideoMetadataId",
"MusicVideoMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
"FK_MediaVersion_MusicVideo_MusicVideoId",
"MediaVersion",
"MusicVideoId",
"MusicVideo",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
"FK_Studio_MusicVideoMetadata_MusicVideoMetadataId",
"Studio",
"MusicVideoMetadataId",
"MusicVideoMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
"FK_Tag_MusicVideoMetadata_MusicVideoMetadataId",
"Tag",
"MusicVideoMetadataId",
"MusicVideoMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
"FK_Artwork_MusicVideoMetadata_MusicVideoMetadataId",
"Artwork");
migrationBuilder.DropForeignKey(
"FK_Genre_MusicVideoMetadata_MusicVideoMetadataId",
"Genre");
migrationBuilder.DropForeignKey(
"FK_MediaVersion_MusicVideo_MusicVideoId",
"MediaVersion");
migrationBuilder.DropForeignKey(
"FK_Studio_MusicVideoMetadata_MusicVideoMetadataId",
"Studio");
migrationBuilder.DropForeignKey(
"FK_Tag_MusicVideoMetadata_MusicVideoMetadataId",
"Tag");
migrationBuilder.DropTable(
"MusicVideoMetadata");
migrationBuilder.DropTable(
"MusicVideo");
migrationBuilder.DropIndex(
"IX_Tag_MusicVideoMetadataId",
"Tag");
migrationBuilder.DropIndex(
"IX_Studio_MusicVideoMetadataId",
"Studio");
migrationBuilder.DropIndex(
"IX_MediaVersion_MusicVideoId",
"MediaVersion");
migrationBuilder.DropIndex(
"IX_Genre_MusicVideoMetadataId",
"Genre");
migrationBuilder.DropIndex(
"IX_Artwork_MusicVideoMetadataId",
"Artwork");
migrationBuilder.DropColumn(
"MusicVideoMetadataId",
"Tag");
migrationBuilder.DropColumn(
"MusicVideoMetadataId",
"Studio");
migrationBuilder.DropColumn(
"MusicVideoId",
"MediaVersion");
migrationBuilder.DropColumn(
"MusicVideoMetadataId",
"Genre");
migrationBuilder.DropColumn(
"MusicVideoMetadataId",
"Artwork");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,43 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Remove_MediaVersion_Codecs : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
"AudioCodec",
"MediaVersion");
migrationBuilder.DropColumn(
"VideoCodec",
"MediaVersion");
migrationBuilder.DropColumn(
"VideoProfile",
"MediaVersion");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
"AudioCodec",
"MediaVersion",
"TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
"VideoCodec",
"MediaVersion",
"TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
"VideoProfile",
"MediaVersion",
"TEXT",
nullable: true);
}
}
}
@@ -42,6 +42,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int?>("MovieMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("MusicVideoMetadataId")
.HasColumnType("INTEGER");
b.Property<string>("Path")
.HasColumnType("TEXT");
@@ -59,6 +62,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasIndex("MovieMetadataId");
b.HasIndex("MusicVideoMetadataId");
b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId");
@@ -240,6 +245,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int>("AudioVolume")
.HasColumnType("INTEGER");
b.Property<string>("FrameRate")
.HasColumnType("TEXT");
b.Property<int>("HardwareAcceleration")
.HasColumnType("INTEGER");
@@ -249,13 +257,7 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<bool>("NormalizeAudio")
.HasColumnType("INTEGER");
b.Property<bool>("NormalizeAudioCodec")
.HasColumnType("INTEGER");
b.Property<bool>("NormalizeResolution")
.HasColumnType("INTEGER");
b.Property<bool>("NormalizeVideoCodec")
b.Property<bool>("NormalizeVideo")
.HasColumnType("INTEGER");
b.Property<int>("ResolutionId")
@@ -297,6 +299,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int?>("MovieMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("MusicVideoMetadataId")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
@@ -312,6 +317,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasIndex("MovieMetadataId");
b.HasIndex("MusicVideoMetadataId");
b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId");
@@ -475,9 +482,6 @@ namespace ErsatzTV.Infrastructure.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudioCodec")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAdded")
.HasColumnType("TEXT");
@@ -499,18 +503,15 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int?>("MovieId")
.HasColumnType("INTEGER");
b.Property<int?>("MusicVideoId")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("SampleAspectRatio")
.HasColumnType("TEXT");
b.Property<string>("VideoCodec")
.HasColumnType("TEXT");
b.Property<string>("VideoProfile")
.HasColumnType("TEXT");
b.Property<int>("VideoScanKind")
.HasColumnType("INTEGER");
@@ -523,6 +524,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasIndex("MovieId");
b.HasIndex("MusicVideoId");
b.ToTable("MediaVersion");
});
@@ -577,6 +580,57 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("MovieMetadata");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.MusicVideoMetadata",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Album")
.HasColumnType("TEXT");
b.Property<string>("Artist")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAdded")
.HasColumnType("TEXT");
b.Property<DateTime>("DateUpdated")
.HasColumnType("TEXT");
b.Property<int>("MetadataKind")
.HasColumnType("INTEGER");
b.Property<int>("MusicVideoId")
.HasColumnType("INTEGER");
b.Property<string>("OriginalTitle")
.HasColumnType("TEXT");
b.Property<string>("Plot")
.HasColumnType("TEXT");
b.Property<DateTime?>("ReleaseDate")
.HasColumnType("TEXT");
b.Property<string>("SortTitle")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.Property<int?>("Year")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("MusicVideoId");
b.ToTable("MusicVideoMetadata");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Playout",
b =>
@@ -611,6 +665,12 @@ namespace ErsatzTV.Infrastructure.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("CustomGroup")
.HasColumnType("INTEGER");
b.Property<string>("CustomTitle")
.HasColumnType("TEXT");
b.Property<DateTime>("Finish")
.HasColumnType("TEXT");
@@ -752,6 +812,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int>("CollectionType")
.HasColumnType("INTEGER");
b.Property<string>("CustomTitle")
.HasColumnType("TEXT");
b.Property<int>("Index")
.HasColumnType("INTEGER");
@@ -907,6 +970,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int?>("MovieMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("MusicVideoMetadataId")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
@@ -922,6 +988,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasIndex("MovieMetadataId");
b.HasIndex("MusicVideoMetadataId");
b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId");
@@ -943,6 +1011,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int?>("MovieMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("MusicVideoMetadataId")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
@@ -958,6 +1029,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasIndex("MovieMetadataId");
b.HasIndex("MusicVideoMetadataId");
b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId");
@@ -1030,6 +1103,15 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("Movie");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.MusicVideo",
b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaItem");
b.ToTable("MusicVideo");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Season",
b =>
@@ -1201,6 +1283,11 @@ namespace ErsatzTV.Infrastructure.Migrations
.HasForeignKey("MovieMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.MusicVideoMetadata", null)
.WithMany("Artwork")
.HasForeignKey("MusicVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Artwork")
.HasForeignKey("SeasonMetadataId")
@@ -1285,6 +1372,11 @@ namespace ErsatzTV.Infrastructure.Migrations
.HasForeignKey("MovieMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.MusicVideoMetadata", null)
.WithMany("Genres")
.HasForeignKey("MusicVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Genres")
.HasForeignKey("SeasonMetadataId");
@@ -1373,6 +1465,11 @@ namespace ErsatzTV.Infrastructure.Migrations
.WithMany("MediaVersions")
.HasForeignKey("MovieId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.MusicVideo", null)
.WithMany("MediaVersions")
.HasForeignKey("MusicVideoId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity(
@@ -1388,6 +1485,19 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Movie");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.MusicVideoMetadata",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.MusicVideo", "MusicVideo")
.WithMany("MusicVideoMetadata")
.HasForeignKey("MusicVideoId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("MusicVideo");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Playout",
b =>
@@ -1412,6 +1522,12 @@ namespace ErsatzTV.Infrastructure.Migrations
b1.Property<int>("PlayoutId")
.HasColumnType("INTEGER");
b1.Property<DateTime?>("DurationFinish")
.HasColumnType("TEXT");
b1.Property<int?>("MultipleRemaining")
.HasColumnType("INTEGER");
b1.Property<int>("NextScheduleItemId")
.HasColumnType("INTEGER");
@@ -1615,6 +1731,11 @@ namespace ErsatzTV.Infrastructure.Migrations
.HasForeignKey("MovieMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.MusicVideoMetadata", null)
.WithMany("Studios")
.HasForeignKey("MusicVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Studios")
.HasForeignKey("SeasonMetadataId");
@@ -1638,6 +1759,11 @@ namespace ErsatzTV.Infrastructure.Migrations
.HasForeignKey("MovieMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.MusicVideoMetadata", null)
.WithMany("Tags")
.HasForeignKey("MusicVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Tags")
.HasForeignKey("SeasonMetadataId");
@@ -1711,6 +1837,17 @@ namespace ErsatzTV.Infrastructure.Migrations
.IsRequired();
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.MusicVideo",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaItem", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.MusicVideo", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Season",
b =>
@@ -1905,6 +2042,19 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Tags");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.MusicVideoMetadata",
b =>
{
b.Navigation("Artwork");
b.Navigation("Genres");
b.Navigation("Studios");
b.Navigation("Tags");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Playout",
b =>
@@ -1967,6 +2117,15 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("MovieMetadata");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.MusicVideo",
b =>
{
b.Navigation("MediaVersions");
b.Navigation("MusicVideoMetadata");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Season",
b =>
@@ -12,8 +12,8 @@ namespace ErsatzTV.Infrastructure.Plex.Models
public string Thumb { get; set; }
public string Art { get; set; }
public string OriginallyAvailableAt { get; set; }
public int AddedAt { get; set; }
public int UpdatedAt { get; set; }
public long AddedAt { get; set; }
public long UpdatedAt { get; set; }
public int Index { get; set; }
public string Studio { get; set; }
public List<PlexMediaResponse> Media { get; set; }
@@ -38,7 +38,7 @@ namespace ErsatzTV.Infrastructure.Plex
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
return BaseError.New(ex.ToString());
}
}
@@ -56,7 +56,7 @@ namespace ErsatzTV.Infrastructure.Plex
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
return BaseError.New(ex.ToString());
}
}
@@ -74,7 +74,7 @@ namespace ErsatzTV.Infrastructure.Plex
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
return BaseError.New(ex.ToString());
}
}
@@ -93,7 +93,7 @@ namespace ErsatzTV.Infrastructure.Plex
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
return BaseError.New(ex.ToString());
}
}
@@ -112,7 +112,7 @@ namespace ErsatzTV.Infrastructure.Plex
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
return BaseError.New(ex.ToString());
}
}
@@ -133,7 +133,7 @@ namespace ErsatzTV.Infrastructure.Plex
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
return BaseError.New(ex.ToString());
}
}
@@ -29,6 +29,7 @@ namespace ErsatzTV.Infrastructure.Search
private const string IdField = "id";
private const string TypeField = "type";
private const string ArtistField = "artist";
private const string TitleField = "title";
private const string SortTitleField = "sort_title";
private const string GenreField = "genre";
@@ -42,6 +43,7 @@ namespace ErsatzTV.Infrastructure.Search
private const string MovieType = "movie";
private const string ShowType = "show";
private const string MusicVideoType = "music_video";
private static bool _isRebuilding;
@@ -93,6 +95,9 @@ namespace ErsatzTV.Infrastructure.Search
case Show show:
UpdateShow(show, writer);
break;
case MusicVideo musicVideo:
UpdateMusicVideo(musicVideo, writer);
break;
}
}
}
@@ -121,6 +126,9 @@ namespace ErsatzTV.Infrastructure.Search
case Show show:
UpdateShow(show, writer);
break;
case MusicVideo musicVideo:
UpdateMusicVideo(musicVideo, writer);
break;
}
}
@@ -338,6 +346,66 @@ namespace ErsatzTV.Infrastructure.Search
}
}
private void UpdateMusicVideo(MusicVideo musicVideo, IndexWriter writer)
{
Option<MusicVideoMetadata> maybeMetadata = musicVideo.MusicVideoMetadata.HeadOrNone();
if (maybeMetadata.IsSome)
{
MusicVideoMetadata metadata = maybeMetadata.ValueUnsafe();
try
{
var doc = new Document
{
new StringField(IdField, musicVideo.Id.ToString(), Field.Store.YES),
new StringField(TypeField, MusicVideoType, Field.Store.NO),
new TextField(ArtistField, metadata.Artist, Field.Store.NO),
new TextField(TitleField, metadata.Title, Field.Store.NO),
new StringField(SortTitleField, metadata.SortTitle.ToLowerInvariant(), Field.Store.NO),
new TextField(LibraryNameField, musicVideo.LibraryPath.Library.Name, Field.Store.NO),
new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO),
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES)
};
if (metadata.ReleaseDate.HasValue)
{
doc.Add(
new StringField(
ReleaseDateField,
metadata.ReleaseDate.Value.ToString("yyyyMMdd"),
Field.Store.NO));
}
if (!string.IsNullOrWhiteSpace(metadata.Plot))
{
doc.Add(new TextField(PlotField, metadata.Plot ?? string.Empty, Field.Store.NO));
}
foreach (Genre genre in metadata.Genres)
{
doc.Add(new TextField(GenreField, genre.Name, Field.Store.NO));
}
foreach (Tag tag in metadata.Tags)
{
doc.Add(new TextField(TagField, tag.Name, Field.Store.NO));
}
foreach (Studio studio in metadata.Studios)
{
doc.Add(new TextField(StudioField, studio.Name, Field.Store.NO));
}
writer.UpdateDocument(new Term(IdField, musicVideo.Id.ToString()), doc);
}
catch (Exception ex)
{
metadata.MusicVideo = null;
_logger.LogWarning(ex, "Error indexing music video with metadata {@Metadata}", metadata);
}
}
}
private SearchItem ProjectToSearchItem(Document doc) => new(Convert.ToInt32(doc.Get(IdField)));
private Query ParseQuery(string searchQuery, QueryParser parser)
+3
View File
@@ -44,6 +44,9 @@ namespace ErsatzTV.Controllers
process =>
{
_logger.LogInformation("Starting ts stream for channel {ChannelNumber}", channelNumber);
// _logger.LogDebug(
// "ffmpeg concat arguments {FFmpegArguments}",
// string.Join(" ", process.StartInfo.ArgumentList));
process.Start();
return new FileStreamResult(process.StandardOutput.BaseStream, "video/mp2t");
},
+46 -8
View File
@@ -52,6 +52,10 @@
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#episodes")">@_data.EpisodeCards.Count Episodes</MudLink>
}
@if (_data.MusicVideoCards.Any())
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#music_videos")">@_data.MusicVideoCards.Count Music Videos</MudLink>
}
@if (SupportsCustomOrdering())
{
<div style="margin-left: auto">
@@ -165,6 +169,29 @@
}
</MudContainer>
}
@if (_data.MusicVideoCards.Any())
{
<MudText GutterBottom="true"
Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "music_videos" } })">
Music Videos
</MudText>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (MusicVideoCardViewModel card in _data.MusicVideoCards.OrderBy(e => e.SortTitle))
{
<MediaCard Data="@card"
Link=""
DeleteClicked="@RemoveMusicVideoFromCollection"
SelectColor="@Color.Error"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudContainer>
}
</MudContainer>
@code {
@@ -215,14 +242,12 @@
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();
}
List<MediaCardViewModel> GetSortedItems() => _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))
.Append(_data.MusicVideoCards.OrderBy(mv => mv.SortTitle))
.ToList();
SelectClicked(GetSortedItems, card, e);
}
@@ -240,6 +265,19 @@
}
}
private async Task RemoveMusicVideoFromCollection(MediaCardViewModel vm)
{
if (vm is MusicVideoCardViewModel musicVideo)
{
var request = new RemoveItemsFromCollection(Id)
{
MediaItemIds = new List<int> { musicVideo.MusicVideoId }
};
await RemoveItemsWithConfirmation("music video", $"{musicVideo.Title} ({musicVideo.Subtitle})", request);
}
}
private async Task RemoveShowFromCollection(MediaCardViewModel vm)
{
if (vm is TelevisionShowCardViewModel show)
+3 -3
View File
@@ -74,17 +74,17 @@
@(context.Transcode ? "Yes" : "No")
</MudTd>
<MudTd DataLabel="Resolution">
<MudText Color="@(context.Transcode && context.NormalizeResolution ? Color.Tertiary : Color.Inherit)">
<MudText Color="@(context.Transcode && context.NormalizeVideo ? Color.Tertiary : Color.Inherit)">
@context.Resolution.Name
</MudText>
</MudTd>
<MudTd DataLabel="Video Codec">
<MudText Color="@(context.Transcode && context.NormalizeVideoCodec ? Color.Tertiary : Color.Inherit)">
<MudText Color="@(context.Transcode && context.NormalizeVideo ? Color.Tertiary : Color.Inherit)">
@context.VideoCodec
</MudText>
</MudTd>
<MudTd DataLabel="Audio Codec">
<MudText Color="@(context.Transcode && context.NormalizeAudioCodec ? Color.Tertiary : Color.Inherit)">
<MudText Color="@(context.Transcode && context.NormalizeAudio ? Color.Tertiary : Color.Inherit)">
@context.AudioCodec
</MudText>
</MudTd>
+4 -7
View File
@@ -58,6 +58,9 @@
}
</MudSelect>
</MudElement>
<MudElement HtmlTag="div" Class="mt-3">
<MudTextField Disabled="@(!_model.Transcode)" Label="Frame Rate" @bind-Value="_model.FrameRate" For="@(() => _model.FrameRate)" Adornment="Adornment.End" AdornmentText="fps"/>
</MudElement>
</MudItem>
<MudItem>
<MudText Typo="Typo.h6">Audio</MudText>
@@ -80,13 +83,7 @@
</MudItem>
<MudItem>
<MudText Typo="Typo.h6">Normalization</MudText>
<MudCheckBox Disabled="@(!_model.Transcode)" Label="Normalize Resolution" @bind-Checked="@_model.NormalizeResolution" For="@(() => _model.NormalizeResolution)"/>
<MudElement HtmlTag="div" Class="mt-3">
<MudCheckBox Disabled="@(!_model.Transcode)" Label="Normalize Video Codec" @bind-Checked="@_model.NormalizeVideoCodec" For="@(() => _model.NormalizeVideoCodec)"/>
</MudElement>
<MudElement HtmlTag="div" Class="mt-3">
<MudCheckBox Disabled="@(!_model.Transcode)" Label="Normalize Audio Codec" @bind-Checked="@_model.NormalizeAudioCodec" For="@(() => _model.NormalizeAudioCodec)"/>
</MudElement>
<MudCheckBox Disabled="@(!_model.Transcode)" Label="Normalize Video" @bind-Checked="@_model.NormalizeVideo" For="@(() => _model.NormalizeVideo)"/>
<MudElement HtmlTag="div" Class="mt-3">
<MudCheckBox Disabled="@(!_model.Transcode)" Label="Normalize Audio" @bind-Checked="@_model.NormalizeAudio" For="@(() => _model.NormalizeAudio)"/>
</MudElement>

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