Compare commits

..
Author SHA1 Message Date
Jason DoveandGitHub a2700e087c show release notes on home page (#165) 2021-04-11 12:34:41 -05:00
Jason DoveandGitHub 34fbfce0a5 limit to one playout per channel (#164) 2021-04-11 05:55:26 -05:00
Jason Dove 993293c104 add search docs [no docker] 2021-04-11 05:34:20 -05:00
Jason DoveandGitHub ececa62446 fix synchronizing plex show metadata (#163) 2021-04-10 20:57:09 -05:00
Jason DoveandGitHub 237729e79d add movie, show, artist language buttons. search by english language name (#162) 2021-04-10 20:40:54 -05:00
Jason DoveandGitHub 9c0ada2df5 fix television metadata (#161) 2021-04-10 18:40:18 -05:00
Jason Dove dee264597b fix search index warning 2021-04-10 13:07:58 -05:00
Jason Dove a8db294043 add local libraries doc [no docker] 2021-04-09 18:33:28 -05:00
Jason Dove a2a63e0120 add creative commons attribution [no docker] 2021-04-09 15:11:22 -05:00
Jason Dove c7881aec14 remove screenshots [no docker] 2021-04-09 14:39:10 -05:00
Jason DoveandGitHub 558bdcb6b0 client setup doc updates (#160)
* update jellyfin client setup

* update channels-dvr setup
2021-04-09 13:11:49 -05:00
Jason DoveandGitHub 24f2b4b727 force music video library scan (#159) 2021-04-09 07:42:13 -05:00
Jason DoveandGitHub 667887f387 fix television show and season playouts (#158) 2021-04-09 07:24:12 -05:00
Jason Dove 98eb72fcfe skip docker with [no docker] 2021-04-09 06:42:16 -05:00
Jason Dove c2f92fd054 add github release links to docs [no ci] 2021-04-09 06:39:22 -05:00
Jason DoveandGitHub f04ddd3a40 save collection page size (#157) 2021-04-09 06:30:46 -05:00
Jason DoveandGitHub aa0942384d fix removing deleted music videos (#156) 2021-04-09 05:34:28 -05:00
Jason DoveandGitHub cd100be3a2 relax music video naming requirements (#155) 2021-04-09 05:22:02 -05:00
Jason DoveandGitHub 2b26a5411c add artists as owners of music videos (#154)
* clean up genre, tag, studio orphans

* enforce foreign keys at connection level

* wip

* fix fragment scroll offset

* fix see all link for music videos

* add fake artist metadata

* not null artist id

* add artist scanning

* remove improperly named music videos

* code cleanup

* add artists to search results and collections

* clean up music video metadata / artist

* add artist view

* show music videos on artist page

* add music video artwork placeholder
2021-04-09 05:10:58 -05:00
Jason DoveandGitHub baf81f31cd fix plex server and connection sync (#153) 2021-04-08 18:48:12 -05:00
Jason DoveandGitHub bfa290790b trim parsed music video titles (#152) 2021-04-07 16:36:20 -05:00
Jason DoveandGitHub b975922a77 only index video stream languages for movies and music videos (#151) 2021-04-07 16:12:39 -05:00
Jason Dove 1a39978a77 try to fix windows builds 2021-04-07 05:34:02 -05:00
Jason DoveandGitHub 436c9119fa add all search results to collection (#150) 2021-04-06 20:43:44 -05:00
Jason Dove 33642a13ce allow manual ci trigger 2021-04-06 09:21:54 -05:00
Jason Dove 09b349d1cb only index audio language [no ci] 2021-04-06 09:11:39 -05:00
Jason DoveandGitHub 2be729c10e search show by language (#149) 2021-04-06 07:58:45 -05:00
Jason DoveandGitHub 0aac702853 search movies and music videos by language (#148) 2021-04-06 07:42:21 -05:00
Jason DoveandGitHub 3f406ac556 log viewer improvements (#147) 2021-04-06 07:25:05 -05:00
Jason DoveandGitHub 454e2edf7c add documentation link to ui (#146) 2021-04-06 06:39:48 -05:00
b3f4fa8c23 add documentation (#145)
* start to reorganize documentation

* revert readme tag changes

* revert readme tag changes pt2

* doc updates; doc theme updates

* doc updates

* publish docs from documentation branch

* use favicon for docs

* create channel

* collections, jellyfin client, schedule items and playout docs

* channels dvr

* tivimate

* scale tivimate screenshots

* add channels dvr server setup

* add copyright and social

* Added UnRAID Docker install, formatting fixes (#100)

Co-authored-by: Thaddeus Cooper <redacted@redacted.co.nz>

* minor doc updates

* readme tweak

* add basic plex documentation

Co-authored-by: suckerface <9060047+suckerface@users.noreply.github.com>
Co-authored-by: Thaddeus Cooper <redacted@redacted.co.nz>
2021-04-06 06:20:49 -05:00
Jason DoveandGitHub a6496db58d settings rework (#144)
* add hdhr tuner count setting

* code cleanup
2021-04-05 19:48:17 -05:00
Jason Dove 3eed79b5e1 Merge branch 'main' of github.com:jasongdove/ErsatzTV 2021-04-05 16:19:22 -05:00
Jason DoveandGitHub 79bfba6428 better search index thread fix (#143)
* Revert "fix search index threading (#141)"

This reverts commit 3fb6da0754.

* better search index thread fix
2021-04-05 16:18:17 -05:00
Jason Dove 9f6d4114a6 Merge branch 'main' of github.com:jasongdove/ErsatzTV 2021-04-05 16:06:28 -05:00
Jason DoveandGitHub 9809c60924 send all audio streams on hls channels with no preferred language (#142)
* Revert "fix search index threading (#141)"

This reverts commit 3fb6da0754.

* send all audio streams on hls channels with no preferred language
2021-04-05 16:06:13 -05:00
Jason Dove 16072fed1c Revert "fix search index threading (#141)"
This reverts commit 3fb6da0754.
2021-04-05 07:44:42 -05:00
Jason DoveandGitHub 3fb6da0754 fix search index threading (#141)
* fix search index threading

* code cleanup
2021-04-05 05:41:29 -05:00
Jason DoveandGitHub 24cdf6295f clean up fragment letter anchor code (#140) 2021-04-04 20:56:47 -05:00
Jason DoveandGitHub c1b41e2865 use fragment navigation with letter bar (#139) 2021-04-04 20:30:40 -05:00
Jason DoveandGitHub d249e95f12 fix poster width (#138) 2021-04-04 20:13:29 -05:00
Jason DoveandGitHub efae005447 use full preferred language names in ui (#137) 2021-04-04 18:30:42 -05:00
Jason DoveandGitHub cead787c55 force SAR 1:1 if missing (#136) 2021-04-04 18:00:39 -05:00
Jason DoveandGitHub 77a69af1a8 sort channels and schedules in playout editor (#135) 2021-04-04 16:24:46 -05:00
Jason DoveandGitHub 8fea24a3a5 add fallback metadata for music videos (#134) 2021-04-04 15:57:25 -05:00
Jason DoveandGitHub 6b44873474 add library scan progress detail (#133)
* add library scan progress detail

* scan plex libraries on plex thread
2021-04-04 10:44:10 -05:00
Jason DoveandGitHub c5ee5903b2 use table for collections ui (#132) 2021-04-03 16:21:06 -05:00
Jason DoveandGitHub 526eada48b channels, schedules, playouts paging/sorting (#131)
* add paging to playouts

* add sorting, paging to schedules

* fix channels sorting; add channels paging
2021-04-03 16:07:05 -05:00
Jason DoveandGitHub 7a0d65a433 fix epg with music videos (#130) 2021-04-03 15:38:12 -05:00
Jason DoveandGitHub 74c95249c3 add loudness normalization (#129)
* fix music video search result artwork

* add normalize loudness setting

* fix audio normalization

* fix music video thumbnails in collection items view

* fix ef core warnings querying playout item

* implement audio loudness normalization filter
2021-04-03 13:36:11 -05:00
Jason DoveandGitHub d4a2197dfa async fixes (#128)
* refactor local metadata provider

* resolve async warnings

* more async fixes
2021-04-03 11:01:20 -05:00
Jason DoveandGitHub 633586ddba add music videos library (#125)
* add music videos library

* add music video tables

* first pass at music video library scan

* support music videos in playouts

* display music videos in search results and collections

* fix music video thumbnails

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

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

* let ffmpeg determine thread count by default

* disable stdin for ffmpeg processes
2021-03-31 01:08:57 +00:00
Jason DoveandGitHub fc33c5cd05 add show title to playout details (#120) 2021-03-30 21:23:02 +00:00
Jason DoveandGitHub 37eee73ab7 clear search query when clicking nav links (#119) 2021-03-30 21:10:56 +00:00
297 changed files with 43307 additions and 1506 deletions
+4
View File
@@ -79,3 +79,7 @@ indent_size=2
indent_style=space
indent_size=4
tab_width=4
[*.yml]
indent_style = space
indent_size = 2
+2 -1
View File
@@ -1,5 +1,6 @@
name: Build
on:
workflow_dispatch:
pull_request:
push:
branches:
@@ -35,7 +36,7 @@ jobs:
name: Build & Publish to Docker Hub
needs: build_and_test
runs-on: ubuntu-latest
if: github.event_name == 'push' && !contains(github.event.head_commit.message, '[no ci]')
if: github.event_name == 'push' && !contains(github.event.head_commit.message, '[no docker]')
steps:
- name: Checkout
uses: actions/checkout@v2
+19
View File
@@ -0,0 +1,19 @@
name: Publish docs via GitHub Pages
on:
push:
branches:
- main
jobs:
build:
name: Deploy docs
runs-on: ubuntu-latest
steps:
- name: Checkout master
uses: actions/checkout@v1
- name: Deploy docs
uses: mhausenblas/mkdocs-deploy-gh-pages@master
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CUSTOM_DOMAIN: ersatztv.org
+3
View File
@@ -40,3 +40,6 @@ msbuild.wrn
core
scripts/generate-api-sdk/swagger.json
docker-compose.override.yml
@@ -0,0 +1,16 @@
using System.Collections.Generic;
using System.Globalization;
namespace ErsatzTV.Application.Artists
{
public record ArtistViewModel(
string Name,
string Disambiguation,
string Biography,
string Thumbnail,
string FanArt,
List<string> Genres,
List<string> Styles,
List<string> Moods,
List<CultureInfo> Languages);
}
+46
View File
@@ -0,0 +1,46 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using ErsatzTV.Core.Domain;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.Artists
{
internal static class Mapper
{
internal static ArtistViewModel ProjectToViewModel(Artist artist, List<string> languages)
{
ArtistMetadata metadata = Optional(artist.ArtistMetadata).Flatten().Head();
return new ArtistViewModel(
metadata.Title,
metadata.Disambiguation,
metadata.Biography,
Artwork(metadata, ArtworkKind.Thumbnail),
Artwork(metadata, ArtworkKind.FanArt),
metadata.Genres.Map(g => g.Name).ToList(),
metadata.Styles.Map(s => s.Name).ToList(),
metadata.Moods.Map(m => m.Name).ToList(),
LanguagesForArtist(languages));
}
private static string Artwork(Metadata metadata, ArtworkKind artworkKind) =>
Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind))
.Match(a => a.Path, string.Empty);
private static List<CultureInfo> LanguagesForArtist(List<string> languages)
{
CultureInfo[] allCultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures);
return languages
.Distinct()
.Map(
lang => allCultures.Filter(
ci => string.Equals(ci.ThreeLetterISOLanguageName, lang, StringComparison.OrdinalIgnoreCase)))
.Sequence()
.Flatten()
.ToList();
}
}
}
@@ -0,0 +1,7 @@
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Artists.Queries
{
public record GetArtistById(int ArtistId) : IRequest<Option<ArtistViewModel>>;
}
@@ -0,0 +1,37 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.Artists.Mapper;
namespace ErsatzTV.Application.Artists.Queries
{
public class GetArtistByIdHandler : IRequestHandler<GetArtistById, Option<ArtistViewModel>>
{
private readonly IArtistRepository _artistRepository;
private readonly ISearchRepository _searchRepository;
public GetArtistByIdHandler(IArtistRepository artistRepository, ISearchRepository searchRepository)
{
_artistRepository = artistRepository;
_searchRepository = searchRepository;
}
public async Task<Option<ArtistViewModel>> Handle(
GetArtistById request,
CancellationToken cancellationToken)
{
Option<Artist> maybeArtist = await _artistRepository.GetArtist(request.ArtistId);
return await maybeArtist.Match<Task<Option<ArtistViewModel>>>(
async artist =>
{
List<string> languages = await _searchRepository.GetLanguagesForArtist(artist);
return ProjectToViewModel(artist, languages);
},
() => Task.FromResult(Option<ArtistViewModel>.None));
}
}
}
@@ -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")
};
}
}
@@ -0,0 +1,7 @@
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.HDHR.Commands
{
public record UpdateHDHRTunerCount(int TunerCount) : MediatR.IRequest<Either<BaseError, Unit>>;
}
@@ -0,0 +1,45 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.HDHR.Commands
{
public class UpdateHDHRTunerCountHandler : MediatR.IRequestHandler<UpdateHDHRTunerCount, Either<BaseError, Unit>>
{
private readonly IConfigElementRepository _configElementRepository;
public UpdateHDHRTunerCountHandler(IConfigElementRepository configElementRepository) =>
_configElementRepository = configElementRepository;
public Task<Either<BaseError, Unit>> Handle(
UpdateHDHRTunerCount request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(_ => Upsert(ConfigElementKey.HDHRTunerCount, request.TunerCount.ToString()))
.Bind(v => v.ToEitherAsync());
private Task<Validation<BaseError, Unit>> Validate(UpdateHDHRTunerCount request) =>
Optional(request.TunerCount)
.Filter(tc => tc > 0)
.Map(_ => Unit.Default)
.ToValidation<BaseError>("Tuner count must be greater than zero")
.AsTask();
private Task<Unit> 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);
}).ToUnit();
}
}
@@ -0,0 +1,6 @@
using MediatR;
namespace ErsatzTV.Application.HDHR.Queries
{
public record GetHDHRTunerCount : IRequest<int>;
}
@@ -0,0 +1,21 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.HDHR.Queries
{
public class GetHDHRTunerCountHandler : IRequestHandler<GetHDHRTunerCount, int>
{
private readonly IConfigElementRepository _configElementRepository;
public GetHDHRTunerCountHandler(IConfigElementRepository configElementRepository) =>
_configElementRepository = configElementRepository;
public Task<int> Handle(GetHDHRTunerCount request, CancellationToken cancellationToken) =>
_configElementRepository.GetValue<int>(ConfigElementKey.HDHRTunerCount)
.Map(result => result.IfNone(2));
}
}
@@ -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
@@ -1,12 +1,12 @@
using System;
using Serilog.Events;
namespace ErsatzTV.Application.Logs
{
public record LogEntryViewModel(
int Id,
DateTime Timestamp,
string Level,
LogEventLevel Level,
string Exception,
string RenderedMessage,
string Properties);
string Message);
}
+35 -6
View File
@@ -1,16 +1,45 @@
using ErsatzTV.Core.Domain;
using System;
using System.Collections.Generic;
using ErsatzTV.Core.Domain;
using Newtonsoft.Json.Linq;
using Serilog.Events;
namespace ErsatzTV.Application.Logs
{
internal static class Mapper
{
internal static LogEntryViewModel ProjectToViewModel(LogEntry logEntry) =>
new(
internal static LogEntryViewModel ProjectToViewModel(LogEntry logEntry)
{
string message = logEntry.RenderedMessage;
if (!string.IsNullOrWhiteSpace(logEntry.Properties))
{
foreach (KeyValuePair<string, JToken> property in JObject.Parse(logEntry.Properties))
{
var token = $"{{{property.Key}}}";
if (message.Contains(token))
{
message = message.Replace(token, property.Value.ToString());
}
var destructureToken = $"{{@{property.Key}}}";
if (message.Contains(destructureToken))
{
message = message.Replace(destructureToken, property.Value.ToString());
}
}
}
if (!Enum.TryParse(logEntry.Level, out LogEventLevel level))
{
level = LogEventLevel.Debug;
}
return new LogEntryViewModel(
logEntry.Id,
logEntry.Timestamp,
logEntry.Level,
level,
logEntry.Exception,
logEntry.RenderedMessage,
logEntry.Properties);
message);
}
}
}
@@ -0,0 +1,11 @@
using System.Collections.Generic;
using ErsatzTV.Core.Search;
using LanguageExt;
namespace ErsatzTV.Application.MediaCards
{
public record ArtistCardResultsViewModel(
int Count,
List<ArtistCardViewModel> Cards,
Option<SearchPageMap> PageMap);
}
@@ -0,0 +1,12 @@
namespace ErsatzTV.Application.MediaCards
{
public record ArtistCardViewModel
(int ArtistId, string Title, string Subtitle, string SortTitle, string Poster) : MediaCardViewModel(
ArtistId,
Title,
Subtitle,
SortTitle,
Poster)
{
}
}
@@ -7,7 +7,9 @@ namespace ErsatzTV.Application.MediaCards
List<MovieCardViewModel> MovieCards,
List<TelevisionShowCardViewModel> ShowCards,
List<TelevisionSeasonCardViewModel> SeasonCards,
List<TelevisionEpisodeCardViewModel> EpisodeCards)
List<TelevisionEpisodeCardViewModel> EpisodeCards,
List<ArtistCardViewModel> ArtistCards,
List<MusicVideoCardViewModel> MusicVideoCards)
{
public bool UseCustomPlaybackOrder { get; set; }
}
+20
View File
@@ -52,6 +52,23 @@ namespace ErsatzTV.Application.MediaCards
movieMetadata.SortTitle,
GetPoster(movieMetadata));
internal static MusicVideoCardViewModel ProjectToViewModel(MusicVideoMetadata musicVideoMetadata) =>
new(
musicVideoMetadata.MusicVideoId,
musicVideoMetadata.Title,
musicVideoMetadata.MusicVideo.Artist.ArtistMetadata.Head().Title,
musicVideoMetadata.SortTitle,
musicVideoMetadata.Plot,
GetThumbnail(musicVideoMetadata));
internal static ArtistCardViewModel ProjectToViewModel(ArtistMetadata artistMetadata) =>
new(
artistMetadata.ArtistId,
artistMetadata.Title,
artistMetadata.Disambiguation,
artistMetadata.SortTitle,
GetThumbnail(artistMetadata));
internal static CollectionCardResultsViewModel
ProjectToViewModel(Collection collection) =>
new(
@@ -64,6 +81,9 @@ 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<Artist>().Map(a => ProjectToViewModel(a.ArtistMetadata.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,19 @@
namespace ErsatzTV.Application.MediaCards
{
public record MusicVideoCardViewModel
(
int MusicVideoId,
string Title,
string Subtitle,
string SortTitle,
string Plot,
string Poster) : MediaCardViewModel(
MusicVideoId,
Title,
Subtitle,
SortTitle,
Poster)
{
public int CustomIndex { get; set; }
}
}
@@ -0,0 +1,7 @@
using MediatR;
namespace ErsatzTV.Application.MediaCards.Queries
{
public record GetMusicVideoCards
(int ArtistId, int PageNumber, int PageSize) : IRequest<MusicVideoCardResultsViewModel>;
}
@@ -0,0 +1,33 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.MediaCards.Mapper;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaCards.Queries
{
public class GetMusicVideoCardsHandler : IRequestHandler<GetMusicVideoCards, MusicVideoCardResultsViewModel>
{
private readonly IMusicVideoRepository _musicVideoRepository;
public GetMusicVideoCardsHandler(IMusicVideoRepository musicVideoRepository) =>
_musicVideoRepository = musicVideoRepository;
public async Task<MusicVideoCardResultsViewModel> Handle(
GetMusicVideoCards request,
CancellationToken cancellationToken)
{
int count = await _musicVideoRepository.GetMusicVideoCount(request.ArtistId);
List<MusicVideoCardViewModel> results = await _musicVideoRepository
.GetPagedMusicVideos(request.ArtistId, request.PageNumber, request.PageSize)
.Map(list => list.Map(ProjectToViewModel).ToList());
return new MusicVideoCardResultsViewModel(count, results, None);
}
}
}
@@ -0,0 +1,8 @@
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record AddArtistToCollection
(int CollectionId, int ArtistId) : 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
AddArtistToCollectionHandler : MediatR.IRequestHandler<AddArtistToCollection, Either<BaseError, Unit>>
{
private readonly IArtistRepository _artistRepository;
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
public AddArtistToCollectionHandler(
IMediaCollectionRepository mediaCollectionRepository,
IArtistRepository artistRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
{
_mediaCollectionRepository = mediaCollectionRepository;
_artistRepository = artistRepository;
_channel = channel;
}
public Task<Either<BaseError, Unit>> Handle(
AddArtistToCollection request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(_ => ApplyAddArtistRequest(request))
.Bind(v => v.ToEitherAsync());
private async Task<Unit> ApplyAddArtistRequest(AddArtistToCollection request)
{
if (await _mediaCollectionRepository.AddMediaItem(request.CollectionId, request.ArtistId))
{
// 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(AddArtistToCollection request) =>
(await CollectionMustExist(request), await ValidateArtist(request))
.Apply((_, _) => Unit.Default);
private Task<Validation<BaseError, Unit>> CollectionMustExist(AddArtistToCollection request) =>
_mediaCollectionRepository.GetCollectionWithItems(request.CollectionId)
.MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Collection does not exist."));
private Task<Validation<BaseError, Unit>> ValidateArtist(AddArtistToCollection request) =>
LoadArtist(request)
.MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Music video does not exist"));
private Task<Option<Artist>> LoadArtist(AddArtistToCollection request) =>
_artistRepository.GetArtist(request.ArtistId);
}
}
@@ -5,5 +5,10 @@ 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> ArtistIds,
List<int> MusicVideoIds) : MediatR.IRequest<Either<BaseError, Unit>>;
}
@@ -39,9 +39,13 @@ namespace ErsatzTV.Application.MediaCollections.Commands
private async Task<Unit> ApplyAddItemsRequest(AddItemsToCollection request)
{
if (await _mediaCollectionRepository.AddMediaItems(
request.CollectionId,
request.MovieIds.Append(request.ShowIds).ToList()))
var allItems = request.MovieIds
.Append(request.ShowIds)
.Append(request.ArtistIds)
.Append(request.MusicVideoIds)
.ToList();
if (await _mediaCollectionRepository.AddMediaItems(request.CollectionId, allItems))
{
// 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);
}
}
@@ -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)
{
@@ -0,0 +1,6 @@
using System.Collections.Generic;
namespace ErsatzTV.Application.MediaCollections
{
public record PagedMediaCollectionsViewModel(int TotalCount, List<MediaCollectionViewModel> Page);
}
@@ -0,0 +1,6 @@
using MediatR;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public record GetPagedCollections(int PageNum, int PageSize) : IRequest<PagedMediaCollectionsViewModel>;
}
@@ -0,0 +1,32 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.MediaCollections.Mapper;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public class GetPagedCollectionsHandler : IRequestHandler<GetPagedCollections, PagedMediaCollectionsViewModel>
{
private readonly IMediaCollectionRepository _mediaCollectionRepository;
public GetPagedCollectionsHandler(IMediaCollectionRepository mediaCollectionRepository) =>
_mediaCollectionRepository = mediaCollectionRepository;
public async Task<PagedMediaCollectionsViewModel> Handle(
GetPagedCollections request,
CancellationToken cancellationToken)
{
int count = await _mediaCollectionRepository.CountAllCollections();
List<MediaCollectionViewModel> page = await _mediaCollectionRepository
.GetPagedCollections(request.PageNum, request.PageSize)
.Map(list => list.Map(ProjectToViewModel).ToList());
return new PagedMediaCollectionsViewModel(count, page);
}
}
}
+1 -55
View File
@@ -1,5 +1,4 @@
using System;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaItems
{
@@ -8,59 +7,6 @@ namespace ErsatzTV.Application.MediaItems
internal static MediaItemViewModel ProjectToViewModel(MediaItem mediaItem) =>
new(mediaItem.Id, mediaItem.LibraryPathId);
internal static MediaItemSearchResultViewModel ProjectToSearchViewModel(MediaItem mediaItem) =>
mediaItem switch
{
Episode e => ProjectToSearchViewModel(e),
Movie m => ProjectToSearchViewModel(m),
_ => throw new ArgumentOutOfRangeException()
};
private static MediaItemSearchResultViewModel ProjectToSearchViewModel(Episode mediaItem) =>
new(
mediaItem.Id,
GetLibraryName(mediaItem),
"TV Show",
GetDisplayTitle(mediaItem),
GetDisplayDuration(mediaItem));
private static MediaItemSearchResultViewModel ProjectToSearchViewModel(Movie mediaItem) =>
new(
mediaItem.Id,
GetLibraryName(mediaItem),
"Movie",
GetDisplayTitle(mediaItem),
GetDisplayDuration(mediaItem));
private static string GetDisplayTitle(MediaItem mediaItem) =>
mediaItem switch
{
Episode e => e.EpisodeMetadata.HeadOrNone()
.Map(em => $"{em.Title} - s{e.Season.SeasonNumber:00}e{e.EpisodeNumber:00}")
.IfNone("[unknown episode]"),
Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Title).IfNone("[unknown movie]"),
_ => string.Empty
};
private static string GetDisplayDuration(MediaItem mediaItem)
{
MediaVersion version = mediaItem switch
{
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
};
return string.Format(
version.Duration.TotalHours >= 1 ? @"{0:h\:mm\:ss}" : @"{0:mm\:ss}",
version.Duration);
}
// TODO: fix this when search is reimplemented
private static string GetLibraryName(MediaItem item) =>
"Library Name";
public static NamedMediaItemViewModel ProjectToViewModel(Show show) =>
new(show.Id, show.ShowMetadata.HeadOrNone().Map(sm => $"{sm?.Title} ({sm?.Year})").IfNone("???"));
@@ -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.NeutralCultures);
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;
+23 -2
View File
@@ -1,5 +1,9 @@
using System.Linq;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using ErsatzTV.Core.Domain;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.Movies
@@ -17,7 +21,24 @@ namespace ErsatzTV.Application.Movies
Artwork(metadata, ArtworkKind.FanArt),
metadata.Genres.Map(g => g.Name).ToList(),
metadata.Tags.Map(t => t.Name).ToList(),
metadata.Studios.Map(s => s.Name).ToList());
metadata.Studios.Map(s => s.Name).ToList(),
LanguagesForMovie(movie));
}
private static List<CultureInfo> LanguagesForMovie(Movie movie)
{
CultureInfo[] allCultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures);
return movie.MediaVersions
.Map(mv => mv.Streams.Filter(s => s.MediaStreamKind == MediaStreamKind.Audio).Map(s => s.Language))
.Flatten()
.Distinct()
.Map(
lang => allCultures.Filter(
ci => string.Equals(ci.ThreeLetterISOLanguageName, lang, StringComparison.OrdinalIgnoreCase)))
.Sequence()
.Flatten()
.ToList();
}
private static string Artwork(Metadata metadata, ArtworkKind artworkKind) =>
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Globalization;
namespace ErsatzTV.Application.Movies
{
@@ -10,5 +11,6 @@ namespace ErsatzTV.Application.Movies
string FanArt,
List<string> Genres,
List<string> Tags,
List<string> Studios);
List<string> Studios,
List<CultureInfo> Languages);
}
@@ -48,7 +48,7 @@ namespace ErsatzTV.Application.Playouts.Commands
}
private async Task<Validation<BaseError, Playout>> Validate(CreatePlayout request) =>
(await ChannelMustExist(request), await ProgramScheduleMustExist(request), ValidatePlayoutType(request))
(await ValidateChannel(request), await ProgramScheduleMustExist(request), ValidatePlayoutType(request))
.Apply(
(channel, programSchedule, playoutType) => new Playout
{
@@ -57,10 +57,19 @@ namespace ErsatzTV.Application.Playouts.Commands
ProgramSchedulePlayoutType = playoutType
});
private Task<Validation<BaseError, Channel>> ValidateChannel(CreatePlayout createPlayout) =>
ChannelMustExist(createPlayout).BindT(ChannelMustNotHavePlayouts);
private async Task<Validation<BaseError, Channel>> ChannelMustExist(CreatePlayout createPlayout) =>
(await _channelRepository.Get(createPlayout.ChannelId))
.ToValidation<BaseError>("Channel does not exist.");
private async Task<Validation<BaseError, Channel>> ChannelMustNotHavePlayouts(Channel channel) =>
Optional(await _channelRepository.CountPlayouts(channel.Id))
.Filter(count => count == 0)
.Map(_ => channel)
.ToValidation<BaseError>("Channel already has one playout.");
private async Task<Validation<BaseError, ProgramSchedule>> ProgramScheduleMustExist(
CreatePlayout createPlayout) =>
(await _programScheduleRepository.GetWithPlayouts(createPlayout.ProgramScheduleId))
+22 -8
View File
@@ -24,15 +24,28 @@ 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:
string artistName = mv.Artist.ArtistMetadata.HeadOrNone()
.Map(am => $"{am.Title} - ").IfNone(string.Empty);
return mv.MusicVideoMetadata.HeadOrNone()
.Map(mvm => $"{artistName}{mvm.Title}")
.IfNone("[unknown music video]");
default:
return string.Empty;
}
}
private static string GetDisplayDuration(MediaItem mediaItem)
{
@@ -40,6 +53,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
{
@@ -10,6 +10,7 @@ using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Plex.Commands
{
@@ -19,6 +20,7 @@ namespace ErsatzTV.Application.Plex.Commands
{
private readonly ChannelWriter<IPlexBackgroundServiceRequest> _channel;
private readonly IEntityLocker _entityLocker;
private readonly ILogger<SynchronizePlexMediaSourcesHandler> _logger;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IPlexTvApiClient _plexTvApiClient;
@@ -26,12 +28,14 @@ namespace ErsatzTV.Application.Plex.Commands
IMediaSourceRepository mediaSourceRepository,
IPlexTvApiClient plexTvApiClient,
ChannelWriter<IPlexBackgroundServiceRequest> channel,
IEntityLocker entityLocker)
IEntityLocker entityLocker,
ILogger<SynchronizePlexMediaSourcesHandler> logger)
{
_mediaSourceRepository = mediaSourceRepository;
_plexTvApiClient = plexTvApiClient;
_channel = channel;
_entityLocker = entityLocker;
_logger = logger;
}
public Task<Either<BaseError, List<PlexMediaSource>>> Handle(
@@ -47,6 +51,14 @@ namespace ErsatzTV.Application.Plex.Commands
await SynchronizeServer(allExisting, server);
}
// delete removed servers
foreach (PlexMediaSource removed in allExisting.Filter(
s => servers.All(pms => pms.ClientIdentifier != s.ClientIdentifier)))
{
_logger.LogWarning("Deleting removed Plex server {ServerName}!", removed.Id.ToString());
await _mediaSourceRepository.DeletePlex(removed);
}
foreach (PlexMediaSource mediaSource in await _mediaSourceRepository.GetAllPlex())
{
await _channel.WriteAsync(new SynchronizePlexLibraries(mediaSource.Id));
@@ -57,11 +69,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;
@@ -72,7 +84,7 @@ namespace ErsatzTV.Application.Plex.Commands
.Filter(connection => existing.Connections.All(c => c.Uri != connection.Uri)).ToList();
var toRemove = existing.Connections
.Filter(connection => server.Connections.All(c => c.Uri != connection.Uri)).ToList();
return _mediaSourceRepository.Update(existing, toAdd, toRemove);
return _mediaSourceRepository.Update(existing, server.Connections, toAdd, toRemove);
},
async () =>
{
@@ -84,15 +96,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));
}
}
}
@@ -4,6 +4,7 @@ using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using LanguageExt;
@@ -14,6 +15,7 @@ namespace ErsatzTV.Application.Search.Commands
public class RebuildSearchIndexHandler : MediatR.IRequestHandler<RebuildSearchIndex, Unit>
{
private readonly IConfigElementRepository _configElementRepository;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<RebuildSearchIndexHandler> _logger;
private readonly ISearchIndex _searchIndex;
private readonly ISearchRepository _searchRepository;
@@ -22,18 +24,22 @@ namespace ErsatzTV.Application.Search.Commands
ISearchIndex searchIndex,
ISearchRepository searchRepository,
IConfigElementRepository configElementRepository,
ILocalFileSystem localFileSystem,
ILogger<RebuildSearchIndexHandler> logger)
{
_searchIndex = searchIndex;
_logger = logger;
_searchRepository = searchRepository;
_configElementRepository = configElementRepository;
_localFileSystem = localFileSystem;
}
public async Task<Unit> Handle(RebuildSearchIndex request, CancellationToken cancellationToken)
{
bool indexFolderExists = Directory.Exists(FileSystemLayout.SearchIndexFolder);
await _searchIndex.Initialize(_localFileSystem);
if (!indexFolderExists ||
await _configElementRepository.GetValue<int>(ConfigElementKey.SearchIndexVersion) <
_searchIndex.Version)
@@ -41,7 +47,7 @@ namespace ErsatzTV.Application.Search.Commands
_logger.LogDebug("Migrating search index to version {Version}", _searchIndex.Version);
List<int> itemIds = await _searchRepository.GetItemIdsToIndex();
await _searchIndex.Rebuild(itemIds);
await _searchIndex.Rebuild(_searchRepository, itemIds);
Option<ConfigElement> maybeVersion =
await _configElementRepository.Get(ConfigElementKey.SearchIndexVersion);
@@ -1,7 +0,0 @@
using ErsatzTV.Core.Search;
using MediatR;
namespace ErsatzTV.Application.Search.Queries
{
public record QuerySearchIndex(string Query) : IRequest<SearchResult>;
}
@@ -0,0 +1,6 @@
using MediatR;
namespace ErsatzTV.Application.Search.Queries
{
public record QuerySearchIndexAllItems(string Query) : IRequest<SearchResultAllItemsViewModel>;
}
@@ -0,0 +1,34 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Search;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Search.Queries
{
public class
QuerySearchIndexAllItemsHandler : IRequestHandler<QuerySearchIndexAllItems, SearchResultAllItemsViewModel>
{
private readonly ISearchIndex _searchIndex;
public QuerySearchIndexAllItemsHandler(ISearchIndex searchIndex) => _searchIndex = searchIndex;
public async Task<SearchResultAllItemsViewModel> Handle(
QuerySearchIndexAllItems request,
CancellationToken cancellationToken)
{
List<int> movieIds = await _searchIndex.Search($"type:movie AND ({request.Query})", 0, 0)
.Map(result => result.Items.Map(i => i.Id).ToList());
List<int> showIds = await _searchIndex.Search($"type:show AND ({request.Query})", 0, 0)
.Map(result => result.Items.Map(i => i.Id).ToList());
List<int> artistIds = await _searchIndex.Search($"type:artist AND ({request.Query})", 0, 0)
.Map(result => result.Items.Map(i => i.Id).ToList());
List<int> musicVideoIds = await _searchIndex.Search($"type:music_video AND ({request.Query})", 0, 0)
.Map(result => result.Items.Map(i => i.Id).ToList());
return new SearchResultAllItemsViewModel(movieIds, showIds, artistIds, musicVideoIds);
}
}
}
@@ -0,0 +1,8 @@
using ErsatzTV.Application.MediaCards;
using MediatR;
namespace ErsatzTV.Application.Search.Queries
{
public record QuerySearchIndexArtists
(string Query, int PageNumber, int PageSize) : IRequest<ArtistCardResultsViewModel>;
}
@@ -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
QuerySearchIndexArtistsHandler : IRequestHandler<QuerySearchIndexArtists, ArtistCardResultsViewModel
>
{
private readonly IArtistRepository _artistRepository;
private readonly ISearchIndex _searchIndex;
public QuerySearchIndexArtistsHandler(ISearchIndex searchIndex, IArtistRepository artistRepository)
{
_searchIndex = searchIndex;
_artistRepository = artistRepository;
}
public async Task<ArtistCardResultsViewModel> Handle(
QuerySearchIndexArtists request,
CancellationToken cancellationToken)
{
SearchResult searchResult = await _searchIndex.Search(
request.Query,
(request.PageNumber - 1) * request.PageSize,
request.PageSize);
List<ArtistCardViewModel> items = await _artistRepository
.GetArtistsForCards(searchResult.Items.Map(i => i.Id).ToList())
.Map(list => list.Map(ProjectToViewModel).ToList());
return new ArtistCardResultsViewModel(searchResult.TotalCount, items, searchResult.PageMap);
}
}
}
@@ -1,18 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
using MediatR;
namespace ErsatzTV.Application.Search.Queries
{
public class QuerySearchIndexHandler : IRequestHandler<QuerySearchIndex, SearchResult>
{
private readonly ISearchIndex _searchIndex;
public QuerySearchIndexHandler(ISearchIndex searchIndex) => _searchIndex = searchIndex;
public Task<SearchResult> Handle(QuerySearchIndex request, CancellationToken cancellationToken) =>
_searchIndex.Search(request.Query, 0, 100);
}
}
@@ -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);
}
}
}
@@ -0,0 +1,10 @@
using System.Collections.Generic;
namespace ErsatzTV.Application.Search
{
public record SearchResultAllItemsViewModel(
List<int> MovieIds,
List<int> ShowIds,
List<int> ArtistIds,
List<int> MusicVideoIds);
}
@@ -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))
};
+21 -3
View File
@@ -1,13 +1,16 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using ErsatzTV.Core.Domain;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.Television
{
internal static class Mapper
{
internal static TelevisionShowViewModel ProjectToViewModel(Show show) =>
internal static TelevisionShowViewModel ProjectToViewModel(Show show, List<string> languages) =>
new(
show.Id,
show.ShowMetadata.HeadOrNone().Map(m => m.Title ?? string.Empty).IfNone(string.Empty),
@@ -18,7 +21,8 @@ namespace ErsatzTV.Application.Television
show.ShowMetadata.HeadOrNone().Map(m => m.Genres.Map(g => g.Name).ToList()).IfNone(new List<string>()),
show.ShowMetadata.HeadOrNone().Map(m => m.Tags.Map(g => g.Name).ToList()).IfNone(new List<string>()),
show.ShowMetadata.HeadOrNone().Map(m => m.Studios.Map(s => s.Name).ToList())
.IfNone(new List<string>()));
.IfNone(new List<string>()),
LanguagesForShow(languages));
internal static TelevisionSeasonViewModel ProjectToViewModel(Season season) =>
new(
@@ -48,5 +52,19 @@ namespace ErsatzTV.Application.Television
private static string GetArtwork(Metadata metadata, ArtworkKind artworkKind) =>
Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind))
.Match(a => a.Path, string.Empty);
private static List<CultureInfo> LanguagesForShow(List<string> languages)
{
CultureInfo[] allCultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures);
return languages
.Distinct()
.Map(
lang => allCultures.Filter(
ci => string.Equals(ci.ThreeLetterISOLanguageName, lang, StringComparison.OrdinalIgnoreCase)))
.Sequence()
.Flatten()
.ToList();
}
}
}
@@ -1,5 +1,7 @@
using System.Threading;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
@@ -9,15 +11,29 @@ namespace ErsatzTV.Application.Television.Queries
{
public class GetTelevisionShowByIdHandler : IRequestHandler<GetTelevisionShowById, Option<TelevisionShowViewModel>>
{
private readonly ISearchRepository _searchRepository;
private readonly ITelevisionRepository _televisionRepository;
public GetTelevisionShowByIdHandler(ITelevisionRepository televisionRepository) =>
public GetTelevisionShowByIdHandler(
ITelevisionRepository televisionRepository,
ISearchRepository searchRepository)
{
_televisionRepository = televisionRepository;
_searchRepository = searchRepository;
}
public Task<Option<TelevisionShowViewModel>> Handle(
public async Task<Option<TelevisionShowViewModel>> Handle(
GetTelevisionShowById request,
CancellationToken cancellationToken) =>
_televisionRepository.GetShow(request.Id)
.MapT(ProjectToViewModel);
CancellationToken cancellationToken)
{
Option<Show> maybeShow = await _televisionRepository.GetShow(request.Id);
return await maybeShow.Match<Task<Option<TelevisionShowViewModel>>>(
async show =>
{
List<string> languages = await _searchRepository.GetLanguagesForShow(show);
return ProjectToViewModel(show, languages);
},
() => Task.FromResult(Option<TelevisionShowViewModel>.None));
}
}
}
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Globalization;
namespace ErsatzTV.Application.Television
{
@@ -11,5 +12,6 @@ namespace ErsatzTV.Application.Television
string FanArt,
List<string> Genres,
List<string> Tags,
List<string> Studios);
List<string> Studios,
List<CultureInfo> Languages);
}
@@ -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" />
@@ -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]
@@ -26,6 +26,11 @@ namespace ErsatzTV.Core.Tests.Fakes
throw new NotSupportedException();
public Task<List<Collection>> GetAll() => throw new NotSupportedException();
public Task<int> CountAllCollections() => throw new NotSupportedException();
public Task<List<Collection>> GetPagedCollections(int pageNumber, int pageSize) =>
throw new NotSupportedException();
public Task<Option<List<MediaItem>>> GetItems(int id) => Some(_data[id].ToList()).AsTask();
Task<bool> IMediaCollectionRepository.Update(Collection collection) => throw new NotSupportedException();
public Task Delete(int collectionId) => throw new NotSupportedException();
@@ -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,8 @@ namespace ErsatzTV.Core.Tests.Metadata
new Mock<IMetadataRepository>().Object,
_imageCache.Object,
new Mock<ISearchIndex>().Object,
new Mock<ISearchRepository>().Object,
new Mock<IMediator>().Object,
new Mock<ILogger<MovieFolderScanner>>().Object
);
}
+1
View File
@@ -13,5 +13,6 @@
public static ConfigElementKey FFmpegSaveReports => new("ffmpeg.save_reports");
public static ConfigElementKey FFmpegPreferredLanguageCode => new("ffmpeg.preferred_language_code");
public static ConfigElementKey SearchIndexVersion => new("search_index.version");
public static ConfigElementKey HDHRTunerCount => new("hdhr.tuner_count");
}
}
+7 -9
View File
@@ -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
}
}
+3 -1
View File
@@ -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; }
+10
View File
@@ -0,0 +1,10 @@
using System.Collections.Generic;
namespace ErsatzTV.Core.Domain
{
public class Artist : MediaItem
{
public List<MusicVideo> MusicVideos { get; set; }
public List<ArtistMetadata> ArtistMetadata { 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,12 @@
using System.Collections.Generic;
namespace ErsatzTV.Core.Domain
{
public class MusicVideo : MediaItem
{
public int ArtistId { get; set; }
public Artist Artist { get; set; }
public List<MusicVideoMetadata> MusicVideoMetadata { get; set; }
public List<MediaVersion> MediaVersions { get; set; }
}
}
@@ -0,0 +1,15 @@
using System.Collections.Generic;
namespace ErsatzTV.Core.Domain
{
public class ArtistMetadata : Metadata
{
public string Disambiguation { get; set; }
public string Biography { get; set; }
public string Formed { get; set; }
public int ArtistId { get; set; }
public Artist Artist { get; set; }
public List<Style> Styles { get; set; }
public List<Mood> Moods { get; set; }
}
}
+8
View File
@@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Domain
{
public class Mood
{
public int Id { get; set; }
public string Name { get; set; }
}
}
@@ -0,0 +1,10 @@
namespace ErsatzTV.Core.Domain
{
public class MusicVideoMetadata : Metadata
{
public string Album { get; set; }
public string Plot { get; set; }
public int MusicVideoId { get; set; }
public MusicVideo MusicVideo { get; set; }
}
}
+8
View File
@@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Domain
{
public class Style
{
public int Id { get; set; }
public string Name { get; set; }
}
}
+10
View File
@@ -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,18 +50,30 @@ 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 Option<FFmpegComplexFilter> Build(int videoStreamIndex, int audioStreamIndex)
public FFmpegComplexFilterBuilder WithFrameRate(Option<string> frameRate)
{
_frameRate = frameRate;
return this;
}
public Option<FFmpegComplexFilter> Build(int videoStreamIndex, Option<int> audioStreamIndex)
{
var complexFilter = new StringBuilder();
var videoLabel = $"0:{videoStreamIndex}";
var audioLabel = $"0:{audioStreamIndex}";
string audioLabel = audioStreamIndex.Match(index => $"0:{index}", () => "0:a");
HardwareAccelerationKind acceleration = _hardwareAccelerationKind.IfNone(HardwareAccelerationKind.None);
bool isHardwareDecode = acceleration switch
@@ -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; }
}
}
@@ -22,6 +22,7 @@ using System;
using System.Collections.Generic;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.FFmpeg
@@ -46,7 +47,7 @@ namespace ErsatzTV.Core.FFmpeg
FFmpegProfile ffmpegProfile,
MediaVersion version,
MediaStream videoStream,
MediaStream audioStream,
Option<MediaStream> audioStream,
DateTimeOffset start,
DateTimeOffset now)
{
@@ -81,11 +82,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 +108,24 @@ 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)
audioStream.IfSome(
stream =>
{
result.AudioChannels = ffmpegProfile.AudioChannels;
}
if (stream.Channels != ffmpegProfile.AudioChannels)
{
result.AudioChannels = ffmpegProfile.AudioChannels;
}
});
result.AudioSampleRate = ffmpegProfile.AudioSampleRate;
result.AudioDuration = version.Duration;
}
result.AudioSampleRate = ffmpegProfile.AudioSampleRate;
result.AudioDuration = version.Duration;
result.NormalizeLoudness = ffmpegProfile.NormalizeLoudness;
}
else
{
@@ -141,7 +153,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 +171,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)
{
+39 -8
View File
@@ -42,6 +42,7 @@ namespace ErsatzTV.Core.FFmpeg
private readonly string _ffmpegPath;
private readonly bool _saveReports;
private FFmpegComplexFilterBuilder _complexFilterBuilder = new();
private bool _isConcat;
public FFmpegProcessBuilder(string ffmpegPath, bool saveReports)
{
@@ -186,6 +187,8 @@ namespace ErsatzTV.Core.FFmpeg
public FFmpegProcessBuilder WithConcat(string concatPlaylist)
{
_isConcat = true;
var arguments = new List<string>
{
"-f", "concat",
@@ -193,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,18 +325,44 @@ 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);
return this;
}
public FFmpegProcessBuilder WithFilterComplex(int videoStreamIndex, int audioStreamIndex)
public FFmpegProcessBuilder WithFilterComplex(MediaStream videoStream, Option<MediaStream> maybeAudioStream)
{
var videoLabel = $"0:{videoStreamIndex}";
var audioLabel = $"0:{audioStreamIndex}";
int videoStreamIndex = videoStream.Index;
Option<int> maybeIndex = maybeAudioStream.Map(ms => ms.Index);
Option<FFmpegComplexFilter> maybeFilter = _complexFilterBuilder.Build(videoStreamIndex, audioStreamIndex);
var videoLabel = $"0:{videoStreamIndex}";
var audioLabel = $"0:{maybeIndex.Match(i => i.ToString(), () => "a")}";
Option<FFmpegComplexFilter> maybeFilter = _complexFilterBuilder.Build(videoStreamIndex, maybeIndex);
maybeFilter.IfSome(
filter =>
{
@@ -373,10 +401,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);
+13 -12
View File
@@ -30,14 +30,14 @@ namespace ErsatzTV.Core.FFmpeg
DateTimeOffset now)
{
MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(channel, version);
MediaStream audioStream = await _ffmpegStreamSelector.SelectAudioStream(channel, version);
Option<MediaStream> maybeAudioStream = await _ffmpegStreamSelector.SelectAudioStream(channel, version);
FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.CalculateSettings(
channel.StreamingMode,
channel.FFmpegProfile,
version,
videoStream,
audioStream,
maybeAudioStream,
start,
now);
@@ -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,8 +67,7 @@ namespace ErsatzTV.Core.FFmpeg
}
builder = builder
.WithAlignedAudio(playbackSettings.AudioDuration)
.WithFilterComplex(videoStream.Index, audioStream.Index);
.WithFilterComplex(videoStream, maybeAudioStream);
},
() =>
{
@@ -73,20 +76,18 @@ namespace ErsatzTV.Core.FFmpeg
builder = builder
.WithDeinterlace(playbackSettings.Deinterlace)
.WithBlackBars(channel.FFmpegProfile.Resolution)
.WithAlignedAudio(playbackSettings.AudioDuration)
.WithFilterComplex(videoStream.Index, audioStream.Index);
.WithFilterComplex(videoStream, maybeAudioStream);
}
else if (playbackSettings.Deinterlace)
{
builder = builder.WithDeinterlace(playbackSettings.Deinterlace)
.WithAlignedAudio(playbackSettings.AudioDuration)
.WithFilterComplex(videoStream.Index, audioStream.Index);
.WithFilterComplex(videoStream, maybeAudioStream);
}
else
{
builder = builder
.WithAlignedAudio(playbackSettings.AudioDuration)
.WithFilterComplex(videoStream.Index, audioStream.Index);
.WithFilterComplex(videoStream, maybeAudioStream);
}
});
@@ -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)
+11 -1
View File
@@ -6,6 +6,7 @@ using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.FFmpeg
{
@@ -25,8 +26,17 @@ namespace ErsatzTV.Core.FFmpeg
public Task<MediaStream> SelectVideoStream(Channel channel, MediaVersion version) =>
version.Streams.First(s => s.MediaStreamKind == MediaStreamKind.Video).AsTask();
public async Task<MediaStream> SelectAudioStream(Channel channel, MediaVersion version)
public async Task<Option<MediaStream>> SelectAudioStream(Channel channel, MediaVersion version)
{
if (channel.StreamingMode == StreamingMode.HttpLiveStreaming &&
string.IsNullOrWhiteSpace(channel.PreferredLanguageCode))
{
_logger.LogDebug(
"Channel {Number} is HLS with no preferred language; using all audio streams",
channel.Number);
return None;
}
var audioStreams = version.Streams.Filter(s => s.MediaStreamKind == MediaStreamKind.Audio).ToList();
string language = (channel.PreferredLanguageCode ?? string.Empty).ToLowerInvariant();
@@ -1,11 +1,12 @@
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.FFmpeg
{
public interface IFFmpegStreamSelector
{
Task<MediaStream> SelectVideoStream(Channel channel, MediaVersion version);
Task<MediaStream> SelectAudioStream(Channel channel, MediaVersion version);
Task<Option<MediaStream>> SelectAudioStream(Channel channel, MediaVersion version);
}
}
@@ -0,0 +1,11 @@
using System.Threading.Tasks;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.GitHub
{
public interface IGitHubApiClient
{
Task<Either<BaseError, string>> GetLatestReleaseNotes();
Task<Either<BaseError, string>> GetReleaseNotes(string tag);
}
}
@@ -1,13 +1,16 @@
using System;
using ErsatzTV.Core.Domain;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Metadata
{
public interface IFallbackMetadataProvider
{
ShowMetadata GetFallbackMetadataForShow(string showFolder);
ArtistMetadata GetFallbackMetadataForArtist(string artistFolder);
Tuple<EpisodeMetadata, int> GetFallbackMetadata(Episode episode);
MovieMetadata GetFallbackMetadata(Movie movie);
Option<MusicVideoMetadata> GetFallbackMetadata(MusicVideo musicVideo);
string GetSortTitle(string title);
}
}
@@ -6,9 +6,16 @@ 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<ArtistMetadata> GetMetadataForArtist(string artistFolder);
Task<bool> RefreshSidecarMetadata(Movie movie, string nfoFileName);
Task<bool> RefreshSidecarMetadata(Show televisionShow, string nfoFileName);
Task<bool> RefreshSidecarMetadata(Episode episode, string nfoFileName);
Task<bool> RefreshSidecarMetadata(Artist artist, string nfoFileName);
Task<bool> RefreshSidecarMetadata(MusicVideo musicVideo, string nfoFileName);
Task<bool> RefreshFallbackMetadata(Movie movie);
Task<bool> RefreshFallbackMetadata(Episode episode);
Task<bool> RefreshFallbackMetadata(Artist artist, string artistFolder);
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);
}
}

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