Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efae005447 | ||
|
|
cead787c55 | ||
|
|
77a69af1a8 | ||
|
|
8fea24a3a5 | ||
|
|
6b44873474 | ||
|
|
c5ee5903b2 | ||
|
|
526eada48b | ||
|
|
7a0d65a433 | ||
|
|
74c95249c3 | ||
|
|
d4a2197dfa | ||
|
|
633586ddba | ||
|
|
da3e05b231 | ||
|
|
9e6de7e2eb | ||
|
|
4097288fed | ||
|
|
90f775aab4 | ||
|
|
fc33c5cd05 | ||
|
|
37eee73ab7 |
@@ -40,3 +40,6 @@ msbuild.wrn
|
||||
core
|
||||
|
||||
scripts/generate-api-sdk/swagger.json
|
||||
|
||||
docker-compose.override.yml
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ namespace ErsatzTV.Application.Channels.Commands
|
||||
private async Task<Validation<BaseError, string>> ValidateNumber(UpdateChannel updateChannel)
|
||||
{
|
||||
Option<Channel> match = await _channelRepository.GetByNumber(updateChannel.Number);
|
||||
int matchId = match.Map(c => c.Id).IfNone(updateChannel.ChannelId);
|
||||
int matchId = await match.Map(c => c.Id).IfNoneAsync(updateChannel.ChannelId);
|
||||
if (matchId == updateChannel.ChannelId)
|
||||
{
|
||||
if (Regex.IsMatch(updateChannel.Number, Channel.NumberValidator))
|
||||
|
||||
@@ -2,11 +2,20 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
<NoWarn>VSTHRD200</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AsyncFixer" Version="1.5.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="MediatR" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="5.0.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.9.60">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
|
||||
<PackageReference Include="Winista.MimeDetect" Version="1.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -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,
|
||||
bool NormalizeLoudness,
|
||||
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,
|
||||
NormalizeLoudness = request.NormalizeLoudness,
|
||||
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,
|
||||
bool NormalizeLoudness,
|
||||
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.NormalizeLoudness = update.NormalizeLoudness;
|
||||
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))
|
||||
|
||||
@@ -71,87 +71,32 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
|
||||
|
||||
private async Task<Unit> ApplyUpdate(UpdateFFmpegSettings request)
|
||||
{
|
||||
await _configElementRepository.Get(ConfigElementKey.FFmpegPath).Match(
|
||||
ce =>
|
||||
{
|
||||
ce.Value = request.Settings.FFmpegPath;
|
||||
_configElementRepository.Update(ce);
|
||||
},
|
||||
() =>
|
||||
{
|
||||
var ce = new ConfigElement
|
||||
{ Key = ConfigElementKey.FFmpegPath.Key, Value = request.Settings.FFmpegPath };
|
||||
_configElementRepository.Add(ce);
|
||||
});
|
||||
|
||||
await _configElementRepository.Get(ConfigElementKey.FFprobePath).Match(
|
||||
ce =>
|
||||
{
|
||||
ce.Value = request.Settings.FFprobePath;
|
||||
_configElementRepository.Update(ce);
|
||||
},
|
||||
() =>
|
||||
{
|
||||
var ce = new ConfigElement
|
||||
{ Key = ConfigElementKey.FFprobePath.Key, Value = request.Settings.FFprobePath };
|
||||
_configElementRepository.Add(ce);
|
||||
});
|
||||
|
||||
await _configElementRepository.Get(ConfigElementKey.FFmpegDefaultProfileId).Match(
|
||||
ce =>
|
||||
{
|
||||
ce.Value = request.Settings.DefaultFFmpegProfileId.ToString();
|
||||
_configElementRepository.Update(ce);
|
||||
},
|
||||
() =>
|
||||
{
|
||||
var ce = new ConfigElement
|
||||
{
|
||||
Key = ConfigElementKey.FFmpegDefaultProfileId.Key,
|
||||
Value = request.Settings.DefaultFFmpegProfileId.ToString()
|
||||
};
|
||||
_configElementRepository.Add(ce);
|
||||
});
|
||||
|
||||
await _configElementRepository.Get(ConfigElementKey.FFmpegSaveReports).Match(
|
||||
ce =>
|
||||
{
|
||||
ce.Value = request.Settings.SaveReports.ToString();
|
||||
_configElementRepository.Update(ce);
|
||||
},
|
||||
() =>
|
||||
{
|
||||
var ce = new ConfigElement
|
||||
{
|
||||
Key = ConfigElementKey.FFmpegSaveReports.Key,
|
||||
Value = request.Settings.SaveReports.ToString()
|
||||
};
|
||||
_configElementRepository.Add(ce);
|
||||
});
|
||||
await Upsert(ConfigElementKey.FFmpegPath, request.Settings.FFmpegPath);
|
||||
await Upsert(ConfigElementKey.FFprobePath, request.Settings.FFprobePath);
|
||||
await Upsert(ConfigElementKey.FFmpegDefaultProfileId, request.Settings.DefaultFFmpegProfileId.ToString());
|
||||
await Upsert(ConfigElementKey.FFmpegSaveReports, request.Settings.SaveReports.ToString());
|
||||
|
||||
if (request.Settings.SaveReports && !Directory.Exists(FileSystemLayout.FFmpegReportsFolder))
|
||||
{
|
||||
Directory.CreateDirectory(FileSystemLayout.FFmpegReportsFolder);
|
||||
}
|
||||
|
||||
await _configElementRepository.Get(ConfigElementKey.FFmpegPreferredLanguageCode).Match(
|
||||
ce =>
|
||||
{
|
||||
ce.Value = request.Settings.PreferredLanguageCode;
|
||||
_configElementRepository.Update(ce);
|
||||
},
|
||||
() =>
|
||||
{
|
||||
var ce = new ConfigElement
|
||||
{
|
||||
Key = ConfigElementKey.FFmpegPreferredLanguageCode.Key,
|
||||
Value = request.Settings.PreferredLanguageCode
|
||||
};
|
||||
_configElementRepository.Add(ce);
|
||||
});
|
||||
|
||||
await Upsert(ConfigElementKey.FFmpegPreferredLanguageCode, request.Settings.PreferredLanguageCode);
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private Task Upsert(ConfigElementKey key, string value) =>
|
||||
_configElementRepository.Get(key).Match(
|
||||
ce =>
|
||||
{
|
||||
ce.Value = value;
|
||||
return _configElementRepository.Update(ce);
|
||||
},
|
||||
() =>
|
||||
{
|
||||
var ce = new ConfigElement { Key = key.Key, Value = value };
|
||||
return _configElementRepository.Add(ce);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
bool NormalizeLoudness,
|
||||
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.NormalizeLoudness,
|
||||
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);
|
||||
|
||||
@@ -29,11 +29,11 @@ namespace ErsatzTV.Application.FFmpegProfiles.Queries
|
||||
|
||||
return new FFmpegSettingsViewModel
|
||||
{
|
||||
FFmpegPath = ffmpegPath.IfNone(string.Empty),
|
||||
FFprobePath = ffprobePath.IfNone(string.Empty),
|
||||
DefaultFFmpegProfileId = defaultFFmpegProfileId.IfNone(0),
|
||||
SaveReports = saveReports.IfNone(false),
|
||||
PreferredLanguageCode = preferredLanguageCode.IfNone("eng")
|
||||
FFmpegPath = await ffmpegPath.IfNoneAsync(string.Empty),
|
||||
FFprobePath = await ffprobePath.IfNoneAsync(string.Empty),
|
||||
DefaultFFmpegProfileId = await defaultFFmpegProfileId.IfNoneAsync(0),
|
||||
SaveReports = await saveReports.IfNoneAsync(false),
|
||||
PreferredLanguageCode = await preferredLanguageCode.IfNoneAsync("eng")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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
-1
@@ -39,7 +39,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
Option<CollectionItem> maybeCollectionItem =
|
||||
c.CollectionItems.FirstOrDefault(ci => ci.MediaItemId == updateItem.MediaItemId);
|
||||
|
||||
maybeCollectionItem.IfSome(ci => ci.CustomIndex = updateItem.CustomIndex);
|
||||
await maybeCollectionItem.IfSomeAsync(ci => ci.CustomIndex = updateItem.CustomIndex);
|
||||
}
|
||||
|
||||
if (await _mediaCollectionRepository.Update(c))
|
||||
|
||||
@@ -32,7 +32,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
private async Task<Unit> ApplyUpdateRequest(Collection c, UpdateCollection request)
|
||||
{
|
||||
c.Name = request.Name;
|
||||
request.UseCustomPlaybackOrder.IfSome(
|
||||
await request.UseCustomPlaybackOrder.IfSomeAsync(
|
||||
useCustomPlaybackOrder => c.UseCustomPlaybackOrder = useCustomPlaybackOrder);
|
||||
if (await _mediaCollectionRepository.Update(c) && request.UseCustomPlaybackOrder.IsSome)
|
||||
{
|
||||
|
||||
@@ -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("???"));
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.MediaItems.Queries
|
||||
{
|
||||
public record GetAllLanguageCodes : IRequest<List<CultureInfo>>;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.MediaItems.Queries
|
||||
{
|
||||
public class GetAllLanguageCodesHandler : IRequestHandler<GetAllLanguageCodes, List<CultureInfo>>
|
||||
{
|
||||
private readonly IMediaItemRepository _mediaItemRepository;
|
||||
|
||||
public GetAllLanguageCodesHandler(IMediaItemRepository mediaItemRepository) =>
|
||||
_mediaItemRepository = mediaItemRepository;
|
||||
|
||||
public async Task<List<CultureInfo>> Handle(GetAllLanguageCodes request, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = new List<CultureInfo>();
|
||||
|
||||
CultureInfo[] allCultures = CultureInfo.GetCultures(CultureTypes.AllCultures);
|
||||
List<string> allLanguageCodes = await _mediaItemRepository.GetAllLanguageCodes();
|
||||
foreach (string code in allLanguageCodes)
|
||||
{
|
||||
Option<CultureInfo> maybeCulture = allCultures.Find(
|
||||
ci => string.Equals(code, ci.ThreeLetterISOLanguageName, StringComparison.OrdinalIgnoreCase));
|
||||
await maybeCulture.IfSomeAsync(cultureInfo => result.Add(cultureInfo));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -22,7 +23,9 @@ namespace ErsatzTV.Application.MediaSources.Commands
|
||||
private readonly IEntityLocker _entityLocker;
|
||||
private readonly ILibraryRepository _libraryRepository;
|
||||
private readonly ILogger<ScanLocalLibraryHandler> _logger;
|
||||
private readonly IMediator _mediator;
|
||||
private readonly IMovieFolderScanner _movieFolderScanner;
|
||||
private readonly IMusicVideoFolderScanner _musicVideoFolderScanner;
|
||||
private readonly ITelevisionFolderScanner _televisionFolderScanner;
|
||||
|
||||
public ScanLocalLibraryHandler(
|
||||
@@ -30,14 +33,18 @@ namespace ErsatzTV.Application.MediaSources.Commands
|
||||
IConfigElementRepository configElementRepository,
|
||||
IMovieFolderScanner movieFolderScanner,
|
||||
ITelevisionFolderScanner televisionFolderScanner,
|
||||
IMusicVideoFolderScanner musicVideoFolderScanner,
|
||||
IEntityLocker entityLocker,
|
||||
IMediator mediator,
|
||||
ILogger<ScanLocalLibraryHandler> logger)
|
||||
{
|
||||
_libraryRepository = libraryRepository;
|
||||
_configElementRepository = configElementRepository;
|
||||
_movieFolderScanner = movieFolderScanner;
|
||||
_televisionFolderScanner = televisionFolderScanner;
|
||||
_musicVideoFolderScanner = musicVideoFolderScanner;
|
||||
_entityLocker = entityLocker;
|
||||
_mediator = mediator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -59,40 +66,61 @@ namespace ErsatzTV.Application.MediaSources.Commands
|
||||
{
|
||||
(LocalLibrary localLibrary, string ffprobePath, bool forceScan) = parameters;
|
||||
|
||||
var lastScan = new DateTimeOffset(localLibrary.LastScan ?? DateTime.MinValue, TimeSpan.Zero);
|
||||
if (forceScan || lastScan < DateTimeOffset.Now - TimeSpan.FromHours(6))
|
||||
{
|
||||
var sw = new Stopwatch();
|
||||
sw.Start();
|
||||
var sw = new Stopwatch();
|
||||
sw.Start();
|
||||
|
||||
foreach (LibraryPath libraryPath in localLibrary.Paths)
|
||||
for (var i = 0; i < localLibrary.Paths.Count; i++)
|
||||
{
|
||||
LibraryPath libraryPath = localLibrary.Paths[i];
|
||||
|
||||
decimal progressMin = (decimal) i / localLibrary.Paths.Count;
|
||||
decimal progressMax = (decimal) (i + 1) / localLibrary.Paths.Count;
|
||||
|
||||
var lastScan = new DateTimeOffset(libraryPath.LastScan ?? DateTime.MinValue, TimeSpan.Zero);
|
||||
if (forceScan || lastScan < DateTimeOffset.Now - TimeSpan.FromHours(6))
|
||||
{
|
||||
switch (localLibrary.MediaKind)
|
||||
{
|
||||
case LibraryMediaKind.Movies:
|
||||
await _movieFolderScanner.ScanFolder(libraryPath, ffprobePath, lastScan);
|
||||
await _movieFolderScanner.ScanFolder(
|
||||
libraryPath,
|
||||
ffprobePath,
|
||||
lastScan,
|
||||
progressMin,
|
||||
progressMax);
|
||||
break;
|
||||
case LibraryMediaKind.Shows:
|
||||
await _televisionFolderScanner.ScanFolder(libraryPath, ffprobePath, lastScan);
|
||||
await _televisionFolderScanner.ScanFolder(
|
||||
libraryPath,
|
||||
ffprobePath,
|
||||
lastScan,
|
||||
progressMin,
|
||||
progressMax);
|
||||
break;
|
||||
case LibraryMediaKind.MusicVideos:
|
||||
await _musicVideoFolderScanner.ScanFolder(
|
||||
libraryPath,
|
||||
ffprobePath,
|
||||
lastScan,
|
||||
progressMin,
|
||||
progressMax);
|
||||
break;
|
||||
}
|
||||
|
||||
libraryPath.LastScan = DateTime.UtcNow;
|
||||
await _libraryRepository.UpdateLastScan(libraryPath);
|
||||
}
|
||||
|
||||
localLibrary.LastScan = DateTime.UtcNow;
|
||||
await _libraryRepository.UpdateLastScan(localLibrary);
|
||||
await _mediator.Publish(new LibraryScanProgress(libraryPath.LibraryId, progressMax));
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
_logger.LogDebug(
|
||||
"Scan of library {Name} completed in {Duration}",
|
||||
localLibrary.Name,
|
||||
TimeSpan.FromMilliseconds(sw.ElapsedMilliseconds));
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Skipping unforced scan of library {Name}",
|
||||
localLibrary.Name);
|
||||
}
|
||||
sw.Stop();
|
||||
_logger.LogDebug(
|
||||
"Scan of library {Name} completed in {Duration}",
|
||||
localLibrary.Name,
|
||||
TimeSpan.FromMilliseconds(sw.ElapsedMilliseconds));
|
||||
|
||||
await _mediator.Publish(new LibraryScanProgress(localLibrary.Id, 0));
|
||||
|
||||
_entityLocker.UnlockLibrary(localLibrary.Id);
|
||||
return Unit.Default;
|
||||
|
||||
@@ -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))
|
||||
};
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Plex.Commands
|
||||
{
|
||||
public interface ISynchronizePlexLibraryById : IRequest<Either<BaseError, string>>, IBackgroundServiceRequest
|
||||
public interface ISynchronizePlexLibraryById : IRequest<Either<BaseError, string>>, IPlexBackgroundServiceRequest
|
||||
{
|
||||
int PlexLibraryId { get; }
|
||||
bool ForceScan { get; }
|
||||
|
||||
@@ -20,6 +20,7 @@ namespace ErsatzTV.Application.Plex.Commands
|
||||
IRequestHandler<SynchronizePlexLibraryByIdIfNeeded, Either<BaseError, string>>
|
||||
{
|
||||
private readonly IEntityLocker _entityLocker;
|
||||
private readonly ILibraryRepository _libraryRepository;
|
||||
private readonly ILogger<SynchronizePlexLibraryByIdHandler> _logger;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IPlexMovieLibraryScanner _plexMovieLibraryScanner;
|
||||
@@ -31,6 +32,7 @@ namespace ErsatzTV.Application.Plex.Commands
|
||||
IPlexSecretStore plexSecretStore,
|
||||
IPlexMovieLibraryScanner plexMovieLibraryScanner,
|
||||
IPlexTelevisionLibraryScanner plexTelevisionLibraryScanner,
|
||||
ILibraryRepository libraryRepository,
|
||||
IEntityLocker entityLocker,
|
||||
ILogger<SynchronizePlexLibraryByIdHandler> logger)
|
||||
{
|
||||
@@ -38,6 +40,7 @@ namespace ErsatzTV.Application.Plex.Commands
|
||||
_plexSecretStore = plexSecretStore;
|
||||
_plexMovieLibraryScanner = plexMovieLibraryScanner;
|
||||
_plexTelevisionLibraryScanner = plexTelevisionLibraryScanner;
|
||||
_libraryRepository = libraryRepository;
|
||||
_entityLocker = entityLocker;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -78,7 +81,7 @@ namespace ErsatzTV.Application.Plex.Commands
|
||||
}
|
||||
|
||||
parameters.Library.LastScan = DateTime.UtcNow;
|
||||
await _mediaSourceRepository.Update(parameters.Library);
|
||||
await _libraryRepository.UpdateLastScan(parameters.Library);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -57,11 +57,11 @@ namespace ErsatzTV.Application.Plex.Commands
|
||||
return allExisting;
|
||||
}
|
||||
|
||||
private async Task SynchronizeServer(List<PlexMediaSource> allExisting, PlexMediaSource server)
|
||||
private Task SynchronizeServer(List<PlexMediaSource> allExisting, PlexMediaSource server)
|
||||
{
|
||||
Option<PlexMediaSource> maybeExisting =
|
||||
allExisting.Find(s => s.ClientIdentifier == server.ClientIdentifier);
|
||||
await maybeExisting.Match(
|
||||
return maybeExisting.Match(
|
||||
existing =>
|
||||
{
|
||||
existing.Platform = server.Platform;
|
||||
@@ -84,15 +84,5 @@ namespace ErsatzTV.Application.Plex.Commands
|
||||
await _mediaSourceRepository.Add(server);
|
||||
});
|
||||
}
|
||||
|
||||
private void MergeConnections(
|
||||
List<PlexConnection> existing,
|
||||
List<PlexConnection> incoming)
|
||||
{
|
||||
var toAdd = incoming.Filter(connection => existing.All(c => c.Uri != connection.Uri)).ToList();
|
||||
var toRemove = existing.Filter(connection => incoming.All(c => c.Uri != connection.Uri)).ToList();
|
||||
existing.AddRange(toAdd);
|
||||
toRemove.ForEach(c => existing.Remove(c));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -2,13 +2,22 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
<NoWarn>VSTHRD200</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AsyncFixer" Version="1.5.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="FluentAssertions" Version="5.10.3" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="3.4.15" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="5.0.1" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.1" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.9.60">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Moq" Version="4.16.1" />
|
||||
<PackageReference Include="NUnit" Version="3.13.1" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="3.17.0" />
|
||||
|
||||
+116
-57
@@ -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
|
||||
};
|
||||
|
||||
@@ -590,11 +581,11 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_SetCopyAudioCodec_When_CorrectCodec_ForTransportStream()
|
||||
public void Should_SetDesiredAudioCodec_When_NormalizingAudio_With_CorrectCodec_ForTransportStream()
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
NormalizeAudioCodec = true,
|
||||
NormalizeAudio = true,
|
||||
AudioCodec = "aac"
|
||||
};
|
||||
|
||||
@@ -609,15 +600,15 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now);
|
||||
|
||||
actual.AudioCodec.Should().Be("copy");
|
||||
actual.AudioCodec.Should().Be("aac");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_SetCopyAudioCodec_When_NotNormalizingWrongCodec_ForTransportStream()
|
||||
public void Should_SetCopyAudioCodec_When_NotNormalizingAudio_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_NormalizingAudio_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_NormalizingAudio_ForHttpLiveStreaming()
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
NormalizeAudioCodec = true,
|
||||
NormalizeAudio = true,
|
||||
AudioCodec = "aac"
|
||||
};
|
||||
|
||||
@@ -682,12 +673,13 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_SetAudioBitrate_When_NormalizingWrongCodec_ForTransportStream()
|
||||
public void Should_SetAudioBitrate_When_NormalizingAudio_With_CorrectCodec_ForTransportStream()
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
NormalizeAudioCodec = true,
|
||||
AudioBitrate = 2424
|
||||
NormalizeAudio = true,
|
||||
AudioBitrate = 2424,
|
||||
AudioCodec = "ac3"
|
||||
};
|
||||
|
||||
var version = new MediaVersion();
|
||||
@@ -705,12 +697,13 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_SetAudioBufferSize_When_NormalizingWrongCodec_ForTransportStream()
|
||||
public void Should_SetAudioBufferSize_When_NormalizingAudio_With_CorrectCodec_ForTransportStream()
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
NormalizeAudioCodec = true,
|
||||
AudioBufferSize = 2424
|
||||
NormalizeAudio = true,
|
||||
AudioBufferSize = 2424,
|
||||
AudioCodec = "ac3"
|
||||
};
|
||||
|
||||
var version = new MediaVersion();
|
||||
@@ -728,11 +721,10 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ShouldNot_SetAudioChannels_When_CorrectCodec_ForTransportStream()
|
||||
public void Should_SetAudioChannels_When_NormalizingAudio_With_CorrectCodec_ForTransportStream()
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
NormalizeAudioCodec = true,
|
||||
NormalizeAudio = true,
|
||||
AudioCodec = "ac3",
|
||||
AudioChannels = 6
|
||||
@@ -749,15 +741,14 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now);
|
||||
|
||||
actual.AudioChannels.IsNone.Should().BeTrue();
|
||||
actual.AudioChannels.IfNone(0).Should().Be(6);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ShouldNot_SetAudioSampleRate_When_CorrectCodec_ForTransportStream()
|
||||
public void Should_SetAudioSampleRate_When_NormalizingAudio_With_CorrectCodec_ForTransportStream()
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
NormalizeAudioCodec = true,
|
||||
NormalizeAudio = true,
|
||||
AudioCodec = "ac3",
|
||||
AudioSampleRate = 48
|
||||
@@ -774,15 +765,14 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now);
|
||||
|
||||
actual.AudioSampleRate.IsNone.Should().BeTrue();
|
||||
actual.AudioSampleRate.IfNone(0).Should().Be(48);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_SetAudioChannels_When_NormalizingWrongCodecAndAudio_ForTransportStream()
|
||||
public void Should_SetAudioChannels_When_NormalizingAudio_ForTransportStream()
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
NormalizeAudioCodec = true,
|
||||
NormalizeAudio = true,
|
||||
AudioChannels = 6
|
||||
};
|
||||
@@ -802,11 +792,10 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_SetAudioSampleRate_When_NormalizingWrongCodecAndAudio_ForTransportStream()
|
||||
public void Should_SetAudioSampleRate_When_NormalizingAudio_ForTransportStream()
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
NormalizeAudioCodec = true,
|
||||
NormalizeAudio = true,
|
||||
AudioSampleRate = 48
|
||||
};
|
||||
@@ -824,6 +813,76 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
|
||||
actual.AudioSampleRate.IfNone(0).Should().Be(48);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_SetAudioDuration_When_NormalizingAudio_With_CorrectCodec_ForTransportStream()
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
NormalizeAudio = true,
|
||||
AudioSampleRate = 48,
|
||||
AudioCodec = "ac3"
|
||||
};
|
||||
|
||||
var version = new MediaVersion { Duration = TimeSpan.FromMinutes(2) };
|
||||
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
version,
|
||||
new MediaStream(),
|
||||
new MediaStream { Codec = "ac3" },
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now);
|
||||
|
||||
actual.AudioDuration.IfNone(TimeSpan.MinValue).Should().Be(TimeSpan.FromMinutes(2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_SetNormalizeLoudness_When_NormalizingAudio_ForTransportStream()
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
NormalizeAudio = true,
|
||||
NormalizeLoudness = true
|
||||
};
|
||||
|
||||
var version = new MediaVersion();
|
||||
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
version,
|
||||
new MediaStream(),
|
||||
new MediaStream { Codec = "ac3" },
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now);
|
||||
|
||||
actual.NormalizeLoudness.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_NotSetNormalizeLoudness_When_NotNormalizingAudio_ForTransportStream()
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||
{
|
||||
NormalizeAudio = false,
|
||||
NormalizeLoudness = true
|
||||
};
|
||||
|
||||
var version = new MediaVersion();
|
||||
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
version,
|
||||
new MediaStream(),
|
||||
new MediaStream { Codec = "ac3" },
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now);
|
||||
|
||||
actual.NormalizeLoudness.Should().BeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
[TestFixture]
|
||||
@@ -14,10 +14,12 @@ using ErsatzTV.Core.Metadata;
|
||||
using ErsatzTV.Core.Tests.Fakes;
|
||||
using FluentAssertions;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using static LanguageExt.Prelude;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Metadata
|
||||
{
|
||||
@@ -57,7 +59,7 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
.Returns<string, MediaItem>((_, _) => Right<BaseError, bool>(true).AsTask());
|
||||
|
||||
// fallback metadata adds metadata to a movie, so we need to replicate that here
|
||||
_localMetadataProvider.Setup(x => x.RefreshFallbackMetadata(It.IsAny<MediaItem>()))
|
||||
_localMetadataProvider.Setup(x => x.RefreshFallbackMetadata(It.IsAny<Movie>()))
|
||||
.Returns(
|
||||
(MediaItem mediaItem) =>
|
||||
{
|
||||
@@ -84,7 +86,9 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(
|
||||
libraryPath,
|
||||
FFprobePath,
|
||||
DateTimeOffset.MinValue);
|
||||
DateTimeOffset.MinValue,
|
||||
0,
|
||||
1);
|
||||
|
||||
result.IsLeft.Should().BeTrue();
|
||||
result.IfLeft(error => error.Should().BeOfType<MediaSourceInaccessible>());
|
||||
@@ -107,7 +111,9 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(
|
||||
libraryPath,
|
||||
FFprobePath,
|
||||
DateTimeOffset.MinValue);
|
||||
DateTimeOffset.MinValue,
|
||||
0,
|
||||
1);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
|
||||
@@ -146,7 +152,9 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(
|
||||
libraryPath,
|
||||
FFprobePath,
|
||||
DateTimeOffset.MinValue);
|
||||
DateTimeOffset.MinValue,
|
||||
0,
|
||||
1);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
|
||||
@@ -186,7 +194,9 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(
|
||||
libraryPath,
|
||||
FFprobePath,
|
||||
DateTimeOffset.MinValue);
|
||||
DateTimeOffset.MinValue,
|
||||
0,
|
||||
1);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
|
||||
@@ -230,7 +240,9 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(
|
||||
libraryPath,
|
||||
FFprobePath,
|
||||
DateTimeOffset.MinValue);
|
||||
DateTimeOffset.MinValue,
|
||||
0,
|
||||
1);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
|
||||
@@ -277,7 +289,9 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(
|
||||
libraryPath,
|
||||
FFprobePath,
|
||||
DateTimeOffset.MinValue);
|
||||
DateTimeOffset.MinValue,
|
||||
0,
|
||||
1);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
|
||||
@@ -324,7 +338,9 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(
|
||||
libraryPath,
|
||||
FFprobePath,
|
||||
DateTimeOffset.MinValue);
|
||||
DateTimeOffset.MinValue,
|
||||
0,
|
||||
1);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
|
||||
@@ -370,7 +386,9 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(
|
||||
libraryPath,
|
||||
FFprobePath,
|
||||
DateTimeOffset.MinValue);
|
||||
DateTimeOffset.MinValue,
|
||||
0,
|
||||
1);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
|
||||
@@ -412,7 +430,9 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(
|
||||
libraryPath,
|
||||
FFprobePath,
|
||||
DateTimeOffset.MinValue);
|
||||
DateTimeOffset.MinValue,
|
||||
0,
|
||||
1);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
|
||||
@@ -448,7 +468,9 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(
|
||||
libraryPath,
|
||||
FFprobePath,
|
||||
DateTimeOffset.MinValue);
|
||||
DateTimeOffset.MinValue,
|
||||
0,
|
||||
1);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
|
||||
@@ -486,7 +508,9 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(
|
||||
libraryPath,
|
||||
FFprobePath,
|
||||
DateTimeOffset.MinValue);
|
||||
DateTimeOffset.MinValue,
|
||||
0,
|
||||
1);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
|
||||
@@ -513,7 +537,9 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(
|
||||
libraryPath,
|
||||
FFprobePath,
|
||||
DateTimeOffset.MinValue);
|
||||
DateTimeOffset.MinValue,
|
||||
0,
|
||||
1);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
|
||||
@@ -531,6 +557,7 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
new Mock<IMetadataRepository>().Object,
|
||||
_imageCache.Object,
|
||||
new Mock<ISearchIndex>().Object,
|
||||
new Mock<IMediator>().Object,
|
||||
new Mock<ILogger<MovieFolderScanner>>().Object
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,16 +9,15 @@
|
||||
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; }
|
||||
public bool NormalizeLoudness { get; set; }
|
||||
public int AudioChannels { get; set; }
|
||||
public int AudioSampleRate { get; set; }
|
||||
public bool NormalizeAudio { get; set; }
|
||||
@@ -27,7 +26,7 @@
|
||||
new()
|
||||
{
|
||||
Name = name,
|
||||
ThreadCount = 4,
|
||||
ThreadCount = 0,
|
||||
Transcode = true,
|
||||
ResolutionId = resolution.Id,
|
||||
Resolution = resolution,
|
||||
@@ -37,12 +36,11 @@
|
||||
VideoBufferSize = 4000,
|
||||
AudioBitrate = 192,
|
||||
AudioBufferSize = 384,
|
||||
AudioVolume = 100,
|
||||
NormalizeLoudness = true,
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
@@ -6,6 +7,7 @@ namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Path { get; set; }
|
||||
public DateTime? LastScan { get; set; }
|
||||
|
||||
public int LibraryId { get; set; }
|
||||
public Library Library { get; set; }
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,22 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
<NoWarn>VSTHRD200</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AsyncFixer" Version="1.5.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="LanguageExt.Core" Version="3.4.15" />
|
||||
<PackageReference Include="MediatR" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="5.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="5.0.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.9.60">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
|
||||
<PackageReference Include="Serilog" Version="2.10.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="3.1.1" />
|
||||
|
||||
@@ -13,8 +13,10 @@ 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 bool _normalizeLoudness;
|
||||
private Option<IDisplaySize> _padToSize = None;
|
||||
private Option<IDisplaySize> _scaleToSize = None;
|
||||
|
||||
@@ -48,12 +50,24 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegComplexFilterBuilder WithNormalizeLoudness(bool normalizeLoudness)
|
||||
{
|
||||
_normalizeLoudness = normalizeLoudness;
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegComplexFilterBuilder WithInputCodec(string codec)
|
||||
{
|
||||
_inputCodec = codec;
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegComplexFilterBuilder WithFrameRate(Option<string> frameRate)
|
||||
{
|
||||
_frameRate = frameRate;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Option<FFmpegComplexFilter> Build(int videoStreamIndex, int audioStreamIndex)
|
||||
{
|
||||
var complexFilter = new StringBuilder();
|
||||
@@ -70,22 +84,22 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
_ => false
|
||||
};
|
||||
|
||||
_audioDuration.IfSome(
|
||||
audioDuration =>
|
||||
{
|
||||
complexFilter.Append($"[{audioLabel}]");
|
||||
complexFilter.Append($"apad=whole_dur={audioDuration.TotalMilliseconds}ms");
|
||||
audioLabel = "[a]";
|
||||
complexFilter.Append(audioLabel);
|
||||
});
|
||||
var audioFilterQueue = new List<string>();
|
||||
var videoFilterQueue = new List<string>();
|
||||
|
||||
var filterQueue = new List<string>();
|
||||
if (_normalizeLoudness)
|
||||
{
|
||||
audioFilterQueue.Add("loudnorm=I=-16:TP=-1.5:LRA=11");
|
||||
}
|
||||
|
||||
_audioDuration.IfSome(
|
||||
audioDuration => audioFilterQueue.Add($"apad=whole_dur={audioDuration.TotalMilliseconds}ms"));
|
||||
|
||||
bool usesHardwareFilters = acceleration != HardwareAccelerationKind.None && !isHardwareDecode &&
|
||||
(_deinterlace || _scaleToSize.IsSome);
|
||||
if (usesHardwareFilters)
|
||||
{
|
||||
filterQueue.Add("hwupload");
|
||||
videoFilterQueue.Add("hwupload");
|
||||
}
|
||||
|
||||
if (_deinterlace)
|
||||
@@ -100,10 +114,12 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter))
|
||||
{
|
||||
filterQueue.Add(filter);
|
||||
videoFilterQueue.Add(filter);
|
||||
}
|
||||
}
|
||||
|
||||
_frameRate.IfSome(frameRate => videoFilterQueue.Add($"fps=fps={frameRate}"));
|
||||
|
||||
_scaleToSize.IfSome(
|
||||
size =>
|
||||
{
|
||||
@@ -117,7 +133,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter))
|
||||
{
|
||||
filterQueue.Add(filter);
|
||||
videoFilterQueue.Add(filter);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -125,19 +141,19 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
{
|
||||
if (acceleration != HardwareAccelerationKind.None && (isHardwareDecode || usesHardwareFilters))
|
||||
{
|
||||
filterQueue.Add("hwdownload");
|
||||
videoFilterQueue.Add("hwdownload");
|
||||
string format = acceleration switch
|
||||
{
|
||||
HardwareAccelerationKind.Vaapi => "format=nv12|vaapi",
|
||||
_ => "format=nv12"
|
||||
};
|
||||
filterQueue.Add(format);
|
||||
videoFilterQueue.Add(format);
|
||||
}
|
||||
|
||||
filterQueue.Add("setsar=1");
|
||||
videoFilterQueue.Add("setsar=1");
|
||||
}
|
||||
|
||||
_padToSize.IfSome(size => filterQueue.Add($"pad={size.Width}:{size.Height}:(ow-iw)/2:(oh-ih)/2"));
|
||||
_padToSize.IfSome(size => videoFilterQueue.Add($"pad={size.Width}:{size.Height}:(ow-iw)/2:(oh-ih)/2"));
|
||||
|
||||
if ((_scaleToSize.IsSome || _padToSize.IsSome) && acceleration != HardwareAccelerationKind.None)
|
||||
{
|
||||
@@ -146,19 +162,27 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
HardwareAccelerationKind.Qsv => "hwupload=extra_hw_frames=64",
|
||||
_ => "hwupload"
|
||||
};
|
||||
filterQueue.Add(upload);
|
||||
videoFilterQueue.Add(upload);
|
||||
}
|
||||
|
||||
if (filterQueue.Any())
|
||||
bool hasAudioFilters = audioFilterQueue.Any();
|
||||
if (hasAudioFilters)
|
||||
{
|
||||
// TODO: any audio filter
|
||||
if (_audioDuration.IsSome)
|
||||
complexFilter.Append($"[{audioLabel}]");
|
||||
complexFilter.Append(string.Join(",", audioFilterQueue));
|
||||
audioLabel = "[a]";
|
||||
complexFilter.Append(audioLabel);
|
||||
}
|
||||
|
||||
if (videoFilterQueue.Any())
|
||||
{
|
||||
if (hasAudioFilters)
|
||||
{
|
||||
complexFilter.Append(';');
|
||||
}
|
||||
|
||||
complexFilter.Append($"[{videoLabel}]");
|
||||
complexFilter.Append(string.Join(",", filterQueue));
|
||||
complexFilter.Append(string.Join(",", videoFilterQueue));
|
||||
videoLabel = "[v]";
|
||||
complexFilter.Append(videoLabel);
|
||||
}
|
||||
|
||||
@@ -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,8 @@ 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; }
|
||||
public bool NormalizeLoudness { 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))
|
||||
{
|
||||
@@ -98,22 +107,20 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
result.VideoCodec = "copy";
|
||||
}
|
||||
|
||||
if (NeedToNormalizeAudioCodec(ffmpegProfile, audioStream))
|
||||
if (ffmpegProfile.NormalizeAudio)
|
||||
{
|
||||
result.AudioCodec = ffmpegProfile.AudioCodec;
|
||||
result.AudioBitrate = ffmpegProfile.AudioBitrate;
|
||||
result.AudioBufferSize = ffmpegProfile.AudioBufferSize;
|
||||
|
||||
if (ffmpegProfile.NormalizeAudio)
|
||||
if (audioStream.Channels != ffmpegProfile.AudioChannels)
|
||||
{
|
||||
if (audioStream.Channels != ffmpegProfile.AudioChannels)
|
||||
{
|
||||
result.AudioChannels = ffmpegProfile.AudioChannels;
|
||||
}
|
||||
|
||||
result.AudioSampleRate = ffmpegProfile.AudioSampleRate;
|
||||
result.AudioDuration = version.Duration;
|
||||
result.AudioChannels = ffmpegProfile.AudioChannels;
|
||||
}
|
||||
|
||||
result.AudioSampleRate = ffmpegProfile.AudioSampleRate;
|
||||
result.AudioDuration = version.Duration;
|
||||
result.NormalizeLoudness = ffmpegProfile.NormalizeLoudness;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -141,7 +148,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 +166,7 @@ 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;
|
||||
|
||||
private static bool NeedToNormalizeAudioCodec(FFmpegProfile ffmpegProfile, MediaStream audioStream) =>
|
||||
ffmpegProfile.NormalizeAudioCodec && ffmpegProfile.AudioCodec != audioStream.Codec;
|
||||
ffmpegProfile.NormalizeVideo && ffmpegProfile.VideoCodec != videoStream.Codec;
|
||||
|
||||
private static IDisplaySize CalculateScaledSize(FFmpegProfile ffmpegProfile, MediaVersion version)
|
||||
{
|
||||
|
||||
@@ -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,11 +196,10 @@ 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"
|
||||
// "-avoid_negative_ts", "make_zero"
|
||||
};
|
||||
_arguments.AddRange(arguments);
|
||||
return this;
|
||||
@@ -228,7 +230,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 +325,29 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithNormalizeLoudness(bool normalizeLoudness)
|
||||
{
|
||||
_complexFilterBuilder = _complexFilterBuilder.WithNormalizeLoudness(normalizeLoudness);
|
||||
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);
|
||||
@@ -373,10 +398,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);
|
||||
|
||||
@@ -48,7 +48,11 @@ 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)
|
||||
.WithAlignedAudio(playbackSettings.AudioDuration)
|
||||
.WithNormalizeLoudness(playbackSettings.NormalizeLoudness);
|
||||
|
||||
playbackSettings.ScaledSize.Match(
|
||||
scaledSize =>
|
||||
@@ -63,7 +67,6 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
}
|
||||
|
||||
builder = builder
|
||||
.WithAlignedAudio(playbackSettings.AudioDuration)
|
||||
.WithFilterComplex(videoStream.Index, audioStream.Index);
|
||||
},
|
||||
() =>
|
||||
@@ -73,7 +76,6 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
builder = builder
|
||||
.WithDeinterlace(playbackSettings.Deinterlace)
|
||||
.WithBlackBars(channel.FFmpegProfile.Resolution)
|
||||
.WithAlignedAudio(playbackSettings.AudioDuration)
|
||||
.WithFilterComplex(videoStream.Index, audioStream.Index);
|
||||
}
|
||||
else if (playbackSettings.Deinterlace)
|
||||
@@ -85,7 +87,6 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
else
|
||||
{
|
||||
builder = builder
|
||||
.WithAlignedAudio(playbackSettings.AudioDuration)
|
||||
.WithFilterComplex(videoStream.Index, audioStream.Index);
|
||||
}
|
||||
});
|
||||
@@ -124,11 +125,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,7 @@ namespace ErsatzTV.Core.Interfaces.Metadata
|
||||
ShowMetadata GetFallbackMetadataForShow(string showFolder);
|
||||
Tuple<EpisodeMetadata, int> GetFallbackMetadata(Episode episode);
|
||||
MovieMetadata GetFallbackMetadata(Movie movie);
|
||||
MusicVideoMetadata GetFallbackMetadata(MusicVideo musicVideo);
|
||||
string GetSortTitle(string title);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,13 @@ namespace ErsatzTV.Core.Interfaces.Metadata
|
||||
public interface ILocalMetadataProvider
|
||||
{
|
||||
Task<ShowMetadata> GetMetadataForShow(string showFolder);
|
||||
Task<bool> RefreshSidecarMetadata(MediaItem mediaItem, string path);
|
||||
Task<bool> RefreshSidecarMetadata(Show televisionShow, string showFolder);
|
||||
Task<bool> RefreshFallbackMetadata(MediaItem mediaItem);
|
||||
Task<bool> RefreshSidecarMetadata(Movie movie, string nfoFileName);
|
||||
Task<bool> RefreshSidecarMetadata(Show televisionShow, string nfoFileName);
|
||||
Task<bool> RefreshSidecarMetadata(Episode episode, string nfoFileName);
|
||||
Task<bool> RefreshSidecarMetadata(MusicVideo musicVideo, string nfoFileName);
|
||||
Task<bool> RefreshFallbackMetadata(Movie movie);
|
||||
Task<bool> RefreshFallbackMetadata(Episode episode);
|
||||
Task<bool> RefreshFallbackMetadata(MusicVideo musicVideo);
|
||||
Task<bool> RefreshFallbackMetadata(Show televisionShow, string showFolder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,11 @@ namespace ErsatzTV.Core.Interfaces.Metadata
|
||||
{
|
||||
public interface IMovieFolderScanner
|
||||
{
|
||||
Task<Either<BaseError, Unit>> ScanFolder(LibraryPath libraryPath, string ffprobePath, DateTimeOffset lastScan);
|
||||
Task<Either<BaseError, Unit>> ScanFolder(
|
||||
LibraryPath libraryPath,
|
||||
string ffprobePath,
|
||||
DateTimeOffset lastScan,
|
||||
decimal progressMin,
|
||||
decimal progressMax);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
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,
|
||||
decimal progressMin,
|
||||
decimal progressMax);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,11 @@ namespace ErsatzTV.Core.Interfaces.Metadata
|
||||
{
|
||||
public interface ITelevisionFolderScanner
|
||||
{
|
||||
Task<Either<BaseError, Unit>> ScanFolder(LibraryPath libraryPath, string ffprobePath, DateTimeOffset lastScan);
|
||||
Task<Either<BaseError, Unit>> ScanFolder(
|
||||
LibraryPath libraryPath,
|
||||
string ffprobePath,
|
||||
DateTimeOffset lastScan,
|
||||
decimal progressMin,
|
||||
decimal progressMax);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
Task<Option<LocalLibrary>> GetLocal(int libraryId);
|
||||
Task<List<Library>> GetAll();
|
||||
Task<Unit> UpdateLastScan(Library library);
|
||||
Task<Unit> UpdateLastScan(LibraryPath libraryPath);
|
||||
Task<List<LibraryPath>> GetLocalPaths(int libraryId);
|
||||
Task<Option<LibraryPath>> GetPath(int libraryPathId);
|
||||
Task<int> CountMediaItemsByPath(int libraryPathId);
|
||||
|
||||
@@ -10,5 +10,6 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
Task<Option<MediaItem>> Get(int id);
|
||||
Task<List<MediaItem>> GetAll();
|
||||
Task<bool> Update(MediaItem mediaItem);
|
||||
Task<List<string>> GetAllLanguageCodes();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
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<Either<BaseError, MediaItemScanResult<MusicVideo>>> GetOrAdd(LibraryPath libraryPath, string path);
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -221,6 +221,8 @@ namespace ErsatzTV.Core.Iptv
|
||||
.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]"
|
||||
};
|
||||
}
|
||||
@@ -253,6 +255,8 @@ namespace ErsatzTV.Core.Iptv
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using System.Threading.Tasks;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading.Tasks;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Core
|
||||
{
|
||||
[SuppressMessage("ReSharper", "VSTHRD003")]
|
||||
public static class LanguageExtensions
|
||||
{
|
||||
public static Either<BaseError, TR> ToEither<TR>(this Validation<BaseError, TR> validation) =>
|
||||
|
||||
@@ -36,6 +36,19 @@ namespace ErsatzTV.Core.Metadata
|
||||
return fileName != null ? GetMovieMetadata(fileName, metadata) : metadata;
|
||||
}
|
||||
|
||||
public MusicVideoMetadata GetFallbackMetadata(MusicVideo musicVideo)
|
||||
{
|
||||
string path = musicVideo.MediaVersions.Head().MediaFiles.Head().Path;
|
||||
string fileName = Path.GetFileName(path);
|
||||
var metadata = new MusicVideoMetadata
|
||||
{
|
||||
MetadataKind = MetadataKind.Fallback,
|
||||
Title = fileName ?? path
|
||||
};
|
||||
|
||||
return fileName != null ? GetMusicVideoMetadata(fileName, metadata) : metadata;
|
||||
}
|
||||
|
||||
public string GetSortTitle(string title)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
@@ -112,6 +125,30 @@ namespace ErsatzTV.Core.Metadata
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private MusicVideoMetadata GetMusicVideoMetadata(string fileName, MusicVideoMetadata metadata)
|
||||
{
|
||||
try
|
||||
{
|
||||
const string PATTERN = @"^(.*?) - (.*?).\w+$";
|
||||
Match match = Regex.Match(fileName, PATTERN);
|
||||
if (match.Success)
|
||||
{
|
||||
metadata.Artist = match.Groups[1].Value;
|
||||
metadata.Title = match.Groups[2].Value;
|
||||
metadata.Genres = new List<Genre>();
|
||||
metadata.Tags = new List<Tag>();
|
||||
metadata.Studios = new List<Studio>();
|
||||
metadata.DateUpdated = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private ShowMetadata GetTelevisionShowMetadata(string fileName, ShowMetadata metadata)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
public record LibraryScanProgress(int LibraryId, decimal Progress) : INotification;
|
||||
}
|
||||
@@ -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))
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ using System.Xml.Serialization;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Metadata.Nfo;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static LanguageExt.Prelude;
|
||||
@@ -18,18 +19,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 +41,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
_metadataRepository = metadataRepository;
|
||||
_movieRepository = movieRepository;
|
||||
_televisionRepository = televisionRepository;
|
||||
_musicVideoRepository = musicVideoRepository;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
_localFileSystem = localFileSystem;
|
||||
_logger = logger;
|
||||
@@ -65,39 +70,73 @@ namespace ErsatzTV.Core.Metadata
|
||||
});
|
||||
}
|
||||
|
||||
public Task<bool> RefreshSidecarMetadata(MediaItem mediaItem, string path) =>
|
||||
mediaItem switch
|
||||
{
|
||||
Episode e => LoadMetadata(e, path)
|
||||
.Bind(
|
||||
maybeMetadata => maybeMetadata.Match(
|
||||
metadata => ApplyMetadataUpdate(e, metadata),
|
||||
() => Task.FromResult(false))),
|
||||
Movie m => LoadMetadata(m, path)
|
||||
.Bind(
|
||||
maybeMetadata => maybeMetadata.Match(
|
||||
metadata => ApplyMetadataUpdate(m, metadata),
|
||||
() => Task.FromResult(false))),
|
||||
_ => Task.FromResult(false)
|
||||
};
|
||||
public Task<bool> RefreshSidecarMetadata(Movie movie, string nfoFileName) =>
|
||||
LoadMovieMetadata(movie, nfoFileName).Bind(
|
||||
maybeMetadata => maybeMetadata.Match(
|
||||
metadata => ApplyMetadataUpdate(movie, metadata),
|
||||
() => Task.FromResult(false)));
|
||||
|
||||
public Task<bool> RefreshSidecarMetadata(Show televisionShow, string showFolder) =>
|
||||
LoadMetadata(televisionShow, showFolder).Bind(
|
||||
public Task<bool> RefreshSidecarMetadata(Show televisionShow, string nfoFileName) =>
|
||||
LoadTelevisionShowMetadata(nfoFileName).Bind(
|
||||
maybeMetadata => maybeMetadata.Match(
|
||||
metadata => ApplyMetadataUpdate(televisionShow, metadata),
|
||||
() => Task.FromResult(false)));
|
||||
|
||||
public Task<bool> RefreshFallbackMetadata(MediaItem mediaItem) =>
|
||||
mediaItem switch
|
||||
{
|
||||
Episode e => ApplyMetadataUpdate(e, _fallbackMetadataProvider.GetFallbackMetadata(e)),
|
||||
Movie m => ApplyMetadataUpdate(m, _fallbackMetadataProvider.GetFallbackMetadata(m)),
|
||||
_ => Task.FromResult(false)
|
||||
};
|
||||
public Task<bool> RefreshSidecarMetadata(Episode episode, string nfoFileName) =>
|
||||
LoadEpisodeMetadata(episode, nfoFileName).Bind(
|
||||
maybeMetadata => maybeMetadata.Match(
|
||||
metadata => ApplyMetadataUpdate(episode, metadata),
|
||||
() => Task.FromResult(false)));
|
||||
|
||||
public Task<bool> RefreshSidecarMetadata(MusicVideo musicVideo, string nfoFileName) =>
|
||||
LoadMusicVideoMetadata(nfoFileName).Bind(
|
||||
maybeMetadata => maybeMetadata.Match(
|
||||
metadata => ApplyMetadataUpdate(musicVideo, metadata),
|
||||
() => RefreshFallbackMetadata(musicVideo)));
|
||||
|
||||
public Task<bool> RefreshFallbackMetadata(Movie movie) =>
|
||||
ApplyMetadataUpdate(movie, _fallbackMetadataProvider.GetFallbackMetadata(movie));
|
||||
|
||||
public Task<bool> RefreshFallbackMetadata(Episode episode) =>
|
||||
ApplyMetadataUpdate(episode, _fallbackMetadataProvider.GetFallbackMetadata(episode));
|
||||
|
||||
public Task<bool> RefreshFallbackMetadata(MusicVideo musicVideo) =>
|
||||
ApplyMetadataUpdate(musicVideo, _fallbackMetadataProvider.GetFallbackMetadata(musicVideo));
|
||||
|
||||
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;
|
||||
@@ -148,8 +187,6 @@ namespace ErsatzTV.Core.Metadata
|
||||
Optional(movie.MovieMetadata).Flatten().HeadOrNone().Match(
|
||||
async existing =>
|
||||
{
|
||||
var updated = false;
|
||||
|
||||
existing.Outline = metadata.Outline;
|
||||
existing.Plot = metadata.Plot;
|
||||
existing.Tagline = metadata.Tagline;
|
||||
@@ -169,67 +206,12 @@ namespace ErsatzTV.Core.Metadata
|
||||
? _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 _movieRepository.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 _movieRepository.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 _movieRepository.AddStudio(existing, studio))
|
||||
{
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
bool updated = await UpdateMetadataCollections(
|
||||
existing,
|
||||
metadata,
|
||||
_movieRepository.AddGenre,
|
||||
_movieRepository.AddTag,
|
||||
_movieRepository.AddStudio);
|
||||
|
||||
return await _metadataRepository.Update(existing) || updated;
|
||||
},
|
||||
@@ -248,8 +230,6 @@ namespace ErsatzTV.Core.Metadata
|
||||
Optional(show.ShowMetadata).Flatten().HeadOrNone().Match(
|
||||
async existing =>
|
||||
{
|
||||
var updated = false;
|
||||
|
||||
existing.Outline = metadata.Outline;
|
||||
existing.Plot = metadata.Plot;
|
||||
existing.Tagline = metadata.Tagline;
|
||||
@@ -269,67 +249,12 @@ namespace ErsatzTV.Core.Metadata
|
||||
? _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 _televisionRepository.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 _televisionRepository.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 _televisionRepository.AddStudio(existing, studio))
|
||||
{
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
bool updated = await UpdateMetadataCollections(
|
||||
existing,
|
||||
metadata,
|
||||
_televisionRepository.AddGenre,
|
||||
_televisionRepository.AddTag,
|
||||
_televisionRepository.AddStudio);
|
||||
|
||||
return await _metadataRepository.Update(existing) || updated;
|
||||
},
|
||||
@@ -344,38 +269,48 @@ namespace ErsatzTV.Core.Metadata
|
||||
return await _metadataRepository.Add(metadata);
|
||||
});
|
||||
|
||||
private async Task<Option<MovieMetadata>> LoadMetadata(Movie mediaItem, string nfoFileName)
|
||||
{
|
||||
if (nfoFileName == null || !File.Exists(nfoFileName))
|
||||
{
|
||||
_logger.LogDebug("NFO file does not exist at {Path}", nfoFileName);
|
||||
return None;
|
||||
}
|
||||
private Task<bool> ApplyMetadataUpdate(MusicVideo musicVideo, MusicVideoMetadata metadata) =>
|
||||
Optional(musicVideo.MusicVideoMetadata).Flatten().HeadOrNone().Match(
|
||||
async existing =>
|
||||
{
|
||||
existing.Artist = metadata.Artist;
|
||||
existing.Title = metadata.Title;
|
||||
existing.Year = metadata.Year;
|
||||
existing.Plot = metadata.Plot;
|
||||
existing.Album = metadata.Album;
|
||||
|
||||
return await LoadMovieMetadata(mediaItem, nfoFileName);
|
||||
}
|
||||
if (existing.DateAdded == DateTime.MinValue)
|
||||
{
|
||||
existing.DateAdded = metadata.DateAdded;
|
||||
}
|
||||
|
||||
private async Task<Option<Tuple<EpisodeMetadata, int>>> LoadMetadata(Episode mediaItem, string nfoFileName)
|
||||
{
|
||||
if (nfoFileName == null || !File.Exists(nfoFileName))
|
||||
{
|
||||
_logger.LogDebug("NFO file does not exist at {Path}", nfoFileName);
|
||||
return None;
|
||||
}
|
||||
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;
|
||||
|
||||
return await LoadEpisodeMetadata(mediaItem, nfoFileName);
|
||||
}
|
||||
bool updated = await UpdateMetadataCollections(
|
||||
existing,
|
||||
metadata,
|
||||
_musicVideoRepository.AddGenre,
|
||||
_musicVideoRepository.AddTag,
|
||||
_musicVideoRepository.AddStudio);
|
||||
|
||||
private async Task<Option<ShowMetadata>> LoadMetadata(Show televisionShow, string nfoFileName)
|
||||
{
|
||||
if (nfoFileName == null || !File.Exists(nfoFileName))
|
||||
{
|
||||
_logger.LogDebug("NFO file does not exist at {Path}", nfoFileName);
|
||||
return None;
|
||||
}
|
||||
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 LoadTelevisionShowMetadata(nfoFileName);
|
||||
}
|
||||
return await _metadataRepository.Add(metadata);
|
||||
});
|
||||
|
||||
private async Task<Option<ShowMetadata>> LoadTelevisionShowMetadata(string nfoFileName)
|
||||
{
|
||||
@@ -437,7 +372,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Option<MovieMetadata>> LoadMovieMetadata(Movie mediaItem, string nfoFileName)
|
||||
private async Task<Option<MovieMetadata>> LoadMovieMetadata(Movie movie, string nfoFileName)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -464,7 +399,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogInformation(ex, "Failed to read Movie nfo metadata from {Path}", nfoFileName);
|
||||
return _fallbackMetadataProvider.GetFallbackMetadata(mediaItem);
|
||||
return _fallbackMetadataProvider.GetFallbackMetadata(movie);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -500,94 +435,79 @@ namespace ErsatzTV.Core.Metadata
|
||||
return DateTime.TryParse(aired, out DateTime parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
[XmlRoot("movie")]
|
||||
public class MovieNfo
|
||||
private async Task<bool> UpdateMetadataCollections<T>(
|
||||
T existing,
|
||||
T incoming,
|
||||
Func<T, Genre, Task<bool>> addGenre,
|
||||
Func<T, Tag, Task<bool>> addTag,
|
||||
Func<T, Studio, Task<bool>> addStudio)
|
||||
where T : Domain.Metadata
|
||||
{
|
||||
[XmlElement("title")]
|
||||
public string Title { get; set; }
|
||||
var updated = false;
|
||||
|
||||
[XmlElement("outline")]
|
||||
public string Outline { get; set; }
|
||||
foreach (Genre genre in existing.Genres.Filter(g => incoming.Genres.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
existing.Genres.Remove(genre);
|
||||
if (await _metadataRepository.RemoveGenre(genre))
|
||||
{
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
[XmlElement("year")]
|
||||
public int Year { get; set; }
|
||||
foreach (Genre genre in incoming.Genres.Filter(g => existing.Genres.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
existing.Genres.Add(genre);
|
||||
if (await addGenre(existing, genre))
|
||||
{
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
[XmlElement("mpaa")]
|
||||
public string ContentRating { get; set; }
|
||||
foreach (Tag tag in existing.Tags.Filter(t => incoming.Tags.All(t2 => t2.Name != t.Name))
|
||||
.ToList())
|
||||
{
|
||||
existing.Tags.Remove(tag);
|
||||
if (await _metadataRepository.RemoveTag(tag))
|
||||
{
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
[XmlElement("premiered")]
|
||||
public DateTime Premiered { get; set; }
|
||||
foreach (Tag tag in incoming.Tags.Filter(t => existing.Tags.All(t2 => t2.Name != t.Name))
|
||||
.ToList())
|
||||
{
|
||||
existing.Tags.Add(tag);
|
||||
if (await addTag(existing, tag))
|
||||
{
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
[XmlElement("plot")]
|
||||
public string Plot { get; set; }
|
||||
foreach (Studio studio in existing.Studios
|
||||
.Filter(s => incoming.Studios.All(s2 => s2.Name != s.Name))
|
||||
.ToList())
|
||||
{
|
||||
existing.Studios.Remove(studio);
|
||||
if (await _metadataRepository.RemoveStudio(studio))
|
||||
{
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
[XmlElement("tagline")]
|
||||
public string Tagline { get; set; }
|
||||
foreach (Studio studio in incoming.Studios
|
||||
.Filter(s => existing.Studios.All(s2 => s2.Name != s.Name))
|
||||
.ToList())
|
||||
{
|
||||
existing.Studios.Add(studio);
|
||||
if (await addStudio(existing, studio))
|
||||
{
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
[XmlElement("genre")]
|
||||
public List<string> Genres { get; set; }
|
||||
|
||||
[XmlElement("tag")]
|
||||
public List<string> Tags { get; set; }
|
||||
|
||||
[XmlElement("studio")]
|
||||
public List<string> Studios { get; set; }
|
||||
}
|
||||
|
||||
[XmlRoot("tvshow")]
|
||||
public class TvShowNfo
|
||||
{
|
||||
[XmlElement("title")]
|
||||
public string Title { get; set; }
|
||||
|
||||
[XmlElement("year")]
|
||||
public int Year { get; set; }
|
||||
|
||||
[XmlElement("plot")]
|
||||
public string Plot { get; set; }
|
||||
|
||||
[XmlElement("outline")]
|
||||
public string Outline { get; set; }
|
||||
|
||||
[XmlElement("tagline")]
|
||||
public string Tagline { get; set; }
|
||||
|
||||
[XmlElement("premiered")]
|
||||
public string Premiered { 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; }
|
||||
}
|
||||
|
||||
[XmlRoot("episodedetails")]
|
||||
public class TvShowEpisodeNfo
|
||||
{
|
||||
[XmlElement("showtitle")]
|
||||
public string ShowTitle { get; set; }
|
||||
|
||||
[XmlElement("title")]
|
||||
public string Title { get; set; }
|
||||
|
||||
[XmlElement("episode")]
|
||||
public int Episode { get; set; }
|
||||
|
||||
[XmlElement("season")]
|
||||
public int Season { get; set; }
|
||||
|
||||
[XmlElement("mpaa")]
|
||||
public string ContentRating { get; set; }
|
||||
|
||||
[XmlElement("aired")]
|
||||
public string Aired { get; set; }
|
||||
|
||||
[XmlElement("plot")]
|
||||
public string Plot { get; set; }
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
};
|
||||
|
||||
@@ -162,7 +164,9 @@ namespace ErsatzTV.Core.Metadata
|
||||
FFprobeStream videoStream = json.streams.FirstOrDefault(s => s.codec_type == "video");
|
||||
if (videoStream != null)
|
||||
{
|
||||
version.SampleAspectRatio = videoStream.sample_aspect_ratio;
|
||||
version.SampleAspectRatio = string.IsNullOrWhiteSpace(videoStream.sample_aspect_ratio)
|
||||
? "1:1"
|
||||
: videoStream.sample_aspect_ratio;
|
||||
version.DisplayAspectRatio = videoStream.display_aspect_ratio;
|
||||
version.Width = videoStream.width;
|
||||
version.Height = videoStream.height;
|
||||
|
||||
@@ -10,9 +10,11 @@ using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static LanguageExt.Prelude;
|
||||
using Seq = LanguageExt.Seq;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
@@ -21,6 +23,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILocalMetadataProvider _localMetadataProvider;
|
||||
private readonly ILogger<MovieFolderScanner> _logger;
|
||||
private readonly IMediator _mediator;
|
||||
private readonly IMovieRepository _movieRepository;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
|
||||
@@ -32,6 +35,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
IMetadataRepository metadataRepository,
|
||||
IImageCache imageCache,
|
||||
ISearchIndex searchIndex,
|
||||
IMediator mediator,
|
||||
ILogger<MovieFolderScanner> logger)
|
||||
: base(localFileSystem, localStatisticsProvider, metadataRepository, imageCache, logger)
|
||||
{
|
||||
@@ -39,19 +43,26 @@ namespace ErsatzTV.Core.Metadata
|
||||
_movieRepository = movieRepository;
|
||||
_localMetadataProvider = localMetadataProvider;
|
||||
_searchIndex = searchIndex;
|
||||
_mediator = mediator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> ScanFolder(
|
||||
LibraryPath libraryPath,
|
||||
string ffprobePath,
|
||||
DateTimeOffset lastScan)
|
||||
DateTimeOffset lastScan,
|
||||
decimal progressMin,
|
||||
decimal progressMax)
|
||||
{
|
||||
decimal progressSpread = progressMax - progressMin;
|
||||
|
||||
if (!_localFileSystem.IsLibraryPathAccessible(libraryPath))
|
||||
{
|
||||
return new MediaSourceInaccessible();
|
||||
}
|
||||
|
||||
var foldersCompleted = 0;
|
||||
|
||||
var folderQueue = new Queue<string>();
|
||||
foreach (string folder in _localFileSystem.ListSubdirectories(libraryPath.Path).OrderBy(identity))
|
||||
{
|
||||
@@ -60,7 +71,12 @@ namespace ErsatzTV.Core.Metadata
|
||||
|
||||
while (folderQueue.Count > 0)
|
||||
{
|
||||
decimal percentCompletion = (decimal) foldersCompleted / (foldersCompleted + folderQueue.Count);
|
||||
await _mediator.Publish(
|
||||
new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread));
|
||||
|
||||
string movieFolder = folderQueue.Dequeue();
|
||||
foldersCompleted++;
|
||||
|
||||
var allFiles = _localFileSystem.ListFiles(movieFolder)
|
||||
.Filter(f => VideoFileExtensions.Contains(Path.GetExtension(f)))
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
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 MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static LanguageExt.Prelude;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScanner
|
||||
{
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILocalMetadataProvider _localMetadataProvider;
|
||||
private readonly ILogger<MusicVideoFolderScanner> _logger;
|
||||
private readonly IMediator _mediator;
|
||||
private readonly IMusicVideoRepository _musicVideoRepository;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
|
||||
public MusicVideoFolderScanner(
|
||||
ILocalFileSystem localFileSystem,
|
||||
ILocalStatisticsProvider localStatisticsProvider,
|
||||
ILocalMetadataProvider localMetadataProvider,
|
||||
IMetadataRepository metadataRepository,
|
||||
IImageCache imageCache,
|
||||
ISearchIndex searchIndex,
|
||||
IMusicVideoRepository musicVideoRepository,
|
||||
IMediator mediator,
|
||||
ILogger<MusicVideoFolderScanner> logger) : base(
|
||||
localFileSystem,
|
||||
localStatisticsProvider,
|
||||
metadataRepository,
|
||||
imageCache,
|
||||
logger)
|
||||
{
|
||||
_localFileSystem = localFileSystem;
|
||||
_localMetadataProvider = localMetadataProvider;
|
||||
_searchIndex = searchIndex;
|
||||
_musicVideoRepository = musicVideoRepository;
|
||||
_mediator = mediator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> ScanFolder(
|
||||
LibraryPath libraryPath,
|
||||
string ffprobePath,
|
||||
DateTimeOffset lastScan,
|
||||
decimal progressMin,
|
||||
decimal progressMax)
|
||||
{
|
||||
decimal progressSpread = progressMax - progressMin;
|
||||
|
||||
if (!_localFileSystem.IsLibraryPathAccessible(libraryPath))
|
||||
{
|
||||
return new MediaSourceInaccessible();
|
||||
}
|
||||
|
||||
var foldersCompleted = 0;
|
||||
|
||||
var folderQueue = new Queue<string>();
|
||||
folderQueue.Enqueue(libraryPath.Path);
|
||||
|
||||
while (folderQueue.Count > 0)
|
||||
{
|
||||
decimal percentCompletion = (decimal) foldersCompleted / (foldersCompleted + folderQueue.Count);
|
||||
await _mediator.Publish(
|
||||
new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread));
|
||||
|
||||
string musicVideoFolder = folderQueue.Dequeue();
|
||||
foldersCompleted++;
|
||||
|
||||
var allFiles = _localFileSystem.ListFiles(musicVideoFolder)
|
||||
.Filter(f => VideoFileExtensions.Contains(Path.GetExtension(f)))
|
||||
.Filter(f => f.Contains(" - "))
|
||||
.ToList();
|
||||
|
||||
foreach (string subdirectory in _localFileSystem.ListSubdirectories(musicVideoFolder)
|
||||
.OrderBy(identity))
|
||||
{
|
||||
folderQueue.Enqueue(subdirectory);
|
||||
}
|
||||
|
||||
if (_localFileSystem.GetLastWriteTime(musicVideoFolder) < lastScan)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (string file in allFiles.OrderBy(identity))
|
||||
{
|
||||
// TODO: figure out how to rebuild playouts
|
||||
Either<BaseError, MediaItemScanResult<MusicVideo>> maybeMusicVideo = await _musicVideoRepository
|
||||
.GetOrAdd(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>>> UpdateMetadata(
|
||||
MediaItemScanResult<MusicVideo> result)
|
||||
{
|
||||
try
|
||||
{
|
||||
MusicVideo musicVideo = result.Item;
|
||||
await LocateNfoFile(musicVideo).Match(
|
||||
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;
|
||||
}
|
||||
}
|
||||
},
|
||||
async () =>
|
||||
{
|
||||
if (!Optional(musicVideo.MusicVideoMetadata).Flatten().Any())
|
||||
{
|
||||
string path = musicVideo.MediaVersions.Head().MediaFiles.Head().Path;
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", path);
|
||||
if (await _localMetadataProvider.RefreshFallbackMetadata(musicVideo))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Xml.Serialization;
|
||||
|
||||
namespace ErsatzTV.Core.Metadata.Nfo
|
||||
{
|
||||
[XmlRoot("movie")]
|
||||
public class MovieNfo
|
||||
{
|
||||
[XmlElement("title")]
|
||||
public string Title { get; set; }
|
||||
|
||||
[XmlElement("outline")]
|
||||
public string Outline { get; set; }
|
||||
|
||||
[XmlElement("year")]
|
||||
public int Year { get; set; }
|
||||
|
||||
[XmlElement("mpaa")]
|
||||
public string ContentRating { get; set; }
|
||||
|
||||
[XmlElement("premiered")]
|
||||
public DateTime Premiered { get; set; }
|
||||
|
||||
[XmlElement("plot")]
|
||||
public string Plot { get; set; }
|
||||
|
||||
[XmlElement("tagline")]
|
||||
public string Tagline { 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; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Xml.Serialization;
|
||||
|
||||
namespace ErsatzTV.Core.Metadata.Nfo
|
||||
{
|
||||
[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; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Xml.Serialization;
|
||||
|
||||
namespace ErsatzTV.Core.Metadata.Nfo
|
||||
{
|
||||
[XmlRoot("episodedetails")]
|
||||
public class TvShowEpisodeNfo
|
||||
{
|
||||
[XmlElement("showtitle")]
|
||||
public string ShowTitle { get; set; }
|
||||
|
||||
[XmlElement("title")]
|
||||
public string Title { get; set; }
|
||||
|
||||
[XmlElement("episode")]
|
||||
public int Episode { get; set; }
|
||||
|
||||
[XmlElement("season")]
|
||||
public int Season { get; set; }
|
||||
|
||||
[XmlElement("mpaa")]
|
||||
public string ContentRating { get; set; }
|
||||
|
||||
[XmlElement("aired")]
|
||||
public string Aired { get; set; }
|
||||
|
||||
[XmlElement("plot")]
|
||||
public string Plot { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Xml.Serialization;
|
||||
|
||||
namespace ErsatzTV.Core.Metadata.Nfo
|
||||
{
|
||||
[XmlRoot("tvshow")]
|
||||
public class TvShowNfo
|
||||
{
|
||||
[XmlElement("title")]
|
||||
public string Title { get; set; }
|
||||
|
||||
[XmlElement("year")]
|
||||
public int Year { get; set; }
|
||||
|
||||
[XmlElement("plot")]
|
||||
public string Plot { get; set; }
|
||||
|
||||
[XmlElement("outline")]
|
||||
public string Outline { get; set; }
|
||||
|
||||
[XmlElement("tagline")]
|
||||
public string Tagline { get; set; }
|
||||
|
||||
[XmlElement("premiered")]
|
||||
public string Premiered { 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; }
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,10 @@ using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static LanguageExt.Prelude;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
@@ -20,6 +22,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILocalMetadataProvider _localMetadataProvider;
|
||||
private readonly ILogger<TelevisionFolderScanner> _logger;
|
||||
private readonly IMediator _mediator;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ITelevisionRepository _televisionRepository;
|
||||
|
||||
@@ -31,6 +34,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
IMetadataRepository metadataRepository,
|
||||
IImageCache imageCache,
|
||||
ISearchIndex searchIndex,
|
||||
IMediator mediator,
|
||||
ILogger<TelevisionFolderScanner> logger) : base(
|
||||
localFileSystem,
|
||||
localStatisticsProvider,
|
||||
@@ -42,14 +46,19 @@ namespace ErsatzTV.Core.Metadata
|
||||
_televisionRepository = televisionRepository;
|
||||
_localMetadataProvider = localMetadataProvider;
|
||||
_searchIndex = searchIndex;
|
||||
_mediator = mediator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> ScanFolder(
|
||||
LibraryPath libraryPath,
|
||||
string ffprobePath,
|
||||
DateTimeOffset lastScan)
|
||||
DateTimeOffset lastScan,
|
||||
decimal progressMin,
|
||||
decimal progressMax)
|
||||
{
|
||||
decimal progressSpread = progressMax - progressMin;
|
||||
|
||||
if (!_localFileSystem.IsLibraryPathAccessible(libraryPath))
|
||||
{
|
||||
return new MediaSourceInaccessible();
|
||||
@@ -62,6 +71,10 @@ namespace ErsatzTV.Core.Metadata
|
||||
|
||||
foreach (string showFolder in allShowFolders)
|
||||
{
|
||||
decimal percentCompletion = (decimal) allShowFolders.IndexOf(showFolder) / allShowFolders.Count;
|
||||
await _mediator.Publish(
|
||||
new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread));
|
||||
|
||||
Either<BaseError, MediaItemScanResult<Show>> maybeShow =
|
||||
await FindOrCreateShow(libraryPath.Id, showFolder)
|
||||
.BindT(show => UpdateMetadataForShow(show, showFolder))
|
||||
|
||||
@@ -3,16 +3,21 @@ using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Core.Plex
|
||||
{
|
||||
public abstract class PlexLibraryScanner
|
||||
{
|
||||
private readonly ILogger<PlexLibraryScanner> _logger;
|
||||
private readonly IMetadataRepository _metadataRepository;
|
||||
|
||||
protected PlexLibraryScanner(IMetadataRepository metadataRepository) =>
|
||||
protected PlexLibraryScanner(IMetadataRepository metadataRepository, ILogger<PlexLibraryScanner> logger)
|
||||
{
|
||||
_metadataRepository = metadataRepository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected async Task<Unit> UpdateArtworkIfNeeded(
|
||||
Domain.Metadata existingMetadata,
|
||||
@@ -27,6 +32,8 @@ namespace ErsatzTV.Core.Plex
|
||||
await maybeIncomingArtwork.Match(
|
||||
async incomingArtwork =>
|
||||
{
|
||||
_logger.LogDebug("Refreshing Plex {Attribute} from {Path}", artworkKind, incomingArtwork.Path);
|
||||
|
||||
Option<Artwork> maybeExistingArtwork = Optional(existingMetadata.Artwork).Flatten()
|
||||
.Find(a => a.ArtworkKind == artworkKind);
|
||||
|
||||
|
||||
@@ -7,13 +7,16 @@ using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Core.Plex
|
||||
{
|
||||
public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScanner
|
||||
{
|
||||
private readonly ILogger<PlexMovieLibraryScanner> _logger;
|
||||
private readonly IMediator _mediator;
|
||||
private readonly IMetadataRepository _metadataRepository;
|
||||
private readonly IMovieRepository _movieRepository;
|
||||
private readonly IPlexServerApiClient _plexServerApiClient;
|
||||
@@ -24,13 +27,15 @@ namespace ErsatzTV.Core.Plex
|
||||
IMovieRepository movieRepository,
|
||||
IMetadataRepository metadataRepository,
|
||||
ISearchIndex searchIndex,
|
||||
IMediator mediator,
|
||||
ILogger<PlexMovieLibraryScanner> logger)
|
||||
: base(metadataRepository)
|
||||
: base(metadataRepository, logger)
|
||||
{
|
||||
_plexServerApiClient = plexServerApiClient;
|
||||
_movieRepository = movieRepository;
|
||||
_metadataRepository = metadataRepository;
|
||||
_searchIndex = searchIndex;
|
||||
_mediator = mediator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -49,6 +54,9 @@ namespace ErsatzTV.Core.Plex
|
||||
{
|
||||
foreach (PlexMovie incoming in movieEntries)
|
||||
{
|
||||
decimal percentCompletion = (decimal) movieEntries.IndexOf(incoming) / movieEntries.Count;
|
||||
await _mediator.Publish(new LibraryScanProgress(plexMediaSourceLibrary.Id, percentCompletion));
|
||||
|
||||
// TODO: figure out how to rebuild playlists
|
||||
Either<BaseError, MediaItemScanResult<PlexMovie>> maybeMovie = await _movieRepository
|
||||
.GetOrAdd(plexMediaSourceLibrary, incoming)
|
||||
@@ -81,6 +89,8 @@ namespace ErsatzTV.Core.Plex
|
||||
var movieKeys = movieEntries.Map(s => s.Key).ToList();
|
||||
List<int> ids = await _movieRepository.RemoveMissingPlexMovies(plexMediaSourceLibrary, movieKeys);
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
|
||||
await _mediator.Publish(new LibraryScanProgress(plexMediaSourceLibrary.Id, 0));
|
||||
},
|
||||
error =>
|
||||
{
|
||||
@@ -113,6 +123,11 @@ namespace ErsatzTV.Core.Plex
|
||||
await maybeStatistics.Match(
|
||||
async mediaVersion =>
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Refreshing {Attribute} from {Path}",
|
||||
"Plex Statistics",
|
||||
existingVersion.MediaFiles.Head().Path);
|
||||
|
||||
existingVersion.SampleAspectRatio = mediaVersion.SampleAspectRatio;
|
||||
existingVersion.VideoScanKind = mediaVersion.VideoScanKind;
|
||||
existingVersion.DateUpdated = mediaVersion.DateUpdated;
|
||||
@@ -135,6 +150,11 @@ namespace ErsatzTV.Core.Plex
|
||||
|
||||
if (incomingMetadata.DateUpdated > existingMetadata.DateUpdated)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Refreshing {Attribute} from {Path}",
|
||||
"Plex Metadata",
|
||||
existing.MediaVersions.Head().MediaFiles.Head().Path);
|
||||
|
||||
foreach (Genre genre in existingMetadata.Genres
|
||||
.Filter(g => incomingMetadata.Genres.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
|
||||
@@ -7,14 +7,17 @@ using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static LanguageExt.Prelude;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Core.Plex
|
||||
{
|
||||
public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionLibraryScanner
|
||||
{
|
||||
private readonly ILogger<PlexTelevisionLibraryScanner> _logger;
|
||||
private readonly IMediator _mediator;
|
||||
private readonly IMetadataRepository _metadataRepository;
|
||||
private readonly IPlexServerApiClient _plexServerApiClient;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
@@ -25,13 +28,15 @@ namespace ErsatzTV.Core.Plex
|
||||
ITelevisionRepository televisionRepository,
|
||||
IMetadataRepository metadataRepository,
|
||||
ISearchIndex searchIndex,
|
||||
IMediator mediator,
|
||||
ILogger<PlexTelevisionLibraryScanner> logger)
|
||||
: base(metadataRepository)
|
||||
: base(metadataRepository, logger)
|
||||
{
|
||||
_plexServerApiClient = plexServerApiClient;
|
||||
_televisionRepository = televisionRepository;
|
||||
_metadataRepository = metadataRepository;
|
||||
_searchIndex = searchIndex;
|
||||
_mediator = mediator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -50,6 +55,9 @@ namespace ErsatzTV.Core.Plex
|
||||
{
|
||||
foreach (PlexShow incoming in showEntries)
|
||||
{
|
||||
decimal percentCompletion = (decimal) showEntries.IndexOf(incoming) / showEntries.Count;
|
||||
await _mediator.Publish(new LibraryScanProgress(plexMediaSourceLibrary.Id, percentCompletion));
|
||||
|
||||
// TODO: figure out how to rebuild playlists
|
||||
Either<BaseError, MediaItemScanResult<PlexShow>> maybeShow = await _televisionRepository
|
||||
.GetOrAddPlexShow(plexMediaSourceLibrary, incoming)
|
||||
@@ -85,6 +93,8 @@ namespace ErsatzTV.Core.Plex
|
||||
await _televisionRepository.RemoveMissingPlexShows(plexMediaSourceLibrary, showKeys);
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
|
||||
await _mediator.Publish(new LibraryScanProgress(plexMediaSourceLibrary.Id, 0));
|
||||
|
||||
return Unit.Default;
|
||||
},
|
||||
error =>
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ namespace ErsatzTV.Core.Scheduling
|
||||
case ProgramScheduleItemCollectionType.Collection:
|
||||
Option<List<MediaItem>> maybeItems =
|
||||
await _mediaCollectionRepository.GetItems(collectionKey.CollectionId ?? 0);
|
||||
return Tuple(collectionKey, maybeItems.IfNone(new List<MediaItem>()));
|
||||
return Tuple(collectionKey, await maybeItems.IfNoneAsync(new List<MediaItem>()));
|
||||
case ProgramScheduleItemCollectionType.TelevisionShow:
|
||||
List<Episode> showItems =
|
||||
await _televisionRepository.GetShowItems(collectionKey.MediaItemId ?? 0);
|
||||
@@ -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)
|
||||
@@ -165,7 +167,7 @@ namespace ErsatzTV.Core.Scheduling
|
||||
durationFinish.IsSome);
|
||||
|
||||
IMediaCollectionEnumerator enumerator = collectionEnumerators[CollectionKeyForItem(scheduleItem)];
|
||||
enumerator.Current.IfSome(
|
||||
await enumerator.Current.IfSomeAsync(
|
||||
mediaItem =>
|
||||
{
|
||||
_logger.LogDebug(
|
||||
@@ -180,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))
|
||||
};
|
||||
|
||||
@@ -235,11 +238,12 @@ namespace ErsatzTV.Core.Scheduling
|
||||
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))
|
||||
};
|
||||
|
||||
@@ -274,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))
|
||||
};
|
||||
|
||||
@@ -479,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
|
||||
@@ -8,7 +10,7 @@ namespace ErsatzTV.Infrastructure.Data
|
||||
{
|
||||
public static class DbInitializer
|
||||
{
|
||||
public static Unit Initialize(TvContext context)
|
||||
public static async Task<Unit> Initialize(TvContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
if (context.Resolutions.Any())
|
||||
{
|
||||
@@ -22,28 +24,28 @@ namespace ErsatzTV.Infrastructure.Data
|
||||
new() { Id = 3, Name = "1920x1080", Width = 1920, Height = 1080 },
|
||||
new() { Id = 4, Name = "3840x2160", Width = 3840, Height = 2160 }
|
||||
};
|
||||
context.Resolutions.AddRange(resolutions);
|
||||
context.SaveChanges();
|
||||
await context.Resolutions.AddRangeAsync(resolutions, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var resolutionConfig = new ConfigElement
|
||||
{
|
||||
Key = ConfigElementKey.FFmpegDefaultResolutionId.Key,
|
||||
Value = "3" // 1920x1080
|
||||
};
|
||||
context.ConfigElements.Add(resolutionConfig);
|
||||
context.SaveChanges();
|
||||
await context.ConfigElements.AddAsync(resolutionConfig, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var defaultProfile = FFmpegProfile.New("1920x1080 x264 ac3", resolutions[2]);
|
||||
context.FFmpegProfiles.Add(defaultProfile);
|
||||
context.SaveChanges();
|
||||
await context.FFmpegProfiles.AddAsync(defaultProfile, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var profileConfig = new ConfigElement
|
||||
{
|
||||
Key = ConfigElementKey.FFmpegDefaultProfileId.Key,
|
||||
Value = defaultProfile.Id.ToString()
|
||||
};
|
||||
context.ConfigElements.Add(profileConfig);
|
||||
context.SaveChanges();
|
||||
await context.ConfigElements.AddAsync(profileConfig, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var defaultChannel = new Channel(Guid.NewGuid())
|
||||
{
|
||||
@@ -52,8 +54,8 @@ namespace ErsatzTV.Infrastructure.Data
|
||||
FFmpegProfile = defaultProfile,
|
||||
StreamingMode = StreamingMode.TransportStream
|
||||
};
|
||||
context.Channels.Add(defaultChannel);
|
||||
context.SaveChanges();
|
||||
await context.Channels.AddAsync(defaultChannel, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// TODO: create looping static image that mentions configuring via web
|
||||
return Unit.Default;
|
||||
|
||||
@@ -62,12 +62,17 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(c => c.Playouts)
|
||||
.ThenInclude(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task Update(Channel channel)
|
||||
public Task Update(Channel channel)
|
||||
{
|
||||
_dbContext.Channels.Update(channel);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
return _dbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task Delete(int channelId)
|
||||
|
||||
@@ -31,16 +31,16 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
public Task<Option<T>> GetValue<T>(ConfigElementKey key) =>
|
||||
Get(key).MapT(ce => (T) Convert.ChangeType(ce.Value, typeof(T)));
|
||||
|
||||
public async Task Update(ConfigElement configElement)
|
||||
public Task Update(ConfigElement configElement)
|
||||
{
|
||||
_dbContext.ConfigElements.Update(configElement);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
return _dbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task Delete(ConfigElement configElement)
|
||||
public Task Delete(ConfigElement configElement)
|
||||
{
|
||||
_dbContext.ConfigElements.Remove(configElement);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
return _dbContext.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,10 +32,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.Include(p => p.Resolution)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task Update(FFmpegProfile ffmpegProfile)
|
||||
public Task Update(FFmpegProfile ffmpegProfile)
|
||||
{
|
||||
_dbContext.FFmpegProfiles.Update(ffmpegProfile);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
return _dbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task Delete(int ffmpegProfileId)
|
||||
|
||||
@@ -52,13 +52,20 @@ 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(
|
||||
"UPDATE Library SET LastScan = @LastScan WHERE Id = @Id",
|
||||
new { library.LastScan, library.Id }).ToUnit();
|
||||
|
||||
public Task<Unit> UpdateLastScan(LibraryPath libraryPath) => _dbConnection.ExecuteAsync(
|
||||
"UPDATE LibraryPath SET LastScan = @LastScan WHERE Id = @Id",
|
||||
new { libraryPath.LastScan, libraryPath.Id }).ToUnit();
|
||||
|
||||
public Task<List<LibraryPath>> GetLocalPaths(int libraryId)
|
||||
{
|
||||
using TvContext context = _dbContextFactory.CreateDbContext();
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Dapper;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
@@ -11,10 +13,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
{
|
||||
public class MediaItemRepository : IMediaItemRepository
|
||||
{
|
||||
private readonly IDbConnection _dbConnection;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public MediaItemRepository(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
public MediaItemRepository(IDbContextFactory<TvContext> dbContextFactory, IDbConnection dbConnection)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_dbConnection = dbConnection;
|
||||
}
|
||||
|
||||
public async Task<Option<MediaItem>> Get(int id)
|
||||
{
|
||||
@@ -38,5 +44,16 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
context.MediaItems.Update(mediaItem);
|
||||
return await context.SaveChangesAsync() > 0;
|
||||
}
|
||||
|
||||
public Task<List<string>> GetAllLanguageCodes() =>
|
||||
_dbConnection.QueryAsync<string>(
|
||||
@"SELECT LanguageCode FROM
|
||||
(SELECT Language AS LanguageCode
|
||||
FROM MediaStream WHERE Language IS NOT NULL
|
||||
UNION ALL SELECT PreferredLanguageCode AS LanguageCode
|
||||
FROM Channel WHERE PreferredLanguageCode IS NOT NULL)
|
||||
GROUP BY LanguageCode
|
||||
ORDER BY COUNT(LanguageCode) DESC")
|
||||
.Map(result => result.ToList());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,170 @@
|
||||
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<Either<BaseError, MediaItemScanResult<MusicVideo>>> GetOrAdd(
|
||||
LibraryPath libraryPath,
|
||||
string path)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
Option<MusicVideo> maybeExisting = await 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)
|
||||
.Include(mv => mv.MediaVersions)
|
||||
.ThenInclude(mv => mv.Streams)
|
||||
.OrderBy(i => i.MediaVersions.First().MediaFiles.First().Path)
|
||||
.SingleOrDefaultAsync(i => i.MediaVersions.First().MediaFiles.First().Path == path);
|
||||
|
||||
return await maybeExisting.Match(
|
||||
mediaItem =>
|
||||
Right<BaseError, MediaItemScanResult<MusicVideo>>(
|
||||
new MediaItemScanResult<MusicVideo>(mediaItem) { IsAdded = false }).AsTask(),
|
||||
async () => await AddMusicVideo(dbContext, libraryPath.Id, path));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
private static async Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> AddMusicVideo(
|
||||
TvContext dbContext,
|
||||
int libraryPathId,
|
||||
string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var musicVideo = new MusicVideo
|
||||
{
|
||||
LibraryPathId = libraryPathId,
|
||||
MediaVersions = new List<MediaVersion>
|
||||
{
|
||||
new()
|
||||
{
|
||||
MediaFiles = new List<MediaFile>
|
||||
{
|
||||
new() { Path = path }
|
||||
},
|
||||
Streams = new List<MediaStream>()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await dbContext.MusicVideos.AddAsync(musicVideo);
|
||||
await dbContext.SaveChangesAsync();
|
||||
await dbContext.Entry(musicVideo).Reference(m => m.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,14 @@ 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()
|
||||
.OrderBy(pi => pi.Start)
|
||||
.SingleOrDefaultAsync()
|
||||
.Map(Optional);
|
||||
|
||||
@@ -89,6 +96,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 +108,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();
|
||||
}
|
||||
@@ -110,10 +125,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task Update(Playout playout)
|
||||
public Task Update(Playout playout)
|
||||
{
|
||||
_dbContext.Playouts.Update(playout);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
return _dbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task Delete(int playoutId)
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -3,9 +3,14 @@
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
|
||||
<NoWarn>VSTHRD200</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AsyncFixer" Version="1.5.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Dapper" Version="2.0.78" />
|
||||
<PackageReference Include="Lucene.Net" Version="4.8.0-beta00013" />
|
||||
<PackageReference Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00013" />
|
||||
@@ -16,6 +21,10 @@
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.4" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.9.60">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Refit" Version="6.0.38" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="1.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -8,7 +8,6 @@ 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;
|
||||
@@ -19,15 +18,10 @@ 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, ILogger<ImageCache> logger)
|
||||
{
|
||||
_localFileSystem = localFileSystem;
|
||||
_logger = logger;
|
||||
}
|
||||
public ImageCache(ILocalFileSystem localFileSystem) => _localFileSystem = localFileSystem;
|
||||
|
||||
public async Task<Either<BaseError, byte[]>> ResizeImage(byte[] imageBuffer, int height)
|
||||
{
|
||||
|
||||
Generated
+1829
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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
+1829
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1826
File diff suppressed because it is too large
Load Diff
+20
@@ -0,0 +1,20 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class 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);
|
||||
}
|
||||
}
|
||||
Generated
+1826
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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user