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

View File

@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout master
uses: actions/checkout@v1
uses: actions/checkout@v2
- name: Deploy docs
uses: mhausenblas/mkdocs-deploy-gh-pages@master

View File

@@ -47,7 +47,7 @@ jobs:
release_name="ErsatzTV-$tag-${{ matrix.target }}"
# Build everything
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net6.0 --runtime "${{ matrix.target }}" -c Release -o "$release_name" /property:InformationalVersion="${tag:1}-${{ matrix.target }}" /property:EnableCompressionInSingleFile=true /property: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
if [ "${{ matrix.target }}" == "win-x64" ]; then

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/).
## [Unreleased]
### Fixed
- Fix local folder scanners to properly detect removed/re-added folders with unchanged contents
- Fix double-click startup on mac
- Fix trakt list sync when show does not contain a year
- Properly unlock libraries when a scan is unable to be performed because ffmpeg or ffprobe have not been found
### Added
- Add trash system for local libraries to maintain collection and schedule integrity through media share outages
- When items are missing from disk, they will be flagged and present in the `Media` > `Trash` page
- The trash page can be used to permanently remove missing items from the database
- When items reappear at the expected location on disk, they will be unflagged and removed from the trash
- Add basic Mac hardware acceleration using VideoToolbox
### Changed
- Local libraries only: when items are missing from disk, they will be added to the trash and no longer removed from collections, etc.
- Show song thumbnail in song list
## [0.3.6-alpha] - 2022-01-10
### Fixed
- Properly index `minutes` field when adding new items during scan (vs when rebuilding index)
- Fix some nvenc edge cases where only padding is needed for normalization
- Properly overwrite environment variables for ffmpeg processes (`LIBVA_DRIVER_NAME`, `FFREPORT`)
### Added
- Add music video `artist` to search index
- This requires rebuilding the search index and search results may be empty or incomplete until the rebuild is complete
### Changed
- Remove `HLS Hybrid` streaming mode; all channels have been reconfigured to use the superior `HLS Segmenter` streaming mode
- Update `MPEG-TS` streaming mode to internally use the HLS segmenter
- This improves compatibility with many clients and also improves performance at program boundaries
- Renamed existing `MPEG-TS` mode as `MPEG-TS (Legacy)`
- This mode will be removed in a future release
## [0.3.5-alpha] - 2022-01-05
### Fixed
- Fix bundled ffmpeg version in base docker image (NOT nvidia or vaapi) which prevented playback since v0.3.0-alpha
- Use software decoding for mpeg4 content when VAAPI acceleration is enabled
- Fix hardware acceleration health check to recognize QSV on non-Windows platforms
### Changed
- Treat `setsar` as a hardware filter, avoiding unneeded `hwdownload` and `hwupload` steps when padding isn't required
## [0.3.4-alpha] - 2021-12-21
### Fixed
- Fix other video and song scanners to include videos contained directly in top-level folders that are added to a library
- Allow saving ffmpeg troubleshooting reports on Windows
## [0.3.3-alpha] - 2021-12-12
### Fixed
@@ -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.
[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.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

View File

@@ -3,7 +3,6 @@
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<NoWarn>VSTHRD200</NoWarn>
<DebugType>embedded</DebugType>
</PropertyGroup>
<ItemGroup>

View File

@@ -1,13 +1,11 @@
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Runtime;
using LanguageExt;
namespace ErsatzTV.Application.FFmpegProfiles.Commands
@@ -16,16 +14,13 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
{
private readonly IConfigElementRepository _configElementRepository;
private readonly ILocalFileSystem _localFileSystem;
private readonly IRuntimeInfo _runtimeInfo;
public UpdateFFmpegSettingsHandler(
IConfigElementRepository configElementRepository,
ILocalFileSystem localFileSystem,
IRuntimeInfo runtimeInfo)
ILocalFileSystem localFileSystem)
{
_configElementRepository = configElementRepository;
_localFileSystem = localFileSystem;
_runtimeInfo = runtimeInfo;
}
public Task<Either<BaseError, Unit>> Handle(
@@ -36,8 +31,8 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
.Bind(v => v.ToEitherAsync());
private async Task<Validation<BaseError, Unit>> Validate(UpdateFFmpegSettings request) =>
(await FFmpegMustExist(request), await FFprobeMustExist(request), ReportsAreNotSupportedOnWindows(request))
.Apply((_, _, _) => Unit.Default);
(await FFmpegMustExist(request), await FFprobeMustExist(request))
.Apply((_, _) => Unit.Default);
private Task<Validation<BaseError, Unit>> FFmpegMustExist(UpdateFFmpegSettings request) =>
ValidateToolPath(request.Settings.FFmpegPath, "ffmpeg");
@@ -45,16 +40,6 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
private Task<Validation<BaseError, Unit>> FFprobeMustExist(UpdateFFmpegSettings request) =>
ValidateToolPath(request.Settings.FFprobePath, "ffprobe");
private Validation<BaseError, Unit> ReportsAreNotSupportedOnWindows(UpdateFFmpegSettings request)
{
if (request.Settings.SaveReports && _runtimeInfo.IsOSPlatform(OSPlatform.Windows))
{
return BaseError.New("FFmpeg reports are not supported on Windows");
}
return Unit.Default;
}
private async Task<Validation<BaseError, Unit>> ValidateToolPath(string path, string name)
{
if (!_localFileSystem.FileExists(path))

View File

@@ -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>>;

View File

@@ -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;
}
}
}

View File

@@ -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) :
MediaCardViewModel(Id, Name, Role, Name, Thumb);
public record ActorCardViewModel(int Id, string Name, string Role, string Thumb, MediaItemState State) :
MediaCardViewModel(Id, Name, Role, Name, Thumb, State);
}

View File

@@ -1,10 +1,19 @@
namespace ErsatzTV.Application.MediaCards
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
{
public record ArtistCardViewModel
(int ArtistId, string Title, string Subtitle, string SortTitle, string Poster) : MediaCardViewModel(
ArtistId,
Title,
Subtitle,
SortTitle,
Poster);
(
int ArtistId,
string Title,
string Subtitle,
string SortTitle,
string Poster,
MediaItemState State) : MediaCardViewModel(
ArtistId,
Title,
Subtitle,
SortTitle,
Poster,
State);
}

View File

@@ -2,6 +2,7 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Jellyfin;
using LanguageExt;
using static LanguageExt.Prelude;
@@ -19,7 +20,8 @@ namespace ErsatzTV.Application.MediaCards
showMetadata.Title,
showMetadata.Year?.ToString(),
showMetadata.SortTitle,
GetPoster(showMetadata, maybeJellyfin, maybeEmby));
GetPoster(showMetadata, maybeJellyfin, maybeEmby),
showMetadata.Show.State);
internal static TelevisionSeasonCardViewModel ProjectToViewModel(
Season season,
@@ -34,7 +36,8 @@ namespace ErsatzTV.Application.MediaCards
GetSeasonName(season.SeasonNumber),
season.SeasonMetadata.HeadOrNone().Map(sm => GetPoster(sm, maybeJellyfin, maybeEmby))
.IfNone(string.Empty),
season.SeasonNumber == 0 ? "S" : season.SeasonNumber.ToString());
season.SeasonNumber == 0 ? "S" : season.SeasonNumber.ToString(),
season.State);
internal static TelevisionSeasonCardViewModel ProjectToViewModel(
SeasonMetadata seasonMetadata,
@@ -53,7 +56,8 @@ namespace ErsatzTV.Application.MediaCards
GetSeasonName(seasonMetadata.Season.SeasonNumber),
$"{showTitle}_{seasonMetadata.Season.SeasonNumber:0000}",
GetPoster(seasonMetadata, maybeJellyfin, maybeEmby),
seasonMetadata.Season.SeasonNumber == 0 ? "S" : seasonMetadata.Season.SeasonNumber.ToString());
seasonMetadata.Season.SeasonNumber == 0 ? "S" : seasonMetadata.Season.SeasonNumber.ToString(),
seasonMetadata.Season.State);
}
internal static TelevisionEpisodeCardViewModel ProjectToViewModel(
@@ -80,7 +84,9 @@ namespace ErsatzTV.Application.MediaCards
? GetEpisodePoster(episodeMetadata, maybeJellyfin, maybeEmby)
: GetThumbnail(episodeMetadata, maybeJellyfin, maybeEmby),
episodeMetadata.Directors.Map(d => d.Name).ToList(),
episodeMetadata.Writers.Map(w => w.Name).ToList());
episodeMetadata.Writers.Map(w => w.Name).ToList(),
episodeMetadata.Episode.State,
episodeMetadata.Episode.GetHeadVersion().MediaFiles.Head().Path);
internal static MovieCardViewModel ProjectToViewModel(
MovieMetadata movieMetadata,
@@ -91,7 +97,8 @@ namespace ErsatzTV.Application.MediaCards
movieMetadata.Title,
movieMetadata.Year?.ToString(),
movieMetadata.SortTitle,
GetPoster(movieMetadata, maybeJellyfin, maybeEmby));
GetPoster(movieMetadata, maybeJellyfin, maybeEmby),
movieMetadata.Movie.State);
internal static MusicVideoCardViewModel ProjectToViewModel(MusicVideoMetadata musicVideoMetadata) =>
new(
@@ -101,14 +108,17 @@ namespace ErsatzTV.Application.MediaCards
musicVideoMetadata.SortTitle,
musicVideoMetadata.Plot,
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) =>
new(
otherVideoMetadata.OtherVideoId,
otherVideoMetadata.Title,
otherVideoMetadata.OriginalTitle,
otherVideoMetadata.SortTitle);
otherVideoMetadata.SortTitle,
otherVideoMetadata.OtherVideo.State);
internal static SongCardViewModel ProjectToViewModel(SongMetadata songMetadata)
{
@@ -117,7 +127,9 @@ namespace ErsatzTV.Application.MediaCards
songMetadata.SongId,
songMetadata.Title,
songMetadata.Artist + album,
songMetadata.SortTitle);
songMetadata.SortTitle,
GetThumbnail(songMetadata, None, None),
songMetadata.Song.State);
}
internal static ArtistCardViewModel ProjectToViewModel(ArtistMetadata artistMetadata) =>
@@ -126,7 +138,8 @@ namespace ErsatzTV.Application.MediaCards
artistMetadata.Title,
artistMetadata.Disambiguation,
artistMetadata.SortTitle,
GetThumbnail(artistMetadata, None, None));
GetThumbnail(artistMetadata, None, None),
artistMetadata.Artist.State);
internal static CollectionCardResultsViewModel
ProjectToViewModel(
@@ -174,7 +187,7 @@ namespace ErsatzTV.Application.MediaCards
.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) =>

View File

@@ -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);
}

View File

