Compare commits

..
1 Commits
Author SHA1 Message Date
Jason Dove f230adc3cb update changelog for release v0.3.1-alpha [no ci] 2021-11-30 10:51:00 -06:00
167 changed files with 1551 additions and 33612 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout master
uses: actions/checkout@v2
uses: actions/checkout@v1
- name: Deploy docs
uses: mhausenblas/mkdocs-deploy-gh-pages@master
+4 -1
View File
@@ -47,11 +47,14 @@ jobs:
release_name="ErsatzTV-$tag-${{ matrix.target }}"
# Build everything
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net6.0 --runtime "${{ matrix.target }}" -c Release -o "$release_name" /property:InformationalVersion="${tag:1}-${{ matrix.target }}" /property:EnableCompressionInSingleFile=true /property:DebugType=Embedded /property:PublishSingleFile=true --self-contained true
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net6.0 --runtime "${{ matrix.target }}" -c Release -o "$release_name" /property:InformationalVersion="${tag:1}-${{ matrix.target }}" /property:EnableCompressionInSingleFile=true /property:PublishSingleFile=true --self-contained true
# Pack files
if [ "${{ matrix.target }}" == "win-x64" ]; then
7z a -tzip "${release_name}.zip" "./${release_name}/*"
elif [ "${{ matrix.target }}" == "linux-arm" ]; then
cp lib/linux-arm/* "$release_name/"
tar czvf "${release_name}.tar.gz" "$release_name"
else
tar czvf "${release_name}.tar.gz" "$release_name"
fi
+1 -78
View File
@@ -4,78 +4,6 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
### Fixed
- Fix local folder scanners to properly detect removed/re-added folders with unchanged contents
- Fix double-click startup on mac
- Fix trakt list sync when show does not contain a year
- Properly unlock libraries when a scan is unable to be performed because ffmpeg or ffprobe have not been found
### Added
- Add trash system for local libraries to maintain collection and schedule integrity through media share outages
- When items are missing from disk, they will be flagged and present in the `Media` > `Trash` page
- The trash page can be used to permanently remove missing items from the database
- When items reappear at the expected location on disk, they will be unflagged and removed from the trash
- Add basic Mac hardware acceleration using VideoToolbox
### Changed
- Local libraries only: when items are missing from disk, they will be added to the trash and no longer removed from collections, etc.
- Show song thumbnail in song list
## [0.3.6-alpha] - 2022-01-10
### Fixed
- Properly index `minutes` field when adding new items during scan (vs when rebuilding index)
- Fix some nvenc edge cases where only padding is needed for normalization
- Properly overwrite environment variables for ffmpeg processes (`LIBVA_DRIVER_NAME`, `FFREPORT`)
### Added
- Add music video `artist` to search index
- This requires rebuilding the search index and search results may be empty or incomplete until the rebuild is complete
### Changed
- Remove `HLS Hybrid` streaming mode; all channels have been reconfigured to use the superior `HLS Segmenter` streaming mode
- Update `MPEG-TS` streaming mode to internally use the HLS segmenter
- This improves compatibility with many clients and also improves performance at program boundaries
- Renamed existing `MPEG-TS` mode as `MPEG-TS (Legacy)`
- This mode will be removed in a future release
## [0.3.5-alpha] - 2022-01-05
### Fixed
- Fix bundled ffmpeg version in base docker image (NOT nvidia or vaapi) which prevented playback since v0.3.0-alpha
- Use software decoding for mpeg4 content when VAAPI acceleration is enabled
- Fix hardware acceleration health check to recognize QSV on non-Windows platforms
### Changed
- Treat `setsar` as a hardware filter, avoiding unneeded `hwdownload` and `hwupload` steps when padding isn't required
## [0.3.4-alpha] - 2021-12-21
### Fixed
- Fix other video and song scanners to include videos contained directly in top-level folders that are added to a library
- Allow saving ffmpeg troubleshooting reports on Windows
## [0.3.3-alpha] - 2021-12-12
### Fixed
- Fix bug with saving multiple blurhash versions for cover art; all cover art will be automatically rescanned
- Fix song detail margin when no cover art exists and no watermark exists
- Fix synchronizing virtual shows and seasons from Jellyfin
- Properly sort channels in M3U
### Changed
- Use blurhash of ErsatzTV colors instead of solid colors for default song backgrounds
- Use select control instead of autocomplete control in many places
- The autocomplete control is not intuitive to use and has focus bugs
## [0.3.2-alpha] - 2021-12-03
### Fixed
- Fix artwork upload on Windows
- Fix unicode song metadata on Windows
- Fix unicode console output on Windows
- Fix TV Show NFO metadata processing when `year` is missing
- Fix song detail outline to help legibility on white backgrounds
- Optimize song artwork scanning to prevent re-processing album artwork for each song
### Changed
- Use custom log database backend which should be more portable (i.e. work in osx-arm64)
- Use cover art blurhashes for song backgrounds instead of solid colors or box blur
## [0.3.1-alpha] - 2021-11-30
### Fixed
@@ -909,12 +837,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Initial release to facilitate testing outside of Docker.
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.3.6-alpha...HEAD
[0.3.5-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.3.5-alpha...v0.3.6-alpha
[0.3.5-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.3.4-alpha...v0.3.5-alpha
[0.3.4-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.3.3-alpha...v0.3.4-alpha
[0.3.3-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.3.2-alpha...v0.3.3-alpha
[0.3.2-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.3.1-alpha...v0.3.2-alpha
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.3.1-alpha...HEAD
[0.3.1-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.3.0-alpha...v0.3.1-alpha
[0.3.0-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.2.5-alpha...v0.3.0-alpha
[0.2.5-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.2.4-alpha...v0.2.5-alpha
@@ -11,6 +11,5 @@ namespace ErsatzTV.Application.Channels
string PreferredLanguageCode,
StreamingMode StreamingMode,
int? WatermarkId,
int? FallbackFillerId,
int PlayoutCount);
int? FallbackFillerId);
}
+1 -2
View File
@@ -16,8 +16,7 @@ namespace ErsatzTV.Application.Channels
channel.PreferredLanguageCode,
channel.StreamingMode,
channel.WatermarkId,
channel.FallbackFillerId,
channel.Playouts?.Count ?? 0);
channel.FallbackFillerId);
private static string GetLogo(Channel channel) =>
Optional(channel.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Logo))
@@ -3,6 +3,7 @@
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<NoWarn>VSTHRD200</NoWarn>
<DebugType>embedded</DebugType>
</PropertyGroup>
<ItemGroup>
@@ -1,11 +1,13 @@
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
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.Runtime;
using LanguageExt;
namespace ErsatzTV.Application.FFmpegProfiles.Commands
@@ -14,13 +16,16 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
{
private readonly IConfigElementRepository _configElementRepository;
private readonly ILocalFileSystem _localFileSystem;
private readonly IRuntimeInfo _runtimeInfo;
public UpdateFFmpegSettingsHandler(
IConfigElementRepository configElementRepository,
ILocalFileSystem localFileSystem)
ILocalFileSystem localFileSystem,
IRuntimeInfo runtimeInfo)
{
_configElementRepository = configElementRepository;
_localFileSystem = localFileSystem;
_runtimeInfo = runtimeInfo;
}
public Task<Either<BaseError, Unit>> Handle(
@@ -31,8 +36,8 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
.Bind(v => v.ToEitherAsync());
private async Task<Validation<BaseError, Unit>> Validate(UpdateFFmpegSettings request) =>
(await FFmpegMustExist(request), await FFprobeMustExist(request))
.Apply((_, _) => Unit.Default);
(await FFmpegMustExist(request), await FFprobeMustExist(request), ReportsAreNotSupportedOnWindows(request))
.Apply((_, _, _) => Unit.Default);
private Task<Validation<BaseError, Unit>> FFmpegMustExist(UpdateFFmpegSettings request) =>
ValidateToolPath(request.Settings.FFmpegPath, "ffmpeg");
@@ -40,6 +45,16 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
private Task<Validation<BaseError, Unit>> FFprobeMustExist(UpdateFFmpegSettings request) =>
ValidateToolPath(request.Settings.FFprobePath, "ffprobe");
private Validation<BaseError, Unit> ReportsAreNotSupportedOnWindows(UpdateFFmpegSettings request)
{
if (request.Settings.SaveReports && _runtimeInfo.IsOSPlatform(OSPlatform.Windows))
{
return BaseError.New("FFmpeg reports are not supported on Windows");
}
return Unit.Default;
}
private async Task<Validation<BaseError, Unit>> ValidateToolPath(string path, string name)
{
if (!_localFileSystem.FileExists(path))
@@ -1,9 +0,0 @@
using System.Collections.Generic;
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Application.Maintenance.Commands;
public record DeleteItemsFromDatabase(List<int> MediaItemIds) : IRequest<Either<BaseError, Unit>>;
@@ -1,38 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using LanguageExt;
namespace ErsatzTV.Application.Maintenance.Commands
{
public class
DeleteItemsFromDatabaseHandler : MediatR.IRequestHandler<DeleteItemsFromDatabase, Either<BaseError, Unit>>
{
private readonly IMediaItemRepository _mediaItemRepository;
private readonly ISearchIndex _searchIndex;
public DeleteItemsFromDatabaseHandler(
IMediaItemRepository mediaItemRepository,
ISearchIndex searchIndex)
{
_mediaItemRepository = mediaItemRepository;
_searchIndex = searchIndex;
}
public async Task<Either<BaseError, Unit>> Handle(
DeleteItemsFromDatabase request,
CancellationToken cancellationToken)
{
Either<BaseError, Unit> deleteResult = await _mediaItemRepository.DeleteItems(request.MediaItemIds);
if (deleteResult.IsRight)
{
await _searchIndex.RemoveItems(request.MediaItemIds);
_searchIndex.Commit();
}
return deleteResult;
}
}
}
@@ -1,7 +1,5 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
namespace ErsatzTV.Application.MediaCards
{
public record ActorCardViewModel(int Id, string Name, string Role, string Thumb, MediaItemState State) :
MediaCardViewModel(Id, Name, Role, Name, Thumb, State);
public record ActorCardViewModel(int Id, string Name, string Role, string Thumb) :
MediaCardViewModel(Id, Name, Role, Name, Thumb);
}
@@ -1,19 +1,10 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
namespace ErsatzTV.Application.MediaCards
{
public record ArtistCardViewModel
(
int ArtistId,
string Title,
string Subtitle,
string SortTitle,
string Poster,
MediaItemState State) : MediaCardViewModel(
ArtistId,
Title,
Subtitle,
SortTitle,
Poster,
State);
(int ArtistId, string Title, string Subtitle, string SortTitle, string Poster) : MediaCardViewModel(
ArtistId,
Title,
Subtitle,
SortTitle,
Poster);
}
+10 -23
View File
@@ -2,7 +2,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Jellyfin;
using LanguageExt;
using static LanguageExt.Prelude;
@@ -20,8 +19,7 @@ namespace ErsatzTV.Application.MediaCards
showMetadata.Title,
showMetadata.Year?.ToString(),
showMetadata.SortTitle,
GetPoster(showMetadata, maybeJellyfin, maybeEmby),
showMetadata.Show.State);
GetPoster(showMetadata, maybeJellyfin, maybeEmby));
internal static TelevisionSeasonCardViewModel ProjectToViewModel(
Season season,
@@ -36,8 +34,7 @@ namespace ErsatzTV.Application.MediaCards
GetSeasonName(season.SeasonNumber),
season.SeasonMetadata.HeadOrNone().Map(sm => GetPoster(sm, maybeJellyfin, maybeEmby))
.IfNone(string.Empty),
season.SeasonNumber == 0 ? "S" : season.SeasonNumber.ToString(),
season.State);
season.SeasonNumber == 0 ? "S" : season.SeasonNumber.ToString());
internal static TelevisionSeasonCardViewModel ProjectToViewModel(
SeasonMetadata seasonMetadata,
@@ -56,8 +53,7 @@ namespace ErsatzTV.Application.MediaCards
GetSeasonName(seasonMetadata.Season.SeasonNumber),
$"{showTitle}_{seasonMetadata.Season.SeasonNumber:0000}",
GetPoster(seasonMetadata, maybeJellyfin, maybeEmby),
seasonMetadata.Season.SeasonNumber == 0 ? "S" : seasonMetadata.Season.SeasonNumber.ToString(),
seasonMetadata.Season.State);
seasonMetadata.Season.SeasonNumber == 0 ? "S" : seasonMetadata.Season.SeasonNumber.ToString());
}
internal static TelevisionEpisodeCardViewModel ProjectToViewModel(
@@ -84,9 +80,7 @@ namespace ErsatzTV.Application.MediaCards
? GetEpisodePoster(episodeMetadata, maybeJellyfin, maybeEmby)
: GetThumbnail(episodeMetadata, maybeJellyfin, maybeEmby),
episodeMetadata.Directors.Map(d => d.Name).ToList(),
episodeMetadata.Writers.Map(w => w.Name).ToList(),
episodeMetadata.Episode.State,
episodeMetadata.Episode.GetHeadVersion().MediaFiles.Head().Path);
episodeMetadata.Writers.Map(w => w.Name).ToList());
internal static MovieCardViewModel ProjectToViewModel(
MovieMetadata movieMetadata,
@@ -97,8 +91,7 @@ namespace ErsatzTV.Application.MediaCards
movieMetadata.Title,
movieMetadata.Year?.ToString(),
movieMetadata.SortTitle,
GetPoster(movieMetadata, maybeJellyfin, maybeEmby),
movieMetadata.Movie.State);
GetPoster(movieMetadata, maybeJellyfin, maybeEmby));
internal static MusicVideoCardViewModel ProjectToViewModel(MusicVideoMetadata musicVideoMetadata) =>
new(
@@ -108,17 +101,14 @@ namespace ErsatzTV.Application.MediaCards
musicVideoMetadata.SortTitle,
musicVideoMetadata.Plot,
musicVideoMetadata.Album,
GetThumbnail(musicVideoMetadata, None, None),
musicVideoMetadata.MusicVideo.State,
musicVideoMetadata.MusicVideo.GetHeadVersion().MediaFiles.Head().Path);
GetThumbnail(musicVideoMetadata, None, None));
internal static OtherVideoCardViewModel ProjectToViewModel(OtherVideoMetadata otherVideoMetadata) =>
new(
otherVideoMetadata.OtherVideoId,
otherVideoMetadata.Title,
otherVideoMetadata.OriginalTitle,
otherVideoMetadata.SortTitle,
otherVideoMetadata.OtherVideo.State);
otherVideoMetadata.SortTitle);
internal static SongCardViewModel ProjectToViewModel(SongMetadata songMetadata)
{
@@ -127,9 +117,7 @@ namespace ErsatzTV.Application.MediaCards
songMetadata.SongId,
songMetadata.Title,
songMetadata.Artist + album,
songMetadata.SortTitle,
GetThumbnail(songMetadata, None, None),
songMetadata.Song.State);
songMetadata.SortTitle);
}
internal static ArtistCardViewModel ProjectToViewModel(ArtistMetadata artistMetadata) =>
@@ -138,8 +126,7 @@ namespace ErsatzTV.Application.MediaCards
artistMetadata.Title,
artistMetadata.Disambiguation,
artistMetadata.SortTitle,
GetThumbnail(artistMetadata, None, None),
artistMetadata.Artist.State);
GetThumbnail(artistMetadata, None, None));
internal static CollectionCardResultsViewModel
ProjectToViewModel(
@@ -187,7 +174,7 @@ namespace ErsatzTV.Application.MediaCards
.SetQueryParam("maxHeight", 440);
}
return new ActorCardViewModel(actor.Id, actor.Name, actor.Role, artwork, MediaItemState.Normal);
return new ActorCardViewModel(actor.Id, actor.Name, actor.Role, artwork);
}
private static int GetCustomIndex(Collection collection, int mediaItemId) =>
@@ -1,12 +1,4 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
namespace ErsatzTV.Application.MediaCards
{
public record MediaCardViewModel(
int MediaItemId,
string Title,
string Subtitle,
string SortTitle,
string Poster,
MediaItemState State);
public record MediaCardViewModel(int MediaItemId, string Title, string Subtitle, string SortTitle, string Poster);
}
@@ -1,21 +1,12 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
namespace ErsatzTV.Application.MediaCards
{
public record MovieCardViewModel
(
int MovieId,
string Title,
string Subtitle,
string SortTitle,
string Poster,
MediaItemState State) : MediaCardViewModel(
MovieId,
Title,
Subtitle,
SortTitle,
Poster,
State)
(int MovieId, string Title, string Subtitle, string SortTitle, string Poster) : MediaCardViewModel(
MovieId,
Title,
Subtitle,
SortTitle,
Poster)
{
public int CustomIndex { get; set; }
}
@@ -1,6 +1,4 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
namespace ErsatzTV.Application.MediaCards
{
public record MusicVideoCardViewModel
(
@@ -10,15 +8,12 @@ namespace ErsatzTV.Application.MediaCards
string SortTitle,
string Plot,
string Album,
string Poster,
MediaItemState State,
string Path) : MediaCardViewModel(
string Poster) : MediaCardViewModel(
MusicVideoId,
Title,
Subtitle,
SortTitle,
Poster,
State)
Poster)
{
public int CustomIndex { get; set; }
}
@@ -1,20 +1,16 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
namespace ErsatzTV.Application.MediaCards
{
public record OtherVideoCardViewModel
(
int OtherVideoId,
string Title,
string Subtitle,
string SortTitle,
MediaItemState State) : MediaCardViewModel(
string SortTitle) : MediaCardViewModel(
OtherVideoId,
Title,
Subtitle,
SortTitle,
null,
State)
null)
{
public int CustomIndex { get; set; }
}
@@ -1,21 +1,16 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
namespace ErsatzTV.Application.MediaCards
{
public record SongCardViewModel
(
int SongId,
string Title,
string Subtitle,
string SortTitle,
string Poster,
MediaItemState State) : MediaCardViewModel(
string SortTitle) : MediaCardViewModel(
SongId,
Title,
Subtitle,
SortTitle,
Poster,
State)
null)
{
public int CustomIndex { get; set; }
}
@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
{
@@ -18,13 +17,10 @@ namespace ErsatzTV.Application.MediaCards
string Plot,
string Poster,
List<string> Directors,
List<string> Writers,
MediaItemState State,
string Path) : MediaCardViewModel(
List<string> Writers) : MediaCardViewModel(
EpisodeId,
Title,
$"Episode {Episode}",
SortTitle,
Poster,
State);
Poster);
}
@@ -1,6 +1,4 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
namespace ErsatzTV.Application.MediaCards
{
public record TelevisionSeasonCardViewModel
(
@@ -11,12 +9,10 @@ namespace ErsatzTV.Application.MediaCards
string Subtitle,
string SortTitle,
string Poster,
string Placeholder,
MediaItemState State) : MediaCardViewModel(
string Placeholder) : MediaCardViewModel(
TelevisionSeasonId,
Title,
Subtitle,
SortTitle,
Poster,
State);
Poster);
}
@@ -1,19 +1,10 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
namespace ErsatzTV.Application.MediaCards
{
public record TelevisionShowCardViewModel
(
int TelevisionShowId,
string Title,
string Subtitle,
string SortTitle,
string Poster,
MediaItemState State) : MediaCardViewModel(
TelevisionShowId,
Title,
Subtitle,
SortTitle,
Poster,
State);
(int TelevisionShowId, string Title, string Subtitle, string SortTitle, string Poster) : MediaCardViewModel(
TelevisionShowId,
Title,
Subtitle,
SortTitle,
Poster);
}
@@ -7,7 +7,7 @@ namespace ErsatzTV.Application.MediaCollections
internal static class Mapper
{
internal static MediaCollectionViewModel ProjectToViewModel(Collection collection) =>
new(collection.Id, collection.Name, collection.UseCustomPlaybackOrder, MediaItemState.Normal);
new(collection.Id, collection.Name, collection.UseCustomPlaybackOrder);
internal static MultiCollectionViewModel ProjectToViewModel(MultiCollection multiCollection) =>
new(
@@ -1,17 +1,11 @@
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCollections
{
public record MediaCollectionViewModel(
int Id,
string Name,
bool UseCustomPlaybackOrder,
MediaItemState State) : MediaCardViewModel(
public record MediaCollectionViewModel(int Id, string Name, bool UseCustomPlaybackOrder) : MediaCardViewModel(
Id,
Name,
string.Empty,
Name,
string.Empty,
State);
string.Empty);
}
@@ -160,36 +160,15 @@ namespace ErsatzTV.Application.MediaSources.Commands
return Unit.Default;
}
private async Task<Validation<BaseError, RequestParameters>> Validate(IScanLocalLibrary request)
{
Validation<BaseError, LocalLibrary> libraryResult = await LocalLibraryMustExist(request);
Validation<BaseError, string> ffprobePathResult = await ValidateFFprobePath();
Validation<BaseError, string> ffmpegPathResult = await ValidateFFmpegPath();
Validation<BaseError, int> refreshIntervalResult = await ValidateLibraryRefreshInterval();
try
{
return (libraryResult, ffprobePathResult, ffmpegPathResult, refreshIntervalResult)
.Apply(
(library, ffprobePath, ffmpegPath, libraryRefreshInterval) => new RequestParameters(
library,
ffprobePath,
ffmpegPath,
request.ForceScan,
libraryRefreshInterval));
}
finally
{
// ensure we unlock the library if any validation is unsuccessful
foreach (LocalLibrary library in libraryResult.SuccessToSeq())
{
if (ffprobePathResult.IsFail || ffmpegPathResult.IsFail || refreshIntervalResult.IsFail)
{
_entityLocker.UnlockLibrary(library.Id);
}
}
}
}
private async Task<Validation<BaseError, RequestParameters>> Validate(IScanLocalLibrary request) =>
(await LocalLibraryMustExist(request), await ValidateFFprobePath(), await ValidateFFmpegPath(), await ValidateLibraryRefreshInterval())
.Apply(
(library, ffprobePath, ffmpegPath, libraryRefreshInterval) => new RequestParameters(
library,
ffprobePath,
ffmpegPath,
request.ForceScan,
libraryRefreshInterval));
private Task<Validation<BaseError, LocalLibrary>> LocalLibraryMustExist(
IScanLocalLibrary request) =>
+1 -4
View File
@@ -4,7 +4,6 @@ using System.Globalization;
using System.Linq;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Jellyfin;
using Flurl;
using LanguageExt;
@@ -35,9 +34,7 @@ namespace ErsatzTV.Application.Movies
.Map(a => MediaCards.Mapper.ProjectToViewModel(a, maybeJellyfin, maybeEmby))
.ToList(),
metadata.Directors.Map(d => d.Name).ToList(),
metadata.Writers.Map(w => w.Name).ToList(),
movie.GetHeadVersion().MediaFiles.Head().Path,
movie.State)
metadata.Writers.Map(w => w.Name).ToList())
{
Poster = Artwork(metadata, ArtworkKind.Poster, maybeJellyfin, maybeEmby),
FanArt = Artwork(metadata, ArtworkKind.FanArt, maybeJellyfin, maybeEmby)
@@ -1,7 +1,6 @@
using System.Collections.Generic;
using System.Globalization;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Movies
{
@@ -16,9 +15,7 @@ namespace ErsatzTV.Application.Movies
List<CultureInfo> Languages,
List<ActorCardViewModel> Actors,
List<string> Directors,
List<string> Writers,
string Path,
MediaItemState MediaItemState)
List<string> Writers)
{
public string Poster { get; set; }
public string FanArt { get; set; }
@@ -33,7 +33,7 @@ namespace ErsatzTV.Application.Movies.Queries
GetMovieById request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Option<JellyfinMediaSource> maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin()
.Map(list => list.HeadOrNone());
@@ -23,7 +23,7 @@ namespace ErsatzTV.Application.Streaming.Queries
public async Task<Either<BaseError, PlayoutItemProcessModel>> Handle(T request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, Tuple<Channel, string>> validation = await Validate(dbContext, request);
return await validation.Match(
tuple => GetProcess(dbContext, request, tuple.Item1, tuple.Item2),
@@ -56,8 +56,7 @@ namespace ErsatzTV.Application.Streaming.Queries
{
"hls-direct" => StreamingMode.HttpLiveStreamingDirect,
"segmenter" => StreamingMode.HttpLiveStreamingSegmenter,
"ts" => StreamingMode.TransportStreamHybrid,
"ts-legacy" => StreamingMode.TransportStream,
"ts" => StreamingMode.TransportStream,
_ => channel.StreamingMode
};
@@ -6,7 +6,7 @@ namespace ErsatzTV.Application.Streaming.Queries
{
public GetConcatProcessByChannelNumber(string scheme, string host, string channelNumber) : base(
channelNumber,
"ts-legacy",
"ts",
DateTimeOffset.Now,
false,
true)
@@ -1,9 +1,11 @@
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Runtime;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt;
@@ -14,13 +16,16 @@ namespace ErsatzTV.Application.Streaming.Queries
public class GetConcatProcessByChannelNumberHandler : FFmpegProcessHandler<GetConcatProcessByChannelNumber>
{
private readonly IFFmpegProcessService _ffmpegProcessService;
private readonly IRuntimeInfo _runtimeInfo;
public GetConcatProcessByChannelNumberHandler(
IDbContextFactory<TvContext> dbContextFactory,
IFFmpegProcessService ffmpegProcessService)
IFFmpegProcessService ffmpegProcessService,
IRuntimeInfo runtimeInfo)
: base(dbContextFactory)
{
_ffmpegProcessService = ffmpegProcessService;
_runtimeInfo = runtimeInfo;
}
protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess(
@@ -29,7 +34,7 @@ namespace ErsatzTV.Application.Streaming.Queries
Channel channel,
string ffmpegPath)
{
bool saveReports = await dbContext.ConfigElements
bool saveReports = !_runtimeInfo.IsOSPlatform(OSPlatform.Windows) && await dbContext.ConfigElements
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
.Map(result => result.IfNone(false));
@@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
@@ -14,6 +15,7 @@ using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Runtime;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@@ -34,6 +36,7 @@ namespace ErsatzTV.Application.Streaming.Queries
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
private readonly ILocalFileSystem _localFileSystem;
private readonly IPlexPathReplacementService _plexPathReplacementService;
private readonly IRuntimeInfo _runtimeInfo;
private readonly ISongVideoGenerator _songVideoGenerator;
public GetPlayoutItemProcessByChannelNumberHandler(
@@ -46,6 +49,7 @@ namespace ErsatzTV.Application.Streaming.Queries
IMediaCollectionRepository mediaCollectionRepository,
ITelevisionRepository televisionRepository,
IArtistRepository artistRepository,
IRuntimeInfo runtimeInfo,
ISongVideoGenerator songVideoGenerator)
: base(dbContextFactory)
{
@@ -57,6 +61,7 @@ namespace ErsatzTV.Application.Streaming.Queries
_mediaCollectionRepository = mediaCollectionRepository;
_televisionRepository = televisionRepository;
_artistRepository = artistRepository;
_runtimeInfo = runtimeInfo;
_songVideoGenerator = songVideoGenerator;
}
@@ -137,7 +142,7 @@ namespace ErsatzTV.Application.Streaming.Queries
ffmpegPath);
}
bool saveReports = await dbContext.ConfigElements
bool saveReports = !_runtimeInfo.IsOSPlatform(OSPlatform.Windows) && await dbContext.ConfigElements
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
.Map(result => result.IfNone(false));
@@ -1,21 +0,0 @@
using System;
namespace ErsatzTV.Application.Streaming.Queries
{
public record GetWrappedProcessByChannelNumber : FFmpegProcessRequest
{
public GetWrappedProcessByChannelNumber(string scheme, string host, string channelNumber) : base(
channelNumber,
"ts",
DateTimeOffset.Now,
false,
true)
{
Scheme = scheme;
Host = host;
}
public string Scheme { get; }
public string Host { get; }
}
}
@@ -1,46 +0,0 @@
using System;
using System.Diagnostics;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Streaming.Queries
{
public class GetWrappedProcessByChannelNumberHandler : FFmpegProcessHandler<GetWrappedProcessByChannelNumber>
{
private readonly IFFmpegProcessService _ffmpegProcessService;
public GetWrappedProcessByChannelNumberHandler(
IDbContextFactory<TvContext> dbContextFactory,
IFFmpegProcessService ffmpegProcessService)
: base(dbContextFactory)
{
_ffmpegProcessService = ffmpegProcessService;
}
protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess(
TvContext dbContext,
GetWrappedProcessByChannelNumber request,
Channel channel,
string ffmpegPath)
{
bool saveReports = await dbContext.ConfigElements
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
.Map(result => result.IfNone(false));
Process process = _ffmpegProcessService.WrapSegmenter(
ffmpegPath,
saveReports,
channel,
request.Scheme,
request.Host);
return new PlayoutItemProcessModel(process, DateTimeOffset.MaxValue);
}
}
}
@@ -0,0 +1,106 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
using ErsatzTV.Api.Sdk.Api;
using ErsatzTV.Api.Sdk.Model;
using LanguageExt;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
namespace ErsatzTV.CommandLine.Commands
{
[Command("channel", Description = "Create or rename a channel")]
public class ChannelCommand : ICommand
{
private readonly ChannelsApi _channelsApi;
private readonly FFmpegProfileApi _ffmpegProfileApi;
private readonly ILogger<ChannelCommand> _logger;
public ChannelCommand(IConfiguration configuration, ILogger<ChannelCommand> logger)
{
_logger = logger;
_channelsApi = new ChannelsApi(configuration["ServerUrl"]);
_ffmpegProfileApi = new FFmpegProfileApi(configuration["ServerUrl"]);
}
[CommandParameter(0, Name = "channel-number", Description = "The channel number")]
public int Number { get; set; }
[CommandParameter(1, Name = "channel-name", Description = "The channel name")]
public string Name { get; set; }
[CommandParameter(2, Name = "streaming-mode", Description = "The streaming mode")]
public StreamingMode StreamingMode { get; set; }
[CommandOption("ffmpeg-profile", Description = "The ffmpeg profile name")]
public string FFmpegProfileName { get; set; }
public async ValueTask ExecuteAsync(IConsole console)
{
try
{
Option<ChannelViewModel> maybeChannel = await _channelsApi.ApiChannelsGetAsync()
.Map(list => Optional(list.SingleOrDefault(c => c.Number == Number)));
FFmpegProfileViewModel ffmpegProfile = await _ffmpegProfileApi.ApiFfmpegProfilesGetAsync()
.Map(
list => Optional(list.SingleOrDefault(p => p.Name == FFmpegProfileName))
.IfNone(new FFmpegProfileViewModel { Id = 1 }));
await maybeChannel.Match(
channel => RenameChannel(channel, ffmpegProfile),
() => AddChannel(ffmpegProfile));
}
catch (Exception ex)
{
_logger.LogError("Unable to synchronize channel: {Message}", ex.Message);
}
}
private async ValueTask RenameChannel(ChannelViewModel existing, FFmpegProfileViewModel ffmpegProfile)
{
int newFFmpegProfileId = string.IsNullOrWhiteSpace(FFmpegProfileName)
? existing.FfmpegProfileId
: ffmpegProfile.Id;
if (existing.Name != Name || existing.FfmpegProfileId != newFFmpegProfileId ||
existing.StreamingMode != StreamingMode)
{
var updateChannel = new UpdateChannel(
existing.Id,
Name,
existing.Number,
newFFmpegProfileId,
existing.Logo,
StreamingMode);
await _channelsApi.ApiChannelsPatchAsync(updateChannel);
}
_logger.LogInformation(
"Successfully synchronized channel {ChannelNumber} - {ChannelName}",
Number,
Name);
}
private async ValueTask AddChannel(FFmpegProfileViewModel ffmpegProfile)
{
var createChannel = new CreateChannel(
Name,
Number,
ffmpegProfile.Id,
null,
StreamingMode);
await _channelsApi.ApiChannelsPostAsync(createChannel);
_logger.LogInformation(
"Successfully created channel {ChannelNumber} - {ChannelName}",
Number,
Name);
}
}
}
@@ -0,0 +1,31 @@
using System;
using System.IO;
using System.Text.Json;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
namespace ErsatzTV.CommandLine.Commands
{
[Command("config", Description = "Configure ErsatzTV server url")]
public class ConfigCommand : ICommand
{
[CommandParameter(0, Name = "server-url", Description = "The url of the ErsatzTV server")]
public string ServerUrl { get; set; }
public async ValueTask ExecuteAsync(IConsole console)
{
// TODO: validate URL
string configFolder = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"ersatztv");
string configFile = Path.Combine(configFolder, "cli.json");
var config = new Config { ServerUrl = ServerUrl };
string contents = JsonSerializer.Serialize(config);
await File.WriteAllTextAsync(configFile, contents);
}
}
}
@@ -0,0 +1,151 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
using ErsatzTV.Api.Sdk.Api;
using ErsatzTV.Api.Sdk.Model;
using LanguageExt;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
namespace ErsatzTV.CommandLine.Commands
{
[Command("ffmpeg-profile", Description = "Synchronize an ffmpeg profile")]
public class FFmpegProfileCommand : ICommand
{
private readonly FFmpegProfileApi _ffmpegProfileApi;
private readonly ILogger<FFmpegProfileCommand> _logger;
public FFmpegProfileCommand(IConfiguration configuration, ILogger<FFmpegProfileCommand> logger)
{
_logger = logger;
_ffmpegProfileApi = new FFmpegProfileApi(configuration["ServerUrl"]);
}
[CommandParameter(0, Name = "profile-name", Description = "The ffmpeg profile name")]
public string Name { get; set; }
[CommandOption("thread-count", Description = "The number of threads")]
public int ThreadCount { get; set; } = 0;
[CommandOption("transcode", Description = "Whether to transcode all media")]
public bool Transcode { get; set; } = true;
// public int ResolutionId { get; set; } = resolution.Id;
// Resolution { get; set; } = resolution;
[CommandOption("resolution", Description = "The resolution")]
public DesiredResolution Resolution { get; set; } = DesiredResolution.W1920H1080;
[CommandOption("video-codec", Description = "The video codec")]
public string VideoCodec { get; set; } = "libx264";
[CommandOption("audio-codec", Description = "The audio codec")]
public string AudioCodec { get; set; } = "ac3";
[CommandOption("video-bitrate", Description = "The video bitrate in kBit/s")]
public int VideoBitrate { get; set; } = 2000;
[CommandOption("video-buffer-size", Description = "The video buffer size in kBit")]
public int VideoBufferSize { get; set; } = 2000;
[CommandOption("audio-bitrate", Description = "The audio bitrate in kBit/s")]
public int AudioBitrate { get; set; } = 192;
[CommandOption("audio-buffer-size", Description = "The audio buffer size in kBits")]
public int AudioBufferSize { get; set; } = 50;
[CommandOption("audio-volume", Description = "The audio volume as a whole number percent")]
public int AudioVolume { get; set; } = 100;
[CommandOption("audio-channels", Description = "The number of audio channels")]
public int AudioChannels { get; set; } = 2;
[CommandOption("audio-sample-rate", Description = "The audio sample rate in kHz")]
public int AudioSampleRate { get; set; } = 48;
[CommandOption("normalize-resolution", Description = "Whether to normalize the resolution of all media")]
public bool NormalizeResolution { get; set; } = true;
[CommandOption("normalize-video-codec", Description = "Whether to normalize the video codec of all media")]
public bool NormalizeVideoCodec { get; set; } = true;
[CommandOption("normalize-audio-codec", Description = "Whether to normalize the audio codec of all media")]
public bool NormalizeAudioCodec { get; set; } = true;
[CommandOption(
"normalize-audio",
Description = "Whether to normalize audio channels and sample rate of all media")]
public bool NormalizeAudio { get; set; } = true;
public async ValueTask ExecuteAsync(IConsole console)
{
try
{
Option<FFmpegProfileViewModel> maybeFFmpegProfile = await _ffmpegProfileApi.ApiFfmpegProfilesGetAsync()
.Map(list => Optional(list.SingleOrDefault(p => p.Name == Name)));
await maybeFFmpegProfile.Match(UpdateProfile, AddProfile);
}
catch (Exception ex)
{
_logger.LogError("Unable to synchronize ffmpeg profile: {Message}", ex.Message);
}
}
private async ValueTask UpdateProfile(FFmpegProfileViewModel existing)
{
var updateFFmpegProfile = new UpdateFFmpegProfile(
existing.Id,
Name,
ThreadCount,
Transcode,
(int) Resolution,
NormalizeResolution,
VideoCodec,
NormalizeVideoCodec,
VideoBitrate,
VideoBufferSize,
AudioCodec,
NormalizeAudioCodec,
AudioBitrate,
AudioBufferSize,
AudioVolume,
AudioChannels,
AudioSampleRate,
NormalizeAudio);
await _ffmpegProfileApi.ApiFfmpegProfilesPatchAsync(updateFFmpegProfile);
_logger.LogInformation("Successfully synchronized ffmpeg profile {ProfileName}", Name);
}
private async ValueTask AddProfile()
{
var createFFmpegProfile = new CreateFFmpegProfile(
Name,
ThreadCount,
Transcode,
(int) Resolution,
NormalizeResolution,
VideoCodec,
NormalizeVideoCodec,
VideoBitrate,
VideoBufferSize,
AudioCodec,
NormalizeAudioCodec,
AudioBitrate,
AudioBufferSize,
AudioVolume,
AudioChannels,
AudioSampleRate,
NormalizeAudio);
await _ffmpegProfileApi.ApiFfmpegProfilesPostAsync(createFFmpegProfile);
_logger.LogInformation("Successfully created ffmpeg profile {ProfileName}", Name);
}
}
}
@@ -0,0 +1,86 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
using ErsatzTV.Api.Sdk.Api;
using ErsatzTV.Api.Sdk.Model;
using LanguageExt;
using LanguageExt.Common;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
namespace ErsatzTV.CommandLine.Commands.MediaCollections
{
[Command("collection clear", Description = "Removes all items from a media collection")]
public class MediaCollectionClearCommand : ICommand
{
private readonly ILogger<MediaCollectionClearCommand> _logger;
private readonly string _serverUrl;
public MediaCollectionClearCommand(IConfiguration configuration, ILogger<MediaCollectionClearCommand> logger)
{
_logger = logger;
_serverUrl = configuration["ServerUrl"];
}
[CommandParameter(0, Name = "collection-name", Description = "The name of the media collection")]
public string Name { get; set; }
public async ValueTask ExecuteAsync(IConsole console)
{
try
{
CancellationToken cancellationToken = console.GetCancellationToken();
Either<Error, Unit> result = await ClearMediaCollection(cancellationToken);
result.Match(
_ => _logger.LogInformation("Successfully cleared media collection {MediaCollection}", Name),
error => _logger.LogError(
"Unable to clear media collection: {Error}",
error.Message));
}
catch (Exception ex)
{
_logger.LogError("Unable to clear media collection: {Error}", ex.Message);
}
}
private async Task<Either<Error, Unit>> ClearMediaCollection(CancellationToken cancellationToken) =>
await EnsureMediaCollectionExists(cancellationToken)
.BindAsync(mediaCollectionId => ClearMediaCollectionImpl(mediaCollectionId, cancellationToken));
private async Task<Either<Error, int>> EnsureMediaCollectionExists(CancellationToken cancellationToken)
{
var mediaCollectionsApi = new MediaCollectionsApi(_serverUrl);
Option<MediaCollectionViewModel> maybeExisting =
(await mediaCollectionsApi.ApiMediaCollectionsGetAsync(cancellationToken))
.SingleOrDefault(mc => mc.Name == Name);
return await maybeExisting.MatchAsync(
existing => existing.Id,
async () =>
{
var data = new CreateSimpleMediaCollection(Name);
MediaCollectionViewModel result =
await mediaCollectionsApi.ApiMediaCollectionsPostAsync(data, cancellationToken);
return result.Id;
});
}
private async Task<Either<Error, Unit>> ClearMediaCollectionImpl(
int mediaCollectionId,
CancellationToken cancellationToken)
{
var mediaCollectionsApi = new MediaCollectionsApi(_serverUrl);
await mediaCollectionsApi.ApiMediaCollectionsIdItemsPutAsync(
mediaCollectionId,
new List<int>(),
cancellationToken);
return unit;
}
}
}
@@ -0,0 +1,72 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
using ErsatzTV.Api.Sdk.Api;
using ErsatzTV.Api.Sdk.Model;
using LanguageExt;
using LanguageExt.Common;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
namespace ErsatzTV.CommandLine.Commands.MediaCollections
{
[Command("collection create", Description = "Creates a new media collection")]
public class MediaCollectionCreateCommand : ICommand
{
private readonly ILogger<MediaCollectionCreateCommand> _logger;
private readonly string _serverUrl;
public MediaCollectionCreateCommand(IConfiguration configuration, ILogger<MediaCollectionCreateCommand> logger)
{
_logger = logger;
_serverUrl = configuration["ServerUrl"];
}
[CommandParameter(0, Name = "collection-name", Description = "The name of the media collection")]
public string Name { get; set; }
public async ValueTask ExecuteAsync(IConsole console)
{
try
{
CancellationToken cancellationToken = console.GetCancellationToken();
Either<Error, Unit> result = await CreateMediaCollection(cancellationToken);
result.IfLeft(error => _logger.LogError("Unable to create media collection: {Error}", error.Message));
}
catch (Exception ex)
{
_logger.LogError("Unable to create media collection: {Error}", ex.Message);
}
}
private async Task<Either<Error, Unit>> CreateMediaCollection(CancellationToken cancellationToken) =>
await EnsureMediaCollectionExists(cancellationToken);
private async Task<Either<Error, Unit>> EnsureMediaCollectionExists(CancellationToken cancellationToken)
{
var mediaCollectionsApi = new MediaCollectionsApi(_serverUrl);
bool needToAdd = await mediaCollectionsApi
.ApiMediaCollectionsGetAsync(cancellationToken)
.Map(list => list.All(mc => mc.Name != Name));
if (needToAdd)
{
var data = new CreateSimpleMediaCollection(Name);
await mediaCollectionsApi.ApiMediaCollectionsPostAsync(data, cancellationToken);
_logger.LogInformation("Successfully created media collection {MediaCollection}", Name);
}
else
{
_logger.LogInformation("Media collection {MediaCollection} is already present", Name);
}
return unit;
}
}
}
@@ -0,0 +1,107 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
using ErsatzTV.Api.Sdk.Api;
using ErsatzTV.Api.Sdk.Model;
using LanguageExt;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.CommandLine.Commands
{
[Command("playout build", Description = "Builds a playout with the requested channel and schedule")]
public class PlayoutCommand : ICommand
{
private readonly ILogger<PlayoutCommand> _logger;
private readonly string _serverUrl;
public PlayoutCommand(IConfiguration configuration, ILogger<PlayoutCommand> logger)
{
_logger = logger;
_serverUrl = configuration["ServerUrl"];
}
[CommandParameter(0, Name = "channel-number", Description = "The channel number")]
public int ChannelNumber { get; set; }
[CommandParameter(1, Name = "schedule-name", Description = "The schedule name")]
public string ScheduleName { get; set; }
// [Option("--type <type>")]
// [Required]
// public ProgramSchedulePlayoutType PlayoutType { get; set; }
public async ValueTask ExecuteAsync(IConsole console)
{
try
{
CancellationToken cancellationToken = console.GetCancellationToken();
var channelsApi = new ChannelsApi(_serverUrl);
Option<ChannelViewModel> maybeChannel = await channelsApi.ApiChannelsGetAsync(cancellationToken)
.Map(list => list.SingleOrDefault(c => c.Number == ChannelNumber));
await maybeChannel.Match(
channel => BuildPlayout(cancellationToken, channel),
() =>
{
_logger.LogError("Unable to locate channel number {ChannelNumber}", ChannelNumber);
return ValueTask.CompletedTask;
});
}
catch (Exception ex)
{
_logger.LogError("Unable to build playout: {Error}", ex.Message);
}
}
private async ValueTask BuildPlayout(CancellationToken cancellationToken, ChannelViewModel channel)
{
var programScheduleApi = new ProgramScheduleApi(_serverUrl);
Option<ProgramScheduleViewModel> maybeSchedule = await programScheduleApi
.ApiSchedulesGetAsync(cancellationToken)
.Map(list => list.SingleOrDefault(s => s.Name == ScheduleName));
await maybeSchedule.Match(
schedule => SynchronizePlayoutAsync(channel.Id, schedule.Id, cancellationToken),
() =>
{
_logger.LogError("Unable to locate schedule {Schedule}", ScheduleName);
return ValueTask.CompletedTask;
});
}
private async ValueTask SynchronizePlayoutAsync(
int channelId,
int scheduleId,
CancellationToken cancellationToken)
{
var playoutApi = new PlayoutApi(_serverUrl);
Option<PlayoutViewModel> maybeExisting = await playoutApi.ApiPlayoutsGetAsync(cancellationToken)
.Map(list => list.SingleOrDefault(p => p.Channel.Id == channelId));
await maybeExisting.Match(
existing =>
{
var data = new UpdatePlayout(existing.Id, channelId, scheduleId, ProgramSchedulePlayoutType.Flood);
if (existing.Channel.Id != data.ChannelId ||
existing.ProgramSchedule.Id != data.ProgramScheduleId ||
existing.ProgramSchedulePlayoutType != data.ProgramSchedulePlayoutType)
{
return playoutApi.ApiPlayoutsPatchAsync(data, cancellationToken);
}
return Task.CompletedTask;
},
() =>
{
var data = new CreatePlayout(channelId, scheduleId, ProgramSchedulePlayoutType.Flood);
return playoutApi.ApiPlayoutsPostAsync(data, cancellationToken);
});
_logger.LogInformation("Successfully built playout for schedule {Schedule}", ScheduleName);
}
}
}
@@ -0,0 +1,140 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
using ErsatzTV.Api.Sdk.Api;
using ErsatzTV.Api.Sdk.Model;
using LanguageExt;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.CommandLine.Commands.Schedules
{
[Command("schedule add-item", Description = "Adds an item to the end of a schedule")]
public class ScheduleAddItemCommand : ICommand
{
private readonly ILogger<ScheduleAddItemCommand> _logger;
private readonly string _serverUrl;
public ScheduleAddItemCommand(IConfiguration configuration, ILogger<ScheduleAddItemCommand> logger)
{
_logger = logger;
_serverUrl = configuration["ServerUrl"];
}
[CommandParameter(0, Name = "schedule-name", Description = "The schedule name")]
public string ScheduleName { get; set; }
[CommandParameter(1, Name = "collection-name", Description = "The media collection name")]
public string CollectionName { get; set; }
// [CommandParameter(2, Description = "The collection playback order")]
// public PlaybackOrder Order { get; set; }
[CommandOption("start-type", 's', Description = "The playout start type")]
public StartType StartType { get; set; } = StartType.Dynamic;
[CommandOption("start-time", 't', Description = "The playout start time (of day)")]
public string StartTime { get; set; } = null;
[CommandOption("playout-mode", 'm', Description = "The playout mode")]
public PlayoutMode PlayoutMode { get; set; } = PlayoutMode.Flood;
[CommandOption(
"multiple-count",
'c',
Description = "How many items to play from the collection (for Multiple playout mode)")]
public int? MultipleCount { get; set; } = null;
[CommandOption(
"playout-duration",
'd',
Description = "How long to play items from the collection (for Duration playout mode)")]
public string PlayoutDuration { get; set; } = null;
[CommandOption(
"offline-tail",
'o',
Description =
"Whether to remain offline for the entire duration, or to start the next item immediately (for Duration playout mode)")]
public bool? OfflineTail { get; set; } = null;
public async ValueTask ExecuteAsync(IConsole console)
{
try
{
CancellationToken cancellationToken = console.GetCancellationToken();
Option<ProgramScheduleViewModel> maybeSchedule = await GetSchedule(cancellationToken);
await maybeSchedule.Match(
programSchedule => AddItemToSchedule(cancellationToken, programSchedule),
() =>
{
_logger.LogError("Unable to locate schedule {Schedule}", ScheduleName);
return ValueTask.CompletedTask;
});
}
catch (Exception ex)
{
_logger.LogError("Unable to add item to schedule: {Error}", ex.Message);
}
}
private async ValueTask AddItemToSchedule(
CancellationToken cancellationToken,
ProgramScheduleViewModel programSchedule)
{
var mediaCollectionsApi = new MediaCollectionsApi(_serverUrl);
Option<MediaCollectionViewModel> maybeMediaCollection = await mediaCollectionsApi
.ApiMediaCollectionsGetAsync(cancellationToken)
.Map(list => list.SingleOrDefault(mc => mc.Name == CollectionName));
await maybeMediaCollection.Match(
collection =>
AddScheduleItem(programSchedule.Id, collection.Id, cancellationToken),
() =>
{
_logger.LogError(
"Unable to locate collection {Collection}",
CollectionName);
return Task.CompletedTask;
});
}
private async Task<Option<ProgramScheduleViewModel>> GetSchedule(CancellationToken cancellationToken)
{
var programScheduleApi = new ProgramScheduleApi(_serverUrl);
return await programScheduleApi.ApiSchedulesGetAsync(cancellationToken)
.Map(list => list.SingleOrDefault(schedule => schedule.Name == ScheduleName));
}
private async Task AddScheduleItem(
int programScheduleId,
int mediaCollectionId,
CancellationToken cancellationToken)
{
var programScheduleApi = new ProgramScheduleApi(_serverUrl);
var request = new AddProgramScheduleItem
{
ProgramScheduleId = programScheduleId,
StartType = StartType,
StartTime = StartTime,
PlayoutMode = PlayoutMode,
MediaCollectionId = mediaCollectionId,
PlayoutDuration = PlayoutDuration,
MultipleCount = MultipleCount,
OfflineTail = OfflineTail
};
await programScheduleApi.ApiSchedulesItemsAddPostAsync(request, cancellationToken);
_logger.LogInformation(
"Collection {Collection} has been added to schedule {Schedule}",
CollectionName,
ScheduleName);
}
}
}
@@ -0,0 +1,86 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
using ErsatzTV.Api.Sdk.Api;
using ErsatzTV.Api.Sdk.Model;
using LanguageExt;
using LanguageExt.Common;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
namespace ErsatzTV.CommandLine.Commands.Schedules
{
[Command("schedule create", Description = "Creates a new schedule")]
public class ScheduleCreateCommand : ICommand
{
private readonly ILogger<ScheduleCreateCommand> _logger;
private readonly string _serverUrl;
public ScheduleCreateCommand(IConfiguration configuration, ILogger<ScheduleCreateCommand> logger)
{
_logger = logger;
_serverUrl = configuration["ServerUrl"];
}
[CommandParameter(0, Name = "schedule-name", Description = "The schedule name")]
public string Name { get; set; }
[CommandParameter(1, Name = "playback-order", Description = "The collection playback order")]
public PlaybackOrder Order { get; set; }
[CommandOption("reset", Description = "Resets the schedule to contain no items")]
public bool Reset { get; set; }
public async ValueTask ExecuteAsync(IConsole console)
{
try
{
CancellationToken cancellationToken = console.GetCancellationToken();
Either<Error, Unit> result = await EnsureScheduleExistsAsync(cancellationToken);
result.IfLeft(error => _logger.LogError("Unable to create schedule: {Error}", error.Message));
}
catch (Exception ex)
{
_logger.LogError("Unable to create schedule: {Error}", ex.Message);
}
}
private async Task<Either<Error, Unit>> EnsureScheduleExistsAsync(CancellationToken cancellationToken)
{
var programScheduleApi = new ProgramScheduleApi(_serverUrl);
Option<ProgramScheduleViewModel> maybeExisting = await programScheduleApi
.ApiSchedulesGetAsync(cancellationToken)
.Map(list => list.SingleOrDefault(schedule => schedule.Name == Name));
await maybeExisting.Match(
existing =>
{
// TODO: update playback order if changed?
_logger.LogInformation("Schedule {Schedule} is already present", Name);
if (Reset)
{
return programScheduleApi
.ApiSchedulesProgramScheduleIdItemsDeleteAsync(existing.Id, cancellationToken)
.Iter(_ => _logger.LogInformation("Successfully reset schedule {Schedule}", Name));
}
return Task.CompletedTask;
},
() =>
{
var data = new CreateProgramSchedule(Name, Order);
return programScheduleApi.ApiSchedulesPostAsync(data, cancellationToken)
.Iter(_ => _logger.LogInformation("Successfully created schedule {Schedule}", Name));
});
return unit;
}
}
}
+7
View File
@@ -0,0 +1,7 @@
namespace ErsatzTV.CommandLine
{
public class Config
{
public string ServerUrl { get; set; }
}
}
+10
View File
@@ -0,0 +1,10 @@
namespace ErsatzTV.CommandLine
{
public enum DesiredResolution
{
W720H480 = 1,
W1280H720 = 2,
W1920H1080 = 3,
W3840H2160 = 4
}
}
@@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net5.0</TargetFramework>
<AssemblyName>ersatztv-cli</AssemblyName>
<LangVersion>9</LangVersion>
<PackageVersion>0.0.1</PackageVersion>
<AssemblyVersion>0.0.1</AssemblyVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CliFx" Version="1.6.0" />
<PackageReference Include="LanguageExt.Core" Version="3.4.15" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="5.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="5.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="5.0.0" />
<PackageReference Include="RestSharp" Version="106.11.7" />
<PackageReference Include="Serilog" Version="2.10.0" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="4.1.2" />
<PackageReference Include="Serilog.Extensions.Logging" Version="3.0.1" />
<PackageReference Include="Serilog.Sinks.Console" Version="3.1.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\generated\ErsatzTV.Api.Sdk\src\ErsatzTV.Api.Sdk\ErsatzTV.Api.Sdk.csproj" />
</ItemGroup>
<ItemGroup>
<Reference Include="Microsoft.Extensions.Hosting.Abstractions, Version=5.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60">
<HintPath>..\..\..\..\..\..\usr\share\dotnet\packs\Microsoft.AspNetCore.App.Ref\5.0.0\ref\net5.0\Microsoft.Extensions.Hosting.Abstractions.dll</HintPath>
</Reference>
</ItemGroup>
</Project>
+75
View File
@@ -0,0 +1,75 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using CliFx;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Serilog;
using Serilog.Events;
namespace ErsatzTV.CommandLine
{
public class Program
{
public static async Task<int> Main(string[] args)
{
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.Enrich.FromLogContext()
.WriteTo.Console()
.CreateLogger();
IHost host = CreateHostBuilder(args).Build();
try
{
return await new CliApplicationBuilder()
.AddCommandsFromThisAssembly()
.UseTypeActivator(host.Services.GetService)
.Build()
.RunAsync(args);
}
catch (Exception ex)
{
Log.Fatal(ex, "Host terminated unexpectedly");
return 1;
}
finally
{
Log.CloseAndFlush();
}
}
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.ConfigureServices(
(_, services) =>
{
services.AddSingleton<IConsole, SystemConsole>();
IEnumerable<Type> typesThatImplementICommand = typeof(Program).Assembly.GetTypes()
.Where(x => typeof(ICommand).IsAssignableFrom(x))
.Where(x => !x.IsAbstract);
foreach (Type t in typesThatImplementICommand)
{
services.AddTransient(t);
}
})
.ConfigureAppConfiguration(
(_, configuration) =>
{
configuration.Sources.Clear();
string configFolder = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"ersatztv");
configuration.SetBasePath(configFolder);
configuration.AddJsonFile("cli.json", true, true);
})
.UseSerilog()
.UseConsoleLifetime();
}
}
@@ -6,7 +6,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.3.0" />
<PackageReference Include="FluentAssertions" Version="6.2.0" />
<PackageReference Include="LanguageExt.Core" Version="4.0.3" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
@@ -16,7 +16,7 @@
</PackageReference>
<PackageReference Include="Moq" Version="4.16.1" />
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="NUnit3TestAdapter" Version="4.2.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.1.0" />
<PackageReference Include="Serilog" Version="2.10.0" />
<PackageReference Include="Serilog.Extensions.Logging" Version="3.1.0" />
<PackageReference Include="Serilog.Sinks.Debug" Version="2.0.0" />
@@ -297,37 +297,37 @@ namespace ErsatzTV.Core.Tests.FFmpeg
true,
true,
false,
"[0:0]deinterlace_qsv,scale_qsv=w=1920:h=1000,setsar=1[v]",
"[0:0]deinterlace_qsv,scale_qsv=w=1920:h=1000,hwdownload,format=nv12,setsar=1,hwupload=extra_hw_frames=64[v]",
"[v]")]
[TestCase(
true,
false,
true,
"[0:0]deinterlace_qsv,setsar=1,hwdownload,format=nv12,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]",
"[0:0]deinterlace_qsv,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]",
"[v]")]
[TestCase(
true,
true,
true,
"[0:0]deinterlace_qsv,scale_qsv=w=1920:h=1000,setsar=1,hwdownload,format=nv12,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]",
"[0:0]deinterlace_qsv,scale_qsv=w=1920:h=1000,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]",
"[v]")]
[TestCase(
false,
true,
false,
"[0:0]scale_qsv=w=1920:h=1000,setsar=1[v]",
"[0:0]scale_qsv=w=1920:h=1000,hwdownload,format=nv12,setsar=1,hwupload=extra_hw_frames=64[v]",
"[v]")]
[TestCase(
false,
false,
true,
"[0:0]setsar=1,hwdownload,format=nv12,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]",
"[0:0]hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]",
"[v]")]
[TestCase(
false,
true,
true,
"[0:0]scale_qsv=w=1920:h=1000,setsar=1,hwdownload,format=nv12,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]",
"[0:0]scale_qsv=w=1920:h=1000,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]",
"[v]")]
public void Should_Return_QSV_Video_Filter(
bool deinterlace,
@@ -368,37 +368,37 @@ namespace ErsatzTV.Core.Tests.FFmpeg
true,
true,
false,
"[0:0]yadif_cuda,scale_cuda=1920:1000,setsar=1[v]",
"[0:0]yadif_cuda,scale_cuda=1920:1000,hwdownload,format=nv12,setsar=1,hwupload[v]",
"[v]")]
[TestCase(
true,
false,
true,
"[0:0]yadif_cuda,setsar=1,hwdownload,format=nv12,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[0:0]yadif_cuda,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")]
[TestCase(
true,
true,
true,
"[0:0]yadif_cuda,scale_cuda=1920:1000,setsar=1,hwdownload,format=nv12,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[0:0]yadif_cuda,scale_cuda=1920:1000,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")]
[TestCase(
false,
true,
false,
"[0:0]scale_cuda=1920:1000,setsar=1[v]",
"[0:0]scale_cuda=1920:1000,hwdownload,format=nv12,setsar=1,hwupload[v]",
"[v]")]
[TestCase(
false,
false,
true,
"[0:0]setsar=1,hwdownload,format=nv12,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[0:0]hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")]
[TestCase(
false,
true,
true,
"[0:0]scale_cuda=1920:1000,setsar=1,hwdownload,format=nv12,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[0:0]scale_cuda=1920:1000,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")]
public void Should_Return_NVENC_Video_Filter(
bool deinterlace,
@@ -409,8 +409,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder()
.WithHardwareAcceleration(HardwareAccelerationKind.Nvenc)
.WithDeinterlace(deinterlace)
.WithInputPixelFormat("h264");
.WithDeinterlace(deinterlace);
if (scale)
{
@@ -441,42 +440,42 @@ namespace ErsatzTV.Core.Tests.FFmpeg
true,
true,
false,
"[0:0]deinterlace_vaapi,scale_vaapi=format=nv12:w=1920:h=1000,setsar=1[v]",
"[0:0]deinterlace_vaapi,scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
"[v]")]
[TestCase(
"h264",
true,
false,
true,
"[0:0]deinterlace_vaapi,setsar=1,hwdownload,format=nv12|vaapi,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[0:0]deinterlace_vaapi,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")]
[TestCase(
"h264",
true,
true,
true,
"[0:0]deinterlace_vaapi,scale_vaapi=format=nv12:w=1920:h=1000,setsar=1,hwdownload,format=nv12|vaapi,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[0:0]deinterlace_vaapi,scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")]
[TestCase(
"h264",
false,
true,
false,
"[0:0]scale_vaapi=format=nv12:w=1920:h=1000,setsar=1[v]",
"[0:0]scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
"[v]")]
[TestCase(
"h264",
false,
false,
true,
"[0:0]setsar=1,hwdownload,format=nv12|vaapi,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[0:0]hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")]
[TestCase(
"h264",
false,
true,
true,
"[0:0]scale_vaapi=format=nv12:w=1920:h=1000,setsar=1,hwdownload,format=nv12|vaapi,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[0:0]scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")]
[TestCase("mpeg4", true, false, false, "[0:0]hwupload,deinterlace_vaapi[v]", "[v]")]
[TestCase(
@@ -484,28 +483,28 @@ namespace ErsatzTV.Core.Tests.FFmpeg
true,
true,
false,
"[0:0]hwupload,deinterlace_vaapi,scale_vaapi=format=nv12:w=1920:h=1000,setsar=1[v]",
"[0:0]hwupload,deinterlace_vaapi,scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
"[v]")]
[TestCase(
"mpeg4",
true,
false,
true,
"[0:0]hwupload,deinterlace_vaapi,setsar=1,hwdownload,format=nv12|vaapi,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[0:0]hwupload,deinterlace_vaapi,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")]
[TestCase(
"mpeg4",
true,
true,
true,
"[0:0]hwupload,deinterlace_vaapi,scale_vaapi=format=nv12:w=1920:h=1000,setsar=1,hwdownload,format=nv12|vaapi,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[0:0]hwupload,deinterlace_vaapi,scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")]
[TestCase(
"mpeg4",
false,
true,
false,
"[0:0]hwupload,scale_vaapi=format=nv12:w=1920:h=1000,setsar=1[v]",
"[0:0]hwupload,scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
"[v]")]
[TestCase(
"mpeg4",
@@ -519,7 +518,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
false,
true,
true,
"[0:0]hwupload,scale_vaapi=format=nv12:w=1920:h=1000,setsar=1,hwdownload,format=nv12|vaapi,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[0:0]hwupload,scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")]
public void Should_Return_VAAPI_Video_Filter(
string codec,
+6 -25
View File
@@ -94,17 +94,6 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
HardwareAccelerationKind.Vaapi
};
public static string[] VideoToolboxCodecs =
{
"h264_videotoolbox",
"hevc_videotoolbox"
};
public static HardwareAccelerationKind[] VideoToolboxAcceleration =
{
HardwareAccelerationKind.VideoToolbox
};
}
[Test, Combinatorial]
@@ -115,27 +104,21 @@ namespace ErsatzTV.Core.Tests.FFmpeg
string inputPixelFormat,
[ValueSource(typeof(TestData), nameof(TestData.Resolutions))]
Resolution profileResolution,
[Values(true, false)]
bool pad,
// [ValueSource(typeof(TestData), nameof(TestData.SoftwareCodecs))] string profileCodec,
// [ValueSource(typeof(TestData), nameof(TestData.NoAcceleration))] HardwareAccelerationKind profileAcceleration)
// [ValueSource(typeof(TestData), nameof(TestData.NvidiaCodecs))] string profileCodec,
// [ValueSource(typeof(TestData), nameof(TestData.NvidiaAcceleration))] HardwareAccelerationKind profileAcceleration)
[ValueSource(typeof(TestData), nameof(TestData.NvidiaCodecs))] string profileCodec,
[ValueSource(typeof(TestData), nameof(TestData.NvidiaAcceleration))] HardwareAccelerationKind profileAcceleration)
// [ValueSource(typeof(TestData), nameof(TestData.VaapiCodecs))] string profileCodec,
// [ValueSource(typeof(TestData), nameof(TestData.VaapiAcceleration))] HardwareAccelerationKind profileAcceleration)
[ValueSource(typeof(TestData), nameof(TestData.VideoToolboxCodecs))] string profileCodec,
[ValueSource(typeof(TestData), nameof(TestData.VideoToolboxAcceleration))] HardwareAccelerationKind profileAcceleration)
{
string name = GetStringSha256Hash(
$"{inputCodec}_{inputPixelFormat}_{pad}_{profileResolution}_{profileCodec}_{profileAcceleration}");
$"{inputCodec}_{inputPixelFormat}_{profileResolution}_{profileCodec}_{profileAcceleration}");
string file = Path.Combine(TestContext.CurrentContext.TestDirectory, $"{name}.mkv");
if (!File.Exists(file))
{
string resolution = pad ? "1920x1060" : "1920x1080";
var args =
$"-y -f lavfi -i anullsrc=channel_layout=stereo:sample_rate=44100 -f lavfi -i testsrc=duration=1:size={resolution}:rate=30 -c:a aac -c:v {inputCodec} -shortest -pix_fmt {inputPixelFormat} -strict -2 {file}";
$"-y -f lavfi -i anullsrc=channel_layout=stereo:sample_rate=44100 -f lavfi -i testsrc=duration=1:size=1920x1080:rate=30 -c:a aac -c:v {inputCodec} -shortest -pix_fmt {inputPixelFormat} -strict -2 {file}";
var p1 = new Process
{
StartInfo = new ProcessStartInfo
@@ -163,8 +146,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
var metadataRepository = new Mock<IMetadataRepository>();
metadataRepository
.Setup(r => r.UpdateLocalStatistics(It.IsAny<MediaItem>(), It.IsAny<MediaVersion>(), It.IsAny<bool>()))
.Callback<MediaItem, MediaVersion, bool>((_, version, _) => v = version);
.Setup(r => r.UpdateLocalStatistics(It.IsAny<int>(), It.IsAny<MediaVersion>(), It.IsAny<bool>()))
.Callback<int, MediaVersion, bool>((_, version, _) => v = version);
var localStatisticsProvider = new LocalStatisticsProvider(
metadataRepository.Object,
@@ -217,8 +200,6 @@ namespace ErsatzTV.Core.Tests.FFmpeg
TimeSpan.FromSeconds(5));
process.StartInfo.RedirectStandardError = true;
// Console.WriteLine($"ffmpeg arguments {string.Join(" ", process.StartInfo.ArgumentList)}");
process.Start().Should().BeTrue();
@@ -44,7 +44,7 @@ namespace ErsatzTV.Core.Tests.Fakes
.IfNone(SystemTime.MinValueUtc);
public bool IsLibraryPathAccessible(LibraryPath libraryPath) =>
_folders.Any(f => f.Path == libraryPath.Path);
_files.Any(f => f.Path.StartsWith(libraryPath.Path + Path.DirectorySeparatorChar));
public IEnumerable<string> ListSubdirectories(string folder) =>
_folders.Map(f => f.Path).Filter(f => f.StartsWith(folder) && Directory.GetParent(f)?.FullName == folder);
@@ -53,7 +53,6 @@ namespace ErsatzTV.Core.Tests.Fakes
_files.Map(f => f.Path).Filter(f => Path.GetDirectoryName(f) == folder);
public bool FileExists(string path) => _files.Any(f => f.Path == path);
public bool FolderExists(string folder) => false;
public Task<byte[]> ReadAllBytes(string path) => TestBytes.AsTask();
@@ -1,211 +0,0 @@
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Runtime;
using ErsatzTV.Core.Jellyfin;
using FluentAssertions;
using LanguageExt;
using Microsoft.Extensions.Logging;
using Moq;
using NUnit.Framework;
namespace ErsatzTV.Core.Tests.Jellyfin
{
[TestFixture]
public class JellyfinPathReplacementServiceTests
{
[Test]
public async Task JellyfinWindows_To_EtvWindows()
{
var replacements = new List<JellyfinPathReplacement>
{
new()
{
Id = 1,
JellyfinPath = @"C:\Something\Some Shared Folder",
LocalPath = @"C:\Something Else\Some Shared Folder",
JellyfinMediaSource = new JellyfinMediaSource { OperatingSystem = "Windows" }
}
};
var repo = new Mock<IMediaSourceRepository>();
repo.Setup(x => x.GetJellyfinPathReplacementsByLibraryId(It.IsAny<int>())).Returns(replacements.AsTask());
var runtime = new Mock<IRuntimeInfo>();
runtime.Setup(x => x.IsOSPlatform(OSPlatform.Windows)).Returns(true);
var service = new JellyfinPathReplacementService(
repo.Object,
runtime.Object,
new Mock<ILogger<JellyfinPathReplacementService>>().Object);
string result = await service.GetReplacementJellyfinPath(
0,
@"C:\Something\Some Shared Folder\Some Movie\Some Movie.mkv");
result.Should().Be(@"C:\Something Else\Some Shared Folder\Some Movie\Some Movie.mkv");
}
[Test]
public async Task JellyfinWindows_To_EtvLinux()
{
var replacements = new List<JellyfinPathReplacement>
{
new()
{
Id = 1,
JellyfinPath = @"C:\Something\Some Shared Folder",
LocalPath = @"/mnt/something else/Some Shared Folder",
JellyfinMediaSource = new JellyfinMediaSource { OperatingSystem = "Windows" }
}
};
var repo = new Mock<IMediaSourceRepository>();
repo.Setup(x => x.GetJellyfinPathReplacementsByLibraryId(It.IsAny<int>())).Returns(replacements.AsTask());
var runtime = new Mock<IRuntimeInfo>();
runtime.Setup(x => x.IsOSPlatform(OSPlatform.Windows)).Returns(false);
var service = new JellyfinPathReplacementService(
repo.Object,
runtime.Object,
new Mock<ILogger<JellyfinPathReplacementService>>().Object);
string result = await service.GetReplacementJellyfinPath(
0,
@"C:\Something\Some Shared Folder\Some Movie\Some Movie.mkv");
result.Should().Be(@"/mnt/something else/Some Shared Folder/Some Movie/Some Movie.mkv");
}
[Test]
public async Task JellyfinWindows_To_EtvLinux_UncPath()
{
var replacements = new List<JellyfinPathReplacement>
{
new()
{
Id = 1,
JellyfinPath = @"\\192.168.1.100\Something\Some Shared Folder",
LocalPath = @"/mnt/something else/Some Shared Folder",
JellyfinMediaSource = new JellyfinMediaSource { OperatingSystem = "Windows" }
}
};
var repo = new Mock<IMediaSourceRepository>();
repo.Setup(x => x.GetJellyfinPathReplacementsByLibraryId(It.IsAny<int>())).Returns(replacements.AsTask());
var runtime = new Mock<IRuntimeInfo>();
runtime.Setup(x => x.IsOSPlatform(OSPlatform.Windows)).Returns(false);
var service = new JellyfinPathReplacementService(
repo.Object,
runtime.Object,
new Mock<ILogger<JellyfinPathReplacementService>>().Object);
string result = await service.GetReplacementJellyfinPath(
0,
@"\\192.168.1.100\Something\Some Shared Folder\Some Movie\Some Movie.mkv");
result.Should().Be(@"/mnt/something else/Some Shared Folder/Some Movie/Some Movie.mkv");
}
[Test]
public async Task JellyfinWindows_To_EtvLinux_UncPathWithTrailingSlash()
{
var replacements = new List<JellyfinPathReplacement>
{
new()
{
Id = 1,
JellyfinPath = @"\\192.168.1.100\Something\Some Shared Folder\",
LocalPath = @"/mnt/something else/Some Shared Folder/",
JellyfinMediaSource = new JellyfinMediaSource { OperatingSystem = "Windows" }
}
};
var repo = new Mock<IMediaSourceRepository>();
repo.Setup(x => x.GetJellyfinPathReplacementsByLibraryId(It.IsAny<int>())).Returns(replacements.AsTask());
var runtime = new Mock<IRuntimeInfo>();
runtime.Setup(x => x.IsOSPlatform(OSPlatform.Windows)).Returns(false);
var service = new JellyfinPathReplacementService(
repo.Object,
runtime.Object,
new Mock<ILogger<JellyfinPathReplacementService>>().Object);
string result = await service.GetReplacementJellyfinPath(
0,
@"\\192.168.1.100\Something\Some Shared Folder\Some Movie\Some Movie.mkv");
result.Should().Be(@"/mnt/something else/Some Shared Folder/Some Movie/Some Movie.mkv");
}
[Test]
public async Task JellyfinLinux_To_EtvWindows()
{
var replacements = new List<JellyfinPathReplacement>
{
new()
{
Id = 1,
JellyfinPath = @"/mnt/something/Some Shared Folder",
LocalPath = @"C:\Something Else\Some Shared Folder",
JellyfinMediaSource = new JellyfinMediaSource { OperatingSystem = "Linux" }
}
};
var repo = new Mock<IMediaSourceRepository>();
repo.Setup(x => x.GetJellyfinPathReplacementsByLibraryId(It.IsAny<int>())).Returns(replacements.AsTask());
var runtime = new Mock<IRuntimeInfo>();
runtime.Setup(x => x.IsOSPlatform(OSPlatform.Windows)).Returns(true);
var service = new JellyfinPathReplacementService(
repo.Object,
runtime.Object,
new Mock<ILogger<JellyfinPathReplacementService>>().Object);
string result = await service.GetReplacementJellyfinPath(
0,
@"/mnt/something/Some Shared Folder/Some Movie/Some Movie.mkv");
result.Should().Be(@"C:\Something Else\Some Shared Folder\Some Movie\Some Movie.mkv");
}
[Test]
public async Task JellyfinLinux_To_EtvLinux()
{
var replacements = new List<JellyfinPathReplacement>
{
new()
{
Id = 1,
JellyfinPath = @"/mnt/something/Some Shared Folder",
LocalPath = @"/mnt/something else/Some Shared Folder",
JellyfinMediaSource = new JellyfinMediaSource { OperatingSystem = "Linux" }
}
};
var repo = new Mock<IMediaSourceRepository>();
repo.Setup(x => x.GetJellyfinPathReplacementsByLibraryId(It.IsAny<int>())).Returns(replacements.AsTask());
var runtime = new Mock<IRuntimeInfo>();
runtime.Setup(x => x.IsOSPlatform(OSPlatform.Windows)).Returns(false);
var service = new JellyfinPathReplacementService(
repo.Object,
runtime.Object,
new Mock<ILogger<JellyfinPathReplacementService>>().Object);
string result = await service.GetReplacementJellyfinPath(
0,
@"/mnt/something/Some Shared Folder/Some Movie/Some Movie.mkv");
result.Should().Be(@"/mnt/something else/Some Shared Folder/Some Movie/Some Movie.mkv");
}
}
}
@@ -6,6 +6,7 @@ using System.Runtime.InteropServices;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata;
@@ -53,10 +54,6 @@ namespace ErsatzTV.Core.Tests.Metadata
_movieRepository.Setup(x => x.FindMoviePaths(It.IsAny<LibraryPath>()))
.Returns(new List<string>().AsEnumerable().AsTask());
_mediaItemRepository = new Mock<IMediaItemRepository>();
_mediaItemRepository.Setup(x => x.FlagFileNotFound(It.IsAny<LibraryPath>(), It.IsAny<string>()))
.Returns(new List<int>().AsTask());
_localStatisticsProvider = new Mock<ILocalStatisticsProvider>();
_localMetadataProvider = new Mock<ILocalMetadataProvider>();
@@ -76,7 +73,6 @@ namespace ErsatzTV.Core.Tests.Metadata
}
private Mock<IMovieRepository> _movieRepository;
private Mock<IMediaItemRepository> _mediaItemRepository;
private Mock<ILocalStatisticsProvider> _localStatisticsProvider;
private Mock<ILocalMetadataProvider> _localMetadataProvider;
private Mock<IImageCache> _imageCache;
@@ -539,9 +535,6 @@ namespace ErsatzTV.Core.Tests.Metadata
[Test]
public async Task RenamedMovie_Should_Delete_Old_Movie()
{
// TODO: handle this case more elegantly
// ideally, detect that the movie was renamed and still delete the old one (or update the path?)
string movieFolder = Path.Combine(FakeRoot, "Movie (2020)");
string oldMoviePath = Path.Combine(movieFolder, "Movie (2020).avi");
@@ -564,14 +557,12 @@ namespace ErsatzTV.Core.Tests.Metadata
result.IsRight.Should().BeTrue();
_mediaItemRepository.Verify(
x => x.FlagFileNotFound(It.IsAny<LibraryPath>(), It.IsAny<string>()),
Times.Once);
_mediaItemRepository.Verify(x => x.FlagFileNotFound(libraryPath, oldMoviePath), Times.Once);
_movieRepository.Verify(x => x.DeleteByPath(It.IsAny<LibraryPath>(), It.IsAny<string>()), Times.Once);
_movieRepository.Verify(x => x.DeleteByPath(libraryPath, oldMoviePath), Times.Once);
}
[Test]
public async Task DeletedMovieAndFolder_Should_Flag_File_Not_Found()
public async Task DeletedMovieAndFolder_Should_Delete_Old_Movie()
{
string movieFolder = Path.Combine(FakeRoot, "Movie (2020)");
string oldMoviePath = Path.Combine(movieFolder, "Movie (2020).avi");
@@ -579,8 +570,10 @@ namespace ErsatzTV.Core.Tests.Metadata
_movieRepository.Setup(x => x.FindMoviePaths(It.IsAny<LibraryPath>()))
.Returns(new List<string> { oldMoviePath }.AsEnumerable().AsTask());
string moviePath = Path.Combine(movieFolder, "Movie (2020).mkv");
MovieFolderScanner service = GetService(
new FakeFolderEntry(FakeRoot)
new FakeFileEntry(moviePath) { LastWriteTime = DateTime.Now }
);
var libraryPath = new LibraryPath
{ Id = 1, Path = FakeRoot, LibraryFolders = new List<LibraryFolder>() };
@@ -593,12 +586,11 @@ namespace ErsatzTV.Core.Tests.Metadata
result.IsRight.Should().BeTrue();
_mediaItemRepository.Verify(
x => x.FlagFileNotFound(It.IsAny<LibraryPath>(), It.IsAny<string>()),
Times.Once);
_mediaItemRepository.Verify(x => x.FlagFileNotFound(libraryPath, oldMoviePath), Times.Once);
_movieRepository.Verify(x => x.DeleteByPath(It.IsAny<LibraryPath>(), It.IsAny<string>()), Times.Once);
_movieRepository.Verify(x => x.DeleteByPath(libraryPath, oldMoviePath), Times.Once);
}
private MovieFolderScanner GetService(params FakeFileEntry[] files) =>
new(
new FakeLocalFileSystem(new List<FakeFileEntry>(files)),
@@ -610,25 +602,6 @@ namespace ErsatzTV.Core.Tests.Metadata
new Mock<ISearchIndex>().Object,
new Mock<ISearchRepository>().Object,
new Mock<ILibraryRepository>().Object,
_mediaItemRepository.Object,
new Mock<IMediator>().Object,
null,
new Mock<ITempFilePool>().Object,
new Mock<ILogger<MovieFolderScanner>>().Object
);
private MovieFolderScanner GetService(params FakeFolderEntry[] folders) =>
new(
new FakeLocalFileSystem(new List<FakeFileEntry>(), new List<FakeFolderEntry>(folders)),
_movieRepository.Object,
_localStatisticsProvider.Object,
_localMetadataProvider.Object,
new Mock<IMetadataRepository>().Object,
_imageCache.Object,
new Mock<ISearchIndex>().Object,
new Mock<ISearchRepository>().Object,
new Mock<ILibraryRepository>().Object,
_mediaItemRepository.Object,
new Mock<IMediator>().Object,
null,
new Mock<ITempFilePool>().Object,
@@ -5,7 +5,6 @@
None = 0,
Qsv = 1,
Nvenc = 2,
Vaapi = 3,
VideoToolbox = 4
Vaapi = 3
}
}
+11 -11
View File
@@ -1,14 +1,14 @@
using System.Collections.Generic;
namespace ErsatzTV.Core.Domain;
public class MediaItem
namespace ErsatzTV.Core.Domain
{
public int Id { get; set; }
public int LibraryPathId { get; set; }
public LibraryPath LibraryPath { get; set; }
public List<Collection> Collections { get; set; }
public List<CollectionItem> CollectionItems { get; set; }
public List<TraktListItem> TraktListItems { get; set; }
public MediaItemState State { get; set; }
}
public class MediaItem
{
public int Id { get; set; }
public int LibraryPathId { get; set; }
public LibraryPath LibraryPath { get; set; }
public List<Collection> Collections { get; set; }
public List<CollectionItem> CollectionItems { get; set; }
public List<TraktListItem> TraktListItems { get; set; }
}
}
@@ -1,7 +0,0 @@
namespace ErsatzTV.Core.Domain;
public enum MediaItemState
{
Normal = 0,
FileNotFound = 1
}
-4
View File
@@ -6,10 +6,6 @@ namespace ErsatzTV.Core.Domain
{
public int Id { get; set; }
public string Path { get; set; }
public string SourcePath { get; set; }
public string BlurHash43 { get; set; }
public string BlurHash54 { get; set; }
public string BlurHash64 { get; set; }
public ArtworkKind ArtworkKind { get; set; }
public DateTime DateAdded { get; set; }
public DateTime DateUpdated { get; set; }
+2 -3
View File
@@ -4,8 +4,7 @@
{
TransportStream = 1,
HttpLiveStreamingDirect = 2,
// HttpLiveStreamingHybrid = 3,
HttpLiveStreamingSegmenter = 4,
TransportStreamHybrid = 5
HttpLiveStreamingHybrid = 3,
HttpLiveStreamingSegmenter = 4
}
}
+1
View File
@@ -3,6 +3,7 @@
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<NoWarn>VSTHRD200</NoWarn>
<DebugType>embedded</DebugType>
</PropertyGroup>
<ItemGroup>
@@ -27,6 +27,7 @@ namespace ErsatzTV.Core.FFmpeg
private string _videoEncoder;
private Option<string> _subtitle;
private bool _boxBlur;
private Option<int> _randomColor;
public FFmpegComplexFilterBuilder WithHardwareAcceleration(HardwareAccelerationKind hardwareAccelerationKind)
{
@@ -101,6 +102,12 @@ namespace ErsatzTV.Core.FFmpeg
return this;
}
public FFmpegComplexFilterBuilder WithRandomColor(Option<int> randomColor)
{
_randomColor = randomColor;
return this;
}
public FFmpegComplexFilterBuilder WithSubtitleFile(Option<string> subtitleFile)
{
foreach (string file in subtitleFile)
@@ -142,14 +149,8 @@ namespace ErsatzTV.Core.FFmpeg
bool isHardwareDecode = acceleration switch
{
HardwareAccelerationKind.Vaapi => !isSong && _inputCodec != "mpeg4",
// we need an initial hwupload_cuda when only padding with these pixel formats
HardwareAccelerationKind.Nvenc when _scaleToSize.IsNone && _padToSize.IsSome =>
!isSong && !_pixelFormat.Contains("p10le") && !_pixelFormat.Contains("444"),
HardwareAccelerationKind.Nvenc => !isSong,
HardwareAccelerationKind.Qsv => !isSong,
HardwareAccelerationKind.VideoToolbox => false,
_ => false
};
@@ -170,11 +171,9 @@ namespace ErsatzTV.Core.FFmpeg
audioFilterQueue.Add($"apad=whole_dur={durationString}ms");
});
bool usesHardwareFilters = acceleration != HardwareAccelerationKind.None &&
acceleration != HardwareAccelerationKind.VideoToolbox &&
!isHardwareDecode &&
bool usesHardwareFilters = acceleration != HardwareAccelerationKind.None && !isHardwareDecode &&
(_deinterlace || _scaleToSize.IsSome);
if (isSong)
{
switch (acceleration)
@@ -251,19 +250,14 @@ namespace ErsatzTV.Core.FFmpeg
_ => $"scale={size.Width}:{size.Height}:flags=fast_bilinear"
};
if (!string.IsNullOrWhiteSpace(filter))
if (_randomColor.IsNone && !string.IsNullOrWhiteSpace(filter))
{
videoFilterQueue.Add(filter);
}
});
bool scaleOrPad = _scaleToSize.IsSome || _padToSize.IsSome;
bool usesSoftwareFilters = _padToSize.IsSome || _watermark.IsSome;
if (scaleOrPad && _boxBlur == false)
{
videoFilterQueue.Add("setsar=1");
}
bool usesSoftwareFilters = scaleOrPad || _watermark.IsSome;
if (usesSoftwareFilters)
{
@@ -281,15 +275,21 @@ namespace ErsatzTV.Core.FFmpeg
};
videoFilterQueue.Add(format);
}
if (scaleOrPad && _boxBlur == false && _randomColor.IsNone)
{
videoFilterQueue.Add("setsar=1");
}
if (_boxBlur)
{
videoFilterQueue.Add("boxblur=40");
}
if (videoOnly)
foreach (int color in _randomColor)
{
videoFilterQueue.Add("deband");
videoFilterQueue.Add(
$"palettegen=max_colors=8,crop=1:1:{color}:0,scale={_resolution.Width}:{_resolution.Height},setsar=1");
}
foreach (ChannelWatermark watermark in _watermark)
@@ -71,7 +71,7 @@ namespace ErsatzTV.Core.FFmpeg
result.VideoCodec = "copy";
result.Deinterlace = false;
break;
case StreamingMode.TransportStreamHybrid:
case StreamingMode.HttpLiveStreamingHybrid:
case StreamingMode.HttpLiveStreamingSegmenter:
case StreamingMode.TransportStream:
result.HardwareAcceleration = ffmpegProfile.HardwareAcceleration;
@@ -121,11 +121,6 @@ namespace ErsatzTV.Core.FFmpeg
(HardwareAccelerationKind.Qsv, "h264", _) => "h264_qsv",
(HardwareAccelerationKind.Qsv, "hevc", _) => "hevc_qsv",
(HardwareAccelerationKind.Qsv, "mpeg2video", _) => "mpeg2_qsv",
// temp disable mpeg4 hardware decoding for all vaapi
// TODO: check for codec support
(HardwareAccelerationKind.Vaapi, "mpeg4", _) => "mpeg4",
_ => null
};
}
+8 -29
View File
@@ -22,7 +22,6 @@ using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
@@ -108,10 +107,6 @@ namespace ErsatzTV.Core.FFmpeg
_arguments.Add("-hwaccel_output_format");
_arguments.Add("vaapi");
break;
case HardwareAccelerationKind.VideoToolbox:
_arguments.Add("-hwaccel");
_arguments.Add("videotoolbox");
break;
}
_complexFilterBuilder = _complexFilterBuilder.WithHardwareAcceleration(hwAccel);
@@ -206,13 +201,6 @@ namespace ErsatzTV.Core.FFmpeg
return this;
}
public FFmpegProcessBuilder WithCopyCodec()
{
_arguments.Add("-c");
_arguments.Add("copy");
return this;
}
public FFmpegProcessBuilder WithWatermark(
Option<WatermarkOptions> watermarkOptions,
IDisplaySize resolution)
@@ -297,7 +285,8 @@ namespace ErsatzTV.Core.FFmpeg
string videoPath,
Option<string> codec,
Option<string> pixelFormat,
bool boxBlur)
bool boxBlur,
Option<int> randomColor)
{
_noAutoScale = true;
_outputFramerate = 30;
@@ -305,7 +294,8 @@ namespace ErsatzTV.Core.FFmpeg
_complexFilterBuilder = _complexFilterBuilder
.WithInputCodec(codec)
.WithInputPixelFormat(pixelFormat)
.WithBoxBlur(boxBlur);
.WithBoxBlur(boxBlur)
.WithRandomColor(randomColor);
_arguments.Add("-i");
_arguments.Add(videoPath);
@@ -637,13 +627,13 @@ namespace ErsatzTV.Core.FFmpeg
switch (_vaapiDriver)
{
case VaapiDriver.i965:
startInfo.EnvironmentVariables["LIBVA_DRIVER_NAME"] = "i965";
startInfo.EnvironmentVariables.Add("LIBVA_DRIVER_NAME", "i965");
break;
case VaapiDriver.iHD:
startInfo.EnvironmentVariables["LIBVA_DRIVER_NAME"] = "iHD";
startInfo.EnvironmentVariables.Add("LIBVA_DRIVER_NAME", "iHD");
break;
case VaapiDriver.RadeonSI:
startInfo.EnvironmentVariables["LIBVA_DRIVER_NAME"] = "radeonsi";
startInfo.EnvironmentVariables.Add("LIBVA_DRIVER_NAME", "radeonsi");
break;
}
}
@@ -653,18 +643,7 @@ namespace ErsatzTV.Core.FFmpeg
string fileName = _isConcat
? Path.Combine(FileSystemLayout.FFmpegReportsFolder, "ffmpeg-%t-concat.log")
: Path.Combine(FileSystemLayout.FFmpegReportsFolder, "ffmpeg-%t-transcode.log");
// rework filename in a format that works on windows
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
// \ is escape, so use / for directory separators
fileName = fileName.Replace(@"\", @"/");
// colon after drive letter needs to be escaped
fileName = fileName.Replace(@":/", @"\:/");
}
startInfo.EnvironmentVariables["FFREPORT"] = $"file={fileName}:level=32";
startInfo.EnvironmentVariables.Add("FFREPORT", $"file={fileName}:level=32");
}
startInfo.ArgumentList.Add("-nostdin");
+6 -25
View File
@@ -68,7 +68,7 @@ namespace ErsatzTV.Core.FFmpeg
outPoint);
Option<WatermarkOptions> watermarkOptions =
await GetWatermarkOptions(channel, globalWatermark, videoVersion, None, None);
await GetWatermarkOptions(channel, globalWatermark, videoVersion, None);
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, saveReports, _logger)
.WithThreads(playbackSettings.ThreadCount)
@@ -247,24 +247,6 @@ namespace ErsatzTV.Core.FFmpeg
.Build();
}
public Process WrapSegmenter(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host)
{
FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.ConcatSettings;
return new FFmpegProcessBuilder(ffmpegPath, saveReports, _logger)
.WithThreads(1)
.WithQuiet()
.WithFormatFlags(playbackSettings.FormatFlags)
.WithRealtimeOutput(playbackSettings.RealtimeOutput)
.WithInput($"http://localhost:{Settings.ListenPort}/iptv/channel/{channel.Number}.m3u8?mode=segmenter")
.WithMap("0")
.WithCopyCodec()
.WithMetadata(channel, None)
.WithFormat("mpegts")
.WithPipe()
.Build();
}
public Process ConvertToPng(string ffmpegPath, string inputFile, string outputFile)
{
return new FFmpegProcessBuilder(ffmpegPath, false, _logger)
@@ -294,7 +276,7 @@ namespace ErsatzTV.Core.FFmpeg
MediaVersion videoVersion,
string videoPath,
bool boxBlur,
Option<string> watermarkPath,
Option<int> randomColor,
ChannelWatermarkLocation watermarkLocation,
int horizontalMarginPercent,
int verticalMarginPercent,
@@ -321,7 +303,7 @@ namespace ErsatzTV.Core.FFmpeg
: None;
Option<WatermarkOptions> watermarkOptions =
await GetWatermarkOptions(channel, globalWatermark, videoVersion, watermarkOverride, watermarkPath);
await GetWatermarkOptions(channel, globalWatermark, videoVersion, watermarkOverride);
FFmpegPlaybackSettings playbackSettings =
_playbackSettingsCalculator.CalculateErrorSettings(channel.FFmpegProfile);
@@ -341,7 +323,7 @@ namespace ErsatzTV.Core.FFmpeg
.WithThreads(1)
.WithQuiet()
.WithFormatFlags(playbackSettings.FormatFlags)
.WithSongInput(videoPath, videoStream.Codec, videoStream.PixelFormat, boxBlur)
.WithSongInput(videoPath, videoStream.Codec, videoStream.PixelFormat, boxBlur, randomColor)
.WithWatermark(watermarkOptions, channel.FFmpegProfile.Resolution)
.WithSubtitleFile(subtitleFile);
@@ -388,8 +370,7 @@ namespace ErsatzTV.Core.FFmpeg
Channel channel,
Option<ChannelWatermark> globalWatermark,
MediaVersion videoVersion,
Option<ChannelWatermark> watermarkOverride,
Option<string> watermarkPath)
Option<ChannelWatermark> watermarkOverride)
{
if (videoVersion is BackgroundImageMediaVersion)
{
@@ -403,7 +384,7 @@ namespace ErsatzTV.Core.FFmpeg
{
return new WatermarkOptions(
watermarkOverride,
await watermarkPath.IfNoneAsync(videoVersion.MediaFiles.Head().Path),
videoVersion.MediaFiles.Head().Path,
0,
false);
}
+35 -54
View File
@@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
@@ -51,22 +50,22 @@ namespace ErsatzTV.Core.FFmpeg
new() { MediaStreamKind = MediaStreamKind.Video, Index = 0 }
}
};
string[] backgrounds =
{
"song_background_1.png",
"song_background_2.png",
"song_background_3.png"
"background_blank.png",
"background_e.png",
"background_t.png",
"background_v.png"
};
// use random ETV color by default
string backgroundPath = Path.Combine(
string artworkPath = Path.Combine(
FileSystemLayout.ResourcesCacheFolder,
backgrounds[NextRandom(backgrounds.Length)]);
Option<string> watermarkPath = None;
var boxBlur = false;
Option<int> randomColor = None;
const int HORIZONTAL_MARGIN_PERCENT = 3;
const int VERTICAL_MARGIN_PERCENT = 5;
@@ -116,18 +115,15 @@ namespace ErsatzTV.Core.FFmpeg
int leftMarginPercent = HORIZONTAL_MARGIN_PERCENT;
int rightMarginPercent = HORIZONTAL_MARGIN_PERCENT;
if (metadata.Artwork.Any(a => a.ArtworkKind == ArtworkKind.Thumbnail))
switch (watermarkLocation)
{
switch (watermarkLocation)
{
case ChannelWatermarkLocation.BottomLeft:
leftMarginPercent += WATERMARK_WIDTH_PERCENT + HORIZONTAL_MARGIN_PERCENT;
break;
case ChannelWatermarkLocation.BottomRight:
leftMarginPercent = rightMarginPercent = HORIZONTAL_MARGIN_PERCENT;
rightMarginPercent += WATERMARK_WIDTH_PERCENT + HORIZONTAL_MARGIN_PERCENT;
break;
}
case ChannelWatermarkLocation.BottomLeft:
leftMarginPercent += WATERMARK_WIDTH_PERCENT + HORIZONTAL_MARGIN_PERCENT;
break;
case ChannelWatermarkLocation.BottomRight:
leftMarginPercent = rightMarginPercent = HORIZONTAL_MARGIN_PERCENT;
rightMarginPercent += WATERMARK_WIDTH_PERCENT + HORIZONTAL_MARGIN_PERCENT;
break;
}
var leftMargin = (int)Math.Round(leftMarginPercent / 100.0 * channel.FFmpegProfile.Resolution.Width);
@@ -139,7 +135,7 @@ namespace ErsatzTV.Core.FFmpeg
.WithFontName("OPTIKabel-Heavy")
.WithFontSize(fontSize)
.WithPrimaryColor("&HFFFFFF")
.WithOutlineColor("&H444444")
.WithOutlineColor("&H555555")
.WithAlignment(0)
.WithMarginRight(rightMargin)
.WithMarginLeft(leftMargin)
@@ -153,6 +149,23 @@ namespace ErsatzTV.Core.FFmpeg
foreach (Artwork artwork in Optional(
metadata.Artwork.Find(a => a.ArtworkKind == ArtworkKind.Thumbnail)))
{
int backgroundRoll = NextRandom(16);
if (backgroundRoll < 8)
{
randomColor = backgroundRoll;
}
else
{
boxBlur = true;
}
string customPath = _imageCache.GetPathForImage(
artwork.Path,
ArtworkKind.Thumbnail,
Option<int>.None);
artworkPath = customPath;
// signal that we want to use cover art as watermark
videoVersion = new CoverArtMediaVersion
{
@@ -166,42 +179,10 @@ namespace ErsatzTV.Core.FFmpeg
new() { MediaStreamKind = MediaStreamKind.Video, Index = 0 }
}
};
string customPath = _imageCache.GetPathForImage(
artwork.Path,
ArtworkKind.Thumbnail,
Option<int>.None);
watermarkPath = customPath;
// randomize selected blur hash
var hashes = new List<string>
{
artwork.BlurHash43,
artwork.BlurHash54,
artwork.BlurHash64
}.Filter(s => !string.IsNullOrWhiteSpace(s)).ToList();
if (hashes.Any())
{
string hash = hashes[NextRandom(hashes.Count)];
backgroundPath = await _imageCache.WriteBlurHash(
hash,
channel.FFmpegProfile.Resolution);
videoVersion.Height = channel.FFmpegProfile.Resolution.Height;
videoVersion.Width = channel.FFmpegProfile.Resolution.Width;
}
else
{
backgroundPath = customPath;
boxBlur = true;
}
}
}
string videoPath = backgroundPath;
string videoPath = artworkPath;
videoVersion.MediaFiles = new List<MediaFile>
{
@@ -216,7 +197,7 @@ namespace ErsatzTV.Core.FFmpeg
videoVersion,
videoPath,
boxBlur,
watermarkPath,
randomColor,
watermarkLocation,
HORIZONTAL_MARGIN_PERCENT,
VERTICAL_MARGIN_PERCENT,
+2 -2
View File
@@ -118,8 +118,8 @@ namespace ErsatzTV.Core.FFmpeg
}
sb.AppendLine("[V4+ Styles]");
sb.AppendLine("Format: Name, Fontname, Fontsize, PrimaryColour, OutlineColour, BorderStyle, Outline, Shadow, Alignment, Encoding");
sb.AppendLine($"Style: Default,{await _fontName.IfNoneAsync("")},{await _fontSize.IfNoneAsync(32)},{await _primaryColor.IfNoneAsync("")},{await _outlineColor.IfNoneAsync("")},{await _borderStyle.IfNoneAsync(0)},1,{await _shadow.IfNoneAsync(0)},{await _alignment.IfNoneAsync(0)},1");
sb.AppendLine("Format: Name, Fontname, Fontsize, PrimaryColour, OutlineColour, BorderStyle, Shadow, Alignment, Encoding");
sb.AppendLine($"Style: Default,{await _fontName.IfNoneAsync("")},{await _fontSize.IfNoneAsync(32)},{await _primaryColor.IfNoneAsync("")},{await _outlineColor.IfNoneAsync("")},{await _borderStyle.IfNoneAsync(0)},{await _shadow.IfNoneAsync(0)}, {await _alignment.IfNoneAsync(0)},1");
sb.AppendLine("[Events]");
sb.AppendLine("Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text");
+2 -1
View File
@@ -22,7 +22,8 @@ namespace ErsatzTV.Core.Hdhr
public string URL => _channel.StreamingMode switch
{
StreamingMode.HttpLiveStreamingDirect => $"{_scheme}://{_host}/iptv/channel/{_channel.Number}.m3u8",
StreamingMode.HttpLiveStreamingDirect or StreamingMode.HttpLiveStreamingHybrid =>
$"{_scheme}://{_host}/iptv/channel/{_channel.Number}.m3u8",
_ => $"{_scheme}://{_host}/iptv/channel/{_channel.Number}.ts"
};
}
@@ -1,5 +0,0 @@
namespace ErsatzTV.Core.Health.Checks;
public interface IFileNotFoundHealthCheck : IHealthCheck
{
}
+2 -4
View File
@@ -1,6 +1,4 @@
using LanguageExt;
namespace ErsatzTV.Core.Health
namespace ErsatzTV.Core.Health
{
public record HealthCheckResult(string Title, HealthCheckStatus Status, string Message, Option<string> Link);
public record HealthCheckResult(string Title, HealthCheckStatus Status, string Message);
}
@@ -38,8 +38,6 @@ namespace ErsatzTV.Core.Interfaces.FFmpeg
Process ConcatChannel(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host);
Process WrapSegmenter(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host);
Process ConvertToPng(string ffmpegPath, string inputFile, string outputFile);
Process ExtractAttachedPicAsPng(string ffmpegPath, string inputFile, int streamIndex, string outputFile);
@@ -52,7 +50,7 @@ namespace ErsatzTV.Core.Interfaces.FFmpeg
MediaVersion videoVersion,
string videoPath,
bool boxBlur,
Option<string> watermarkPath,
Option<int> randomColor,
ChannelWatermarkLocation watermarkLocation,
int horizontalMarginPercent,
int verticalMarginPercent,
@@ -1,7 +1,6 @@
using System.IO;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Images
@@ -13,7 +12,5 @@ namespace ErsatzTV.Core.Interfaces.Images
Task<Either<BaseError, string>> CopyArtworkToCache(string path, ArtworkKind artworkKind);
string GetPathForImage(string fileName, ArtworkKind artworkKind, Option<int> maybeMaxHeight);
Task<bool> IsAnimated(string fileName);
Task<string> CalculateBlurHash(string fileName, ArtworkKind artworkKind, int x, int y);
Task<string> WriteBlurHash(string blurHash, IDisplaySize targetSize);
}
}
@@ -14,7 +14,6 @@ namespace ErsatzTV.Core.Interfaces.Metadata
IEnumerable<string> ListSubdirectories(string folder);
IEnumerable<string> ListFiles(string folder);
bool FileExists(string path);
bool FolderExists(string folder);
Task<Either<BaseError, Unit>> CopyFile(string source, string destination);
Unit EmptyFolder(string folder);
}
@@ -19,6 +19,5 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<List<int>> GetMediaIdsByLocalPath(int libraryPathId);
Task DeleteLocalPath(int libraryPathId);
Task<Unit> SetEtag(LibraryPath libraryPath, Option<LibraryFolder> knownFolder, string path, string etag);
Task<Unit> CleanEtagsForLibraryPath(LibraryPath libraryPath);
}
}
@@ -1,15 +1,10 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Repositories
{
public interface IMediaItemRepository
{
Task<List<string>> GetAllLanguageCodes();
Task<List<int>> FlagFileNotFound(LibraryPath libraryPath, string path);
Task<Unit> FlagNormal(MediaItem mediaItem);
Task<Either<BaseError, Unit>> DeleteItems(List<int> mediaItemIds);
}
}
@@ -15,17 +15,11 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<bool> RemoveActor(Actor actor);
Task<bool> Update(Domain.Metadata metadata);
Task<bool> Add(Domain.Metadata metadata);
Task<bool> UpdateLocalStatistics(MediaItem mediaItem, MediaVersion incoming, bool updateVersion = true);
Task<bool> UpdateLocalStatistics(int mediaVersionId, MediaVersion incoming, bool updateVersion = true);
Task<bool> UpdatePlexStatistics(int mediaVersionId, MediaVersion incoming);
Task<Unit> UpdateArtworkPath(Artwork artwork);
Task<Unit> AddArtwork(Domain.Metadata metadata, Artwork artwork);
Task<Unit> RemoveArtwork(Domain.Metadata metadata, ArtworkKind artworkKind);
Task<bool> CloneArtwork(
Domain.Metadata metadata,
Option<Artwork> maybeArtwork,
ArtworkKind artworkKind,
string sourcePath,
DateTime lastWriteTime);
Task<Unit> MarkAsUpdated(ShowMetadata metadata, DateTime dateUpdated);
Task<Unit> MarkAsUpdated(SeasonMetadata metadata, DateTime dateUpdated);
Task<Unit> MarkAsUpdated(MovieMetadata metadata, DateTime dateUpdated);
@@ -14,7 +14,6 @@ namespace ErsatzTV.Core.Interfaces.Search
public int Version { get; }
Task<bool> Initialize(ILocalFileSystem localFileSystem);
Task<Unit> Rebuild(ISearchRepository searchRepository, List<int> itemIds);
Task<Unit> RebuildItems(ISearchRepository searchRepository, List<int> itemIds);
Task<Unit> AddItems(ISearchRepository searchRepository, List<MediaItem> items);
Task<Unit> UpdateItems(ISearchRepository searchRepository, List<MediaItem> items);
Task<Unit> RemoveItems(List<int> ids);
+3 -3
View File
@@ -26,7 +26,7 @@ namespace ErsatzTV.Core.Iptv
var xmltv = $"{_scheme}://{_host}/iptv/xmltv.xml";
sb.AppendLine($"#EXTM3U url-tvg=\"{xmltv}\" x-tvg-url=\"{xmltv}\"");
foreach (Channel channel in _channels.OrderBy(c => decimal.Parse(c.Number)))
foreach (Channel channel in _channels.OrderBy(c => c.Number))
{
string logo = Optional(channel.Artwork).Flatten()
.Filter(a => a.ArtworkKind == ArtworkKind.Logo)
@@ -43,9 +43,9 @@ namespace ErsatzTV.Core.Iptv
string format = channel.StreamingMode switch
{
StreamingMode.HttpLiveStreamingDirect => "m3u8?mode=hls-direct",
StreamingMode.HttpLiveStreamingHybrid => "m3u8",
StreamingMode.HttpLiveStreamingSegmenter => "m3u8?mode=segmenter",
StreamingMode.TransportStreamHybrid => "ts",
_ => "ts?mode=legacy"
_ => "ts"
};
string vcodec = channel.FFmpegProfile.VideoCodec.Split("_").Head();
@@ -50,8 +50,6 @@ namespace ErsatzTV.Core.Metadata
public bool FileExists(string path) => File.Exists(path);
public bool FolderExists(string folder) => Directory.Exists(folder);
public async Task<Either<BaseError, Unit>> CopyFile(string source, string destination)
{
try
@@ -57,13 +57,11 @@ namespace ErsatzTV.Core.Metadata
private readonly ILocalStatisticsProvider _localStatisticsProvider;
private readonly ILogger _logger;
private readonly IMetadataRepository _metadataRepository;
private readonly IMediaItemRepository _mediaItemRepository;
protected LocalFolderScanner(
ILocalFileSystem localFileSystem,
ILocalStatisticsProvider localStatisticsProvider,
IMetadataRepository metadataRepository,
IMediaItemRepository mediaItemRepository,
IImageCache imageCache,
IFFmpegProcessService ffmpegProcessService,
ITempFilePool tempFilePool,
@@ -72,7 +70,6 @@ namespace ErsatzTV.Core.Metadata
_localFileSystem = localFileSystem;
_localStatisticsProvider = localStatisticsProvider;
_metadataRepository = metadataRepository;
_mediaItemRepository = mediaItemRepository;
_imageCache = imageCache;
_ffmpegProcessService = ffmpegProcessService;
_tempFilePool = tempFilePool;
@@ -142,17 +139,6 @@ namespace ErsatzTV.Core.Metadata
{
_logger.LogDebug("Refreshing {Attribute} from {Path}", artworkKind, artworkFile);
string sourcePath = artworkFile;
if (await _metadataRepository.CloneArtwork(
metadata,
maybeArtwork,
artworkKind,
sourcePath,
lastWriteTime))
{
return true;
}
// if ffmpeg path is passed, we need pre-processing
foreach (string path in ffmpegPath)
{
@@ -193,28 +179,7 @@ namespace ErsatzTV.Core.Metadata
async artwork =>
{
artwork.Path = cacheName;
artwork.SourcePath = sourcePath;
artwork.DateUpdated = lastWriteTime;
if (metadata is SongMetadata)
{
artwork.BlurHash43 = await _imageCache.CalculateBlurHash(
cacheName,
artworkKind,
4,
3);
artwork.BlurHash54 = await _imageCache.CalculateBlurHash(
cacheName,
artworkKind,
5,
4);
artwork.BlurHash64 = await _imageCache.CalculateBlurHash(
cacheName,
artworkKind,
6,
4);
}
await _metadataRepository.UpdateArtworkPath(artwork);
},
async () =>
@@ -222,31 +187,10 @@ namespace ErsatzTV.Core.Metadata
var artwork = new Artwork
{
Path = cacheName,
SourcePath = sourcePath,
DateAdded = DateTime.UtcNow,
DateUpdated = lastWriteTime,
ArtworkKind = artworkKind
};
if (metadata is SongMetadata)
{
artwork.BlurHash43 = await _imageCache.CalculateBlurHash(
cacheName,
artworkKind,
4,
3);
artwork.BlurHash54 = await _imageCache.CalculateBlurHash(
cacheName,
artworkKind,
5,
4);
artwork.BlurHash64 = await _imageCache.CalculateBlurHash(
cacheName,
artworkKind,
6,
4);
}
metadata.Artwork.Add(artwork);
await _metadataRepository.AddArtwork(metadata, artwork);
});
@@ -268,29 +212,6 @@ namespace ErsatzTV.Core.Metadata
return false;
}
protected Task<List<int>> FlagFileNotFound(LibraryPath libraryPath, string path) =>
_mediaItemRepository.FlagFileNotFound(libraryPath, path);
protected async Task<Either<BaseError, MediaItemScanResult<T>>> FlagNormal<T>(MediaItemScanResult<T> result)
where T : MediaItem
{
try
{
T mediaItem = result.Item;
if (mediaItem.State != MediaItemState.Normal)
{
await _mediaItemRepository.FlagNormal(mediaItem);
result.IsUpdated = true;
}
return result;
}
catch (Exception ex)
{
return BaseError.New(ex.ToString());
}
}
protected bool ShouldIncludeFolder(string folder) =>
!Path.GetFileName(folder).StartsWith('.') &&
!_localFileSystem.FileExists(Path.Combine(folder, ".etvignore"));
@@ -969,9 +969,9 @@ namespace ErsatzTV.Core.Metadata
}
}
private static int? GetYear(int? year, string premiered)
private static int? GetYear(int year, string premiered)
{
if (year is > 1000)
if (year > 1000)
{
return year;
}
@@ -989,9 +989,9 @@ namespace ErsatzTV.Core.Metadata
return null;
}
private static DateTime? GetAired(int? year, string aired)
private static DateTime? GetAired(int year, string aired)
{
DateTime? fallback = year is > 1000 ? new DateTime(year.Value, 1, 1) : null;
DateTime? fallback = year > 1000 ? new DateTime(year, 1, 1) : null;
if (string.IsNullOrWhiteSpace(aired))
{
@@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
@@ -132,7 +131,7 @@ namespace ErsatzTV.Core.Metadata
version.DateUpdated = _localFileSystem.GetLastWriteTime(filePath);
return await _metadataRepository.UpdateLocalStatistics(mediaItem, version) && durationChange;
return await _metadataRepository.UpdateLocalStatistics(mediaItemVersion.Id, version) && durationChange;
}
private Task<Either<BaseError, FFprobe>> GetProbeOutput(string ffprobePath, string filePath)
@@ -142,9 +141,7 @@ namespace ErsatzTV.Core.Metadata
FileName = ffprobePath,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8
UseShellExecute = false
};
startInfo.ArgumentList.Add("-v");
+4 -9
View File
@@ -40,7 +40,6 @@ namespace ErsatzTV.Core.Metadata
ISearchIndex searchIndex,
ISearchRepository searchRepository,
ILibraryRepository libraryRepository,
IMediaItemRepository mediaItemRepository,
IMediator mediator,
IFFmpegProcessService ffmpegProcessService,
ITempFilePool tempFilePool,
@@ -49,7 +48,6 @@ namespace ErsatzTV.Core.Metadata
localFileSystem,
localStatisticsProvider,
metadataRepository,
mediaItemRepository,
imageCache,
ffmpegProcessService,
tempFilePool,
@@ -142,8 +140,7 @@ namespace ErsatzTV.Core.Metadata
.BindT(movie => UpdateStatistics(movie, ffprobePath))
.BindT(UpdateMetadata)
.BindT(movie => UpdateArtwork(movie, ArtworkKind.Poster))
.BindT(movie => UpdateArtwork(movie, ArtworkKind.FanArt))
.BindT(FlagNormal);
.BindT(movie => UpdateArtwork(movie, ArtworkKind.FanArt));
await maybeMovie.Match(
async result =>
@@ -171,9 +168,9 @@ namespace ErsatzTV.Core.Metadata
{
if (!_localFileSystem.FileExists(path))
{
_logger.LogInformation("Flagging missing movie at {Path}", path);
List<int> ids = await FlagFileNotFound(libraryPath, path);
await _searchIndex.RebuildItems(_searchRepository, ids);
_logger.LogInformation("Removing missing movie at {Path}", path);
List<int> ids = await _movieRepository.DeleteByPath(libraryPath, path);
await _searchIndex.RemoveItems(ids);
}
else if (Path.GetFileName(path).StartsWith("._"))
{
@@ -183,8 +180,6 @@ namespace ErsatzTV.Core.Metadata
}
}
await _libraryRepository.CleanEtagsForLibraryPath(libraryPath);
_searchIndex.Commit();
return Unit.Default;
}
@@ -41,7 +41,6 @@ namespace ErsatzTV.Core.Metadata
IArtistRepository artistRepository,
IMusicVideoRepository musicVideoRepository,
ILibraryRepository libraryRepository,
IMediaItemRepository mediaItemRepository,
IMediator mediator,
IFFmpegProcessService ffmpegProcessService,
ITempFilePool tempFilePool,
@@ -49,7 +48,6 @@ namespace ErsatzTV.Core.Metadata
localFileSystem,
localStatisticsProvider,
metadataRepository,
mediaItemRepository,
imageCache,
ffmpegProcessService,
tempFilePool,
@@ -137,9 +135,9 @@ namespace ErsatzTV.Core.Metadata
{
if (!_localFileSystem.FileExists(path))
{
_logger.LogInformation("Flagging missing music video at {Path}", path);
List<int> musicVideoIds = await FlagFileNotFound(libraryPath, path);
await _searchIndex.RebuildItems(_searchRepository, musicVideoIds);
_logger.LogInformation("Removing missing music video at {Path}", path);
List<int> musicVideoIds = await _musicVideoRepository.DeleteByPath(libraryPath, path);
await _searchIndex.RemoveItems(musicVideoIds);
}
else if (Path.GetFileName(path).StartsWith("._"))
{
@@ -148,8 +146,6 @@ namespace ErsatzTV.Core.Metadata
await _searchIndex.RemoveItems(musicVideoIds);
}
}
await _libraryRepository.CleanEtagsForLibraryPath(libraryPath);
List<int> artistIds = await _artistRepository.DeleteEmptyArtists(libraryPath);
await _searchIndex.RemoveItems(artistIds);
@@ -280,8 +276,7 @@ namespace ErsatzTV.Core.Metadata
.GetOrAdd(artist, libraryPath, file)
.BindT(musicVideo => UpdateStatistics(musicVideo, ffprobePath))
.BindT(UpdateMetadata)
.BindT(UpdateThumbnail)
.BindT(FlagNormal);
.BindT(UpdateThumbnail);
await maybeMusicVideo.Match(
async result =>
+1 -8
View File
@@ -9,15 +9,8 @@ namespace ErsatzTV.Core.Metadata.Nfo
[XmlElement("title")]
public string Title { get; set; }
[XmlIgnore]
public int? Year { get; set; }
[XmlElement("year")]
public string YearAsText
{
get => Year.HasValue ? Year.ToString() : null;
set => Year = !string.IsNullOrWhiteSpace(value) ? int.Parse(value) : default(int?);
}
public int Year { get; set; }
[XmlElement("plot")]
public string Plot { get; set; }
@@ -40,14 +40,12 @@ namespace ErsatzTV.Core.Metadata
ISearchRepository searchRepository,
IOtherVideoRepository otherVideoRepository,
ILibraryRepository libraryRepository,
IMediaItemRepository mediaItemRepository,
IFFmpegProcessService ffmpegProcessService,
ITempFilePool tempFilePool,
ILogger<OtherVideoFolderScanner> logger) : base(
localFileSystem,
localStatisticsProvider,
metadataRepository,
mediaItemRepository,
imageCache,
ffmpegProcessService,
tempFilePool,
@@ -79,15 +77,9 @@ namespace ErsatzTV.Core.Metadata
var foldersCompleted = 0;
var folderQueue = new Queue<string>();
if (ShouldIncludeFolder(libraryPath.Path))
{
folderQueue.Enqueue(libraryPath.Path);
}
foreach (string folder in _localFileSystem.ListSubdirectories(libraryPath.Path)
.Filter(ShouldIncludeFolder)
.OrderBy(identity))
.Filter(ShouldIncludeFolder)
.OrderBy(identity))
{
folderQueue.Enqueue(folder);
}
@@ -135,8 +127,7 @@ namespace ErsatzTV.Core.Metadata
Either<BaseError, MediaItemScanResult<OtherVideo>> maybeVideo = await _otherVideoRepository
.GetOrAdd(libraryPath, file)
.BindT(video => UpdateStatistics(video, ffprobePath))
.BindT(UpdateMetadata)
.BindT(FlagNormal);
.BindT(UpdateMetadata);
await maybeVideo.Match(
async result =>
@@ -164,9 +155,9 @@ namespace ErsatzTV.Core.Metadata
{
if (!_localFileSystem.FileExists(path))
{
_logger.LogInformation("Flagging missing other video at {Path}", path);
List<int> otherVideoIds = await FlagFileNotFound(libraryPath, path);
await _searchIndex.RebuildItems(_searchRepository, otherVideoIds);
_logger.LogInformation("Removing missing other video at {Path}", path);
List<int> otherVideoIds = await _otherVideoRepository.DeleteByPath(libraryPath, path);
await _searchIndex.RemoveItems(otherVideoIds);
}
else if (Path.GetFileName(path).StartsWith("._"))
{
@@ -175,8 +166,6 @@ namespace ErsatzTV.Core.Metadata
await _searchIndex.RemoveItems(otherVideoIds);
}
}
await _libraryRepository.CleanEtagsForLibraryPath(libraryPath);
_searchIndex.Commit();
return Unit.Default;
+6 -17
View File
@@ -41,14 +41,12 @@ namespace ErsatzTV.Core.Metadata
ISearchRepository searchRepository,
ISongRepository songRepository,
ILibraryRepository libraryRepository,
IMediaItemRepository mediaItemRepository,
IFFmpegProcessService ffmpegProcessService,
ITempFilePool tempFilePool,
ILogger<SongFolderScanner> logger) : base(
localFileSystem,
localStatisticsProvider,
metadataRepository,
mediaItemRepository,
imageCache,
ffmpegProcessService,
tempFilePool,
@@ -81,15 +79,9 @@ namespace ErsatzTV.Core.Metadata
var foldersCompleted = 0;
var folderQueue = new Queue<string>();
if (ShouldIncludeFolder(libraryPath.Path))
{
folderQueue.Enqueue(libraryPath.Path);
}
foreach (string folder in _localFileSystem.ListSubdirectories(libraryPath.Path)
.Filter(ShouldIncludeFolder)
.OrderBy(identity))
.Filter(ShouldIncludeFolder)
.OrderBy(identity))
{
folderQueue.Enqueue(folder);
}
@@ -138,8 +130,7 @@ namespace ErsatzTV.Core.Metadata
.GetOrAdd(libraryPath, file)
.BindT(video => UpdateStatistics(video, ffprobePath))
.BindT(video => UpdateMetadata(video, ffprobePath))
.BindT(video => UpdateThumbnail(video, ffmpegPath))
.BindT(FlagNormal);
.BindT(video => UpdateThumbnail(video, ffmpegPath));
await maybeSong.Match(
async result =>
@@ -167,9 +158,9 @@ namespace ErsatzTV.Core.Metadata
{
if (!_localFileSystem.FileExists(path))
{
_logger.LogInformation("Flagging missing song at {Path}", path);
List<int> songIds = await FlagFileNotFound(libraryPath, path);
await _searchIndex.RebuildItems(_searchRepository, songIds);
_logger.LogInformation("Removing missing song at {Path}", path);
List<int> songIds = await _songRepository.DeleteByPath(libraryPath, path);
await _searchIndex.RemoveItems(songIds);
}
else if (Path.GetFileName(path).StartsWith("._"))
{
@@ -178,8 +169,6 @@ namespace ErsatzTV.Core.Metadata
await _searchIndex.RemoveItems(songIds);
}
}
await _libraryRepository.CleanEtagsForLibraryPath(libraryPath);
_searchIndex.Commit();
return Unit.Default;
@@ -40,7 +40,6 @@ namespace ErsatzTV.Core.Metadata
ISearchIndex searchIndex,
ISearchRepository searchRepository,
ILibraryRepository libraryRepository,
IMediaItemRepository mediaItemRepository,
IMediator mediator,
IFFmpegProcessService ffmpegProcessService,
ITempFilePool tempFilePool,
@@ -48,7 +47,6 @@ namespace ErsatzTV.Core.Metadata
localFileSystem,
localStatisticsProvider,
metadataRepository,
mediaItemRepository,
imageCache,
ffmpegProcessService,
tempFilePool,
@@ -128,9 +126,8 @@ namespace ErsatzTV.Core.Metadata
{
if (!_localFileSystem.FileExists(path))
{
_logger.LogInformation("Flagging missing episode at {Path}", path);
List<int> episodeIds = await FlagFileNotFound(libraryPath, path);
await _searchIndex.RebuildItems(_searchRepository, episodeIds);
_logger.LogInformation("Removing missing episode at {Path}", path);
await _televisionRepository.DeleteByPath(libraryPath, path);
}
else if (Path.GetFileName(path).StartsWith("._"))
{
@@ -138,8 +135,6 @@ namespace ErsatzTV.Core.Metadata
await _televisionRepository.DeleteByPath(libraryPath, path);
}
}
await _libraryRepository.CleanEtagsForLibraryPath(libraryPath);
await _televisionRepository.DeleteEmptySeasons(libraryPath);
List<int> ids = await _televisionRepository.DeleteEmptyShows(libraryPath);
@@ -236,9 +231,7 @@ namespace ErsatzTV.Core.Metadata
episode => UpdateStatistics(new MediaItemScanResult<Episode>(episode), ffprobePath)
.MapT(_ => episode))
.BindT(UpdateMetadata)
.BindT(UpdateThumbnail)
.BindT(e => FlagNormal(new MediaItemScanResult<Episode>(e)))
.MapT(r => r.Item);
.BindT(UpdateThumbnail);
await maybeEpisode.Match(
async episode =>
+1 -15
View File
@@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Scheduling;
using LanguageExt;
@@ -255,7 +254,6 @@ namespace ErsatzTV.Core.Scheduling
foreach ((CollectionKey _, List<MediaItem> items) in collectionMediaItems)
{
var zeroItems = new List<MediaItem>();
// var missingItems = new List<MediaItem>();
foreach (MediaItem item in items)
{
@@ -274,17 +272,6 @@ namespace ErsatzTV.Core.Scheduling
_ => true
};
// if (item.State == MediaItemState.FileNotFound)
// {
// _logger.LogWarning(
// "Skipping media item that does not exist on disk {MediaItem} - {MediaItemTitle} - {Path}",
// item.Id,
// DisplayTitle(item),
// item.GetHeadVersion().MediaFiles.Head().Path);
//
// missingItems.Add(item);
// }
// else
if (isZero)
{
_logger.LogWarning(
@@ -296,8 +283,7 @@ namespace ErsatzTV.Core.Scheduling
}
}
// items.RemoveAll(missingItems.Contains);
items.RemoveAll(zeroItems.Contains);
items.RemoveAll(i => zeroItems.Contains(i));
}
return collectionMediaItems.Find(c => !c.Value.Any()).Map(c => c.Key);
@@ -123,11 +123,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<List<ArtistMetadata>> GetArtistsForCards(List<int> ids)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.ArtistMetadata
.AsNoTracking()
.Filter(am => ids.Contains(am.ArtistId))
.Include(am => am.Artist)
.Include(am => am.Artwork)
.OrderBy(am => am.SortTitle)
.ToListAsync();
@@ -150,14 +149,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<List<MusicVideo>> GetArtistItems(int artistId)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.MusicVideos
.AsNoTracking()
.Include(mv => mv.MusicVideoMetadata)
.Include(mv => mv.MediaVersions)
.ThenInclude(mv => mv.Chapters)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(mv => mv.Artist)
.ThenInclude(a => a.ArtistMetadata)
.Filter(mv => mv.ArtistId == artistId)
@@ -7,7 +7,6 @@ using LanguageExt;
using Microsoft.EntityFrameworkCore;
using static LanguageExt.Prelude;
namespace ErsatzTV.Infrastructure.Data.Repositories
{
public class ChannelRepository : IChannelRepository
@@ -43,11 +42,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<List<Channel>> GetAll()
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.Channels
.Include(c => c.FFmpegProfile)
.Include(c => c.Artwork)
.Include(c => c.Playouts)
.ToListAsync();
}
@@ -4,7 +4,6 @@ using System.Linq;
using System.Threading.Tasks;
using Dapper;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
@@ -15,15 +14,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public class LibraryRepository : ILibraryRepository
{
private readonly IDbConnection _dbConnection;
private readonly ILocalFileSystem _localFileSystem;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public LibraryRepository(
ILocalFileSystem localFileSystem,
IDbContextFactory<TvContext> dbContextFactory,
IDbConnection dbConnection)
public LibraryRepository(IDbContextFactory<TvContext> dbContextFactory, IDbConnection dbConnection)
{
_localFileSystem = localFileSystem;
_dbContextFactory = dbContextFactory;
_dbConnection = dbConnection;
}
@@ -126,7 +120,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
},
async () =>
{
await using TvContext context = await _dbContextFactory.CreateDbContextAsync();
await using TvContext context = _dbContextFactory.CreateDbContext();
await context.LibraryFolders.AddAsync(
new LibraryFolder
{
@@ -136,23 +130,5 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
});
await context.SaveChangesAsync();
}).ToUnit();
public async Task<Unit> CleanEtagsForLibraryPath(LibraryPath libraryPath)
{
IEnumerable<string> folders = await _dbConnection.QueryAsync<string>(
@"SELECT LF.Path
FROM LibraryFolder LF
WHERE LF.LibraryPathId = @LibraryPathId",
new { LibraryPathId = libraryPath.Id });
foreach (string folder in folders.Where(f => !_localFileSystem.FolderExists(f)))
{
await _dbConnection.ExecuteAsync(
@"DELETE FROM LibraryFolder WHERE LibraryPathId = @LibraryPathId AND Path = @Path",
new { LibraryPathId = libraryPath.Id, Path = folder });
}
return Unit.Default;
}
}
}
@@ -371,8 +371,6 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(m => m.MovieMetadata)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.Chapters)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Filter(m => movieIds.Contains(m.Id))
.ToListAsync();
@@ -397,8 +395,6 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(m => m.MusicVideoMetadata)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.Chapters)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Filter(m => musicVideoIds.Contains(m.Id))
.ToListAsync();
@@ -431,8 +427,6 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(m => m.MusicVideoMetadata)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.Chapters)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Filter(m => musicVideoIds.Contains(m.Id))
.ToListAsync();
@@ -452,8 +446,6 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(m => m.OtherVideoMetadata)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.Chapters)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Filter(m => otherVideoIds.Contains(m.Id))
.ToListAsync();
@@ -473,8 +465,6 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(m => m.SongMetadata)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.Chapters)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Filter(m => songIds.Contains(m.Id))
.ToListAsync();
@@ -496,8 +486,6 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(e => e.EpisodeMetadata)
.Include(e => e.MediaVersions)
.ThenInclude(mv => mv.Chapters)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(e => e.Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
@@ -533,8 +521,6 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(e => e.EpisodeMetadata)
.Include(e => e.MediaVersions)
.ThenInclude(mv => mv.Chapters)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(e => e.Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
@@ -568,8 +554,6 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(e => e.EpisodeMetadata)
.Include(e => e.MediaVersions)
.ThenInclude(mv => mv.Chapters)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(e => e.Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
@@ -3,8 +3,6 @@ using System.Data;
using System.Linq;
using System.Threading.Tasks;
using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
@@ -26,44 +24,5 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
GROUP BY LanguageCode
ORDER BY COUNT(LanguageCode) DESC")
.Map(result => result.ToList());
public async Task<List<int>> FlagFileNotFound(LibraryPath libraryPath, string path)
{
List<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT M.Id
FROM MediaItem M
INNER JOIN MediaVersion MV on M.Id = COALESCE(MovieId, MusicVideoId, OtherVideoId, SongId, EpisodeId)
INNER JOIN MediaFile MF on MV.Id = MF.MediaVersionId
WHERE M.LibraryPathId = @LibraryPathId AND MF.Path = @Path",
new { LibraryPathId = libraryPath.Id, Path = path })
.Map(result => result.ToList());
await _dbConnection.ExecuteAsync(
@"UPDATE MediaItem SET State = 1 WHERE Id IN @Ids",
new { Ids = ids });
return ids;
}
public async Task<Unit> FlagNormal(MediaItem mediaItem)
{
mediaItem.State = MediaItemState.Normal;
return await _dbConnection.ExecuteAsync(
@"UPDATE MediaItem SET State = 0 WHERE Id = @Id",
new { mediaItem.Id }).ToUnit();
}
public async Task<Either<BaseError, Unit>> DeleteItems(List<int> mediaItemIds)
{
foreach (int mediaItemId in mediaItemIds)
{
await _dbConnection.ExecuteAsync(
"DELETE FROM MediaItem WHERE Id = @Id",
new { Id = mediaItemId });
}
return Unit.Default;
}
}
}
@@ -4,7 +4,6 @@ using System.Linq;
using System.Threading.Tasks;
using Dapper;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
@@ -111,17 +110,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
}
public async Task<bool> UpdateLocalStatistics(
MediaItem mediaItem,
int mediaVersionId,
MediaVersion incoming,
bool updateVersion = true)
{
int mediaVersionId = mediaItem.GetHeadVersion().Id;
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<MediaVersion> maybeVersion = await dbContext.MediaVersions
.Include(v => v.Streams)
.Include(v => v.Chapters)
.Include(v => v.MediaFiles)
.OrderBy(v => v.Id)
.SingleOrDefaultAsync(v => v.Id == mediaVersionId)
.Map(Optional);
@@ -195,37 +191,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
existingChapter.Title = incomingChapter.Title;
}
if (await dbContext.SaveChangesAsync() <= 0)
{
return false;
}
// reload the media versions so we can properly index the duration
switch (mediaItem)
{
case Movie movie:
movie.MediaVersions.Clear();
movie.MediaVersions.Add(existing);
break;
case Episode episode:
episode.MediaVersions.Clear();
episode.MediaVersions.Add(existing);
break;
case MusicVideo musicVideo:
musicVideo.MediaVersions.Clear();
musicVideo.MediaVersions.Add(existing);
break;
case OtherVideo otherVideo:
otherVideo.MediaVersions.Clear();
otherVideo.MediaVersions.Add(existing);
break;
case Song song:
song.MediaVersions.Clear();
song.MediaVersions.Add(existing);
break;
}
return true;
return await dbContext.SaveChangesAsync() > 0;
},
() => Task.FromResult(false));
}
@@ -243,52 +209,51 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public Task<Unit> UpdateArtworkPath(Artwork artwork) =>
_dbConnection.ExecuteAsync(
"UPDATE Artwork SET Path = @Path, SourcePath = @SourcePath, DateUpdated = @DateUpdated, BlurHash43 = @BlurHash43, BlurHash54 = @BlurHash54, BlurHash64 = @BlurHash64 WHERE Id = @Id",
new { artwork.Path, artwork.SourcePath, artwork.DateUpdated, artwork.BlurHash43, artwork.BlurHash54, artwork.BlurHash64, artwork.Id }).ToUnit();
"UPDATE Artwork SET Path = @Path, DateUpdated = @DateUpdated WHERE Id = @Id",
new { artwork.Path, artwork.DateUpdated, artwork.Id }).ToUnit();
public Task<Unit> AddArtwork(Metadata metadata, Artwork artwork)
{
var parameters = new
{
artwork.ArtworkKind, metadata.Id, artwork.DateAdded, artwork.DateUpdated, artwork.Path,
artwork.SourcePath, artwork.BlurHash43, artwork.BlurHash54, artwork.BlurHash64
artwork.ArtworkKind, metadata.Id, artwork.DateAdded, artwork.DateUpdated, artwork.Path
};
return metadata switch
{
MovieMetadata => _dbConnection.ExecuteAsync(
@"INSERT INTO Artwork (ArtworkKind, MovieMetadataId, DateAdded, DateUpdated, Path, SourcePath, BlurHash43, BlurHash54, BlurHash64)
VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path, @SourcePath, @BlurHash43, @BlurHash54, @BlurHash64)",
@"INSERT INTO Artwork (ArtworkKind, MovieMetadataId, DateAdded, DateUpdated, Path)
VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path)",
parameters)
.ToUnit(),
ShowMetadata => _dbConnection.ExecuteAsync(
@"INSERT INTO Artwork (ArtworkKind, ShowMetadataId, DateAdded, DateUpdated, Path, SourcePath, BlurHash43, BlurHash54, BlurHash64)
VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path, @SourcePath, @BlurHash43, @BlurHash54, @BlurHash64)",
@"INSERT INTO Artwork (ArtworkKind, ShowMetadataId, DateAdded, DateUpdated, Path)
VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path)",
parameters)
.ToUnit(),
SeasonMetadata => _dbConnection.ExecuteAsync(
@"INSERT INTO Artwork (ArtworkKind, SeasonMetadataId, DateAdded, DateUpdated, Path, SourcePath, BlurHash43, BlurHash54, BlurHash64)
VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path, @SourcePath, @BlurHash43, @BlurHash54, @BlurHash64)",
@"INSERT INTO Artwork (ArtworkKind, SeasonMetadataId, DateAdded, DateUpdated, Path)
VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path)",
parameters)
.ToUnit(),
EpisodeMetadata => _dbConnection.ExecuteAsync(
@"INSERT INTO Artwork (ArtworkKind, EpisodeMetadataId, DateAdded, DateUpdated, Path, SourcePath, BlurHash43, BlurHash54, BlurHash64)
VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path, @SourcePath, @BlurHash43, @BlurHash54, @BlurHash64)",
@"INSERT INTO Artwork (ArtworkKind, EpisodeMetadataId, DateAdded, DateUpdated, Path)
VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path)",
parameters)
.ToUnit(),
ArtistMetadata => _dbConnection.ExecuteAsync(
@"INSERT INTO Artwork (ArtworkKind, ArtistMetadataId, DateAdded, DateUpdated, Path, SourcePath, BlurHash43, BlurHash54, BlurHash64)
Values (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path, @SourcePath, @BlurHash43, @BlurHash54, @BlurHash64)",
@"INSERT INTO Artwork (ArtworkKind, ArtistMetadataId, DateAdded, DateUpdated, Path)
Values (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path)",
parameters)
.ToUnit(),
MusicVideoMetadata => _dbConnection.ExecuteAsync(
@"INSERT INTO Artwork (ArtworkKind, MusicVideoMetadataId, DateAdded, DateUpdated, Path, SourcePath, BlurHash43, BlurHash54, BlurHash64)
VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path, @SourcePath, @BlurHash43, @BlurHash54, @BlurHash64)",
@"INSERT INTO Artwork (ArtworkKind, MusicVideoMetadataId, DateAdded, DateUpdated, Path)
VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path)",
parameters)
.ToUnit(),
SongMetadata => _dbConnection.ExecuteAsync(
@"INSERT INTO Artwork (ArtworkKind, SongMetadataId, DateAdded, DateUpdated, Path, SourcePath, BlurHash43, BlurHash54, BlurHash64)
VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path, @SourcePath, @BlurHash43, @BlurHash54, @BlurHash64)",
@"INSERT INTO Artwork (ArtworkKind, SongMetadataId, DateAdded, DateUpdated, Path)
VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path)",
parameters)
.ToUnit(),
_ => Task.FromResult(Unit.Default)
@@ -301,52 +266,6 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
OR ShowMetadataId = @Id OR SeasonMetadataId = @Id OR EpisodeMetadataId = @Id)",
new { ArtworkKind = artworkKind, metadata.Id }).ToUnit();
public async Task<bool> CloneArtwork(
Metadata metadata,
Option<Artwork> maybeArtwork,
ArtworkKind artworkKind,
string sourcePath,
DateTime lastWriteTime)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<Artwork> maybeExisting = await dbContext.Artwork
.AsNoTracking()
.Filter(
a => a.SourcePath == sourcePath && a.ArtworkKind == artworkKind && a.DateUpdated == lastWriteTime)
.FirstOrDefaultAsync()
.Map(Optional);
foreach (Artwork existing in maybeExisting)
{
Artwork artwork = await maybeArtwork.IfNoneAsync(new Artwork());
if (maybeArtwork.IsNone)
{
metadata.Artwork.Add(artwork);
}
artwork.Path = existing.Path;
artwork.SourcePath = existing.SourcePath;
artwork.ArtworkKind = artworkKind;
artwork.BlurHash43 = existing.BlurHash43;
artwork.BlurHash54 = existing.BlurHash54;
artwork.BlurHash64 = existing.BlurHash64;
artwork.DateAdded = existing.DateAdded;
artwork.DateUpdated = existing.DateUpdated;
if (maybeArtwork.IsNone)
{
await AddArtwork(metadata, artwork);
}
else
{
await UpdateArtworkPath(artwork);
}
return true;
}
return false;
}
public Task<Unit> MarkAsUpdated(ShowMetadata metadata, DateTime dateUpdated) =>
_dbConnection.ExecuteAsync(
@"UPDATE ShowMetadata SET DateUpdated = @DateUpdated WHERE Id = @Id",
@@ -56,8 +56,6 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(mm => mm.Writers)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.OrderBy(m => m.Id)
.SingleOrDefaultAsync(m => m.Id == movieId)
.Map(Optional);
@@ -65,7 +63,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<Either<BaseError, MediaItemScanResult<Movie>>> GetOrAdd(LibraryPath libraryPath, string path)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Option<Movie> maybeExisting = await dbContext.Movies
.Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Artwork)
@@ -148,13 +146,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<List<MovieMetadata>> GetMoviesForCards(List<int> ids)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.MovieMetadata
.AsNoTracking()
.Filter(mm => ids.Contains(mm.MovieId))
.Include(mm => mm.Artwork)
.OrderBy(mm => mm.SortTitle)
.Include(mm => mm.Movie)
.ToListAsync();
}
@@ -170,7 +167,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<List<int>> DeleteByPath(LibraryPath libraryPath, string path)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
List<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT M.Id
FROM Movie M
@@ -184,16 +181,13 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
foreach (int movieId in ids)
{
Movie movie = await dbContext.Movies.FindAsync(movieId);
if (movie != null)
{
dbContext.Movies.Remove(movie);
}
dbContext.Movies.Remove(movie);
}
bool changed = await dbContext.SaveChangesAsync() > 0;
return changed ? ids : new List<int>();
}
public Task<bool> AddGenre(MovieMetadata metadata, Genre genre) =>
_dbConnection.ExecuteAsync(
"INSERT INTO Genre (Name, MovieMetadataId) VALUES (@Name, @MetadataId)",
@@ -30,11 +30,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
LibraryPath libraryPath,
string path)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Option<MusicVideo> maybeExisting = await dbContext.MusicVideos
.AsNoTracking()
.Include(mv => mv.Artist)
.ThenInclude(a => a.ArtistMetadata)
.Include(mv => mv.MusicVideoMetadata)
.ThenInclude(mvm => mvm.Artwork)
.Include(mv => mv.MusicVideoMetadata)
@@ -94,21 +92,18 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE MI.LibraryPathId = @LibraryPathId AND MF.Path = @Path",
new { LibraryPathId = libraryPath.Id, Path = path }).Map(result => result.ToList());
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
foreach (int musicVideoId in ids)
{
MusicVideo musicVideo = await dbContext.MusicVideos.FindAsync(musicVideoId);
if (musicVideo != null)
{
dbContext.MusicVideos.Remove(musicVideo);
}
dbContext.MusicVideos.Remove(musicVideo);
}
await dbContext.SaveChangesAsync();
return ids;
}
public Task<bool> AddGenre(MusicVideoMetadata metadata, Genre genre) =>
_dbConnection.ExecuteAsync(
"INSERT INTO Genre (Name, MusicVideoMetadataId) VALUES (@Name, @MetadataId)",
@@ -126,7 +121,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<List<MusicVideoMetadata>> GetMusicVideosForCards(List<int> ids)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.MusicVideoMetadata
.AsNoTracking()
.Filter(mvm => ids.Contains(mvm.MusicVideoId))
@@ -166,9 +161,6 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(m => m.MusicVideo)
.ThenInclude(mv => mv.Artist)
.ThenInclude(a => a.ArtistMetadata)
.Include(m => m.MusicVideo)
.ThenInclude(mv => mv.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Filter(m => m.MusicVideo.ArtistId == artistId)
.OrderBy(m => m.SortTitle)
.Skip((pageNumber - 1) * pageSize)
@@ -204,8 +196,6 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
await dbContext.MusicVideos.AddAsync(musicVideo);
await dbContext.SaveChangesAsync();
await dbContext.Entry(musicVideo).Reference(m => m.Artist).LoadAsync();
await dbContext.Entry(musicVideo.Artist).Collection(a => a.ArtistMetadata).LoadAsync();
await dbContext.Entry(musicVideo).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(musicVideo.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return new MediaItemScanResult<MusicVideo>(musicVideo) { IsAdded = true };
@@ -83,8 +83,6 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(mm => mm.Studios)
.Include(mi => (mi as Show).ShowMetadata)
.ThenInclude(mm => mm.Actors)
.Include(mi => (mi as MusicVideo).Artist)
.ThenInclude(mm => mm.ArtistMetadata)
.Include(mi => (mi as MusicVideo).MusicVideoMetadata)
.ThenInclude(mm => mm.Genres)
.Include(mi => (mi as MusicVideo).MusicVideoMetadata)
@@ -45,7 +45,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<List<Show>> GetAllShows()
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.Shows
.AsNoTracking()
.Include(s => s.ShowMetadata)
@@ -55,7 +55,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<Option<Show>> GetShow(int showId)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.Shows
.AsNoTracking()
.Filter(s => s.Id == showId)
@@ -77,19 +77,18 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<List<ShowMetadata>> GetShowsForCards(List<int> ids)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.ShowMetadata
.AsNoTracking()
.Filter(sm => ids.Contains(sm.ShowId))
.Include(sm => sm.Artwork)
.Include(sm => sm.Show)
.OrderBy(sm => sm.SortTitle)
.ToListAsync();
}
public async Task<List<SeasonMetadata>> GetSeasonsForCards(List<int> ids)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.SeasonMetadata
.AsNoTracking()
.Filter(s => ids.Contains(s.SeasonId))
@@ -106,7 +105,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<List<EpisodeMetadata>> GetEpisodesForCards(List<int> ids)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.EpisodeMetadata
.AsNoTracking()
.Filter(em => ids.Contains(em.EpisodeId))
@@ -122,16 +121,13 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(em => em.Episode)
.ThenInclude(e => e.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.OrderBy(em => em.SortTitle)
.ToListAsync();
}
public async Task<List<Season>> GetAllSeasons()
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.Seasons
.AsNoTracking()
.Include(s => s.SeasonMetadata)
@@ -144,7 +140,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<Option<Season>> GetSeason(int seasonId)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.Seasons
.AsNoTracking()
.Include(s => s.SeasonMetadata)
@@ -159,7 +155,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<int> GetSeasonCount(int showId)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.Seasons
.AsNoTracking()
.CountAsync(s => s.ShowId == showId);
@@ -175,7 +171,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
new { ShowId = televisionShowId })
.Map(results => results.ToList());
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.Seasons
.AsNoTracking()
.Where(s => showIds.Contains(s.ShowId))
@@ -191,7 +187,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<int> GetEpisodeCount(int seasonId)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.Episodes
.AsNoTracking()
.CountAsync(e => e.SeasonId == seasonId);
@@ -199,7 +195,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<List<EpisodeMetadata>> GetPagedEpisodes(int seasonId, int pageNumber, int pageSize)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.EpisodeMetadata
.AsNoTracking()
.Filter(em => em.Episode.SeasonId == seasonId)
@@ -212,9 +208,6 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Actors)
.ThenInclude(a => a.Artwork)
.Include(em => em.Episode)
.ThenInclude(e => e.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.OrderBy(em => em.EpisodeNumber)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
@@ -223,7 +216,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<Option<Show>> GetShowByMetadata(int libraryPathId, ShowMetadata metadata)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Option<int> maybeId = await dbContext.ShowMetadata
.Where(s => s.Title == metadata.Title && s.Year == metadata.Year)
.Where(s => s.Show.LibraryPathId == libraryPathId)
@@ -265,7 +258,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
string showFolder,
ShowMetadata metadata)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
try
{
@@ -298,7 +291,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<Either<BaseError, Season>> GetOrAddSeason(Show show, int libraryPathId, int seasonNumber)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Option<Season> maybeExisting = await dbContext.Seasons
.Include(s => s.SeasonMetadata)
.ThenInclude(sm => sm.Artwork)
@@ -322,7 +315,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
LibraryPath libraryPath,
string path)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Option<Episode> maybeExisting = await dbContext.Episodes
.Include(i => i.EpisodeMetadata)
.ThenInclude(em => em.Artwork)
@@ -394,14 +387,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE MI.LibraryPathId = @LibraryPathId AND MF.Path = @Path",
new { LibraryPathId = libraryPath.Id, Path = path });
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
foreach (int episodeId in ids)
{
Episode episode = await dbContext.Episodes.FindAsync(episodeId);
if (episode != null)
{
dbContext.Episodes.Remove(episode);
}
dbContext.Episodes.Remove(episode);
}
await dbContext.SaveChangesAsync();
@@ -411,7 +401,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<Unit> DeleteEmptySeasons(LibraryPath libraryPath)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
List<Season> seasons = await dbContext.Seasons
.Filter(s => s.LibraryPathId == libraryPath.Id)
.Filter(s => s.Episodes.Count == 0)
@@ -423,7 +413,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<List<int>> DeleteEmptyShows(LibraryPath libraryPath)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
List<Show> shows = await dbContext.Shows
.Filter(s => s.LibraryPathId == libraryPath.Id)
.Filter(s => s.Seasons.Count == 0)
@@ -438,7 +428,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
PlexLibrary library,
PlexShow item)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Option<PlexShow> maybeExisting = await dbContext.PlexShows
.AsNoTracking()
.Include(i => i.ShowMetadata)
@@ -469,7 +459,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<Either<BaseError, PlexSeason>> GetOrAddPlexSeason(PlexLibrary library, PlexSeason item)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Option<PlexSeason> maybeExisting = await dbContext.PlexSeasons
.AsNoTracking()
.Include(i => i.SeasonMetadata)
@@ -490,7 +480,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<Either<BaseError, PlexEpisode>> GetOrAddPlexEpisode(PlexLibrary library, PlexEpisode item)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Option<PlexEpisode> maybeExisting = await dbContext.PlexEpisodes
.AsNoTracking()
.Include(i => i.EpisodeMetadata)
@@ -587,14 +577,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE Show.Id = @ShowId",
new { ShowId = showId });
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.Episodes
.AsNoTracking()
.Include(e => e.EpisodeMetadata)
.Include(e => e.MediaVersions)
.ThenInclude(mv => mv.Chapters)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(e => e.Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
@@ -604,14 +592,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<List<Episode>> GetSeasonItems(int seasonId)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.Episodes
.AsNoTracking()
.Include(e => e.EpisodeMetadata)
.Include(e => e.MediaVersions)
.ThenInclude(mv => mv.Chapters)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(e => e.Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
@@ -37,7 +37,6 @@ namespace ErsatzTV.Infrastructure.Data
public DbSet<MediaStream> MediaStreams { get; set; }
public DbSet<Movie> Movies { get; set; }
public DbSet<MovieMetadata> MovieMetadata { get; set; }
public DbSet<Artwork> Artwork { get; set; }
public DbSet<Artist> Artists { get; set; }
public DbSet<ArtistMetadata> ArtistMetadata { get; set; }
public DbSet<MusicVideo> MusicVideos { get; set; }
@@ -4,20 +4,20 @@
<TargetFramework>net6.0</TargetFramework>
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
<NoWarn>VSTHRD200</NoWarn>
<DebugType>embedded</DebugType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Blurhash.ImageSharp" Version="1.1.1" />
<PackageReference Include="Dapper" Version="2.0.123" />
<PackageReference Include="Lucene.Net" Version="4.8.0-beta00015" />
<PackageReference Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00015" />
<PackageReference Include="Lucene.Net.QueryParser" Version="4.8.0-beta00015" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="6.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.1">
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="6.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.0" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.0.64">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@@ -2,8 +2,8 @@
using System.Diagnostics;
using System.Threading.Tasks;
using ErsatzTV.Core.Health;
using LanguageExt;
using Lucene.Net.Util;
using static LanguageExt.Prelude;
namespace ErsatzTV.Infrastructure.Health.Checks
{
@@ -12,25 +12,22 @@ namespace ErsatzTV.Infrastructure.Health.Checks
protected abstract string Title { get; }
protected HealthCheckResult Result(HealthCheckStatus status, string message) =>
new(Title, status, message, None);
new(Title, status, message);
protected HealthCheckResult NotApplicableResult() =>
new(Title, HealthCheckStatus.NotApplicable, string.Empty, None);
new(Title, HealthCheckStatus.NotApplicable, string.Empty);
protected HealthCheckResult OkResult() =>
new(Title, HealthCheckStatus.Pass, string.Empty, None);
new(Title, HealthCheckStatus.Pass, string.Empty);
protected HealthCheckResult FailResult(string message) =>
new(Title, HealthCheckStatus.Fail, message, None);
new(Title, HealthCheckStatus.Fail, message);
protected HealthCheckResult WarningResult(string message) =>
new(Title, HealthCheckStatus.Warning, message, None);
protected HealthCheckResult WarningResult(string message, string link) =>
new(Title, HealthCheckStatus.Warning, message, link);
new(Title, HealthCheckStatus.Warning, message);
protected HealthCheckResult InfoResult(string message) =>
new(Title, HealthCheckStatus.Info, message, None);
new(Title, HealthCheckStatus.Info, message);
protected static async Task<string> GetProcessOutput(string path, IEnumerable<string> arguments)
{
@@ -11,7 +11,7 @@ namespace ErsatzTV.Infrastructure.Health.Checks
{
public class FFmpegVersionHealthCheck : BaseHealthCheck, IFFmpegVersionHealthCheck
{
private const string BundledVersion = "N-105153-g8abc192236";
private const string BundledVersion = "N-104321-ga742ba60f1";
private readonly IConfigElementRepository _configElementRepository;
public FFmpegVersionHealthCheck(IConfigElementRepository configElementRepository)
@@ -66,7 +66,7 @@ namespace ErsatzTV.Infrastructure.Health.Checks
}
}
return new HealthCheckResult("FFmpeg Version", HealthCheckStatus.Pass, string.Empty, None);
return new HealthCheckResult("FFmpeg Version", HealthCheckStatus.Pass, string.Empty);
}
private Option<HealthCheckResult> ValidateVersion(string version, string app)
@@ -1,76 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Health;
using ErsatzTV.Core.Health.Checks;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Infrastructure.Health.Checks;
public class FileNotFoundHealthCheck : BaseHealthCheck, IFileNotFoundHealthCheck
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public FileNotFoundHealthCheck(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
protected override string Title => "File Not Found";
public async Task<HealthCheckResult> Check()
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
List<Episode> episodes = await dbContext.Episodes
.Filter(e => e.State == MediaItemState.FileNotFound)
.Include(e => e.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.ToListAsync();
List<Movie> movies = await dbContext.Movies
.Filter(m => m.State == MediaItemState.FileNotFound)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.ToListAsync();
List<MusicVideo> musicVideos = await dbContext.MusicVideos
.Filter(mv => mv.State == MediaItemState.FileNotFound)
.Include(mv => mv.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.ToListAsync();
List<OtherVideo> otherVideos = await dbContext.OtherVideos
.Filter(ov => ov.State == MediaItemState.FileNotFound)
.Include(ov => ov.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.ToListAsync();
List<Song> songs = await dbContext.Songs
.Filter(s => s.State == MediaItemState.FileNotFound)
.Include(s => s.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.ToListAsync();
var all = movies.Map(m => m.MediaVersions.Head().MediaFiles.Head().Path)
.Append(episodes.Map(e => e.MediaVersions.Head().MediaFiles.Head().Path))
.Append(musicVideos.Map(mv => mv.GetHeadVersion().MediaFiles.Head().Path))
.Append(otherVideos.Map(ov => ov.GetHeadVersion().MediaFiles.Head().Path))
.Append(songs.Map(s => s.GetHeadVersion().MediaFiles.Head().Path))
.ToList();
if (all.Any())
{
var paths = all.Take(5).ToList();
var files = string.Join(", ", paths);
return WarningResult(
$"There are {all.Count} files that do not exist on disk, including the following: {files}",
$"/search?query=state%3AFileNotFound");
}
return OkResult();
}
}
@@ -113,10 +113,11 @@ namespace ErsatzTV.Infrastructure.Health.Checks
result.Add(HardwareAccelerationKind.Nvenc);
break;
case "qsv":
result.Add(HardwareAccelerationKind.Qsv);
break;
case "videotoolbox":
result.Add(HardwareAccelerationKind.VideoToolbox);
// qsv is only supported on windows
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
result.Add(HardwareAccelerationKind.Qsv);
}
break;
}
}

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