Compare commits

...
26 Commits
Author SHA1 Message Date
Jason Dove 0440f7643b add videotoolbox acceleration (#575) 2022-01-17 15:05:23 -06:00
Jason Dove 0f4219f731 properly unlock libraries after failed scans (#574) 2022-01-14 13:03:15 -06:00
Jason Dove cbe5d47611 fix trakt list sync when show does not contain a year (#572) 2022-01-12 21:09:26 -06:00
Jason Dove afa52ccc89 add trash system for local libraries (#571)
* flag local movies as file not found

* show warning icon on cards

* unflag movie that is found during scan

* skip missing files when building playouts

* add state to search index

* add file not found health check

* link to search from file not found health check

* support flagging other media kinds as file not found

* continue to schedule missing items

* support episode files not found

* wip trash page

* fix trash url

* trash page is functional

* update changelog

* fix changelog merge
2022-01-12 20:27:53 -06:00
Jason Dove 7d1163c68f fix double-click startup on mac (#570) 2022-01-11 15:36:12 -06:00
Jason Dove 883492bd33 update changelog for release v0.3.6-alpha [no ci] 2022-01-10 19:51:01 -06:00
Jason Dove a4eac4feea properly overwrite environment variables (#567) 2022-01-08 10:01:22 -06:00
Jason Dove dab58f5840 fix tests 2022-01-07 19:18:31 -06:00
Jason Dove 176f136c23 fix some nvenc edge cases where only padding is needed for normalization (#565) 2022-01-07 18:53:28 -06:00
Jason Dove 816d77e15b update changelog [no ci] 2022-01-06 12:01:25 -06:00
Jason Dove 7c4d47a211 update changelog [no ci] 2022-01-06 10:31:18 -06:00
Jason Dove d9d2cfa8be search index fixes (#559)
* add music video artist to search index

* properly index minutes field when adding from scan

* bump search index version
2022-01-06 10:28:53 -06:00
Jason Dove 8036e46966 update streaming mode docs (#558) [no docker] 2022-01-06 09:10:12 -06:00
Jason Dove 594ce437fb rework mpeg-ts mode (#557) 2022-01-05 21:27:28 -06:00
Jason Dove 004c43f895 update changelog for release v0.3.5-alpha [no ci] 2022-01-05 09:26:58 -06:00
Jason Dove 257384ea9b fix health checks (#556)
* update bundled ffmpeg version in health check

* recognize qsv acceleration on linux

* update changelog
2022-01-05 08:29:52 -06:00
Jason Dove 637f3a0c8b update docs [no docker] 2022-01-04 22:38:52 -06:00
Jason Dove 7346808059 update dependencies (#555) 2022-01-04 22:35:14 -06:00
Jason Dove 4210d97ee2 optimize setsar filter (#553) 2022-01-02 23:47:07 -06:00
Jason Dove 6a8ecd2532 use software decoding for mpeg4 with vaapi (#550) 2022-01-02 10:59:08 -06:00
Jason Dove 9b834f7cbe update changelog for release v0.3.4-alpha [no ci] 2021-12-21 09:46:43 -06:00
Jason Dove 7b73677bad allow ffmpeg reports on windows (#547)
* enable troubleshooting reports on windows

* update changelog

* tweak changelog
2021-12-21 09:27:49 -06:00
Jason Dove 85b2a46353 update dependencies (#546) 2021-12-21 08:52:51 -06:00
Jason Dove 6f40f2cbd6 fix songs docs [no docker] 2021-12-17 08:48:40 -06:00
Jason Dove b62ee4dee9 add files from top-level folder (#541) 2021-12-14 14:27:12 -06:00
Jason Dove a6e7f192cc add jellyfin path replacement tests [no ci] 2021-12-13 06:25:37 -06:00
113 changed files with 9765 additions and 346 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout master - name: Checkout master
uses: actions/checkout@v1 uses: actions/checkout@v2
- name: Deploy docs - name: Deploy docs
uses: mhausenblas/mkdocs-deploy-gh-pages@master uses: mhausenblas/mkdocs-deploy-gh-pages@master
+1 -1
View File
@@ -47,7 +47,7 @@ jobs:
release_name="ErsatzTV-$tag-${{ matrix.target }}" release_name="ErsatzTV-$tag-${{ matrix.target }}"
# Build everything # 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: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:DebugType=Embedded /property:PublishSingleFile=true --self-contained true
# Pack files # Pack files
if [ "${{ matrix.target }}" == "win-x64" ]; then if [ "${{ matrix.target }}" == "win-x64" ]; then
+51 -1
View File
@@ -4,6 +4,53 @@ 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/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased] ## [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 ## [0.3.3-alpha] - 2021-12-12
### Fixed ### Fixed
@@ -862,7 +909,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Initial release to facilitate testing outside of Docker. - Initial release to facilitate testing outside of Docker.
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.3.3-alpha...HEAD [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.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 [0.3.2-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.3.1-alpha...v0.3.2-alpha
[0.3.1-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.3.0-alpha...v0.3.1-alpha [0.3.1-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.3.0-alpha...v0.3.1-alpha
@@ -3,7 +3,6 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net6.0</TargetFramework> <TargetFramework>net6.0</TargetFramework>
<NoWarn>VSTHRD200</NoWarn> <NoWarn>VSTHRD200</NoWarn>
<DebugType>embedded</DebugType>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@@ -1,13 +1,11 @@
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
using System.Runtime.InteropServices;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Runtime;
using LanguageExt; using LanguageExt;
namespace ErsatzTV.Application.FFmpegProfiles.Commands namespace ErsatzTV.Application.FFmpegProfiles.Commands
@@ -16,16 +14,13 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
{ {
private readonly IConfigElementRepository _configElementRepository; private readonly IConfigElementRepository _configElementRepository;
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
private readonly IRuntimeInfo _runtimeInfo;
public UpdateFFmpegSettingsHandler( public UpdateFFmpegSettingsHandler(
IConfigElementRepository configElementRepository, IConfigElementRepository configElementRepository,
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem)
IRuntimeInfo runtimeInfo)
{ {
_configElementRepository = configElementRepository; _configElementRepository = configElementRepository;
_localFileSystem = localFileSystem; _localFileSystem = localFileSystem;
_runtimeInfo = runtimeInfo;
} }
public Task<Either<BaseError, Unit>> Handle( public Task<Either<BaseError, Unit>> Handle(
@@ -36,8 +31,8 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
.Bind(v => v.ToEitherAsync()); .Bind(v => v.ToEitherAsync());
private async Task<Validation<BaseError, Unit>> Validate(UpdateFFmpegSettings request) => private async Task<Validation<BaseError, Unit>> Validate(UpdateFFmpegSettings request) =>
(await FFmpegMustExist(request), await FFprobeMustExist(request), ReportsAreNotSupportedOnWindows(request)) (await FFmpegMustExist(request), await FFprobeMustExist(request))
.Apply((_, _, _) => Unit.Default); .Apply((_, _) => Unit.Default);
private Task<Validation<BaseError, Unit>> FFmpegMustExist(UpdateFFmpegSettings request) => private Task<Validation<BaseError, Unit>> FFmpegMustExist(UpdateFFmpegSettings request) =>
ValidateToolPath(request.Settings.FFmpegPath, "ffmpeg"); ValidateToolPath(request.Settings.FFmpegPath, "ffmpeg");
@@ -45,16 +40,6 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
private Task<Validation<BaseError, Unit>> FFprobeMustExist(UpdateFFmpegSettings request) => private Task<Validation<BaseError, Unit>> FFprobeMustExist(UpdateFFmpegSettings request) =>
ValidateToolPath(request.Settings.FFprobePath, "ffprobe"); 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) private async Task<Validation<BaseError, Unit>> ValidateToolPath(string path, string name)
{ {
if (!_localFileSystem.FileExists(path)) if (!_localFileSystem.FileExists(path))
@@ -0,0 +1,9 @@
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>>;
@@ -0,0 +1,38 @@
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,5 +1,7 @@
namespace ErsatzTV.Application.MediaCards using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
{ {
public record ActorCardViewModel(int Id, string Name, string Role, string Thumb) : public record ActorCardViewModel(int Id, string Name, string Role, string Thumb, MediaItemState State) :
MediaCardViewModel(Id, Name, Role, Name, Thumb); MediaCardViewModel(Id, Name, Role, Name, Thumb, State);
} }
@@ -1,10 +1,19 @@
namespace ErsatzTV.Application.MediaCards using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
{ {
public record ArtistCardViewModel public record ArtistCardViewModel
(int ArtistId, string Title, string Subtitle, string SortTitle, string Poster) : MediaCardViewModel( (
ArtistId, int ArtistId,
Title, string Title,
Subtitle, string Subtitle,
SortTitle, string SortTitle,
Poster); string Poster,
MediaItemState State) : MediaCardViewModel(
ArtistId,
Title,
Subtitle,
SortTitle,
Poster,
State);
} }
+23 -10
View File
@@ -2,6 +2,7 @@
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby; using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Jellyfin; using ErsatzTV.Core.Jellyfin;
using LanguageExt; using LanguageExt;
using static LanguageExt.Prelude; using static LanguageExt.Prelude;
@@ -19,7 +20,8 @@ namespace ErsatzTV.Application.MediaCards
showMetadata.Title, showMetadata.Title,
showMetadata.Year?.ToString(), showMetadata.Year?.ToString(),
showMetadata.SortTitle, showMetadata.SortTitle,
GetPoster(showMetadata, maybeJellyfin, maybeEmby)); GetPoster(showMetadata, maybeJellyfin, maybeEmby),
showMetadata.Show.State);
internal static TelevisionSeasonCardViewModel ProjectToViewModel( internal static TelevisionSeasonCardViewModel ProjectToViewModel(
Season season, Season season,
@@ -34,7 +36,8 @@ namespace ErsatzTV.Application.MediaCards
GetSeasonName(season.SeasonNumber), GetSeasonName(season.SeasonNumber),
season.SeasonMetadata.HeadOrNone().Map(sm => GetPoster(sm, maybeJellyfin, maybeEmby)) season.SeasonMetadata.HeadOrNone().Map(sm => GetPoster(sm, maybeJellyfin, maybeEmby))
.IfNone(string.Empty), .IfNone(string.Empty),
season.SeasonNumber == 0 ? "S" : season.SeasonNumber.ToString()); season.SeasonNumber == 0 ? "S" : season.SeasonNumber.ToString(),
season.State);
internal static TelevisionSeasonCardViewModel ProjectToViewModel( internal static TelevisionSeasonCardViewModel ProjectToViewModel(
SeasonMetadata seasonMetadata, SeasonMetadata seasonMetadata,
@@ -53,7 +56,8 @@ namespace ErsatzTV.Application.MediaCards
GetSeasonName(seasonMetadata.Season.SeasonNumber), GetSeasonName(seasonMetadata.Season.SeasonNumber),
$"{showTitle}_{seasonMetadata.Season.SeasonNumber:0000}", $"{showTitle}_{seasonMetadata.Season.SeasonNumber:0000}",
GetPoster(seasonMetadata, maybeJellyfin, maybeEmby), GetPoster(seasonMetadata, maybeJellyfin, maybeEmby),
seasonMetadata.Season.SeasonNumber == 0 ? "S" : seasonMetadata.Season.SeasonNumber.ToString()); seasonMetadata.Season.SeasonNumber == 0 ? "S" : seasonMetadata.Season.SeasonNumber.ToString(),
seasonMetadata.Season.State);
} }
internal static TelevisionEpisodeCardViewModel ProjectToViewModel( internal static TelevisionEpisodeCardViewModel ProjectToViewModel(
@@ -80,7 +84,9 @@ namespace ErsatzTV.Application.MediaCards
? GetEpisodePoster(episodeMetadata, maybeJellyfin, maybeEmby) ? GetEpisodePoster(episodeMetadata, maybeJellyfin, maybeEmby)
: GetThumbnail(episodeMetadata, maybeJellyfin, maybeEmby), : GetThumbnail(episodeMetadata, maybeJellyfin, maybeEmby),
episodeMetadata.Directors.Map(d => d.Name).ToList(), episodeMetadata.Directors.Map(d => d.Name).ToList(),
episodeMetadata.Writers.Map(w => w.Name).ToList()); episodeMetadata.Writers.Map(w => w.Name).ToList(),
episodeMetadata.Episode.State,
episodeMetadata.Episode.GetHeadVersion().MediaFiles.Head().Path);
internal static MovieCardViewModel ProjectToViewModel( internal static MovieCardViewModel ProjectToViewModel(
MovieMetadata movieMetadata, MovieMetadata movieMetadata,
@@ -91,7 +97,8 @@ namespace ErsatzTV.Application.MediaCards
movieMetadata.Title, movieMetadata.Title,
movieMetadata.Year?.ToString(), movieMetadata.Year?.ToString(),
movieMetadata.SortTitle, movieMetadata.SortTitle,
GetPoster(movieMetadata, maybeJellyfin, maybeEmby)); GetPoster(movieMetadata, maybeJellyfin, maybeEmby),
movieMetadata.Movie.State);
internal static MusicVideoCardViewModel ProjectToViewModel(MusicVideoMetadata musicVideoMetadata) => internal static MusicVideoCardViewModel ProjectToViewModel(MusicVideoMetadata musicVideoMetadata) =>
new( new(
@@ -101,14 +108,17 @@ namespace ErsatzTV.Application.MediaCards
musicVideoMetadata.SortTitle, musicVideoMetadata.SortTitle,
musicVideoMetadata.Plot, musicVideoMetadata.Plot,
musicVideoMetadata.Album, musicVideoMetadata.Album,
GetThumbnail(musicVideoMetadata, None, None)); GetThumbnail(musicVideoMetadata, None, None),
musicVideoMetadata.MusicVideo.State,
musicVideoMetadata.MusicVideo.GetHeadVersion().MediaFiles.Head().Path);
internal static OtherVideoCardViewModel ProjectToViewModel(OtherVideoMetadata otherVideoMetadata) => internal static OtherVideoCardViewModel ProjectToViewModel(OtherVideoMetadata otherVideoMetadata) =>
new( new(
otherVideoMetadata.OtherVideoId, otherVideoMetadata.OtherVideoId,
otherVideoMetadata.Title, otherVideoMetadata.Title,
otherVideoMetadata.OriginalTitle, otherVideoMetadata.OriginalTitle,
otherVideoMetadata.SortTitle); otherVideoMetadata.SortTitle,
otherVideoMetadata.OtherVideo.State);
internal static SongCardViewModel ProjectToViewModel(SongMetadata songMetadata) internal static SongCardViewModel ProjectToViewModel(SongMetadata songMetadata)
{ {
@@ -117,7 +127,9 @@ namespace ErsatzTV.Application.MediaCards
songMetadata.SongId, songMetadata.SongId,
songMetadata.Title, songMetadata.Title,
songMetadata.Artist + album, songMetadata.Artist + album,
songMetadata.SortTitle); songMetadata.SortTitle,
GetThumbnail(songMetadata, None, None),
songMetadata.Song.State);
} }
internal static ArtistCardViewModel ProjectToViewModel(ArtistMetadata artistMetadata) => internal static ArtistCardViewModel ProjectToViewModel(ArtistMetadata artistMetadata) =>
@@ -126,7 +138,8 @@ namespace ErsatzTV.Application.MediaCards
artistMetadata.Title, artistMetadata.Title,
artistMetadata.Disambiguation, artistMetadata.Disambiguation,
artistMetadata.SortTitle, artistMetadata.SortTitle,
GetThumbnail(artistMetadata, None, None)); GetThumbnail(artistMetadata, None, None),
artistMetadata.Artist.State);
internal static CollectionCardResultsViewModel internal static CollectionCardResultsViewModel
ProjectToViewModel( ProjectToViewModel(
@@ -174,7 +187,7 @@ namespace ErsatzTV.Application.MediaCards
.SetQueryParam("maxHeight", 440); .SetQueryParam("maxHeight", 440);
} }
return new ActorCardViewModel(actor.Id, actor.Name, actor.Role, artwork); return new ActorCardViewModel(actor.Id, actor.Name, actor.Role, artwork, MediaItemState.Normal);
} }
private static int GetCustomIndex(Collection collection, int mediaItemId) => private static int GetCustomIndex(Collection collection, int mediaItemId) =>
@@ -1,4 +1,12 @@
namespace ErsatzTV.Application.MediaCards using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
{ {
public record MediaCardViewModel(int MediaItemId, string Title, string Subtitle, string SortTitle, string Poster); public record MediaCardViewModel(
int MediaItemId,
string Title,
string Subtitle,
string SortTitle,
string Poster,
MediaItemState State);
} }
@@ -1,12 +1,21 @@
namespace ErsatzTV.Application.MediaCards using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
{ {
public record MovieCardViewModel public record MovieCardViewModel
(int MovieId, string Title, string Subtitle, string SortTitle, string Poster) : MediaCardViewModel( (
MovieId, int MovieId,
Title, string Title,
Subtitle, string Subtitle,
SortTitle, string SortTitle,
Poster) string Poster,
MediaItemState State) : MediaCardViewModel(
MovieId,
Title,
Subtitle,
SortTitle,
Poster,
State)
{ {
public int CustomIndex { get; set; } public int CustomIndex { get; set; }
} }
@@ -1,4 +1,6 @@
namespace ErsatzTV.Application.MediaCards using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
{ {
public record MusicVideoCardViewModel public record MusicVideoCardViewModel
( (
@@ -8,12 +10,15 @@
string SortTitle, string SortTitle,
string Plot, string Plot,
string Album, string Album,
string Poster) : MediaCardViewModel( string Poster,
MediaItemState State,
string Path) : MediaCardViewModel(
MusicVideoId, MusicVideoId,
Title, Title,
Subtitle, Subtitle,
SortTitle, SortTitle,
Poster) Poster,
State)
{ {
public int CustomIndex { get; set; } public int CustomIndex { get; set; }
} }
@@ -1,16 +1,20 @@
namespace ErsatzTV.Application.MediaCards using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
{ {
public record OtherVideoCardViewModel public record OtherVideoCardViewModel
( (
int OtherVideoId, int OtherVideoId,
string Title, string Title,
string Subtitle, string Subtitle,
string SortTitle) : MediaCardViewModel( string SortTitle,
MediaItemState State) : MediaCardViewModel(
OtherVideoId, OtherVideoId,
Title, Title,
Subtitle, Subtitle,
SortTitle, SortTitle,
null) null,
State)
{ {
public int CustomIndex { get; set; } public int CustomIndex { get; set; }
} }
@@ -1,16 +1,21 @@
namespace ErsatzTV.Application.MediaCards using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
{ {
public record SongCardViewModel public record SongCardViewModel
( (
int SongId, int SongId,
string Title, string Title,
string Subtitle, string Subtitle,
string SortTitle) : MediaCardViewModel( string SortTitle,
string Poster,
MediaItemState State) : MediaCardViewModel(
SongId, SongId,
Title, Title,
Subtitle, Subtitle,
SortTitle, SortTitle,
null) Poster,
State)
{ {
public int CustomIndex { get; set; } public int CustomIndex { get; set; }
} }
@@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards namespace ErsatzTV.Application.MediaCards
{ {
@@ -17,10 +18,13 @@ namespace ErsatzTV.Application.MediaCards
string Plot, string Plot,
string Poster, string Poster,
List<string> Directors, List<string> Directors,
List<string> Writers) : MediaCardViewModel( List<string> Writers,
MediaItemState State,
string Path) : MediaCardViewModel(
EpisodeId, EpisodeId,
Title, Title,
$"Episode {Episode}", $"Episode {Episode}",
SortTitle, SortTitle,
Poster); Poster,
State);
} }
@@ -1,4 +1,6 @@
namespace ErsatzTV.Application.MediaCards using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
{ {
public record TelevisionSeasonCardViewModel public record TelevisionSeasonCardViewModel
( (
@@ -9,10 +11,12 @@
string Subtitle, string Subtitle,
string SortTitle, string SortTitle,
string Poster, string Poster,
string Placeholder) : MediaCardViewModel( string Placeholder,
MediaItemState State) : MediaCardViewModel(
TelevisionSeasonId, TelevisionSeasonId,
Title, Title,
Subtitle, Subtitle,
SortTitle, SortTitle,
Poster); Poster,
State);
} }
@@ -1,10 +1,19 @@
namespace ErsatzTV.Application.MediaCards using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
{ {
public record TelevisionShowCardViewModel public record TelevisionShowCardViewModel
(int TelevisionShowId, string Title, string Subtitle, string SortTitle, string Poster) : MediaCardViewModel( (
TelevisionShowId, int TelevisionShowId,
Title, string Title,
Subtitle, string Subtitle,
SortTitle, string SortTitle,
Poster); string Poster,
MediaItemState State) : MediaCardViewModel(
TelevisionShowId,
Title,
Subtitle,
SortTitle,
Poster,
State);
} }
@@ -7,7 +7,7 @@ namespace ErsatzTV.Application.MediaCollections
internal static class Mapper internal static class Mapper
{ {
internal static MediaCollectionViewModel ProjectToViewModel(Collection collection) => internal static MediaCollectionViewModel ProjectToViewModel(Collection collection) =>
new(collection.Id, collection.Name, collection.UseCustomPlaybackOrder); new(collection.Id, collection.Name, collection.UseCustomPlaybackOrder, MediaItemState.Normal);
internal static MultiCollectionViewModel ProjectToViewModel(MultiCollection multiCollection) => internal static MultiCollectionViewModel ProjectToViewModel(MultiCollection multiCollection) =>
new( new(
@@ -1,11 +1,17 @@
using ErsatzTV.Application.MediaCards; using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCollections namespace ErsatzTV.Application.MediaCollections
{ {
public record MediaCollectionViewModel(int Id, string Name, bool UseCustomPlaybackOrder) : MediaCardViewModel( public record MediaCollectionViewModel(
int Id,
string Name,
bool UseCustomPlaybackOrder,
MediaItemState State) : MediaCardViewModel(
Id, Id,
Name, Name,
string.Empty, string.Empty,
Name, Name,
string.Empty); string.Empty,
State);
} }
@@ -160,15 +160,36 @@ namespace ErsatzTV.Application.MediaSources.Commands
return Unit.Default; return Unit.Default;
} }
private async Task<Validation<BaseError, RequestParameters>> Validate(IScanLocalLibrary request) => private async Task<Validation<BaseError, RequestParameters>> Validate(IScanLocalLibrary request)
(await LocalLibraryMustExist(request), await ValidateFFprobePath(), await ValidateFFmpegPath(), await ValidateLibraryRefreshInterval()) {
.Apply( Validation<BaseError, LocalLibrary> libraryResult = await LocalLibraryMustExist(request);
(library, ffprobePath, ffmpegPath, libraryRefreshInterval) => new RequestParameters( Validation<BaseError, string> ffprobePathResult = await ValidateFFprobePath();
library, Validation<BaseError, string> ffmpegPathResult = await ValidateFFmpegPath();
ffprobePath, Validation<BaseError, int> refreshIntervalResult = await ValidateLibraryRefreshInterval();
ffmpegPath,
request.ForceScan, try
libraryRefreshInterval)); {
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 Task<Validation<BaseError, LocalLibrary>> LocalLibraryMustExist( private Task<Validation<BaseError, LocalLibrary>> LocalLibraryMustExist(
IScanLocalLibrary request) => IScanLocalLibrary request) =>
+4 -1
View File
@@ -4,6 +4,7 @@ using System.Globalization;
using System.Linq; using System.Linq;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby; using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Jellyfin; using ErsatzTV.Core.Jellyfin;
using Flurl; using Flurl;
using LanguageExt; using LanguageExt;
@@ -34,7 +35,9 @@ namespace ErsatzTV.Application.Movies
.Map(a => MediaCards.Mapper.ProjectToViewModel(a, maybeJellyfin, maybeEmby)) .Map(a => MediaCards.Mapper.ProjectToViewModel(a, maybeJellyfin, maybeEmby))
.ToList(), .ToList(),
metadata.Directors.Map(d => d.Name).ToList(), metadata.Directors.Map(d => d.Name).ToList(),
metadata.Writers.Map(w => w.Name).ToList()) metadata.Writers.Map(w => w.Name).ToList(),
movie.GetHeadVersion().MediaFiles.Head().Path,
movie.State)
{ {
Poster = Artwork(metadata, ArtworkKind.Poster, maybeJellyfin, maybeEmby), Poster = Artwork(metadata, ArtworkKind.Poster, maybeJellyfin, maybeEmby),
FanArt = Artwork(metadata, ArtworkKind.FanArt, maybeJellyfin, maybeEmby) FanArt = Artwork(metadata, ArtworkKind.FanArt, maybeJellyfin, maybeEmby)
@@ -1,6 +1,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using ErsatzTV.Application.MediaCards; using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Movies namespace ErsatzTV.Application.Movies
{ {
@@ -15,7 +16,9 @@ namespace ErsatzTV.Application.Movies
List<CultureInfo> Languages, List<CultureInfo> Languages,
List<ActorCardViewModel> Actors, List<ActorCardViewModel> Actors,
List<string> Directors, List<string> Directors,
List<string> Writers) List<string> Writers,
string Path,
MediaItemState MediaItemState)
{ {
public string Poster { get; set; } public string Poster { get; set; }
public string FanArt { get; set; } public string FanArt { get; set; }
@@ -33,7 +33,7 @@ namespace ErsatzTV.Application.Movies.Queries
GetMovieById request, GetMovieById request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<JellyfinMediaSource> maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin() Option<JellyfinMediaSource> maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin()
.Map(list => list.HeadOrNone()); .Map(list => list.HeadOrNone());
@@ -23,7 +23,7 @@ namespace ErsatzTV.Application.Streaming.Queries
public async Task<Either<BaseError, PlayoutItemProcessModel>> Handle(T request, CancellationToken cancellationToken) public async Task<Either<BaseError, PlayoutItemProcessModel>> Handle(T request, CancellationToken cancellationToken)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Tuple<Channel, string>> validation = await Validate(dbContext, request); Validation<BaseError, Tuple<Channel, string>> validation = await Validate(dbContext, request);
return await validation.Match( return await validation.Match(
tuple => GetProcess(dbContext, request, tuple.Item1, tuple.Item2), tuple => GetProcess(dbContext, request, tuple.Item1, tuple.Item2),
@@ -56,7 +56,8 @@ namespace ErsatzTV.Application.Streaming.Queries
{ {
"hls-direct" => StreamingMode.HttpLiveStreamingDirect, "hls-direct" => StreamingMode.HttpLiveStreamingDirect,
"segmenter" => StreamingMode.HttpLiveStreamingSegmenter, "segmenter" => StreamingMode.HttpLiveStreamingSegmenter,
"ts" => StreamingMode.TransportStream, "ts" => StreamingMode.TransportStreamHybrid,
"ts-legacy" => StreamingMode.TransportStream,
_ => channel.StreamingMode _ => channel.StreamingMode
}; };
@@ -6,7 +6,7 @@ namespace ErsatzTV.Application.Streaming.Queries
{ {
public GetConcatProcessByChannelNumber(string scheme, string host, string channelNumber) : base( public GetConcatProcessByChannelNumber(string scheme, string host, string channelNumber) : base(
channelNumber, channelNumber,
"ts", "ts-legacy",
DateTimeOffset.Now, DateTimeOffset.Now,
false, false,
true) true)
@@ -1,11 +1,9 @@
using System; using System;
using System.Diagnostics; using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Runtime;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
using LanguageExt; using LanguageExt;
@@ -16,16 +14,13 @@ namespace ErsatzTV.Application.Streaming.Queries
public class GetConcatProcessByChannelNumberHandler : FFmpegProcessHandler<GetConcatProcessByChannelNumber> public class GetConcatProcessByChannelNumberHandler : FFmpegProcessHandler<GetConcatProcessByChannelNumber>
{ {
private readonly IFFmpegProcessService _ffmpegProcessService; private readonly IFFmpegProcessService _ffmpegProcessService;
private readonly IRuntimeInfo _runtimeInfo;
public GetConcatProcessByChannelNumberHandler( public GetConcatProcessByChannelNumberHandler(
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
IFFmpegProcessService ffmpegProcessService, IFFmpegProcessService ffmpegProcessService)
IRuntimeInfo runtimeInfo)
: base(dbContextFactory) : base(dbContextFactory)
{ {
_ffmpegProcessService = ffmpegProcessService; _ffmpegProcessService = ffmpegProcessService;
_runtimeInfo = runtimeInfo;
} }
protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess( protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess(
@@ -34,7 +29,7 @@ namespace ErsatzTV.Application.Streaming.Queries
Channel channel, Channel channel,
string ffmpegPath) string ffmpegPath)
{ {
bool saveReports = !_runtimeInfo.IsOSPlatform(OSPlatform.Windows) && await dbContext.ConfigElements bool saveReports = await dbContext.ConfigElements
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports) .GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
.Map(result => result.IfNone(false)); .Map(result => result.IfNone(false));
@@ -2,7 +2,6 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.Linq; using System.Linq;
using System.Runtime.InteropServices;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
@@ -15,7 +14,6 @@ using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Plex; using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Runtime;
using ErsatzTV.Core.Scheduling; using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
@@ -36,7 +34,6 @@ namespace ErsatzTV.Application.Streaming.Queries
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService; private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
private readonly IPlexPathReplacementService _plexPathReplacementService; private readonly IPlexPathReplacementService _plexPathReplacementService;
private readonly IRuntimeInfo _runtimeInfo;
private readonly ISongVideoGenerator _songVideoGenerator; private readonly ISongVideoGenerator _songVideoGenerator;
public GetPlayoutItemProcessByChannelNumberHandler( public GetPlayoutItemProcessByChannelNumberHandler(
@@ -49,7 +46,6 @@ namespace ErsatzTV.Application.Streaming.Queries
IMediaCollectionRepository mediaCollectionRepository, IMediaCollectionRepository mediaCollectionRepository,
ITelevisionRepository televisionRepository, ITelevisionRepository televisionRepository,
IArtistRepository artistRepository, IArtistRepository artistRepository,
IRuntimeInfo runtimeInfo,
ISongVideoGenerator songVideoGenerator) ISongVideoGenerator songVideoGenerator)
: base(dbContextFactory) : base(dbContextFactory)
{ {
@@ -61,7 +57,6 @@ namespace ErsatzTV.Application.Streaming.Queries
_mediaCollectionRepository = mediaCollectionRepository; _mediaCollectionRepository = mediaCollectionRepository;
_televisionRepository = televisionRepository; _televisionRepository = televisionRepository;
_artistRepository = artistRepository; _artistRepository = artistRepository;
_runtimeInfo = runtimeInfo;
_songVideoGenerator = songVideoGenerator; _songVideoGenerator = songVideoGenerator;
} }
@@ -142,7 +137,7 @@ namespace ErsatzTV.Application.Streaming.Queries
ffmpegPath); ffmpegPath);
} }
bool saveReports = !_runtimeInfo.IsOSPlatform(OSPlatform.Windows) && await dbContext.ConfigElements bool saveReports = await dbContext.ConfigElements
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports) .GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
.Map(result => result.IfNone(false)); .Map(result => result.IfNone(false));
@@ -0,0 +1,21 @@
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; }
}
}
@@ -0,0 +1,46 @@
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);
}
}
}
@@ -6,7 +6,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.2.0" /> <PackageReference Include="FluentAssertions" Version="6.3.0" />
<PackageReference Include="LanguageExt.Core" Version="4.0.3" /> <PackageReference Include="LanguageExt.Core" Version="4.0.3" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
@@ -16,7 +16,7 @@
</PackageReference> </PackageReference>
<PackageReference Include="Moq" Version="4.16.1" /> <PackageReference Include="Moq" Version="4.16.1" />
<PackageReference Include="NUnit" Version="3.13.2" /> <PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="NUnit3TestAdapter" Version="4.1.0" /> <PackageReference Include="NUnit3TestAdapter" Version="4.2.0" />
<PackageReference Include="Serilog" Version="2.10.0" /> <PackageReference Include="Serilog" Version="2.10.0" />
<PackageReference Include="Serilog.Extensions.Logging" Version="3.1.0" /> <PackageReference Include="Serilog.Extensions.Logging" Version="3.1.0" />
<PackageReference Include="Serilog.Sinks.Debug" Version="2.0.0" /> <PackageReference Include="Serilog.Sinks.Debug" Version="2.0.0" />
@@ -297,37 +297,37 @@ namespace ErsatzTV.Core.Tests.FFmpeg
true, true,
true, true,
false, false,
"[0:0]deinterlace_qsv,scale_qsv=w=1920:h=1000,hwdownload,format=nv12,setsar=1,hwupload=extra_hw_frames=64[v]", "[0:0]deinterlace_qsv,scale_qsv=w=1920:h=1000,setsar=1[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
true, true,
false, false,
true, true,
"[0:0]deinterlace_qsv,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]", "[0:0]deinterlace_qsv,setsar=1,hwdownload,format=nv12,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
true, true,
true, true,
true, true,
"[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]", "[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]",
"[v]")] "[v]")]
[TestCase( [TestCase(
false, false,
true, true,
false, false,
"[0:0]scale_qsv=w=1920:h=1000,hwdownload,format=nv12,setsar=1,hwupload=extra_hw_frames=64[v]", "[0:0]scale_qsv=w=1920:h=1000,setsar=1[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
false, false,
false, false,
true, true,
"[0:0]hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]", "[0:0]setsar=1,hwdownload,format=nv12,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
false, false,
true, true,
true, true,
"[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]", "[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]",
"[v]")] "[v]")]
public void Should_Return_QSV_Video_Filter( public void Should_Return_QSV_Video_Filter(
bool deinterlace, bool deinterlace,
@@ -368,37 +368,37 @@ namespace ErsatzTV.Core.Tests.FFmpeg
true, true,
true, true,
false, false,
"[0:0]yadif_cuda,scale_cuda=1920:1000,hwdownload,format=nv12,setsar=1,hwupload[v]", "[0:0]yadif_cuda,scale_cuda=1920:1000,setsar=1[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
true, true,
false, false,
true, true,
"[0:0]yadif_cuda,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]", "[0:0]yadif_cuda,setsar=1,hwdownload,format=nv12,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
true, true,
true, true,
true, true,
"[0:0]yadif_cuda,scale_cuda=1920:1000,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]", "[0:0]yadif_cuda,scale_cuda=1920:1000,setsar=1,hwdownload,format=nv12,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
false, false,
true, true,
false, false,
"[0:0]scale_cuda=1920:1000,hwdownload,format=nv12,setsar=1,hwupload[v]", "[0:0]scale_cuda=1920:1000,setsar=1[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
false, false,
false, false,
true, true,
"[0:0]hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]", "[0:0]setsar=1,hwdownload,format=nv12,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
false, false,
true, true,
true, true,
"[0:0]scale_cuda=1920:1000,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]", "[0:0]scale_cuda=1920:1000,setsar=1,hwdownload,format=nv12,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")] "[v]")]
public void Should_Return_NVENC_Video_Filter( public void Should_Return_NVENC_Video_Filter(
bool deinterlace, bool deinterlace,
@@ -409,7 +409,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{ {
FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder() FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder()
.WithHardwareAcceleration(HardwareAccelerationKind.Nvenc) .WithHardwareAcceleration(HardwareAccelerationKind.Nvenc)
.WithDeinterlace(deinterlace); .WithDeinterlace(deinterlace)
.WithInputPixelFormat("h264");
if (scale) if (scale)
{ {
@@ -440,42 +441,42 @@ namespace ErsatzTV.Core.Tests.FFmpeg
true, true,
true, true,
false, false,
"[0:0]deinterlace_vaapi,scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]", "[0:0]deinterlace_vaapi,scale_vaapi=format=nv12:w=1920:h=1000,setsar=1[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
"h264", "h264",
true, true,
false, false,
true, true,
"[0:0]deinterlace_vaapi,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]", "[0:0]deinterlace_vaapi,setsar=1,hwdownload,format=nv12|vaapi,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
"h264", "h264",
true, true,
true, true,
true, true,
"[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]", "[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]",
"[v]")] "[v]")]
[TestCase( [TestCase(
"h264", "h264",
false, false,
true, true,
false, false,
"[0:0]scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]", "[0:0]scale_vaapi=format=nv12:w=1920:h=1000,setsar=1[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
"h264", "h264",
false, false,
false, false,
true, true,
"[0:0]hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]", "[0:0]setsar=1,hwdownload,format=nv12|vaapi,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
"h264", "h264",
false, false,
true, true,
true, true,
"[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]", "[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]",
"[v]")] "[v]")]
[TestCase("mpeg4", true, false, false, "[0:0]hwupload,deinterlace_vaapi[v]", "[v]")] [TestCase("mpeg4", true, false, false, "[0:0]hwupload,deinterlace_vaapi[v]", "[v]")]
[TestCase( [TestCase(
@@ -483,28 +484,28 @@ namespace ErsatzTV.Core.Tests.FFmpeg
true, true,
true, true,
false, false,
"[0:0]hwupload,deinterlace_vaapi,scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]", "[0:0]hwupload,deinterlace_vaapi,scale_vaapi=format=nv12:w=1920:h=1000,setsar=1[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
"mpeg4", "mpeg4",
true, true,
false, false,
true, true,
"[0:0]hwupload,deinterlace_vaapi,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]", "[0:0]hwupload,deinterlace_vaapi,setsar=1,hwdownload,format=nv12|vaapi,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
"mpeg4", "mpeg4",
true, true,
true, true,
true, true,
"[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]", "[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]",
"[v]")] "[v]")]
[TestCase( [TestCase(
"mpeg4", "mpeg4",
false, false,
true, true,
false, false,
"[0:0]hwupload,scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]", "[0:0]hwupload,scale_vaapi=format=nv12:w=1920:h=1000,setsar=1[v]",
"[v]")] "[v]")]
[TestCase( [TestCase(
"mpeg4", "mpeg4",
@@ -518,7 +519,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
false, false,
true, true,
true, true,
"[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]", "[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]",
"[v]")] "[v]")]
public void Should_Return_VAAPI_Video_Filter( public void Should_Return_VAAPI_Video_Filter(
string codec, string codec,
+25 -6
View File
@@ -94,6 +94,17 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{ {
HardwareAccelerationKind.Vaapi HardwareAccelerationKind.Vaapi
}; };
public static string[] VideoToolboxCodecs =
{
"h264_videotoolbox",
"hevc_videotoolbox"
};
public static HardwareAccelerationKind[] VideoToolboxAcceleration =
{
HardwareAccelerationKind.VideoToolbox
};
} }
[Test, Combinatorial] [Test, Combinatorial]
@@ -104,21 +115,27 @@ namespace ErsatzTV.Core.Tests.FFmpeg
string inputPixelFormat, string inputPixelFormat,
[ValueSource(typeof(TestData), nameof(TestData.Resolutions))] [ValueSource(typeof(TestData), nameof(TestData.Resolutions))]
Resolution profileResolution, Resolution profileResolution,
[Values(true, false)]
bool pad,
// [ValueSource(typeof(TestData), nameof(TestData.SoftwareCodecs))] string profileCodec, // [ValueSource(typeof(TestData), nameof(TestData.SoftwareCodecs))] string profileCodec,
// [ValueSource(typeof(TestData), nameof(TestData.NoAcceleration))] HardwareAccelerationKind profileAcceleration) // [ValueSource(typeof(TestData), nameof(TestData.NoAcceleration))] HardwareAccelerationKind profileAcceleration)
[ValueSource(typeof(TestData), nameof(TestData.NvidiaCodecs))] string profileCodec, // [ValueSource(typeof(TestData), nameof(TestData.NvidiaCodecs))] string profileCodec,
[ValueSource(typeof(TestData), nameof(TestData.NvidiaAcceleration))] HardwareAccelerationKind profileAcceleration) // [ValueSource(typeof(TestData), nameof(TestData.NvidiaAcceleration))] HardwareAccelerationKind profileAcceleration)
// [ValueSource(typeof(TestData), nameof(TestData.VaapiCodecs))] string profileCodec, // [ValueSource(typeof(TestData), nameof(TestData.VaapiCodecs))] string profileCodec,
// [ValueSource(typeof(TestData), nameof(TestData.VaapiAcceleration))] HardwareAccelerationKind profileAcceleration) // [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( string name = GetStringSha256Hash(
$"{inputCodec}_{inputPixelFormat}_{profileResolution}_{profileCodec}_{profileAcceleration}"); $"{inputCodec}_{inputPixelFormat}_{pad}_{profileResolution}_{profileCodec}_{profileAcceleration}");
string file = Path.Combine(TestContext.CurrentContext.TestDirectory, $"{name}.mkv"); string file = Path.Combine(TestContext.CurrentContext.TestDirectory, $"{name}.mkv");
if (!File.Exists(file)) if (!File.Exists(file))
{ {
string resolution = pad ? "1920x1060" : "1920x1080";
var args = var args =
$"-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}"; $"-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}";
var p1 = new Process var p1 = new Process
{ {
StartInfo = new ProcessStartInfo StartInfo = new ProcessStartInfo
@@ -146,8 +163,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
var metadataRepository = new Mock<IMetadataRepository>(); var metadataRepository = new Mock<IMetadataRepository>();
metadataRepository metadataRepository
.Setup(r => r.UpdateLocalStatistics(It.IsAny<int>(), It.IsAny<MediaVersion>(), It.IsAny<bool>())) .Setup(r => r.UpdateLocalStatistics(It.IsAny<MediaItem>(), It.IsAny<MediaVersion>(), It.IsAny<bool>()))
.Callback<int, MediaVersion, bool>((_, version, _) => v = version); .Callback<MediaItem, MediaVersion, bool>((_, version, _) => v = version);
var localStatisticsProvider = new LocalStatisticsProvider( var localStatisticsProvider = new LocalStatisticsProvider(
metadataRepository.Object, metadataRepository.Object,
@@ -200,6 +217,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
TimeSpan.FromSeconds(5)); TimeSpan.FromSeconds(5));
process.StartInfo.RedirectStandardError = true; process.StartInfo.RedirectStandardError = true;
// Console.WriteLine($"ffmpeg arguments {string.Join(" ", process.StartInfo.ArgumentList)}");
process.Start().Should().BeTrue(); process.Start().Should().BeTrue();
@@ -44,7 +44,7 @@ namespace ErsatzTV.Core.Tests.Fakes
.IfNone(SystemTime.MinValueUtc); .IfNone(SystemTime.MinValueUtc);
public bool IsLibraryPathAccessible(LibraryPath libraryPath) => public bool IsLibraryPathAccessible(LibraryPath libraryPath) =>
_files.Any(f => f.Path.StartsWith(libraryPath.Path + Path.DirectorySeparatorChar)); _folders.Any(f => f.Path == libraryPath.Path);
public IEnumerable<string> ListSubdirectories(string folder) => public IEnumerable<string> ListSubdirectories(string folder) =>
_folders.Map(f => f.Path).Filter(f => f.StartsWith(folder) && Directory.GetParent(f)?.FullName == folder); _folders.Map(f => f.Path).Filter(f => f.StartsWith(folder) && Directory.GetParent(f)?.FullName == folder);
@@ -53,6 +53,7 @@ namespace ErsatzTV.Core.Tests.Fakes
_files.Map(f => f.Path).Filter(f => Path.GetDirectoryName(f) == folder); _files.Map(f => f.Path).Filter(f => Path.GetDirectoryName(f) == folder);
public bool FileExists(string path) => _files.Any(f => f.Path == path); 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(); public Task<byte[]> ReadAllBytes(string path) => TestBytes.AsTask();
@@ -0,0 +1,211 @@
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,7 +6,6 @@ using System.Runtime.InteropServices;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors; using ErsatzTV.Core.Errors;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images; using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
@@ -54,6 +53,10 @@ namespace ErsatzTV.Core.Tests.Metadata
_movieRepository.Setup(x => x.FindMoviePaths(It.IsAny<LibraryPath>())) _movieRepository.Setup(x => x.FindMoviePaths(It.IsAny<LibraryPath>()))
.Returns(new List<string>().AsEnumerable().AsTask()); .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>(); _localStatisticsProvider = new Mock<ILocalStatisticsProvider>();
_localMetadataProvider = new Mock<ILocalMetadataProvider>(); _localMetadataProvider = new Mock<ILocalMetadataProvider>();
@@ -73,6 +76,7 @@ namespace ErsatzTV.Core.Tests.Metadata
} }
private Mock<IMovieRepository> _movieRepository; private Mock<IMovieRepository> _movieRepository;
private Mock<IMediaItemRepository> _mediaItemRepository;
private Mock<ILocalStatisticsProvider> _localStatisticsProvider; private Mock<ILocalStatisticsProvider> _localStatisticsProvider;
private Mock<ILocalMetadataProvider> _localMetadataProvider; private Mock<ILocalMetadataProvider> _localMetadataProvider;
private Mock<IImageCache> _imageCache; private Mock<IImageCache> _imageCache;
@@ -535,6 +539,9 @@ namespace ErsatzTV.Core.Tests.Metadata
[Test] [Test]
public async Task RenamedMovie_Should_Delete_Old_Movie() 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 movieFolder = Path.Combine(FakeRoot, "Movie (2020)");
string oldMoviePath = Path.Combine(movieFolder, "Movie (2020).avi"); string oldMoviePath = Path.Combine(movieFolder, "Movie (2020).avi");
@@ -557,12 +564,14 @@ namespace ErsatzTV.Core.Tests.Metadata
result.IsRight.Should().BeTrue(); result.IsRight.Should().BeTrue();
_movieRepository.Verify(x => x.DeleteByPath(It.IsAny<LibraryPath>(), It.IsAny<string>()), Times.Once); _mediaItemRepository.Verify(
_movieRepository.Verify(x => x.DeleteByPath(libraryPath, oldMoviePath), Times.Once); x => x.FlagFileNotFound(It.IsAny<LibraryPath>(), It.IsAny<string>()),
Times.Once);
_mediaItemRepository.Verify(x => x.FlagFileNotFound(libraryPath, oldMoviePath), Times.Once);
} }
[Test] [Test]
public async Task DeletedMovieAndFolder_Should_Delete_Old_Movie() public async Task DeletedMovieAndFolder_Should_Flag_File_Not_Found()
{ {
string movieFolder = Path.Combine(FakeRoot, "Movie (2020)"); string movieFolder = Path.Combine(FakeRoot, "Movie (2020)");
string oldMoviePath = Path.Combine(movieFolder, "Movie (2020).avi"); string oldMoviePath = Path.Combine(movieFolder, "Movie (2020).avi");
@@ -570,10 +579,8 @@ namespace ErsatzTV.Core.Tests.Metadata
_movieRepository.Setup(x => x.FindMoviePaths(It.IsAny<LibraryPath>())) _movieRepository.Setup(x => x.FindMoviePaths(It.IsAny<LibraryPath>()))
.Returns(new List<string> { oldMoviePath }.AsEnumerable().AsTask()); .Returns(new List<string> { oldMoviePath }.AsEnumerable().AsTask());
string moviePath = Path.Combine(movieFolder, "Movie (2020).mkv");
MovieFolderScanner service = GetService( MovieFolderScanner service = GetService(
new FakeFileEntry(moviePath) { LastWriteTime = DateTime.Now } new FakeFolderEntry(FakeRoot)
); );
var libraryPath = new LibraryPath var libraryPath = new LibraryPath
{ Id = 1, Path = FakeRoot, LibraryFolders = new List<LibraryFolder>() }; { Id = 1, Path = FakeRoot, LibraryFolders = new List<LibraryFolder>() };
@@ -586,11 +593,12 @@ namespace ErsatzTV.Core.Tests.Metadata
result.IsRight.Should().BeTrue(); result.IsRight.Should().BeTrue();
_movieRepository.Verify(x => x.DeleteByPath(It.IsAny<LibraryPath>(), It.IsAny<string>()), Times.Once); _mediaItemRepository.Verify(
_movieRepository.Verify(x => x.DeleteByPath(libraryPath, oldMoviePath), Times.Once); x => x.FlagFileNotFound(It.IsAny<LibraryPath>(), It.IsAny<string>()),
Times.Once);
_mediaItemRepository.Verify(x => x.FlagFileNotFound(libraryPath, oldMoviePath), Times.Once);
} }
private MovieFolderScanner GetService(params FakeFileEntry[] files) => private MovieFolderScanner GetService(params FakeFileEntry[] files) =>
new( new(
new FakeLocalFileSystem(new List<FakeFileEntry>(files)), new FakeLocalFileSystem(new List<FakeFileEntry>(files)),
@@ -602,6 +610,25 @@ namespace ErsatzTV.Core.Tests.Metadata
new Mock<ISearchIndex>().Object, new Mock<ISearchIndex>().Object,
new Mock<ISearchRepository>().Object, new Mock<ISearchRepository>().Object,
new Mock<ILibraryRepository>().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, new Mock<IMediator>().Object,
null, null,
new Mock<ITempFilePool>().Object, new Mock<ITempFilePool>().Object,
@@ -5,6 +5,7 @@
None = 0, None = 0,
Qsv = 1, Qsv = 1,
Nvenc = 2, Nvenc = 2,
Vaapi = 3 Vaapi = 3,
VideoToolbox = 4
} }
} }
+11 -11
View File
@@ -1,14 +1,14 @@
using System.Collections.Generic; using System.Collections.Generic;
namespace ErsatzTV.Core.Domain namespace ErsatzTV.Core.Domain;
public class MediaItem
{ {
public class MediaItem public int Id { get; set; }
{ public int LibraryPathId { get; set; }
public int Id { get; set; } public LibraryPath LibraryPath { get; set; }
public int LibraryPathId { get; set; } public List<Collection> Collections { get; set; }
public LibraryPath LibraryPath { get; set; } public List<CollectionItem> CollectionItems { get; set; }
public List<Collection> Collections { get; set; } public List<TraktListItem> TraktListItems { get; set; }
public List<CollectionItem> CollectionItems { get; set; } public MediaItemState State { get; set; }
public List<TraktListItem> TraktListItems { get; set; } }
}
}
@@ -0,0 +1,7 @@
namespace ErsatzTV.Core.Domain;
public enum MediaItemState
{
Normal = 0,
FileNotFound = 1
}
+3 -2
View File
@@ -4,7 +4,8 @@
{ {
TransportStream = 1, TransportStream = 1,
HttpLiveStreamingDirect = 2, HttpLiveStreamingDirect = 2,
HttpLiveStreamingHybrid = 3, // HttpLiveStreamingHybrid = 3,
HttpLiveStreamingSegmenter = 4 HttpLiveStreamingSegmenter = 4,
TransportStreamHybrid = 5
} }
} }
-1
View File
@@ -3,7 +3,6 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net6.0</TargetFramework> <TargetFramework>net6.0</TargetFramework>
<NoWarn>VSTHRD200</NoWarn> <NoWarn>VSTHRD200</NoWarn>
<DebugType>embedded</DebugType>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@@ -142,8 +142,14 @@ namespace ErsatzTV.Core.FFmpeg
bool isHardwareDecode = acceleration switch bool isHardwareDecode = acceleration switch
{ {
HardwareAccelerationKind.Vaapi => !isSong && _inputCodec != "mpeg4", 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.Nvenc => !isSong,
HardwareAccelerationKind.Qsv => !isSong, HardwareAccelerationKind.Qsv => !isSong,
HardwareAccelerationKind.VideoToolbox => false,
_ => false _ => false
}; };
@@ -164,9 +170,11 @@ namespace ErsatzTV.Core.FFmpeg
audioFilterQueue.Add($"apad=whole_dur={durationString}ms"); audioFilterQueue.Add($"apad=whole_dur={durationString}ms");
}); });
bool usesHardwareFilters = acceleration != HardwareAccelerationKind.None && !isHardwareDecode && bool usesHardwareFilters = acceleration != HardwareAccelerationKind.None &&
acceleration != HardwareAccelerationKind.VideoToolbox &&
!isHardwareDecode &&
(_deinterlace || _scaleToSize.IsSome); (_deinterlace || _scaleToSize.IsSome);
if (isSong) if (isSong)
{ {
switch (acceleration) switch (acceleration)
@@ -250,7 +258,12 @@ namespace ErsatzTV.Core.FFmpeg
}); });
bool scaleOrPad = _scaleToSize.IsSome || _padToSize.IsSome; bool scaleOrPad = _scaleToSize.IsSome || _padToSize.IsSome;
bool usesSoftwareFilters = scaleOrPad || _watermark.IsSome; bool usesSoftwareFilters = _padToSize.IsSome || _watermark.IsSome;
if (scaleOrPad && _boxBlur == false)
{
videoFilterQueue.Add("setsar=1");
}
if (usesSoftwareFilters) if (usesSoftwareFilters)
{ {
@@ -268,12 +281,7 @@ namespace ErsatzTV.Core.FFmpeg
}; };
videoFilterQueue.Add(format); videoFilterQueue.Add(format);
} }
if (scaleOrPad && _boxBlur == false)
{
videoFilterQueue.Add("setsar=1");
}
if (_boxBlur) if (_boxBlur)
{ {
videoFilterQueue.Add("boxblur=40"); videoFilterQueue.Add("boxblur=40");
@@ -71,7 +71,7 @@ namespace ErsatzTV.Core.FFmpeg
result.VideoCodec = "copy"; result.VideoCodec = "copy";
result.Deinterlace = false; result.Deinterlace = false;
break; break;
case StreamingMode.HttpLiveStreamingHybrid: case StreamingMode.TransportStreamHybrid:
case StreamingMode.HttpLiveStreamingSegmenter: case StreamingMode.HttpLiveStreamingSegmenter:
case StreamingMode.TransportStream: case StreamingMode.TransportStream:
result.HardwareAcceleration = ffmpegProfile.HardwareAcceleration; result.HardwareAcceleration = ffmpegProfile.HardwareAcceleration;
@@ -121,6 +121,11 @@ namespace ErsatzTV.Core.FFmpeg
(HardwareAccelerationKind.Qsv, "h264", _) => "h264_qsv", (HardwareAccelerationKind.Qsv, "h264", _) => "h264_qsv",
(HardwareAccelerationKind.Qsv, "hevc", _) => "hevc_qsv", (HardwareAccelerationKind.Qsv, "hevc", _) => "hevc_qsv",
(HardwareAccelerationKind.Qsv, "mpeg2video", _) => "mpeg2_qsv", (HardwareAccelerationKind.Qsv, "mpeg2video", _) => "mpeg2_qsv",
// temp disable mpeg4 hardware decoding for all vaapi
// TODO: check for codec support
(HardwareAccelerationKind.Vaapi, "mpeg4", _) => "mpeg4",
_ => null _ => null
}; };
} }
+27 -4
View File
@@ -22,6 +22,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
using System.Runtime.InteropServices;
using System.Text; using System.Text;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg;
@@ -107,6 +108,10 @@ namespace ErsatzTV.Core.FFmpeg
_arguments.Add("-hwaccel_output_format"); _arguments.Add("-hwaccel_output_format");
_arguments.Add("vaapi"); _arguments.Add("vaapi");
break; break;
case HardwareAccelerationKind.VideoToolbox:
_arguments.Add("-hwaccel");
_arguments.Add("videotoolbox");
break;
} }
_complexFilterBuilder = _complexFilterBuilder.WithHardwareAcceleration(hwAccel); _complexFilterBuilder = _complexFilterBuilder.WithHardwareAcceleration(hwAccel);
@@ -201,6 +206,13 @@ namespace ErsatzTV.Core.FFmpeg
return this; return this;
} }
public FFmpegProcessBuilder WithCopyCodec()
{
_arguments.Add("-c");
_arguments.Add("copy");
return this;
}
public FFmpegProcessBuilder WithWatermark( public FFmpegProcessBuilder WithWatermark(
Option<WatermarkOptions> watermarkOptions, Option<WatermarkOptions> watermarkOptions,
IDisplaySize resolution) IDisplaySize resolution)
@@ -625,13 +637,13 @@ namespace ErsatzTV.Core.FFmpeg
switch (_vaapiDriver) switch (_vaapiDriver)
{ {
case VaapiDriver.i965: case VaapiDriver.i965:
startInfo.EnvironmentVariables.Add("LIBVA_DRIVER_NAME", "i965"); startInfo.EnvironmentVariables["LIBVA_DRIVER_NAME"] = "i965";
break; break;
case VaapiDriver.iHD: case VaapiDriver.iHD:
startInfo.EnvironmentVariables.Add("LIBVA_DRIVER_NAME", "iHD"); startInfo.EnvironmentVariables["LIBVA_DRIVER_NAME"] = "iHD";
break; break;
case VaapiDriver.RadeonSI: case VaapiDriver.RadeonSI:
startInfo.EnvironmentVariables.Add("LIBVA_DRIVER_NAME", "radeonsi"); startInfo.EnvironmentVariables["LIBVA_DRIVER_NAME"] = "radeonsi";
break; break;
} }
} }
@@ -641,7 +653,18 @@ namespace ErsatzTV.Core.FFmpeg
string fileName = _isConcat string fileName = _isConcat
? Path.Combine(FileSystemLayout.FFmpegReportsFolder, "ffmpeg-%t-concat.log") ? Path.Combine(FileSystemLayout.FFmpegReportsFolder, "ffmpeg-%t-concat.log")
: Path.Combine(FileSystemLayout.FFmpegReportsFolder, "ffmpeg-%t-transcode.log"); : Path.Combine(FileSystemLayout.FFmpegReportsFolder, "ffmpeg-%t-transcode.log");
startInfo.EnvironmentVariables.Add("FFREPORT", $"file={fileName}:level=32");
// 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.ArgumentList.Add("-nostdin"); startInfo.ArgumentList.Add("-nostdin");
@@ -247,6 +247,24 @@ namespace ErsatzTV.Core.FFmpeg
.Build(); .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) public Process ConvertToPng(string ffmpegPath, string inputFile, string outputFile)
{ {
return new FFmpegProcessBuilder(ffmpegPath, false, _logger) return new FFmpegProcessBuilder(ffmpegPath, false, _logger)
+1 -2
View File
@@ -22,8 +22,7 @@ namespace ErsatzTV.Core.Hdhr
public string URL => _channel.StreamingMode switch public string URL => _channel.StreamingMode switch
{ {
StreamingMode.HttpLiveStreamingDirect or StreamingMode.HttpLiveStreamingHybrid => StreamingMode.HttpLiveStreamingDirect => $"{_scheme}://{_host}/iptv/channel/{_channel.Number}.m3u8",
$"{_scheme}://{_host}/iptv/channel/{_channel.Number}.m3u8",
_ => $"{_scheme}://{_host}/iptv/channel/{_channel.Number}.ts" _ => $"{_scheme}://{_host}/iptv/channel/{_channel.Number}.ts"
}; };
} }
@@ -0,0 +1,5 @@
namespace ErsatzTV.Core.Health.Checks;
public interface IFileNotFoundHealthCheck : IHealthCheck
{
}
+4 -2
View File
@@ -1,4 +1,6 @@
namespace ErsatzTV.Core.Health using LanguageExt;
namespace ErsatzTV.Core.Health
{ {
public record HealthCheckResult(string Title, HealthCheckStatus Status, string Message); public record HealthCheckResult(string Title, HealthCheckStatus Status, string Message, Option<string> Link);
} }
@@ -38,6 +38,8 @@ namespace ErsatzTV.Core.Interfaces.FFmpeg
Process ConcatChannel(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host); 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 ConvertToPng(string ffmpegPath, string inputFile, string outputFile);
Process ExtractAttachedPicAsPng(string ffmpegPath, string inputFile, int streamIndex, string outputFile); Process ExtractAttachedPicAsPng(string ffmpegPath, string inputFile, int streamIndex, string outputFile);
@@ -14,6 +14,7 @@ namespace ErsatzTV.Core.Interfaces.Metadata
IEnumerable<string> ListSubdirectories(string folder); IEnumerable<string> ListSubdirectories(string folder);
IEnumerable<string> ListFiles(string folder); IEnumerable<string> ListFiles(string folder);
bool FileExists(string path); bool FileExists(string path);
bool FolderExists(string folder);
Task<Either<BaseError, Unit>> CopyFile(string source, string destination); Task<Either<BaseError, Unit>> CopyFile(string source, string destination);
Unit EmptyFolder(string folder); Unit EmptyFolder(string folder);
} }
@@ -19,5 +19,6 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<List<int>> GetMediaIdsByLocalPath(int libraryPathId); Task<List<int>> GetMediaIdsByLocalPath(int libraryPathId);
Task DeleteLocalPath(int libraryPathId); Task DeleteLocalPath(int libraryPathId);
Task<Unit> SetEtag(LibraryPath libraryPath, Option<LibraryFolder> knownFolder, string path, string etag); Task<Unit> SetEtag(LibraryPath libraryPath, Option<LibraryFolder> knownFolder, string path, string etag);
Task<Unit> CleanEtagsForLibraryPath(LibraryPath libraryPath);
} }
} }
@@ -1,10 +1,15 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Repositories namespace ErsatzTV.Core.Interfaces.Repositories
{ {
public interface IMediaItemRepository public interface IMediaItemRepository
{ {
Task<List<string>> GetAllLanguageCodes(); 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,7 +15,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<bool> RemoveActor(Actor actor); Task<bool> RemoveActor(Actor actor);
Task<bool> Update(Domain.Metadata metadata); Task<bool> Update(Domain.Metadata metadata);
Task<bool> Add(Domain.Metadata metadata); Task<bool> Add(Domain.Metadata metadata);
Task<bool> UpdateLocalStatistics(int mediaVersionId, MediaVersion incoming, bool updateVersion = true); Task<bool> UpdateLocalStatistics(MediaItem mediaItem, MediaVersion incoming, bool updateVersion = true);
Task<bool> UpdatePlexStatistics(int mediaVersionId, MediaVersion incoming); Task<bool> UpdatePlexStatistics(int mediaVersionId, MediaVersion incoming);
Task<Unit> UpdateArtworkPath(Artwork artwork); Task<Unit> UpdateArtworkPath(Artwork artwork);
Task<Unit> AddArtwork(Domain.Metadata metadata, Artwork artwork); Task<Unit> AddArtwork(Domain.Metadata metadata, Artwork artwork);
@@ -14,6 +14,7 @@ namespace ErsatzTV.Core.Interfaces.Search
public int Version { get; } public int Version { get; }
Task<bool> Initialize(ILocalFileSystem localFileSystem); Task<bool> Initialize(ILocalFileSystem localFileSystem);
Task<Unit> Rebuild(ISearchRepository searchRepository, List<int> itemIds); 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> AddItems(ISearchRepository searchRepository, List<MediaItem> items);
Task<Unit> UpdateItems(ISearchRepository searchRepository, List<MediaItem> items); Task<Unit> UpdateItems(ISearchRepository searchRepository, List<MediaItem> items);
Task<Unit> RemoveItems(List<int> ids); Task<Unit> RemoveItems(List<int> ids);
+2 -2
View File
@@ -43,9 +43,9 @@ namespace ErsatzTV.Core.Iptv
string format = channel.StreamingMode switch string format = channel.StreamingMode switch
{ {
StreamingMode.HttpLiveStreamingDirect => "m3u8?mode=hls-direct", StreamingMode.HttpLiveStreamingDirect => "m3u8?mode=hls-direct",
StreamingMode.HttpLiveStreamingHybrid => "m3u8",
StreamingMode.HttpLiveStreamingSegmenter => "m3u8?mode=segmenter", StreamingMode.HttpLiveStreamingSegmenter => "m3u8?mode=segmenter",
_ => "ts" StreamingMode.TransportStreamHybrid => "ts",
_ => "ts?mode=legacy"
}; };
string vcodec = channel.FFmpegProfile.VideoCodec.Split("_").Head(); string vcodec = channel.FFmpegProfile.VideoCodec.Split("_").Head();
@@ -50,6 +50,8 @@ namespace ErsatzTV.Core.Metadata
public bool FileExists(string path) => File.Exists(path); 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) public async Task<Either<BaseError, Unit>> CopyFile(string source, string destination)
{ {
try try
@@ -57,11 +57,13 @@ namespace ErsatzTV.Core.Metadata
private readonly ILocalStatisticsProvider _localStatisticsProvider; private readonly ILocalStatisticsProvider _localStatisticsProvider;
private readonly ILogger _logger; private readonly ILogger _logger;
private readonly IMetadataRepository _metadataRepository; private readonly IMetadataRepository _metadataRepository;
private readonly IMediaItemRepository _mediaItemRepository;
protected LocalFolderScanner( protected LocalFolderScanner(
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
ILocalStatisticsProvider localStatisticsProvider, ILocalStatisticsProvider localStatisticsProvider,
IMetadataRepository metadataRepository, IMetadataRepository metadataRepository,
IMediaItemRepository mediaItemRepository,
IImageCache imageCache, IImageCache imageCache,
IFFmpegProcessService ffmpegProcessService, IFFmpegProcessService ffmpegProcessService,
ITempFilePool tempFilePool, ITempFilePool tempFilePool,
@@ -70,6 +72,7 @@ namespace ErsatzTV.Core.Metadata
_localFileSystem = localFileSystem; _localFileSystem = localFileSystem;
_localStatisticsProvider = localStatisticsProvider; _localStatisticsProvider = localStatisticsProvider;
_metadataRepository = metadataRepository; _metadataRepository = metadataRepository;
_mediaItemRepository = mediaItemRepository;
_imageCache = imageCache; _imageCache = imageCache;
_ffmpegProcessService = ffmpegProcessService; _ffmpegProcessService = ffmpegProcessService;
_tempFilePool = tempFilePool; _tempFilePool = tempFilePool;
@@ -265,6 +268,29 @@ namespace ErsatzTV.Core.Metadata
return false; 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) => protected bool ShouldIncludeFolder(string folder) =>
!Path.GetFileName(folder).StartsWith('.') && !Path.GetFileName(folder).StartsWith('.') &&
!_localFileSystem.FileExists(Path.Combine(folder, ".etvignore")); !_localFileSystem.FileExists(Path.Combine(folder, ".etvignore"));
@@ -132,7 +132,7 @@ namespace ErsatzTV.Core.Metadata
version.DateUpdated = _localFileSystem.GetLastWriteTime(filePath); version.DateUpdated = _localFileSystem.GetLastWriteTime(filePath);
return await _metadataRepository.UpdateLocalStatistics(mediaItemVersion.Id, version) && durationChange; return await _metadataRepository.UpdateLocalStatistics(mediaItem, version) && durationChange;
} }
private Task<Either<BaseError, FFprobe>> GetProbeOutput(string ffprobePath, string filePath) private Task<Either<BaseError, FFprobe>> GetProbeOutput(string ffprobePath, string filePath)
+9 -4
View File
@@ -40,6 +40,7 @@ namespace ErsatzTV.Core.Metadata
ISearchIndex searchIndex, ISearchIndex searchIndex,
ISearchRepository searchRepository, ISearchRepository searchRepository,
ILibraryRepository libraryRepository, ILibraryRepository libraryRepository,
IMediaItemRepository mediaItemRepository,
IMediator mediator, IMediator mediator,
IFFmpegProcessService ffmpegProcessService, IFFmpegProcessService ffmpegProcessService,
ITempFilePool tempFilePool, ITempFilePool tempFilePool,
@@ -48,6 +49,7 @@ namespace ErsatzTV.Core.Metadata
localFileSystem, localFileSystem,
localStatisticsProvider, localStatisticsProvider,
metadataRepository, metadataRepository,
mediaItemRepository,
imageCache, imageCache,
ffmpegProcessService, ffmpegProcessService,
tempFilePool, tempFilePool,
@@ -140,7 +142,8 @@ namespace ErsatzTV.Core.Metadata
.BindT(movie => UpdateStatistics(movie, ffprobePath)) .BindT(movie => UpdateStatistics(movie, ffprobePath))
.BindT(UpdateMetadata) .BindT(UpdateMetadata)
.BindT(movie => UpdateArtwork(movie, ArtworkKind.Poster)) .BindT(movie => UpdateArtwork(movie, ArtworkKind.Poster))
.BindT(movie => UpdateArtwork(movie, ArtworkKind.FanArt)); .BindT(movie => UpdateArtwork(movie, ArtworkKind.FanArt))
.BindT(FlagNormal);
await maybeMovie.Match( await maybeMovie.Match(
async result => async result =>
@@ -168,9 +171,9 @@ namespace ErsatzTV.Core.Metadata
{ {
if (!_localFileSystem.FileExists(path)) if (!_localFileSystem.FileExists(path))
{ {
_logger.LogInformation("Removing missing movie at {Path}", path); _logger.LogInformation("Flagging missing movie at {Path}", path);
List<int> ids = await _movieRepository.DeleteByPath(libraryPath, path); List<int> ids = await FlagFileNotFound(libraryPath, path);
await _searchIndex.RemoveItems(ids); await _searchIndex.RebuildItems(_searchRepository, ids);
} }
else if (Path.GetFileName(path).StartsWith("._")) else if (Path.GetFileName(path).StartsWith("._"))
{ {
@@ -180,6 +183,8 @@ namespace ErsatzTV.Core.Metadata
} }
} }
await _libraryRepository.CleanEtagsForLibraryPath(libraryPath);
_searchIndex.Commit(); _searchIndex.Commit();
return Unit.Default; return Unit.Default;
} }
@@ -41,6 +41,7 @@ namespace ErsatzTV.Core.Metadata
IArtistRepository artistRepository, IArtistRepository artistRepository,
IMusicVideoRepository musicVideoRepository, IMusicVideoRepository musicVideoRepository,
ILibraryRepository libraryRepository, ILibraryRepository libraryRepository,
IMediaItemRepository mediaItemRepository,
IMediator mediator, IMediator mediator,
IFFmpegProcessService ffmpegProcessService, IFFmpegProcessService ffmpegProcessService,
ITempFilePool tempFilePool, ITempFilePool tempFilePool,
@@ -48,6 +49,7 @@ namespace ErsatzTV.Core.Metadata
localFileSystem, localFileSystem,
localStatisticsProvider, localStatisticsProvider,
metadataRepository, metadataRepository,
mediaItemRepository,
imageCache, imageCache,
ffmpegProcessService, ffmpegProcessService,
tempFilePool, tempFilePool,
@@ -135,9 +137,9 @@ namespace ErsatzTV.Core.Metadata
{ {
if (!_localFileSystem.FileExists(path)) if (!_localFileSystem.FileExists(path))
{ {
_logger.LogInformation("Removing missing music video at {Path}", path); _logger.LogInformation("Flagging missing music video at {Path}", path);
List<int> musicVideoIds = await _musicVideoRepository.DeleteByPath(libraryPath, path); List<int> musicVideoIds = await FlagFileNotFound(libraryPath, path);
await _searchIndex.RemoveItems(musicVideoIds); await _searchIndex.RebuildItems(_searchRepository, musicVideoIds);
} }
else if (Path.GetFileName(path).StartsWith("._")) else if (Path.GetFileName(path).StartsWith("._"))
{ {
@@ -146,6 +148,8 @@ namespace ErsatzTV.Core.Metadata
await _searchIndex.RemoveItems(musicVideoIds); await _searchIndex.RemoveItems(musicVideoIds);
} }
} }
await _libraryRepository.CleanEtagsForLibraryPath(libraryPath);
List<int> artistIds = await _artistRepository.DeleteEmptyArtists(libraryPath); List<int> artistIds = await _artistRepository.DeleteEmptyArtists(libraryPath);
await _searchIndex.RemoveItems(artistIds); await _searchIndex.RemoveItems(artistIds);
@@ -276,7 +280,8 @@ namespace ErsatzTV.Core.Metadata
.GetOrAdd(artist, libraryPath, file) .GetOrAdd(artist, libraryPath, file)
.BindT(musicVideo => UpdateStatistics(musicVideo, ffprobePath)) .BindT(musicVideo => UpdateStatistics(musicVideo, ffprobePath))
.BindT(UpdateMetadata) .BindT(UpdateMetadata)
.BindT(UpdateThumbnail); .BindT(UpdateThumbnail)
.BindT(FlagNormal);
await maybeMusicVideo.Match( await maybeMusicVideo.Match(
async result => async result =>
@@ -40,12 +40,14 @@ namespace ErsatzTV.Core.Metadata
ISearchRepository searchRepository, ISearchRepository searchRepository,
IOtherVideoRepository otherVideoRepository, IOtherVideoRepository otherVideoRepository,
ILibraryRepository libraryRepository, ILibraryRepository libraryRepository,
IMediaItemRepository mediaItemRepository,
IFFmpegProcessService ffmpegProcessService, IFFmpegProcessService ffmpegProcessService,
ITempFilePool tempFilePool, ITempFilePool tempFilePool,
ILogger<OtherVideoFolderScanner> logger) : base( ILogger<OtherVideoFolderScanner> logger) : base(
localFileSystem, localFileSystem,
localStatisticsProvider, localStatisticsProvider,
metadataRepository, metadataRepository,
mediaItemRepository,
imageCache, imageCache,
ffmpegProcessService, ffmpegProcessService,
tempFilePool, tempFilePool,
@@ -77,9 +79,15 @@ namespace ErsatzTV.Core.Metadata
var foldersCompleted = 0; var foldersCompleted = 0;
var folderQueue = new Queue<string>(); var folderQueue = new Queue<string>();
if (ShouldIncludeFolder(libraryPath.Path))
{
folderQueue.Enqueue(libraryPath.Path);
}
foreach (string folder in _localFileSystem.ListSubdirectories(libraryPath.Path) foreach (string folder in _localFileSystem.ListSubdirectories(libraryPath.Path)
.Filter(ShouldIncludeFolder) .Filter(ShouldIncludeFolder)
.OrderBy(identity)) .OrderBy(identity))
{ {
folderQueue.Enqueue(folder); folderQueue.Enqueue(folder);
} }
@@ -127,7 +135,8 @@ namespace ErsatzTV.Core.Metadata
Either<BaseError, MediaItemScanResult<OtherVideo>> maybeVideo = await _otherVideoRepository Either<BaseError, MediaItemScanResult<OtherVideo>> maybeVideo = await _otherVideoRepository
.GetOrAdd(libraryPath, file) .GetOrAdd(libraryPath, file)
.BindT(video => UpdateStatistics(video, ffprobePath)) .BindT(video => UpdateStatistics(video, ffprobePath))
.BindT(UpdateMetadata); .BindT(UpdateMetadata)
.BindT(FlagNormal);
await maybeVideo.Match( await maybeVideo.Match(
async result => async result =>
@@ -155,9 +164,9 @@ namespace ErsatzTV.Core.Metadata
{ {
if (!_localFileSystem.FileExists(path)) if (!_localFileSystem.FileExists(path))
{ {
_logger.LogInformation("Removing missing other video at {Path}", path); _logger.LogInformation("Flagging missing other video at {Path}", path);
List<int> otherVideoIds = await _otherVideoRepository.DeleteByPath(libraryPath, path); List<int> otherVideoIds = await FlagFileNotFound(libraryPath, path);
await _searchIndex.RemoveItems(otherVideoIds); await _searchIndex.RebuildItems(_searchRepository, otherVideoIds);
} }
else if (Path.GetFileName(path).StartsWith("._")) else if (Path.GetFileName(path).StartsWith("._"))
{ {
@@ -166,6 +175,8 @@ namespace ErsatzTV.Core.Metadata
await _searchIndex.RemoveItems(otherVideoIds); await _searchIndex.RemoveItems(otherVideoIds);
} }
} }
await _libraryRepository.CleanEtagsForLibraryPath(libraryPath);
_searchIndex.Commit(); _searchIndex.Commit();
return Unit.Default; return Unit.Default;
+17 -6
View File
@@ -41,12 +41,14 @@ namespace ErsatzTV.Core.Metadata
ISearchRepository searchRepository, ISearchRepository searchRepository,
ISongRepository songRepository, ISongRepository songRepository,
ILibraryRepository libraryRepository, ILibraryRepository libraryRepository,
IMediaItemRepository mediaItemRepository,
IFFmpegProcessService ffmpegProcessService, IFFmpegProcessService ffmpegProcessService,
ITempFilePool tempFilePool, ITempFilePool tempFilePool,
ILogger<SongFolderScanner> logger) : base( ILogger<SongFolderScanner> logger) : base(
localFileSystem, localFileSystem,
localStatisticsProvider, localStatisticsProvider,
metadataRepository, metadataRepository,
mediaItemRepository,
imageCache, imageCache,
ffmpegProcessService, ffmpegProcessService,
tempFilePool, tempFilePool,
@@ -79,9 +81,15 @@ namespace ErsatzTV.Core.Metadata
var foldersCompleted = 0; var foldersCompleted = 0;
var folderQueue = new Queue<string>(); var folderQueue = new Queue<string>();
if (ShouldIncludeFolder(libraryPath.Path))
{
folderQueue.Enqueue(libraryPath.Path);
}
foreach (string folder in _localFileSystem.ListSubdirectories(libraryPath.Path) foreach (string folder in _localFileSystem.ListSubdirectories(libraryPath.Path)
.Filter(ShouldIncludeFolder) .Filter(ShouldIncludeFolder)
.OrderBy(identity)) .OrderBy(identity))
{ {
folderQueue.Enqueue(folder); folderQueue.Enqueue(folder);
} }
@@ -130,7 +138,8 @@ namespace ErsatzTV.Core.Metadata
.GetOrAdd(libraryPath, file) .GetOrAdd(libraryPath, file)
.BindT(video => UpdateStatistics(video, ffprobePath)) .BindT(video => UpdateStatistics(video, ffprobePath))
.BindT(video => UpdateMetadata(video, ffprobePath)) .BindT(video => UpdateMetadata(video, ffprobePath))
.BindT(video => UpdateThumbnail(video, ffmpegPath)); .BindT(video => UpdateThumbnail(video, ffmpegPath))
.BindT(FlagNormal);
await maybeSong.Match( await maybeSong.Match(
async result => async result =>
@@ -158,9 +167,9 @@ namespace ErsatzTV.Core.Metadata
{ {
if (!_localFileSystem.FileExists(path)) if (!_localFileSystem.FileExists(path))
{ {
_logger.LogInformation("Removing missing song at {Path}", path); _logger.LogInformation("Flagging missing song at {Path}", path);
List<int> songIds = await _songRepository.DeleteByPath(libraryPath, path); List<int> songIds = await FlagFileNotFound(libraryPath, path);
await _searchIndex.RemoveItems(songIds); await _searchIndex.RebuildItems(_searchRepository, songIds);
} }
else if (Path.GetFileName(path).StartsWith("._")) else if (Path.GetFileName(path).StartsWith("._"))
{ {
@@ -169,6 +178,8 @@ namespace ErsatzTV.Core.Metadata
await _searchIndex.RemoveItems(songIds); await _searchIndex.RemoveItems(songIds);
} }
} }
await _libraryRepository.CleanEtagsForLibraryPath(libraryPath);
_searchIndex.Commit(); _searchIndex.Commit();
return Unit.Default; return Unit.Default;
@@ -40,6 +40,7 @@ namespace ErsatzTV.Core.Metadata
ISearchIndex searchIndex, ISearchIndex searchIndex,
ISearchRepository searchRepository, ISearchRepository searchRepository,
ILibraryRepository libraryRepository, ILibraryRepository libraryRepository,
IMediaItemRepository mediaItemRepository,
IMediator mediator, IMediator mediator,
IFFmpegProcessService ffmpegProcessService, IFFmpegProcessService ffmpegProcessService,
ITempFilePool tempFilePool, ITempFilePool tempFilePool,
@@ -47,6 +48,7 @@ namespace ErsatzTV.Core.Metadata
localFileSystem, localFileSystem,
localStatisticsProvider, localStatisticsProvider,
metadataRepository, metadataRepository,
mediaItemRepository,
imageCache, imageCache,
ffmpegProcessService, ffmpegProcessService,
tempFilePool, tempFilePool,
@@ -126,8 +128,9 @@ namespace ErsatzTV.Core.Metadata
{ {
if (!_localFileSystem.FileExists(path)) if (!_localFileSystem.FileExists(path))
{ {
_logger.LogInformation("Removing missing episode at {Path}", path); _logger.LogInformation("Flagging missing episode at {Path}", path);
await _televisionRepository.DeleteByPath(libraryPath, path); List<int> episodeIds = await FlagFileNotFound(libraryPath, path);
await _searchIndex.RebuildItems(_searchRepository, episodeIds);
} }
else if (Path.GetFileName(path).StartsWith("._")) else if (Path.GetFileName(path).StartsWith("._"))
{ {
@@ -135,6 +138,8 @@ namespace ErsatzTV.Core.Metadata
await _televisionRepository.DeleteByPath(libraryPath, path); await _televisionRepository.DeleteByPath(libraryPath, path);
} }
} }
await _libraryRepository.CleanEtagsForLibraryPath(libraryPath);
await _televisionRepository.DeleteEmptySeasons(libraryPath); await _televisionRepository.DeleteEmptySeasons(libraryPath);
List<int> ids = await _televisionRepository.DeleteEmptyShows(libraryPath); List<int> ids = await _televisionRepository.DeleteEmptyShows(libraryPath);
@@ -231,7 +236,9 @@ namespace ErsatzTV.Core.Metadata
episode => UpdateStatistics(new MediaItemScanResult<Episode>(episode), ffprobePath) episode => UpdateStatistics(new MediaItemScanResult<Episode>(episode), ffprobePath)
.MapT(_ => episode)) .MapT(_ => episode))
.BindT(UpdateMetadata) .BindT(UpdateMetadata)
.BindT(UpdateThumbnail); .BindT(UpdateThumbnail)
.BindT(e => FlagNormal(new MediaItemScanResult<Episode>(e)))
.MapT(r => r.Item);
await maybeEpisode.Match( await maybeEpisode.Match(
async episode => async episode =>
+15 -1
View File
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Scheduling; using ErsatzTV.Core.Interfaces.Scheduling;
using LanguageExt; using LanguageExt;
@@ -254,6 +255,7 @@ namespace ErsatzTV.Core.Scheduling
foreach ((CollectionKey _, List<MediaItem> items) in collectionMediaItems) foreach ((CollectionKey _, List<MediaItem> items) in collectionMediaItems)
{ {
var zeroItems = new List<MediaItem>(); var zeroItems = new List<MediaItem>();
// var missingItems = new List<MediaItem>();
foreach (MediaItem item in items) foreach (MediaItem item in items)
{ {
@@ -272,6 +274,17 @@ namespace ErsatzTV.Core.Scheduling
_ => true _ => 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) if (isZero)
{ {
_logger.LogWarning( _logger.LogWarning(
@@ -283,7 +296,8 @@ namespace ErsatzTV.Core.Scheduling
} }
} }
items.RemoveAll(i => zeroItems.Contains(i)); // items.RemoveAll(missingItems.Contains);
items.RemoveAll(zeroItems.Contains);
} }
return collectionMediaItems.Find(c => !c.Value.Any()).Map(c => c.Key); return collectionMediaItems.Find(c => !c.Value.Any()).Map(c => c.Key);
@@ -123,10 +123,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<List<ArtistMetadata>> GetArtistsForCards(List<int> ids) public async Task<List<ArtistMetadata>> GetArtistsForCards(List<int> ids)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.ArtistMetadata return await dbContext.ArtistMetadata
.AsNoTracking() .AsNoTracking()
.Filter(am => ids.Contains(am.ArtistId)) .Filter(am => ids.Contains(am.ArtistId))
.Include(am => am.Artist)
.Include(am => am.Artwork) .Include(am => am.Artwork)
.OrderBy(am => am.SortTitle) .OrderBy(am => am.SortTitle)
.ToListAsync(); .ToListAsync();
@@ -149,12 +150,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<List<MusicVideo>> GetArtistItems(int artistId) public async Task<List<MusicVideo>> GetArtistItems(int artistId)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.MusicVideos return await dbContext.MusicVideos
.AsNoTracking() .AsNoTracking()
.Include(mv => mv.MusicVideoMetadata) .Include(mv => mv.MusicVideoMetadata)
.Include(mv => mv.MediaVersions) .Include(mv => mv.MediaVersions)
.ThenInclude(mv => mv.Chapters) .ThenInclude(mv => mv.Chapters)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(mv => mv.Artist) .Include(mv => mv.Artist)
.ThenInclude(a => a.ArtistMetadata) .ThenInclude(a => a.ArtistMetadata)
.Filter(mv => mv.ArtistId == artistId) .Filter(mv => mv.ArtistId == artistId)
@@ -4,6 +4,7 @@ using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Dapper; using Dapper;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt; using LanguageExt;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -14,10 +15,15 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public class LibraryRepository : ILibraryRepository public class LibraryRepository : ILibraryRepository
{ {
private readonly IDbConnection _dbConnection; private readonly IDbConnection _dbConnection;
private readonly ILocalFileSystem _localFileSystem;
private readonly IDbContextFactory<TvContext> _dbContextFactory; private readonly IDbContextFactory<TvContext> _dbContextFactory;
public LibraryRepository(IDbContextFactory<TvContext> dbContextFactory, IDbConnection dbConnection) public LibraryRepository(
ILocalFileSystem localFileSystem,
IDbContextFactory<TvContext> dbContextFactory,
IDbConnection dbConnection)
{ {
_localFileSystem = localFileSystem;
_dbContextFactory = dbContextFactory; _dbContextFactory = dbContextFactory;
_dbConnection = dbConnection; _dbConnection = dbConnection;
} }
@@ -120,7 +126,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
}, },
async () => async () =>
{ {
await using TvContext context = _dbContextFactory.CreateDbContext(); await using TvContext context = await _dbContextFactory.CreateDbContextAsync();
await context.LibraryFolders.AddAsync( await context.LibraryFolders.AddAsync(
new LibraryFolder new LibraryFolder
{ {
@@ -130,5 +136,23 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
}); });
await context.SaveChangesAsync(); await context.SaveChangesAsync();
}).ToUnit(); }).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,6 +371,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(m => m.MovieMetadata) .Include(m => m.MovieMetadata)
.Include(m => m.MediaVersions) .Include(m => m.MediaVersions)
.ThenInclude(mv => mv.Chapters) .ThenInclude(mv => mv.Chapters)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Filter(m => movieIds.Contains(m.Id)) .Filter(m => movieIds.Contains(m.Id))
.ToListAsync(); .ToListAsync();
@@ -395,6 +397,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(m => m.MusicVideoMetadata) .Include(m => m.MusicVideoMetadata)
.Include(m => m.MediaVersions) .Include(m => m.MediaVersions)
.ThenInclude(mv => mv.Chapters) .ThenInclude(mv => mv.Chapters)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Filter(m => musicVideoIds.Contains(m.Id)) .Filter(m => musicVideoIds.Contains(m.Id))
.ToListAsync(); .ToListAsync();
@@ -427,6 +431,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(m => m.MusicVideoMetadata) .Include(m => m.MusicVideoMetadata)
.Include(m => m.MediaVersions) .Include(m => m.MediaVersions)
.ThenInclude(mv => mv.Chapters) .ThenInclude(mv => mv.Chapters)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Filter(m => musicVideoIds.Contains(m.Id)) .Filter(m => musicVideoIds.Contains(m.Id))
.ToListAsync(); .ToListAsync();
@@ -446,6 +452,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(m => m.OtherVideoMetadata) .Include(m => m.OtherVideoMetadata)
.Include(m => m.MediaVersions) .Include(m => m.MediaVersions)
.ThenInclude(mv => mv.Chapters) .ThenInclude(mv => mv.Chapters)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Filter(m => otherVideoIds.Contains(m.Id)) .Filter(m => otherVideoIds.Contains(m.Id))
.ToListAsync(); .ToListAsync();
@@ -465,6 +473,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(m => m.SongMetadata) .Include(m => m.SongMetadata)
.Include(m => m.MediaVersions) .Include(m => m.MediaVersions)
.ThenInclude(mv => mv.Chapters) .ThenInclude(mv => mv.Chapters)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Filter(m => songIds.Contains(m.Id)) .Filter(m => songIds.Contains(m.Id))
.ToListAsync(); .ToListAsync();
@@ -486,6 +496,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(e => e.EpisodeMetadata) .Include(e => e.EpisodeMetadata)
.Include(e => e.MediaVersions) .Include(e => e.MediaVersions)
.ThenInclude(mv => mv.Chapters) .ThenInclude(mv => mv.Chapters)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(e => e.Season) .Include(e => e.Season)
.ThenInclude(s => s.Show) .ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata) .ThenInclude(s => s.ShowMetadata)
@@ -521,6 +533,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(e => e.EpisodeMetadata) .Include(e => e.EpisodeMetadata)
.Include(e => e.MediaVersions) .Include(e => e.MediaVersions)
.ThenInclude(mv => mv.Chapters) .ThenInclude(mv => mv.Chapters)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(e => e.Season) .Include(e => e.Season)
.ThenInclude(s => s.Show) .ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata) .ThenInclude(s => s.ShowMetadata)
@@ -554,6 +568,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(e => e.EpisodeMetadata) .Include(e => e.EpisodeMetadata)
.Include(e => e.MediaVersions) .Include(e => e.MediaVersions)
.ThenInclude(mv => mv.Chapters) .ThenInclude(mv => mv.Chapters)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(e => e.Season) .Include(e => e.Season)
.ThenInclude(s => s.Show) .ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata) .ThenInclude(s => s.ShowMetadata)
@@ -3,6 +3,8 @@ using System.Data;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Dapper; using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt; using LanguageExt;
@@ -24,5 +26,44 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
GROUP BY LanguageCode GROUP BY LanguageCode
ORDER BY COUNT(LanguageCode) DESC") ORDER BY COUNT(LanguageCode) DESC")
.Map(result => result.ToList()); .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,6 +4,7 @@ using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Dapper; using Dapper;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt; using LanguageExt;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -110,14 +111,17 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
} }
public async Task<bool> UpdateLocalStatistics( public async Task<bool> UpdateLocalStatistics(
int mediaVersionId, MediaItem mediaItem,
MediaVersion incoming, MediaVersion incoming,
bool updateVersion = true) bool updateVersion = true)
{ {
int mediaVersionId = mediaItem.GetHeadVersion().Id;
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<MediaVersion> maybeVersion = await dbContext.MediaVersions Option<MediaVersion> maybeVersion = await dbContext.MediaVersions
.Include(v => v.Streams) .Include(v => v.Streams)
.Include(v => v.Chapters) .Include(v => v.Chapters)
.Include(v => v.MediaFiles)
.OrderBy(v => v.Id) .OrderBy(v => v.Id)
.SingleOrDefaultAsync(v => v.Id == mediaVersionId) .SingleOrDefaultAsync(v => v.Id == mediaVersionId)
.Map(Optional); .Map(Optional);
@@ -191,7 +195,37 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
existingChapter.Title = incomingChapter.Title; existingChapter.Title = incomingChapter.Title;
} }
return await dbContext.SaveChangesAsync() > 0; 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;
}, },
() => Task.FromResult(false)); () => Task.FromResult(false));
} }
@@ -56,6 +56,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(mm => mm.Writers) .ThenInclude(mm => mm.Writers)
.Include(m => m.MediaVersions) .Include(m => m.MediaVersions)
.ThenInclude(mv => mv.Streams) .ThenInclude(mv => mv.Streams)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.OrderBy(m => m.Id) .OrderBy(m => m.Id)
.SingleOrDefaultAsync(m => m.Id == movieId) .SingleOrDefaultAsync(m => m.Id == movieId)
.Map(Optional); .Map(Optional);
@@ -63,7 +65,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<Either<BaseError, MediaItemScanResult<Movie>>> GetOrAdd(LibraryPath libraryPath, string path) public async Task<Either<BaseError, MediaItemScanResult<Movie>>> GetOrAdd(LibraryPath libraryPath, string path)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<Movie> maybeExisting = await dbContext.Movies Option<Movie> maybeExisting = await dbContext.Movies
.Include(i => i.MovieMetadata) .Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Artwork) .ThenInclude(mm => mm.Artwork)
@@ -146,12 +148,13 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<List<MovieMetadata>> GetMoviesForCards(List<int> ids) public async Task<List<MovieMetadata>> GetMoviesForCards(List<int> ids)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.MovieMetadata return await dbContext.MovieMetadata
.AsNoTracking() .AsNoTracking()
.Filter(mm => ids.Contains(mm.MovieId)) .Filter(mm => ids.Contains(mm.MovieId))
.Include(mm => mm.Artwork) .Include(mm => mm.Artwork)
.OrderBy(mm => mm.SortTitle) .OrderBy(mm => mm.SortTitle)
.Include(mm => mm.Movie)
.ToListAsync(); .ToListAsync();
} }
@@ -167,7 +170,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<List<int>> DeleteByPath(LibraryPath libraryPath, string path) public async Task<List<int>> DeleteByPath(LibraryPath libraryPath, string path)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
List<int> ids = await _dbConnection.QueryAsync<int>( List<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT M.Id @"SELECT M.Id
FROM Movie M FROM Movie M
@@ -181,13 +184,16 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
foreach (int movieId in ids) foreach (int movieId in ids)
{ {
Movie movie = await dbContext.Movies.FindAsync(movieId); Movie movie = await dbContext.Movies.FindAsync(movieId);
dbContext.Movies.Remove(movie); if (movie != null)
{
dbContext.Movies.Remove(movie);
}
} }
bool changed = await dbContext.SaveChangesAsync() > 0; bool changed = await dbContext.SaveChangesAsync() > 0;
return changed ? ids : new List<int>(); return changed ? ids : new List<int>();
} }
public Task<bool> AddGenre(MovieMetadata metadata, Genre genre) => public Task<bool> AddGenre(MovieMetadata metadata, Genre genre) =>
_dbConnection.ExecuteAsync( _dbConnection.ExecuteAsync(
"INSERT INTO Genre (Name, MovieMetadataId) VALUES (@Name, @MetadataId)", "INSERT INTO Genre (Name, MovieMetadataId) VALUES (@Name, @MetadataId)",
@@ -30,9 +30,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
LibraryPath libraryPath, LibraryPath libraryPath,
string path) string path)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<MusicVideo> maybeExisting = await dbContext.MusicVideos Option<MusicVideo> maybeExisting = await dbContext.MusicVideos
.AsNoTracking() .AsNoTracking()
.Include(mv => mv.Artist)
.ThenInclude(a => a.ArtistMetadata)
.Include(mv => mv.MusicVideoMetadata) .Include(mv => mv.MusicVideoMetadata)
.ThenInclude(mvm => mvm.Artwork) .ThenInclude(mvm => mvm.Artwork)
.Include(mv => mv.MusicVideoMetadata) .Include(mv => mv.MusicVideoMetadata)
@@ -92,18 +94,21 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE MI.LibraryPathId = @LibraryPathId AND MF.Path = @Path", WHERE MI.LibraryPathId = @LibraryPathId AND MF.Path = @Path",
new { LibraryPathId = libraryPath.Id, Path = path }).Map(result => result.ToList()); new { LibraryPathId = libraryPath.Id, Path = path }).Map(result => result.ToList());
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
foreach (int musicVideoId in ids) foreach (int musicVideoId in ids)
{ {
MusicVideo musicVideo = await dbContext.MusicVideos.FindAsync(musicVideoId); MusicVideo musicVideo = await dbContext.MusicVideos.FindAsync(musicVideoId);
dbContext.MusicVideos.Remove(musicVideo); if (musicVideo != null)
{
dbContext.MusicVideos.Remove(musicVideo);
}
} }
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
return ids; return ids;
} }
public Task<bool> AddGenre(MusicVideoMetadata metadata, Genre genre) => public Task<bool> AddGenre(MusicVideoMetadata metadata, Genre genre) =>
_dbConnection.ExecuteAsync( _dbConnection.ExecuteAsync(
"INSERT INTO Genre (Name, MusicVideoMetadataId) VALUES (@Name, @MetadataId)", "INSERT INTO Genre (Name, MusicVideoMetadataId) VALUES (@Name, @MetadataId)",
@@ -121,7 +126,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<List<MusicVideoMetadata>> GetMusicVideosForCards(List<int> ids) public async Task<List<MusicVideoMetadata>> GetMusicVideosForCards(List<int> ids)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.MusicVideoMetadata return await dbContext.MusicVideoMetadata
.AsNoTracking() .AsNoTracking()
.Filter(mvm => ids.Contains(mvm.MusicVideoId)) .Filter(mvm => ids.Contains(mvm.MusicVideoId))
@@ -161,6 +166,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(m => m.MusicVideo) .Include(m => m.MusicVideo)
.ThenInclude(mv => mv.Artist) .ThenInclude(mv => mv.Artist)
.ThenInclude(a => a.ArtistMetadata) .ThenInclude(a => a.ArtistMetadata)
.Include(m => m.MusicVideo)
.ThenInclude(mv => mv.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Filter(m => m.MusicVideo.ArtistId == artistId) .Filter(m => m.MusicVideo.ArtistId == artistId)
.OrderBy(m => m.SortTitle) .OrderBy(m => m.SortTitle)
.Skip((pageNumber - 1) * pageSize) .Skip((pageNumber - 1) * pageSize)
@@ -196,6 +204,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
await dbContext.MusicVideos.AddAsync(musicVideo); await dbContext.MusicVideos.AddAsync(musicVideo);
await dbContext.SaveChangesAsync(); 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).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(musicVideo.LibraryPath).Reference(lp => lp.Library).LoadAsync(); await dbContext.Entry(musicVideo.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return new MediaItemScanResult<MusicVideo>(musicVideo) { IsAdded = true }; return new MediaItemScanResult<MusicVideo>(musicVideo) { IsAdded = true };
@@ -83,6 +83,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(mm => mm.Studios) .ThenInclude(mm => mm.Studios)
.Include(mi => (mi as Show).ShowMetadata) .Include(mi => (mi as Show).ShowMetadata)
.ThenInclude(mm => mm.Actors) .ThenInclude(mm => mm.Actors)
.Include(mi => (mi as MusicVideo).Artist)
.ThenInclude(mm => mm.ArtistMetadata)
.Include(mi => (mi as MusicVideo).MusicVideoMetadata) .Include(mi => (mi as MusicVideo).MusicVideoMetadata)
.ThenInclude(mm => mm.Genres) .ThenInclude(mm => mm.Genres)
.Include(mi => (mi as MusicVideo).MusicVideoMetadata) .Include(mi => (mi as MusicVideo).MusicVideoMetadata)
@@ -45,7 +45,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<List<Show>> GetAllShows() public async Task<List<Show>> GetAllShows()
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Shows return await dbContext.Shows
.AsNoTracking() .AsNoTracking()
.Include(s => s.ShowMetadata) .Include(s => s.ShowMetadata)
@@ -55,7 +55,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<Option<Show>> GetShow(int showId) public async Task<Option<Show>> GetShow(int showId)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Shows return await dbContext.Shows
.AsNoTracking() .AsNoTracking()
.Filter(s => s.Id == showId) .Filter(s => s.Id == showId)
@@ -77,18 +77,19 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<List<ShowMetadata>> GetShowsForCards(List<int> ids) public async Task<List<ShowMetadata>> GetShowsForCards(List<int> ids)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.ShowMetadata return await dbContext.ShowMetadata
.AsNoTracking() .AsNoTracking()
.Filter(sm => ids.Contains(sm.ShowId)) .Filter(sm => ids.Contains(sm.ShowId))
.Include(sm => sm.Artwork) .Include(sm => sm.Artwork)
.Include(sm => sm.Show)
.OrderBy(sm => sm.SortTitle) .OrderBy(sm => sm.SortTitle)
.ToListAsync(); .ToListAsync();
} }
public async Task<List<SeasonMetadata>> GetSeasonsForCards(List<int> ids) public async Task<List<SeasonMetadata>> GetSeasonsForCards(List<int> ids)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.SeasonMetadata return await dbContext.SeasonMetadata
.AsNoTracking() .AsNoTracking()
.Filter(s => ids.Contains(s.SeasonId)) .Filter(s => ids.Contains(s.SeasonId))
@@ -105,7 +106,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<List<EpisodeMetadata>> GetEpisodesForCards(List<int> ids) public async Task<List<EpisodeMetadata>> GetEpisodesForCards(List<int> ids)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.EpisodeMetadata return await dbContext.EpisodeMetadata
.AsNoTracking() .AsNoTracking()
.Filter(em => ids.Contains(em.EpisodeId)) .Filter(em => ids.Contains(em.EpisodeId))
@@ -121,13 +122,16 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(s => s.Show) .ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata) .ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Artwork) .ThenInclude(sm => sm.Artwork)
.Include(em => em.Episode)
.ThenInclude(e => e.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.OrderBy(em => em.SortTitle) .OrderBy(em => em.SortTitle)
.ToListAsync(); .ToListAsync();
} }
public async Task<List<Season>> GetAllSeasons() public async Task<List<Season>> GetAllSeasons()
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Seasons return await dbContext.Seasons
.AsNoTracking() .AsNoTracking()
.Include(s => s.SeasonMetadata) .Include(s => s.SeasonMetadata)
@@ -140,7 +144,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<Option<Season>> GetSeason(int seasonId) public async Task<Option<Season>> GetSeason(int seasonId)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Seasons return await dbContext.Seasons
.AsNoTracking() .AsNoTracking()
.Include(s => s.SeasonMetadata) .Include(s => s.SeasonMetadata)
@@ -155,7 +159,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<int> GetSeasonCount(int showId) public async Task<int> GetSeasonCount(int showId)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Seasons return await dbContext.Seasons
.AsNoTracking() .AsNoTracking()
.CountAsync(s => s.ShowId == showId); .CountAsync(s => s.ShowId == showId);
@@ -171,7 +175,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
new { ShowId = televisionShowId }) new { ShowId = televisionShowId })
.Map(results => results.ToList()); .Map(results => results.ToList());
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Seasons return await dbContext.Seasons
.AsNoTracking() .AsNoTracking()
.Where(s => showIds.Contains(s.ShowId)) .Where(s => showIds.Contains(s.ShowId))
@@ -187,7 +191,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<int> GetEpisodeCount(int seasonId) public async Task<int> GetEpisodeCount(int seasonId)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Episodes return await dbContext.Episodes
.AsNoTracking() .AsNoTracking()
.CountAsync(e => e.SeasonId == seasonId); .CountAsync(e => e.SeasonId == seasonId);
@@ -195,7 +199,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<List<EpisodeMetadata>> GetPagedEpisodes(int seasonId, int pageNumber, int pageSize) public async Task<List<EpisodeMetadata>> GetPagedEpisodes(int seasonId, int pageNumber, int pageSize)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.EpisodeMetadata return await dbContext.EpisodeMetadata
.AsNoTracking() .AsNoTracking()
.Filter(em => em.Episode.SeasonId == seasonId) .Filter(em => em.Episode.SeasonId == seasonId)
@@ -208,6 +212,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(s => s.ShowMetadata) .ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Actors) .ThenInclude(sm => sm.Actors)
.ThenInclude(a => a.Artwork) .ThenInclude(a => a.Artwork)
.Include(em => em.Episode)
.ThenInclude(e => e.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.OrderBy(em => em.EpisodeNumber) .OrderBy(em => em.EpisodeNumber)
.Skip((pageNumber - 1) * pageSize) .Skip((pageNumber - 1) * pageSize)
.Take(pageSize) .Take(pageSize)
@@ -216,7 +223,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<Option<Show>> GetShowByMetadata(int libraryPathId, ShowMetadata metadata) public async Task<Option<Show>> GetShowByMetadata(int libraryPathId, ShowMetadata metadata)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<int> maybeId = await dbContext.ShowMetadata Option<int> maybeId = await dbContext.ShowMetadata
.Where(s => s.Title == metadata.Title && s.Year == metadata.Year) .Where(s => s.Title == metadata.Title && s.Year == metadata.Year)
.Where(s => s.Show.LibraryPathId == libraryPathId) .Where(s => s.Show.LibraryPathId == libraryPathId)
@@ -258,7 +265,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
string showFolder, string showFolder,
ShowMetadata metadata) ShowMetadata metadata)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
try try
{ {
@@ -291,7 +298,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<Either<BaseError, Season>> GetOrAddSeason(Show show, int libraryPathId, int seasonNumber) public async Task<Either<BaseError, Season>> GetOrAddSeason(Show show, int libraryPathId, int seasonNumber)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<Season> maybeExisting = await dbContext.Seasons Option<Season> maybeExisting = await dbContext.Seasons
.Include(s => s.SeasonMetadata) .Include(s => s.SeasonMetadata)
.ThenInclude(sm => sm.Artwork) .ThenInclude(sm => sm.Artwork)
@@ -315,7 +322,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
LibraryPath libraryPath, LibraryPath libraryPath,
string path) string path)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<Episode> maybeExisting = await dbContext.Episodes Option<Episode> maybeExisting = await dbContext.Episodes
.Include(i => i.EpisodeMetadata) .Include(i => i.EpisodeMetadata)
.ThenInclude(em => em.Artwork) .ThenInclude(em => em.Artwork)
@@ -387,11 +394,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE MI.LibraryPathId = @LibraryPathId AND MF.Path = @Path", WHERE MI.LibraryPathId = @LibraryPathId AND MF.Path = @Path",
new { LibraryPathId = libraryPath.Id, Path = path }); new { LibraryPathId = libraryPath.Id, Path = path });
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
foreach (int episodeId in ids) foreach (int episodeId in ids)
{ {
Episode episode = await dbContext.Episodes.FindAsync(episodeId); Episode episode = await dbContext.Episodes.FindAsync(episodeId);
dbContext.Episodes.Remove(episode); if (episode != null)
{
dbContext.Episodes.Remove(episode);
}
} }
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
@@ -401,7 +411,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<Unit> DeleteEmptySeasons(LibraryPath libraryPath) public async Task<Unit> DeleteEmptySeasons(LibraryPath libraryPath)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
List<Season> seasons = await dbContext.Seasons List<Season> seasons = await dbContext.Seasons
.Filter(s => s.LibraryPathId == libraryPath.Id) .Filter(s => s.LibraryPathId == libraryPath.Id)
.Filter(s => s.Episodes.Count == 0) .Filter(s => s.Episodes.Count == 0)
@@ -413,7 +423,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<List<int>> DeleteEmptyShows(LibraryPath libraryPath) public async Task<List<int>> DeleteEmptyShows(LibraryPath libraryPath)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
List<Show> shows = await dbContext.Shows List<Show> shows = await dbContext.Shows
.Filter(s => s.LibraryPathId == libraryPath.Id) .Filter(s => s.LibraryPathId == libraryPath.Id)
.Filter(s => s.Seasons.Count == 0) .Filter(s => s.Seasons.Count == 0)
@@ -428,7 +438,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
PlexLibrary library, PlexLibrary library,
PlexShow item) PlexShow item)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<PlexShow> maybeExisting = await dbContext.PlexShows Option<PlexShow> maybeExisting = await dbContext.PlexShows
.AsNoTracking() .AsNoTracking()
.Include(i => i.ShowMetadata) .Include(i => i.ShowMetadata)
@@ -459,7 +469,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<Either<BaseError, PlexSeason>> GetOrAddPlexSeason(PlexLibrary library, PlexSeason item) public async Task<Either<BaseError, PlexSeason>> GetOrAddPlexSeason(PlexLibrary library, PlexSeason item)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<PlexSeason> maybeExisting = await dbContext.PlexSeasons Option<PlexSeason> maybeExisting = await dbContext.PlexSeasons
.AsNoTracking() .AsNoTracking()
.Include(i => i.SeasonMetadata) .Include(i => i.SeasonMetadata)
@@ -480,7 +490,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<Either<BaseError, PlexEpisode>> GetOrAddPlexEpisode(PlexLibrary library, PlexEpisode item) public async Task<Either<BaseError, PlexEpisode>> GetOrAddPlexEpisode(PlexLibrary library, PlexEpisode item)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<PlexEpisode> maybeExisting = await dbContext.PlexEpisodes Option<PlexEpisode> maybeExisting = await dbContext.PlexEpisodes
.AsNoTracking() .AsNoTracking()
.Include(i => i.EpisodeMetadata) .Include(i => i.EpisodeMetadata)
@@ -577,12 +587,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE Show.Id = @ShowId", WHERE Show.Id = @ShowId",
new { ShowId = showId }); new { ShowId = showId });
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Episodes return await dbContext.Episodes
.AsNoTracking() .AsNoTracking()
.Include(e => e.EpisodeMetadata) .Include(e => e.EpisodeMetadata)
.Include(e => e.MediaVersions) .Include(e => e.MediaVersions)
.ThenInclude(mv => mv.Chapters) .ThenInclude(mv => mv.Chapters)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(e => e.Season) .Include(e => e.Season)
.ThenInclude(s => s.Show) .ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata) .ThenInclude(s => s.ShowMetadata)
@@ -592,12 +604,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<List<Episode>> GetSeasonItems(int seasonId) public async Task<List<Episode>> GetSeasonItems(int seasonId)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Episodes return await dbContext.Episodes
.AsNoTracking() .AsNoTracking()
.Include(e => e.EpisodeMetadata) .Include(e => e.EpisodeMetadata)
.Include(e => e.MediaVersions) .Include(e => e.MediaVersions)
.ThenInclude(mv => mv.Chapters) .ThenInclude(mv => mv.Chapters)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(e => e.Season) .Include(e => e.Season)
.ThenInclude(s => s.Show) .ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata) .ThenInclude(s => s.ShowMetadata)
@@ -4,7 +4,6 @@
<TargetFramework>net6.0</TargetFramework> <TargetFramework>net6.0</TargetFramework>
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles> <GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
<NoWarn>VSTHRD200</NoWarn> <NoWarn>VSTHRD200</NoWarn>
<DebugType>embedded</DebugType>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@@ -13,12 +12,12 @@
<PackageReference Include="Lucene.Net" Version="4.8.0-beta00015" /> <PackageReference Include="Lucene.Net" Version="4.8.0-beta00015" />
<PackageReference Include="Lucene.Net.Analysis.Common" 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="Lucene.Net.QueryParser" Version="4.8.0-beta00015" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="6.0.0" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="6.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.0"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.1">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.0" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.1" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.0.64"> <PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.0.64">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@@ -2,8 +2,8 @@
using System.Diagnostics; using System.Diagnostics;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core.Health; using ErsatzTV.Core.Health;
using LanguageExt;
using Lucene.Net.Util; using Lucene.Net.Util;
using static LanguageExt.Prelude;
namespace ErsatzTV.Infrastructure.Health.Checks namespace ErsatzTV.Infrastructure.Health.Checks
{ {
@@ -12,22 +12,25 @@ namespace ErsatzTV.Infrastructure.Health.Checks
protected abstract string Title { get; } protected abstract string Title { get; }
protected HealthCheckResult Result(HealthCheckStatus status, string message) => protected HealthCheckResult Result(HealthCheckStatus status, string message) =>
new(Title, status, message); new(Title, status, message, None);
protected HealthCheckResult NotApplicableResult() => protected HealthCheckResult NotApplicableResult() =>
new(Title, HealthCheckStatus.NotApplicable, string.Empty); new(Title, HealthCheckStatus.NotApplicable, string.Empty, None);
protected HealthCheckResult OkResult() => protected HealthCheckResult OkResult() =>
new(Title, HealthCheckStatus.Pass, string.Empty); new(Title, HealthCheckStatus.Pass, string.Empty, None);
protected HealthCheckResult FailResult(string message) => protected HealthCheckResult FailResult(string message) =>
new(Title, HealthCheckStatus.Fail, message); new(Title, HealthCheckStatus.Fail, message, None);
protected HealthCheckResult WarningResult(string message) => protected HealthCheckResult WarningResult(string message) =>
new(Title, HealthCheckStatus.Warning, message); new(Title, HealthCheckStatus.Warning, message, None);
protected HealthCheckResult WarningResult(string message, string link) =>
new(Title, HealthCheckStatus.Warning, message, link);
protected HealthCheckResult InfoResult(string message) => protected HealthCheckResult InfoResult(string message) =>
new(Title, HealthCheckStatus.Info, message); new(Title, HealthCheckStatus.Info, message, None);
protected static async Task<string> GetProcessOutput(string path, IEnumerable<string> arguments) 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 public class FFmpegVersionHealthCheck : BaseHealthCheck, IFFmpegVersionHealthCheck
{ {
private const string BundledVersion = "N-104321-ga742ba60f1"; private const string BundledVersion = "N-105153-g8abc192236";
private readonly IConfigElementRepository _configElementRepository; private readonly IConfigElementRepository _configElementRepository;
public FFmpegVersionHealthCheck(IConfigElementRepository configElementRepository) public FFmpegVersionHealthCheck(IConfigElementRepository configElementRepository)
@@ -66,7 +66,7 @@ namespace ErsatzTV.Infrastructure.Health.Checks
} }
} }
return new HealthCheckResult("FFmpeg Version", HealthCheckStatus.Pass, string.Empty); return new HealthCheckResult("FFmpeg Version", HealthCheckStatus.Pass, string.Empty, None);
} }
private Option<HealthCheckResult> ValidateVersion(string version, string app) private Option<HealthCheckResult> ValidateVersion(string version, string app)
@@ -0,0 +1,76 @@
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,11 +113,10 @@ namespace ErsatzTV.Infrastructure.Health.Checks
result.Add(HardwareAccelerationKind.Nvenc); result.Add(HardwareAccelerationKind.Nvenc);
break; break;
case "qsv": case "qsv":
// qsv is only supported on windows result.Add(HardwareAccelerationKind.Qsv);
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) break;
{ case "videotoolbox":
result.Add(HardwareAccelerationKind.Qsv); result.Add(HardwareAccelerationKind.VideoToolbox);
}
break; break;
} }
} }
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Health; using ErsatzTV.Core.Health;
using ErsatzTV.Core.Health.Checks; using ErsatzTV.Core.Health.Checks;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
@@ -21,7 +22,7 @@ namespace ErsatzTV.Infrastructure.Health.Checks
public async Task<HealthCheckResult> Check() public async Task<HealthCheckResult> Check()
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
List<Episode> episodes = await dbContext.Episodes List<Episode> episodes = await dbContext.Episodes
.Filter(e => e.MediaVersions.Any(mv => mv.Duration == TimeSpan.Zero)) .Filter(e => e.MediaVersions.Any(mv => mv.Duration == TimeSpan.Zero))
@@ -30,13 +31,34 @@ namespace ErsatzTV.Infrastructure.Health.Checks
.ToListAsync(); .ToListAsync();
List<Movie> movies = await dbContext.Movies List<Movie> movies = await dbContext.Movies
.Filter(e => e.MediaVersions.Any(mv => mv.Duration == TimeSpan.Zero)) .Filter(m => m.MediaVersions.Any(mv => mv.Duration == TimeSpan.Zero))
.Include(e => e.MediaVersions) .Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.ToListAsync();
List<MusicVideo> musicVideos = await dbContext.MusicVideos
.Filter(mv => mv.MediaVersions.Any(v => v.Duration == TimeSpan.Zero))
.Include(mv => mv.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.ToListAsync();
List<OtherVideo> otherVideos = await dbContext.OtherVideos
.Filter(ov => ov.MediaVersions.Any(mv => mv.Duration == TimeSpan.Zero))
.Include(ov => ov.MediaVersions)
.ThenInclude(mv => mv.MediaFiles) .ThenInclude(mv => mv.MediaFiles)
.ToListAsync(); .ToListAsync();
List<string> all = movies.Map(m => m.MediaVersions.Head().MediaFiles.Head().Path) List<Song> songs = await dbContext.Songs
.Filter(s => s.MediaVersions.Any(mv => mv.Duration == TimeSpan.Zero))
.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(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(); .ToList();
if (all.Any()) if (all.Any())
@@ -14,21 +14,23 @@ namespace ErsatzTV.Infrastructure.Health
// ReSharper disable SuggestBaseTypeForParameterInConstructor // ReSharper disable SuggestBaseTypeForParameterInConstructor
public HealthCheckService( public HealthCheckService(
IFFmpegVersionHealthCheck ffmpegVersionHealthCheck, IFFmpegVersionHealthCheck ffmpegVersionHealthCheck,
IFFmpegReportsHealthCheck fFmpegReportsHealthCheck, IFFmpegReportsHealthCheck ffmpegReportsHealthCheck,
IHardwareAccelerationHealthCheck hardwareAccelerationHealthCheck, IHardwareAccelerationHealthCheck hardwareAccelerationHealthCheck,
IMovieMetadataHealthCheck movieMetadataHealthCheck, IMovieMetadataHealthCheck movieMetadataHealthCheck,
IEpisodeMetadataHealthCheck episodeMetadataHealthCheck, IEpisodeMetadataHealthCheck episodeMetadataHealthCheck,
IZeroDurationHealthCheck zeroDurationHealthCheck, IZeroDurationHealthCheck zeroDurationHealthCheck,
IFileNotFoundHealthCheck fileNotFoundHealthCheck,
IVaapiDriverHealthCheck vaapiDriverHealthCheck) IVaapiDriverHealthCheck vaapiDriverHealthCheck)
{ {
_checks = new List<IHealthCheck> _checks = new List<IHealthCheck>
{ {
ffmpegVersionHealthCheck, ffmpegVersionHealthCheck,
fFmpegReportsHealthCheck, ffmpegReportsHealthCheck,
hardwareAccelerationHealthCheck, hardwareAccelerationHealthCheck,
movieMetadataHealthCheck, movieMetadataHealthCheck,
episodeMetadataHealthCheck, episodeMetadataHealthCheck,
zeroDurationHealthCheck, zeroDurationHealthCheck,
fileNotFoundHealthCheck,
vaapiDriverHealthCheck vaapiDriverHealthCheck
}; };
} }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_MediaItemState : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "State",
table: "MediaItem",
type: "INTEGER",
nullable: false,
defaultValue: 0);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "State",
table: "MediaItem");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Remove_HLSHybridStreamingMode : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
// replace HLS Hybrid with HLS Segmenter
migrationBuilder.Sql("UPDATE Channel SET StreamingMode = 4 WHERE StreamingMode = 3");
// replace MPEG-TS (Legacy) with new MPEG-TS
migrationBuilder.Sql("UPDATE Channel SET StreamingMode = 5 WHERE StreamingMode = 1");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
@@ -15,7 +15,7 @@ namespace ErsatzTV.Infrastructure.Migrations
protected override void BuildModel(ModelBuilder modelBuilder) protected override void BuildModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "6.0.0"); modelBuilder.HasAnnotation("ProductVersion", "6.0.1");
modelBuilder.Entity("ErsatzTV.Core.Domain.Actor", b => modelBuilder.Entity("ErsatzTV.Core.Domain.Actor", b =>
{ {
@@ -843,6 +843,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int>("LibraryPathId") b.Property<int>("LibraryPathId")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<int>("State")
.HasColumnType("INTEGER");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("LibraryPathId"); b.HasIndex("LibraryPathId");
+29 -9
View File
@@ -55,6 +55,7 @@ namespace ErsatzTV.Infrastructure.Search
private const string AlbumField = "album"; private const string AlbumField = "album";
private const string MinutesField = "minutes"; private const string MinutesField = "minutes";
private const string ArtistField = "artist"; private const string ArtistField = "artist";
private const string StateField = "state";
public const string MovieType = "movie"; public const string MovieType = "movie";
public const string ShowType = "show"; public const string ShowType = "show";
@@ -79,7 +80,7 @@ namespace ErsatzTV.Infrastructure.Search
_initialized = false; _initialized = false;
} }
public int Version => 18; public int Version => 19;
public Task<bool> Initialize(ILocalFileSystem localFileSystem) public Task<bool> Initialize(ILocalFileSystem localFileSystem)
{ {
@@ -160,7 +161,8 @@ namespace ErsatzTV.Infrastructure.Search
using var analyzer = new StandardAnalyzer(AppLuceneVersion); using var analyzer = new StandardAnalyzer(AppLuceneVersion);
var customAnalyzers = new Dictionary<string, Analyzer> var customAnalyzers = new Dictionary<string, Analyzer>
{ {
{ ContentRatingField, new KeywordAnalyzer() } { ContentRatingField, new KeywordAnalyzer() },
{ StateField, new KeywordAnalyzer() }
}; };
using var analyzerWrapper = new PerFieldAnalyzerWrapper(analyzer, customAnalyzers); using var analyzerWrapper = new PerFieldAnalyzerWrapper(analyzer, customAnalyzers);
QueryParser parser = !string.IsNullOrWhiteSpace(searchField) QueryParser parser = !string.IsNullOrWhiteSpace(searchField)
@@ -202,12 +204,18 @@ namespace ErsatzTV.Infrastructure.Search
{ {
_writer.DeleteAll(); _writer.DeleteAll();
await RebuildItems(searchRepository, itemIds);
_writer.Commit();
return Unit.Default;
}
public async Task<Unit> RebuildItems(ISearchRepository searchRepository, List<int> itemIds)
{
foreach (int id in itemIds) foreach (int id in itemIds)
{ {
Option<MediaItem> maybeMediaItem = await searchRepository.GetItemToIndex(id); foreach (MediaItem mediaItem in await searchRepository.GetItemToIndex(id))
if (maybeMediaItem.IsSome)
{ {
MediaItem mediaItem = maybeMediaItem.ValueUnsafe();
switch (mediaItem) switch (mediaItem)
{ {
case Movie movie: case Movie movie:
@@ -238,7 +246,6 @@ namespace ErsatzTV.Infrastructure.Search
} }
} }
_writer.Commit();
return Unit.Default; return Unit.Default;
} }
@@ -306,7 +313,8 @@ namespace ErsatzTV.Infrastructure.Search
new TextField(LibraryNameField, movie.LibraryPath.Library.Name, Field.Store.NO), new TextField(LibraryNameField, movie.LibraryPath.Library.Name, Field.Store.NO),
new StringField(LibraryIdField, movie.LibraryPath.Library.Id.ToString(), Field.Store.NO), new StringField(LibraryIdField, movie.LibraryPath.Library.Id.ToString(), Field.Store.NO),
new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO), new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO),
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES) new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES),
new StringField(StateField, movie.State.ToString(), Field.Store.NO)
}; };
await AddLanguages(searchRepository, doc, movie.MediaVersions); await AddLanguages(searchRepository, doc, movie.MediaVersions);
@@ -632,7 +640,8 @@ namespace ErsatzTV.Infrastructure.Search
new TextField(LibraryNameField, musicVideo.LibraryPath.Library.Name, Field.Store.NO), new TextField(LibraryNameField, musicVideo.LibraryPath.Library.Name, Field.Store.NO),
new StringField(LibraryIdField, musicVideo.LibraryPath.Library.Id.ToString(), Field.Store.NO), new StringField(LibraryIdField, musicVideo.LibraryPath.Library.Id.ToString(), Field.Store.NO),
new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO), new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO),
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES) new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES),
new StringField(StateField, musicVideo.State.ToString(), Field.Store.NO)
}; };
await AddLanguages(searchRepository, doc, musicVideo.MediaVersions); await AddLanguages(searchRepository, doc, musicVideo.MediaVersions);
@@ -675,6 +684,14 @@ namespace ErsatzTV.Infrastructure.Search
{ {
doc.Add(new TextField(StudioField, studio.Name, Field.Store.NO)); doc.Add(new TextField(StudioField, studio.Name, Field.Store.NO));
} }
if (musicVideo.Artist != null)
{
foreach (ArtistMetadata artistMetadata in musicVideo.Artist.ArtistMetadata)
{
doc.Add(new TextField(ArtistField, artistMetadata.Title, Field.Store.NO));
}
}
_writer.UpdateDocument(new Term(IdField, musicVideo.Id.ToString()), doc); _writer.UpdateDocument(new Term(IdField, musicVideo.Id.ToString()), doc);
} }
@@ -712,7 +729,8 @@ namespace ErsatzTV.Infrastructure.Search
new TextField(LibraryNameField, episode.LibraryPath.Library.Name, Field.Store.NO), new TextField(LibraryNameField, episode.LibraryPath.Library.Name, Field.Store.NO),
new StringField(LibraryIdField, episode.LibraryPath.Library.Id.ToString(), Field.Store.NO), new StringField(LibraryIdField, episode.LibraryPath.Library.Id.ToString(), Field.Store.NO),
new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO), new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO),
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES) new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES),
new StringField(StateField, episode.State.ToString(), Field.Store.NO)
}; };
await AddLanguages(searchRepository, doc, episode.MediaVersions); await AddLanguages(searchRepository, doc, episode.MediaVersions);
@@ -800,6 +818,7 @@ namespace ErsatzTV.Infrastructure.Search
new StringField(LibraryIdField, otherVideo.LibraryPath.Library.Id.ToString(), Field.Store.NO), new StringField(LibraryIdField, otherVideo.LibraryPath.Library.Id.ToString(), Field.Store.NO),
new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO), new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO),
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES), new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES),
new StringField(StateField, otherVideo.State.ToString(), Field.Store.NO)
}; };
await AddLanguages(searchRepository, doc, otherVideo.MediaVersions); await AddLanguages(searchRepository, doc, otherVideo.MediaVersions);
@@ -843,6 +862,7 @@ namespace ErsatzTV.Infrastructure.Search
new StringField(LibraryIdField, song.LibraryPath.Library.Id.ToString(), Field.Store.NO), new StringField(LibraryIdField, song.LibraryPath.Library.Id.ToString(), Field.Store.NO),
new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO), new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO),
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES), new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES),
new StringField(StateField, song.State.ToString(), Field.Store.NO)
}; };
await AddLanguages(searchRepository, doc, song.MediaVersions); await AddLanguages(searchRepository, doc, song.MediaVersions);
@@ -3,7 +3,7 @@
public class TraktListItemShow public class TraktListItemShow
{ {
public string Title { get; set; } public string Title { get; set; }
public int Year { get; set; } public int? Year { get; set; }
public TraktListItemIds Ids { get; set; } public TraktListItemIds Ids { get; set; }
} }
} }
+1 -1
View File
@@ -27,7 +27,7 @@ namespace ErsatzTV.Controllers
public Task<IActionResult> GetConcatPlaylist(string channelNumber) => public Task<IActionResult> GetConcatPlaylist(string channelNumber) =>
_mediator.Send(new GetConcatPlaylistByChannelNumber(Request.Scheme, Request.Host.ToString(), channelNumber)) _mediator.Send(new GetConcatPlaylistByChannelNumber(Request.Scheme, Request.Host.ToString(), channelNumber))
.ToActionResult(); .ToActionResult();
[HttpGet("ffmpeg/stream/{channelNumber}")] [HttpGet("ffmpeg/stream/{channelNumber}")]
public Task<IActionResult> GetStream( public Task<IActionResult> GetStream(
string channelNumber, string channelNumber,
+14 -3
View File
@@ -52,14 +52,24 @@ namespace ErsatzTV.Controllers
.Map<ChannelGuide, IActionResult>(Ok); .Map<ChannelGuide, IActionResult>(Ok);
[HttpGet("iptv/channel/{channelNumber}.ts")] [HttpGet("iptv/channel/{channelNumber}.ts")]
public Task<IActionResult> GetTransportStreamVideo(string channelNumber) => public async Task<IActionResult> GetTransportStreamVideo(
_mediator.Send(new GetConcatProcessByChannelNumber(Request.Scheme, Request.Host.ToString(), channelNumber)) string channelNumber,
[FromQuery]
string mode = null)
{
FFmpegProcessRequest request = mode switch
{
"legacy" => new GetConcatProcessByChannelNumber(Request.Scheme, Request.Host.ToString(), channelNumber),
_ => new GetWrappedProcessByChannelNumber(Request.Scheme, Request.Host.ToString(), channelNumber)
};
return await _mediator.Send(request)
.Map( .Map(
result => result.Match<IActionResult>( result => result.Match<IActionResult>(
processModel => processModel =>
{ {
Process process = processModel.Process; Process process = processModel.Process;
_logger.LogInformation("Starting ts stream for channel {ChannelNumber}", channelNumber); _logger.LogInformation("Starting ts stream for channel {ChannelNumber}", channelNumber);
// _logger.LogDebug( // _logger.LogDebug(
// "ffmpeg concat arguments {FFmpegArguments}", // "ffmpeg concat arguments {FFmpegArguments}",
@@ -68,6 +78,7 @@ namespace ErsatzTV.Controllers
return new FileStreamResult(process.StandardOutput.BaseStream, "video/mp2t"); return new FileStreamResult(process.StandardOutput.BaseStream, "video/mp2t");
}, },
error => BadRequest(error.Value))); error => BadRequest(error.Value)));
}
[HttpGet("iptv/session/{channelNumber}/hls.m3u8")] [HttpGet("iptv/session/{channelNumber}/hls.m3u8")]
public async Task<IActionResult> GetLivePlaylist(string channelNumber) public async Task<IActionResult> GetLivePlaylist(string channelNumber)
+5 -6
View File
@@ -4,7 +4,6 @@
<TargetFramework>net6.0</TargetFramework> <TargetFramework>net6.0</TargetFramework>
<NoWarn>VSTHRD200</NoWarn> <NoWarn>VSTHRD200</NoWarn>
<IncludeAllContentForSelfExtract>true</IncludeAllContentForSelfExtract> <IncludeAllContentForSelfExtract>true</IncludeAllContentForSelfExtract>
<DebugType>embedded</DebugType>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@@ -14,15 +13,15 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Blazored.LocalStorage" Version="4.1.5" /> <PackageReference Include="Blazored.LocalStorage" Version="4.1.5" />
<PackageReference Include="FluentValidation" Version="10.3.5" /> <PackageReference Include="FluentValidation" Version="10.3.6" />
<PackageReference Include="FluentValidation.AspNetCore" Version="10.3.5" /> <PackageReference Include="FluentValidation.AspNetCore" Version="10.3.6" />
<PackageReference Include="HtmlSanitizer" Version="6.0.453" /> <PackageReference Include="HtmlSanitizer" Version="6.0.453" />
<PackageReference Include="LanguageExt.Core" Version="4.0.3" /> <PackageReference Include="LanguageExt.Core" Version="4.0.3" />
<PackageReference Include="Markdig" Version="0.26.0" /> <PackageReference Include="Markdig" Version="0.26.0" />
<PackageReference Include="MediatR.Courier.DependencyInjection" Version="4.0.0" /> <PackageReference Include="MediatR.Courier.DependencyInjection" Version="4.0.0" />
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="9.0.0" /> <PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="6.0.0" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="6.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.0"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.1">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
@@ -30,7 +29,7 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="MudBlazor" Version="6.0.2" /> <PackageReference Include="MudBlazor" Version="6.0.4" />
<PackageReference Include="NaturalSort.Extension" Version="3.2.0" /> <PackageReference Include="NaturalSort.Extension" Version="3.2.0" />
<PackageReference Include="PPioli.FluentValidation.Blazor" Version="5.0.0" /> <PackageReference Include="PPioli.FluentValidation.Blazor" Version="5.0.0" />
<PackageReference Include="Refit.HttpClientFactory" Version="6.1.15" /> <PackageReference Include="Refit.HttpClientFactory" Version="6.1.15" />
+25 -3
View File
@@ -124,17 +124,31 @@
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8"> <MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
@foreach (MusicVideoCardViewModel musicVideo in _musicVideos.Cards) @foreach (MusicVideoCardViewModel musicVideo in _musicVideos.Cards)
{ {
<MudCard Class="mb-6"> <MudCard Class="mb-6" Style="display: flex; flex-direction: column">
<div id="@($"music-video-{musicVideo.MusicVideoId}")" style="display: flex; flex-direction: row; scroll-margin-top: 85px"> <div id="@($"music-video-{musicVideo.MusicVideoId}")" style="display: flex; flex-direction: row; scroll-margin-top: 85px">
@if (!string.IsNullOrWhiteSpace(musicVideo.Poster)) @if (!string.IsNullOrWhiteSpace(musicVideo.Poster))
{ {
<MudPaper style="display: flex; flex-direction: column"> <MudPaper style="display: flex; flex-direction: column; position: relative">
<MudCardMedia Image="@($"/artwork/thumbnails/{musicVideo.Poster}")" Style="flex-grow: 1; height: 220px; width: 293px;"/> <MudCardMedia Image="@($"/artwork/thumbnails/{musicVideo.Poster}")" Style="flex-grow: 1; height: 220px; width: 293px;"/>
@if (musicVideo.State == MediaItemState.FileNotFound)
{
<div style="position: absolute; top: 8px; right: 10px">
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Error" Size="Size.Large"/>
</div>
}
</MudPaper> </MudPaper>
} }
else else
{ {
<MudSkeleton SkeletonType="SkeletonType.Rectangle" Animation="Animation.False" Height="220px" Width="293px"/> <div style="height: 220px; width: 293px; display: flex; position: relative">
<MudSkeleton SkeletonType="SkeletonType.Rectangle" Animation="Animation.False"/>
@if (musicVideo.State == MediaItemState.FileNotFound)
{
<div style="position: absolute; top: 8px; right: 10px">
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Error" Size="Size.Large"/>
</div>
}
</div>
} }
<MudCardContent Class="ml-3"> <MudCardContent Class="ml-3">
<div style="display: flex; flex-direction: column; height: 100%"> <div style="display: flex; flex-direction: column; height: 100%">
@@ -158,6 +172,14 @@
</div> </div>
</MudCardContent> </MudCardContent>
</div> </div>
@if (musicVideo.State == MediaItemState.FileNotFound)
{
<div class="ml-3 mt-3 mb-3" style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Error" Class="mr-2"/>
<MudText>File Not Found:&nbsp;</MudText>
<MudText>@musicVideo.Path</MudText>
</div>
}
</MudCard> </MudCard>
} }
</MudContainer> </MudContainer>
+3 -3
View File
@@ -32,9 +32,9 @@
<MudTextField Label="Number" @bind-Value="_model.Number" For="@(() => _model.Number)" Immediate="true"/> <MudTextField Label="Number" @bind-Value="_model.Number" For="@(() => _model.Number)" Immediate="true"/>
<MudTextField Class="mt-3" Label="Name" @bind-Value="_model.Name" For="@(() => _model.Name)"/> <MudTextField Class="mt-3" Label="Name" @bind-Value="_model.Name" For="@(() => _model.Name)"/>
<MudSelect Class="mt-3" Label="Streaming Mode" @bind-Value="_model.StreamingMode" For="@(() => _model.StreamingMode)"> <MudSelect Class="mt-3" Label="Streaming Mode" @bind-Value="_model.StreamingMode" For="@(() => _model.StreamingMode)">
<MudSelectItem Value="@(StreamingMode.TransportStream)">MPEG-TS</MudSelectItem> <MudSelectItem Value="@(StreamingMode.TransportStreamHybrid)">MPEG-TS</MudSelectItem>
<MudSelectItem Value="@(StreamingMode.TransportStream)">MPEG-TS (Legacy)</MudSelectItem>
<MudSelectItem Value="@(StreamingMode.HttpLiveStreamingDirect)">HLS Direct</MudSelectItem> <MudSelectItem Value="@(StreamingMode.HttpLiveStreamingDirect)">HLS Direct</MudSelectItem>
<MudSelectItem Value="@(StreamingMode.HttpLiveStreamingHybrid)">HLS Hybrid</MudSelectItem>
<MudSelectItem Value="@(StreamingMode.HttpLiveStreamingSegmenter)">HLS Segmenter</MudSelectItem> <MudSelectItem Value="@(StreamingMode.HttpLiveStreamingSegmenter)">HLS Segmenter</MudSelectItem>
</MudSelect> </MudSelect>
<MudSelect Class="mt-3" Label="FFmpeg Profile" @bind-Value="_model.FFmpegProfileId" For="@(() => _model.FFmpegProfileId)" <MudSelect Class="mt-3" Label="FFmpeg Profile" @bind-Value="_model.FFmpegProfileId" For="@(() => _model.FFmpegProfileId)"
@@ -154,7 +154,7 @@
_model.Number = (maxNumber + 1).ToString(); _model.Number = (maxNumber + 1).ToString();
_model.Name = "New Channel"; _model.Name = "New Channel";
_model.FFmpegProfileId = ffmpegSettings.DefaultFFmpegProfileId; _model.FFmpegProfileId = ffmpegSettings.DefaultFFmpegProfileId;
_model.StreamingMode = StreamingMode.TransportStream; _model.StreamingMode = StreamingMode.TransportStreamHybrid;
} }
} }
+2 -2
View File
@@ -140,9 +140,9 @@
private static string GetStreamingMode(StreamingMode streamingMode) => streamingMode switch { private static string GetStreamingMode(StreamingMode streamingMode) => streamingMode switch {
StreamingMode.HttpLiveStreamingDirect => "HLS Direct", StreamingMode.HttpLiveStreamingDirect => "HLS Direct",
StreamingMode.HttpLiveStreamingHybrid => "HLS Hybrid",
StreamingMode.HttpLiveStreamingSegmenter => "HLS Segmenter", StreamingMode.HttpLiveStreamingSegmenter => "HLS Segmenter",
_ => "MPEG-TS" StreamingMode.TransportStreamHybrid => "MPEG-TS",
_ => "MPEG-TS (Legacy)"
}; };
} }
+17 -1
View File
@@ -49,7 +49,23 @@
<div class="ml-2">@context.Title</div> <div class="ml-2">@context.Title</div>
</div> </div>
</MudTd> </MudTd>
<MudTd DataLabel="Message">@context.Message</MudTd> <MudTd DataLabel="Message">
@if (context.Link.IsSome)
{
foreach (string link in context.Link)
{
<MudLink Href="@link">
@context.Message
</MudLink>
}
}
else
{
<MudText>
@context.Message
</MudText>
}
</MudTd>
</RowTemplate> </RowTemplate>
</MudTable> </MudTable>
</MudContainer> </MudContainer>
+32 -12
View File
@@ -28,18 +28,26 @@
<div style="display: flex; flex-direction: row;" class="mb-6"> <div style="display: flex; flex-direction: row;" class="mb-6">
@if (!string.IsNullOrWhiteSpace(_movie.Poster)) @if (!string.IsNullOrWhiteSpace(_movie.Poster))
{ {
if (_movie.Poster.StartsWith("http://") || _movie.Poster.StartsWith("https://")) <div class="mr-6" style="display: flex; flex-direction: column; max-height: 440px; position: relative">
{ @if (_movie.Poster.StartsWith("http://") || _movie.Poster.StartsWith("https://"))
<img class="mud-elevation-2 mr-6" {
style="border-radius: 4px; flex-shrink: 0; max-height: 440px;" <img class="mud-elevation-2"
src="@_movie.Poster" alt="movie poster"/> style="border-radius: 4px; flex-shrink: 0; max-height: 440px;"
} src="@_movie.Poster" alt="movie poster"/>
else }
{ else
<img class="mud-elevation-2 mr-6" {
style="border-radius: 4px; flex-shrink: 0; max-height: 440px;" <img class="mud-elevation-2"
src="@($"/artwork/posters/{_movie.Poster}")" alt="movie poster"/> style="border-radius: 4px; flex-shrink: 0; max-height: 440px;"
} src="@($"/artwork/posters/{_movie.Poster}")" alt="movie poster"/>
}
@if (_movie.MediaItemState == MediaItemState.FileNotFound)
{
<div style="position: absolute; top: 8px; right: 10px">
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Error" Size="Size.Large"/>
</div>
}
</div>
} }
<div style="display: flex; flex-direction: column; height: 100%"> <div style="display: flex; flex-direction: column; height: 100%">
<MudText Typo="Typo.h2" Class="media-item-title">@_movie.Title</MudText> <MudText Typo="Typo.h2" Class="media-item-title">@_movie.Title</MudText>
@@ -62,6 +70,18 @@
</div> </div>
</div> </div>
</div> </div>
@if (_movie.MediaItemState == MediaItemState.FileNotFound)
{
<MudCard Class="mb-6">
<MudCardContent>
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Error" Class="mr-2"/>
<MudText>File Not Found:&nbsp;</MudText>
<MudText>@_movie.Path</MudText>
</div>
</MudCardContent>
</MudCard>
}
<MudCard Class="mb-6"> <MudCard Class="mb-6">
<MudCardContent> <MudCardContent>
@if (_sortedContentRatings.Any()) @if (_sortedContentRatings.Any())
+2
View File
@@ -42,6 +42,8 @@ namespace ErsatzTV.Pages
[Inject] [Inject]
protected IMediator Mediator { get; set; } protected IMediator Mediator { get; set; }
protected System.Collections.Generic.HashSet<MediaCardViewModel> SelectedItems => _selectedItems;
protected bool IsSelected(MediaCardViewModel card) => protected bool IsSelected(MediaCardViewModel card) =>
_selectedItems.Contains(card); _selectedItems.Contains(card);
+22 -10
View File
@@ -78,18 +78,22 @@
<div id="@($"episode-{episode.EpisodeId}")" style="display: flex; flex-direction: row; scroll-margin-top: 85px"> <div id="@($"episode-{episode.EpisodeId}")" style="display: flex; flex-direction: row; scroll-margin-top: 85px">
@if (!string.IsNullOrWhiteSpace(episode.Poster)) @if (!string.IsNullOrWhiteSpace(episode.Poster))
{ {
if (episode.Poster.StartsWith("http://") || episode.Poster.StartsWith("https://")) <MudPaper style="display: flex; flex-direction: column; position: relative">
{ @if (episode.Poster.StartsWith("http://") || episode.Poster.StartsWith("https://"))
<MudPaper style="display: flex; flex-direction: column"> {
<MudCardMedia Image="@episode.Poster" Style="flex-grow: 1; height: 220px; width: 392px;"/> <MudCardMedia Image="@episode.Poster" Style="flex-grow: 1; height: 220px; width: 392px;"/>
</MudPaper> }
} else
else {
{
<MudPaper style="display: flex; flex-direction: column">
<MudCardMedia Image="@($"/artwork/thumbnails/{episode.Poster}")" Style="flex-grow: 1; height: 220px; width: 392px;"/> <MudCardMedia Image="@($"/artwork/thumbnails/{episode.Poster}")" Style="flex-grow: 1; height: 220px; width: 392px;"/>
</MudPaper> }
} @if (episode.State == MediaItemState.FileNotFound)
{
<div style="position: absolute; top: 8px; right: 10px">
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Error" Size="Size.Large"/>
</div>
}
</MudPaper>
} }
<MudCardContent Class="ml-3"> <MudCardContent Class="ml-3">
<div style="display: flex; flex-direction: column; height: 100%"> <div style="display: flex; flex-direction: column; height: 100%">
@@ -107,6 +111,14 @@
</MudCardContent> </MudCardContent>
</div> </div>
<div class="pl-3 pt-3"> <div class="pl-3 pt-3">
@if (episode.State == MediaItemState.FileNotFound)
{
<div class="mb-3" style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Error" Class="mr-2"/>
<MudText>File Not Found:&nbsp;</MudText>
<MudText>@episode.Path</MudText>
</div>
}
<div style="display: flex; flex-direction: row; flex-wrap: wrap"> <div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Released: @episode.Aired.ToShortDateString()</MudText> <MudText GutterBottom="true">Released: @episode.Aired.ToShortDateString()</MudText>
</div> </div>
+564
View File
@@ -0,0 +1,564 @@
@page "/media/trash"
@using ErsatzTV.Application.MediaCards
@using ErsatzTV.Application.Search.Queries
@using ErsatzTV.Extensions
@using Unit = LanguageExt.Unit
@using ErsatzTV.Application.Maintenance.Commands
@inherits MultiSelectBase<Search>
@inject NavigationManager _navigationManager
@inject ChannelWriter<IBackgroundServiceRequest> _channel
<MudPaper Square="true" Style="display: flex; height: 64px; left: 240px; padding: 0; position: fixed; right: 0; z-index: 100;">
<div style="display: flex; flex-direction: row; margin-bottom: auto; margin-top: auto; width: 100%" class="ml-6 mr-6">
@if (IsSelectMode())
{
<MudText Typo="Typo.h6" Color="Color.Primary">@SelectionLabel()</MudText>
<div style="margin-left: auto">
<MudButton Variant="Variant.Filled"
Color="Color.Error"
StartIcon="@Icons.Material.Filled.Delete"
OnClick="@(_ => DeleteFromDatabase())">
Delete From Database
</MudButton>
<MudButton Class="ml-3"
Variant="Variant.Filled"
Color="Color.Secondary"
StartIcon="@Icons.Material.Filled.Check"
OnClick="@(_ => ClearSelection())">
Clear Selection
</MudButton>
</div>
}
else
{
if (_movies?.Count > 0)
{
<MudLink Class="ml-4" Href="@(_navigationManager.Uri.Split("#").Head() + "#movies")" Style="margin-bottom: auto; margin-top: auto">@_movies.Count Movies</MudLink>
}
if (_shows?.Count > 0)
{
<MudLink Class="ml-4" Href="@(_navigationManager.Uri.Split("#").Head() + "#shows")" Style="margin-bottom: auto; margin-top: auto">@_shows.Count Shows</MudLink>
}
if (_seasons?.Count > 0)
{
<MudLink Class="ml-4" Href="@(_navigationManager.Uri.Split("#").Head() + "#seasons")" Style="margin-bottom: auto; margin-top: auto">@_seasons.Count Seasons</MudLink>
}
if (_episodes?.Count > 0)
{
<MudLink Class="ml-4" Href="@(_navigationManager.Uri.Split("#").Head() + "#episodes")" Style="margin-bottom: auto; margin-top: auto">@_episodes.Count Episodes</MudLink>
}
if (_artists?.Count > 0)
{
<MudLink Class="ml-4" Href="@(_navigationManager.Uri.Split("#").Head() + "#artists")" Style="margin-bottom: auto; margin-top: auto">@_artists.Count Artists</MudLink>
}
if (_musicVideos?.Count > 0)
{
<MudLink Class="ml-4" Href="@(_navigationManager.Uri.Split("#").Head() + "#music_videos")" Style="margin-bottom: auto; margin-top: auto">@_musicVideos.Count Music Videos</MudLink>
}
if (_otherVideos?.Count > 0)
{
<MudLink Class="ml-4" Href="@(_navigationManager.Uri.Split("#").Head() + "#other_videos")" Style="margin-bottom: auto; margin-top: auto">@_otherVideos.Count Other Videos</MudLink>
}
if (_songs?.Count > 0)
{
<MudLink Class="ml-4" Href="@(_navigationManager.Uri.Split("#").Head() + "#songs")" Style="margin-bottom: auto; margin-top: auto">@_songs.Count Songs</MudLink>
}
if (_movies?.Count == 0 && _shows?.Count == 0 && _seasons?.Count == 0 && _episodes?.Count == 0 && _artists?.Count == 0 && _musicVideos?.Count == 0 && _otherVideos?.Count == 0 && _songs?.Count == 0)
{
<MudText>Nothing to see here...</MudText>
}
}
</div>
</MudPaper>
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Style="margin-top: 96px">
@if (_movies?.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "movies" } })">
Movies
</MudText>
@if (_movies.Count > 50)
{
<MudLink Href="@GetMoviesLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (MovieCardViewModel card in _movies.Cards.OrderBy(m => m.SortTitle))
{
<MediaCard Data="@card"
Link="@($"/media/movies/{card.MovieId}")"
DeleteClicked="@DeleteItemFromDatabase"
SelectColor="@Color.Error"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudContainer>
}
@if (_shows?.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "shows" } })">
Shows
</MudText>
@if (_shows.Count > 50)
{
<MudLink Href="@GetShowsLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (TelevisionShowCardViewModel card in _shows.Cards.OrderBy(s => s.SortTitle))
{
<MediaCard Data="@card"
Link="@($"/media/tv/shows/{card.TelevisionShowId}")"
DeleteClicked="@DeleteItemFromDatabase"
SelectColor="@Color.Error"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudContainer>
}
@if (_seasons?.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "seasons" } })">
Seasons
</MudText>
@if (_seasons.Count > 50)
{
<MudLink Href="@GetSeasonsLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (TelevisionSeasonCardViewModel card in _seasons.Cards.OrderBy(s => s.SortTitle))
{
<MediaCard Data="@card"
Link="@($"/media/tv/seasons/{card.TelevisionSeasonId}")"
DeleteClicked="@DeleteItemFromDatabase"
SelectColor="@Color.Error"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudContainer>
}
@if (_episodes?.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "episodes" } })">
Episodes
</MudText>
@if (_episodes.Count > 50)
{
<MudLink Href="@GetEpisodesLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (TelevisionEpisodeCardViewModel card in _episodes.Cards.OrderBy(s => s.SortTitle))
{
<MediaCard Data="@card"
DeleteClicked="@DeleteItemFromDatabase"
SelectColor="@Color.Error"
Link="@($"/media/tv/seasons/{card.SeasonId}#episode-{card.EpisodeId}")"
Subtitle="@($"{card.ShowTitle} - S{card.Season} E{card.Episode}")"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudContainer>
}
@if (_artists?.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "artists" } })">
Artists
</MudText>
@if (_artists.Count > 50)
{
<MudLink Href="@GetArtistsLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (ArtistCardViewModel card in _artists.Cards.OrderBy(s => s.SortTitle))
{
<MediaCard Data="@card"
Link="@($"/media/music/artists/{card.ArtistId}")"
ArtworkKind="ArtworkKind.Thumbnail"
DeleteClicked="@DeleteItemFromDatabase"
SelectColor="@Color.Error"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudContainer>
}
@if (_musicVideos?.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "music_videos" } })">
Music Videos
</MudText>
@if (_musicVideos.Count > 50)
{
<MudLink Href="@GetMusicVideosLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (MusicVideoCardViewModel card in _musicVideos.Cards.OrderBy(s => s.SortTitle))
{
<MediaCard Data="@card"
Link=""
ArtworkKind="ArtworkKind.Thumbnail"
DeleteClicked="@DeleteItemFromDatabase"
SelectColor="@Color.Error"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudContainer>
}
@if (_otherVideos?.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "other_videos" } })">
Other Videos
</MudText>
@if (_otherVideos.Count > 50)
{
<MudLink Href="@GetOtherVideosLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (OtherVideoCardViewModel card in _otherVideos.Cards.OrderBy(s => s.SortTitle))
{
<MediaCard Data="@card"
Link=""
ArtworkKind="ArtworkKind.Thumbnail"
DeleteClicked="@DeleteItemFromDatabase"
SelectColor="@Color.Error"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudContainer>
}
@if (_songs?.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "songs" } })">
Songs
</MudText>
@if (_songs.Count > 50)
{
<MudLink Href="@GetSongsLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (SongCardViewModel card in _songs.Cards.OrderBy(s => s.SortTitle))
{
<MediaCard Data="@card"
Link=""
ArtworkKind="ArtworkKind.Thumbnail"
DeleteClicked="@DeleteItemFromDatabase"
SelectColor="@Color.Error"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudContainer>
}
</MudContainer>
@code {
private string _query;
private MovieCardResultsViewModel _movies;
private TelevisionShowCardResultsViewModel _shows;
private TelevisionSeasonCardResultsViewModel _seasons;
private TelevisionEpisodeCardResultsViewModel _episodes;
private MusicVideoCardResultsViewModel _musicVideos;
private OtherVideoCardResultsViewModel _otherVideos;
private SongCardResultsViewModel _songs;
private ArtistCardResultsViewModel _artists;
protected override Task OnInitializedAsync() => RefreshData();
protected override async Task RefreshData()
{
_query = "state:FileNotFound";
if (!string.IsNullOrWhiteSpace(_query))
{
_movies = await Mediator.Send(new QuerySearchIndexMovies($"type:movie AND ({_query})", 1, 50));
_shows = await Mediator.Send(new QuerySearchIndexShows($"type:show AND ({_query})", 1, 50));
_seasons = await Mediator.Send(new QuerySearchIndexSeasons($"type:season AND ({_query})", 1, 50));
_episodes = await Mediator.Send(new QuerySearchIndexEpisodes($"type:episode AND ({_query})", 1, 50));
_musicVideos = await Mediator.Send(new QuerySearchIndexMusicVideos($"type:music_video AND ({_query})", 1, 50));
_otherVideos = await Mediator.Send(new QuerySearchIndexOtherVideos($"type:other_video AND ({_query})", 1, 50));
_songs = await Mediator.Send(new QuerySearchIndexSongs($"type:song AND ({_query})", 1, 50));
_artists = await Mediator.Send(new QuerySearchIndexArtists($"type:artist AND ({_query})", 1, 50));
}
}
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
{
List<MediaCardViewModel> GetSortedItems()
{
return _movies.Cards.OrderBy(m => m.SortTitle)
.Append<MediaCardViewModel>(_shows.Cards.OrderBy(s => s.SortTitle))
.Append(_seasons.Cards.OrderBy(s => s.SortTitle))
.Append(_episodes.Cards.OrderBy(ep => ep.SortTitle))
.Append(_artists.Cards.OrderBy(a => a.SortTitle))
.Append(_musicVideos.Cards.OrderBy(mv => mv.SortTitle))
.Append(_otherVideos.Cards.OrderBy(ov => ov.SortTitle))
.Append(_songs.Cards.OrderBy(ov => ov.SortTitle))
.ToList();
}
SelectClicked(GetSortedItems, card, e);
}
private string GetMoviesLink()
{
var uri = "/media/movies/page/1";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
return uri;
}
private string GetShowsLink()
{
var uri = "/media/tv/shows/page/1";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
return uri;
}
private string GetSeasonsLink()
{
var uri = "/media/tv/seasons/page/1";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
return uri;
}
private string GetEpisodesLink()
{
var uri = "/media/tv/episodes/page/1";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
return uri;
}
private string GetArtistsLink()
{
var uri = "/media/music/artists/page/1";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
return uri;
}
private string GetMusicVideosLink()
{
var uri = "/media/music/videos/page/1";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
return uri;
}
private string GetOtherVideosLink()
{
var uri = "/media/other/videos/page/1";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
return uri;
}
private string GetSongsLink()
{
var uri = "/media/music/songs/page/1";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
return uri;
}
private Task DeleteFromDatabase() => DeleteItemsFromDatabase(
SelectedItems.OfType<MovieCardViewModel>().Map(m => m.MovieId).ToList(),
SelectedItems.OfType<TelevisionShowCardViewModel>().Map(s => s.TelevisionShowId).ToList(),
SelectedItems.OfType<TelevisionSeasonCardViewModel>().Map(s => s.TelevisionSeasonId).ToList(),
SelectedItems.OfType<TelevisionEpisodeCardViewModel>().Map(e => e.EpisodeId).ToList(),
SelectedItems.OfType<ArtistCardViewModel>().Map(a => a.ArtistId).ToList(),
SelectedItems.OfType<MusicVideoCardViewModel>().Map(mv => mv.MusicVideoId).ToList(),
SelectedItems.OfType<OtherVideoCardViewModel>().Map(ov => ov.OtherVideoId).ToList(),
SelectedItems.OfType<SongCardViewModel>().Map(s => s.SongId).ToList());
private async Task DeleteItemsFromDatabase(
List<int> movieIds,
List<int> showIds,
List<int> seasonIds,
List<int> episodeIds,
List<int> artistIds,
List<int> musicVideoIds,
List<int> otherVideoIds,
List<int> songIds,
string entityName = "selected items")
{
int count = movieIds.Count + showIds.Count + seasonIds.Count + episodeIds.Count + artistIds.Count +
musicVideoIds.Count + otherVideoIds.Count + songIds.Count;
var parameters = new DialogParameters
{ { "EntityType", count.ToString() }, { "EntityName", entityName } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = Dialog.Show<DeleteFromDatabaseDialog>("Delete From Database", parameters, options);
DialogResult result = await dialog.Result;
if (!result.Cancelled)
{
var request = new DeleteItemsFromDatabase(
movieIds.Append(showIds)
.Append(seasonIds)
.Append(episodeIds)
.Append(artistIds)
.Append(musicVideoIds)
.Append(otherVideoIds)
.Append(songIds)
.ToList());
Either<BaseError, Unit> addResult = await Mediator.Send(request);
await addResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error deleting items from database: {error.Value}");
Logger.LogError("Unexpected error deleting items from database: {Error}", error.Value);
return Task.CompletedTask;
},
Right: async _ =>
{
Snackbar.Add($"Deleted {count} items from the database", Severity.Success);
ClearSelection();
await RefreshData();
});
}
}
private async Task DeleteItemFromDatabase(MediaCardViewModel vm)
{
DeleteItemsFromDatabase request;
switch (vm)
{
case MovieCardViewModel movie:
request = new DeleteItemsFromDatabase(new List<int> { movie.MovieId });
await DeleteItemsWithConfirmation("movie", $"{movie.Title} ({movie.Subtitle})", request);
break;
case TelevisionShowCardViewModel show:
request = new DeleteItemsFromDatabase(new List<int> { show.TelevisionShowId });
await DeleteItemsWithConfirmation("show", $"{show.Title} ({show.Subtitle})", request);
break;
case TelevisionSeasonCardViewModel season:
request = new DeleteItemsFromDatabase(new List<int> { season.TelevisionSeasonId });
await DeleteItemsWithConfirmation("season", $"{season.Title} ({season.Subtitle})", request);
break;
case TelevisionEpisodeCardViewModel episode:
request = new DeleteItemsFromDatabase(new List<int> { episode.EpisodeId });
await DeleteItemsWithConfirmation("episode", $"{episode.Title} ({episode.Subtitle})", request);
break;
case ArtistCardViewModel artist:
request = new DeleteItemsFromDatabase(new List<int> { artist.ArtistId });
await DeleteItemsWithConfirmation("artist", $"{artist.Title} ({artist.Subtitle})", request);
break;
case MusicVideoCardViewModel musicVideo:
request = new DeleteItemsFromDatabase(new List<int> { musicVideo.MusicVideoId });
await DeleteItemsWithConfirmation("music video", $"{musicVideo.Title} ({musicVideo.Subtitle})", request);
break;
case OtherVideoCardViewModel otherVideo:
request = new DeleteItemsFromDatabase(new List<int> { otherVideo.OtherVideoId });
await DeleteItemsWithConfirmation("other video", $"{otherVideo.Title} ({otherVideo.Subtitle})", request);
break;
case SongCardViewModel song:
request = new DeleteItemsFromDatabase(new List<int> { song.SongId });
await DeleteItemsWithConfirmation("song", $"{song.Title} ({song.Subtitle})", request);
break;
}
}
private async Task DeleteItemsWithConfirmation(
string entityType,
string entityName,
DeleteItemsFromDatabase request)
{
var parameters = new DialogParameters { { "EntityType", entityType }, { "EntityName", entityName } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = Dialog.Show<DeleteFromDatabaseDialog>("Delete From Database", parameters, options);
DialogResult result = await dialog.Result;
if (!result.Cancelled)
{
await Mediator.Send(request);
await RefreshData();
}
}
}
+2 -1
View File
@@ -1,5 +1,6 @@
using System; using System;
using System.IO; using System.IO;
using System.Reflection;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core; using ErsatzTV.Core;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
@@ -12,7 +13,7 @@ namespace ErsatzTV
public class Program public class Program
{ {
private static IConfiguration Configuration { get; } = new ConfigurationBuilder() private static IConfiguration Configuration { get; } = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory()) .SetBasePath(Path.GetDirectoryName(Assembly.GetEntryAssembly().Location))
.AddJsonFile("appsettings.json", false, true) .AddJsonFile("appsettings.json", false, true)
.AddJsonFile( .AddJsonFile(
$"appsettings.{Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production"}.json", $"appsettings.{Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production"}.json",
@@ -2,9 +2,7 @@
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Runtime; using ErsatzTV.Core.Interfaces.Runtime;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
@@ -33,13 +31,6 @@ namespace ErsatzTV.Services.RunOnce
await using TvContext dbContext = scope.ServiceProvider.GetRequiredService<TvContext>(); await using TvContext dbContext = scope.ServiceProvider.GetRequiredService<TvContext>();
IRuntimeInfo runtimeInfo = scope.ServiceProvider.GetRequiredService<IRuntimeInfo>(); IRuntimeInfo runtimeInfo = scope.ServiceProvider.GetRequiredService<IRuntimeInfo>();
if (runtimeInfo != null && runtimeInfo.IsOSPlatform(OSPlatform.Windows))
{
_logger.LogInformation("Disabling ffmpeg reports on Windows platform");
IConfigElementRepository repo = scope.ServiceProvider.GetRequiredService<IConfigElementRepository>();
await repo.Upsert(ConfigElementKey.FFmpegSaveReports, false);
}
if (runtimeInfo != null && runtimeInfo.IsOSPlatform(OSPlatform.Linux) && if (runtimeInfo != null && runtimeInfo.IsOSPlatform(OSPlatform.Linux) &&
System.IO.Directory.Exists("/dev/dri")) System.IO.Directory.Exists("/dev/dri"))
{ {

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