@@ -1,12 +1,21 @@
namespace ErsatzTV.Application.MediaCards
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
{
public record MovieCardViewModel
(int MovieId, string Title, string Subtitle, string SortTitle, string Poster) : MediaCardViewModel(
MovieId,
Title,
Subtitle,
SortTitle,
Poster)
(
int MovieId,
string Title,
string Subtitle,
string SortTitle,
string Poster,
MediaItemState State) : MediaCardViewModel(
MovieId,
Title,
Subtitle,
SortTitle,
Poster,
State)
{
public int CustomIndex { get; set; }
}

View File

@@ -1,4 +1,6 @@
namespace ErsatzTV.Application.MediaCards
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
{
public record MusicVideoCardViewModel
(
@@ -8,12 +10,15 @@
string SortTitle,
string Plot,
string Album,
string Poster) : MediaCardViewModel(
string Poster,
MediaItemState State,
string Path) : MediaCardViewModel(
MusicVideoId,
Title,
Subtitle,
SortTitle,
Poster)
Poster,
State)
{
public int CustomIndex { get; set; }
}

View File

@@ -1,16 +1,20 @@
namespace ErsatzTV.Application.MediaCards
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
{
public record OtherVideoCardViewModel
(
int OtherVideoId,
string Title,
string Subtitle,
string SortTitle) : MediaCardViewModel(
string SortTitle,
MediaItemState State) : MediaCardViewModel(
OtherVideoId,
Title,
Subtitle,
SortTitle,
null)
null,
State)
{
public int CustomIndex { get; set; }
}

View File

@@ -1,16 +1,21 @@
namespace ErsatzTV.Application.MediaCards
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
{
public record SongCardViewModel
(
int SongId,
string Title,
string Subtitle,
string SortTitle) : MediaCardViewModel(
string SortTitle,
string Poster,
MediaItemState State) : MediaCardViewModel(
SongId,
Title,
Subtitle,
SortTitle,
null)
Poster,
State)
{
public int CustomIndex { get; set; }
}

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
{
@@ -17,10 +18,13 @@ namespace ErsatzTV.Application.MediaCards
string Plot,
string Poster,
List<string> Directors,
List<string> Writers) : MediaCardViewModel(
List<string> Writers,
MediaItemState State,
string Path) : MediaCardViewModel(
EpisodeId,
Title,
$"Episode {Episode}",
SortTitle,
Poster);
Poster,
State);
}

View File

@@ -1,4 +1,6 @@
namespace ErsatzTV.Application.MediaCards
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
{
public record TelevisionSeasonCardViewModel
(
@@ -9,10 +11,12 @@
string Subtitle,
string SortTitle,
string Poster,
string Placeholder) : MediaCardViewModel(
string Placeholder,
MediaItemState State) : MediaCardViewModel(
TelevisionSeasonId,
Title,
Subtitle,
SortTitle,
Poster);
Poster,
State);
}

View File

@@ -1,10 +1,19 @@
namespace ErsatzTV.Application.MediaCards
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
{
public record TelevisionShowCardViewModel
(int TelevisionShowId, string Title, string Subtitle, string SortTitle, string Poster) : MediaCardViewModel(
TelevisionShowId,
Title,
Subtitle,
SortTitle,
Poster);
(
int TelevisionShowId,
string Title,
string Subtitle,
string SortTitle,
string Poster,
MediaItemState State) : MediaCardViewModel(
TelevisionShowId,
Title,
Subtitle,
SortTitle,
Poster,
State);
}

View File

@@ -7,7 +7,7 @@ namespace ErsatzTV.Application.MediaCollections
internal static class Mapper
{
internal static MediaCollectionViewModel ProjectToViewModel(Collection collection) =>
new(collection.Id, collection.Name, collection.UseCustomPlaybackOrder);
new(collection.Id, collection.Name, collection.UseCustomPlaybackOrder, MediaItemState.Normal);
internal static MultiCollectionViewModel ProjectToViewModel(MultiCollection multiCollection) =>
new(

View File

@@ -1,11 +1,17 @@
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Domain;
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,
Name,
string.Empty,
Name,
string.Empty);
string.Empty,
State);
}

View File

@@ -160,15 +160,36 @@ namespace ErsatzTV.Application.MediaSources.Commands
return Unit.Default;
}
private async Task<Validation<BaseError, RequestParameters>> Validate(IScanLocalLibrary request) =>
(await LocalLibraryMustExist(request), await ValidateFFprobePath(), await ValidateFFmpegPath(), await ValidateLibraryRefreshInterval())
.Apply(
(library, ffprobePath, ffmpegPath, libraryRefreshInterval) => new RequestParameters(
library,
ffprobePath,
ffmpegPath,
request.ForceScan,
libraryRefreshInterval));
private async Task<Validation<BaseError, RequestParameters>> Validate(IScanLocalLibrary request)
{
Validation<BaseError, LocalLibrary> libraryResult = await LocalLibraryMustExist(request);
Validation<BaseError, string> ffprobePathResult = await ValidateFFprobePath();
Validation<BaseError, string> ffmpegPathResult = await ValidateFFmpegPath();
Validation<BaseError, int> refreshIntervalResult = await ValidateLibraryRefreshInterval();
try
{
return (libraryResult, ffprobePathResult, ffmpegPathResult, refreshIntervalResult)
.Apply(
(library, ffprobePath, ffmpegPath, libraryRefreshInterval) => new RequestParameters(
library,
ffprobePath,
ffmpegPath,
request.ForceScan,
libraryRefreshInterval));
}
finally
{
// ensure we unlock the library if any validation is unsuccessful
foreach (LocalLibrary library in libraryResult.SuccessToSeq())
{
if (ffprobePathResult.IsFail || ffmpegPathResult.IsFail || refreshIntervalResult.IsFail)
{
_entityLocker.UnlockLibrary(library.Id);
}
}
}
}
private Task<Validation<BaseError, LocalLibrary>> LocalLibraryMustExist(
IScanLocalLibrary request) =>

View File

@@ -4,6 +4,7 @@ using System.Globalization;
using System.Linq;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Jellyfin;
using Flurl;
using LanguageExt;
@@ -34,7 +35,9 @@ namespace ErsatzTV.Application.Movies
.Map(a => MediaCards.Mapper.ProjectToViewModel(a, maybeJellyfin, maybeEmby))
.ToList(),
metadata.Directors.Map(d => d.Name).ToList(),
metadata.Writers.Map(w => w.Name).ToList())
metadata.Writers.Map(w => w.Name).ToList(),
movie.GetHeadVersion().MediaFiles.Head().Path,
movie.State)
{
Poster = Artwork(metadata, ArtworkKind.Poster, maybeJellyfin, maybeEmby),
FanArt = Artwork(metadata, ArtworkKind.FanArt, maybeJellyfin, maybeEmby)

View File

@@ -1,6 +1,7 @@
using System.Collections.Generic;
using System.Globalization;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Movies
{
@@ -15,7 +16,9 @@ namespace ErsatzTV.Application.Movies
List<CultureInfo> Languages,
List<ActorCardViewModel> Actors,
List<string> Directors,
List<string> Writers)
List<string> Writers,
string Path,
MediaItemState MediaItemState)
{
public string Poster { get; set; }
public string FanArt { get; set; }

View File

@@ -33,7 +33,7 @@ namespace ErsatzTV.Application.Movies.Queries
GetMovieById request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<JellyfinMediaSource> maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin()
.Map(list => list.HeadOrNone());

View File

@@ -23,7 +23,7 @@ namespace ErsatzTV.Application.Streaming.Queries
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);
return await validation.Match(
tuple => GetProcess(dbContext, request, tuple.Item1, tuple.Item2),
@@ -56,7 +56,8 @@ namespace ErsatzTV.Application.Streaming.Queries
{
"hls-direct" => StreamingMode.HttpLiveStreamingDirect,
"segmenter" => StreamingMode.HttpLiveStreamingSegmenter,
"ts" => StreamingMode.TransportStream,
"ts" => StreamingMode.TransportStreamHybrid,
"ts-legacy" => StreamingMode.TransportStream,
_ => channel.StreamingMode
};

View File

@@ -6,7 +6,7 @@ namespace ErsatzTV.Application.Streaming.Queries
{
public GetConcatProcessByChannelNumber(string scheme, string host, string channelNumber) : base(
channelNumber,
"ts",
"ts-legacy",
DateTimeOffset.Now,
false,
true)

View File

@@ -1,11 +1,9 @@
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Runtime;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt;
@@ -16,16 +14,13 @@ namespace ErsatzTV.Application.Streaming.Queries
public class GetConcatProcessByChannelNumberHandler : FFmpegProcessHandler<GetConcatProcessByChannelNumber>
{
private readonly IFFmpegProcessService _ffmpegProcessService;
private readonly IRuntimeInfo _runtimeInfo;
public GetConcatProcessByChannelNumberHandler(
IDbContextFactory<TvContext> dbContextFactory,
IFFmpegProcessService ffmpegProcessService,
IRuntimeInfo runtimeInfo)
IFFmpegProcessService ffmpegProcessService)
: base(dbContextFactory)
{
_ffmpegProcessService = ffmpegProcessService;
_runtimeInfo = runtimeInfo;
}
protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess(
@@ -34,7 +29,7 @@ namespace ErsatzTV.Application.Streaming.Queries
Channel channel,
string ffmpegPath)
{
bool saveReports = !_runtimeInfo.IsOSPlatform(OSPlatform.Windows) && await dbContext.ConfigElements
bool saveReports = await dbContext.ConfigElements
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
.Map(result => result.IfNone(false));

View File

@@ -2,7 +2,6 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
@@ -15,7 +14,6 @@ using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Runtime;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@@ -36,7 +34,6 @@ namespace ErsatzTV.Application.Streaming.Queries
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
private readonly ILocalFileSystem _localFileSystem;
private readonly IPlexPathReplacementService _plexPathReplacementService;
private readonly IRuntimeInfo _runtimeInfo;
private readonly ISongVideoGenerator _songVideoGenerator;
public GetPlayoutItemProcessByChannelNumberHandler(
@@ -49,7 +46,6 @@ namespace ErsatzTV.Application.Streaming.Queries
IMediaCollectionRepository mediaCollectionRepository,
ITelevisionRepository televisionRepository,
IArtistRepository artistRepository,
IRuntimeInfo runtimeInfo,
ISongVideoGenerator songVideoGenerator)
: base(dbContextFactory)
{
@@ -61,7 +57,6 @@ namespace ErsatzTV.Application.Streaming.Queries
_mediaCollectionRepository = mediaCollectionRepository;
_televisionRepository = televisionRepository;
_artistRepository = artistRepository;
_runtimeInfo = runtimeInfo;
_songVideoGenerator = songVideoGenerator;
}
@@ -142,7 +137,7 @@ namespace ErsatzTV.Application.Streaming.Queries
ffmpegPath);
}
bool saveReports = !_runtimeInfo.IsOSPlatform(OSPlatform.Windows) && await dbContext.ConfigElements
bool saveReports = await dbContext.ConfigElements
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
.Map(result => result.IfNone(false));

View File

@@ -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; }
}
}

View File

@@ -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);
}
}
}

View File

@@ -6,7 +6,7 @@
</PropertyGroup>
<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="Microsoft.Extensions.DependencyInjection" Version="6.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
@@ -16,7 +16,7 @@
</PackageReference>
<PackageReference Include="Moq" Version="4.16.1" />
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="NUnit3TestAdapter" Version="4.1.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.2.0" />
<PackageReference Include="Serilog" Version="2.10.0" />
<PackageReference Include="Serilog.Extensions.Logging" Version="3.1.0" />
<PackageReference Include="Serilog.Sinks.Debug" Version="2.0.0" />

View File

@@ -297,37 +297,37 @@ namespace ErsatzTV.Core.Tests.FFmpeg
true,
true,
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]")]
[TestCase(
true,
false,
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]")]
[TestCase(
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]")]
[TestCase(
false,
true,
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]")]
[TestCase(
false,
false,
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]")]
[TestCase(
false,
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]")]
public void Should_Return_QSV_Video_Filter(
bool deinterlace,
@@ -368,37 +368,37 @@ namespace ErsatzTV.Core.Tests.FFmpeg
true,
true,
false,
"[0:0]yadif_cuda,scale_cuda=1920:1000,hwdownload,format=nv12,setsar=1,hwupload[v]",
"[0:0]yadif_cuda,scale_cuda=1920:1000,setsar=1[v]",
"[v]")]
[TestCase(
true,
false,
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]")]
[TestCase(
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]")]
[TestCase(
false,
true,
false,
"[0:0]scale_cuda=1920:1000,hwdownload,format=nv12,setsar=1,hwupload[v]",
"[0:0]scale_cuda=1920:1000,setsar=1[v]",
"[v]")]
[TestCase(
false,
false,
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]")]
[TestCase(
false,
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]")]
public void Should_Return_NVENC_Video_Filter(
bool deinterlace,
@@ -409,7 +409,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder()
.WithHardwareAcceleration(HardwareAccelerationKind.Nvenc)
.WithDeinterlace(deinterlace);
.WithDeinterlace(deinterlace)
.WithInputPixelFormat("h264");
if (scale)
{
@@ -440,42 +441,42 @@ namespace ErsatzTV.Core.Tests.FFmpeg
true,
true,
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]")]
[TestCase(
"h264",
true,
false,
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]")]
[TestCase(
"h264",
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]")]
[TestCase(
"h264",
false,
true,
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]")]
[TestCase(
"h264",
false,
false,
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]")]
[TestCase(
"h264",
false,
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]")]
[TestCase("mpeg4", true, false, false, "[0:0]hwupload,deinterlace_vaapi[v]", "[v]")]
[TestCase(
@@ -483,28 +484,28 @@ namespace ErsatzTV.Core.Tests.FFmpeg
true,
true,
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]")]
[TestCase(
"mpeg4",
true,
false,
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]")]
[TestCase(
"mpeg4",
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]")]
[TestCase(
"mpeg4",
false,
true,
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]")]
[TestCase(
"mpeg4",
@@ -518,7 +519,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
false,
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]")]
public void Should_Return_VAAPI_Video_Filter(
string codec,

View File

@@ -94,6 +94,17 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
HardwareAccelerationKind.Vaapi
};
public static string[] VideoToolboxCodecs =
{
"h264_videotoolbox",
"hevc_videotoolbox"
};
public static HardwareAccelerationKind[] VideoToolboxAcceleration =
{
HardwareAccelerationKind.VideoToolbox
};
}
[Test, Combinatorial]
@@ -104,21 +115,27 @@ namespace ErsatzTV.Core.Tests.FFmpeg
string inputPixelFormat,
[ValueSource(typeof(TestData), nameof(TestData.Resolutions))]
Resolution profileResolution,
[Values(true, false)]
bool pad,
// [ValueSource(typeof(TestData), nameof(TestData.SoftwareCodecs))] string profileCodec,
// [ValueSource(typeof(TestData), nameof(TestData.NoAcceleration))] HardwareAccelerationKind profileAcceleration)
[ValueSource(typeof(TestData), nameof(TestData.NvidiaCodecs))] string profileCodec,
[ValueSource(typeof(TestData), nameof(TestData.NvidiaAcceleration))] HardwareAccelerationKind profileAcceleration)
// [ValueSource(typeof(TestData), nameof(TestData.NvidiaCodecs))] string profileCodec,
// [ValueSource(typeof(TestData), nameof(TestData.NvidiaAcceleration))] HardwareAccelerationKind profileAcceleration)
// [ValueSource(typeof(TestData), nameof(TestData.VaapiCodecs))] string profileCodec,
// [ValueSource(typeof(TestData), nameof(TestData.VaapiAcceleration))] HardwareAccelerationKind profileAcceleration)
[ValueSource(typeof(TestData), nameof(TestData.VideoToolboxCodecs))] string profileCodec,
[ValueSource(typeof(TestData), nameof(TestData.VideoToolboxAcceleration))] HardwareAccelerationKind profileAcceleration)
{
string name = GetStringSha256Hash(
$"{inputCodec}_{inputPixelFormat}_{profileResolution}_{profileCodec}_{profileAcceleration}");
$"{inputCodec}_{inputPixelFormat}_{pad}_{profileResolution}_{profileCodec}_{profileAcceleration}");
string file = Path.Combine(TestContext.CurrentContext.TestDirectory, $"{name}.mkv");
if (!File.Exists(file))
{
string resolution = pad ? "1920x1060" : "1920x1080";
var args =
$"-y -f lavfi -i anullsrc=channel_layout=stereo:sample_rate=44100 -f lavfi -i testsrc=duration=1:size=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
{
StartInfo = new ProcessStartInfo
@@ -146,8 +163,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
var metadataRepository = new Mock<IMetadataRepository>();
metadataRepository
.Setup(r => r.UpdateLocalStatistics(It.IsAny<int>(), It.IsAny<MediaVersion>(), It.IsAny<bool>()))
.Callback<int, MediaVersion, bool>((_, version, _) => v = version);
.Setup(r => r.UpdateLocalStatistics(It.IsAny<MediaItem>(), It.IsAny<MediaVersion>(), It.IsAny<bool>()))
.Callback<MediaItem, MediaVersion, bool>((_, version, _) => v = version);
var localStatisticsProvider = new LocalStatisticsProvider(
metadataRepository.Object,
@@ -200,6 +217,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
TimeSpan.FromSeconds(5));
process.StartInfo.RedirectStandardError = true;
// Console.WriteLine($"ffmpeg arguments {string.Join(" ", process.StartInfo.ArgumentList)}");
process.Start().Should().BeTrue();

View File

@@ -44,7 +44,7 @@ namespace ErsatzTV.Core.Tests.Fakes
.IfNone(SystemTime.MinValueUtc);
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) =>
_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);
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();

View File

@@ -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");
}
}
}

View File

@@ -6,7 +6,6 @@ using System.Runtime.InteropServices;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata;
@@ -54,6 +53,10 @@ namespace ErsatzTV.Core.Tests.Metadata
_movieRepository.Setup(x => x.FindMoviePaths(It.IsAny<LibraryPath>()))
.Returns(new List<string>().AsEnumerable().AsTask());
_mediaItemRepository = new Mock<IMediaItemRepository>();
_mediaItemRepository.Setup(x => x.FlagFileNotFound(It.IsAny<LibraryPath>(), It.IsAny<string>()))
.Returns(new List<int>().AsTask());
_localStatisticsProvider = new Mock<ILocalStatisticsProvider>();
_localMetadataProvider = new Mock<ILocalMetadataProvider>();
@@ -73,6 +76,7 @@ namespace ErsatzTV.Core.Tests.Metadata
}
private Mock<IMovieRepository> _movieRepository;
private Mock<IMediaItemRepository> _mediaItemRepository;
private Mock<ILocalStatisticsProvider> _localStatisticsProvider;
private Mock<ILocalMetadataProvider> _localMetadataProvider;
private Mock<IImageCache> _imageCache;
@@ -535,6 +539,9 @@ namespace ErsatzTV.Core.Tests.Metadata
[Test]
public async Task RenamedMovie_Should_Delete_Old_Movie()
{
// TODO: handle this case more elegantly
// ideally, detect that the movie was renamed and still delete the old one (or update the path?)
string movieFolder = Path.Combine(FakeRoot, "Movie (2020)");
string oldMoviePath = Path.Combine(movieFolder, "Movie (2020).avi");
@@ -557,12 +564,14 @@ namespace ErsatzTV.Core.Tests.Metadata
result.IsRight.Should().BeTrue();
_movieRepository.Verify(x => x.DeleteByPath(It.IsAny<LibraryPath>(), It.IsAny<string>()), Times.Once);
_movieRepository.Verify(x => x.DeleteByPath(libraryPath, oldMoviePath), Times.Once);
_mediaItemRepository.Verify(
x => x.FlagFileNotFound(It.IsAny<LibraryPath>(), It.IsAny<string>()),
Times.Once);
_mediaItemRepository.Verify(x => x.FlagFileNotFound(libraryPath, oldMoviePath), Times.Once);
}
[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 oldMoviePath = Path.Combine(movieFolder, "Movie (2020).avi");
@@ -570,10 +579,8 @@ namespace ErsatzTV.Core.Tests.Metadata
_movieRepository.Setup(x => x.FindMoviePaths(It.IsAny<LibraryPath>()))
.Returns(new List<string> { oldMoviePath }.AsEnumerable().AsTask());
string moviePath = Path.Combine(movieFolder, "Movie (2020).mkv");
MovieFolderScanner service = GetService(
new FakeFileEntry(moviePath) { LastWriteTime = DateTime.Now }
new FakeFolderEntry(FakeRoot)
);
var libraryPath = new LibraryPath
{ Id = 1, Path = FakeRoot, LibraryFolders = new List<LibraryFolder>() };
@@ -586,11 +593,12 @@ namespace ErsatzTV.Core.Tests.Metadata
result.IsRight.Should().BeTrue();
_movieRepository.Verify(x => x.DeleteByPath(It.IsAny<LibraryPath>(), It.IsAny<string>()), Times.Once);
_movieRepository.Verify(x => x.DeleteByPath(libraryPath, oldMoviePath), Times.Once);
_mediaItemRepository.Verify(
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) =>
new(
new FakeLocalFileSystem(new List<FakeFileEntry>(files)),
@@ -602,6 +610,25 @@ namespace ErsatzTV.Core.Tests.Metadata
new Mock<ISearchIndex>().Object,
new Mock<ISearchRepository>().Object,
new Mock<ILibraryRepository>().Object,
_mediaItemRepository.Object,
new Mock<IMediator>().Object,
null,
new Mock<ITempFilePool>().Object,
new Mock<ILogger<MovieFolderScanner>>().Object
);
private MovieFolderScanner GetService(params FakeFolderEntry[] folders) =>
new(
new FakeLocalFileSystem(new List<FakeFileEntry>(), new List<FakeFolderEntry>(folders)),
_movieRepository.Object,
_localStatisticsProvider.Object,
_localMetadataProvider.Object,
new Mock<IMetadataRepository>().Object,
_imageCache.Object,
new Mock<ISearchIndex>().Object,
new Mock<ISearchRepository>().Object,
new Mock<ILibraryRepository>().Object,
_mediaItemRepository.Object,
new Mock<IMediator>().Object,
null,
new Mock<ITempFilePool>().Object,

View File

@@ -5,6 +5,7 @@
None = 0,
Qsv = 1,
Nvenc = 2,
Vaapi = 3
Vaapi = 3,
VideoToolbox = 4
}
}

View File

@@ -1,14 +1,14 @@
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 LibraryPath LibraryPath { get; set; }
public List<Collection> Collections { get; set; }
public List<CollectionItem> CollectionItems { get; set; }
public List<TraktListItem> TraktListItems { get; set; }
}
}
public int Id { get; set; }
public int LibraryPathId { get; set; }
public LibraryPath LibraryPath { get; set; }
public List<Collection> Collections { get; set; }
public List<CollectionItem> CollectionItems { get; set; }
public List<TraktListItem> TraktListItems { get; set; }
public MediaItemState State { get; set; }
}

View File

@@ -0,0 +1,7 @@
namespace ErsatzTV.Core.Domain;
public enum MediaItemState
{
Normal = 0,
FileNotFound = 1
}

View File

@@ -4,7 +4,8 @@
{
TransportStream = 1,
HttpLiveStreamingDirect = 2,
HttpLiveStreamingHybrid = 3,
HttpLiveStreamingSegmenter = 4
// HttpLiveStreamingHybrid = 3,
HttpLiveStreamingSegmenter = 4,
TransportStreamHybrid = 5
}
}

View File

@@ -3,7 +3,6 @@
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<NoWarn>VSTHRD200</NoWarn>
<DebugType>embedded</DebugType>
</PropertyGroup>
<ItemGroup>

View File

@@ -142,8 +142,14 @@ namespace ErsatzTV.Core.FFmpeg
bool isHardwareDecode = acceleration switch
{
HardwareAccelerationKind.Vaapi => !isSong && _inputCodec != "mpeg4",
// we need an initial hwupload_cuda when only padding with these pixel formats
HardwareAccelerationKind.Nvenc when _scaleToSize.IsNone && _padToSize.IsSome =>
!isSong && !_pixelFormat.Contains("p10le") && !_pixelFormat.Contains("444"),
HardwareAccelerationKind.Nvenc => !isSong,
HardwareAccelerationKind.Qsv => !isSong,
HardwareAccelerationKind.VideoToolbox => false,
_ => false
};
@@ -164,9 +170,11 @@ namespace ErsatzTV.Core.FFmpeg
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);
if (isSong)
{
switch (acceleration)
@@ -250,7 +258,12 @@ namespace ErsatzTV.Core.FFmpeg
});
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)
{
@@ -268,12 +281,7 @@ namespace ErsatzTV.Core.FFmpeg
};
videoFilterQueue.Add(format);
}
if (scaleOrPad && _boxBlur == false)
{
videoFilterQueue.Add("setsar=1");
}
if (_boxBlur)
{
videoFilterQueue.Add("boxblur=40");

View File

@@ -71,7 +71,7 @@ namespace ErsatzTV.Core.FFmpeg
result.VideoCodec = "copy";
result.Deinterlace = false;
break;
case StreamingMode.HttpLiveStreamingHybrid:
case StreamingMode.TransportStreamHybrid:
case StreamingMode.HttpLiveStreamingSegmenter:
case StreamingMode.TransportStream:
result.HardwareAcceleration = ffmpegProfile.HardwareAcceleration;
@@ -121,6 +121,11 @@ namespace ErsatzTV.Core.FFmpeg
(HardwareAccelerationKind.Qsv, "h264", _) => "h264_qsv",
(HardwareAccelerationKind.Qsv, "hevc", _) => "hevc_qsv",
(HardwareAccelerationKind.Qsv, "mpeg2video", _) => "mpeg2_qsv",
// temp disable mpeg4 hardware decoding for all vaapi
// TODO: check for codec support
(HardwareAccelerationKind.Vaapi, "mpeg4", _) => "mpeg4",
_ => null
};
}

View File

@@ -22,6 +22,7 @@ using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
@@ -107,6 +108,10 @@ namespace ErsatzTV.Core.FFmpeg
_arguments.Add("-hwaccel_output_format");
_arguments.Add("vaapi");
break;
case HardwareAccelerationKind.VideoToolbox:
_arguments.Add("-hwaccel");
_arguments.Add("videotoolbox");
break;
}
_complexFilterBuilder = _complexFilterBuilder.WithHardwareAcceleration(hwAccel);
@@ -201,6 +206,13 @@ namespace ErsatzTV.Core.FFmpeg
return this;
}
public FFmpegProcessBuilder WithCopyCodec()
{
_arguments.Add("-c");
_arguments.Add("copy");
return this;
}
public FFmpegProcessBuilder WithWatermark(
Option<WatermarkOptions> watermarkOptions,
IDisplaySize resolution)
@@ -625,13 +637,13 @@ namespace ErsatzTV.Core.FFmpeg
switch (_vaapiDriver)
{
case VaapiDriver.i965:
startInfo.EnvironmentVariables.Add("LIBVA_DRIVER_NAME", "i965");
startInfo.EnvironmentVariables["LIBVA_DRIVER_NAME"] = "i965";
break;
case VaapiDriver.iHD:
startInfo.EnvironmentVariables.Add("LIBVA_DRIVER_NAME", "iHD");
startInfo.EnvironmentVariables["LIBVA_DRIVER_NAME"] = "iHD";
break;
case VaapiDriver.RadeonSI:
startInfo.EnvironmentVariables.Add("LIBVA_DRIVER_NAME", "radeonsi");
startInfo.EnvironmentVariables["LIBVA_DRIVER_NAME"] = "radeonsi";
break;
}
}
@@ -641,7 +653,18 @@ namespace ErsatzTV.Core.FFmpeg
string fileName = _isConcat
? Path.Combine(FileSystemLayout.FFmpegReportsFolder, "ffmpeg-%t-concat.log")
: Path.Combine(FileSystemLayout.FFmpegReportsFolder, "ffmpeg-%t-transcode.log");
startInfo.EnvironmentVariables.Add("FFREPORT", $"file={fileName}:level=32");
// 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");

View File

@@ -247,6 +247,24 @@ namespace ErsatzTV.Core.FFmpeg
.Build();
}
public Process WrapSegmenter(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host)
{
FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.ConcatSettings;
return new FFmpegProcessBuilder(ffmpegPath, saveReports, _logger)
.WithThreads(1)
.WithQuiet()
.WithFormatFlags(playbackSettings.FormatFlags)
.WithRealtimeOutput(playbackSettings.RealtimeOutput)
.WithInput($"http://localhost:{Settings.ListenPort}/iptv/channel/{channel.Number}.m3u8?mode=segmenter")
.WithMap("0")
.WithCopyCodec()
.WithMetadata(channel, None)
.WithFormat("mpegts")
.WithPipe()
.Build();
}
public Process ConvertToPng(string ffmpegPath, string inputFile, string outputFile)
{
return new FFmpegProcessBuilder(ffmpegPath, false, _logger)

View File

@@ -22,8 +22,7 @@ namespace ErsatzTV.Core.Hdhr
public string URL => _channel.StreamingMode switch
{
StreamingMode.HttpLiveStreamingDirect or StreamingMode.HttpLiveStreamingHybrid =>
$"{_scheme}://{_host}/iptv/channel/{_channel.Number}.m3u8",
StreamingMode.HttpLiveStreamingDirect => $"{_scheme}://{_host}/iptv/channel/{_channel.Number}.m3u8",
_ => $"{_scheme}://{_host}/iptv/channel/{_channel.Number}.ts"
};
}

View File

@@ -0,0 +1,5 @@
namespace ErsatzTV.Core.Health.Checks;
public interface IFileNotFoundHealthCheck : IHealthCheck
{
}

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);
}

View File

@@ -38,6 +38,8 @@ namespace ErsatzTV.Core.Interfaces.FFmpeg
Process ConcatChannel(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host);
Process WrapSegmenter(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host);
Process ConvertToPng(string ffmpegPath, string inputFile, string outputFile);
Process ExtractAttachedPicAsPng(string ffmpegPath, string inputFile, int streamIndex, string outputFile);

View File

@@ -14,6 +14,7 @@ namespace ErsatzTV.Core.Interfaces.Metadata
IEnumerable<string> ListSubdirectories(string folder);
IEnumerable<string> ListFiles(string folder);
bool FileExists(string path);
bool FolderExists(string folder);
Task<Either<BaseError, Unit>> CopyFile(string source, string destination);
Unit EmptyFolder(string folder);
}

View File

@@ -19,5 +19,6 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<List<int>> GetMediaIdsByLocalPath(int libraryPathId);
Task DeleteLocalPath(int libraryPathId);
Task<Unit> SetEtag(LibraryPath libraryPath, Option<LibraryFolder> knownFolder, string path, string etag);
Task<Unit> CleanEtagsForLibraryPath(LibraryPath libraryPath);
}
}

View File

@@ -1,10 +1,15 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Repositories
{
public interface IMediaItemRepository
{
Task<List<string>> GetAllLanguageCodes();
Task<List<int>> FlagFileNotFound(LibraryPath libraryPath, string path);
Task<Unit> FlagNormal(MediaItem mediaItem);
Task<Either<BaseError, Unit>> DeleteItems(List<int> mediaItemIds);
}
}

View File

@@ -15,7 +15,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<bool> RemoveActor(Actor actor);
Task<bool> Update(Domain.Metadata metadata);
Task<bool> Add(Domain.Metadata metadata);
Task<bool> UpdateLocalStatistics(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<Unit> UpdateArtworkPath(Artwork artwork);
Task<Unit> AddArtwork(Domain.Metadata metadata, Artwork artwork);

View File

@@ -14,6 +14,7 @@ namespace ErsatzTV.Core.Interfaces.Search
public int Version { get; }
Task<bool> Initialize(ILocalFileSystem localFileSystem);
Task<Unit> Rebuild(ISearchRepository searchRepository, List<int> itemIds);
Task<Unit> RebuildItems(ISearchRepository searchRepository, List<int> itemIds);
Task<Unit> AddItems(ISearchRepository searchRepository, List<MediaItem> items);
Task<Unit> UpdateItems(ISearchRepository searchRepository, List<MediaItem> items);
Task<Unit> RemoveItems(List<int> ids);

View File

@@ -43,9 +43,9 @@ namespace ErsatzTV.Core.Iptv
string format = channel.StreamingMode switch
{
StreamingMode.HttpLiveStreamingDirect => "m3u8?mode=hls-direct",
StreamingMode.HttpLiveStreamingHybrid => "m3u8",
StreamingMode.HttpLiveStreamingSegmenter => "m3u8?mode=segmenter",
_ => "ts"
StreamingMode.TransportStreamHybrid => "ts",
_ => "ts?mode=legacy"
};
string vcodec = channel.FFmpegProfile.VideoCodec.Split("_").Head();

View File

@@ -50,6 +50,8 @@ namespace ErsatzTV.Core.Metadata
public bool FileExists(string path) => File.Exists(path);
public bool FolderExists(string folder) => Directory.Exists(folder);
public async Task<Either<BaseError, Unit>> CopyFile(string source, string destination)
{
try

View File

@@ -57,11 +57,13 @@ namespace ErsatzTV.Core.Metadata
private readonly ILocalStatisticsProvider _localStatisticsProvider;
private readonly ILogger _logger;
private readonly IMetadataRepository _metadataRepository;
private readonly IMediaItemRepository _mediaItemRepository;
protected LocalFolderScanner(
ILocalFileSystem localFileSystem,
ILocalStatisticsProvider localStatisticsProvider,
IMetadataRepository metadataRepository,
IMediaItemRepository mediaItemRepository,
IImageCache imageCache,
IFFmpegProcessService ffmpegProcessService,
ITempFilePool tempFilePool,
@@ -70,6 +72,7 @@ namespace ErsatzTV.Core.Metadata
_localFileSystem = localFileSystem;
_localStatisticsProvider = localStatisticsProvider;
_metadataRepository = metadataRepository;
_mediaItemRepository = mediaItemRepository;
_imageCache = imageCache;
_ffmpegProcessService = ffmpegProcessService;
_tempFilePool = tempFilePool;
@@ -265,6 +268,29 @@ namespace ErsatzTV.Core.Metadata
return false;
}
protected Task<List<int>> FlagFileNotFound(LibraryPath libraryPath, string path) =>
_mediaItemRepository.FlagFileNotFound(libraryPath, path);
protected async Task<Either<BaseError, MediaItemScanResult<T>>> FlagNormal<T>(MediaItemScanResult<T> result)
where T : MediaItem
{
try
{
T mediaItem = result.Item;
if (mediaItem.State != MediaItemState.Normal)
{
await _mediaItemRepository.FlagNormal(mediaItem);
result.IsUpdated = true;
}
return result;
}
catch (Exception ex)
{
return BaseError.New(ex.ToString());
}
}
protected bool ShouldIncludeFolder(string folder) =>
!Path.GetFileName(folder).StartsWith('.') &&
!_localFileSystem.FileExists(Path.Combine(folder, ".etvignore"));

View File

@@ -132,7 +132,7 @@ namespace ErsatzTV.Core.Metadata
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)

View File

@@ -40,6 +40,7 @@ namespace ErsatzTV.Core.Metadata
ISearchIndex searchIndex,
ISearchRepository searchRepository,
ILibraryRepository libraryRepository,
IMediaItemRepository mediaItemRepository,
IMediator mediator,
IFFmpegProcessService ffmpegProcessService,
ITempFilePool tempFilePool,
@@ -48,6 +49,7 @@ namespace ErsatzTV.Core.Metadata
localFileSystem,
localStatisticsProvider,
metadataRepository,
mediaItemRepository,
imageCache,
ffmpegProcessService,
tempFilePool,
@@ -140,7 +142,8 @@ namespace ErsatzTV.Core.Metadata
.BindT(movie => UpdateStatistics(movie, ffprobePath))
.BindT(UpdateMetadata)
.BindT(movie => UpdateArtwork(movie, ArtworkKind.Poster))
.BindT(movie => UpdateArtwork(movie, ArtworkKind.FanArt));
.BindT(movie => UpdateArtwork(movie, ArtworkKind.FanArt))
.BindT(FlagNormal);
await maybeMovie.Match(
async result =>
@@ -168,9 +171,9 @@ namespace ErsatzTV.Core.Metadata
{
if (!_localFileSystem.FileExists(path))
{
_logger.LogInformation("Removing missing movie at {Path}", path);
List<int> ids = await _movieRepository.DeleteByPath(libraryPath, path);
await _searchIndex.RemoveItems(ids);
_logger.LogInformation("Flagging missing movie at {Path}", path);
List<int> ids = await FlagFileNotFound(libraryPath, path);
await _searchIndex.RebuildItems(_searchRepository, ids);
}
else if (Path.GetFileName(path).StartsWith("._"))
{
@@ -180,6 +183,8 @@ namespace ErsatzTV.Core.Metadata
}
}
await _libraryRepository.CleanEtagsForLibraryPath(libraryPath);
_searchIndex.Commit();
return Unit.Default;
}

View File

@@ -41,6 +41,7 @@ namespace ErsatzTV.Core.Metadata
IArtistRepository artistRepository,
IMusicVideoRepository musicVideoRepository,
ILibraryRepository libraryRepository,
IMediaItemRepository mediaItemRepository,
IMediator mediator,
IFFmpegProcessService ffmpegProcessService,
ITempFilePool tempFilePool,
@@ -48,6 +49,7 @@ namespace ErsatzTV.Core.Metadata
localFileSystem,
localStatisticsProvider,
metadataRepository,
mediaItemRepository,
imageCache,
ffmpegProcessService,
tempFilePool,
@@ -135,9 +137,9 @@ namespace ErsatzTV.Core.Metadata
{
if (!_localFileSystem.FileExists(path))
{
_logger.LogInformation("Removing missing music video at {Path}", path);
List<int> musicVideoIds = await _musicVideoRepository.DeleteByPath(libraryPath, path);
await _searchIndex.RemoveItems(musicVideoIds);
_logger.LogInformation("Flagging missing music video at {Path}", path);
List<int> musicVideoIds = await FlagFileNotFound(libraryPath, path);
await _searchIndex.RebuildItems(_searchRepository, musicVideoIds);
}
else if (Path.GetFileName(path).StartsWith("._"))
{
@@ -146,6 +148,8 @@ namespace ErsatzTV.Core.Metadata
await _searchIndex.RemoveItems(musicVideoIds);
}
}
await _libraryRepository.CleanEtagsForLibraryPath(libraryPath);
List<int> artistIds = await _artistRepository.DeleteEmptyArtists(libraryPath);
await _searchIndex.RemoveItems(artistIds);
@@ -276,7 +280,8 @@ namespace ErsatzTV.Core.Metadata
.GetOrAdd(artist, libraryPath, file)
.BindT(musicVideo => UpdateStatistics(musicVideo, ffprobePath))
.BindT(UpdateMetadata)
.BindT(UpdateThumbnail);
.BindT(UpdateThumbnail)
.BindT(FlagNormal);
await maybeMusicVideo.Match(
async result =>

View File

@@ -40,12 +40,14 @@ namespace ErsatzTV.Core.Metadata
ISearchRepository searchRepository,
IOtherVideoRepository otherVideoRepository,
ILibraryRepository libraryRepository,
IMediaItemRepository mediaItemRepository,
IFFmpegProcessService ffmpegProcessService,
ITempFilePool tempFilePool,
ILogger<OtherVideoFolderScanner> logger) : base(
localFileSystem,
localStatisticsProvider,
metadataRepository,
mediaItemRepository,
imageCache,
ffmpegProcessService,
tempFilePool,
@@ -77,9 +79,15 @@ namespace ErsatzTV.Core.Metadata
var foldersCompleted = 0;
var folderQueue = new Queue<string>();
if (ShouldIncludeFolder(libraryPath.Path))
{
folderQueue.Enqueue(libraryPath.Path);
}
foreach (string folder in _localFileSystem.ListSubdirectories(libraryPath.Path)
.Filter(ShouldIncludeFolder)
.OrderBy(identity))
.Filter(ShouldIncludeFolder)
.OrderBy(identity))
{
folderQueue.Enqueue(folder);
}
@@ -127,7 +135,8 @@ namespace ErsatzTV.Core.Metadata
Either<BaseError, MediaItemScanResult<OtherVideo>> maybeVideo = await _otherVideoRepository
.GetOrAdd(libraryPath, file)
.BindT(video => UpdateStatistics(video, ffprobePath))
.BindT(UpdateMetadata);
.BindT(UpdateMetadata)
.BindT(FlagNormal);
await maybeVideo.Match(
async result =>
@@ -155,9 +164,9 @@ namespace ErsatzTV.Core.Metadata
{
if (!_localFileSystem.FileExists(path))
{
_logger.LogInformation("Removing missing other video at {Path}", path);
List<int> otherVideoIds = await _otherVideoRepository.DeleteByPath(libraryPath, path);
await _searchIndex.RemoveItems(otherVideoIds);
_logger.LogInformation("Flagging missing other video at {Path}", path);
List<int> otherVideoIds = await FlagFileNotFound(libraryPath, path);
await _searchIndex.RebuildItems(_searchRepository, otherVideoIds);
}
else if (Path.GetFileName(path).StartsWith("._"))
{
@@ -166,6 +175,8 @@ namespace ErsatzTV.Core.Metadata
await _searchIndex.RemoveItems(otherVideoIds);
}
}
await _libraryRepository.CleanEtagsForLibraryPath(libraryPath);
_searchIndex.Commit();
return Unit.Default;

View File

@@ -41,12 +41,14 @@ namespace ErsatzTV.Core.Metadata
ISearchRepository searchRepository,
ISongRepository songRepository,
ILibraryRepository libraryRepository,
IMediaItemRepository mediaItemRepository,
IFFmpegProcessService ffmpegProcessService,
ITempFilePool tempFilePool,
ILogger<SongFolderScanner> logger) : base(
localFileSystem,
localStatisticsProvider,
metadataRepository,
mediaItemRepository,
imageCache,
ffmpegProcessService,
tempFilePool,
@@ -79,9 +81,15 @@ namespace ErsatzTV.Core.Metadata
var foldersCompleted = 0;
var folderQueue = new Queue<string>();
if (ShouldIncludeFolder(libraryPath.Path))
{
folderQueue.Enqueue(libraryPath.Path);
}
foreach (string folder in _localFileSystem.ListSubdirectories(libraryPath.Path)
.Filter(ShouldIncludeFolder)
.OrderBy(identity))
.Filter(ShouldIncludeFolder)
.OrderBy(identity))
{
folderQueue.Enqueue(folder);
}
@@ -130,7 +138,8 @@ namespace ErsatzTV.Core.Metadata
.GetOrAdd(libraryPath, file)
.BindT(video => UpdateStatistics(video, ffprobePath))
.BindT(video => UpdateMetadata(video, ffprobePath))
.BindT(video => UpdateThumbnail(video, ffmpegPath));
.BindT(video => UpdateThumbnail(video, ffmpegPath))
.BindT(FlagNormal);
await maybeSong.Match(
async result =>
@@ -158,9 +167,9 @@ namespace ErsatzTV.Core.Metadata
{
if (!_localFileSystem.FileExists(path))
{
_logger.LogInformation("Removing missing song at {Path}", path);
List<int> songIds = await _songRepository.DeleteByPath(libraryPath, path);
await _searchIndex.RemoveItems(songIds);
_logger.LogInformation("Flagging missing song at {Path}", path);
List<int> songIds = await FlagFileNotFound(libraryPath, path);
await _searchIndex.RebuildItems(_searchRepository, songIds);
}
else if (Path.GetFileName(path).StartsWith("._"))
{
@@ -169,6 +178,8 @@ namespace ErsatzTV.Core.Metadata
await _searchIndex.RemoveItems(songIds);
}
}
await _libraryRepository.CleanEtagsForLibraryPath(libraryPath);
_searchIndex.Commit();
return Unit.Default;

View File

@@ -40,6 +40,7 @@ namespace ErsatzTV.Core.Metadata
ISearchIndex searchIndex,
ISearchRepository searchRepository,
ILibraryRepository libraryRepository,
IMediaItemRepository mediaItemRepository,
IMediator mediator,
IFFmpegProcessService ffmpegProcessService,
ITempFilePool tempFilePool,
@@ -47,6 +48,7 @@ namespace ErsatzTV.Core.Metadata
localFileSystem,
localStatisticsProvider,
metadataRepository,
mediaItemRepository,
imageCache,
ffmpegProcessService,
tempFilePool,
@@ -126,8 +128,9 @@ namespace ErsatzTV.Core.Metadata
{
if (!_localFileSystem.FileExists(path))
{
_logger.LogInformation("Removing missing episode at {Path}", path);
await _televisionRepository.DeleteByPath(libraryPath, path);
_logger.LogInformation("Flagging missing episode at {Path}", path);
List<int> episodeIds = await FlagFileNotFound(libraryPath, path);
await _searchIndex.RebuildItems(_searchRepository, episodeIds);
}
else if (Path.GetFileName(path).StartsWith("._"))
{
@@ -135,6 +138,8 @@ namespace ErsatzTV.Core.Metadata
await _televisionRepository.DeleteByPath(libraryPath, path);
}
}
await _libraryRepository.CleanEtagsForLibraryPath(libraryPath);
await _televisionRepository.DeleteEmptySeasons(libraryPath);
List<int> ids = await _televisionRepository.DeleteEmptyShows(libraryPath);
@@ -231,7 +236,9 @@ namespace ErsatzTV.Core.Metadata
episode => UpdateStatistics(new MediaItemScanResult<Episode>(episode), ffprobePath)
.MapT(_ => episode))
.BindT(UpdateMetadata)
.BindT(UpdateThumbnail);
.BindT(UpdateThumbnail)
.BindT(e => FlagNormal(new MediaItemScanResult<Episode>(e)))
.MapT(r => r.Item);
await maybeEpisode.Match(
async episode =>

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Scheduling;
using LanguageExt;
@@ -254,6 +255,7 @@ namespace ErsatzTV.Core.Scheduling
foreach ((CollectionKey _, List<MediaItem> items) in collectionMediaItems)
{
var zeroItems = new List<MediaItem>();
// var missingItems = new List<MediaItem>();
foreach (MediaItem item in items)
{
@@ -272,6 +274,17 @@ namespace ErsatzTV.Core.Scheduling
_ => true
};
// if (item.State == MediaItemState.FileNotFound)
// {
// _logger.LogWarning(
// "Skipping media item that does not exist on disk {MediaItem} - {MediaItemTitle} - {Path}",
// item.Id,
// DisplayTitle(item),
// item.GetHeadVersion().MediaFiles.Head().Path);
//
// missingItems.Add(item);
// }
// else
if (isZero)
{
_logger.LogWarning(
@@ -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);

View File

@@ -123,10 +123,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
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
.AsNoTracking()
.Filter(am => ids.Contains(am.ArtistId))
.Include(am => am.Artist)
.Include(am => am.Artwork)
.OrderBy(am => am.SortTitle)
.ToListAsync();
@@ -149,12 +150,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
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
.AsNoTracking()
.Include(mv => mv.MusicVideoMetadata)
.Include(mv => mv.MediaVersions)
.ThenInclude(mv => mv.Chapters)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(mv => mv.Artist)
.ThenInclude(a => a.ArtistMetadata)
.Filter(mv => mv.ArtistId == artistId)

View File

@@ -4,6 +4,7 @@ using System.Linq;
using System.Threading.Tasks;
using Dapper;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
@@ -14,10 +15,15 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public class LibraryRepository : ILibraryRepository
{
private readonly IDbConnection _dbConnection;
private readonly ILocalFileSystem _localFileSystem;
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;
_dbConnection = dbConnection;
}
@@ -120,7 +126,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
},
async () =>
{
await using TvContext context = _dbContextFactory.CreateDbContext();
await using TvContext context = await _dbContextFactory.CreateDbContextAsync();
await context.LibraryFolders.AddAsync(
new LibraryFolder
{
@@ -130,5 +136,23 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
});
await context.SaveChangesAsync();
}).ToUnit();
public async Task<Unit> CleanEtagsForLibraryPath(LibraryPath libraryPath)
{
IEnumerable<string> folders = await _dbConnection.QueryAsync<string>(
@"SELECT LF.Path
FROM LibraryFolder LF
WHERE LF.LibraryPathId = @LibraryPathId",
new { LibraryPathId = libraryPath.Id });
foreach (string folder in folders.Where(f => !_localFileSystem.FolderExists(f)))
{
await _dbConnection.ExecuteAsync(
@"DELETE FROM LibraryFolder WHERE LibraryPathId = @LibraryPathId AND Path = @Path",
new { LibraryPathId = libraryPath.Id, Path = folder });
}
return Unit.Default;
}
}
}

View File

@@ -371,6 +371,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(m => m.MovieMetadata)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.Chapters)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Filter(m => movieIds.Contains(m.Id))
.ToListAsync();
@@ -395,6 +397,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(m => m.MusicVideoMetadata)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.Chapters)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Filter(m => musicVideoIds.Contains(m.Id))
.ToListAsync();
@@ -427,6 +431,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(m => m.MusicVideoMetadata)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.Chapters)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Filter(m => musicVideoIds.Contains(m.Id))
.ToListAsync();
@@ -446,6 +452,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(m => m.OtherVideoMetadata)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.Chapters)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Filter(m => otherVideoIds.Contains(m.Id))
.ToListAsync();
@@ -465,6 +473,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(m => m.SongMetadata)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.Chapters)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Filter(m => songIds.Contains(m.Id))
.ToListAsync();
@@ -486,6 +496,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(e => e.EpisodeMetadata)
.Include(e => e.MediaVersions)
.ThenInclude(mv => mv.Chapters)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(e => e.Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
@@ -521,6 +533,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(e => e.EpisodeMetadata)
.Include(e => e.MediaVersions)
.ThenInclude(mv => mv.Chapters)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(e => e.Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
@@ -554,6 +568,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(e => e.EpisodeMetadata)
.Include(e => e.MediaVersions)
.ThenInclude(mv => mv.Chapters)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(e => e.Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)

View File

@@ -3,6 +3,8 @@ using System.Data;
using System.Linq;
using System.Threading.Tasks;
using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
@@ -24,5 +26,44 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
GROUP BY LanguageCode
ORDER BY COUNT(LanguageCode) DESC")
.Map(result => result.ToList());
public async Task<List<int>> FlagFileNotFound(LibraryPath libraryPath, string path)
{
List<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT M.Id
FROM MediaItem M
INNER JOIN MediaVersion MV on M.Id = COALESCE(MovieId, MusicVideoId, OtherVideoId, SongId, EpisodeId)
INNER JOIN MediaFile MF on MV.Id = MF.MediaVersionId
WHERE M.LibraryPathId = @LibraryPathId AND MF.Path = @Path",
new { LibraryPathId = libraryPath.Id, Path = path })
.Map(result => result.ToList());
await _dbConnection.ExecuteAsync(
@"UPDATE MediaItem SET State = 1 WHERE Id IN @Ids",
new { Ids = ids });
return ids;
}
public async Task<Unit> FlagNormal(MediaItem mediaItem)
{
mediaItem.State = MediaItemState.Normal;
return await _dbConnection.ExecuteAsync(
@"UPDATE MediaItem SET State = 0 WHERE Id = @Id",
new { mediaItem.Id }).ToUnit();
}
public async Task<Either<BaseError, Unit>> DeleteItems(List<int> mediaItemIds)
{
foreach (int mediaItemId in mediaItemIds)
{
await _dbConnection.ExecuteAsync(
"DELETE FROM MediaItem WHERE Id = @Id",
new { Id = mediaItemId });
}
return Unit.Default;
}
}
}

View File

@@ -4,6 +4,7 @@ using System.Linq;
using System.Threading.Tasks;
using Dapper;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
@@ -110,14 +111,17 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
}
public async Task<bool> UpdateLocalStatistics(
int mediaVersionId,
MediaItem mediaItem,
MediaVersion incoming,
bool updateVersion = true)
{
int mediaVersionId = mediaItem.GetHeadVersion().Id;
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<MediaVersion> maybeVersion = await dbContext.MediaVersions
.Include(v => v.Streams)
.Include(v => v.Chapters)
.Include(v => v.MediaFiles)
.OrderBy(v => v.Id)
.SingleOrDefaultAsync(v => v.Id == mediaVersionId)
.Map(Optional);
@@ -191,7 +195,37 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
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));
}

View File

@@ -56,6 +56,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(mm => mm.Writers)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.OrderBy(m => m.Id)
.SingleOrDefaultAsync(m => m.Id == movieId)
.Map(Optional);
@@ -63,7 +65,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
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
.Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Artwork)
@@ -146,12 +148,13 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
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
.AsNoTracking()
.Filter(mm => ids.Contains(mm.MovieId))
.Include(mm => mm.Artwork)
.OrderBy(mm => mm.SortTitle)
.Include(mm => mm.Movie)
.ToListAsync();
}
@@ -167,7 +170,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
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>(
@"SELECT M.Id
FROM Movie M
@@ -181,13 +184,16 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
foreach (int movieId in ids)
{
Movie movie = await dbContext.Movies.FindAsync(movieId);
dbContext.Movies.Remove(movie);
if (movie != null)
{
dbContext.Movies.Remove(movie);
}
}
bool changed = await dbContext.SaveChangesAsync() > 0;
return changed ? ids : new List<int>();
}
public Task<bool> AddGenre(MovieMetadata metadata, Genre genre) =>
_dbConnection.ExecuteAsync(
"INSERT INTO Genre (Name, MovieMetadataId) VALUES (@Name, @MetadataId)",

View File

@@ -30,9 +30,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
LibraryPath libraryPath,
string path)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<MusicVideo> maybeExisting = await dbContext.MusicVideos
.AsNoTracking()
.Include(mv => mv.Artist)
.ThenInclude(a => a.ArtistMetadata)
.Include(mv => mv.MusicVideoMetadata)
.ThenInclude(mvm => mvm.Artwork)
.Include(mv => mv.MusicVideoMetadata)
@@ -92,18 +94,21 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE MI.LibraryPathId = @LibraryPathId AND MF.Path = @Path",
new { LibraryPathId = libraryPath.Id, Path = path }).Map(result => result.ToList());
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
foreach (int musicVideoId in ids)
{
MusicVideo musicVideo = await dbContext.MusicVideos.FindAsync(musicVideoId);
dbContext.MusicVideos.Remove(musicVideo);
if (musicVideo != null)
{
dbContext.MusicVideos.Remove(musicVideo);
}
}
await dbContext.SaveChangesAsync();
return ids;
}
public Task<bool> AddGenre(MusicVideoMetadata metadata, Genre genre) =>
_dbConnection.ExecuteAsync(
"INSERT INTO Genre (Name, MusicVideoMetadataId) VALUES (@Name, @MetadataId)",
@@ -121,7 +126,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
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
.AsNoTracking()
.Filter(mvm => ids.Contains(mvm.MusicVideoId))
@@ -161,6 +166,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(m => m.MusicVideo)
.ThenInclude(mv => mv.Artist)
.ThenInclude(a => a.ArtistMetadata)
.Include(m => m.MusicVideo)
.ThenInclude(mv => mv.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Filter(m => m.MusicVideo.ArtistId == artistId)
.OrderBy(m => m.SortTitle)
.Skip((pageNumber - 1) * pageSize)
@@ -196,6 +204,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
await dbContext.MusicVideos.AddAsync(musicVideo);
await dbContext.SaveChangesAsync();
await dbContext.Entry(musicVideo).Reference(m => m.Artist).LoadAsync();
await dbContext.Entry(musicVideo.Artist).Collection(a => a.ArtistMetadata).LoadAsync();
await dbContext.Entry(musicVideo).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(musicVideo.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return new MediaItemScanResult<MusicVideo>(musicVideo) { IsAdded = true };

View File

@@ -83,6 +83,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(mm => mm.Studios)
.Include(mi => (mi as Show).ShowMetadata)
.ThenInclude(mm => mm.Actors)
.Include(mi => (mi as MusicVideo).Artist)
.ThenInclude(mm => mm.ArtistMetadata)
.Include(mi => (mi as MusicVideo).MusicVideoMetadata)
.ThenInclude(mm => mm.Genres)
.Include(mi => (mi as MusicVideo).MusicVideoMetadata)

View File

@@ -45,7 +45,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<List<Show>> GetAllShows()
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Shows
.AsNoTracking()
.Include(s => s.ShowMetadata)
@@ -55,7 +55,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<Option<Show>> GetShow(int showId)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Shows
.AsNoTracking()
.Filter(s => s.Id == showId)
@@ -77,18 +77,19 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
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
.AsNoTracking()
.Filter(sm => ids.Contains(sm.ShowId))
.Include(sm => sm.Artwork)
.Include(sm => sm.Show)
.OrderBy(sm => sm.SortTitle)
.ToListAsync();
}
public async Task<List<SeasonMetadata>> GetSeasonsForCards(List<int> ids)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.SeasonMetadata
.AsNoTracking()
.Filter(s => ids.Contains(s.SeasonId))
@@ -105,7 +106,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
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
.AsNoTracking()
.Filter(em => ids.Contains(em.EpisodeId))
@@ -121,13 +122,16 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(em => em.Episode)
.ThenInclude(e => e.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.OrderBy(em => em.SortTitle)
.ToListAsync();
}
public async Task<List<Season>> GetAllSeasons()
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Seasons
.AsNoTracking()
.Include(s => s.SeasonMetadata)
@@ -140,7 +144,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
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
.AsNoTracking()
.Include(s => s.SeasonMetadata)
@@ -155,7 +159,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<int> GetSeasonCount(int showId)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Seasons
.AsNoTracking()
.CountAsync(s => s.ShowId == showId);
@@ -171,7 +175,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
new { ShowId = televisionShowId })
.Map(results => results.ToList());
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Seasons
.AsNoTracking()
.Where(s => showIds.Contains(s.ShowId))
@@ -187,7 +191,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<int> GetEpisodeCount(int seasonId)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Episodes
.AsNoTracking()
.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)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.EpisodeMetadata
.AsNoTracking()
.Filter(em => em.Episode.SeasonId == seasonId)
@@ -208,6 +212,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Actors)
.ThenInclude(a => a.Artwork)
.Include(em => em.Episode)
.ThenInclude(e => e.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.OrderBy(em => em.EpisodeNumber)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
@@ -216,7 +223,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
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
.Where(s => s.Title == metadata.Title && s.Year == metadata.Year)
.Where(s => s.Show.LibraryPathId == libraryPathId)
@@ -258,7 +265,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
string showFolder,
ShowMetadata metadata)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
try
{
@@ -291,7 +298,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
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
.Include(s => s.SeasonMetadata)
.ThenInclude(sm => sm.Artwork)
@@ -315,7 +322,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
LibraryPath libraryPath,
string path)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<Episode> maybeExisting = await dbContext.Episodes
.Include(i => i.EpisodeMetadata)
.ThenInclude(em => em.Artwork)
@@ -387,11 +394,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE MI.LibraryPathId = @LibraryPathId AND MF.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)
{
Episode episode = await dbContext.Episodes.FindAsync(episodeId);
dbContext.Episodes.Remove(episode);
if (episode != null)
{
dbContext.Episodes.Remove(episode);
}
}
await dbContext.SaveChangesAsync();
@@ -401,7 +411,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
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
.Filter(s => s.LibraryPathId == libraryPath.Id)
.Filter(s => s.Episodes.Count == 0)
@@ -413,7 +423,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
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
.Filter(s => s.LibraryPathId == libraryPath.Id)
.Filter(s => s.Seasons.Count == 0)
@@ -428,7 +438,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
PlexLibrary library,
PlexShow item)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<PlexShow> maybeExisting = await dbContext.PlexShows
.AsNoTracking()
.Include(i => i.ShowMetadata)
@@ -459,7 +469,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
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
.AsNoTracking()
.Include(i => i.SeasonMetadata)
@@ -480,7 +490,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
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
.AsNoTracking()
.Include(i => i.EpisodeMetadata)
@@ -577,12 +587,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE Show.Id = @ShowId",
new { ShowId = showId });
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Episodes
.AsNoTracking()
.Include(e => e.EpisodeMetadata)
.Include(e => e.MediaVersions)
.ThenInclude(mv => mv.Chapters)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(e => e.Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
@@ -592,12 +604,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
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
.AsNoTracking()
.Include(e => e.EpisodeMetadata)
.Include(e => e.MediaVersions)
.ThenInclude(mv => mv.Chapters)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(e => e.Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)

View File

@@ -4,7 +4,6 @@
<TargetFramework>net6.0</TargetFramework>
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
<NoWarn>VSTHRD200</NoWarn>
<DebugType>embedded</DebugType>
</PropertyGroup>
<ItemGroup>
@@ -13,12 +12,12 @@
<PackageReference Include="Lucene.Net" Version="4.8.0-beta00015" />
<PackageReference Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00015" />
<PackageReference Include="Lucene.Net.QueryParser" Version="4.8.0-beta00015" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="6.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.0">
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="6.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</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">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View File

@@ -2,8 +2,8 @@
using System.Diagnostics;
using System.Threading.Tasks;
using ErsatzTV.Core.Health;
using LanguageExt;
using Lucene.Net.Util;
using static LanguageExt.Prelude;
namespace ErsatzTV.Infrastructure.Health.Checks
{
@@ -12,22 +12,25 @@ namespace ErsatzTV.Infrastructure.Health.Checks
protected abstract string Title { get; }
protected HealthCheckResult Result(HealthCheckStatus status, string message) =>
new(Title, status, message);
new(Title, status, message, None);
protected HealthCheckResult NotApplicableResult() =>
new(Title, HealthCheckStatus.NotApplicable, string.Empty);
new(Title, HealthCheckStatus.NotApplicable, string.Empty, None);
protected HealthCheckResult OkResult() =>
new(Title, HealthCheckStatus.Pass, string.Empty);
new(Title, HealthCheckStatus.Pass, string.Empty, None);
protected HealthCheckResult FailResult(string message) =>
new(Title, HealthCheckStatus.Fail, message);
new(Title, HealthCheckStatus.Fail, message, None);
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) =>
new(Title, HealthCheckStatus.Info, message);
new(Title, HealthCheckStatus.Info, message, None);
protected static async Task<string> GetProcessOutput(string path, IEnumerable<string> arguments)
{

View File

@@ -11,7 +11,7 @@ namespace ErsatzTV.Infrastructure.Health.Checks
{
public class FFmpegVersionHealthCheck : BaseHealthCheck, IFFmpegVersionHealthCheck
{
private const string BundledVersion = "N-104321-ga742ba60f1";
private const string BundledVersion = "N-105153-g8abc192236";
private readonly IConfigElementRepository _configElementRepository;
public FFmpegVersionHealthCheck(IConfigElementRepository configElementRepository)
@@ -66,7 +66,7 @@ namespace ErsatzTV.Infrastructure.Health.Checks
}
}
return new HealthCheckResult("FFmpeg Version", HealthCheckStatus.Pass, string.Empty);
return new HealthCheckResult("FFmpeg Version", HealthCheckStatus.Pass, string.Empty, None);
}
private Option<HealthCheckResult> ValidateVersion(string version, string app)

View File

@@ -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();
}
}

View File

@@ -113,11 +113,10 @@ namespace ErsatzTV.Infrastructure.Health.Checks
result.Add(HardwareAccelerationKind.Nvenc);
break;
case "qsv":
// qsv is only supported on windows
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
result.Add(HardwareAccelerationKind.Qsv);
}
result.Add(HardwareAccelerationKind.Qsv);
break;
case "videotoolbox":
result.Add(HardwareAccelerationKind.VideoToolbox);
break;
}
}

View File

@@ -3,6 +3,7 @@ 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;
@@ -21,7 +22,7 @@ namespace ErsatzTV.Infrastructure.Health.Checks
public async Task<HealthCheckResult> Check()
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
List<Episode> episodes = await dbContext.Episodes
.Filter(e => e.MediaVersions.Any(mv => mv.Duration == TimeSpan.Zero))
@@ -30,13 +31,34 @@ namespace ErsatzTV.Infrastructure.Health.Checks
.ToListAsync();
List<Movie> movies = await dbContext.Movies
.Filter(e => e.MediaVersions.Any(mv => mv.Duration == TimeSpan.Zero))
.Include(e => e.MediaVersions)
.Filter(m => m.MediaVersions.Any(mv => mv.Duration == TimeSpan.Zero))
.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)
.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(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())

View File

@@ -14,21 +14,23 @@ namespace ErsatzTV.Infrastructure.Health
// ReSharper disable SuggestBaseTypeForParameterInConstructor
public HealthCheckService(
IFFmpegVersionHealthCheck ffmpegVersionHealthCheck,
IFFmpegReportsHealthCheck fFmpegReportsHealthCheck,
IFFmpegReportsHealthCheck ffmpegReportsHealthCheck,
IHardwareAccelerationHealthCheck hardwareAccelerationHealthCheck,
IMovieMetadataHealthCheck movieMetadataHealthCheck,
IEpisodeMetadataHealthCheck episodeMetadataHealthCheck,
IZeroDurationHealthCheck zeroDurationHealthCheck,
IFileNotFoundHealthCheck fileNotFoundHealthCheck,
IVaapiDriverHealthCheck vaapiDriverHealthCheck)
{
_checks = new List<IHealthCheck>
{
ffmpegVersionHealthCheck,
fFmpegReportsHealthCheck,
ffmpegReportsHealthCheck,
hardwareAccelerationHealthCheck,
movieMetadataHealthCheck,
episodeMetadataHealthCheck,
zeroDurationHealthCheck,
fileNotFoundHealthCheck,
vaapiDriverHealthCheck
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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)
{
}
}
}

View File

@@ -15,7 +15,7 @@ namespace ErsatzTV.Infrastructure.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "6.0.0");
modelBuilder.HasAnnotation("ProductVersion", "6.0.1");
modelBuilder.Entity("ErsatzTV.Core.Domain.Actor", b =>
{
@@ -843,6 +843,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int>("LibraryPathId")
.HasColumnType("INTEGER");
b.Property<int>("State")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("LibraryPathId");

View File

@@ -55,6 +55,7 @@ namespace ErsatzTV.Infrastructure.Search
private const string AlbumField = "album";
private const string MinutesField = "minutes";
private const string ArtistField = "artist";
private const string StateField = "state";
public const string MovieType = "movie";
public const string ShowType = "show";
@@ -79,7 +80,7 @@ namespace ErsatzTV.Infrastructure.Search
_initialized = false;
}
public int Version => 18;
public int Version => 19;
public Task<bool> Initialize(ILocalFileSystem localFileSystem)
{
@@ -160,7 +161,8 @@ namespace ErsatzTV.Infrastructure.Search
using var analyzer = new StandardAnalyzer(AppLuceneVersion);
var customAnalyzers = new Dictionary<string, Analyzer>
{
{ ContentRatingField, new KeywordAnalyzer() }
{ ContentRatingField, new KeywordAnalyzer() },
{ StateField, new KeywordAnalyzer() }
};
using var analyzerWrapper = new PerFieldAnalyzerWrapper(analyzer, customAnalyzers);
QueryParser parser = !string.IsNullOrWhiteSpace(searchField)
@@ -202,12 +204,18 @@ namespace ErsatzTV.Infrastructure.Search
{
_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)
{
Option<MediaItem> maybeMediaItem = await searchRepository.GetItemToIndex(id);
if (maybeMediaItem.IsSome)
foreach (MediaItem mediaItem in await searchRepository.GetItemToIndex(id))
{
MediaItem mediaItem = maybeMediaItem.ValueUnsafe();
switch (mediaItem)
{
case Movie movie:
@@ -238,7 +246,6 @@ namespace ErsatzTV.Infrastructure.Search
}
}
_writer.Commit();
return Unit.Default;
}
@@ -306,7 +313,8 @@ namespace ErsatzTV.Infrastructure.Search
new TextField(LibraryNameField, movie.LibraryPath.Library.Name, Field.Store.NO),
new StringField(LibraryIdField, movie.LibraryPath.Library.Id.ToString(), 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);
@@ -632,7 +640,8 @@ namespace ErsatzTV.Infrastructure.Search
new TextField(LibraryNameField, musicVideo.LibraryPath.Library.Name, Field.Store.NO),
new StringField(LibraryIdField, musicVideo.LibraryPath.Library.Id.ToString(), 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);
@@ -675,6 +684,14 @@ namespace ErsatzTV.Infrastructure.Search
{
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);
}
@@ -712,7 +729,8 @@ namespace ErsatzTV.Infrastructure.Search
new TextField(LibraryNameField, episode.LibraryPath.Library.Name, Field.Store.NO),
new StringField(LibraryIdField, episode.LibraryPath.Library.Id.ToString(), 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);
@@ -800,6 +818,7 @@ namespace ErsatzTV.Infrastructure.Search
new StringField(LibraryIdField, otherVideo.LibraryPath.Library.Id.ToString(), Field.Store.NO),
new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO),
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES),
new StringField(StateField, otherVideo.State.ToString(), Field.Store.NO)
};
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(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO),
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES),
new StringField(StateField, song.State.ToString(), Field.Store.NO)
};
await AddLanguages(searchRepository, doc, song.MediaVersions);

View File

@@ -3,7 +3,7 @@
public class TraktListItemShow
{
public string Title { get; set; }
public int Year { get; set; }
public int? Year { get; set; }
public TraktListItemIds Ids { get; set; }
}
}

View File

@@ -27,7 +27,7 @@ namespace ErsatzTV.Controllers
public Task<IActionResult> GetConcatPlaylist(string channelNumber) =>
_mediator.Send(new GetConcatPlaylistByChannelNumber(Request.Scheme, Request.Host.ToString(), channelNumber))
.ToActionResult();
[HttpGet("ffmpeg/stream/{channelNumber}")]
public Task<IActionResult> GetStream(
string channelNumber,

View File

@@ -52,14 +52,24 @@ namespace ErsatzTV.Controllers
.Map<ChannelGuide, IActionResult>(Ok);
[HttpGet("iptv/channel/{channelNumber}.ts")]
public Task<IActionResult> GetTransportStreamVideo(string channelNumber) =>
_mediator.Send(new GetConcatProcessByChannelNumber(Request.Scheme, Request.Host.ToString(), channelNumber))
public async Task<IActionResult> GetTransportStreamVideo(
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(
result => result.Match<IActionResult>(
processModel =>
{
Process process = processModel.Process;
_logger.LogInformation("Starting ts stream for channel {ChannelNumber}", channelNumber);
// _logger.LogDebug(
// "ffmpeg concat arguments {FFmpegArguments}",
@@ -68,6 +78,7 @@ namespace ErsatzTV.Controllers
return new FileStreamResult(process.StandardOutput.BaseStream, "video/mp2t");
},
error => BadRequest(error.Value)));
}
[HttpGet("iptv/session/{channelNumber}/hls.m3u8")]
public async Task<IActionResult> GetLivePlaylist(string channelNumber)

View File

@@ -4,7 +4,6 @@
<TargetFramework>net6.0</TargetFramework>
<NoWarn>VSTHRD200</NoWarn>
<IncludeAllContentForSelfExtract>true</IncludeAllContentForSelfExtract>
<DebugType>embedded</DebugType>
</PropertyGroup>
<ItemGroup>
@@ -14,15 +13,15 @@
<ItemGroup>
<PackageReference Include="Blazored.LocalStorage" Version="4.1.5" />
<PackageReference Include="FluentValidation" Version="10.3.5" />
<PackageReference Include="FluentValidation.AspNetCore" Version="10.3.5" />
<PackageReference Include="FluentValidation" Version="10.3.6" />
<PackageReference Include="FluentValidation.AspNetCore" Version="10.3.6" />
<PackageReference Include="HtmlSanitizer" Version="6.0.453" />
<PackageReference Include="LanguageExt.Core" Version="4.0.3" />
<PackageReference Include="Markdig" Version="0.26.0" />
<PackageReference Include="MediatR.Courier.DependencyInjection" Version="4.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.EntityFrameworkCore.Design" Version="6.0.0">
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="6.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
@@ -30,7 +29,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</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="PPioli.FluentValidation.Blazor" Version="5.0.0" />
<PackageReference Include="Refit.HttpClientFactory" Version="6.1.15" />

View File

@@ -124,17 +124,31 @@
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
@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">
@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;"/>
@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>
}
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">
<div style="display: flex; flex-direction: column; height: 100%">
@@ -158,6 +172,14 @@
</div>
</MudCardContent>
</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>
}
</MudContainer>

View File

@@ -32,9 +32,9 @@
<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)"/>
<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.HttpLiveStreamingHybrid)">HLS Hybrid</MudSelectItem>
<MudSelectItem Value="@(StreamingMode.HttpLiveStreamingSegmenter)">HLS Segmenter</MudSelectItem>
</MudSelect>
<MudSelect Class="mt-3" Label="FFmpeg Profile" @bind-Value="_model.FFmpegProfileId" For="@(() => _model.FFmpegProfileId)"
@@ -154,7 +154,7 @@
_model.Number = (maxNumber + 1).ToString();
_model.Name = "New Channel";
_model.FFmpegProfileId = ffmpegSettings.DefaultFFmpegProfileId;
_model.StreamingMode = StreamingMode.TransportStream;
_model.StreamingMode = StreamingMode.TransportStreamHybrid;
}
}

View File

@@ -140,9 +140,9 @@
private static string GetStreamingMode(StreamingMode streamingMode) => streamingMode switch {
StreamingMode.HttpLiveStreamingDirect => "HLS Direct",
StreamingMode.HttpLiveStreamingHybrid => "HLS Hybrid",
StreamingMode.HttpLiveStreamingSegmenter => "HLS Segmenter",
_ => "MPEG-TS"
StreamingMode.TransportStreamHybrid => "MPEG-TS",
_ => "MPEG-TS (Legacy)"
};
}

View File

@@ -49,7 +49,23 @@
<div class="ml-2">@context.Title</div>
</div>
</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>
</MudTable>
</MudContainer>

View File

@@ -28,18 +28,26 @@
<div style="display: flex; flex-direction: row;" class="mb-6">
@if (!string.IsNullOrWhiteSpace(_movie.Poster))
{
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;"
src="@_movie.Poster" alt="movie poster"/>
}
else
{
<img class="mud-elevation-2 mr-6"
style="border-radius: 4px; flex-shrink: 0; max-height: 440px;"
src="@($"/artwork/posters/{_movie.Poster}")" alt="movie poster"/>
}
<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"
style="border-radius: 4px; flex-shrink: 0; max-height: 440px;"
src="@_movie.Poster" alt="movie poster"/>
}
else
{
<img class="mud-elevation-2"
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%">
<MudText Typo="Typo.h2" Class="media-item-title">@_movie.Title</MudText>
@@ -62,6 +70,18 @@
</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">
<MudCardContent>
@if (_sortedContentRatings.Any())

View File

@@ -42,6 +42,8 @@ namespace ErsatzTV.Pages
[Inject]
protected IMediator Mediator { get; set; }
protected System.Collections.Generic.HashSet<MediaCardViewModel> SelectedItems => _selectedItems;
protected bool IsSelected(MediaCardViewModel card) =>
_selectedItems.Contains(card);

View File

@@ -78,18 +78,22 @@
<div id="@($"episode-{episode.EpisodeId}")" style="display: flex; flex-direction: row; scroll-margin-top: 85px">
@if (!string.IsNullOrWhiteSpace(episode.Poster))
{
if (episode.Poster.StartsWith("http://") || episode.Poster.StartsWith("https://"))
{
<MudPaper style="display: flex; flex-direction: column">
<MudPaper style="display: flex; flex-direction: column; position: relative">
@if (episode.Poster.StartsWith("http://") || episode.Poster.StartsWith("https://"))
{
<MudCardMedia Image="@episode.Poster" Style="flex-grow: 1; height: 220px; width: 392px;"/>
</MudPaper>
}
else
{
<MudPaper style="display: flex; flex-direction: column">
}
else
{
<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">
<div style="display: flex; flex-direction: column; height: 100%">
@@ -107,6 +111,14 @@
</MudCardContent>
</div>
<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">
<MudText GutterBottom="true">Released: @episode.Aired.ToShortDateString()</MudText>
</div>

564
ErsatzTV/Pages/Trash.razor Normal file
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();
}
}
}

View File

@@ -1,5 +1,6 @@
using System;
using System.IO;
using System.Reflection;
using System.Threading.Tasks;
using ErsatzTV.Core;
using Microsoft.AspNetCore.Hosting;
@@ -12,7 +13,7 @@ namespace ErsatzTV
public class Program
{
private static IConfiguration Configuration { get; } = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.SetBasePath(Path.GetDirectoryName(Assembly.GetEntryAssembly().Location))
.AddJsonFile("appsettings.json", false, true)
.AddJsonFile(
$"appsettings.{Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production"}.json",

View File

@@ -2,9 +2,7 @@
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Runtime;
using ErsatzTV.Infrastructure.Data;
using Microsoft.Extensions.Caching.Memory;
@@ -33,13 +31,6 @@ namespace ErsatzTV.Services.RunOnce
await using TvContext dbContext = scope.ServiceProvider.GetRequiredService<TvContext>();
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) &&
System.IO.Directory.Exists("/dev/dri"))
{

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